update
Some checks failed
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled

This commit is contained in:
Team3
2026-09-28 19:44:30 +02:00
parent dfcd3cb713
commit f0a8f18de8
37 changed files with 1588 additions and 880 deletions

View File

@@ -4,7 +4,6 @@ extends Control
## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer).
var app
var n_opp: int
var stage := -1 # Kampagnenstufe oder -1
var net_play := false # Netzwerkspiel (auch als Zuschauer)
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
@@ -24,8 +23,6 @@ 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
var preview_timer := 0.0
var pad: BattleGamepad
var gp_zone: String:
get:
@@ -36,7 +33,6 @@ var gp_index: int:
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 saved_game: Game = null # fortgesetzter Kampf (siehe resume)
var undo_log_len := 0
var undo_t := 0.0
@@ -54,11 +50,12 @@ var hero_layer: Control
var tile_layer: Control
var hand_layer: Control
var shop_layer: Control
var zoom_layer: Control
var log_overlay: Control
var b_log: Button
var status_panel: Panel
var fx_layer: Control
var log_box: RichTextLabel
var preview_holder: Control
var bar_panel: Panel
var name_label: Label
var life_label: Label
var energy_label: Label
@@ -69,38 +66,35 @@ var b_end: Button
var b_fuse: Button
var b_reroll: Button
var b_giveup: Button
var field_label: Label
var field_view: FieldView
var link_view: Links # Resonanzlinien unter den Feldreihen
var info_label: Label # Vorschau: Resonanz, Feld, Feldkombo
var board_panel: Panel # eigenes Brett, färbt sich mit dem Feld
var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt
var banner_turn := -1 # Zug, für den die Zugansage schon lief
var deal_shop := false # nächster Aufbau teilt den Laden animiert aus
var overlay: Control
var result_label: Label
var reward_label: Label
var notice_label: Label
var confirm: Control
var b_save_leave: Button
var cursor_layer: Control
var wait_label: Label
var b_skip: Button
var b_undo: Button
func _init(n: int = 2, campaign_stage: int = -1) -> void:
n_opp = n
func _init(campaign_stage: int = -1) -> void:
stage = campaign_stage
static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(0, -1)
var b := BattleScreen.new(-1)
b.net_play = true
b.online_seat = seat
return b
## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle).
static func resume(data: Dictionary) -> BattleScreen:
var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1)))
s.saved_game = Game.from_snapshot(data.game)
return s
func is_online() -> bool:
return net_play
@@ -137,13 +131,11 @@ func _ready() -> void:
net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true
return # der erste Zustand kommt vom Host
elif saved_game != null:
game = saved_game
elif stage >= 0:
var r := Game.make_rng()
game = Game.new_battle_with(Campaign.opponents(stage), r, false, own_rules())
game = Game.new_battle_with(Campaign.opponent(stage), r, false, own_rules())
else:
game = Game.new_battle(n_opp, Game.make_rng(), own_rules())
game = Game.new_battle(Game.make_rng(), own_rules())
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile()
_begin()
@@ -160,7 +152,6 @@ func own_rules() -> Dictionary:
func _begin() -> void:
n_opp = game.players.size() - 1
if is_online():
perspective = game.players[maxi(online_seat, 0)]
else:
@@ -170,67 +161,94 @@ func _begin() -> void:
if is_online() and net.is_host:
net.broadcast()
game.events.clear()
b_save_leave.visible = resumable()
save_battle()
refresh()
announce_turn()
func _build_static() -> void:
Style.panel(self, Rect2(10, 205, 320, 290))
Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20))
log_box = RichTextLabel.new()
log_box.position = Vector2(18, 230)
log_box.size = Vector2(304, 258)
log_box.bbcode_enabled = true
log_box.scroll_following = true
log_box.add_theme_font_size_override("normal_font_size", 13)
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(log_box)
b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true)
Style.panel(self, Rect2(950, 205, 320, 380))
Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10)
bar_panel = Style.panel(self, BattleLayout.BAR)
name_label = Style.label(self, "", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true)
life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true)
energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true)
income_label = Style.label(self, "", 16, Style.MUTED, Rect2(760, 424, 170, 24), "right")
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24))
msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true)
b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c"))
b_skip = Style.button(self, "Gegnerzüge überspringen", Rect2(1005, 527, 210, 48), func(): skip_ai = true)
var L := BattleLayout
# oben links: Aufgeben und Verlauf, klein
b_giveup = Style.button(self, "Aufgeben", L.B_GIVEUP, func(): confirm.visible = true)
b_giveup.add_theme_font_size_override("font_size", 13)
b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log)
b_log.add_theme_font_size_override("font_size", 13)
# Mitte: Feldband, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben
Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8)
field_view = FieldView.new(L.FIELD)
add_child(field_view)
msg_label = Style.label(self, "", 15, Style.ORANGE, L.MSG, "center", true)
board_panel = Style.panel(self, L.OWN_PANEL, Style.darker(Style.PANEL, 0.8), 10)
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.08), 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
name_label = Style.label(self, "", 18, Style.TEXT, Rect2(sx + 12, sy + 8, 190, 26), "left", true)
name_label.clip_text = true
life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 12, sy + 40, 190, 34), "left", true)
energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + 12, sy + 78, 100, 34), "left", true)
income_label = Style.label(self, "", 15, Style.MUTED, Rect2(sx + 100, sy + 88, 100, 22), "left")
status_label = Style.label(self, "", 13, Style.MUTED, Rect2(sx + 12, sy + 124, 190, 22), "left")
b_fuse = Style.button(self, "Verschmelzen", L.B_FUSE, toggle_fuse, Color("3c2858"))
b_fuse.add_theme_font_size_override("font_size", 15)
b_fuse.visible = false
b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 15)
b_undo.visible = false
# unten: Laden mit Neuwürfeln links und Zug beenden rechts
b_reroll = Style.button(self, "", L.B_REROLL, do_reroll, Color("285a5a"))
b_reroll.add_theme_font_size_override("font_size", 15)
b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY)
b_end.add_theme_font_size_override("font_size", 20)
b_skip = Style.button(self, "Gegnerzüge überspringen", L.B_END, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 14)
b_skip.visible = false
b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo)
b_undo.visible = false
field_label = Style.label(self, "", 15, Style.MUTED, Rect2(340, 211, 600, 24), "center")
b_fuse = Style.button(self, "Verschmelzen", Rect2(10, 503, 150, 40), toggle_fuse, Color("3c2858"))
b_fuse.visible = false
b_reroll = Style.button(self, "", Rect2(170, 503, 150, 40), do_reroll, Color("285a5a"))
b_reroll.add_theme_font_size_override("font_size", 14)
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false
hero_layer = _layer()
link_view = Links.new()
add_child(link_view)
tile_layer = _layer()
hand_layer = _layer()
shop_layer = _layer()
preview_holder = _layer()
zoom_layer = _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()
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)
# 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"))
# Aufgeben bestätigen
confirm = Style.overlay(self)
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2)
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828"))
b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c"))
b_save_leave.add_theme_font_size_override("font_size", 14)
Style.button(confirm, "Weiterspielen", Rect2(745, 420, 180, 46), func(): confirm.visible = false)
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
func toggle_log() -> void:
log_overlay.visible = not log_overlay.visible
func _layer() -> Control:
@@ -244,54 +262,27 @@ func _overlay() -> Control:
return Style.overlay(self)
## Einzelspieler-Kämpfe (auch Kampagne) lassen sich unterbrechen und fortsetzen.
func resumable() -> bool:
return counts_for_profile()
func save_battle() -> void:
if not resumable() or game == null or game.winner != "":
return
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "game": game.snapshot(-1)}
var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify(d))
f.close()
func save_and_leave() -> void:
save_battle()
app.show_screen(MenuScreen.new())
func leave() -> void:
if is_online():
net.leave()
if resumable() and game != null and game.winner == "":
app.clear_battle_save() # aufgegeben
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:
var out := []
var n := game.players.size()
var start := game.players.find(perspective)
for i in range(1, n):
out.append(game.players[(start + i) % n])
return out
func compact() -> bool:
return BattleLayout.compact(others().size())
return [opponent()]
func slot_rect(p: Game.Player, j: int) -> Rect2:
if p == perspective:
return BattleLayout.own_slot(j)
var os := others()
return BattleLayout.other_slot(os.size(), os.find(p), j)
return BattleLayout.opp_slot(j)
func ref_key(ref: Dictionary) -> String:
@@ -306,47 +297,37 @@ func target_center(ref: Dictionary) -> Vector2:
return Vector2(-1, -1)
var p: Game.Player = game.players[ref.index]
if p == perspective:
return BattleLayout.BAR.position + BattleLayout.BAR.size / 2
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 oben in der Mitte; ein Klick zielt auf den Helden.
func build_hero_boxes() -> void:
Style.clear(hero_layer)
hero_panels = {}
hero_labels = {}
var os := others()
var c := compact()
for i in os.size():
var o: Game.Player = os[i]
var box := BattleLayout.hero_box_rect(os.size(), i)
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, 14 if c else 18, Style.TEXT,
Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (58 if c else 20), 26), "left", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED,
Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true)
labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED,
Rect2(box.position.x + 90, box.position.y + 38, box.size.x - 100, 20), "right")
hero_labels[o] = labels
if c:
var b := Style.button(hero_layer, "Lupe", Rect2(box.end.x - 44, box.position.y + 5, 38, 22), func():
zoom_player = o
refresh())
b.add_theme_font_size_override("font_size", 11)
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, 18, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "left", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 20, Style.RED, Rect2(box.position.x + 8, box.position.y + 30, 120, 24), "left", true)
labels["stats"] = Style.label(hero_layer, "", 14, Style.MUTED, Rect2(box.position.x + 80, box.position.y + 34, box.size.x - 88, 20), "right")
hero_labels[o] = labels
# --- Ablauf und Eingabe ------------------------------------------------------------------
@@ -358,7 +339,7 @@ func my_turn() -> bool:
func any_overlay() -> bool:
return confirm.visible or overlay.visible
return confirm.visible or overlay.visible or log_overlay.visible
func _input(event: InputEvent) -> void:
@@ -376,7 +357,9 @@ func _input(event: InputEvent) -> void:
KEY_ESCAPE:
if confirm.visible:
confirm.visible = false
elif selected != null or pending != null or fusing or zoom_player != null:
elif log_overlay.visible:
log_overlay.visible = false
elif selected != null or pending != null or fusing:
cancel_all()
elif not any_overlay():
confirm.visible = true
@@ -402,7 +385,6 @@ func cancel_all() -> void:
if confirm.visible:
confirm.visible = false
cancel()
zoom_player = null
refresh()
@@ -412,10 +394,6 @@ func _process(delta: float) -> void:
msg_t = maxf(0.0, msg_t - delta)
if msg_t <= 0.0 and msg_label.text != "":
msg_label.text = ""
if preview_timer > 0.0:
preview_timer -= delta
if preview_timer <= 0.0:
show_preview(null, false)
if undo_t > 0.0:
undo_t -= delta
if undo_t <= 0.0:
@@ -477,10 +455,9 @@ func _finish() -> void:
var won := game.winner == "human"
if counts_for_profile():
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, n_opp, stage, factor)
reward = app.profile.record_battle(won, stage, factor)
app.save()
app.sfx.play("win" if won else "lose")
app.clear_battle_save()
else:
var wp := game.winner_player
var mine: bool = wp != null and wp == perspective
@@ -506,10 +483,13 @@ func play(card: Cards.Card, target) -> void:
return
var before: Game = game.clone() if not is_online() else null
var log_len := game.log.size()
var from := _hand_rect_of(card)
var err := game.play_card(perspective, card, target)
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 (keine Effekte und keine Kombos)
if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before
@@ -534,9 +514,15 @@ func do_buy(slot: int) -> void:
if is_client():
net.send_action({"type": "buy", "slot": slot})
return
var from := BattleLayout.shop_rect(slot)
var offer = perspective.shop[slot] if slot < perspective.shop.size() else null
var err := game.buy(perspective, slot)
if err != "":
say(tr(err))
elif offer != null:
fx.sfx("buy")
fx.fly_card(offer, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1),
Cards.RARITIES[int(perspective.looks.get(offer.id, 0))])
## Würfelt den Laden neu (kostet Mana).
@@ -551,6 +537,8 @@ func do_reroll() -> void:
var err := game.reroll(perspective)
if err != "":
say(tr(err))
else:
deal_shop = true
after_action()
@@ -596,7 +584,6 @@ func end_turn() -> void:
return
cancel()
clear_undo()
zoom_player = null
if is_client():
net.send_action({"type": "end"})
return
@@ -629,7 +616,6 @@ func on_click(kind: String, obj) -> void:
play(card, obj)
else:
say(tr("Abgebrochen"))
zoom_player = null
after_action()
return
if selected != null:
@@ -643,7 +629,6 @@ func on_click(kind: String, obj) -> void:
var err := g.attack(cr, obj)
if err != "":
say(tr(err))
zoom_player = null
after_action()
return
match kind:
@@ -677,17 +662,121 @@ func on_tile_click(cr: Game.Creature) -> void:
on_click("enemy", cr)
func show_preview(card, entered: bool, owner_looks: Dictionary = {}) -> void:
## 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")
deal_shop = true
refresh()
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 v := CardView.new(card, Vector2(210, 294), false, Cards.RARITIES[int(owner_looks.get(card.id, 0))])
v.position = Vector2(1005, 215)
var r := BattleLayout.preview_rect(anchor)
var v := CardView.new(card, r.size, false, Cards.RARITIES[int(owner_looks.get(card.id, 0))])
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
func show_hand_preview(card: Cards.Card, entered: bool) -> void:
show_preview(card, entered, perspective.looks)
## Eigene Hand- und Ladenkarte: zeigt, was das Ausspielen jetzt brächte, und markiert den Kombo-Partner.
func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void:
var f := game.forecast(perspective, card)
var lines: Array[String] = []
var parts: Array[String] = []
if f.resonance > 0:
parts.append(tr("Resonanz +%d") % f.resonance)
if f.field > 0:
parts.append(tr("Feld +%d") % f.field)
if not parts.is_empty():
lines.append(" · ".join(parts))
if f.combo != "":
lines.append(tr("Feldkombo %s mit %s: %s") % [tr(f.combo), f.partner.card.title(), tr(Cards.FIELD_COMBO_TEXT[f.combo])])
for t in tiles.values():
t.mark = ""
t.queue_redraw()
if entered and f.partner != null and tiles.has(f.partner.uid):
tiles[f.partner.uid].mark = f.combo
tiles[f.partner.uid].queue_redraw()
show_preview(card, entered, perspective.looks, "\n".join(lines), anchor)
## Hand- und Ladenkarte tragen ihren Bonus und ihre Feldkombo als Vorschau.
func apply_forecast(v: CardView) -> void:
var f := game.forecast(perspective, v.card)
v.bonus = f.resonance + f.field
v.tag = f.combo
## Was den Angriff eines Wesens gerade hebt.
func creature_info(cr: Game.Creature) -> String:
var parts: Array[String] = []
if cr.resonance > 0:
parts.append(tr("Resonanz +%d") % cr.resonance)
if cr.field_bonus > 0:
parts.append(tr("Feld +%d") % cr.field_bonus)
var perk := cr.power() - cr.attack - cr.resonance - cr.field_bonus
if perk > 0:
parts.append(tr("Merkmal +%d") % perk)
return " · ".join(parts)
## Elementvorteil gegen ein mögliches Ziel: des gewählten Angreifers oder eines Schadenszaubers auf der Hand.
func matchup_for(cr: Game.Creature, valid: Array) -> int:
if selected != null and game.can_attack(selected, cr)[0]:
return Game.matchup_bonus(selected, cr)
if pending != null and cr in valid and pending.effects.any(func(fx): return fx.kind == "damage" and fx.target in Cards.CHOSEN_TARGETS):
return Cards.matchup(pending.elements, cr.card.elements)
return 0
## Resonanzgruppen je Reihe: Wesen mit gleichem Element werden unter der Reihe verbunden.
func resonance_groups() -> Array:
var out := []
for p in game.players:
var k := 0
for e in Cards.ELEMENTS:
var xs: Array[float] = []
for j in p.board.size():
if e in p.board[j].card.elements:
xs.append(slot_rect(p, j).get_center().x)
if xs.size() < 2:
continue
out.append({"color": Style.lighter(Cards.ELEMENT_COLORS[e], 0.25), "y": slot_rect(p, 0).end.y + 4 + k * 6, "xs": xs})
k += 1
return out
class Links extends Control:
var groups: Array = []
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
for gr in groups:
var y: float = gr.y
draw_line(Vector2(gr.xs[0], y), Vector2(gr.xs[-1], y), gr.color, 3.0)
for x in gr.xs:
draw_circle(Vector2(x, y), 4.0, gr.color)
# --- Netzwerk -------------------------------------------------------------------------------
@@ -744,7 +833,7 @@ func after_action() -> void:
centers[ref_key(ev[key])] = target_center(ev[key])
refresh()
fx.play_events(evs, centers)
save_battle()
announce_turn()
# --- Ansicht angleichen --------------------------------------------------------------------
@@ -781,8 +870,7 @@ func refresh() -> void:
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 = ("H %d ◆ %d" % [o.hand.size(), o.energy]) if compact() \
else ("Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy])
labels.stats.text = "Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy]
else:
labels.life.text = "besiegt"
labels.life.add_theme_color_override("font_color", Style.MUTED)
@@ -798,10 +886,11 @@ func refresh() -> void:
if tiles.has(cr.uid):
tile = tiles[cr.uid]
tile.creature = cr
if tile.size != rect.size:
tile.size = rect.size
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
@@ -809,13 +898,17 @@ func refresh() -> void:
tiles[cr.uid] = tile
tile_layer.add_child(tile)
var looks: Dictionary = p.looks
tile.hover.connect(func(card, entered): show_preview(card, entered, looks))
tile.hover.connect(func(card, entered): show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size)))
var uid := cr.uid
tile.clicked.connect(func(_v):
var c := game.find_creature(uid)
if c != null:
on_tile_click(c))
fx.pop_in(tile)
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)
@@ -835,6 +928,8 @@ func refresh() -> void:
w = 3
tile.border = b
tile.width = w
tile.mark = ""
tile.matchup = matchup_for(cr, valid)
tile.queue_redraw()
if p == h:
own_views.append(tile)
@@ -845,6 +940,8 @@ func refresh() -> void:
var t: CreatureView = tiles[uid]
tiles.erase(uid)
fx.fade_out(t)
link_view.groups = resonance_groups()
link_view.queue_redraw()
Style.clear(shop_layer)
if not spectating():
@@ -856,17 +953,22 @@ func refresh() -> void:
continue
var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))])
sv.position = sr.position
sv.lift = mine
apply_forecast(sv)
sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND
sv.hover.connect(show_hand_preview)
sv.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(sv.position, sv.size)))
var slot := i
sv.set_meta("slot", slot) # der Platz im Laden, auch für das Gamepad
sv.clicked.connect(func(_view): on_click("shop", slot))
shop_layer.add_child(sv)
shop_views.append(sv)
if deal_shop:
fx.deal(sv, i)
deal_shop = false
Style.clear(hand_layer)
if spectating():
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center")
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(240, BattleLayout.HAND_Y + 18, 800, 30), "center")
else:
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
@@ -874,12 +976,14 @@ func refresh() -> void:
var r := BattleLayout.hand_rect(h.hand.size(), i)
var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))])
v.position = r.position
v.lift = mine
apply_forecast(v)
if card == pending:
v.border = Style.YELLOW
elif playable:
v.border = Style.GREEN
v.dim = not playable
v.hover.connect(show_hand_preview)
v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size)))
v.clicked.connect(func(_view): on_click("hand", card))
hand_layer.add_child(v)
hand_views.append(v)
@@ -887,7 +991,11 @@ func refresh() -> void:
var bar_border = Style.BLUE if mine else null
if int(flash_until.get(g.players.find(h), 0)) > now:
bar_border = Style.RED
bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
status_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
var board_col := Style.darker(Style.PANEL, 0.8)
if g.field_element != "":
board_col = board_col.lerp(Cards.ELEMENT_COLORS[g.field_element], 0.22) # das Feld färbt das Brett
board_panel.add_theme_stylebox_override("panel", Style.box(board_col, 10))
name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d" % h.energy
@@ -902,18 +1010,10 @@ func refresh() -> void:
b_undo.visible = undo_state != null and mine
b_fuse.visible = mine and not spectating() and h.board.size() >= 2
b_reroll.visible = mine and not spectating()
b_reroll.text = tr("Neu würfeln (%d)") % Game.REROLL_COST
b_reroll.text = tr("Neu würfeln ◆ %d") % Game.REROLL_COST
b_reroll.disabled = h.energy < Game.REROLL_COST
b_fuse.text = tr("Abbrechen") if fusing else tr("Verschmelzen")
if g.field_element == "":
var parts: Array[String] = []
for e in Cards.ELEMENTS:
parts.append("%s %d" % [tr(e), int(g.field_charge.get(e, 0))])
field_label.text = tr("Feld: neutral") + " · " + " ".join(parts)
field_label.add_theme_color_override("font_color", Style.MUTED)
else:
field_label.text = "%s: %s" % [tr(Cards.FIELD_NAMES[g.field_element]), tr(Cards.FIELD_TEXT[g.field_element])]
field_label.add_theme_color_override("font_color", Cards.ELEMENT_COLORS[g.field_element])
field_view.update_from(g)
var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()):
@@ -929,10 +1029,6 @@ func refresh() -> void:
log_box.text = "\n".join(lines)
pad.draw_cursor()
Style.clear(zoom_layer)
if zoom_player != null and zoom_player.alive():
build_zoom(valid)
if g.winner != "":
var wp := g.winner_player
if counts_for_profile():
@@ -951,30 +1047,3 @@ func refresh() -> void:
overlay.visible = true
confirm.visible = false
func build_zoom(valid: Array) -> void:
var o: Game.Player = zoom_player
var g := game
var box := Rect2(190, 190, 900, 300)
var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2)
back.mouse_filter = Control.MOUSE_FILTER_STOP
Style.label(zoom_layer, "%s · %s ♥ %d Hand %d ◆ %d (+%d)" % [o.name, o.title, o.life, o.hand.size(), o.energy, o.max_energy],
18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true)
for j in o.board.size():
var cr: Game.Creature = o.board[j]
var b = null
if cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
b = Style.ORANGE
var v := CreatureView.new(cr, Vector2(110, 140), b, 3 if b != null else 2)
v.position = Vector2(box.position.x + 20 + j * 122, box.position.y + 56)
v.hover.connect(show_preview)
v.clicked.connect(func(_x): on_tile_click(cr))
zoom_layer.add_child(v)
if o.board.is_empty():
Style.label(zoom_layer, "Keine Wesen auf dem Feld", 16, Style.MUTED, Rect2(box.position.x + 20, box.position.y + 110, 600, 30))
var hero_ok: bool = (o in valid) or (selected != null and g.can_attack(selected, o)[0])
var bh := Style.button(zoom_layer, "Held als Ziel", Rect2(box.position.x + 20, box.end.y - 60, 180, 44), func(): on_click("hero", o), Color("5a3c28"))
bh.disabled = not hero_ok
Style.button(zoom_layer, "Schließen", Rect2(box.end.x - 180, box.end.y - 60, 160, 44), func():
zoom_player = null
refresh())