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
This commit is contained in:
134
scripts/ai.gd
134
scripts/ai.gd
@@ -1,7 +1,9 @@
|
||||
class_name AI
|
||||
## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des
|
||||
## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante
|
||||
## (choose_action_greedy) dient als Vergleich.
|
||||
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels
|
||||
## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um
|
||||
## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient
|
||||
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und
|
||||
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
|
||||
|
||||
|
||||
# --- Bewertung -------------------------------------------------------------------
|
||||
@@ -52,27 +54,88 @@ static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
|
||||
# --- Vorausschau ---------------------------------------------------------------
|
||||
|
||||
static func candidate_actions(game: Game, p: Game.Player) -> Array:
|
||||
const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen
|
||||
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
|
||||
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
|
||||
|
||||
|
||||
## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren).
|
||||
static func _target_rank(kind: String, amount: int, t) -> float:
|
||||
if t is Game.Player:
|
||||
return 50.0 - t.life if kind == "damage" else 0.0
|
||||
match kind:
|
||||
"damage":
|
||||
return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t))
|
||||
"freeze", "bounce":
|
||||
return t.card.cost * 10.0 + t.power()
|
||||
"buff", "shield":
|
||||
return (100.0 if t.can_attack() else 0.0) + t.power()
|
||||
return float(_value(t))
|
||||
|
||||
|
||||
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest.
|
||||
static func _attack_rank(cr: Game.Creature, t) -> float:
|
||||
if t is Game.Player:
|
||||
return 50.0 - t.life
|
||||
if _kills(cr, t):
|
||||
return (100.0 if not _kills(t, cr) else 60.0) + _value(t)
|
||||
return -float(t.health)
|
||||
|
||||
|
||||
static func _top(list: Array, rank: Callable, n: int) -> Array:
|
||||
if list.size() <= n:
|
||||
return list
|
||||
var ranked := []
|
||||
for t in list:
|
||||
ranked.append([rank.call(t), t])
|
||||
ranked.sort_custom(func(a, b): return a[0] > b[0])
|
||||
return ranked.slice(0, n).map(func(pair): return pair[1])
|
||||
|
||||
|
||||
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer.
|
||||
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
|
||||
var out := []
|
||||
var valid_cache := {}
|
||||
for card in p.hand:
|
||||
if not game.can_play(p, card)[0]:
|
||||
continue
|
||||
var tt: String = card.target_type()
|
||||
if tt == "":
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
else:
|
||||
var valid := game.valid_targets(p, tt)
|
||||
if valid.is_empty():
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
for t in valid:
|
||||
out.append({"type": "play", "card": card, "target": t})
|
||||
continue
|
||||
if not valid_cache.has(tt):
|
||||
valid_cache[tt] = game.valid_targets(p, tt)
|
||||
var valid: Array = valid_cache[tt]
|
||||
if valid.is_empty():
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
continue
|
||||
if prune:
|
||||
var fx: Cards.Effect = null
|
||||
for f in card.effects:
|
||||
if f.target == tt:
|
||||
fx = f
|
||||
break
|
||||
var kind: String = fx.kind if fx != null else ""
|
||||
var amount: int = fx.amount if fx != null else 0
|
||||
var key := "%s/%s/%d" % [tt, kind, amount]
|
||||
if not valid_cache.has(key):
|
||||
valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS)
|
||||
valid = valid_cache[key]
|
||||
for t in valid:
|
||||
out.append({"type": "play", "card": card, "target": t})
|
||||
for cr in p.board:
|
||||
if not cr.can_attack():
|
||||
continue
|
||||
var targets := []
|
||||
for e in game.enemies_of(p):
|
||||
for t in e.board + [e]:
|
||||
if game.can_attack(cr, t)[0]:
|
||||
out.append({"type": "attack", "creature": cr, "target": t})
|
||||
targets.append(t)
|
||||
if prune:
|
||||
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
|
||||
for t in targets:
|
||||
out.append({"type": "attack", "creature": cr, "target": t})
|
||||
return out
|
||||
|
||||
|
||||
@@ -94,8 +157,14 @@ static func map_target(g2: Game, game: Game, t):
|
||||
return g2.players[game.players.find(t)]
|
||||
|
||||
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
|
||||
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
|
||||
## Überträgt eine auf der Kopie gewählte Aktion zurück auf das Original.
|
||||
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
|
||||
var out := action.duplicate()
|
||||
if action.has("creature"):
|
||||
out["creature"] = game.find_creature(action.creature.uid)
|
||||
if action.get("target") != null:
|
||||
out["target"] = map_target(game, copy, action.target)
|
||||
return out
|
||||
|
||||
|
||||
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
|
||||
@@ -103,37 +172,48 @@ static func mulligan_cards(p: Game.Player) -> Array:
|
||||
return p.hand.filter(func(c): return c.cost >= 5)
|
||||
|
||||
|
||||
## Beste Aktion nach einer Vorausschau von zwei Schritten: jede Aktion wird auf einer
|
||||
## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen).
|
||||
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
|
||||
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
|
||||
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
|
||||
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||
return {"type": "end"}
|
||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
||||
var best := {"type": "end"}
|
||||
var best_score := evaluate(game, p) + 0.01
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var examined := 0
|
||||
for action in candidate_actions(game, p):
|
||||
if examined > 0 and Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
var base := evaluate(game, p) + 0.01
|
||||
var firsts := [] # {action, game, score}
|
||||
for action in candidate_actions(game, p, true):
|
||||
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
break
|
||||
examined += 1
|
||||
var g2 := game.clone_for(p)
|
||||
apply_on_clone(g2, game, action)
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var score := evaluate(g2, p2)
|
||||
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])})
|
||||
firsts.sort_custom(func(a, b): return a.score > b.score)
|
||||
var best := {"type": "end"}
|
||||
var best_score := base
|
||||
for i in mini(firsts.size(), DEEPEN):
|
||||
var f: Dictionary = firsts[i]
|
||||
var g2: Game = f.game
|
||||
var score: float = f.score
|
||||
if g2.winner == "":
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var count := 0
|
||||
for f in candidate_actions(g2, p2):
|
||||
for nxt in candidate_actions(g2, p2, true):
|
||||
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
break
|
||||
count += 1
|
||||
var g3 := g2.clone_for(p2)
|
||||
apply_on_clone(g3, g2, f)
|
||||
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001)
|
||||
apply_on_clone(g3, g2, nxt)
|
||||
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = action
|
||||
best = f.action
|
||||
# Erstschritte ohne Vertiefung zählen mit ihrer Einzelbewertung
|
||||
for i in range(DEEPEN, firsts.size()):
|
||||
if firsts[i].score > best_score:
|
||||
best_score = firsts[i].score
|
||||
best = firsts[i].action
|
||||
return best
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.
|
||||
const RESONANCE_STEPS := [0, 1, 2, 3, 3]
|
||||
|
||||
# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, 60 Partien je Archetyp, tests/strength_probe.gd).
|
||||
const ARCHETYPE_STRENGTH := {
|
||||
"Feuer": 36, "Wasser": 40, "Erde": 55, "Luft": 55,
|
||||
"Feuer+Wasser": 50, "Feuer+Erde": 65, "Feuer+Luft": 46,
|
||||
"Wasser+Erde": 51, "Wasser+Luft": 45, "Erde+Luft": 78,
|
||||
}
|
||||
const ARCHETYPE_STRENGTH := {"Erde": 61, "Erde+Luft": 78, "Feuer": 41, "Feuer+Erde": 66, "Feuer+Luft": 46, "Feuer+Wasser": 50, "Luft": 55, "Wasser": 40, "Wasser+Erde": 43, "Wasser+Luft": 45}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ var pending = null # Cards.Card, wartet auf Ziel
|
||||
var msg_t := 0.0
|
||||
var ai_delay := 0.75
|
||||
var ai_timer := 0.0
|
||||
var ai_task := -1 # WorkerThreadPool-Aufgabe der laufenden KI-Berechnung
|
||||
var ai_copy: Game # Spielkopie, auf der die KI rechnet
|
||||
var ai_box := {} # Ergebnis der Berechnung (vom Thread befüllt)
|
||||
var reward := -1
|
||||
var ended := false
|
||||
var zoom_player = null
|
||||
@@ -301,12 +304,7 @@ func _build_static() -> void:
|
||||
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center")
|
||||
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
|
||||
# Aufgeben bestätigen
|
||||
confirm = _overlay()
|
||||
Style.panel(confirm, Rect2(390, 300, 500, 180), Style.PANEL, 10, Style.MUTED, 2)
|
||||
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(390, 320, 500, 40), "center", true)
|
||||
Style.label(confirm, "Der Kampf endet ohne Münzen.", 16, Style.MUTED, Rect2(390, 365, 500, 24), "center")
|
||||
Style.button(confirm, "Ja, aufgeben", Rect2(420, 410, 200, 46), leave, Color("5a2828"))
|
||||
Style.button(confirm, "Weiterspielen", Rect2(660, 410, 200, 46), func(): confirm.visible = false)
|
||||
confirm = Style.confirm(self, "Wirklich aufgeben?", "Der Kampf endet ohne Münzen.", "Ja, aufgeben", leave, "Weiterspielen")
|
||||
# Übergabe zwischen Menschen (Hotseat)
|
||||
handover = _overlay()
|
||||
Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2)
|
||||
@@ -323,16 +321,7 @@ func _layer() -> Control:
|
||||
|
||||
|
||||
func _overlay() -> 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)
|
||||
add_child(o)
|
||||
return o
|
||||
return Style.overlay(self)
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
@@ -520,9 +509,22 @@ func _process(delta: float) -> void:
|
||||
ai_timer = 0.0
|
||||
else:
|
||||
ai_timer -= delta
|
||||
if ai_timer > 0:
|
||||
return
|
||||
var action := AI.choose_action(game, cur) if smart_ai else AI.choose_action_greedy(game, cur)
|
||||
var action: Dictionary
|
||||
if smart_ai:
|
||||
# Die Vorausschau rechnet im Hintergrund auf einer Kopie, schon während der Wartezeit,
|
||||
# damit Animationen nicht stocken.
|
||||
if ai_task < 0:
|
||||
_start_ai(cur)
|
||||
if ai_timer > 0 or not WorkerThreadPool.is_task_completed(ai_task):
|
||||
return
|
||||
WorkerThreadPool.wait_for_task_completion(ai_task)
|
||||
ai_task = -1
|
||||
action = AI.map_action(game, ai_copy, ai_box.action)
|
||||
ai_copy = null
|
||||
else:
|
||||
if ai_timer > 0:
|
||||
return
|
||||
action = AI.choose_action_greedy(game, cur)
|
||||
AI.apply_action(game, cur, action)
|
||||
ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0)
|
||||
if game.current().is_human:
|
||||
@@ -530,6 +532,20 @@ func _process(delta: float) -> void:
|
||||
after_action()
|
||||
|
||||
|
||||
func _start_ai(cur: Game.Player) -> void:
|
||||
ai_copy = game.clone_for(cur)
|
||||
var copy := ai_copy
|
||||
var idx := game.current_idx
|
||||
var box := ai_box
|
||||
ai_task = WorkerThreadPool.add_task(func(): box["action"] = AI.choose_action(copy, copy.players[idx]))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if ai_task >= 0:
|
||||
WorkerThreadPool.wait_for_task_completion(ai_task)
|
||||
ai_task = -1
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
var won := game.winner == "human"
|
||||
if counts_for_profile():
|
||||
|
||||
@@ -153,20 +153,8 @@ func _ready() -> void:
|
||||
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c"))
|
||||
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck)
|
||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), leave)
|
||||
confirm = Control.new()
|
||||
confirm.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
confirm.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
|
||||
confirm.add_child(dark)
|
||||
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.GOLD, 2)
|
||||
Style.label(confirm, "Ungespeicherte Änderungen", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
|
||||
Style.label(confirm, "Das Deck wurde geändert, aber nicht gespeichert.", 16, Style.MUTED, Rect2(340, 360, 600, 24), "center")
|
||||
Style.button(confirm, "Verwerfen", Rect2(380, 420, 240, 46), func(): app.show_screen(MenuScreen.new()), Color("5a2828"))
|
||||
Style.button(confirm, "Weiter bearbeiten", Rect2(660, 420, 240, 46), func(): confirm.visible = false)
|
||||
add_child(confirm)
|
||||
confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.",
|
||||
"Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD)
|
||||
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
|
||||
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview.visible = false
|
||||
|
||||
@@ -82,20 +82,8 @@ func _ready() -> void:
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
|
||||
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
confirm = Control.new()
|
||||
confirm.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
confirm.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
|
||||
confirm.add_child(dark)
|
||||
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.RED, 2)
|
||||
Style.label(confirm, "Profil wirklich zurücksetzen?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
|
||||
Style.label(confirm, "Sammlung, Decks, Münzen und Fortschritt gehen verloren.", 16, Style.MUTED, Rect2(340, 360, 600, 24), "center")
|
||||
Style.button(confirm, "Ja, zurücksetzen", Rect2(380, 420, 240, 46), _reset, Color("5a2828"))
|
||||
Style.button(confirm, "Abbrechen", Rect2(660, 420, 240, 46), func(): confirm.visible = false)
|
||||
add_child(confirm)
|
||||
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Decks, Münzen und Fortschritt gehen verloren.",
|
||||
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
|
||||
_labels()
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ var players: Array[AudioStreamPlayer] = []
|
||||
var volume := 0.8
|
||||
var music_player: AudioStreamPlayer
|
||||
var music_volume := 0.5
|
||||
var music_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
|
||||
var music_box := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -26,9 +28,23 @@ func _ready() -> void:
|
||||
add_child(p)
|
||||
players.append(p)
|
||||
music_player = AudioStreamPlayer.new()
|
||||
music_player.stream = _music()
|
||||
add_child(music_player)
|
||||
set_music_volume(music_volume)
|
||||
var box := music_box
|
||||
music_task = WorkerThreadPool.add_task(func(): box["wav"] = _music())
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if music_task >= 0 and WorkerThreadPool.is_task_completed(music_task):
|
||||
WorkerThreadPool.wait_for_task_completion(music_task)
|
||||
music_task = -1
|
||||
music_player.stream = music_box.wav
|
||||
set_music_volume(music_volume)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if music_task >= 0:
|
||||
WorkerThreadPool.wait_for_task_completion(music_task)
|
||||
music_task = -1
|
||||
|
||||
|
||||
## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt.
|
||||
@@ -67,7 +83,7 @@ func set_music_volume(v: float) -> void:
|
||||
music_player.stop()
|
||||
else:
|
||||
music_player.volume_db = linear_to_db(music_volume * 0.6)
|
||||
if not music_player.playing:
|
||||
if not music_player.playing and music_player.stream != null:
|
||||
music_player.play()
|
||||
|
||||
|
||||
|
||||
@@ -165,6 +165,31 @@ static func spacer(parent: Node, h: float) -> Control:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user