Files
cards/scripts/ui/battle_screen.gd
T
team3 04fe9716e8
Tests / headless (push) Waiting to run
Tests / fenster-schnell (push) Waiting to run
Tests / fenster-voll (push) Skipped
Tests / balance (push) Skipped
update
2026-10-15 17:16:01 +02:00

1220 lines
46 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name BattleScreen
extends Control
## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx.
## Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden); den Zug beendet
## die Leertaste oder der Rechtsklick ins Leere.
## Mittlere Maustaste auf eine Handkarte legt sie verdeckt. Rechtsklick auf eine Handkarte oder
## ein eigenes Wesen öffnet das Kartenmenü: verdeckt legen, aufdecken, Eigenschaften kaufen.
## Verschmelzen: eine Handkarte oder ein eigenes Wesen auf ein anderes ziehen und loslassen; das
## Ergebnis (eine neue Handkarte) erscheint über dem Ziel, darunter Bestätigen oder Abbrechen.
## Mit Gamepad öffnet Y das Menü, dort zusätzlich mit „Verschmelzen mit …“.
## Modi: Einzelspieler und Kampagne.
var app
var stage := -1 # Kampagnenstufe oder -1
var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt
var fx: BattleFx
var selected = null # Game.Creature
var hover_target = null # fremdes Wesen unter der Maus: Vorschau des Angriffs mit dem gewählten Wesen
var fuse_first = null # Karte oder Wesen, für das im Menü „Verschmelzen mit …“ gewählt wurde
var fuse_pending = null # [a, b] nach dem Loslassen über einem Ziel, wartet auf Bestätigung
var press_target = null # Karte oder Wesen unter der gedrückten linken Maustaste
var press_view: Control = null
var press_pos := Vector2.ZERO
var drag_ghost: Control = null # schwebende Kopie der gezogenen Karte
var drag_target = null # mögliches Verschmelzungsziel unter dem Zeiger
var drag_target_view: Control = null
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 # gutgeschriebene Münzen (mit Kampagnenbonus)
var earned := 0 # Münzen aus Zügen, Karten und Tempo (Profile.battle_coins)
var fast_turns := 0 # eigene Züge unter Profile.FAST_TURN_SEC
var turn_start := 0 # Beginn des laufenden eigenen Zugs (ms)
var ended := false
var pad: BattleGamepad
var gp_zone: String:
get:
return pad.zone
var gp_index: int:
get:
return pad.index
var skip_ai := false # Gegnerzüge im Schnelldurchlauf
var smart_ai := true # Vorausschau-KI (Leicht: einfache KI)
var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion
var undo_log_len := 0
var undo_t := 0.0
# Ansichten (auch für Tests)
var tiles := {}
var hand_views: Array = []
var own_views: Array = []
var enemy_views: Array = []
var hero_panels := {} # Player -> Panel
var hero_labels := {}
var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
var hero_layer: Control
var tile_layer: Control
var hand_layer: Control
var deck: Deck
var opp_deck: Deck # Stapel des Gegners, nur zum Ansehen
var log_overlay: Control
var options: Control # Optionsmenü (Esc): Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden
var status_panel: Panel
var fx_layer: Control
var log_box: RichTextLabel
var preview_holder: Control
var name_label: Label
var life_label: Label
var energy_label: Label
var msg_label: Label
var element_view: ElementView # eigene Elementleiste
var opp_element_view: ElementView # die des Gegners darüber
var info_label: Label # Vorschau: Merkmale, Eigenschaften
var menu_layer: Control # Kartenmenü (Rechtsklick)
var menu_target = null # Karte oder Wesen, für das das Menü offen ist
var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt
var hand_arrivals := {} # Karte -> Rect2: neue Handkarte, die aus der Verschmelzung in die Hand gleitet
var banner_turn := -1 # Zug, für den die Zugansage schon lief
var overlay: Control
var result_label: Label
var reward_label: Label
var detail_label: Label # Aufschlüsselung der Münzen
var notice_label: Label
var confirm: Control
var cursor_layer: Control
var b_skip: Button
var b_undo: Button
func _init(campaign_stage: int = -1) -> void:
stage = campaign_stage
func _ready() -> void:
var s: Dictionary = app.profile.settings
fx = BattleFx.new(self)
fx.enabled = s.animations
pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed)
_build_static()
var r := Game.make_rng()
var opp: Game.Player = Campaign.opponent(stage) if stage >= 0 else app.profile.single_opponent(r)
game = Game.new_battle_with(opp, r, false, own_rules())
smart_ai = bool(app.profile.difficulty().smart)
_begin()
## Eigener Stapel und Kartenstufen und das eigene Merkmal (nach Kapitel 1).
func own_rules() -> Dictionary:
var p: Profile = app.profile
var rules := {"looks": p.looks(), "deck": p.deck_cards(), "bonus_attack": p.skill("stark"), "bonus_health": p.skill("zaeh"), "cheap_from": Skills.cheap_from(p.skill("sparsam"))}
var perks := p.battle_perks()
if not perks.is_empty():
rules["human_perks"] = perks
return rules
func _begin() -> void:
game.events.clear()
perspective = game.human
build_hero_boxes()
refresh()
announce_turn()
func _build_static() -> void:
var L := BattleLayout
b_skip = Style.button(self, "Überspringen", L.B_SKIP, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 13)
b_skip.visible = false
# Elementzähler beider Seiten links neben ihrem Brett
opp_element_view = ElementView.new(L.OPP_ELEMENTS)
add_child(opp_element_view)
element_view = ElementView.new(L.OWN_ELEMENTS)
add_child(element_view)
msg_label = Style.label(self, "", 14, Style.ORANGE, L.MSG, "center", true)
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var slots := Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
slots.draw.connect(func():
for j in Game.MAX_BOARD:
Style.border(slots, L.own_slot(j), Color(1, 1, 1, 0.05), 2, 8)
Style.border(slots, L.opp_slot(j), Color(1, 1, 1, 0.05), 2, 8))
add_child(slots)
status_panel = Style.panel(self, L.STATUS)
var sx: float = L.STATUS.position.x
var sy: float = L.STATUS.position.y
var sw: float = L.STATUS.size.x
name_label = Style.label(self, "", 16, Style.TEXT, Rect2(sx + 8, sy + 4, sw - 16, 22), "center", true)
name_label.clip_text = true
life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 10, sy + 30, sw / 2 - 10, 34), "left", true)
energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + sw / 2, sy + 30, sw / 2 - 10, 34), "right", true)
b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 13)
b_undo.visible = false
deck = Deck.new(L.DECK)
deck.clicked.connect(func(): on_click("deck", null))
add_child(deck)
opp_deck = Deck.new(L.OPP_DECK)
opp_deck.interactive = false
opp_deck.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(opp_deck)
hero_layer = _layer()
tile_layer = _layer()
hand_layer = _layer()
preview_holder = _layer()
info_label = Style.label(self, "", 13, Style.TEXT, Rect2(0, 0, 220, 60), "center")
info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info_label.visible = false
fx_layer = _layer()
# Kartenmenü: durchsichtiger Fänger, der das Menü beim Klick daneben schließt
menu_layer = Control.new()
menu_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
menu_layer.mouse_filter = Control.MOUSE_FILTER_STOP
menu_layer.visible = false
menu_layer.gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
close_menu())
add_child(menu_layer)
cursor_layer = _layer()
# Verlauf als Überlagerung
log_overlay = Style.overlay(self)
log_overlay.get_child(0).gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed:
log_overlay.visible = false)
Style.panel(log_overlay, L.LOG_PANEL, Style.PANEL, 10, Style.MUTED, 2)
Style.label(log_overlay, "Verlauf", 20, Style.GOLD, Rect2(L.LOG_PANEL.position + Vector2(20, 12), Vector2(300, 30)), "left", true)
log_box = RichTextLabel.new()
log_box.position = L.LOG_PANEL.position + Vector2(20, 50)
log_box.size = L.LOG_PANEL.size - Vector2(40, 110)
log_box.bbcode_enabled = true
log_box.scroll_following = true
log_box.add_theme_font_size_override("normal_font_size", 14)
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
log_overlay.add_child(log_box)
Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false)
# Optionsmenü (Esc)
options = Style.overlay(self)
options.get_child(0).gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed:
options.visible = false)
Style.panel(options, L.OPTIONS, Style.PANEL, 10, Style.GOLD, 2)
Style.label(options, "Optionen", 24, Style.GOLD, Rect2(L.OPTIONS.position.x, L.OPTIONS.position.y + 18, L.OPTIONS.size.x, 34), "center", true)
var oy := L.OPTIONS.position.y + 76
for entry in [["Verlauf", func():
options.visible = false
log_overlay.visible = true],
["Aufgeben", func():
options.visible = false
confirm.visible = true],
["Einstellungen", func():
options.visible = false
app.show_screen(SettingsScreen.new(), true)],
["Hauptmenü", leave],
["Beenden", func(): get_tree().quit()]]:
var ob := Style.button(options, entry[0], Rect2(L.OPTIONS.position.x + 60, oy, L.OPTIONS.size.x - 120, 48), entry[1])
ob.add_theme_font_size_override("font_size", 18)
oy += 60
# Spielende
overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
reward_label = Style.label(overlay, "", 26, Style.GOLD, Rect2(0, 335, 1280, 40), "center")
detail_label = Style.label(overlay, "", 17, Style.MUTED, Rect2(0, 372, 1280, 28), "center")
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 405, 1280, 70), "center")
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
func toggle_options() -> void:
options.visible = not options.visible
func _layer() -> Control:
var c := Control.new()
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(c)
return c
func _overlay() -> Control:
return Style.overlay(self)
func leave() -> void:
app.show_screen(MenuScreen.new())
# --- Layout-Helfer ------------------------------------------------------------------
## Der Gegner aus Sicht der eigenen Perspektive (Zuschauer: der zweite Spieler).
func opponent() -> Game.Player:
return game.enemy_of(perspective)
func others() -> Array:
return [opponent()]
func slot_rect(p: Game.Player, j: int) -> Rect2:
if p == perspective:
return BattleLayout.own_slot(j)
return BattleLayout.opp_slot(j)
func ref_key(ref: Dictionary) -> String:
return "c%d" % ref.uid if ref.kind == "creature" else "p%d" % ref.index
func target_center(ref: Dictionary) -> Vector2:
if ref.kind == "creature":
if tiles.has(ref.uid):
var t: CreatureView = tiles[ref.uid]
return t.slot + t.size / 2
return Vector2(-1, -1)
var p: Game.Player = game.players[ref.index]
if p == perspective:
return BattleLayout.STATUS.get_center()
if hero_panels.has(p):
var panel: Panel = hero_panels[p]
return panel.position + panel.size / 2
return Vector2(-1, -1)
## Kasten des Gegners links oben, gebaut wie der eigene; ein Klick zielt auf den Helden.
func build_hero_boxes() -> void:
Style.clear(hero_layer)
hero_panels = {}
hero_labels = {}
var o := opponent()
var box := BattleLayout.OPP_BOX
var panel := Style.panel(hero_layer, box)
panel.gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
on_click("hero", o))
if not o.perks.is_empty():
panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k]))
hero_panels[o] = panel
var labels := {}
var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name
if not o.perks.is_empty():
title += " ★"
labels["name"] = Style.label(hero_layer, title, 16, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "center", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 26, Style.RED, Rect2(box.position.x + 10, box.position.y + 30, box.size.x / 2 - 10, 34), "left", true)
labels["stats"] = Style.label(hero_layer, "", 26, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 30, box.size.x / 2 - 10, 34), "right", true)
hero_labels[o] = labels
# --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool:
return game != null and game.winner == "" and game.current() == perspective
func any_overlay() -> bool:
return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible or options.visible
func _input(event: InputEvent) -> void:
if game == null:
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
var under := get_viewport().gui_get_hovered_control()
if menu_layer.visible:
close_menu()
elif under is CardView and under in hand_views and my_turn():
open_menu(under.card, Rect2(under.position, under.size))
elif under is CreatureView and under in own_views and my_turn():
open_menu(under.creature, Rect2(under.slot, under.size))
elif selected != null or fuse_first != null or any_overlay() or not my_turn():
cancel_all()
else:
show_menu(tr("Zug %d") % game.turn, [[tr("Zug beenden"), end_turn, true]], Rect2(event.position, Vector2.ZERO))
get_viewport().set_input_as_handled() # sonst schließt derselbe Klick das Menü gleich wieder
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_MIDDLE:
var under := get_viewport().gui_get_hovered_control()
if under is CardView and under in hand_views and my_turn() and not any_overlay():
cancel()
play(under.card, true)
after_action()
get_viewport().set_input_as_handled()
return
if event is InputEventMouseMotion:
_drag_motion(event.position)
return
if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_release()
return
if event is InputEventJoypadButton and event.pressed:
pad.button(event.button_index)
return
if not (event is InputEventKey and event.pressed and not event.echo):
return
match event.keycode:
KEY_ESCAPE:
if confirm.visible:
confirm.visible = false
elif menu_layer.visible:
close_menu()
elif log_overlay.visible:
log_overlay.visible = false
elif options.visible:
options.visible = false
elif selected != null or fuse_first != null:
cancel_all()
elif not any_overlay():
options.visible = true
KEY_SPACE:
if my_turn() and not any_overlay():
end_turn()
elif b_skip.visible:
skip_ai = true
KEY_U:
if b_undo.visible:
undo()
KEY_ENTER, KEY_KP_ENTER:
if overlay.visible:
leave()
_:
if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay():
var idx: int = event.keycode - KEY_1
if idx < perspective.hand.size():
on_click("hand", perspective.hand[idx])
elif event.keycode == KEY_Z and my_turn() and not any_overlay():
on_click("deck", null)
func cancel_all() -> void:
if confirm.visible:
confirm.visible = false
close_menu()
cancel()
refresh()
func _process(delta: float) -> void:
if game == null:
return
msg_t = maxf(0.0, msg_t - delta)
if msg_t <= 0.0 and msg_label.text != "":
msg_label.text = ""
if undo_t > 0.0:
undo_t -= delta
if undo_t <= 0.0:
clear_undo()
if game.winner != "":
if not ended:
ended = true
_finish()
return
var cur := game.current()
if cur.is_human:
return
if skip_ai:
ai_timer = 0.0
else:
ai_timer -= delta
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:
skip_ai = false
after_action()
func _start_ai(cur: Game.Player) -> void:
ai_copy = game.masked_clone(cur) # verdeckte Wesen des Menschen kennt die KI nicht
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 game.current() == game.human:
_close_turn() # der Zug, der das Spiel entschied
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
earned = Profile.battle_coins(won, game.human.turns, game.human.cards_played, fast_turns, factor)
reward = app.profile.record_battle(won, earned, stage)
app.save()
app.sfx.play("win" if won else "lose")
refresh()
func say(m: String) -> void:
msg_t = 2.5
msg_label.text = m
func cancel() -> void:
selected = null
fuse_first = null
press_target = null
_end_drag()
# --- Ziehen mit der Maus: Klick beim Loslassen; losgelassen über einem Ziel fragt nach dem Verschmelzen ---
func _press(obj, view: Control, pos: Vector2) -> void:
_end_drag()
press_target = obj
press_view = view
press_pos = pos
func _drag_motion(pos: Vector2) -> void:
if press_target == null:
return
if drag_ghost == null:
if pos.distance_to(press_pos) < 8.0 or not my_turn() or any_overlay():
return
_start_drag(pos)
drag_ghost.position = pos - drag_ghost.size / 2
var t = _fusion_target_at(pos)
if t != drag_target:
_mark_target(false)
drag_target = t
drag_target_view = _view_of(t)
_mark_target(true)
## Loslassen ohne Ziehen ist der Klick; über einem Ziel öffnet es die Verschmelzungsfrage.
func _release() -> void:
var obj = press_target
var target = drag_target
var target_rect := _rect_of(drag_target_view) if drag_target_view != null else Rect2()
var dragged := drag_ghost != null
press_target = null
_end_drag()
if obj == null:
return
if dragged:
if target != null:
open_fuse_confirm(obj, target, target_rect)
else:
refresh()
elif obj is Cards.Card:
on_click("hand", obj)
else:
on_tile_click(obj)
func _start_drag(pos: Vector2) -> void:
show_preview(null, false)
if press_target is Cards.Card:
var size := Vector2(BattleLayout.HAND_W, BattleLayout.HAND_H) * 0.8
drag_ghost = CardView.new(press_target, size, false, press_target.rarity_in(perspective.looks))
else:
drag_ghost = CreatureView.new(press_target, BattleLayout.own_slot(0).size)
drag_ghost.mouse_filter = Control.MOUSE_FILTER_IGNORE
drag_ghost.modulate.a = 0.85
drag_ghost.position = pos - drag_ghost.size / 2
fx_layer.add_child(drag_ghost)
if press_view != null:
press_view.modulate.a = 0.4
func _end_drag() -> void:
if drag_ghost != null:
drag_ghost.queue_free()
drag_ghost = null
if is_instance_valid(press_view):
press_view.modulate.a = 1.0
_mark_target(false)
drag_target = null
drag_target_view = null
func _mark_target(on: bool) -> void:
if is_instance_valid(drag_target_view):
drag_target_view.drop = on
drag_target_view.queue_redraw()
## Platz einer Ansicht: Kacheln zählen an ihrem Platz, auch während sie dorthin gleiten.
func _rect_of(v: Control) -> Rect2:
return Rect2(v.slot, v.size) if v is CreatureView else v.get_global_rect()
## Handkarte oder eigenes Wesen unter pos, mit dem sich die gezogene Karte verschmelzen lässt.
func _fusion_target_at(pos: Vector2):
for v in hand_views + own_views:
var obj = v.card if v is CardView else v.creature
if obj != press_target and _rect_of(v).has_point(pos) and game.can_fuse(perspective, press_target, obj)[0]:
return obj
return null
func _view_of(obj) -> Control:
if obj is Cards.Card:
for v in hand_views:
if v.card == obj:
return v
elif obj is Game.Creature and tiles.has(obj.uid):
return tiles[obj.uid]
return null
## Mögliche Partner für „Verschmelzen mit …“: alle anderen Handkarten und eigenen Wesen.
func fusion_partners(target) -> Array:
var pool: Array = perspective.hand + perspective.board
return pool.filter(func(o): return o != target and game.can_fuse(perspective, target, o)[0])
func play(card: Cards.Card, face_down: bool = false) -> void:
var before: Game = game.clone()
var log_len := game.log.size()
var from := _hand_rect_of(card)
var err := game.play_card(perspective, card, face_down)
if err != "":
say(tr(err))
return
if from.size.x > 0 and not perspective.board.is_empty() and perspective.board[-1].card == card:
arrivals[perspective.board[-1].uid] = from
# Rückgängig nur, wenn nichts weiter passiert ist (kein Wesen gestorben)
if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before
undo_log_len = log_len
undo_t = 6.0
else:
clear_undo()
## Deckt ein eigenes verdecktes Wesen auf (kostenlos).
func do_reveal(cr: Game.Creature) -> void:
clear_undo()
var err := game.reveal(perspective, cr)
if err != "":
say(tr(err))
after_action()
## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. full: auch verschmelzen anbieten
## (Gamepad, das nicht ziehen kann).
func open_menu(target, anchor: Rect2, full: bool = false) -> void:
if not my_turn():
return
cancel()
Style.clear(menu_layer)
menu_target = target
var entries := [] # [Text, Callable, aktiv]
var is_card: bool = target is Cards.Card
var card: Cards.Card = target if is_card else target.card
if is_card:
var ok := game.can_play(perspective, card)
entries.append([tr("Verdeckt legen ◆ %d") % card.cost, func():
play(card, true)
after_action(), ok[0]])
entries.append([tr("Abwerfen +%d ◆") % Game.DISCARD_GAIN, func(): do_discard(card), game.can_discard(perspective, card)[0]])
elif not is_card and target.hidden:
entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]])
if full:
var partners := fusion_partners(target)
entries.append([tr("Verschmelzen mit …"), func():
fuse_first = target
say(tr("Ziel wählen"))
refresh(), not partners.is_empty()])
if entries.is_empty(): # offenes eigenes Wesen per Maus: nichts zu wählen
close_menu()
return
show_menu(card.title(), entries, anchor)
## Kleines Menü neben anchor: Überschrift und Knöpfe [Text, Callable, aktiv].
func show_menu(title: String, entries: Array, anchor: Rect2) -> void:
Style.clear(menu_layer)
var w := 230.0
var h := 18.0 + entries.size() * 44 + 34
var x := clampf(anchor.end.x + 8, 10, 1270 - w)
if x + w > anchor.position.x and x < anchor.end.x:
x = clampf(anchor.position.x - 8 - w, 10, 1270 - w)
var y := clampf(anchor.position.y, 10, 790 - h)
var box := Rect2(x, y, w, h)
Style.panel(menu_layer, box, Style.PANEL, 10, Style.GOLD, 2)
Style.label(menu_layer, title, 15, Style.GOLD, Rect2(box.position.x + 12, box.position.y + 8, w - 24, 22), "left", true)
var by := box.position.y + 36
for e in entries:
var act: Callable = e[1]
var b := Style.button(menu_layer, e[0], Rect2(box.position.x + 12, by, w - 24, 38), func():
close_menu()
act.call())
b.add_theme_font_size_override("font_size", 15)
b.disabled = not e[2]
by += 44
menu_layer.visible = true
func close_menu() -> void:
if menu_layer.visible:
menu_layer.visible = false
Style.clear(menu_layer)
menu_target = null
fuse_pending = null
## Nach dem Loslassen über einem Ziel: die neue Handkarte schwebt über dem Ziel, darunter
## Bestätigen und Abbrechen; beide Ausgangskarten bleiben sichtbar. Klick daneben oder Esc bricht ab.
func open_fuse_confirm(a, b, target: Rect2) -> void:
cancel()
Style.clear(menu_layer)
fuse_pending = [a, b]
var card := Game.fusion_result(perspective, a, b)
var size := Vector2(BattleLayout.HAND_W, BattleLayout.HAND_H)
var bw := 150.0
var x := clampf(target.get_center().x - size.x / 2, 10 + bw - size.x / 2, 1270 - bw - size.x / 2)
var y := maxf(10.0, target.position.y - size.y - 48)
var v := CardView.new(card, size, false, Cards.RARITIES[card.look])
v.position = Vector2(x, y)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
menu_layer.add_child(v)
var by := y + size.y + 6
var bx := x + size.x / 2 - bw - 3
var yes := Style.button(menu_layer, tr("Verschmelzen"), Rect2(bx, by, bw, 36), func():
var pair: Array = fuse_pending
close_menu()
do_fuse(pair[0], pair[1], Rect2(v.position, v.size))
after_action(), Color("285a3c"))
yes.add_theme_font_size_override("font_size", 15)
var no := Style.button(menu_layer, tr("Abbrechen"), Rect2(bx + bw + 6, by, bw, 36), func():
close_menu()
refresh())
no.add_theme_font_size_override("font_size", 15)
menu_layer.visible = true
## Abwerfen: eine Handkarte gegen Mana loswerden.
func do_discard(card: Cards.Card) -> void:
clear_undo()
var err := game.discard(perspective, card)
if err != "":
say(tr(err))
after_action()
## Ziehen: eine Karte vom eigenen Stapel, die erste im Zug frei.
func do_draw() -> void:
clear_undo()
var err := game.draw(perspective)
if err != "":
say(tr(err))
else:
fx.sfx("buy")
## Verschmilzt zwei eigene Karten zur neuen Handkarte (siehe Game.fuse). from: von dort gleitet
## die neue Karte in die Hand, Eltern auf der Hand blenden aus (Wesen tut das der Abgleich).
func do_fuse(a, b, from: Rect2 = Rect2()) -> void:
cancel()
clear_undo()
var before: Array = perspective.hand.duplicate()
var err := game.fuse(perspective, a, b)
if err != "":
say(tr(err))
return
if from.size.x <= 0:
return
for x in [a, b]:
var pv := _view_of(x)
if pv is CardView:
pv.reparent(fx_layer)
fx.fade_view(pv)
for c in perspective.hand:
if not (c in before):
hand_arrivals[c] = from
func clear_undo() -> void:
undo_state = null
undo_t = 0.0
b_undo.visible = false
## Nimmt das letzte Ausspielen eines Wesens zurück (nur solange nichts Verdecktes offenbart wurde).
func undo() -> void:
if undo_state == null:
return
var idx := game.players.find(perspective)
var restored := undo_state
restored.quiet = false
restored.rng = game.rng
restored.log = game.log.slice(0, undo_log_len)
restored.say("Rückgängig.")
game = restored
perspective = game.players[idx]
clear_undo()
cancel()
build_hero_boxes()
after_action()
func end_turn() -> void:
if not my_turn():
return
cancel()
clear_undo()
_close_turn()
game.end_turn()
ai_timer = ai_delay
after_action()
func on_click(kind: String, obj) -> void:
var g := game
if not my_turn() or any_overlay():
return
if fuse_first != null:
var first = fuse_first
fuse_first = null
if obj != null and obj != first and g.can_fuse(perspective, first, obj)[0]:
var tv := _view_of(obj)
do_fuse(first, obj, _rect_of(tv) if tv != null else Rect2())
else:
say(tr("Abgebrochen"))
after_action()
return
if selected != null:
var cr = selected
selected = null
if kind == "enemy" or kind == "hero":
clear_undo()
var err := g.attack(cr, obj)
if err != "":
say(tr(err))
after_action()
return
match kind:
"deck":
do_draw()
"hand":
var ok := g.can_play(perspective, obj)
if not ok[0]:
say(tr(ok[1]))
else:
play(obj)
"own":
if obj.hidden:
do_reveal(obj)
return
if obj.can_attack():
selected = obj
say(tr("Wähle ein Angriffsziel"))
elif not obj.ready:
say(tr("%s greift erst im nächsten Zug an") % obj.card.title())
else:
say(tr("%s kann nicht angreifen") % obj.card.title())
after_action()
func on_tile_click(cr: Game.Creature) -> void:
var o = cr.owner()
if o == null:
return
if o == perspective:
on_click("own", cr)
elif o.alive():
on_click("enemy", cr)
## Zählt den laufenden eigenen Zug als schnell, wenn er unter Profile.FAST_TURN_SEC blieb.
func _close_turn() -> void:
if Time.get_ticks_msec() - turn_start < Profile.FAST_TURN_SEC * 1000:
fast_turns += 1
## Aufschlüsselung der Münzen fürs Ergebnisbild.
func reward_text() -> String:
var h := game.human
var s := "%s · %s · %s = %d" % [tr("%d Züge") % h.turns, tr("%d Karten") % h.cards_played, tr("%d schnelle Züge") % fast_turns,
Profile.COINS_TURN * h.turns + Profile.COINS_CARD * h.cards_played + Profile.COINS_FAST * fast_turns]
if game.winner == "human":
s += " · " + tr("Sieg ×2")
var d: Dictionary = app.profile.difficulty()
if stage < 0 and float(d.reward) != 1.0:
s += " · %s %s" % [tr(d.name), Style.factor(float(d.reward))]
var boosted := int(round(earned * app.profile.coin_factor()))
if boosted != earned:
s += " · %s %s" % [tr("Münzsegen"), Style.factor(app.profile.coin_factor())]
if reward > boosted:
s += " · " + tr("Kampagne +%d") % (reward - boosted)
return s
## Zugansage, wenn der eigene Zug beginnt; ab hier läuft die Zeit für den schnellen Zug.
func announce_turn() -> void:
if game == null or not my_turn() or banner_turn == game.turn:
return
banner_turn = game.turn
turn_start = Time.get_ticks_msec()
fx.banner(tr("Dein Zug"), Style.GOLD)
fx.sfx("turn")
func _hand_rect_of(card: Cards.Card) -> Rect2:
for v in hand_views:
if v.card == card:
return Rect2(v.position, v.size)
return Rect2()
## Vorschau schwebt neben der gezeigten Karte (anchor), darunter die Zusatzzeile (info).
func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: String = "", anchor: Rect2 = Rect2()) -> void:
Style.clear(preview_holder)
info_label.visible = entered and info != ""
info_label.text = info if entered else ""
if entered and card != null:
var r := BattleLayout.preview_rect(anchor)
var v := CardView.new(card, r.size, false, card.rarity_in(owner_looks))
v.position = r.position
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var ir := BattleLayout.info_rect(r)
info_label.position = ir.position
info_label.size = ir.size
## Eigene Handkarte in der Vorschau.
func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void:
if drag_ghost != null:
return
show_preview(card, entered, perspective.looks, "", anchor)
## Was ein Wesen gerade hebt, was es kann und ob es schon angreift.
func creature_info(cr: Game.Creature) -> String:
var parts: Array[String] = []
var perk := cr.power() - cr.attack
if perk > 0:
parts.append(tr("Merkmal +%d") % perk)
if cr.card.is_neutral():
parts.append(tr(Cards.NEUTRAL_TEXT))
for k in cr.keywords():
if not (k in cr.card.keywords):
parts.append(tr("%s durch das Element") % tr(k))
if cr.hidden:
parts.append(tr("verdeckt"))
elif not cr.ready and not cr.has("Flink") and cr.owner() == perspective:
parts.append(tr("greift erst im nächsten Zug an"))
return " · ".join(parts)
## Konter eines möglichen Ziels gegen den gewählten Angreifer (Verschiebung durch sein Element);
## gegen Verdeckte unbekannt.
func matchup_for(cr: Game.Creature) -> int:
if selected != null and not cr.hidden and game.can_attack(selected, cr)[0]:
return Game.matchup_bonus(cr, selected)
return 0
## Vorschau für den Angriff auf das Wesen unter der Maus: der Angreifer zeigt seinen Angriff mit
## Elementvorteil, und wer dabei stirbt, bekommt die Marke. Verdeckte Ziele bleiben unbekannt.
func update_attack_preview() -> void:
var target = hover_target
if selected == null or target == null or target.hidden or not game.can_attack(selected, target)[0]:
target = null
for v in enemy_views:
v.doomed = target != null and v.creature == target and selected.power() + Game.matchup_bonus(selected, target) >= target.health
v.queue_redraw()
for v in own_views:
var mine: bool = v.creature == selected
v.attack_shift = Game.matchup_bonus(selected, target) if mine and target != null else 0
var counter: int = maxi(0, target.power() + Game.matchup_bonus(target, selected)) if target != null else 0
v.doomed = mine and target != null and counter >= selected.health
v.queue_redraw()
## Der eigene Stapel: Kartenstoß mit Kosten des nächsten Ziehens und Restzahl.
class Deck extends Control:
signal clicked
var enabled := true
var interactive := true # false: der Stapel des Gegners, nur Anzahl
var count := 0
var cost := 0
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_STOP
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit()
func _draw() -> void:
for k in range(2, -1, -1):
var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12))
r.position.y += 6
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled and interactive else Style.MUTED, 2), r)
if k == 0:
Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6)
if interactive:
Style.text(self, tr("Ziehen"), 16, Style.TEXT if enabled else Style.MUTED, r.get_center() + Vector2(0, -12), "center", true)
Style.text(self, "◆ %d" % cost, 20, Style.BLUE if enabled else Style.MUTED, r.get_center() + Vector2(0, 14), "center", true)
Style.text(self, str(count), 12, Style.MUTED, Vector2(r.get_center().x, r.end.y - 18), "center")
else:
Style.text(self, str(count), 22, Style.TEXT, r.get_center(), "center", true)
if interactive and not enabled:
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
## Eine verdeckte Handkarte des Gegners in voller Größe.
class CardBack extends Control:
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
var r := Rect2(Vector2.ZERO, size)
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 2), r)
Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6)
Style.text(self, "?", 40, Style.GOLD, size / 2, "center", true)
# --- Ereignisse ----------------------------------------------------------------------------
## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen.
func after_action() -> void:
var evs := game.events.duplicate()
game.events.clear()
var centers := {}
for ev in evs:
for key in ["target", "attacker", "source"]:
if ev.has(key):
centers[ref_key(ev[key])] = target_center(ev[key])
refresh()
fx.play_events(evs, centers)
announce_turn()
# --- Ansicht angleichen --------------------------------------------------------------------
func refresh() -> void:
if game == null:
return
var g := game
var h := perspective
var now := Time.get_ticks_msec()
var mine := my_turn()
hand_views = []
own_views = []
enemy_views = []
for o in others():
var panel: Panel = hero_panels[o]
var bcol = null
var bw := 0
if g.current() == o:
bcol = Style.BLUE
bw = 3
if selected != null and o.alive() and g.can_attack(selected, o)[0]:
bcol = Style.ORANGE
bw = 3
if int(flash_until.get(g.players.find(o), 0)) > now:
bcol = Style.RED
bw = 4
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if o.alive() else Style.darker(Style.PANEL, 0.6), 8, bcol, bw))
panel.mouse_filter = Control.MOUSE_FILTER_STOP if o.alive() else Control.MOUSE_FILTER_IGNORE
var labels: Dictionary = hero_labels[o]
labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
if o.alive():
labels.life.text = "♥ %d" % maxi(o.life, 0)
labels.stats.text = "◆ %d" % o.energy
else:
labels.life.text = "besiegt"
labels.life.add_theme_color_override("font_color", Style.MUTED)
labels.stats.text = ""
var seen := {}
for p in g.players:
for j in p.board.size():
var cr: Game.Creature = p.board[j]
var rect := slot_rect(p, j)
seen[cr.uid] = true
var tile: CreatureView
if tiles.has(cr.uid):
tile = tiles[cr.uid]
tile.creature = cr
if tile.size != rect.size: # erst die Mindestgröße, sonst bleibt die Kachel groß
tile.custom_minimum_size = rect.size
tile.size = rect.size
tile.pivot_offset = rect.size / 2
tile.set_process(Style.rarity_animated(tile.rarity()))
else:
tile = CreatureView.new(cr, rect.size)
tile.position = rect.position
tile.slot = rect.position
tiles[cr.uid] = tile
tile_layer.add_child(tile)
var looks: Dictionary = p.looks
tile.hover.connect(func(card, entered):
hover_target = tile.creature if entered else null
update_attack_preview()
if tile.secret:
show_preview(null, false)
else:
show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size)))
var uid := cr.uid
tile.pressed.connect(func(_v, pos):
var c := game.find_creature(uid)
if c != null:
_press(c, tile, pos))
tile.double_clicked.connect(func(_v):
var c := game.find_creature(uid)
if c != null and c.owner() == perspective and my_turn() and not any_overlay():
selected = c # Doppelklick: gleich auf das Leben des Gegners
on_click("hero", opponent()))
if arrivals.has(cr.uid):
fx.fly_in(tile, arrivals[cr.uid])
arrivals.erase(cr.uid)
else:
fx.pop_in(tile)
tile.slot = rect.position
if not tile.busy and tile.position != rect.position:
fx.move_to(tile, rect.position)
var b = null
var w := 2
if cr == selected or cr == fuse_first:
b = Style.YELLOW
w = 4
elif fuse_first != null and p == h and mine and g.can_fuse(h, fuse_first, cr)[0]:
b = Style.PURPLE
w = 3
elif selected != null and g.can_attack(selected, cr)[0]:
b = Style.ORANGE
w = 3
elif p == h and mine and cr.can_attack():
b = Style.GREEN
w = 3
tile.border = b
tile.width = w
tile.secret = cr.hidden and p != h
tile.matchup = matchup_for(cr)
tile.queue_redraw()
if p == h:
own_views.append(tile)
else:
enemy_views.append(tile)
for uid in tiles.keys():
if not seen.has(uid):
var t: CreatureView = tiles[uid]
tiles.erase(uid)
fx.fade_out(t)
update_attack_preview()
deck.enabled = mine and g.can_draw(h)[0]
deck.count = h.deck.size()
deck.cost = g.draw_cost(h)
deck.queue_redraw()
opp_deck.count = opponent().deck.size()
opp_deck.queue_redraw()
Style.clear(hand_layer)
for o in others(): # die Hand des Gegners oben, nur Rückseiten, spiegelverkehrter Bogen
for i in o.hand.size():
var back := CardBack.new(BattleLayout.opp_hand_rect(o.hand.size(), i))
var arc: Array = BattleLayout.hand_arc(o.hand.size(), i, true)
back.pivot_offset = back.size / 2
back.rotation_degrees = arc[0]
back.position.y += arc[1]
hand_layer.add_child(back)
if true:
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0]
var r := BattleLayout.hand_rect(h.hand.size(), i)
var v := CardView.new(card, r.size, false, card.rarity_in(h.looks))
v.position = r.position
var arc: Array = BattleLayout.hand_arc(h.hand.size(), i)
v.rotation_degrees = arc[0]
v.position.y += arc[1]
if hand_arrivals.has(card):
fx.glide(v, hand_arrivals[card])
hand_arrivals.erase(card)
v.lift = mine
if fuse_first != null and mine and g.can_fuse(h, fuse_first, card)[0]:
v.border = Style.PURPLE
elif card == fuse_first:
v.border = Style.YELLOW
elif playable:
v.border = Style.GREEN
v.dim = not playable and card != fuse_first
v.drag_enabled = true
v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size)))
v.pressed.connect(func(_view, pos): _press(card, v, pos))
hand_layer.add_child(v)
hand_views.append(v)
var bar_border = Style.BLUE if mine else null
if int(flash_until.get(g.players.find(h), 0)) > now:
bar_border = Style.RED
status_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d" % h.energy
var ai_running: bool = g.winner == "" and not g.current().is_human
b_skip.visible = ai_running
b_undo.visible = undo_state != null and mine
element_view.update_from(g, h)
opp_element_view.update_from(g, opponent())
var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()):
var line := g.log[i]
if line.begins_with("---"):
lines.append("[color=#6eaaf0]%s[/color]" % line)
elif " verschmelzen " in line or " wird aufgedeckt." in line:
lines.append("[color=#8c78dc]%s[/color]" % line)
elif " bekommt " in line or " regeneriert sich." in line:
lines.append("[color=#5ad282]%s[/color]" % line)
else:
lines.append(line)
log_box.text = "\n".join(lines)
pad.draw_cursor()
if g.winner != "":
var won := g.winner == "human"
result_label.text = "Sieg!" if won else "Niederlage"
result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED)
reward_label.text = "+%d Münzen" % maxi(reward, 0)
detail_label.text = reward_text()
var notes: Array[String] = app.profile.take_notices()
if not notes.is_empty():
notice_label.text = "\n".join(notes)
overlay.visible = true
confirm.visible = false