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 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: Elementbonus, Merkmale 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 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() if stage >= 0: var r := Game.make_rng() game = Game.new_battle_with(Campaign.opponent(stage, r), r, false, own_rules()) else: game = Game.new_battle(Game.make_rng(), own_rules()) smart_ai = bool(app.profile.difficulty().smart) _begin() ## Eigener Stapel und Kartenstufen, Gegnerleben (Schwierigkeit) 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()} if stage < 0: rules["opponent_life"] = int(p.difficulty().life) if p.perk != "" and p.perk_unlocked(): rules["human_perks"] = [p.perk] 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") 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")) 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" var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0 reward = app.profile.record_battle(won, stage, factor) 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() 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) ## Zugansage, wenn der eigene Zug beginnt. func announce_turn() -> void: if game == null or not my_turn() or banner_turn == game.turn: return banner_turn = game.turn 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: zeigt den Elementbonus, den sie offen gelegt bekäme. func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void: if drag_ghost != null: return var a := game.forecast(perspective, card) var info := "" if a > 0: info = tr("Elementbonus +%d") % a show_preview(card, entered, perspective.looks, info, 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] = [] if cr.aura > 0: parts.append(tr("Elementbonus +%d") % cr.aura) var perk := cr.power() - cr.attack - cr.aura 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) ## Elementvorteil des gewählten Angreifers gegen ein mögliches Ziel; 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(selected, cr) 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 v.bonus = g.forecast(h, card) 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) 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