Files
cards/scripts/ui/style.gd
Team3 b6d5916a8d Spiel untersucht und optimiert: KI-Suche, Hintergrund-Threads, Dialog-Helfer
- KI-Vorausschau in zwei Stufen mit Zielvorauswahl (höchstens 4 Ziele je Karte
  und Angreifer, die 8 besten Erstschritte vertieft): 5 Gegner 2063 ms -> 101 ms,
  1 Gegner 160 ms -> 36 ms, alle Kandidaten im Budget; 52:48 gegen die alte Suche
- KI rechnet im WorkerThreadPool auf einer Spielkopie, schon während der Wartezeit;
  längste Bildzeit im Kampf mit 5 Gegnern 108 ms -> 38 ms
- Musik wird im Hintergrund erzeugt (Start 185 ms schneller)
- Style.overlay und Style.confirm ersetzen vier kopierte Dialoge
- Übersetzungen für Abbrechen und Zurücksetzen, Archetypen neu gemessen
- tests/perf_probe.gd (Laufzeiten) und tests/frame_probe.tscn (Bildzeiten)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 17:53:27 +02:00

399 lines
17 KiB
GDScript

class_name Style
## Farben, Schriften, Theme und Zeichenhilfen.
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
static func fs(size: int) -> int:
return int(round(size * font_scale))
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
const BG := Color("1a1c24")
const PANEL := Color("282b36")
const PANEL_LIGHT := Color("3a3e4c")
const TEXT := Color("ececf0")
const MUTED := Color("9698a5")
const GOLD := Color("f5c850")
const GREEN := Color("5ad282")
const RED := Color("e65555")
const BLUE := Color("6eaaf0")
const YELLOW := Color("ffe65a")
const ORANGE := Color("ff963c")
static func darker(c: Color, f: float = 0.65) -> Color:
return Color(c.r * f, c.g * f, c.b * f, c.a)
static func lighter(c: Color, d: float = 0.1) -> Color:
return Color(minf(1, c.r + d), minf(1, c.g + d), minf(1, c.b + d), c.a)
static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.set_corner_radius_all(radius)
if border_col != null:
sb.border_color = border_col
sb.set_border_width_all(border_w)
return sb
static func make_theme() -> Theme:
var t := Theme.new()
t.default_font = FONT
t.default_font_size = fs(16)
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2))
t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2))
t.set_stylebox("disabled", "Button", box(darker(PANEL, 0.9), 8, PANEL, 2))
t.set_stylebox("focus", "Button", box(Color(0, 0, 0, 0), 8, GOLD, 3))
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
t.set_color(name, "Button", TEXT)
t.set_color("font_disabled_color", "Button", MUTED)
t.set_font_size("font_size", "Button", fs(18))
t.set_color("font_color", "Label", TEXT)
t.set_stylebox("panel", "Panel", box(PANEL, 8))
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
t.set_stylebox("focus", "RichTextLabel", StyleBoxEmpty.new())
t.set_stylebox("panel", "ScrollContainer", StyleBoxEmpty.new())
t.set_stylebox("normal", "LineEdit", box(PANEL_LIGHT, 6, MUTED, 1))
t.set_stylebox("focus", "LineEdit", box(PANEL_LIGHT, 6, BLUE, 2))
t.set_color("font_color", "LineEdit", TEXT)
t.set_color("font_placeholder_color", "LineEdit", MUTED)
t.set_stylebox("normal", "OptionButton", box(PANEL_LIGHT, 6, MUTED, 1))
t.set_stylebox("hover", "OptionButton", box(lighter(PANEL_LIGHT), 6, MUTED, 1))
t.set_stylebox("pressed", "OptionButton", box(darker(PANEL_LIGHT), 6, MUTED, 1))
t.set_stylebox("focus", "OptionButton", StyleBoxEmpty.new())
t.set_color("font_color", "OptionButton", TEXT)
t.set_color("font_color", "CheckButton", TEXT)
t.set_stylebox("focus", "CheckButton", StyleBoxEmpty.new())
return t
# --- Knoten-Hilfen -------------------------------------------------------------
static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8, border_col = null, border_w: int = 0) -> Panel:
var p := Panel.new()
p.position = rect.position
p.size = rect.size
p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w))
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(p)
return p
static func label(parent: Node, text: String, size: int, color: Color, rect: Rect2, align: String = "left", bold: bool = false) -> Label:
var l := Label.new()
l.text = text
l.position = rect.position
l.size = rect.size
l.add_theme_font_size_override("font_size", fs(size))
l.add_theme_color_override("font_color", color)
if bold:
l.add_theme_font_override("font", FONT_BOLD)
match align:
"center":
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
"right":
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(l)
return l
static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable, color = null) -> Button:
var b := Button.new()
b.text = text
b.position = rect.position
b.size = rect.size
b.pressed.connect(on_pressed)
if color != null:
b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2))
b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2))
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
parent.add_child(b)
return b
## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
var v := VBoxContainer.new()
v.anchor_left = 0.5
v.anchor_right = 0.5
v.anchor_top = 0
v.anchor_bottom = 0
v.offset_left = -width / 2
v.offset_right = width / 2
v.offset_top = top
v.add_theme_constant_override("separation", sep)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(v)
return v
static func row(parent: Node, sep: int = 12) -> HBoxContainer:
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", sep)
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(h)
return h
## Label in einem Container.
static func clabel(parent: Node, text: String, size: int, color: Color, align: String = "center", bold: bool = false, min_w: float = 0) -> Label:
var l := label(parent, text, size, color, Rect2(0, 0, 0, 0), align, bold)
l.size_flags_horizontal = Control.SIZE_EXPAND_FILL if min_w == 0 else Control.SIZE_FILL
if min_w > 0:
l.custom_minimum_size = Vector2(min_w, 0)
return l
## Knopf in einem Container.
static func cbutton(parent: Node, text: String, size: Vector2, on_pressed: Callable, color = null) -> Button:
var b := button(parent, text, Rect2(Vector2.ZERO, size), on_pressed, color)
b.custom_minimum_size = size
return b
static func spacer(parent: Node, h: float) -> Control:
var c := Control.new()
c.custom_minimum_size = Vector2(0, h)
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(c)
return c
## Unsichtbare Vollbild-Ebene mit abgedunkeltem Hintergrund, die Klicks schluckt.
static func overlay(parent: Node) -> Control:
var o := Control.new()
o.set_anchors_preset(Control.PRESET_FULL_RECT)
o.visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
o.add_child(dark)
parent.add_child(o)
return o
## Bestätigungsdialog (unsichtbar): Titel, Hinweis, Ja-Knopf mit Aktion und ein Abbrechen-Knopf.
static func confirm(parent: Node, title: String, text: String, yes: String, on_yes: Callable, no: String = "Abbrechen", color: Color = MUTED) -> Control:
var o := overlay(parent)
panel(o, Rect2(340, 290, 600, 200), PANEL, 10, color, 2)
label(o, title, 26, TEXT, Rect2(340, 312, 600, 40), "center", true)
label(o, text, 16, MUTED, Rect2(340, 360, 600, 24), "center")
button(o, yes, Rect2(380, 420, 240, 46), on_yes, Color("5a2828"))
button(o, no, Rect2(660, 420, 240, 46), func(): o.visible = false)
return o
static func clear(node: Node) -> void:
for c in node.get_children():
node.remove_child(c)
c.queue_free()
# --- Zeichenhilfen für _draw() ---------------------------------------------------
static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
var size := fs(size_in)
var font: Font = FONT_BOLD if bold else FONT
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x
var y := pos.y
match anchor:
"center":
x -= dim.x / 2
y -= dim.y / 2
"midtop":
x -= dim.x / 2
"topright":
x -= dim.x
"midleft":
y -= dim.y / 2
"midright":
x -= dim.x
y -= dim.y / 2
"bottomleft":
y -= dim.y
"bottomright":
x -= dim.x
y -= dim.y
"midbottom":
x -= dim.x / 2
y -= dim.y
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
var font: Font = FONT_BOLD if bold else FONT
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
s = s.substr(0, s.length() - 2) + "…"
return s
static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius: int) -> void:
var sb := StyleBoxFlat.new()
sb.draw_center = false
sb.border_color = color
sb.set_border_width_all(width)
sb.set_corner_radius_all(radius)
ci.draw_style_box(sb, rect)
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
if elements.size() == 1:
ci.draw_style_box(box(a, radius), rect)
elif elements[0] == elements[1]:
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
ci.draw_style_box(box(a, radius), rect)
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3))
else:
var b: Color = Cards.ELEMENT_COLORS[elements[1]]
var half := floorf(rect.size.x / 2)
var left := StyleBoxFlat.new()
left.bg_color = a
left.corner_radius_top_left = radius
left.corner_radius_bottom_left = radius
var right := StyleBoxFlat.new()
right.bg_color = b
right.corner_radius_top_right = radius
right.corner_radius_bottom_right = radius
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
static func rules_text() -> String:
var lines := [
"Ziel: Besiege alle anderen. Du verlierst, wenn dein Leben auf 0 fällt.",
"Du spielst gegen 1 bis 5 Magier, jeder gegen jeden, alle nach denselben Regeln:",
"%d Leben, pro Zug 1 Energie mehr (höchstens 10) und 1 Karte. Die Zugreihenfolge wird ausgelost." % Game.START_LIFE,
"Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug 1 Energie extra.",
"Fallen liegen verdeckt (höchstens 2) und lösen aus, wenn dich jemand angreift oder ein Wesen ausspielt.",
"",
"Karten kosten Energie. Wesen kommen aufs Feld (max. 5) und greifen ab dem nächsten Zug an.",
"Zauber wirken sofort. Ein Wesen greift ein gegnerisches Wesen oder direkt den Gegner an;",
"beide Seiten verursachen ihren Angriffswert als Schaden.",
"",
"Elemente: Feuer (Schaden), Wasser (Kontrolle), Erde (Zähigkeit), Luft (Tempo).",
"Kombos verbinden zwei Elemente, auch das gleiche doppelt (Feuer+Feuer = Inferno).",
"Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.",
Cards.RESONANCE_TEXT,
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an das stärkste gegnerische Wesen, Blitz 2 Schaden",
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.",
"",
"Schlüsselwörter:",
]
for k in Cards.KEYWORDS:
lines.append(" %s: %s" % [k, Cards.KEYWORD_HELP[k]])
lines.append("")
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES])
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.")
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.")
lines.append("")
lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.")
lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.")
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine")
lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.")
lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.")
lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.")
return "\n".join(lines)
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
var variant := absi(hash(card.id)) % 3
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
col.a = 0.95
if card.is_trap():
# Falle: Kreuz im Kreis
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
var d := size * 0.28
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
return
if card.kind == "Zauber":
_star(ci, center, size * 0.55, Color(col, 0.35))
var elems: Array = card.elements
if elems.size() >= 2 and elems[0] != elems[1]:
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
col2.a = 0.95
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
else:
_element_glyph(ci, elems[0], center, size, variant, col)
if elems.size() >= 2:
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
if elems.size() >= 3:
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
var pts := PackedVector2Array()
for i in 8:
var a := TAU * i / 8 - PI / 2
var rr := r if i % 2 == 0 else r * 0.45
pts.append(c + Vector2(cos(a), sin(a)) * rr)
ci.draw_colored_polygon(pts, col)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
var h := size / 2
match element:
"Feuer":
var n := 1 + variant
for i in n:
var off := Vector2((i - (n - 1) / 2.0) * size * 0.32, 0)
var s := 1.0 if i == n / 2 else 0.7
_flame(ci, c + off, h * s, col)
"Wasser":
for i in 2 + variant % 2:
var y := c.y + (i - 0.5) * size * 0.3
for k in 3:
var x := c.x - size * 0.4 + k * size * 0.4
ci.draw_arc(Vector2(x, y), size * 0.2, PI, TAU, 12, col, maxf(2, size * 0.09))
"Erde":
var n := 1 + variant
for i in n:
var off := (i - (n - 1) / 2.0) * size * 0.4
var s := 1.0 if i == n / 2 else 0.65
var pts := PackedVector2Array([c + Vector2(off - h * s, h * 0.7), c + Vector2(off, -h * s), c + Vector2(off + h * s, h * 0.7)])
ci.draw_colored_polygon(pts, col)
ci.draw_line(c + Vector2(-h, h * 0.7), c + Vector2(h, h * 0.7), col, maxf(2, size * 0.08))
"Luft":
for i in 2 + variant:
var r := h * (1.0 - i * 0.28)
ci.draw_arc(c + Vector2(i * size * 0.06, 0), r, PI * 0.2 + i * 0.4, PI * 1.5 + i * 0.4, 20, col, maxf(2, size * 0.09))
static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
var w := h * 0.75
var pts := PackedVector2Array([
c + Vector2(0, -h), c + Vector2(w * 0.55, -h * 0.25), c + Vector2(w * 0.4, h * 0.45),
c + Vector2(0, h), c + Vector2(-w * 0.4, h * 0.45), c + Vector2(-w * 0.55, -h * 0.25)])
ci.draw_colored_polygon(pts, col)
var inner := PackedVector2Array([
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75))
## Dezenter Hintergrund: Farbverlauf und schräge Linien.
class Backdrop extends Control:
func _draw() -> void:
var sz := size
draw_rect(Rect2(Vector2.ZERO, sz), Style.BG)
var c := Vector2(sz.x * 0.5, sz.y * 0.35)
for i in range(8, 0, -1):
var r := sz.x * 0.09 * i
draw_circle(c, r, Color(0.16, 0.18, 0.26, 0.05))
for k in range(-20, 40):
var x := k * 80.0
draw_line(Vector2(x, 0), Vector2(x + sz.y * 0.6, sz.y), Color(1, 1, 1, 0.012), 1.0)