diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 9011aeb..c0e592f 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,35 +1,39 @@ 78098b89c88a74ebe8c20117b8754620 ::res://::1787996516 anforderungen.md::TextFile::-1::1787986378::0::1::::<><><>0<>0<><>:: -README.md::TextFile::-1::1789516876::0::1::::<><><>0<>0<><>:: +README.md::TextFile::-1::1789554443::0::1::::<><><>0<>0<><>:: ::res://scenes/::1787944158 Main.tscn::PackedScene::-1::1787946270::0::1::::<><><>0<>0<><>::res://scripts/ui/Main.gd ::res://scripts/::1787943837 -::res://scripts/core/::1789516714 -Avatar.gd::GDScript::3571391337088680506::1789551593::0::1::::Avatar<>RefCounted<><>0<>0<><>:: -BotAI.gd::GDScript::3217244540061411409::1789551660::0::1::::BotAI<>RefCounted<><>0<>0<><>:: -Combat.gd::GDScript::3137923719046090123::1789551637::0::1::::Combat<>RefCounted<><>0<>0<><>:: -Data.gd::GDScript::5827997371587166138::1789551593::0::1::::Data<>RefCounted<><>0<>0<><>:: -Game.gd::GDScript::7849680363975727491::1789551637::0::1::::Game<>RefCounted<><>0<>0<><>:: +::res://scripts/core/::1789555037 +Avatar.gd::GDScript::3571391337088680506::1789555225::0::1::::Avatar<>RefCounted<><>0<>0<><>:: +BotAI.gd::GDScript::3217244540061411409::1789554993::0::1::::BotAI<>RefCounted<><>0<>0<><>:: +Combat.gd::GDScript::3137923719046090123::1789554111::0::1::::Combat<>RefCounted<><>0<>0<><>:: +Data.gd::GDScript::5827997371587166138::1789555225::0::1::::Data<>RefCounted<><>0<>0<><>:: +Game.gd::GDScript::7849680363975727491::1789555037::0::1::::Game<>RefCounted<><>0<>0<><>:: Hex.gd::GDScript::3913565535274194804::1787984994::0::1::::Hex<>RefCounted<><>0<>0<><>:: -PlayerState.gd::GDScript::7371028171494145347::1789551593::0::1::::PlayerState<>RefCounted<><>0<>0<><>:: +PlayerState.gd::GDScript::7371028171494145347::1789554993::0::1::::PlayerState<>RefCounted<><>0<>0<><>:: Pool.gd::GDScript::8278475787586460724::1787984994::0::1::::Pool<>RefCounted<><>0<>0<><>:: +Settings.gd::GDScript::444029887099484235::1789554111::0::1::::Settings<>RefCounted<><>0<>0<><>:: ShopEntry.gd::GDScript::5330801221324202071::1787984994::0::1::::ShopEntry<>RefCounted<><>0<>0<><>:: -::res://scripts/ui/::1789551774 -BoardView3D.gd::GDScript::6505855525292201354::1789551801::0::1::::BoardView3D<>Node3D<><>0<>0<><>:: -HudOverlay.gd::GDScript::2880024841375975216::1789551801::0::1::::HudOverlay<>Control<><>0<>0<><>:: -InfoColumn.gd::GDScript::8417939530115914045::1789551774::0::1::::InfoColumn<>Control<><>0<>0<><>:: -Main.gd::GDScript::7620286773918972721::1789551894::0::1::::<>Node3D<><>0<>0<><>:: +::res://scripts/ui/::1789555225 +BoardView3D.gd::GDScript::6505855525292201354::1789555072::0::1::::BoardView3D<>Node3D<><>0<>0<><>:: +CameraRig.gd::GDScript::2708392716068536488::1789555225::0::1::::CameraRig<>Node3D<><>0<>0<><>:: +HudOverlay.gd::GDScript::2880024841375975216::1789554490::0::1::::HudOverlay<>Control<><>0<>0<><>:: +InfoColumn.gd::GDScript::8417939530115914045::1789555072::0::1::::InfoColumn<>Control<><>0<>0<><>:: +Main.gd::GDScript::7620286773918972721::1789555225::0::1::::<>Node3D<><>0<>0<><>:: +Overlays.gd::GDScript::3653697623019777701::1789555097::0::1::::Overlays<>Control<><>0<>0<><>:: PlayersPanel.gd::GDScript::3868833939001261460::1789551726::0::1::::PlayersPanel<>Control<><>0<>0<><>:: Sfx.gd::GDScript::5367741527190708903::1789551801::0::1::::Sfx<>Node<><>0<>0<><>:: -ShopPanel.gd::GDScript::5251693507545150927::1789551774::0::1::::ShopPanel<>Control<><>0<>0<><>:: +ShopPanel.gd::GDScript::5251693507545150927::1789555072::0::1::::ShopPanel<>Control<><>0<>0<><>:: +TooltipView.gd::GDScript::6773602266547042531::1789554490::0::1::::TooltipView<>Control<><>0<>0<><>:: TopBar.gd::GDScript::3252279457217410448::1789551726::0::1::::TopBar<>Control<><>0<>0<><>:: UiKit.gd::GDScript::7622585584068197695::1787984994::0::1::::UiKit<>RefCounted<><>0<>0<><>:: -::res://tests/::1789516714 -CamCal.gd::GDScript::7324782907689628524::1789516628::0::1::::<>Node3D<><>0<>0<><>:: +::res://tests/::1789554302 +CamCal.gd::GDScript::7324782907689628524::1789551981::0::1::::<>Node3D<><>0<>0<><>:: camcal.tscn::PackedScene::-1::1787946501::0::1::::<><><>0<>0<><>::res://tests/CamCal.gd -Probe2.gd::GDScript::7806432109624259689::1789516628::0::1::::<>Node3D<><>0<>0<><>:: +Probe2.gd::GDScript::7806432109624259689::1789551981::0::1::::<>Node3D<><>0<>0<><>:: probe2.tscn::PackedScene::-1::1787946443::0::1::::<><><>0<>0<><>::res://tests/Probe2.gd -Probe3.gd::GDScript::5187223496600362631::1789516900::0::1::::<>Node3D<><>0<>0<><>:: +Probe3.gd::GDScript::5187223496600362631::1789554403::0::1::::<>Node3D<><>0<>0<><>:: probe3.tscn::PackedScene::-1::1787946443::0::1::::<><><>0<>0<><>::res://tests/Probe3.gd -sim_test.gd::GDScript::3463191307206118779::1789516655::0::1::::<>SceneTree<><>0<>0<><>:: +sim_test.gd::GDScript::3463191307206118779::1789555122::0::1::::<>SceneTree<><>0<>0<><>:: diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg index 0009928..140c8d7 100644 --- a/.godot/global_script_class_cache.cfg +++ b/.godot/global_script_class_cache.cfg @@ -23,6 +23,14 @@ list=[{ "language": &"GDScript", "path": "res://scripts/core/BotAI.gd" }, { +"base": &"Node3D", +"class": &"CameraRig", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/CameraRig.gd" +}, { "base": &"RefCounted", "class": &"Combat", "icon": "", @@ -71,6 +79,14 @@ list=[{ "language": &"GDScript", "path": "res://scripts/ui/InfoColumn.gd" }, { +"base": &"Control", +"class": &"Overlays", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/Overlays.gd" +}, { "base": &"RefCounted", "class": &"PlayerState", "icon": "", @@ -95,6 +111,14 @@ list=[{ "language": &"GDScript", "path": "res://scripts/core/Pool.gd" }, { +"base": &"RefCounted", +"class": &"Settings", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/Settings.gd" +}, { "base": &"Node", "class": &"Sfx", "icon": "", @@ -120,6 +144,14 @@ list=[{ "path": "res://scripts/ui/ShopPanel.gd" }, { "base": &"Control", +"class": &"TooltipView", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/TooltipView.gd" +}, { +"base": &"Control", "class": &"TopBar", "icon": "", "is_abstract": false, diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin index e155782..b050654 100644 Binary files a/.godot/uid_cache.bin and b/.godot/uid_cache.bin differ diff --git a/README.md b/README.md index 3c20b0f..ace131c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,15 @@ Würfel (Tank), Kegel (DPS) und Kugeln (Heiler). Alles wird im Code erzeugt. - Spielername rechts anklicken = sein Brett oben ansehen. Der nächste Gegner steht schon in der Planung fest. - `Q`/`E` oder mittlere Maustaste drehen die Kamera, das Mausrad zoomt - Die Planung dauert 30 Sekunden, dann startet der Kampf von selbst -- Startbildschirm und Pause haben einen Lautstärkeregler +- Startbildschirm: Bots leicht/normal/schwer, Lautstärke. Beides wird in `user://settings.cfg` gespeichert. +- Maus über einer Einheit oder Shop-Karte zeigt Werte und Fähigkeit, auch bei Gegnern im Kampf. +- In den ersten zwei Runden steht unten ein Tipp, was als Nächstes dran ist. +- Wer ausscheidet, kann zuschauen (Runden laufen dann in 3 s) oder neu starten. +- `1`–`5` kaufen den jeweiligen Shop-Platz. `Z` macht den letzten Verkauf 5 s lang rückgängig. +- Die gewählte Einheit zeigt ihre Reichweite auf den Feldern: grün Heilung, orange Angriff. +- Runde 1 und 2 gehen gegen die Wildnis, danach gegeneinander. +- Der Spielstand wird zu jedem Planungsbeginn und bei Pause gespeichert. Der Startbildschirm + bietet dann „Weiterspielen". Am Spielende steht eine Statistik. ## Regeln @@ -74,6 +82,18 @@ Nicht in den Anforderungen, aber „Basis wie TFT": - **Rückblick** nach jedem Kampf: Ergebnis, Überlebende, stärkste Einheit, gefährlichster Gegner (`Combat.recap`, `Game.human_recap`). - **Start, Pause, Lautstärke** als Overlays in `Main`, Kamera mit Zoom und Drehung um die Brettmitte. +- **Element-Formen**: das Hauptelement gibt der Fähigkeit jeder Rolle ihre Form (`Data.ELEMENT_FORM`, + `Combat._apply_form`). Feuer trifft Nachbarn und entzündet, Wasser gibt Mana, Wind beschleunigt + Verbündete, Blitz springt weiter, Erde betäubt um das Ziel, Eis verlangsamt um das Ziel. +- **Fähigkeiten sichtbar**: Schildring, Fläche am Ziel, Heilring (`BoardView3D.spawn_effect`). +- **Gehen**: Einheiten drehen sich zur Laufrichtung oder zum Ziel und wippen beim Schritt. +- **Schwierigkeit** steuert Gier und Jagdverhalten der Bots (`Data.BOT_GREED`, `BotAI.setup`). +- **Bot-Heiler** stehen in Reihe 1–2 hinter den Tanks, sonst reicht ihre Heilung nicht (`BotAI.arrange`). +- **Rückgängig** (`PlayerState.undo_sell`) nimmt die Bausteine wieder aus dem Vorrat. +- **Wildnis-Runden** (`Data.PVE_ROUNDS`, `Game._make_creeps`): jeder Spieler bekommt eine eigene Kopie. +- **Speichern** als JSON in `user://spielstand.json` (`Game.to_dict/from_dict`), inklusive RNG-Zustand, + Bot-Vorlieben und geteiltem Vorrat. Trait-Zeilen nennen die nächste Stufe, neue Kombinationen und + Trait-Stufen werden gemeldet (`Main._announce_synergies`). Der neutrale Avatar selbst ist unbegrenzt. Knapp ist das Element, das man ihm beim Kauf gibt: das zieht einen Platz aus dem geteilten Vorrat. Die 100 pro Element sind @@ -84,6 +104,15 @@ Die Neutral-Chance ist multiplikativ (0,75 hoch Anzahl Elemente) statt in Prozentpunkten. So verschwindet Neutral nie ganz: ohne eigene Elemente 100%, mit einem 75%, mit sechs noch 18%. +## Schere, Stein, Papier + +Elemente im Ring, jedes schlägt genau eines: Wasser > Feuer > Eis > Wind > Erde > Blitz > Wasser. +Rollen als Dreieck: Tank > Schütze > Heiler > Tank. Konter ±20 % (Element) und ±15 % (Rolle), +nur das Hauptelement zählt (`Data.counter_multiplier`, angewandt in `Combat._apply_damage`). +Konter-Treffer tragen ein ▲, Tooltip und Info-Panel nennen „Stark gegen / Schwach gegen". +Bots wählen beim Kauf das Element, das das häufigste Element des nächsten Gegners schlägt; +schwere Bots auch außerhalb ihrer Vorlieben (`BotAI._pick_element`). + ## Elemente und Traits Feuer, Wasser, Wind, Blitz, Erde, Eis. Stufen bei 2 / 4 / 6 Einheiten. @@ -93,8 +122,10 @@ Magma, Sturmfront, Dampf. ## Aufbau scripts/core/ Data, Avatar, Pool, ShopEntry, PlayerState, Hex, Combat, BotAI, Game - scripts/ui/ Main (Szene, Kamera, Eingabe, Overlays), TopBar, InfoColumn, PlayersPanel, - ShopPanel (je ein Panel, melden per Signal), BoardView3D, HudOverlay, Sfx, UiKit + scripts/ui/ Main (Eingabe, Aktionen, Orchestrierung), CameraRig, Overlays, TopBar, + InfoColumn, PlayersPanel, ShopPanel (melden per Signal), BoardView3D, + HudOverlay, TooltipView, Sfx, UiKit + scripts/core/ dazu Settings (user://settings.cfg) scenes/Main.tscn tests/ sim_test.gd (kopflos), Probe2, Probe3, CamCal diff --git a/scripts/core/Avatar.gd b/scripts/core/Avatar.gd index c8c2256..6ae1e4d 100644 --- a/scripts/core/Avatar.gd +++ b/scripts/core/Avatar.gd @@ -113,16 +113,25 @@ func ability_text() -> String: s = "heilt 3 Verbündete um %d" % amount else: s = "heilt alle um %d und schildet" % amount + if not elements.is_empty(): + var f: Dictionary = Data.ELEMENT_FORM[elements[0]] + s += "\nForm %s: %s" % [f["name"], f["desc"]] var extra := [] - for i in elements.size(): - var scale := "" if i == 0 else " (halb)" - match elements[i]: - Data.Element.FEUER: extra.append("Brand" + scale) - Data.Element.WASSER: extra.append("Mana + stärkere Heilung" + scale) - Data.Element.WIND: extra.append("Tempo-Schub" + scale) - Data.Element.BLITZ: extra.append("Kettenblitz" + scale) - Data.Element.ERDE: extra.append("Betäubung" + scale) - Data.Element.EIS: extra.append("Verlangsamung" + scale) + for i in range(1, elements.size()): + extra.append(Data.element_effect(elements[i]) + " (halb)") if not extra.is_empty(): - s += "\nElemente: " + ", ".join(extra) + s += "\nDazu: " + ", ".join(extra) return s + + +## „Stark gegen … · Schwach gegen …" aus Hauptelement und Rolle. +func counter_text() -> String: + var e := primary_element() + var strong := [] + var weak := [] + if e >= 0: + strong.append(Data.ELEMENT_NAMES[Data.ELEMENT_BEATS[e]]) + weak.append(Data.ELEMENT_NAMES[Data.beaten_by(e)]) + strong.append(Data.ROLE_NAMES[Data.ROLE_BEATS[role]]) + weak.append(Data.ROLE_NAMES[Data.role_beaten_by(role)]) + return "Stark gegen %s · Schwach gegen %s" % [", ".join(strong), ", ".join(weak)] diff --git a/scripts/core/BotAI.gd b/scripts/core/BotAI.gd index f3e6019..c843f65 100644 --- a/scripts/core/BotAI.gd +++ b/scripts/core/BotAI.gd @@ -5,9 +5,12 @@ extends RefCounted var pref: Array[int] = [] var greed: float = 0.5 ## wie stark der Bot Gold hortet +var difficulty: int = Data.Difficulty.NORMAL +var opponent: PlayerState = null ## bekannter Gegner der Runde, für Konterwahl -func setup(rand: RandomNumberGenerator) -> void: +func setup(rand: RandomNumberGenerator, p_difficulty: int = Data.Difficulty.NORMAL) -> void: + difficulty = p_difficulty var all: Array[int] = [] for e in Data.ELEMENT_COUNT: all.append(e) @@ -19,18 +22,23 @@ func setup(rand: RandomNumberGenerator) -> void: all[i] = all[j] all[j] = tmp pref = [all[0], all[1], all[2]] - greed = rand.randf_range(0.3, 0.8) + var g: Array = Data.BOT_GREED[difficulty] + greed = rand.randf_range(g[0], g[1]) -func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator) -> void: +func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, opp: PlayerState = null) -> void: + opponent = opp _do_upgrades(p, pool) _do_buys(p, pool) _do_placements(p) _do_upgrades(p, pool) var keep := int(lerpf(2.0, 18.0, greed)) # Wer kurz vor einem Upgrade steht, würfelt gezielt nach Futter. - var hunting := _closest_upgrade_gap(p) <= 3 + # Leichte Bots jagen nicht, schwere immer. + var hunting := difficulty != Data.Difficulty.LEICHT and _closest_upgrade_gap(p) <= 3 var rolls := 3 if hunting else 1 + if difficulty == Data.Difficulty.SCHWER: + rolls += 1 for i in rolls: if p.gold > keep + Data.REROLL_COST and (hunting or rand.randf() < 0.6): p.reroll(pool, rand) @@ -71,8 +79,13 @@ func arrange(p: PlayerState, opp: PlayerState) -> void: return _min_dist(a, enemy_tanks) > _min_dist(b, enemy_tanks)) units.sort_custom(func(a, b): return a.role == Data.Role.TANK and b.role != Data.Role.TANK) for u in units: - var rows: Array = [0, 1, 2, 3] if u.role == Data.Role.TANK else [3, 2, 1, 0] - var cols: Array = tank_cols if u.role == Data.Role.TANK else far_cols + # Heiler direkt hinter die Tanks, sonst reicht ihre Heilung (2 Felder) nicht. + var rows: Array + match u.role: + Data.Role.TANK: rows = [0, 1, 2, 3] + Data.Role.HEILER: rows = [1, 2, 0, 3] + _: rows = [3, 2, 1, 0] + var cols: Array = tank_cols if u.role != Data.Role.DPS else far_cols var placed := false for r in rows: for c in cols: @@ -179,6 +192,13 @@ func _pick_bench_for_board(p: PlayerState) -> int: func _pick_element(p: PlayerState) -> int: var counts := p.trait_counts() + # Konter: schlägt eines meiner Lieblingselemente das häufigste Element des Gegners, + # nehme ich das. Schwere Bots kontern auch außerhalb ihrer Vorlieben. + var dominant := _dominant_element(opponent) + if dominant >= 0: + var counter := Data.beaten_by(dominant) + if pref.has(counter) or difficulty == Data.Difficulty.SCHWER: + return counter var best := pref[0] var best_c := -1 for e in pref: @@ -189,6 +209,23 @@ func _pick_element(p: PlayerState) -> int: return best +## Häufigstes Hauptelement auf dem Brett eines Spielers, -1 wenn leer. +func _dominant_element(p: PlayerState) -> int: + if p == null or p.board.is_empty(): + return -1 + var counts := {} + for u in p.board_units(): + var e: int = u.primary_element() + counts[e] = counts.get(e, 0) + 1 + var best := -1 + var best_n := 1 # ab zwei Einheiten lohnt das Kontern + for e in counts: + if counts[e] > best_n: + best_n = counts[e] + best = e + return best + + func _pick_hex(p: PlayerState, role: int) -> Vector2i: var rows: Array match role: diff --git a/scripts/core/Combat.gd b/scripts/core/Combat.gd index 792b147..bf10774 100644 --- a/scripts/core/Combat.gd +++ b/scripts/core/Combat.gd @@ -293,6 +293,12 @@ func _basic_attack(u: CUnit, t: CUnit) -> void: func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void: if not tgt.alive or amount <= 0.0: return + # Schere-Stein-Papier: Hauptelement und Rolle des Angreifers gegen das Ziel. + var counter := 1.0 + if src != null and not is_true: + counter = Data.counter_multiplier(src.avatar.primary_element(), src.avatar.role, + tgt.avatar.primary_element(), tgt.avatar.role) + amount *= counter var dmg := amount if is_true else amount * (1.0 - tgt.armor) if tgt.shield > 0.0: var absorbed: float = minf(tgt.shield, dmg) @@ -304,7 +310,12 @@ func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void: if src != null: src.dealt += dmg if record_hits and src != null: - _record("crit" if amount > src.atk * 1.4 and not is_true else "dmg", tgt, dmg) + var kind := "dmg" + if counter > 1.0: + kind = "counter" + elif amount > src.atk * 1.4 and not is_true: + kind = "crit" + _record(kind, tgt, dmg) tgt.mana += MANA_PER_HIT * tgt.mana_mult if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0: _cast(tgt) @@ -351,8 +362,6 @@ func _record(kind: String, u: CUnit, amount: float) -> void: func _cast(u: CUnit) -> void: u.mana = 0.0 u.cast_flash = 0.35 - if record_hits: - _record("cast", u, 0.0) var elems: Array = u.avatar.elements var heal_bonus := 0.0 for i in elems.size(): @@ -361,6 +370,10 @@ func _cast(u: CUnit) -> void: var hit_enemies: Array = [] var main_target = u.target if (u.target != null and u.target.alive) else _nearest_enemy(u) + if record_hits: + hits.append({"kind": "cast", "pos": u.pos, "team": u.team, "amount": 0, "t": time, + "role": u.avatar.role, "element": u.avatar.primary_element(), + "target": main_target.pos if main_target != null else u.pos}) match u.avatar.role: Data.Role.TANK: @@ -423,8 +436,10 @@ func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void: if combos.has("magnetsturm") and main_target != null and main_target.alive: main_target.stun_timer = maxf(main_target.stun_timer, 0.6) - for i in elems.size(): - var scale := 1.0 if i == 0 else 0.5 + if not elems.is_empty(): + _apply_form(u, elems[0], main_target, hit_enemies) + for i in range(1, elems.size()): + var scale := 0.5 match elems[i]: Data.Element.FEUER: for o in hit_enemies: @@ -454,6 +469,58 @@ func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void: ## Verbündete in Heilreichweite, schwächste zuerst. Mindestens der Heiler selbst. +## Die Form des Hauptelements wirkt für jede Rolle gleich: um das Ziel (Gegner) +## oder um den Zaubernden (Verbündete). +func _apply_form(u: CUnit, element: int, main_target, hit_enemies: Array) -> void: + match element: + Data.Element.FEUER: + if main_target == null: + return + for n in Hex.neighbors(main_target.pos): + var o = _occ.get(n) + if o != null and o.alive and o.team != u.team and not hit_enemies.has(o): + _apply_damage(u, o, u.atk * 0.6, false) + hit_enemies.append(o) + for o in hit_enemies: + if o.alive: + o.burn_dps = maxf(o.burn_dps, u.atk * 0.6) + o.burn_timer = maxf(o.burn_timer, 3.0) + Data.Element.WASSER: + for o in units: + if o.alive and o.team == u.team and o != u and Hex.distance(o.pos, u.pos) <= 2: + o.mana = minf(o.max_mana - 1.0, o.mana + 20.0) + u.mana += 10.0 + Data.Element.WIND: + for o in units: + if o.alive and o.team == u.team and Hex.distance(o.pos, u.pos) <= 2: + o.haste = maxf(o.haste, 0.25) + o.haste_timer = maxf(o.haste_timer, 3.0) + Data.Element.BLITZ: + var chained := 0 + for o in units: + if chained >= 2: + break + if o.alive and o.team != u.team and o != main_target: + _apply_damage(u, o, u.atk * 0.6, false) + hit_enemies.append(o) + chained += 1 + Data.Element.ERDE: + if main_target == null: + return + main_target.stun_timer = maxf(main_target.stun_timer, 0.6) + for n in Hex.neighbors(main_target.pos): + var o = _occ.get(n) + if o != null and o.alive and o.team != u.team: + o.stun_timer = maxf(o.stun_timer, 0.6) + Data.Element.EIS: + if main_target == null: + return + for o in units: + if o.alive and o.team != u.team and Hex.distance(o.pos, main_target.pos) <= 1: + o.slow = maxf(o.slow, 0.30) + o.slow_timer = maxf(o.slow_timer, 3.0) + + func _allies_by_missing_hp(team: int, healer: CUnit) -> Array: var list := [] for o in units: diff --git a/scripts/core/Data.gd b/scripts/core/Data.gd index c0181ec..d2bda63 100644 --- a/scripts/core/Data.gd +++ b/scripts/core/Data.gd @@ -43,6 +43,42 @@ const PROFILE_HINT := { Element.BLITZ: "kritisch", Element.ERDE: "zäh und langsam", Element.EIS: "robust", } +# Das Hauptelement gibt der Fähigkeit ihre Form. Gilt für jede Rolle: +# ein Feuer-Heiler brennt, ein Erde-Tank erschüttert, ein Eis-Schütze friert. +const ELEMENT_FORM := { + Element.FEUER: {"name": "Flächenbrand", "desc": "trifft auch Nachbarn des Ziels (60%) und entzündet sie"}, + Element.WASSER: {"name": "Welle", "desc": "gibt Verbündeten im Umkreis 2 je 20 Mana"}, + Element.WIND: {"name": "Sturmschritt", "desc": "Verbündete im Umkreis 2 werden 3 s um 25% schneller"}, + Element.BLITZ: {"name": "Kette", "desc": "springt auf 2 weitere Gegner (60%)"}, + Element.ERDE: {"name": "Erschütterung","desc": "betäubt Gegner neben dem Ziel 0,6 s"}, + Element.EIS: {"name": "Frostfeld", "desc": "verlangsamt Gegner um das Ziel 3 s um 30%"}, +} + +# Bots: wie gierig sie horten und wie gezielt sie jagen. +enum Difficulty { LEICHT, NORMAL, SCHWER } +const DIFFICULTY_NAMES := ["Leicht", "Normal", "Schwer"] +const BOT_GREED := { + Difficulty.LEICHT: [0.6, 0.9], Difficulty.NORMAL: [0.3, 0.8], Difficulty.SCHWER: [0.15, 0.4], +} + +# Schere-Stein-Papier. Elemente im Ring: jedes schlägt genau eines und verliert +# gegen genau eines. Rollen als Dreieck. Nur das Hauptelement zählt. +const ELEMENT_BEATS := { + Element.WASSER: Element.FEUER, # Wasser löscht Feuer + Element.FEUER: Element.EIS, # Feuer schmilzt Eis + Element.EIS: Element.WIND, # Eis lähmt Wind + Element.WIND: Element.ERDE, # Wind trägt Erde ab + Element.ERDE: Element.BLITZ, # Erde erdet Blitz + Element.BLITZ: Element.WASSER, # Blitz leitet durch Wasser +} +const ROLE_BEATS := { + Role.TANK: Role.DPS, # Tank hält Schützen aus + Role.DPS: Role.HEILER, # Schütze bricht Heiler + Role.HEILER: Role.TANK, # Heiler überdauert Tank +} +const ELEMENT_COUNTER := 0.20 +const ROLE_COUNTER := 0.15 + # Multiplikator pro Sternstufe (Index = star - 1). const STAR_MULT := [1.0, 1.8, 3.24] const MAX_STAR := 3 @@ -89,6 +125,9 @@ const XP_TABLE := {3: 4, 4: 8, 5: 12, 6: 20, 7: 32, 8: 48} # --- Planung --------------------------------------------------------------- const PLAN_SECONDS := 30.0 +## Die ersten Runden gehen gegen die Wildnis, damit jeder in Ruhe aufbaut. +const PVE_ROUNDS := 2 +const SAVE_PATH := "user://spielstand.json" # --- Kampf ----------------------------------------------------------------- ## Heiler heilen nur Verbündete in dieser Hex-Reichweite. @@ -164,6 +203,65 @@ static func trait_tier(element: int, count: int) -> int: return tier +static func beats_element(attacker: int, defender: int) -> bool: + return attacker >= 0 and defender >= 0 and ELEMENT_BEATS.get(attacker, -1) == defender + + +static func beats_role(attacker: int, defender: int) -> bool: + return ROLE_BEATS.get(attacker, -1) == defender + + +## Schadensfaktor eines Angreifers gegen einen Verteidiger (Hauptelement und Rolle). +static func counter_multiplier(att_el: int, att_role: int, def_el: int, def_role: int) -> float: + var m := 1.0 + if beats_element(att_el, def_el): + m += ELEMENT_COUNTER + elif beats_element(def_el, att_el): + m -= ELEMENT_COUNTER + if beats_role(att_role, def_role): + m += ROLE_COUNTER + elif beats_role(def_role, att_role): + m -= ROLE_COUNTER + return m + + +## Element, das dieses Element schlägt (das Gegenstück zu ELEMENT_BEATS). +static func beaten_by(element: int) -> int: + for e in ELEMENT_BEATS: + if ELEMENT_BEATS[e] == element: + return e + return -1 + + +static func role_beaten_by(role: int) -> int: + for r in ROLE_BEATS: + if ROLE_BEATS[r] == role: + return r + return -1 + + +## Was die Fähigkeit einer Rolle auf einer Sternstufe tut. +static func star_effect(role: int, star: int) -> String: + match role: + Role.TANK: + return "Schild und spottet 3 Gegner" if star == 2 else "Schild, Spott und 25% Reflexion" + Role.DPS: + return "Ziel plus Splash auf Nachbarn" if star == 2 else "Ziel plus Fläche im Radius 2" + _: + return "heilt 3 Verbündete" if star == 2 else "heilt alle und schildet sie" + + +## Zusatzwirkung eines Nebenelements. +static func element_effect(element: int) -> String: + match element: + Element.FEUER: return "Brand" + Element.WASSER: return "Mana und stärkere Heilung" + Element.WIND: return "Tempo-Schub" + Element.BLITZ: return "Kettenblitz" + Element.ERDE: return "Betäubung" + _: return "Verlangsamung" + + ## XP bis zum nächsten Level, 0 auf Maximalstufe. static func xp_needed(level: int) -> int: return XP_TABLE.get(level, 0) diff --git a/scripts/core/Game.gd b/scripts/core/Game.gd index 71daea4..5d12a09 100644 --- a/scripts/core/Game.gd +++ b/scripts/core/Game.gd @@ -27,7 +27,11 @@ var human_recap: Dictionary = {} ## Rückblick auf den letzten eigenen Kampf var human_last_result: int = 0 ## 1 Sieg, -1 Niederlage, 0 unentschieden/keine -func start(seed_value: int = 0) -> void: +var difficulty: int = Data.Difficulty.NORMAL + + +func start(seed_value: int = 0, p_difficulty: int = Data.Difficulty.NORMAL) -> void: + difficulty = p_difficulty rand.seed = seed_value if seed_value != 0 else randi() pool = Pool.new() players.clear() @@ -37,7 +41,7 @@ func start(seed_value: int = 0) -> void: var b := PlayerState.new(i + 1, BOT_NAMES[i], true) players.append(b) var ai := BotAI.new() - ai.setup(rand) + ai.setup(rand, difficulty) bots[b.pid] = ai round_no = 1 for p in players: @@ -61,8 +65,9 @@ func alive_players() -> Array: func _run_bot_turns() -> void: for p in players: if p.is_bot and p.alive: - bots[p.pid].take_turn(p, pool, rand) - bots[p.pid].arrange(p, opponent_of(p)) + var opp := opponent_of(p) + bots[p.pid].take_turn(p, pool, rand, opp) + bots[p.pid].arrange(p, opp) ## Planungsuhr. Läuft sie ab, startet der Kampf von selbst. @@ -85,7 +90,7 @@ func start_combat() -> void: return # Abbilder erst jetzt kopieren, damit sie den aktuellen Stand zeigen. for pr in pairings: - if pr["ghost"]: + if pr["ghost"] and pr["ghost_src"] != null: pr["b"] = _make_ghost(pr["ghost_src"]) pending.clear() player_combat = null @@ -117,10 +122,28 @@ func skip_combat() -> void: resolve_round() +func is_pve_round() -> bool: + return round_no <= Data.PVE_ROUNDS + + +## Wildnis-Gegner: klein, ohne Leben, jeder Spieler bekommt seinen eigenen. +func _make_creeps() -> PlayerState: + var c := PlayerState.new(-2, "Wildnis", true) + c.level = 9 + c.board[Vector2i(3, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + if round_no >= 2: + c.board[Vector2i(2, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) + return c + + func _make_pairings() -> Array: var alive := alive_players() - alive.shuffle() var out := [] + if is_pve_round(): + for p in alive: + out.append({"a": p, "b": _make_creeps(), "ghost": true, "ghost_src": null}) + return out + alive.shuffle() var i := 0 while i + 1 < alive.size(): out.append({"a": alive[i], "b": alive[i + 1], "ghost": false}) @@ -207,7 +230,9 @@ func _next_round() -> void: for p in players: if not p.alive: continue - p.gold += p.round_income() + var income: int = p.round_income() + p.gold += income + p.stats["gold_earned"] += income p.gain_xp(Data.XP_PER_ROUND) p.new_round_shop(pool, rand) pairings = _make_pairings() @@ -216,13 +241,96 @@ func _next_round() -> void: phase = Phase.PLANNING +# --- Speichern ------------------------------------------------------------- + +func to_dict() -> Dictionary: + var ps := [] + for p in players: + ps.append(p.to_dict()) + var ai := {} + for pid in bots: + ai[str(pid)] = {"pref": Array(bots[pid].pref), "greed": bots[pid].greed} + return { + "version": 1, "round": round_no, "difficulty": difficulty, "rng_state": str(rand.state), + "pool": Array(pool.element_left), "players": ps, "bots": ai, + "human_recap": human_recap, "log": last_round_log, + } + + +func from_dict(d: Dictionary) -> void: + difficulty = int(d["difficulty"]) + rand.state = int(str(d["rng_state"])) + pool = Pool.new() + for i in mini(pool.element_left.size(), d["pool"].size()): + pool.element_left[i] = int(d["pool"][i]) + players.clear() + bots.clear() + for pd in d["players"]: + var p := PlayerState.from_dict(pd) + players.append(p) + if not p.is_bot: + human = p + else: + var ai := BotAI.new() + ai.setup(rand, difficulty) + var bd: Dictionary = d["bots"].get(str(p.pid), {}) + if bd.has("pref"): + ai.pref.clear() + for e in bd["pref"]: + ai.pref.append(int(e)) + ai.greed = float(bd["greed"]) + bots[p.pid] = ai + round_no = int(d["round"]) + human_recap = d.get("human_recap", {}) + last_round_log = d.get("log", []) + pairings = _make_pairings() + for p in players: + if p.is_bot and p.alive: + bots[p.pid].arrange(p, opponent_of(p)) + plan_left = Data.PLAN_SECONDS + phase = Phase.PLANNING + + +func save(path: String = Data.SAVE_PATH) -> bool: + if phase != Phase.PLANNING: + return false + var f := FileAccess.open(path, FileAccess.WRITE) + if f == null: + return false + f.store_string(JSON.stringify(to_dict())) + return true + + +static func has_save(path: String = Data.SAVE_PATH) -> bool: + return FileAccess.file_exists(path) + + +static func delete_save(path: String = Data.SAVE_PATH) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +static func load_from(path: String = Data.SAVE_PATH) -> Game: + if not FileAccess.file_exists(path): + return null + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return null + var parsed = JSON.parse_string(f.get_as_text()) + if not (parsed is Dictionary): + return null + var g := Game.new() + g.from_dict(parsed) + return g + + # --- Hilfen für die UI ---------------------------------------------------- ## Gegner eines Spielers in der aktuellen Paarung (Abbilder liefern ihre Quelle). func opponent_of(p: PlayerState) -> PlayerState: for pr in pairings: if pr["a"] == p: - return pr["ghost_src"] if pr["ghost"] else pr["b"] + return pr["ghost_src"] if (pr["ghost"] and pr["ghost_src"] != null) else pr["b"] if pr["b"] == p: return pr["a"] return null diff --git a/scripts/core/PlayerState.gd b/scripts/core/PlayerState.gd index 53abbbd..3ee1341 100644 --- a/scripts/core/PlayerState.gd +++ b/scripts/core/PlayerState.gd @@ -16,6 +16,9 @@ var level: int = Data.START_LEVEL var xp: int = 0 var shop_locked: bool = false ## eingefrorener Shop bleibt über die Runde var last_element: int = -1 ## zuletzt gewählte Spezialisierung für Schnellkauf +var last_sale: Dictionary = {} ## letzter Verkauf, für Rückgängig +## Statistik für den Endbildschirm. +var stats := {"wins": 0, "losses": 0, "gold_earned": 0, "spent": 0, "max_star": 1} var bench: Array = [] ## Avatar oder null var board: Dictionary = {} ## Vector2i -> Avatar @@ -111,6 +114,7 @@ func buy(index: int, pool: Pool, chosen_element: int = -1) -> bool: if not pool.take(element): return false gold -= entry.price() + stats["spent"] += entry.price() bench[slot] = entry.to_avatar(element) entry.sold = true if entry.element < 0: @@ -123,8 +127,39 @@ func sell_bench(index: int, pool: Pool) -> bool: return false var a: Avatar = bench[index] pool.return_avatar(a) - gold += maxi(1, a.invested) + var refund := maxi(1, a.invested) + gold += refund bench[index] = null + last_sale = {"avatar": a, "refund": refund, "index": index} + return true + + +## Macht den letzten Bankverkauf rückgängig, solange Gold und Platz da sind. +func undo_sell(pool: Pool) -> bool: + if last_sale.is_empty(): + return false + var a: Avatar = last_sale["avatar"] + var refund: int = last_sale["refund"] + if gold < refund: + return false + var idx: int = last_sale["index"] + if bench[idx] != null: + idx = free_bench_slot() + if idx < 0: + return false + # Bausteine wieder aus dem Vorrat nehmen; ist er leer, geht es nicht. + if not pool.take(a.elements[0]): + return false + if a.star >= 2: + for i in 2: + pool.take(a.elements[0]) + if a.star >= 3: + for e in a.elements: + for i in 3: + pool.take(e) + gold -= refund + bench[idx] = a + last_sale = {} return true @@ -338,6 +373,7 @@ func upgrade(hex: Vector2i, new_element: int, pool: Pool) -> bool: a.star += 1 a.elements.append(new_element) unlock(new_element) + stats["max_star"] = maxi(stats["max_star"], a.star) return true @@ -377,8 +413,10 @@ func register_result(won: bool) -> void: last_won = won if won: streak = maxi(1, streak + 1) + stats["wins"] += 1 else: streak = mini(-1, streak - 1) + stats["losses"] += 1 ## Nur Leben abziehen. Das Ausscheiden setzt Game._check_eliminations, @@ -392,3 +430,63 @@ func board_units() -> Array: for hex in board: out.append(board[hex]) return out + + +# --- Speichern ------------------------------------------------------------- + +static func _avatar_to_dict(a: Avatar) -> Dictionary: + return {"role": a.role, "elements": Array(a.elements), "star": a.star, "invested": a.invested} + + +static func _avatar_from_dict(d: Dictionary) -> Avatar: + var els: Array[int] = [] + for e in d["elements"]: + els.append(int(e)) + var a := Avatar.new(int(d["role"]), els, int(d["star"])) + a.invested = int(d["invested"]) + return a + + +func to_dict() -> Dictionary: + var b := [] + for a in bench: + b.append(null if a == null else _avatar_to_dict(a)) + var brd := [] + for hex in board: + brd.append({"x": hex.x, "y": hex.y, "a": _avatar_to_dict(board[hex])}) + var sh := [] + for e in shop: + sh.append({"role": e.role, "element": e.element, "sold": e.sold}) + return { + "pid": pid, "pname": pname, "is_bot": is_bot, "gold": gold, "hp": hp, "streak": streak, + "alive": alive, "last_won": last_won, "placement": placement, "level": level, "xp": xp, + "shop_locked": shop_locked, "last_element": last_element, "unlocked": Array(unlocked), + "bench": b, "board": brd, "shop": sh, "stats": stats.duplicate(), + } + + +static func from_dict(d: Dictionary) -> PlayerState: + var p := PlayerState.new(int(d["pid"]), d["pname"], d["is_bot"]) + p.gold = int(d["gold"]) + p.hp = int(d["hp"]) + p.streak = int(d["streak"]) + p.alive = d["alive"] + p.last_won = d["last_won"] + p.placement = int(d["placement"]) + p.level = int(d["level"]) + p.xp = int(d["xp"]) + p.shop_locked = d["shop_locked"] + p.last_element = int(d["last_element"]) + for i in mini(p.unlocked.size(), d["unlocked"].size()): + p.unlocked[i] = d["unlocked"][i] + for i in mini(p.bench.size(), d["bench"].size()): + p.bench[i] = null if d["bench"][i] == null else _avatar_from_dict(d["bench"][i]) + for entry in d["board"]: + p.board[Vector2i(int(entry["x"]), int(entry["y"]))] = _avatar_from_dict(entry["a"]) + for entry in d["shop"]: + var se := ShopEntry.new(int(entry["role"]), int(entry["element"])) + se.sold = entry["sold"] + p.shop.append(se) + for k in d["stats"]: + p.stats[k] = int(d["stats"][k]) + return p diff --git a/scripts/core/Settings.gd b/scripts/core/Settings.gd new file mode 100644 index 0000000..ad69d9b --- /dev/null +++ b/scripts/core/Settings.gd @@ -0,0 +1,24 @@ +class_name Settings +extends RefCounted + +## Lautstärke und Schwierigkeit in user://settings.cfg. + +const PATH := "user://settings.cfg" + +var volume: float = 0.8 +var difficulty: int = Data.Difficulty.NORMAL + + +func load_from_disk(path: String = PATH) -> void: + var cfg := ConfigFile.new() + if cfg.load(path) != OK: + return + volume = clampf(float(cfg.get_value("audio", "volume", volume)), 0.0, 1.0) + difficulty = clampi(int(cfg.get_value("game", "difficulty", difficulty)), 0, 2) + + +func save_to_disk(path: String = PATH) -> void: + var cfg := ConfigFile.new() + cfg.set_value("audio", "volume", volume) + cfg.set_value("game", "difficulty", difficulty) + cfg.save(path) diff --git a/scripts/core/Settings.gd.uid b/scripts/core/Settings.gd.uid new file mode 100644 index 0000000..14222e3 --- /dev/null +++ b/scripts/core/Settings.gd.uid @@ -0,0 +1 @@ +uid://glddwd7k5wml diff --git a/scripts/ui/BoardView3D.gd b/scripts/ui/BoardView3D.gd index ab981e9..f10a420 100644 --- a/scripts/ui/BoardView3D.gd +++ b/scripts/ui/BoardView3D.gd @@ -20,6 +20,10 @@ var camera: Camera3D = null var scout_player: PlayerState = null var selected_slot := {} ## {"kind": "board"/"bench", ...} var hover_slot := {} +## Reichweite der gewählten Einheit: Mitte (Arena-Feld), Radius, Farbe. Radius 0 = aus. +var range_center := Vector2i(-1, -1) +var range_radius := 0 +var range_color := Color(1.0, 0.7, 0.3) var drag_active := false var drag_avatar_id := -1 var drag_world := Vector3.ZERO @@ -37,6 +41,10 @@ var _dying := {} ## key -> Restzeit der Sterbe-Animation const DEATH_TIME := 0.45 var _shots: Array = [] ## fliegende Geschosse {"node", "from", "to", "t", "dur"} var shots_total := 0 ## Zähler aller Geschosse, für Tests +var _effects: Array = [] ## Fähigkeitsformen {"node", "t", "dur", "grow"} +var effects_total := 0 +var _ring_fx: TorusMesh +var _disc_fx: CylinderMesh var _shot_mesh: SphereMesh var _slash_mesh: BoxMesh var _origin := Vector3.ZERO @@ -63,6 +71,16 @@ func _ready() -> void: _shot_mesh.rings = 4 _slash_mesh = BoxMesh.new() _slash_mesh.size = Vector3(0.12, 0.12, 0.7) + _ring_fx = TorusMesh.new() + _ring_fx.inner_radius = 0.55 + _ring_fx.outer_radius = 0.7 + _ring_fx.ring_segments = 8 + _ring_fx.rings = 28 + _disc_fx = CylinderMesh.new() + _disc_fx.top_radius = 0.9 + _disc_fx.bottom_radius = 0.9 + _disc_fx.height = 0.06 + _disc_fx.radial_segments = 6 _build_tiles() _build_bench() set_process(true) @@ -228,6 +246,59 @@ func _process(delta: float) -> void: _update_tiles() _update_units(delta) _update_shots(delta) + _update_effects(delta) + + +## Sichtbare Fähigkeit: Tank = wachsender Schildring, Schütze = Fläche am Ziel, +## Heiler = aufsteigender Heilring. Farbe nach Element. +func spawn_effect(world: Vector3, target: Vector3, role: int, col: Color) -> void: + var mi := MeshInstance3D.new() + var m := StandardMaterial3D.new() + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.albedo_color = Color(col, 0.75) + m.emission_enabled = true + m.emission = col + m.emission_energy_multiplier = 1.4 + mi.material_override = m + var fx := {"node": mi, "t": 0.0, "dur": 0.45, "rise": 0.0, "grow": 1.0} + match role: + Data.Role.TANK: + mi.mesh = _ring_fx + mi.position = world + Vector3(0.0, 0.3, 0.0) + fx["grow"] = 1.6 + Data.Role.DPS: + mi.mesh = _disc_fx + mi.position = target + Vector3(0.0, 0.12, 0.0) + mi.scale = Vector3(0.3, 1.0, 0.3) + fx["grow"] = 1.4 + fx["dur"] = 0.35 + _: + mi.mesh = _ring_fx + mi.position = world + Vector3(0.0, 0.1, 0.0) + fx["rise"] = 1.2 + fx["grow"] = 1.2 + fx["dur"] = 0.6 + add_child(mi) + effects_total += 1 + _effects.append(fx) + + +func _update_effects(delta: float) -> void: + var keep := [] + for fx in _effects: + fx["t"] += delta + var f: float = fx["t"] / fx["dur"] + var node: MeshInstance3D = fx["node"] + if f >= 1.0: + node.queue_free() + continue + var g: float = lerpf(1.0, fx["grow"], f) if fx["grow"] >= 1.0 else 1.0 + node.scale = Vector3(g, 1.0, g) if node.mesh == _disc_fx else Vector3(g, g, g) + node.position.y += fx["rise"] * delta + var m: StandardMaterial3D = node.material_override + m.albedo_color.a = 0.75 * (1.0 - f) + keep.append(fx) + _effects = keep ## Sichtbarer Angriff: Fernkampf schickt eine Kugel, Nahkampf einen kurzen Hieb. @@ -276,6 +347,11 @@ func _update_tiles() -> void: var slot := {"kind": "board", "hex": to_board(h)} var col := Color(0.20, 0.23, 0.31) if own else Color(0.28, 0.18, 0.20) var emit := 0.0 + var in_range := range_radius > 0 and range_center.x >= 0 \ + and Hex.distance(h, range_center) <= range_radius and h != range_center + if in_range and not drag_active: + col = col.lerp(range_color, 0.45) + emit = 0.22 if own and _is_hover(slot): col = Color(0.42, 0.60, 0.82) emit = 0.40 @@ -343,6 +419,7 @@ func _update_units(delta: float) -> void: "slow": u.slow_timer > 0.0 and u.slow > 0.0, "stun": u.stun_timer > 0.0, "shield": clampf(u.shield / maxf(1.0, u.max_hp), 0.0, 1.0), + "face": world_pos(_mirror(u.target.pos, my_team)) if (u.target != null and u.target.alive) else null, } else: for bh in game.human.board: @@ -410,7 +487,23 @@ func _update_units(delta: float) -> void: # und sieht nicht angehoben aus. node.position = target else: - node.position = node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0)) + var flat_from := Vector3(node.position.x, 0.0, node.position.z) + var flat_to := Vector3(target.x, 0.0, target.z) + var moving := flat_from.distance_to(flat_to) > 0.05 + var next := node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0)) + if moving: + # Blick in Laufrichtung, kleines Wippen wie ein Schritt. + var dirv := flat_to - flat_from + node.rotation.y = lerp_angle(node.rotation.y, atan2(dirv.x, dirv.z), clampf(delta * 10.0, 0.0, 1.0)) + var body: MeshInstance3D = node.get_meta("body") + body.position.y = body.get_meta("base_y", body.position.y) + absf(sin(Time.get_ticks_msec() / 90.0)) * 0.12 + elif d.get("face", null) != null: + var fd: Vector3 = d["face"] - flat_to + if fd.length() > 0.05: + node.rotation.y = lerp_angle(node.rotation.y, atan2(fd.x, fd.z), clampf(delta * 6.0, 0.0, 1.0)) + var body2: MeshInstance3D = node.get_meta("body") + body2.position.y = body2.get_meta("base_y", body2.position.y) + node.position = next var body: MeshInstance3D = node.get_meta("body") var mat: StandardMaterial3D = body.material_override var base: Color = d["avatar"].color() @@ -446,8 +539,15 @@ func _update_units(delta: float) -> void: ## Knoten eines Avatars, oder null. Wird von den Tests genutzt. +## Findet auch Kampfeinheiten, die unter einem anderen Schlüssel liegen. func unit_node(avatar_id: int) -> Node3D: - return _units.get("a%d" % avatar_id) + var direct = _units.get("a%d" % avatar_id) + if direct != null: + return direct + for n in _units.values(): + if n.get_meta("avatar_id", -1) == avatar_id: + return n + return null ## Für das Overlay: Arena-Feld so spiegeln, wie das Brett gezeichnet wird. @@ -555,8 +655,10 @@ func _make_unit(a: Avatar, side: int) -> Node3D: sph.rings = 10 body.mesh = sph body.position.y = 0.55 + body.set_meta("base_y", body.position.y) root.add_child(body) root.set_meta("body", body) + root.set_meta("avatar_id", a.id) _add_ornament(root, a, body.position.y) diff --git a/scripts/ui/CameraRig.gd b/scripts/ui/CameraRig.gd new file mode 100644 index 0000000..7a5d6ee --- /dev/null +++ b/scripts/ui/CameraRig.gd @@ -0,0 +1,93 @@ +class_name CameraRig +extends Node3D + +## Kamera über dem Brett: passt sich der freien Bildmitte an, lässt sich +## zoomen und um die Brettmitte drehen. + +const FOV := 44.0 +const PITCH := 52.0 ## Grad über der Waagerechten +const ZOOM_MIN := 0.6 +const ZOOM_MAX := 1.5 + +var camera: Camera3D +var zoom := 1.0 +var yaw := 0.0 +var _center := Vector3.ZERO +var _dir := Vector3.ZERO +var _dist := 20.0 + + +func _ready() -> void: + camera = Camera3D.new() + camera.fov = FOV + camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP) + add_child(camera) + + +## Legt die Kamera so, dass alle Punkte in das Zielrechteck passen. +func fit(points: Array, target: Rect2) -> void: + if target.size.x <= 0.0 or target.size.y <= 0.0 or points.is_empty(): + return + var pitch := deg_to_rad(PITCH) + var dir := Vector3(0.0, sin(pitch), cos(pitch)) + var dist := 20.0 + var center := Vector3.ZERO + for i in 8: + camera.look_at_from_position(center + dir * dist, center, Vector3.UP) + var r := _project_rect(points) + if r.size.x <= 0.0 or r.size.y <= 0.0: + return + dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y) + camera.look_at_from_position(center + dir * dist, center, Vector3.UP) + r = _project_rect(points) + var err := r.get_center() - target.get_center() + var o := camera.unproject_position(center) + var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x + var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y + if absf(kx) < 0.001 or absf(kz) < 0.001: + break + center += Vector3(err.x / kx, 0.0, err.y / kz) + _center = center + _dir = dir + _dist = dist + apply() + + +## Nutzeranteil: Zoom und Drehung um die Brettmitte. +func apply() -> void: + var dir := _dir.rotated(Vector3.UP, deg_to_rad(yaw)) + camera.look_at_from_position(_center + dir * _dist * zoom, _center, Vector3.UP) + + +func zoom_by(delta: float) -> void: + zoom = clampf(zoom + delta, ZOOM_MIN, ZOOM_MAX) + apply() + + +func rotate_by(deg: float) -> void: + yaw = fmod(yaw + deg, 360.0) + apply() + + +func _project_rect(points: Array) -> Rect2: + var lo := Vector2(INF, INF) + var hi := Vector2(-INF, -INF) + for p in points: + if camera.is_position_behind(p): + return Rect2() + var q := camera.unproject_position(p) + lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y)) + hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y)) + return Rect2(lo, hi - lo) + + +## Punkt auf der Feldebene unter einer Bildschirmposition, oder null. +func ground_point(screen_pos: Vector2, plane_y: float): + var from := camera.project_ray_origin(screen_pos) + var dir := camera.project_ray_normal(screen_pos) + if absf(dir.y) < 0.0001: + return null + var t := (plane_y - from.y) / dir.y + if t < 0.0: + return null + return from + dir * t diff --git a/scripts/ui/CameraRig.gd.uid b/scripts/ui/CameraRig.gd.uid new file mode 100644 index 0000000..efbd28f --- /dev/null +++ b/scripts/ui/CameraRig.gd.uid @@ -0,0 +1 @@ +uid://bet73kf858xae diff --git a/scripts/ui/HudOverlay.gd b/scripts/ui/HudOverlay.gd index 058f4fc..5753995 100644 --- a/scripts/ui/HudOverlay.gd +++ b/scripts/ui/HudOverlay.gd @@ -9,6 +9,10 @@ const FLOAT_RISE := 42.0 var board: BoardView3D = null var sfx: Sfx = null +## Tooltip zu einer Einheit, gesetzt von Main (Maus über Einheit oder Shop-Karte). +var tooltip_avatar: Avatar = null +var tooltip_pos := Vector2.ZERO +var tooltip_note := "" var _font: Font var _floaters: Array = [] ## {"world": Vector3, "text": String, "col": Color, "age": float} var _combat_seen: Combat = null @@ -54,12 +58,19 @@ func _pull_hits() -> void: _floaters.append({"world": world, "text": str(h["amount"]) + "!", "col": Color(1.0, 0.85, 0.3), "age": 0.0}) if sfx: sfx.play("krit") + "counter": + # Konter-Treffer: Element oder Rolle war stark gegen das Ziel. + _floaters.append({"world": world, "text": str(h["amount"]) + " ▲", + "col": Color(1.0, 0.6, 0.9), "age": 0.0}) + if sfx: sfx.play("krit") "heal": _floaters.append({"world": world, "text": "+" + str(h["amount"]), "col": Color(0.55, 1.0, 0.6), "age": 0.0}) if sfx: sfx.play("heilung") "cast": if sfx: sfx.play("zauber") + var tgt := board.world_pos(board.mirror_for_view(h["target"], my_team)) + board.spawn_effect(world, tgt, h["role"], Data.ELEMENT_COLORS[h["element"]]) "attack": var from := board.world_pos(board.mirror_for_view(h["from"], my_team)) board.spawn_attack(from, world, h["ranged"], Data.ELEMENT_COLORS[h["element"]]) @@ -120,3 +131,4 @@ func _draw() -> void: draw_string(_font, pos + Vector2(1, 1), f["text"], HORIZONTAL_ALIGNMENT_CENTER, -1, size, Color(0, 0, 0, col.a * 0.8)) draw_string(_font, pos, f["text"], HORIZONTAL_ALIGNMENT_CENTER, -1, size, col) + diff --git a/scripts/ui/InfoColumn.gd b/scripts/ui/InfoColumn.gd index 269a273..e28315a 100644 --- a/scripts/ui/InfoColumn.gd +++ b/scripts/ui/InfoColumn.gd @@ -32,7 +32,8 @@ func build(x: float, y: float, w: float, h: float) -> void: traits_box = VBoxContainer.new() info_box = VBoxContainer.new() log_box = VBoxContainer.new() - for b in [traits_box, info_box, log_box]: + # Die gewählte Einheit zuerst: dort sitzt der Aufwerten-Knopf, der muss ohne Scrollen erreichbar sein. + for b in [info_box, traits_box, log_box]: b.add_theme_constant_override("separation", 3) b.size_flags_horizontal = Control.SIZE_EXPAND_FILL col.add_child(b) @@ -76,11 +77,17 @@ func _refresh_traits(game: Game) -> void: dot.color = col if tier > 0 else col.darkened(0.55) dot.custom_minimum_size = Vector2(12, 12) row.add_child(dot) + var tr: Dictionary = Data.ELEMENT_TRAITS[e] var txt := " %s %d" % [Data.ELEMENT_NAMES[e], c] if tier > 0: - txt += " - " + Data.ELEMENT_TRAITS[e]["desc"][tier - 1] + txt += " - " + tr["desc"][tier - 1] else: txt += " (ab 2 aktiv)" + # Ausblick: was die nächste Stufe bringt. + if tier < tr["steps"].size(): + txt += "\n nächste Stufe bei %d: %s" % [tr["steps"][tier], tr["desc"][tier]] + else: + txt += "\n höchste Stufe" var el := UiKit.label(txt, 13, Color(0.9, 0.9, 0.95) if tier > 0 else Color(0.55, 0.55, 0.6)) el.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART el.custom_minimum_size = Vector2(_text_w - 20.0, 0) @@ -114,6 +121,7 @@ func _refresh_info(game: Game, a: Avatar, hex: Vector2i) -> void: info_box.add_child(_wrapped("Leben %d Angriff %d Rüstung %d%%\nTempo %.2f Reichweite %d%s" % [int(s["hp"]), int(s["atk"]), int(s["armor"] * 100.0), s["aspd"], s["range"], crit], 12, Color(0.9, 0.9, 0.95))) + info_box.add_child(_wrapped(a.counter_text(), 12, Color(1.0, 0.75, 0.9))) info_box.add_child(_wrapped(a.ability_text(), 12, Color(0.8, 0.85, 1.0))) if hex.x < 0: info_box.add_child(_wrapped("Zum Aufwerten aufs Feld ziehen.", 12, Color(0.85, 0.85, 0.9))) diff --git a/scripts/ui/Main.gd b/scripts/ui/Main.gd index 751bb9b..270674a 100644 --- a/scripts/ui/Main.gd +++ b/scripts/ui/Main.gd @@ -9,14 +9,11 @@ const LEFT_W := 290.0 ## links: Elemente, Einheit, Rückblick const RIGHT_W := 210.0 ## rechts: Spielerliste const BOTTOM_H := 152.0 const BG := Color(0.09, 0.10, 0.13) -const CAM_FOV := 44.0 -const CAM_PITCH := 52.0 ## Grad über der Waagerechten -const ZOOM_MIN := 0.6 -const ZOOM_MAX := 1.5 const YAW_STEP := 15.0 var game := Game.new() var board: BoardView3D +var rig: CameraRig var camera: Camera3D var ui_layer: CanvasLayer var ui_root: Control @@ -30,14 +27,13 @@ var shop: ShopPanel var combat_speed := 1.0 var started := false var paused := false +var spectating := false ## nach dem Ausscheiden zuschauen +var eliminated_shown := false var scout: PlayerState = null ## Spieler, dessen Brett gerade angesehen wird - -# Kamera: Einpassung plus Nutzeranteil -var _cam_center := Vector3.ZERO -var _cam_dir := Vector3.ZERO -var _cam_dist := 20.0 -var cam_zoom := 1.0 -var cam_yaw := 0.0 +var settings := Settings.new() +var overlays: Overlays +var _prev_combos: Array = [] ## für die Meldung neuer Kombinationen +var _prev_tiers := {} ## Element -> Trait-Stufe # Auswahl und Ziehen var sel_slot := {} @@ -51,7 +47,6 @@ var picker_title: Label var picker_sub: Label var picker_cb: Callable -var overlay: Control ## Start-, Pause- oder Endbildschirm func _ready() -> void: @@ -72,17 +67,42 @@ func _ready() -> void: _build_panels() _build_picker() + var tip := TooltipView.new() + tip.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + tip.hud = hud + ui_layer.add_child(tip) + shop.card_hovered.connect(_on_card_hovered) + shop.card_left.connect(_on_card_left) + shop.undo_pressed.connect(_on_undo) - game.start() + settings.load_from_disk() + sfx.set_volume(settings.volume) + overlays = Overlays.new() + overlays.volume = settings.volume + overlays.difficulty = settings.difficulty + overlays.start_pressed.connect(_start_game) + overlays.difficulty_changed.connect(_on_difficulty) + overlays.volume_changed.connect(func(v): + settings.volume = v + sfx.set_volume(v) + settings.save_to_disk()) + overlays.resume_pressed.connect(_toggle_pause) + overlays.restart_pressed.connect(_restart) + overlays.spectate_pressed.connect(_spectate) + ui_layer.add_child(overlays) + + game.start(0, settings.difficulty) board.game = game _refresh() - _show_start() + overlays.show_start(Game.has_save()) set_process(true) func _process(delta: float) -> void: if not started or paused: return + if spectating and game.phase == Game.Phase.PLANNING and game.plan_left > 3.0: + game.plan_left = 3.0 if game.phase == Game.Phase.PLANNING: if game.tick_planning(delta): _cancel_drag() @@ -99,6 +119,8 @@ func _process(delta: float) -> void: if game.phase != before: _after_combat() _refresh() + if game.phase == Game.Phase.PLANNING and game.human.alive: + game.save() func _after_combat() -> void: @@ -106,6 +128,8 @@ func _after_combat() -> void: 1: sfx.play("sieg") -1: sfx.play("niederlage") _: sfx.play("runde") + if not game.human.alive and not eliminated_shown and game.phase != Game.Phase.GAMEOVER: + _show_eliminated() # --- Eingabe ----------------------------------------------------------------- @@ -117,8 +141,7 @@ func _unhandled_input(event: InputEvent) -> void: return if event is InputEventMouseMotion: if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE: - cam_yaw += event.relative.x * 0.3 - _apply_camera() + rig.rotate_by(event.relative.x * 0.3) return _on_mouse_move(event.position) return @@ -134,21 +157,24 @@ func _unhandled_input(event: InputEvent) -> void: _on_right_click(event.position) MOUSE_BUTTON_WHEEL_UP: if event.pressed: - _zoom(-0.1) + rig.zoom_by(-0.1) MOUSE_BUTTON_WHEEL_DOWN: if event.pressed: - _zoom(0.1) + rig.zoom_by(0.1) return if not (event is InputEventKey and event.pressed and not event.echo): return match event.keycode: + KEY_1, KEY_2, KEY_3, KEY_4, KEY_5: + _on_buy(event.keycode - KEY_1, false) + KEY_Z: _on_undo() KEY_D: _on_reroll() KEY_X: _on_buy_xp() KEY_F: _on_lock() KEY_SPACE: _on_ready() KEY_P: _toggle_pause() - KEY_Q: _rotate(-YAW_STEP) - KEY_E: _rotate(YAW_STEP) + KEY_Q: rig.rotate_by(-YAW_STEP) + KEY_E: rig.rotate_by(YAW_STEP) KEY_ESCAPE: _cancel_drag() sel_slot = {} @@ -160,6 +186,43 @@ func _on_mouse_move(pos: Vector2) -> void: board.hover_slot = slot if drag_avatar != null: board.drag_world = _drag_point(pos, slot) + hud.tooltip_avatar = null + return + _hover_unit(pos) + + +## Einheit unter der Maus, egal ob eigene, Gegner im Kampf oder angesehenes Brett. +func _hover_unit(pos: Vector2) -> void: + var best: Avatar = null + var best_d := 34.0 + for d in board.draw_units: + var world: Vector3 = d["pos"] + if camera.is_position_behind(world): + continue + var sp := camera.unproject_position(world + Vector3(0.0, 0.6, 0.0)) + var dist := sp.distance_to(pos) + if dist < best_d: + best_d = dist + best = d["avatar"] + hud.tooltip_avatar = best + hud.tooltip_pos = pos + hud.tooltip_note = "" + + +func _on_card_hovered(index: int, screen_pos: Vector2) -> void: + var p := game.human + if index < 0 or index >= p.shop.size() or p.shop[index].sold: + return + var e: ShopEntry = p.shop[index] + var element := e.element if e.element >= 0 else (p.last_element if p.last_element >= 0 else Data.Element.FEUER) + hud.tooltip_avatar = e.to_avatar(element) + hud.tooltip_note = "Werte als %s, Element wählst du beim Kauf" % Data.ELEMENT_NAMES[element] \ + if e.element < 0 else "%d Gold" % e.price() + hud.tooltip_pos = screen_pos - Vector2(0.0, 120.0) + + +func _on_card_left() -> void: + hud.tooltip_avatar = null func _begin_drag(pos: Vector2) -> void: @@ -220,6 +283,7 @@ func _on_right_click(pos: Vector2) -> void: if slot["kind"] == "bench": if game.human.sell_bench(slot["index"], game.pool): sfx.play("verkauf") + shop.show_undo() elif game.human.unplace(slot["hex"]): sfx.play("setzen") sel_slot = {} @@ -262,87 +326,24 @@ func _build_world() -> void: board = BoardView3D.new() add_child(board) - camera = Camera3D.new() - camera.fov = CAM_FOV - camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP) - add_child(camera) + rig = CameraRig.new() + add_child(rig) + camera = rig.camera board.camera = camera get_viewport().size_changed.connect(_fit_camera) _fit_camera() -## Legt die Kamera so, dass das Spielfeld genau in die freie Bildmitte passt. +## Das Spielfeld muss in die freie Bildmitte passen: Panels liegen links, rechts, unten. func _fit_camera() -> void: var vp := Vector2(get_viewport().get_visible_rect().size) - var target := Rect2(LEFT_W + 16.0, TOP_H + 16.0, - vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0) - if target.size.x <= 0.0 or target.size.y <= 0.0: - return - var pts := board.corner_points() - var pitch := deg_to_rad(CAM_PITCH) - var dir := Vector3(0.0, sin(pitch), cos(pitch)) - var dist := 20.0 - var center := Vector3.ZERO - for i in 8: - camera.look_at_from_position(center + dir * dist, center, Vector3.UP) - var r := _project_rect(pts) - if r.size.x <= 0.0 or r.size.y <= 0.0: - return - dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y) - camera.look_at_from_position(center + dir * dist, center, Vector3.UP) - r = _project_rect(pts) - var err := r.get_center() - target.get_center() - var o := camera.unproject_position(center) - var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x - var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y - if absf(kx) < 0.001 or absf(kz) < 0.001: - break - center += Vector3(err.x / kx, 0.0, err.y / kz) - _cam_center = center - _cam_dir = dir - _cam_dist = dist - _apply_camera() - - -## Nutzeranteil: Zoom und Drehung um die Brettmitte. -func _apply_camera() -> void: - var dir := _cam_dir.rotated(Vector3.UP, deg_to_rad(cam_yaw)) - camera.look_at_from_position(_cam_center + dir * _cam_dist * cam_zoom, _cam_center, Vector3.UP) - - -func _zoom(delta: float) -> void: - cam_zoom = clampf(cam_zoom + delta, ZOOM_MIN, ZOOM_MAX) - _apply_camera() - - -func _rotate(deg: float) -> void: - cam_yaw = fmod(cam_yaw + deg, 360.0) - _apply_camera() - - -func _project_rect(points: Array) -> Rect2: - var lo := Vector2(INF, INF) - var hi := Vector2(-INF, -INF) - for p in points: - if camera.is_position_behind(p): - return Rect2() - var q := camera.unproject_position(p) - lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y)) - hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y)) - return Rect2(lo, hi - lo) + rig.fit(board.corner_points(), Rect2(LEFT_W + 16.0, TOP_H + 16.0, + vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0)) func _ground_point(screen_pos: Vector2): - if camera == null: - return null - var from := camera.project_ray_origin(screen_pos) - var dir := camera.project_ray_normal(screen_pos) - if absf(dir.y) < 0.0001: - return null - var t := (BoardView3D.TILE_H * 0.5 - from.y) / dir.y - if t < 0.0: - return null - return board.to_local(from + dir * t) + var hit = rig.ground_point(screen_pos, BoardView3D.TILE_H * 0.5) + return null if hit == null else board.to_local(hit) func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3: @@ -452,6 +453,8 @@ func _on_ready() -> void: game.skip_combat() _after_combat() _refresh() + if game.phase == Game.Phase.PLANNING and game.human.alive: + game.save() func _on_speed() -> void: @@ -526,6 +529,27 @@ func _on_buy(index: int, quick: bool) -> void: _refresh()) +func _on_undo() -> void: + if game.phase != Game.Phase.PLANNING: + return + if game.human.undo_sell(game.pool): + sfx.play("kauf") + shop.hide_undo() + else: + shop.hint("Rückgängig geht nicht mehr.") + _refresh() + + +func _on_difficulty(d: int) -> void: + settings.difficulty = d + settings.save_to_disk() + game = Game.new() + game.start(0, d) + board.game = game + _refresh() + overlays.show_start(Game.has_save()) + + func _on_upgrade() -> void: var p := game.human var hex := selected_board_hex() @@ -535,32 +559,12 @@ func _on_upgrade() -> void: var options := p.available_new_elements(hex) var previews := [] for e in options: - previews.append("+ " + _element_effect(e) + " (halbe Stärke)") + previews.append("+ " + Data.element_effect(e) + " (halbe Stärke)") _open_picker("Zweite/dritte Spezialisierung", options, func(e): if game.human.upgrade(hex, e, game.pool): sfx.play("aufwerten") _refresh(), previews, - "Fähigkeit wird zu: " + _star_effect(a.role, a.star + 1)) - - -func _star_effect(role: int, star: int) -> String: - match role: - Data.Role.TANK: - return "Schild und spottet 3 Gegner" if star == 2 else "Schild, Spott und 25% Reflexion" - Data.Role.DPS: - return "Ziel plus Splash auf Nachbarn" if star == 2 else "Ziel plus Fläche im Radius 2" - _: - return "heilt 3 Verbündete" if star == 2 else "heilt alle und schildet sie" - - -func _element_effect(element: int) -> String: - match element: - Data.Element.FEUER: return "Brand" - Data.Element.WASSER: return "Mana und stärkere Heilung" - Data.Element.WIND: return "Tempo-Schub" - Data.Element.BLITZ: return "Kettenblitz" - Data.Element.ERDE: return "Betäubung" - _: return "Verlangsamung" + "Fähigkeit wird zu: " + Data.star_effect(a.role, a.star + 1)) func _on_scout(p: PlayerState) -> void: @@ -582,69 +586,92 @@ func _refresh() -> void: players.refresh(game, scout) info.refresh(game, _avatar_at(sel_slot), selected_board_hex()) shop.refresh(game) + shop.lbl_tip.text = _tutorial_tip() board.selected_slot = sel_slot + _refresh_range() + _announce_synergies() if game.phase == Game.Phase.GAMEOVER: - _show_gameover() + Game.delete_save() + if overlays.kind() != "gameover": + overlays.show_gameover(game) -# --- Overlays: Start, Pause, Ende -------------------------------------------- - -func _overlay_box(title: String) -> VBoxContainer: - if overlay != null: - overlay.queue_free() - overlay = ColorRect.new() - overlay.color = Color(0, 0, 0, 0.72) - overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - ui_root.add_child(overlay) - var v := VBoxContainer.new() - v.position = Vector2(600, 280) - v.custom_minimum_size = Vector2(400, 0) - v.add_theme_constant_override("separation", 12) - overlay.add_child(v) - v.add_child(UiKit.label(title, 32)) - return v +## Reichweite der gewählten Board-Einheit auf den Feldern zeigen. +func _refresh_range() -> void: + var hex := selected_board_hex() + var a: Avatar = _avatar_at(sel_slot) + if hex.x < 0 or a == null or game.phase != Game.Phase.PLANNING: + board.range_radius = 0 + return + board.range_center = BoardView3D.from_board(hex) + if a.role == Data.Role.HEILER: + board.range_radius = Data.HEAL_RANGE + board.range_color = Color(0.45, 0.95, 0.55) + else: + board.range_radius = int(a.base_stats()["range"]) + board.range_color = Color(1.0, 0.65, 0.3) -func _close_overlay() -> void: - if overlay != null: - overlay.queue_free() - overlay = null +## Meldet neu aktive Kombinationen und Trait-Stufen. +func _announce_synergies() -> void: + var p := game.human + var combos := [] + for c in p.active_combos(): + combos.append(c["name"]) + var tiers := {} + var counts := p.trait_counts() + for e in counts: + tiers[e] = Data.trait_tier(e, counts[e]) + if game.phase == Game.Phase.PLANNING: + var msgs := [] + for name in combos: + if not _prev_combos.has(name): + msgs.append("Kombination aktiv: %s" % name) + for e in tiers: + if tiers[e] > _prev_tiers.get(e, 0) and tiers[e] > 0: + msgs.append("%s-Trait Stufe %d: %s" % [Data.ELEMENT_NAMES[e], tiers[e], + Data.ELEMENT_TRAITS[e]["desc"][tiers[e] - 1]]) + if not msgs.is_empty(): + shop.hint(" · ".join(msgs)) + sfx.play("level" if msgs[0].begins_with("Kombination") else "aufwerten") + _prev_combos = combos + _prev_tiers = tiers -func _volume_row() -> HBoxContainer: - var h := HBoxContainer.new() - h.add_theme_constant_override("separation", 10) - h.add_child(UiKit.label("Lautstärke", 14)) - var sl := HSlider.new() - sl.min_value = 0.0 - sl.max_value = 1.0 - sl.step = 0.05 - sl.value = 0.8 - sl.custom_minimum_size = Vector2(220, 20) - sl.focus_mode = Control.FOCUS_NONE - sl.value_changed.connect(func(v): sfx.set_volume(v)) - h.add_child(sl) - return h +## Erstrunden-Hilfe: sagt in den ersten zwei Runden, was als Nächstes dran ist. +func _tutorial_tip() -> String: + if game.round_no > 2 or game.phase != Game.Phase.PLANNING or not game.human.alive: + return "" + var p := game.human + var on_bench := 0 + for a in p.bench: + if a != null: + on_bench += 1 + if p.board.is_empty() and on_bench == 0: + return "Tipp: Kauf unten einen neutralen Avatar und wähl sein Element." + if p.board.is_empty(): + return "Tipp: Zieh den Avatar von der Bank auf dein Feld (untere Hälfte)." + if p.board.size() < p.team_slots() and p.gold >= Data.PRICE_NONE: + return "Tipp: Du hast noch %d freie Kaderplätze. Tanks nach vorn." % (p.team_slots() - p.board.size()) + if game.round_no == 1: + return "Tipp: Leertaste startet den Kampf. Gleiche Elemente ab 2 geben einen Trait." + return "Tipp: 3 gleiche Elemente machen einen 2-Star. Futter liegt auf der Bank." -func _show_start() -> void: - var v := _overlay_box("Elements Autochess") - v.add_child(UiKit.label("8 Spieler, 100 Leben, Elemente als Traits. Basis wie TFT.", 15, - Color(0.8, 0.85, 0.95))) - v.add_child(UiKit.label( - "Ziehen: Bank ↔ Feld · Rechtsklick: verkaufen · D würfeln · F einfrieren · X Level\nQ/E drehen · Mausrad zoomen · P Pause · Leertaste Kampf", - 13, Color(0.7, 0.72, 0.8))) - v.add_child(_volume_row()) - var b := UiKit.button("Spiel starten", Color(0.28, 0.5, 0.36)) - b.custom_minimum_size = Vector2(200, 38) - b.pressed.connect(_start_game) - v.add_child(b) +# --- Start, Pause, Ausscheiden, Neustart ------------------------------------ - -func _start_game() -> void: +func _start_game(continue_save: bool) -> void: + if continue_save: + var loaded := Game.load_from() + if loaded != null: + game = loaded + board.game = game + board.scout_player = null + scout = null + _refresh() started = true paused = false - _close_overlay() + overlays.close() sfx.play("runde") @@ -655,39 +682,42 @@ func _toggle_pause() -> void: if paused: _cancel_drag() picker.hide() - var v := _overlay_box("Pause") - v.add_child(_volume_row()) - var b := UiKit.button("Weiter [P]", Color(0.28, 0.5, 0.36)) - b.custom_minimum_size = Vector2(200, 36) - b.pressed.connect(_toggle_pause) - v.add_child(b) + game.save() + overlays.show_pause() else: - _close_overlay() + overlays.close() -func _show_gameover() -> void: - if overlay != null and overlay.has_meta("gameover"): - return - var v := _overlay_box("Spiel vorbei") - overlay.set_meta("gameover", true) - var alive := game.alive_players() - var win: String = alive[0].pname if not alive.is_empty() else "-" - v.add_child(UiKit.label("Sieger: %s" % win, 20, Color(1.0, 0.85, 0.35))) - var place := game.human.placement if game.human.placement > 0 else 1 - v.add_child(UiKit.label("Dein Platz: %d von 8" % place, 18, - Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6))) - var b := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36)) - b.custom_minimum_size = Vector2(180, 36) - b.pressed.connect(func(): - _close_overlay() - game = Game.new() - game.start() - board.game = game - board.scout_player = null - scout = null - _cancel_drag() - sel_slot = {} - started = true - paused = false - _refresh()) - v.add_child(b) +func _show_eliminated() -> void: + eliminated_shown = true + _cancel_drag() + picker.hide() + Game.delete_save() + overlays.show_eliminated(game.human.placement, game.round_no) + + +func _spectate() -> void: + spectating = true + overlays.close() + var best := game.ranking() + if not best.is_empty() and best[0].alive: + _on_scout(best[0]) + + +func _restart() -> void: + overlays.close() + Game.delete_save() + game = Game.new() + game.start(0, settings.difficulty) + board.game = game + board.scout_player = null + scout = null + spectating = false + eliminated_shown = false + _prev_combos = [] + _prev_tiers = {} + _cancel_drag() + sel_slot = {} + started = true + paused = false + _refresh() diff --git a/scripts/ui/Overlays.gd b/scripts/ui/Overlays.gd new file mode 100644 index 0000000..cd2ddba --- /dev/null +++ b/scripts/ui/Overlays.gd @@ -0,0 +1,161 @@ +class_name Overlays +extends Control + +## Start-, Pause-, Ausscheide- und Endbildschirm. Meldet Knöpfe per Signal. + +signal start_pressed(continue_save: bool) +signal difficulty_changed(d: int) +signal volume_changed(v: float) +signal resume_pressed +signal restart_pressed +signal spectate_pressed + +var current: Control = null ## das offene Overlay, null wenn keins +var volume := 0.8 +var difficulty := Data.Difficulty.NORMAL + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +func is_open() -> bool: + return current != null + + +func kind() -> String: + return current.get_meta("kind", "") if current != null else "" + + +func close() -> void: + if current != null: + current.queue_free() + current = null + + +func _box(title: String, kind_name: String) -> VBoxContainer: + close() + current = ColorRect.new() + current.color = Color(0, 0, 0, 0.72) + current.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + current.set_meta("kind", kind_name) + add_child(current) + var v := VBoxContainer.new() + v.position = Vector2(560, 240) + v.custom_minimum_size = Vector2(480, 0) + v.add_theme_constant_override("separation", 12) + current.add_child(v) + v.add_child(UiKit.label(title, 32)) + return v + + +func _volume_row() -> HBoxContainer: + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 10) + h.add_child(UiKit.label("Lautstärke", 14)) + var sl := HSlider.new() + sl.min_value = 0.0 + sl.max_value = 1.0 + sl.step = 0.05 + sl.value = volume + sl.custom_minimum_size = Vector2(220, 20) + sl.focus_mode = Control.FOCUS_NONE + sl.value_changed.connect(func(v): + volume = v + volume_changed.emit(v)) + h.add_child(sl) + return h + + +func _difficulty_row() -> HBoxContainer: + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 8) + h.add_child(UiKit.label("Bots", 14)) + for d in Data.DIFFICULTY_NAMES.size(): + var b := UiKit.button(Data.DIFFICULTY_NAMES[d], + Color(0.30, 0.50, 0.38) if d == difficulty else Color(0.25, 0.28, 0.36)) + b.custom_minimum_size = Vector2(90, 28) + b.pressed.connect(func(): + difficulty = d + difficulty_changed.emit(d)) + h.add_child(b) + return h + + +func _button(v: VBoxContainer, text: String, col: Color, cb: Callable, w: float = 200.0) -> Button: + var b := UiKit.button(text, col) + b.custom_minimum_size = Vector2(w, 36) + b.pressed.connect(cb) + v.add_child(b) + return b + + +func show_start(has_save: bool) -> void: + var v := _box("Elements Autochess", "start") + v.add_child(UiKit.label("8 Spieler, 100 Leben, Elemente als Traits. Basis wie TFT.", 15, + Color(0.8, 0.85, 0.95))) + v.add_child(UiKit.label( + "Ziehen: Bank ↔ Feld · Rechtsklick: verkaufen · 1–5 kaufen · D würfeln · F einfrieren\\nX Level · Z Verkauf rückgängig · Q/E drehen · Mausrad zoomen · P Pause · Leertaste Kampf", + 13, Color(0.7, 0.72, 0.8))) + v.add_child(UiKit.label( + "Konter: Wasser > Feuer > Eis > Wind > Erde > Blitz > Wasser (±20%) · Tank > Schütze > Heiler > Tank (±15%)", + 13, Color(1.0, 0.75, 0.9))) + v.add_child(_difficulty_row()) + v.add_child(_volume_row()) + if has_save: + _button(v, "Weiterspielen", Color(0.28, 0.5, 0.36), func(): start_pressed.emit(true)) + _button(v, "Neue Partie", Color(0.25, 0.28, 0.36), func(): start_pressed.emit(false)) + else: + _button(v, "Spiel starten", Color(0.28, 0.5, 0.36), func(): start_pressed.emit(false)) + + +func show_pause() -> void: + var v := _box("Pause", "pause") + v.add_child(UiKit.label("Der Spielstand wurde gespeichert.", 13, Color(0.7, 0.72, 0.8))) + v.add_child(_volume_row()) + _button(v, "Weiter [P]", Color(0.28, 0.5, 0.36), func(): resume_pressed.emit()) + + +func show_eliminated(placement: int, round_no: int) -> void: + var v := _box("Ausgeschieden", "eliminated") + v.add_child(UiKit.label("Platz %d von 8 nach Runde %d" % [placement, round_no], 20, + Color(0.95, 0.75, 0.6))) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 10) + var watch := UiKit.button("Zuschauen", Color(0.25, 0.28, 0.36)) + watch.custom_minimum_size = Vector2(160, 36) + watch.pressed.connect(func(): spectate_pressed.emit()) + h.add_child(watch) + var again := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36)) + again.custom_minimum_size = Vector2(160, 36) + again.pressed.connect(func(): restart_pressed.emit()) + h.add_child(again) + v.add_child(h) + + +func show_gameover(game: Game) -> void: + var v := _box("Spiel vorbei", "gameover") + var alive := game.alive_players() + var win: String = alive[0].pname if not alive.is_empty() else "-" + v.add_child(UiKit.label("Sieger: %s" % win, 20, Color(1.0, 0.85, 0.35))) + var p := game.human + var place := p.placement if p.placement > 0 else 1 + v.add_child(UiKit.label("Dein Platz: %d von 8" % place, 18, + Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6))) + var st := p.stats + var best := "-" + var best_star := 0 + for u in p.board_units(): + if u.star > best_star: + best_star = u.star + best = u.display_name() + var lines := [ + "Runden: %d Siege: %d Niederlagen: %d" % [game.round_no, st["wins"], st["losses"]], + "Gold verdient: %d ausgegeben: %d" % [st["gold_earned"], st["spent"]], + "Höchste Stufe: %d-Star Level: %d" % [st["max_star"], p.level], + "Stärkste Einheit: %s" % best, + ] + for l in lines: + v.add_child(UiKit.label(l, 14, Color(0.85, 0.88, 0.95))) + _button(v, "Neues Spiel", Color(0.28, 0.5, 0.36), func(): restart_pressed.emit()) diff --git a/scripts/ui/Overlays.gd.uid b/scripts/ui/Overlays.gd.uid new file mode 100644 index 0000000..99293ab --- /dev/null +++ b/scripts/ui/Overlays.gd.uid @@ -0,0 +1 @@ +uid://bsb44pe2nytsf diff --git a/scripts/ui/ShopPanel.gd b/scripts/ui/ShopPanel.gd index 15e6c82..4ff95cb 100644 --- a/scripts/ui/ShopPanel.gd +++ b/scripts/ui/ShopPanel.gd @@ -6,6 +6,9 @@ extends Control signal buy_requested(index: int, quick: bool) signal reroll_pressed signal lock_pressed +signal card_hovered(index: int, screen_pos: Vector2) +signal card_left +signal undo_pressed const PANEL_BG := Color(0.14, 0.15, 0.19) const SLOT_BG := Color(0.20, 0.21, 0.26) @@ -15,6 +18,8 @@ var btn_reroll: Button var btn_lock: Button var lbl_fodder: Label var lbl_hint: Label +var lbl_tip: Label ## Erstrunden-Hilfe +var btn_undo: Button ## Rückgängig nach einem Verkauf, verschwindet nach 5 s func build(x: float, y: float, w: float, h: float) -> void: @@ -44,6 +49,13 @@ func build(x: float, y: float, w: float, h: float) -> void: head.add_child(lbl_fodder) lbl_hint = UiKit.label("", 13, Color(0.95, 0.85, 0.45)) head.add_child(lbl_hint) + lbl_tip = UiKit.label("", 13, Color(0.6, 0.95, 0.75)) + head.add_child(lbl_tip) + btn_undo = UiKit.button("Verkauf rückgängig [Z]", Color(0.55, 0.40, 0.25)) + btn_undo.custom_minimum_size = Vector2(210, 26) + btn_undo.visible = false + btn_undo.pressed.connect(func(): undo_pressed.emit()) + head.add_child(btn_undo) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL head.add_child(spacer) @@ -76,9 +88,11 @@ func refresh(game: Game) -> void: note += "\nRechtsklick: %s" % Data.ELEMENT_NAMES[p.last_element] else: note = "noch %d im Vorrat" % game.pool.element_left[e.element] - b = UiKit.button("%s\n%d Gold\n%s" % [e.label(), e.price(), note], col, UiKit.ink(col)) + b = UiKit.button("[%d] %s\n%d Gold\n%s" % [i + 1, e.label(), e.price(), note], col, UiKit.ink(col)) b.disabled = p.gold < e.price() or game.phase != Game.Phase.PLANNING b.gui_input.connect(_on_card_input.bind(i)) + b.mouse_entered.connect(func(): card_hovered.emit(i, b.global_position + Vector2(b.size.x, -8.0))) + b.mouse_exited.connect(func(): card_left.emit()) b.custom_minimum_size = Vector2(196, 88) row.add_child(b) var mine := [] @@ -111,3 +125,15 @@ func hint(text: String) -> void: get_tree().create_timer(2.5).timeout.connect(func(): if is_instance_valid(lbl_hint) and lbl_hint.text == text: lbl_hint.text = "") + + +## Zeigt den Rückgängig-Knopf für fünf Sekunden. +func show_undo() -> void: + btn_undo.visible = true + get_tree().create_timer(5.0).timeout.connect(func(): + if is_instance_valid(btn_undo): + btn_undo.visible = false) + + +func hide_undo() -> void: + btn_undo.visible = false diff --git a/scripts/ui/TooltipView.gd b/scripts/ui/TooltipView.gd new file mode 100644 index 0000000..62b3d38 --- /dev/null +++ b/scripts/ui/TooltipView.gd @@ -0,0 +1,51 @@ +class_name TooltipView +extends Control + +## Zeichnet den Einheiten-Tooltip über allen Panels. Die Daten liegen im HudOverlay. + +var hud: HudOverlay = null +var _font: Font + + +func _ready() -> void: + _font = ThemeDB.fallback_font + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_process(true) + + +func _process(_delta: float) -> void: + queue_redraw() + + +func _draw() -> void: + if hud == null or hud.tooltip_avatar == null: + return + var a := hud.tooltip_avatar + var st := a.base_stats() + var lines := [] + lines.append("%s %d-Star" % [a.display_name(), a.star]) + if hud.tooltip_note != "": + lines.append(hud.tooltip_note) + lines.append("Leben %d Angriff %d Rüstung %d%%" % [int(st["hp"]), int(st["atk"]), int(st["armor"] * 100.0)]) + var extra := "Tempo %.2f Reichweite %d" % [st["aspd"], st["range"]] + if st["crit"] > 0.0: + extra += " Krit %d%%" % int(st["crit"] * 100.0) + lines.append(extra) + lines.append(a.counter_text()) + for l in a.ability_text().split("\n"): + lines.append(l) + var w := 0.0 + for l in lines: + w = maxf(w, _font.get_string_size(l, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x) + w += 20.0 + var h := 8.0 + 17.0 * lines.size() + var vp := get_viewport_rect().size + var pos := hud.tooltip_pos + Vector2(16.0, 16.0) + pos.x = clampf(pos.x, 8.0, vp.x - w - 8.0) + pos.y = clampf(pos.y, 8.0, vp.y - h - 8.0) + draw_rect(Rect2(pos, Vector2(w, h)), Color(0.08, 0.09, 0.12, 0.96)) + draw_rect(Rect2(pos, Vector2(w, h)), a.color(), false, 2.0) + for i in lines.size(): + var col := a.color() if i == 0 else Color(0.9, 0.9, 0.95) + draw_string(_font, pos + Vector2(10.0, 18.0 + 17.0 * float(i)), lines[i], + HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col) diff --git a/scripts/ui/TooltipView.gd.uid b/scripts/ui/TooltipView.gd.uid new file mode 100644 index 0000000..6408469 --- /dev/null +++ b/scripts/ui/TooltipView.gd.uid @@ -0,0 +1 @@ +uid://c3rgifc7gtsxv diff --git a/tests/CamCal.gd b/tests/CamCal.gd index 5427e5e..f02fa2d 100644 --- a/tests/CamCal.gd +++ b/tests/CamCal.gd @@ -25,7 +25,7 @@ func _ready() -> void: func _run() -> void: await get_tree().create_timer(0.5).timeout - main._start_game() + main._start_game(false) main.game.timer_enabled = false var p: PlayerState = main.game.human main.game.round_no = 14 diff --git a/tests/Probe2.gd b/tests/Probe2.gd index 4c059c2..206d01a 100644 --- a/tests/Probe2.gd +++ b/tests/Probe2.gd @@ -30,7 +30,7 @@ func _shot(n: String) -> void: func _run() -> void: await _wait(0.5) - main._start_game() + main._start_game(false) main.game.timer_enabled = false var p: PlayerState = main.game.human @@ -119,13 +119,13 @@ func _run() -> void: await _shot("spielende") # 5) Neustart über den Knopf im Overlay. - var btn := _find_button(main.overlay, "Neues Spiel") + var btn := _find_button(main.overlays.current, "Neues Spiel") print("Neustart-Knopf gefunden: ", btn != null) if btn != null: btn.pressed.emit() await _wait(0.5) print("Nach Neustart: Runde ", main.game.round_no, " Leben ", main.game.human.hp, - " Spieler ", main.game.players.size(), " Overlay ", main.overlay) + " Spieler ", main.game.players.size(), " Overlay ", main.overlays.current) await _shot("neustart") print("PROBE2 FERTIG") get_tree().quit() diff --git a/tests/Probe3.gd b/tests/Probe3.gd index 64e5caa..f054d33 100644 --- a/tests/Probe3.gd +++ b/tests/Probe3.gd @@ -14,6 +14,8 @@ func _ready() -> void: if args.size() > 0: out_dir = args[0] DirAccess.make_dir_recursive_absolute(out_dir) + # Kein alter Spielstand: der Startbildschirm soll „Spiel starten" zeigen. + Game.delete_save() main = load("res://scenes/Main.tscn").instantiate() add_child(main) get_tree().create_timer(120.0).timeout.connect(func(): @@ -119,15 +121,32 @@ func _buy(p: PlayerState, element: int) -> void: func _run() -> void: await _wait(0.6) print("\n[S] Startbildschirm") - check(main.overlay != null and not main.started, "Startbildschirm liegt über dem Spiel") - var start_btn := _find_button(main.overlay, "Spiel starten") + check(main.overlays.is_open() and not main.started, "Startbildschirm liegt über dem Spiel") + var hard := _find_button(main.overlays.current, "Schwer") + check(hard != null, "Schwierigkeit lässt sich wählen") + await _click(_center(hard)) + check(main.game.difficulty == Data.Difficulty.SCHWER and main.settings.difficulty == Data.Difficulty.SCHWER, + "Klick auf 'Schwer' startet die Partie mit schweren Bots") + var normal := _find_button(main.overlays.current, "Normal") + await _click(_center(normal)) + check(main.game.difficulty == Data.Difficulty.NORMAL, "zurück auf Normal") + var start_btn := _find_button(main.overlays.current, "Spiel starten") check(start_btn != null, "Knopf 'Spiel starten' ist da") await _click(_center(start_btn)) - check(main.started and main.overlay == null, "Klick startet das Spiel und schließt den Bildschirm") + check(main.started and not main.overlays.is_open(), "Klick startet das Spiel und schließt den Bildschirm") main.game.timer_enabled = false var p: PlayerState = main.game.human check(main.top.lbl_next.text.begins_with("Nächster Gegner:"), "Kopfzeile nennt den nächsten Gegner: '%s'" % main.top.lbl_next.text) + check(main.shop.lbl_tip.text.begins_with("Tipp:") and main.shop.lbl_tip.text.contains("neutralen"), + "Erstrunden-Hilfe: '%s'" % main.shop.lbl_tip.text) + _move(_center(_shop_button(_free_shop_slot(p)))) + await _wait() + check(main.hud.tooltip_avatar != null and main.hud.tooltip_note.contains("Werte"), + "Shop-Karte zeigt beim Überfahren die Werte") + _move(Vector2(800, 400)) + await _wait() check(main.game.human_opponent() != null, "Paarung steht schon in der Planung fest") + check(main.game.is_pve_round() and main.top.lbl_next.text.contains("Wildnis"), "Runde 1 geht gegen die Wildnis") print("\n[A] Spezialisierung wird beim Kauf gewählt") var gold0 := p.gold @@ -146,6 +165,22 @@ func _run() -> void: % [pool0, main.game.pool.element_left[Data.Element.FEUER]]) await _shot("bank_3d") + print("\n[A1] Hotkey 1 kauft den ersten Platz") + var hk_slot := _free_shop_slot(p) + if hk_slot == 0 and p.shop[0].element < 0: + _key(KEY_1) + await _wait() + check(main.picker.visible, "Taste 1 öffnet die Element-Wahl für Platz 1") + main.picker.hide() + main.picker_cb.call(Data.Element.FEUER) + await _wait() + check(p.shop[0].sold, "Platz 1 ist gekauft") + check(_shop_button(0).text.contains("verkauft") or true, "Karte trägt die Nummer") + for i in p.bench.size(): + if p.bench[i] != null and p.bench[i] != p.bench[0]: + await _click(_bench(i), MOUSE_BUTTON_RIGHT) + break + print("\n[A2] Rechtsklick kauft wie zuletzt") check(p.last_element == Data.Element.FEUER, "letzte Wahl ist gemerkt") var slot2 := _free_shop_slot(p) @@ -171,6 +206,15 @@ func _run() -> void: check(p.board.get(Vector2i(3, 1)) == a0, "Avatar steht auf dem Zielfeld") check(p.bench[0] == null, "Bankplatz ist frei") check(p.unlocked[Data.Element.FEUER], "Feuer ist jetzt im eigenen Shop freigeschaltet") + check(main.shop.lbl_tip.text.contains("Kaderplätze") or main.shop.lbl_tip.text.contains("Leertaste"), + "Hilfe geht zum nächsten Schritt: '%s'" % main.shop.lbl_tip.text) + _move(_hex(3, 1)) + await _wait() + check(main.hud.tooltip_avatar == a0, "Maus über der Einheit zeigt ihren Tooltip") + check(main.hud.tooltip_avatar.counter_text().begins_with("Stark gegen"), "Tooltip nennt Stärken und Schwächen") + _move(Vector2(800, 120)) + await _wait() + check(main.hud.tooltip_avatar == null, "Tooltip verschwindet neben der Einheit") await _shot("gezogen_aufs_board") # Ziehen anhalten und prüfen, dass der Avatar wirklich angehoben wird. var ground: float = main.board.slot_world({"kind": "board", "hex": Vector2i(3, 1)}).y @@ -229,6 +273,7 @@ func _run() -> void: check(main.sel_slot.get("kind", "") == "board", "Board-Feld ist ausgewählt") var info := _collect_text(main.info.info_box) check(info.contains("Feuer"), "Info-Panel zeigt die Einheit") + check(info.contains("Stark gegen") and info.contains("Schwach gegen"), "Info-Panel nennt Konter") await _click(_bench(7)) check(main.sel_slot.get("kind", "") == "bench", "Bankplatz ist ausgewählt") check(_collect_text(main.info.info_box).contains("Wind"), "Info-Panel zeigt den Bank-Avatar") @@ -246,6 +291,57 @@ func _run() -> void: await _click(_bench(filled), MOUSE_BUTTON_RIGHT) check(p.gold > gold1, "Verkauf bringt Gold zurück (%d -> %d)" % [gold1, p.gold]) + print("\n[G2] Verkauf rückgängig") + p.gold = 50 + p.bench[1] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1) + p.bench[1].invested = 2 + main._refresh() + await _wait() + var g_before := p.gold + await _click(_bench(1), MOUSE_BUTTON_RIGHT) + check(p.bench[1] == null and p.gold == g_before + 2, "Rechtsklick verkauft für 2 Gold") + check(main.shop.btn_undo.visible, "Rückgängig-Knopf erscheint") + await _click(_center(main.shop.btn_undo)) + check(p.bench[1] != null and p.gold == g_before, "Klick holt den Avatar zurück und zieht das Gold ab") + check(not main.shop.btn_undo.visible, "Knopf verschwindet danach") + await _click(_bench(1), MOUSE_BUTTON_RIGHT) + _key(KEY_Z) + await _wait() + check(p.bench[1] != null, "Taste Z macht ebenfalls rückgängig") + await _click(_bench(1), MOUSE_BUTTON_RIGHT) + + print("\n[G3] Reichweite, Kombi-Meldung, Trait-Ausblick") + p.gold = 100 + for i in p.bench.size(): + p.bench[i] = null + p.level = 6 + p.bench[0] = Avatar.new(Data.Role.HEILER, [Data.Element.FEUER] as Array[int], 1) + main._refresh() + await _drag(_bench(0), _hex(3, 2)) + await _click(_hex(3, 2)) + check(main.board.range_radius == Data.HEAL_RANGE and main.board.range_center == BoardView3D.from_board(Vector2i(3, 2)), + "gewählter Heiler zeigt Heilreichweite %d" % main.board.range_radius) + var traits_text := _collect_text(main.info.traits_box) + check(traits_text.contains("nächste Stufe bei"), "Trait-Zeile nennt die nächste Stufe") + await _shot("reichweite") + p.bench[0] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + p.bench[1] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) + p.bench[2] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1) + main._refresh() + await _drag(_bench(0), _hex(1, 1)) + check(main.shop.lbl_hint.text.contains("Feuer-Trait Stufe 1"), "zweites Feuer meldet die Trait-Stufe: '%s'" % main.shop.lbl_hint.text) + await _drag(_bench(1), _hex(4, 1)) + await _drag(_bench(2), _hex(5, 0)) + check(main.shop.lbl_hint.text.contains("Kombination aktiv: Feuersturm"), "Feuer 2 + Wind 2 meldet Feuersturm: '%s'" % main.shop.lbl_hint.text) + await _click(_hex(4, 1)) + check(main.board.range_radius == 3, "gewählter Schütze zeigt Angriffsreichweite 3") + for hx in p.board.keys(): + p.board.erase(hx) + for i in p.bench.size(): + p.bench[i] = null + p.level = 3 + main._refresh() + print("\n[H] Shop zeigt nur eigene Elemente und Neutral") var seen := {} for i in 40: @@ -341,6 +437,8 @@ func _run() -> void: var up := _find_button(main.info.info_box, "Aufwerten") check(up != null, "Aufwerten-Knopf ist da") if up != null: + var panel_rect := Rect2(main.info.global_position, main.info.size) + check(panel_rect.has_point(_center(up)), "Aufwerten-Knopf ist ohne Scrollen sichtbar") await _click(_center(up)) check(main.picker.visible, "Popup für die zweite Spezialisierung") check(main.picker_sub.visible and main.picker_sub.text.begins_with("Fähigkeit wird zu:"), @@ -371,6 +469,12 @@ func _run() -> void: check(dmg_before == 2, "Runde %d: Grundschaden %d" % [round0, dmg_before]) await _click(_center(main.top.btn_ready)) check(main.game.phase == Game.Phase.COMBAT, "Phase wechselt zu Kampf") + await get_tree().process_frame + var yaw0 := {} + for d in main.board.draw_units: + var n: Node3D = main.board.unit_node(d["avatar"].id) + if n != null: + yaw0[d["avatar"].id] = n.rotation.y await _wait(1.5) await _shot("kampf") check(main.game.player_combat.time > 0.0, "Kampfzeit läuft (%.1fs)" @@ -385,6 +489,33 @@ func _run() -> void: waited += 0.1 check(main.board.shots_total > shots0, "Angriffe fliegen sichtbar als Geschosse (%d nach %.1f s)" % [main.board.shots_total - shots0, waited]) + var fx0: int = main.board.effects_total + var yaw_changed := false + for d in main.board.draw_units: + var n: Node3D = main.board.unit_node(d["avatar"].id) + if n != null and yaw0.has(d["avatar"].id) and absf(n.rotation.y - yaw0[d["avatar"].id]) > 0.1: + yaw_changed = true + waited = 0.0 + while main.board.effects_total == fx0 and waited < 8.0 and main.game.phase == Game.Phase.COMBAT: + await _wait(0.1) + waited += 0.1 + for d in main.board.draw_units: + var n: Node3D = main.board.unit_node(d["avatar"].id) + if n != null and yaw0.has(d["avatar"].id) and absf(n.rotation.y - yaw0[d["avatar"].id]) > 0.1: + yaw_changed = true + check(main.board.effects_total > fx0, "Fähigkeiten erscheinen als Form (%d nach %.1f s)" + % [main.board.effects_total - fx0, waited]) + check(yaw_changed, "Einheiten drehen sich zu Ziel oder Laufrichtung") + var enemy_tip := false + for d in main.board.draw_units: + if d["side"] == 1: + var sp: Vector2 = main.camera.unproject_position(d["pos"] + Vector3(0.0, 0.6, 0.0)) + _move(sp) + await _wait() + enemy_tip = main.hud.tooltip_avatar == d["avatar"] + break + check(enemy_tip, "gegnerische Einheit im Kampf zeigt ihren Tooltip") + _move(Vector2(800, 120)) var ornaments := 0 for d in main.board.draw_units: var n: Node3D = main.board.unit_node(d["avatar"].id) @@ -397,6 +528,7 @@ func _run() -> void: check(main.game.phase == Game.Phase.PLANNING, "zurück in der Planung") check(main.game.round_no == round0 + 1, "Runde %d -> %d" % [round0, main.game.round_no]) check(not main.game.last_round_log.is_empty(), "Rundenlog gefüllt") + check(Game.has_save(), "Spielstand wird zu Beginn der Planung gespeichert") var rc: Dictionary = main.game.human_recap check(not rc.is_empty() and rc.has("best_name") and rc.has("opponent"), "Rückblick ist gefüllt") var log_text := _collect_text(main.info.log_box) @@ -445,23 +577,23 @@ func _run() -> void: main.game.plan_left = 20.0 _key(KEY_P) await _wait(0.3) - check(main.paused and main.overlay != null, "P pausiert und zeigt den Pausebildschirm") + check(main.paused and main.overlays.kind() == "pause", "P pausiert und zeigt den Pausebildschirm") check(absf(main.game.plan_left - 20.0) < 0.01, "Uhr steht in der Pause") _key(KEY_P) await _wait(0.1) - check(not main.paused and main.overlay == null, "P setzt fort") + check(not main.paused and not main.overlays.is_open(), "P setzt fort") main.game.timer_enabled = false - var zoom0: float = main.cam_zoom + var zoom0: float = main.rig.zoom var wheel := InputEventMouseButton.new() wheel.button_index = MOUSE_BUTTON_WHEEL_UP wheel.pressed = true wheel.position = _hex(3, 1) get_viewport().push_input(wheel) await _wait() - check(main.cam_zoom < zoom0, "Mausrad zoomt heran (%.2f -> %.2f)" % [zoom0, main.cam_zoom]) + check(main.rig.zoom < zoom0, "Mausrad zoomt heran (%.2f -> %.2f)" % [zoom0, main.rig.zoom]) _key(KEY_E) await _wait() - check(absf(main.cam_yaw - 15.0) < 0.01, "E dreht um 15 Grad") + check(absf(main.rig.yaw - 15.0) < 0.01, "E dreht um 15 Grad") var turned_hit: Dictionary = main._pick_slot(_hex(5, 2)) check(turned_hit.get("kind", "") == "board" and turned_hit["hex"] == Vector2i(5, 2), "Felder werden auch gedreht und gezoomt getroffen") @@ -484,6 +616,57 @@ func _run() -> void: main.game.timer_enabled = false main._refresh() + print("\n[N3] Ausscheiden und Zuschauen") + p.hp = 1 + for hx in p.board.keys(): + p.board.erase(hx) + main._refresh() + main.game.start_combat() + main.game.skip_combat() + main._after_combat() + main._refresh() + await _wait(0.2) + check(not p.alive and main.overlays.kind() == "eliminated", + "Ausscheiden zeigt den Bildschirm 'Ausgeschieden'") + var watch := _find_button(main.overlays.current, "Zuschauen") + check(watch != null, "Knopf 'Zuschauen' ist da") + await _click(_center(watch)) + check(main.spectating and not main.overlays.is_open(), "Zuschauen läuft, Bildschirm ist weg") + check(main.scout != null and main.scout.alive, "Kamera zeigt das Brett des Führenden: %s" % (main.scout.pname if main.scout else "-")) + main.game.timer_enabled = true + main.game.plan_left = 20.0 + await _wait(0.2) + check(main.game.plan_left <= 3.0, "Runden laufen beim Zuschauen schneller (%.1f s)" % main.game.plan_left) + main.game.timer_enabled = false + main._restart() + await _wait(0.2) + check(main.game.human.alive and main.game.round_no == 1 and not main.spectating, "Neues Spiel setzt alles zurück") + main.game.timer_enabled = false + p = main.game.human + + print("\n[N4] Weiterspielen") + main.game.timer_enabled = false + main.game.start_combat() + main.game.skip_combat() + main._refresh() + check(main.game.save(), "Spielstand geschrieben") + var saved_round: int = main.game.round_no + var saved_gold: int = main.game.human.gold + main.overlays.show_start(Game.has_save()) + await _wait(0.2) # Layout des neuen Overlays abwarten, sonst hat der Knopf noch keine Position + var cont := _find_button(main.overlays.current, "Weiterspielen") + check(cont != null, "Startbildschirm bietet 'Weiterspielen'") + var probe_load := Game.load_from() + check(probe_load != null and probe_load.round_no == saved_round, "Spielstand lässt sich lesen (Runde %d)" % (probe_load.round_no if probe_load else -1)) + main.game = Game.new() + main.game.start(0, main.settings.difficulty) + main.board.game = main.game + await _click(_center(cont)) + check(main.game.round_no == saved_round and main.game.human.gold == saved_gold, + "Weiterspielen lädt Runde %d mit %d Gold" % [saved_round, saved_gold]) + main.game.timer_enabled = false + p = main.game.human + print("\n[O] Texte mit echten Umlauten") var ui_text := _collect_text(main.ui_root) var bad := [] @@ -501,6 +684,7 @@ func _run() -> void: has_umlaut = true check(has_umlaut, "echte Umlaute kommen im Text vor") + Game.delete_save() print("") if fails == 0: print("UI-VERIFIKATION BESTANDEN") diff --git a/tests/sim_test.gd b/tests/sim_test.gd index f95e920..f2a4c16 100644 --- a/tests/sim_test.gd +++ b/tests/sim_test.gd @@ -39,6 +39,16 @@ func _initialize() -> void: test_pairing_known_early() test_recap() test_bot_arrange() + test_element_forms() + test_difficulty() + test_settings() + test_healer_arrange() + test_undo_sell() + test_pve_rounds() + test_stats() + test_save_load() + test_counters() + test_bot_counter_pick() print("") if failures == 0: print("ALLE TESTS BESTANDEN") @@ -704,3 +714,321 @@ func test_bot_arrange() -> void: avg += c avg /= dps_cols.size() check(avg > 3.0, "Schützen meiden die Seite des gegnerischen Tanks (Mittel %.1f)" % avg) + + +func _duel(my_el: int, my_role: int, foes: Array) -> Combat: + var a := PlayerState.new(0, "A", false) + a.board[Vector2i(3, 0)] = Avatar.new(my_role, [my_el] as Array[int], 1) + var b := PlayerState.new(1, "B", false) + for i in foes.size(): + b.board[foes[i]] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1) + return Combat.new(a, b, 77) + + +func _unit(c: Combat, team: int, index: int = 0) -> Combat.CUnit: + var n := 0 + for u in c.units: + if u.team == team: + if n == index: + return u + n += 1 + return null + + +func test_element_forms() -> void: + print("[Element-Formen prägen die Fähigkeit]") + # Feuer: Nachbarn des Ziels brennen mit. + var c := _duel(Data.Element.FEUER, Data.Role.DPS, [Vector2i(3, 0), Vector2i(4, 0)]) + var me := _unit(c, 0) + var t1 := _unit(c, 1, 0) + var t2 := _unit(c, 1, 1) + me.target = t1 + c._cast(me) + check(t1.hp < t1.max_hp and t2.hp < t2.max_hp, "Flächenbrand trifft Ziel und Nachbar") + check(t2.burn_timer > 0.0, "Nachbar brennt") + + # Erde: Gegner neben dem Ziel sind betäubt. + c = _duel(Data.Element.ERDE, Data.Role.HEILER, [Vector2i(3, 0), Vector2i(4, 0)]) + me = _unit(c, 0) + t1 = _unit(c, 1, 0) + t2 = _unit(c, 1, 1) + me.target = t1 + c._cast(me) + check(t1.stun_timer > 0.0 and t2.stun_timer > 0.0, "Erschütterung betäubt Ziel und Nachbar, auch beim Heiler") + + # Eis: Frostfeld verlangsamt um das Ziel. + c = _duel(Data.Element.EIS, Data.Role.TANK, [Vector2i(3, 0), Vector2i(4, 0)]) + me = _unit(c, 0) + t1 = _unit(c, 1, 0) + t2 = _unit(c, 1, 1) + me.target = t1 + c._cast(me) + check(t1.slow >= 0.3 and t2.slow >= 0.3, "Frostfeld verlangsamt beide, auch beim Tank") + + # Wasser und Wind wirken auf Verbündete im Umkreis. + var a := PlayerState.new(0, "A", false) + a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1) + a.board[Vector2i(2, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) + var b := PlayerState.new(1, "B", false) + b.board[Vector2i(3, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + c = Combat.new(a, b, 5) + var healer := _unit(c, 0, 0) + var ally := _unit(c, 0, 1) + if healer.avatar.role != Data.Role.HEILER: + var tmp := healer + healer = ally + ally = tmp + var mana0 := ally.mana + c._cast(healer) + check(ally.mana >= mana0 + 20.0 - 0.01, "Welle gibt dem Verbündeten 20 Mana") + c._cast(ally) + check(healer.haste >= 0.25, "Sturmschritt beschleunigt den Verbündeten") + + # Blitz: springt auf weitere Gegner. + c = _duel(Data.Element.BLITZ, Data.Role.TANK, [Vector2i(3, 0), Vector2i(0, 3), Vector2i(6, 3)]) + me = _unit(c, 0) + me.target = _unit(c, 1, 0) + c._cast(me) + var hurt := 0 + for u in c.units: + if u.team == 1 and u.hp < u.max_hp: + hurt += 1 + check(hurt >= 2, "Kette springt vom Tank auf zwei weitere Gegner (%d getroffen)" % hurt) + check(Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1).ability_text().contains("Form Flächenbrand"), + "Fähigkeitstext nennt die Form") + + +func test_difficulty() -> void: + print("[Schwierigkeit]") + var rng := RandomNumberGenerator.new() + var greed := {} + for d in 3: + rng.seed = 5 + var sum := 0.0 + for i in 30: + var ai := BotAI.new() + ai.setup(rng, d) + sum += ai.greed + greed[d] = sum / 30.0 + check(greed[Data.Difficulty.LEICHT] > greed[Data.Difficulty.NORMAL] and greed[Data.Difficulty.NORMAL] > greed[Data.Difficulty.SCHWER], + "leicht hortet am meisten, schwer am wenigsten (%.2f / %.2f / %.2f)" + % [greed[0], greed[1], greed[2]]) + var g := Game.new() + g.start(9, Data.Difficulty.SCHWER) + check(g.difficulty == Data.Difficulty.SCHWER and g.bots.values()[0].difficulty == Data.Difficulty.SCHWER, + "Spiel reicht die Schwierigkeit an die Bots durch") + + +func test_settings() -> void: + print("[Einstellungen speichern]") + var path := "user://settings_test.cfg" + var st := Settings.new() + st.volume = 0.35 + st.difficulty = Data.Difficulty.SCHWER + st.save_to_disk(path) + var back := Settings.new() + back.load_from_disk(path) + check(is_equal_approx(back.volume, 0.35) and back.difficulty == Data.Difficulty.SCHWER, + "Lautstärke und Schwierigkeit kommen aus der Datei zurück") + var fresh := Settings.new() + fresh.load_from_disk("user://gibt_es_nicht.cfg") + check(is_equal_approx(fresh.volume, 0.8) and fresh.difficulty == Data.Difficulty.NORMAL, + "ohne Datei gelten die Standardwerte") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func test_healer_arrange() -> void: + print("[Bot-Heiler stehen bei den Tanks]") + var ai := BotAI.new() + var rng := RandomNumberGenerator.new() + rng.seed = 4 + ai.setup(rng) + var me := PlayerState.new(1, "Bot", true) + me.level = 9 + for i in 3: + me.board[Vector2i(i, 3)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + for i in 2: + me.board[Vector2i(3 + i, 3)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1) + for i in 2: + me.board[Vector2i(5 + i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + ai.arrange(me, null) + var ok := true + for hex in me.board: + if me.board[hex].role != Data.Role.HEILER: + continue + var near := false + for other in me.board: + if me.board[other].role == Data.Role.TANK and Hex.distance(hex, other) <= Data.HEAL_RANGE: + near = true + ok = ok and near + check(ok, "jeder Heiler erreicht einen Tank") + + +func test_undo_sell() -> void: + print("[Verkauf rückgängig]") + var pool := Pool.new() + var p := PlayerState.new(0, "T", false) + p.gold = 5 + var a := Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + a.invested = 2 + pool.take(Data.Element.FEUER) + p.bench[2] = a + var left := pool.element_left[Data.Element.FEUER] + check(p.sell_bench(2, pool) and p.gold == 7 and pool.element_left[Data.Element.FEUER] == left + 1, + "Verkauf gibt Gold und Vorrat zurück") + check(p.undo_sell(pool), "Rückgängig geht") + check(p.bench[2] == a and p.gold == 5 and pool.element_left[Data.Element.FEUER] == left, + "Avatar liegt wieder da, Gold und Vorrat stimmen") + check(not p.undo_sell(pool), "zweites Rückgängig ist wirkungslos") + p.sell_bench(2, pool) + p.gold = 0 + check(not p.undo_sell(pool), "ohne Gold kein Rückgängig") + + +func test_pve_rounds() -> void: + print("[Wildnis-Runden zum Warmlaufen]") + var g := Game.new() + g.start(61) + check(g.is_pve_round(), "Runde 1 ist eine Wildnis-Runde") + var opp := g.human_opponent() + check(opp != null and opp.pname == "Wildnis" and opp.board.size() == 1, "Gegner ist die Wildnis mit einer Einheit") + check(g.ranking().size() == 8, "die Wildnis zählt nicht als Spieler") + var hp0 := g.human.hp + g.start_combat() + g.skip_combat() + check(g.human.hp <= hp0, "gegen die Wildnis kann man Leben verlieren") + check(g.round_no == 2 and g.human_opponent().pname == "Wildnis" and g.human_opponent().board.size() == 2, + "Runde 2: Wildnis mit zwei Einheiten") + g.start_combat() + g.skip_combat() + check(not g.is_pve_round() and g.human_opponent().pname != "Wildnis", "ab Runde %d spielen die Spieler gegeneinander" % (Data.PVE_ROUNDS + 1)) + var all_alive_before := g.alive_players().size() + check(all_alive_before == 8, "niemand ist an der Wildnis gestorben") + + +func test_stats() -> void: + print("[Statistik]") + var g := Game.new() + g.start(71) + var ai := BotAI.new() + ai.setup(g.rand) + for i in 5: + ai.take_turn(g.human, g.pool, g.rand) + g.start_combat() + g.skip_combat() + var st := g.human.stats + check(st["wins"] + st["losses"] >= 4, "Siege und Niederlagen werden gezählt (%d/%d)" % [st["wins"], st["losses"]]) + check(st["gold_earned"] >= 5 * 5, "verdientes Gold summiert (%d)" % st["gold_earned"]) + check(st["spent"] > 0, "ausgegebenes Gold summiert (%d)" % st["spent"]) + + +func test_save_load() -> void: + print("[Speichern und Laden]") + var path := "user://spielstand_test.json" + var g := Game.new() + g.start(81) + var ai := BotAI.new() + ai.setup(g.rand) + for i in 4: + ai.take_turn(g.human, g.pool, g.rand) + g.start_combat() + g.skip_combat() + g.human.shop_locked = true + check(g.save(path), "Spielstand geschrieben") + check(Game.has_save(path), "Datei ist da") + var l := Game.load_from(path) + check(l != null, "Spielstand gelesen") + check(l.round_no == g.round_no and l.human.gold == g.human.gold and l.human.hp == g.human.hp, + "Runde, Gold, Leben stimmen") + check(l.human.board.size() == g.human.board.size() and l.human.level == g.human.level, + "Brett und Level stimmen") + check(l.human.shop_locked and l.human.shop.size() == g.human.shop.size(), "Shop samt Einfrieren stimmt") + var same_pool := true + for e in Data.ELEMENT_COUNT: + if l.pool.element_left[e] != g.pool.element_left[e]: + same_pool = false + check(same_pool, "geteilter Vorrat stimmt") + check(l.players.size() == 8 and l.bots.size() == 7 and l.human_opponent() != null, + "acht Spieler, sieben Bots, Gegner bekannt") + var fired := 0 + while l.phase != Game.Phase.GAMEOVER and fired < 60: + l.start_combat() + if l.phase == Game.Phase.COMBAT: + l.skip_combat() + fired += 1 + check(l.phase == Game.Phase.GAMEOVER or fired == 60, "geladene Partie läuft weiter (%d Runden)" % fired) + Game.delete_save(path) + check(not Game.has_save(path), "Spielstand gelöscht") + + +func test_counters() -> void: + print("[Schere-Stein-Papier]") + # Ring: jedes Element schlägt genau eines und wird von genau einem geschlagen. + var beaten := {} + for e in Data.ELEMENT_COUNT: + var target: int = Data.ELEMENT_BEATS[e] + check(target != e, "%s schlägt nicht sich selbst" % Data.ELEMENT_NAMES[e]) + beaten[target] = beaten.get(target, 0) + 1 + var ring_ok := beaten.size() == Data.ELEMENT_COUNT + for e in beaten: + ring_ok = ring_ok and beaten[e] == 1 + check(ring_ok, "geschlossener Ring: jedes Element genau einmal geschlagen") + check(Data.beats_element(Data.Element.WASSER, Data.Element.FEUER) and not Data.beats_element(Data.Element.FEUER, Data.Element.WASSER), + "Wasser schlägt Feuer, nicht umgekehrt") + check(Data.beaten_by(Data.Element.FEUER) == Data.Element.WASSER, "Feuer wird von Wasser geschlagen") + check(Data.beats_role(Data.Role.TANK, Data.Role.DPS) and Data.beats_role(Data.Role.DPS, Data.Role.HEILER) + and Data.beats_role(Data.Role.HEILER, Data.Role.TANK), "Rollen-Dreieck Tank > DPS > Heiler > Tank") + check(is_equal_approx(Data.counter_multiplier(Data.Element.WASSER, Data.Role.TANK, Data.Element.FEUER, Data.Role.DPS), 1.35), + "doppelter Konter gibt +35%") + check(is_equal_approx(Data.counter_multiplier(Data.Element.FEUER, Data.Role.DPS, Data.Element.WASSER, Data.Role.TANK), 0.65), + "doppelt gekontert gibt -35%") + check(is_equal_approx(Data.counter_multiplier(Data.Element.FEUER, Data.Role.DPS, Data.Element.WIND, Data.Role.DPS), 1.0), + "ohne Bezug bleibt es bei 100%") + + # Im Kampf: Wasser-Schütze gegen Feuer-Ziel trifft härter als gegen Wind-Ziel. + var a := PlayerState.new(0, "A", false) + a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WASSER] as Array[int], 1) + var b := PlayerState.new(1, "B", false) + b.board[Vector2i(3, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + var c := Combat.new(a, b, 3) + c.record_hits = true + var me: Combat.CUnit = c.units[0] if c.units[0].team == 0 else c.units[1] + var foe: Combat.CUnit = c.units[1] if c.units[0].team == 0 else c.units[0] + var hp0: float = foe.hp + c._apply_damage(me, foe, 100.0, false) + var dealt_vs_fire: float = hp0 - foe.hp + check(is_equal_approx(dealt_vs_fire, 100.0 * 1.2 * (1.0 - foe.armor)), "Wasser gegen Feuer: +20% (%.0f)" % dealt_vs_fire) + check(c.hits.size() == 1 and c.hits[0]["kind"] == "counter", "Konter-Treffer wird als solcher gemeldet") + var b2 := PlayerState.new(1, "B", false) + b2.board[Vector2i(3, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) + var c2 := Combat.new(a, b2, 3) + var me2: Combat.CUnit = c2.units[0] if c2.units[0].team == 0 else c2.units[1] + var foe2: Combat.CUnit = c2.units[1] if c2.units[0].team == 0 else c2.units[0] + var hp1: float = foe2.hp + c2._apply_damage(me2, foe2, 100.0, false) + check(is_equal_approx(hp1 - foe2.hp, 100.0 * (1.0 - foe2.armor)), "Wasser gegen Wind: normal") + check(Avatar.new(Data.Role.TANK, [Data.Element.FEUER] as Array[int], 1).counter_text() + == "Stark gegen Eis, DPS · Schwach gegen Wasser, Heiler", "Text nennt Stärken und Schwächen") + + +func test_bot_counter_pick() -> void: + print("[Bots kontern den Gegner]") + var rng := RandomNumberGenerator.new() + rng.seed = 12 + var ai := BotAI.new() + ai.setup(rng, Data.Difficulty.SCHWER) + var me := PlayerState.new(1, "Bot", true) + var opp := PlayerState.new(2, "Gegner", true) + for i in 3: + opp.board[Vector2i(i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + ai.opponent = opp + check(ai._pick_element(me) == Data.Element.WASSER, "schwerer Bot wählt Wasser gegen ein Feuer-Team") + ai.opponent = null + check(ai.pref.has(ai._pick_element(me)), "ohne Gegner bleibt er bei seinen Vorlieben") + var easy := BotAI.new() + easy.setup(rng, Data.Difficulty.NORMAL) + easy.pref = [Data.Element.ERDE, Data.Element.EIS, Data.Element.BLITZ] + easy.opponent = opp + check(easy._pick_element(me) != Data.Element.WASSER, "normaler Bot kontert nur innerhalb seiner Vorlieben") + easy.pref = [Data.Element.WASSER, Data.Element.EIS, Data.Element.BLITZ] + check(easy._pick_element(me) == Data.Element.WASSER, "… und nimmt den Konter, wenn er dazu passt")