diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 756c126..9011aeb 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,30 +1,35 @@ 78098b89c88a74ebe8c20117b8754620 -::res://::1787944732 -anforderungen.md::TextFile::-1::1787943530::0::1::::<><><>0<>0<><>:: -README.md::TextFile::-1::1787944732::0::1::::<><><>0<>0<><>:: +::res://::1787996516 +anforderungen.md::TextFile::-1::1787986378::0::1::::<><><>0<>0<><>:: +README.md::TextFile::-1::1789516876::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/::1787944184 -Avatar.gd::GDScript::3571391337088680506::1787944282::0::1::::Avatar<>RefCounted<><>0<>0<><>:: -BotAI.gd::GDScript::3217244540061411409::1787944184::0::1::::BotAI<>RefCounted<><>0<>0<><>:: -Combat.gd::GDScript::3137923719046090123::1787944065::0::1::::Combat<>RefCounted<><>0<>0<><>:: -Data.gd::GDScript::5827997371587166138::1787944533::0::1::::Data<>RefCounted<><>0<>0<><>:: -Game.gd::GDScript::7849680363975727491::1787944110::0::1::::Game<>RefCounted<><>0<>0<><>:: -Hex.gd::GDScript::3913565535274194804::1787943888::0::1::::Hex<>RefCounted<><>0<>0<><>:: -PlayerState.gd::GDScript::7371028171494145347::1787944651::0::1::::PlayerState<>RefCounted<><>0<>0<><>:: -Pool.gd::GDScript::8278475787586460724::1787944533::0::1::::Pool<>RefCounted<><>0<>0<><>:: -ShopEntry.gd::GDScript::5330801221324202071::1787944184::0::1::::ShopEntry<>RefCounted<><>0<>0<><>:: -::res://scripts/ui/::1787946270 -BoardView3D.gd::GDScript::6505855525292201354::1787946197::0::1::::BoardView3D<>Node3D<><>0<>0<><>:: -HudOverlay.gd::GDScript::2880024841375975216::1787946209::0::1::::HudOverlay<>Control<><>0<>0<><>:: -Main.gd::GDScript::7620286773918972721::1787946270::0::1::::<>Node3D<><>0<>0<><>:: -UiKit.gd::GDScript::7622585584068197695::1787946021::0::1::::UiKit<>RefCounted<><>0<>0<><>:: -::res://tests/::1787945733 -Probe2.gd::GDScript::7806432109624259689::1787946278::0::1::::<>Control<><>0<>0<><>:: -probe2.tscn::PackedScene::-1::1787944588::0::1::::<><><>0<>0<><>::res://tests/Probe2.gd -Probe3.gd::GDScript::5187223496600362631::1787946278::0::1::::<>Control<><>0<>0<><>:: -probe3.tscn::PackedScene::-1::1787945733::0::1::::<><><>0<>0<><>::res://tests/Probe3.gd -Probe.gd::GDScript::4485056788068318808::1787946278::0::1::::<>Control<><>0<>0<><>:: -probe.tscn::PackedScene::-1::1787944434::0::1::::<><><>0<>0<><>::res://tests/Probe.gd -sim_test.gd::GDScript::3463191307206118779::1787944708::0::1::::<>SceneTree<><>0<>0<><>:: +::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<><>:: +Hex.gd::GDScript::3913565535274194804::1787984994::0::1::::Hex<>RefCounted<><>0<>0<><>:: +PlayerState.gd::GDScript::7371028171494145347::1789551593::0::1::::PlayerState<>RefCounted<><>0<>0<><>:: +Pool.gd::GDScript::8278475787586460724::1787984994::0::1::::Pool<>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<><>:: +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<><>:: +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<><>:: +camcal.tscn::PackedScene::-1::1787946501::0::1::::<><><>0<>0<><>::res://tests/CamCal.gd +Probe2.gd::GDScript::7806432109624259689::1789516628::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.tscn::PackedScene::-1::1787946443::0::1::::<><><>0<>0<><>::res://tests/Probe3.gd +sim_test.gd::GDScript::3463191307206118779::1789516655::0::1::::<>SceneTree<><>0<>0<><>:: diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg index e72e81d..0009928 100644 --- a/.godot/global_script_class_cache.cfg +++ b/.godot/global_script_class_cache.cfg @@ -63,6 +63,14 @@ list=[{ "language": &"GDScript", "path": "res://scripts/ui/HudOverlay.gd" }, { +"base": &"Control", +"class": &"InfoColumn", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/InfoColumn.gd" +}, { "base": &"RefCounted", "class": &"PlayerState", "icon": "", @@ -71,6 +79,14 @@ list=[{ "language": &"GDScript", "path": "res://scripts/core/PlayerState.gd" }, { +"base": &"Control", +"class": &"PlayersPanel", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/PlayersPanel.gd" +}, { "base": &"RefCounted", "class": &"Pool", "icon": "", @@ -79,6 +95,14 @@ list=[{ "language": &"GDScript", "path": "res://scripts/core/Pool.gd" }, { +"base": &"Node", +"class": &"Sfx", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/Sfx.gd" +}, { "base": &"RefCounted", "class": &"ShopEntry", "icon": "", @@ -87,6 +111,22 @@ list=[{ "language": &"GDScript", "path": "res://scripts/core/ShopEntry.gd" }, { +"base": &"Control", +"class": &"ShopPanel", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/ShopPanel.gd" +}, { +"base": &"Control", +"class": &"TopBar", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/TopBar.gd" +}, { "base": &"RefCounted", "class": &"UiKit", "icon": "", diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin index 16227c0..e155782 100644 Binary files a/.godot/uid_cache.bin and b/.godot/uid_cache.bin differ diff --git a/README.md b/README.md index b038957..3c20b0f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,12 @@ Würfel (Tank), Kegel (DPS) und Kugeln (Heiler). Alles wird im Code erzeugt. - Klick ohne Ziehen = auswählen. Info rechts, dort auch `Aufwerten`. - Rechtsklick auf dem Feld = zurück auf die Bank, auf der Bank = verkaufen. - Klicks treffen über einen Strahl von der Kamera auf die Feldebene. -- `D` neu würfeln, `Leertaste` Kampf starten / überspringen, `Esc` Auswahl lösen +- `D` neu würfeln, `F` Shop einfrieren, `X` XP kaufen, `Leertaste` Kampf, `P` Pause, `Esc` Auswahl lösen +- Rechtsklick auf eine neutrale Shop-Karte kauft direkt mit der zuletzt gewählten Spezialisierung +- 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 ## Regeln @@ -44,18 +49,36 @@ Würfel (Tank), Kegel (DPS) und Kugeln (Heiler). Alles wird im Code erzeugt. | Elemente kombinierbar wie Traits | `Data.ELEMENT_TRAITS`, `Data.COMBO_TRAITS` | | 5 Gold pro Runde, 1 für Sieg, mehr bei Serie | `PlayerState.round_income()` | | 100 Leben | `Data.START_HP` | -| Begrenzte Elementmenge für alle Spieler | `Pool.element_left`, 180 pro Element | +| Begrenzte Elementmenge für alle Spieler | `Pool.element_left`, 100 pro Element | | 7 Bots | `Game.BOT_NAMES`, `BotAI` | | Keine Items | nicht vorhanden | -Nicht in den Anforderungen, aber nötig: die Kaderplätze wachsen mit der Runde -(`Data.team_slots()`, 3 bis 9). Der neutrale Avatar selbst ist unbegrenzt. Knapp ist -das Element, das man ihm beim Kauf gibt: das zieht einen Platz aus dem geteilten Vorrat. +Nicht in den Anforderungen, aber „Basis wie TFT": -Die 180 pro Element sind gemessen, nicht geraten: bei 30 war jedes Element nach -gut der Hälfte der Partie leer, bei 160 lief in 1 von 12 Partien schon um Runde 15 -eines leer. Bei 180 hält der Vorrat eine ganze Partie, der Tiefstand liegt aber -noch bei rund einem Achtel. +- **Level statt fester Kaderplätze.** Start Level 3, jede Runde 2 XP, 4 XP für 4 Gold. + Level = Kaderplätze, Maximum 9 (`Data.XP_TABLE`, `PlayerState.buy_xp()`). +- **Schaden steigt mit der Runde.** Grundschaden 2, alle vier Runden +1, dazu die + Sternsumme der Überlebenden (`Data.round_base_damage()`, `Combat.survivor_stars`). +- **Planungsuhr** 30 s (`Game.tick_planning()`), Tests schalten sie ab. +- **Gegner ansehen** über die Spielerliste (`Main._on_scout()`, `BoardView3D.scout_player`). +- **Heiler heilen nur im Umkreis 2** (`Data.HEAL_RANGE`), Aufstellung zählt also. +- **Lesbarer Kampf**: Schadens- und Heilzahlen, Krit mit „!", Sterbe-Animation, + Zustände als Tönung und Marke (B Brand, S langsam, Z betäubt), Schildstreifen. +- **Klänge** werden im Code erzeugt (`Sfx`), keine Audiodateien. +- **Sichtbare Angriffe**: Fernkampf schickt eine Kugel, Nahkampf einen Hieb (`BoardView3D.spawn_attack`). +- **Element-Profile**: das Hauptelement prägt die Werte, z. B. Feuer +18% Angriff, Erde +22% Leben + (`Data.ELEMENT_PROFILE`). Dazu ein Kennzeichen am Körper: Flamme, Tropfen, Finne, Zacke, Schultern, Kristall. +- **Gegner vorab**: Paarungen entstehen beim Rundenstart (`Game._make_pairings` in `_next_round`). + Bots stellen dagegen auf: Tanks vor gegnerische Schützen, Schützen weg von Tanks (`BotAI.arrange`). +- **Shop einfrieren** (`PlayerState.shop_locked`), Schnellkauf mit der letzten Wahl (`last_element`). +- **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. + +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 +gemessen: mit den Bots, die jetzt gezielt Futter jagen und Level kaufen, läuft bei 80 +in 2 von 10 Partien ein Element leer, bei 100 keines, Tiefstand rund ein Fünftel. Die Neutral-Chance ist multiplikativ (0,75 hoch Anzahl Elemente) statt in Prozentpunkten. So verschwindet Neutral nie ganz: ohne eigene Elemente 100%, @@ -70,7 +93,8 @@ Magma, Sturmfront, Dampf. ## Aufbau scripts/core/ Data, Avatar, Pool, ShopEntry, PlayerState, Hex, Combat, BotAI, Game - scripts/ui/ Main (Szene + Bedienung), BoardView3D (Arena), HudOverlay, UiKit + scripts/ui/ Main (Szene, Kamera, Eingabe, Overlays), TopBar, InfoColumn, PlayersPanel, + ShopPanel (je ein Panel, melden per Signal), BoardView3D, HudOverlay, Sfx, UiKit scenes/Main.tscn tests/ sim_test.gd (kopflos), Probe2, Probe3, CamCal diff --git a/scripts/core/Avatar.gd b/scripts/core/Avatar.gd index 2b2b8fe..c8c2256 100644 --- a/scripts/core/Avatar.gd +++ b/scripts/core/Avatar.gd @@ -70,13 +70,16 @@ func base_stats() -> Dictionary: var b: Dictionary = Data.ROLE_STATS[role] var mult: float = Data.STAR_MULT[star - 1] var ebonus := 1.0 + Data.ELEMENT_STAT_BONUS * float(maxi(0, elements.size() - 1)) + var prof: Dictionary = Data.ELEMENT_PROFILE.get(primary_element(), + {"hp": 1.0, "atk": 1.0, "armor": 0.0, "aspd": 1.0, "mana": 1.0, "crit": 0.0}) return { - "hp": b["hp"] * mult * ebonus, - "atk": b["atk"] * mult * ebonus, - "armor": b["armor"], - "aspd": b["aspd"], + "hp": b["hp"] * mult * ebonus * prof["hp"], + "atk": b["atk"] * mult * ebonus * prof["atk"], + "armor": minf(0.75, b["armor"] + prof["armor"]), + "aspd": b["aspd"] * prof["aspd"], "range": b["range"], - "mana": b["mana"], + "mana": b["mana"] * prof["mana"], + "crit": prof["crit"], } diff --git a/scripts/core/BotAI.gd b/scripts/core/BotAI.gd index faf2826..f3e6019 100644 --- a/scripts/core/BotAI.gd +++ b/scripts/core/BotAI.gd @@ -22,31 +22,111 @@ func setup(rand: RandomNumberGenerator) -> void: greed = rand.randf_range(0.3, 0.8) -func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> void: +func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator) -> void: _do_upgrades(p, pool) - _do_buys(p, pool, rand, round_no) - _do_placements(p, pool, round_no) + _do_buys(p, pool) + _do_placements(p) _do_upgrades(p, pool) var keep := int(lerpf(2.0, 18.0, greed)) - if p.gold > keep + Data.REROLL_COST and rand.randf() < 0.6: - p.reroll(pool, rand) - _do_buys(p, pool, rand, round_no) - _do_placements(p, pool, round_no) - _do_upgrades(p, pool) + # Wer kurz vor einem Upgrade steht, würfelt gezielt nach Futter. + var hunting := _closest_upgrade_gap(p) <= 3 + var rolls := 3 if hunting else 1 + for i in rolls: + if p.gold > keep + Data.REROLL_COST and (hunting or rand.randf() < 0.6): + p.reroll(pool, rand) + _do_buys(p, pool) + _do_placements(p) + _do_upgrades(p, pool) + # Level kaufen, wenn das Brett voll ist und Nachwuchs auf der Bank wartet. + while p.gold > keep + Data.XP_BUY_COST and not p.can_place() \ + and _pick_bench_for_board(p) >= 0 and p.buy_xp(): + _do_placements(p) _free_bench(p, pool) +## Stellt das Brett gegen den bekannten Gegner um: Tanks vor seine Schützen, +## eigene Schützen weg von seinen Tanks. Ohne Gegner gilt die Standardaufstellung. +func arrange(p: PlayerState, opp: PlayerState) -> void: + if p.board.is_empty(): + return + var units: Array = p.board_units() + p.board.clear() + var threat := [] # Spalten (gespiegelt), in denen gegnerische Schützen stehen + var enemy_tanks := [] # Spalten (gespiegelt) gegnerischer Tanks + if opp != null: + for hex in opp.board: + var col: int = Data.BOARD_COLS - 1 - hex.x + if opp.board[hex].role == Data.Role.TANK: + enemy_tanks.append(col) + else: + threat.append(col) + var center := [3, 2, 4, 1, 5, 0, 6] + var tank_cols := threat.duplicate() + for c in center: + if not tank_cols.has(c): + tank_cols.append(c) + # Schützen möglichst weit weg von gegnerischen Tanks. + var far_cols := center.duplicate() + far_cols.sort_custom(func(a, b): + 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 + var placed := false + for r in rows: + for c in cols: + var h := Vector2i(c, r) + if not p.board.has(h): + p.board[h] = u + placed = true + break + if placed: + break + + +func _min_dist(col: int, cols: Array) -> int: + if cols.is_empty(): + return 0 + var best := 99 + for c in cols: + best = mini(best, absi(col - c)) + return best + + +## Kleinste Zahl fehlender Futter-Avatare über alle Board-Einheiten. +func _closest_upgrade_gap(p: PlayerState) -> int: + var best := 99 + for hex in p.board: + var miss := p.upgrade_missing(hex) + if p.board[hex].upgrade_cost().is_empty(): + continue + var total := 0 + for e in miss: + total += miss[e] + best = mini(best, total) + return best + + +## Elemente, die irgendeine Board-Einheit für ihr nächstes Upgrade braucht. +func _needed_elements(p: PlayerState) -> Dictionary: + var need := {} + for hex in p.board: + for e in p.board[hex].upgrade_cost(): + need[e] = true + return need + + func _wants(entry: ShopEntry, p: PlayerState) -> bool: if entry.element < 0: # Neutral wird beim Kauf spezialisiert, also immer brauchbar. return true - if pref.has(entry.element): + if _needed_elements(p).has(entry.element): return true - # Elemente, die schon auf dem Board sind, sind immer Futter. - return p.trait_counts().has(entry.element) + return pref.has(entry.element) -func _do_buys(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> void: +func _do_buys(p: PlayerState, pool: Pool) -> void: for i in p.shop.size(): var e: ShopEntry = p.shop[i] if e.sold: @@ -60,8 +140,8 @@ func _do_buys(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: p.buy(i, pool, _pick_element(p) if e.element < 0 else e.element) -func _do_placements(p: PlayerState, pool: Pool, round_no: int) -> void: - while p.can_place(round_no): +func _do_placements(p: PlayerState) -> void: + while p.can_place(): var idx := _pick_bench_for_board(p) if idx < 0: return @@ -69,7 +149,7 @@ func _do_placements(p: PlayerState, pool: Pool, round_no: int) -> void: var hex := _pick_hex(p, a.role) if hex.x < 0: return - if not p.place(idx, hex, round_no): + if not p.place(idx, hex): return @@ -77,6 +157,7 @@ func _do_placements(p: PlayerState, pool: Pool, round_no: int) -> void: ## Alles Weitere bleibt als Futter auf der Bank. func _pick_bench_for_board(p: PlayerState) -> int: var counts := p.trait_counts() + var need := _needed_elements(p) var best := -1 var best_score := -1 for i in p.bench.size(): @@ -84,7 +165,9 @@ func _pick_bench_for_board(p: PlayerState) -> int: if a == null: continue var score := 0 - if pref.has(a.elements[0]): + if need.has(a.elements[0]) and counts.get(a.elements[0], 0) > 0: + score = 0 # Futter für ein laufendes Upgrade bleibt auf der Bank + elif pref.has(a.elements[0]): score = 3 if counts.get(a.elements[0], 0) == 0 else 1 else: score = 2 if counts.get(a.elements[0], 0) == 0 else 0 @@ -144,12 +227,16 @@ func _do_upgrades(p: PlayerState, pool: Pool) -> void: func _free_bench(p: PlayerState, pool: Pool) -> void: if p.free_bench_slot() >= 0: return - var counts := p.trait_counts() + var need := _needed_elements(p) + # Erst verkaufen, was weder Futter noch Lieblingselement ist. for i in p.bench.size(): var a = p.bench[i] - if a == null: - continue - if counts.get(a.elements[0], 0) == 0 and not pref.has(a.elements[0]): + if a != null and not need.has(a.elements[0]) and not pref.has(a.elements[0]): + p.sell_bench(i, pool) + return + for i in p.bench.size(): + var a = p.bench[i] + if a != null and not need.has(a.elements[0]): p.sell_bench(i, pool) return p.sell_bench(0, pool) diff --git a/scripts/core/Combat.gd b/scripts/core/Combat.gd index 50958ec..792b147 100644 --- a/scripts/core/Combat.gd +++ b/scripts/core/Combat.gd @@ -47,6 +47,8 @@ class CUnit extends RefCounted: var target = null var cast_flash: float = 0.0 var hit_flash: float = 0.0 + var dealt: float = 0.0 ## ausgeteilter Schaden, für den Rückblick + var healed: float = 0.0 func effective_aspd() -> float: return maxf(0.1, aspd * (1.0 + haste) * (1.0 - slow)) @@ -57,7 +59,12 @@ var time: float = 0.0 var finished: bool = false var winner: int = -1 ## 0 = Team A, 1 = Team B, -1 = unentschieden var survivors: int = 0 +var survivor_stars: int = 0 ## Sternsumme der Überlebenden, bestimmt den Schaden var events: Array = [] ## kurze Textmeldungen für das Log +## Treffer, Heilungen und Zauber für Anzeige und Sound. Nur im Spielerkampf +## aufgezeichnet, Bot-Kämpfe laufen still durch. +var record_hits: bool = false +var hits: Array = [] var _mods := [{}, {}] var _occ := {} @@ -116,7 +123,7 @@ func _spawn(p: PlayerState, team: int) -> void: u.max_mana = s["mana"] u.mana = 0.0 u.mana_mult = m["mana"] - u.crit = m["crit"] + u.crit = m["crit"] + s.get("crit", 0.0) if _occ.has(u.pos): u.pos = _free_near(u.pos) _occ[u.pos] = u @@ -266,6 +273,9 @@ func _basic_attack(u: CUnit, t: CUnit) -> void: var dmg := u.atk if u.crit > 0.0 and _rand.randf() < u.crit: dmg *= CRIT_MULT + if record_hits: + hits.append({"kind": "attack", "pos": t.pos, "from": u.pos, "team": u.team, + "ranged": u.atk_range > 1, "element": u.avatar.primary_element(), "amount": 0, "t": time}) _apply_damage(u, t, dmg, false) var ice: float = _mods[u.team]["ice"] if ice > 0.0: @@ -291,6 +301,10 @@ func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void: if dmg > 0.0: tgt.hp -= dmg tgt.hit_flash = 0.12 + 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) tgt.mana += MANA_PER_HIT * tgt.mana_mult if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0: _cast(tgt) @@ -319,8 +333,17 @@ func _kill(u: CUnit) -> void: func _heal(u: CUnit, amount: float) -> void: - if u.alive: - u.hp = minf(u.max_hp, u.hp + amount) + if not u.alive: + return + var real := minf(u.max_hp, u.hp + amount) - u.hp + u.hp += real + u.healed += real + if record_hits and real >= 1.0: + _record("heal", u, real) + + +func _record(kind: String, u: CUnit, amount: float) -> void: + hits.append({"kind": kind, "pos": u.pos, "team": u.team, "amount": int(round(amount)), "t": time}) # --- Fähigkeiten ---------------------------------------------------------- @@ -328,6 +351,8 @@ func _heal(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(): @@ -373,7 +398,7 @@ func _cast(u: CUnit) -> void: hit_enemies.append(o) Data.Role.HEILER: var amount := u.atk * (3.0 - 0.25 * float(u.avatar.star - 1)) * (1.0 + heal_bonus) - var allies := _allies_by_missing_hp(u.team) + var allies := _allies_by_missing_hp(u.team, u) var count := 1 if u.avatar.star == 2: count = 3 @@ -428,11 +453,14 @@ func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void: main_target.slow_timer = maxf(main_target.slow_timer, 3.0) -func _allies_by_missing_hp(team: int) -> Array: +## Verbündete in Heilreichweite, schwächste zuerst. Mindestens der Heiler selbst. +func _allies_by_missing_hp(team: int, healer: CUnit) -> Array: var list := [] for o in units: - if o.alive and o.team == team: + if o.alive and o.team == team and Hex.distance(o.pos, healer.pos) <= Data.HEAL_RANGE: list.append(o) + if list.is_empty(): + list.append(healer) list.sort_custom(func(a, b): return (a.hp / a.max_hp) < (b.hp / b.max_hp)) return list @@ -447,6 +475,40 @@ func alive_count(team: int) -> int: return n +## Kurzer Rückblick für ein Team: Überlebende, stärkste Einheit, Gegnerspitze. +func recap(team: int) -> Dictionary: + var total := 0 + var alive := 0 + var best = null + var best_enemy = null + for u in units: + if u.team == team: + total += 1 + if u.alive: + alive += 1 + if best == null or u.dealt + u.healed > best.dealt + best.healed: + best = u + elif best_enemy == null or u.dealt > best_enemy.dealt: + best_enemy = u + return { + "total": total, "alive": alive, + "best_name": best.avatar.display_name() if best else "-", + "best_dealt": int(best.dealt) if best else 0, + "best_healed": int(best.healed) if best else 0, + "enemy_name": best_enemy.avatar.display_name() if best_enemy else "-", + "enemy_dealt": int(best_enemy.dealt) if best_enemy else 0, + "duration": time, + } + + +func _star_sum(team: int) -> int: + var n := 0 + for u in units: + if u.alive and u.team == team: + n += u.avatar.star + return n + + func _check_end() -> void: var a := alive_count(0) var b := alive_count(1) @@ -455,9 +517,11 @@ func _check_end() -> void: if a == b: winner = -1 survivors = 0 + survivor_stars = 0 else: winner = 0 if a > b else 1 survivors = maxi(a, b) + survivor_stars = _star_sum(winner) return if time >= TIME_LIMIT: _resolve_by_hp() @@ -472,6 +536,8 @@ func _resolve_by_hp() -> void: if absf(frac[0] - frac[1]) < 0.25: winner = -1 survivors = 0 + survivor_stars = 0 else: winner = 0 if frac[0] > frac[1] else 1 survivors = alive_count(winner) + survivor_stars = _star_sum(winner) diff --git a/scripts/core/Data.gd b/scripts/core/Data.gd index 3e01fb2..c0181ec 100644 --- a/scripts/core/Data.gd +++ b/scripts/core/Data.gd @@ -28,6 +28,21 @@ const ROLE_STATS := { Role.HEILER: {"hp": 520.0, "atk": 38.0, "armor": 0.22, "aspd": 0.70, "range": 3, "mana": 90.0}, } +# Das Hauptelement prägt die Werte: Feuer schlägt hart, Erde hält aus, Wind ist +# schnell, Blitz trifft kritisch, Wasser zaubert öfter, Eis ist zäh und langsam. +const ELEMENT_PROFILE := { + Element.FEUER: {"hp": 0.90, "atk": 1.18, "armor": 0.0, "aspd": 1.0, "mana": 1.0, "crit": 0.0}, + Element.WASSER: {"hp": 1.0, "atk": 0.95, "armor": 0.0, "aspd": 1.0, "mana": 0.75, "crit": 0.0}, + Element.WIND: {"hp": 0.92, "atk": 0.95, "armor": 0.0, "aspd": 1.20, "mana": 1.0, "crit": 0.0}, + Element.BLITZ: {"hp": 0.95, "atk": 1.0, "armor": 0.0, "aspd": 1.05, "mana": 1.0, "crit": 0.15}, + Element.ERDE: {"hp": 1.22, "atk": 0.92, "armor": 0.08, "aspd": 0.90, "mana": 1.0, "crit": 0.0}, + Element.EIS: {"hp": 1.08, "atk": 0.95, "armor": 0.05, "aspd": 0.95, "mana": 0.9, "crit": 0.0}, +} +const PROFILE_HINT := { + Element.FEUER: "hart, aber weich", Element.WASSER: "zaubert oft", Element.WIND: "schnell", + Element.BLITZ: "kritisch", Element.ERDE: "zäh und langsam", Element.EIS: "robust", +} + # Multiplikator pro Sternstufe (Index = star - 1). const STAR_MULT := [1.0, 1.8, 3.24] const MAX_STAR := 3 @@ -55,13 +70,29 @@ const START_GOLD := 4 # --- Geteilter Pool -------------------------------------------------------- ## Alle 8 Spieler teilen sich diesen Vorrat pro Element. Ein 3-Star bindet ## allein 9 Stück, dazu kommt was auf Bänken und Brettern liegt. -## Bei 180 hält der Vorrat eine ganze Partie und kann im Endspiel trotzdem knapp werden. -const POOL_PER_ELEMENT := 180 +## Bei 100 hält der Vorrat eine ganze Partie, geht im Endspiel aber auf rund ein Fünftel. +const POOL_PER_ELEMENT := 100 # --- Board ----------------------------------------------------------------- const BOARD_COLS := 7 const BOARD_ROWS := 4 -const MAX_TEAM_SLOTS := 9 + +# --- Level und Erfahrung --------------------------------------------------- +## Das Spielerlevel ist die Anzahl der Kaderplätze. Jede Runde gibt XP, +## weitere XP kauft man. Level gegen Würfeln ist die TFT-Kernentscheidung. +const START_LEVEL := 3 +const MAX_LEVEL := 9 +const XP_PER_ROUND := 2 +const XP_BUY_COST := 4 +const XP_BUY_AMOUNT := 4 +const XP_TABLE := {3: 4, 4: 8, 5: 12, 6: 20, 7: 32, 8: 48} + +# --- Planung --------------------------------------------------------------- +const PLAN_SECONDS := 30.0 + +# --- Kampf ----------------------------------------------------------------- +## Heiler heilen nur Verbündete in dieser Hex-Reichweite. +const HEAL_RANGE := 2 # --- Element-Traits -------------------------------------------------------- # steps = Anzahl aufgestellter Einheiten mit diesem Element. @@ -133,9 +164,14 @@ static func trait_tier(element: int, count: int) -> int: return tier -## Teamgröße wächst mit der Runde. -static func team_slots(round_no: int) -> int: - return mini(3 + int(float(round_no - 1) / 2.0), MAX_TEAM_SLOTS) +## XP bis zum nächsten Level, 0 auf Maximalstufe. +static func xp_needed(level: int) -> int: + return XP_TABLE.get(level, 0) + + +## Grundschaden einer verlorenen Runde, steigt alle vier Runden. +static func round_base_damage(round_no: int) -> int: + return 2 + int(float(round_no - 1) / 4.0) ## Wahrscheinlichkeit, dass ein Shop-Platz einen neutralen Avatar zeigt. diff --git a/scripts/core/Game.gd b/scripts/core/Game.gd index 69cd33a..71daea4 100644 --- a/scripts/core/Game.gd +++ b/scripts/core/Game.gd @@ -20,6 +20,11 @@ var pairings: Array = [] ## [{a, b, ghost}] var player_combat: Combat = null var pending: Array = [] ## Ergebnisse der Bot-Kämpfe var last_round_log: Array = [] +var plan_left: float = Data.PLAN_SECONDS +var timer_enabled: bool = true +var human_recap: Dictionary = {} ## Rückblick auf den letzten eigenen Kampf +## Zählt, wie oft der Mensch gewonnen bzw. verloren hat (für Sound und Anzeige). +var human_last_result: int = 0 ## 1 Sieg, -1 Niederlage, 0 unentschieden/keine func start(seed_value: int = 0) -> void: @@ -37,7 +42,9 @@ func start(seed_value: int = 0) -> void: round_no = 1 for p in players: p.roll_shop(pool, rand) + pairings = _make_pairings() _run_bot_turns() + plan_left = Data.PLAN_SECONDS phase = Phase.PLANNING @@ -54,7 +61,21 @@ 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, round_no) + bots[p.pid].take_turn(p, pool, rand) + bots[p.pid].arrange(p, opponent_of(p)) + + +## Planungsuhr. Läuft sie ab, startet der Kampf von selbst. +## Gibt true zurück, wenn dadurch die Phase gewechselt hat. +func tick_planning(dt: float) -> bool: + if phase != Phase.PLANNING or not timer_enabled: + return false + plan_left -= dt + if plan_left <= 0.0: + plan_left = 0.0 + start_combat() + return true + return false # --- Kampfphase ------------------------------------------------------------ @@ -62,12 +83,16 @@ func _run_bot_turns() -> void: func start_combat() -> void: if phase != Phase.PLANNING: return - pairings = _make_pairings() + # Abbilder erst jetzt kopieren, damit sie den aktuellen Stand zeigen. + for pr in pairings: + if pr["ghost"]: + pr["b"] = _make_ghost(pr["ghost_src"]) pending.clear() player_combat = null for pr in pairings: if pr["a"] == human or pr["b"] == human: player_combat = Combat.new(pr["a"], pr["b"], rand.randi()) + player_combat.record_hits = true pending.append({"pair": pr, "combat": player_combat}) else: var c := Combat.new(pr["a"], pr["b"], rand.randi()) @@ -107,7 +132,7 @@ func _make_pairings() -> Array: if others.is_empty(): return out var src: PlayerState = others[rand.randi_range(0, others.size() - 1)] - out.append({"a": lone, "b": _make_ghost(src), "ghost": true}) + out.append({"a": lone, "b": _make_ghost(src), "ghost": true, "ghost_src": src}) return out @@ -129,18 +154,25 @@ func resolve_round() -> void: c.run_to_end() var a: PlayerState = pr["a"] var b: PlayerState = pr["b"] - var dmg := 3 + c.survivors + var base := Data.round_base_damage(round_no) + var dmg := base + c.survivor_stars + if a == human or b == human: + human_last_result = 0 if c.winner < 0 else (1 if pr["a" if c.winner == 0 else "b"] == human else -1) + human_recap = c.recap(0 if a == human else 1) + human_recap["opponent"] = (b if a == human else a).pname + human_recap["result"] = human_last_result + human_recap["damage"] = dmg if c.winner >= 0 else base if c.winner == 0: _apply_result(a, b, dmg, pr["ghost"]) elif c.winner == 1: _apply_result(b, a, dmg, pr["ghost"]) else: a.register_result(false) - a.take_damage(3) + a.take_damage(base) if not pr["ghost"]: b.register_result(false) - b.take_damage(3) - last_round_log.append("%s vs %s: unentschieden" % [a.pname, b.pname]) + b.take_damage(base) + last_round_log.append("%s vs %s: unentschieden (-%d)" % [a.pname, b.pname, base]) pending.clear() player_combat = null _check_eliminations() @@ -176,13 +208,26 @@ func _next_round() -> void: if not p.alive: continue p.gold += p.round_income() - p.roll_shop(pool, rand) + p.gain_xp(Data.XP_PER_ROUND) + p.new_round_shop(pool, rand) + pairings = _make_pairings() _run_bot_turns() + plan_left = Data.PLAN_SECONDS phase = Phase.PLANNING # --- 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"] + if pr["b"] == p: + return pr["a"] + return null + + func human_opponent() -> PlayerState: for pr in pairings: if pr["a"] == human: diff --git a/scripts/core/PlayerState.gd b/scripts/core/PlayerState.gd index e1224c8..53abbbd 100644 --- a/scripts/core/PlayerState.gd +++ b/scripts/core/PlayerState.gd @@ -12,6 +12,10 @@ var streak: int = 0 ## >0 Siegserie, <0 Niederlagenserie var alive: bool = true var last_won: bool = false var placement: int = 0 ## Endplatzierung, 0 = noch im Spiel +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 bench: Array = [] ## Avatar oder null var board: Dictionary = {} ## Vector2i -> Avatar @@ -72,10 +76,18 @@ func reroll(pool: Pool, rng: RandomNumberGenerator) -> bool: if gold < Data.REROLL_COST: return false gold -= Data.REROLL_COST + shop_locked = false roll_shop(pool, rng) return true +## Neue Runde: der Shop wird nur neu gefüllt, wenn er nicht eingefroren ist. +func new_round_shop(pool: Pool, rng: RandomNumberGenerator) -> void: + if shop_locked: + return + roll_shop(pool, rng) + + func free_bench_slot() -> int: for i in bench.size(): if bench[i] == null: @@ -101,6 +113,8 @@ func buy(index: int, pool: Pool, chosen_element: int = -1) -> bool: gold -= entry.price() bench[slot] = entry.to_avatar(element) entry.sold = true + if entry.element < 0: + last_element = element return true @@ -126,21 +140,42 @@ func sell_board(hex: Vector2i, pool: Pool) -> bool: # --- Aufstellen ------------------------------------------------------------ -func team_slots(round_no: int) -> int: - return Data.team_slots(round_no) +func team_slots() -> int: + return level -func can_place(round_no: int) -> bool: - return board.size() < team_slots(round_no) +func can_place() -> bool: + return board.size() < level + + +func gain_xp(amount: int) -> void: + xp += amount + while level < Data.MAX_LEVEL and xp >= Data.xp_needed(level): + xp -= Data.xp_needed(level) + level += 1 + if level >= Data.MAX_LEVEL: + xp = 0 + + +func can_buy_xp() -> bool: + return level < Data.MAX_LEVEL and gold >= Data.XP_BUY_COST + + +func buy_xp() -> bool: + if not can_buy_xp(): + return false + gold -= Data.XP_BUY_COST + gain_xp(Data.XP_BUY_AMOUNT) + return true ## Setzt einen Avatar von der Bank aufs Board. -func place(bench_index: int, hex: Vector2i, round_no: int) -> bool: +func place(bench_index: int, hex: Vector2i) -> bool: if bench_index < 0 or bench_index >= bench.size() or bench[bench_index] == null: return false if board.has(hex) or not _valid_hex(hex): return false - if not can_place(round_no): + if not can_place(): return false var a: Avatar = bench[bench_index] board[hex] = a @@ -175,7 +210,7 @@ func move_on_board(from_hex: Vector2i, to_hex: Vector2i) -> bool: ## Verschiebt einen Avatar zwischen Bank und Board, in beide Richtungen. ## Ist das Ziel belegt, tauschen die beiden Avatare die Plätze. ## src und dst sind {"kind": "board"/"bench", "hex": Vector2i, "index": int}. -func move_slot(src: Dictionary, dst: Dictionary, round_no: int) -> bool: +func move_slot(src: Dictionary, dst: Dictionary) -> bool: if src.is_empty() or dst.is_empty() or src == dst: return false var sk: String = src["kind"] @@ -212,7 +247,7 @@ func move_slot(src: Dictionary, dst: Dictionary, round_no: int) -> bool: var hex3: Vector2i = dst["hex"] if bench[i3] == null or not _valid_hex(hex3): return false - if not board.has(hex3) and not can_place(round_no): + if not board.has(hex3) and not can_place(): return false var unit3: Avatar = bench[i3] if board.has(hex3): diff --git a/scripts/ui/BoardView3D.gd b/scripts/ui/BoardView3D.gd index 0e43e18..ab981e9 100644 --- a/scripts/ui/BoardView3D.gd +++ b/scripts/ui/BoardView3D.gd @@ -16,6 +16,8 @@ const BENCH_OFFSET := 1.95 ## Abstand der Bank hinter der letzten Reihe var game: Game = null var camera: Camera3D = null +## Brett eines anderen Spielers, das in der Planung oben gezeigt wird. +var scout_player: PlayerState = null var selected_slot := {} ## {"kind": "board"/"bench", ...} var hover_slot := {} var drag_active := false @@ -31,6 +33,12 @@ var _bench_tiles := {} ## int -> MeshInstance3D var _bench_mats := {} var _bench_z := 0.0 var _units := {} ## key -> Node3D +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 _shot_mesh: SphereMesh +var _slash_mesh: BoxMesh var _origin := Vector3.ZERO var _tile_mesh: CylinderMesh var _ring_mesh: TorusMesh @@ -48,6 +56,13 @@ func _ready() -> void: _ring_mesh.outer_radius = 0.66 _ring_mesh.ring_segments = 8 _ring_mesh.rings = 24 + _shot_mesh = SphereMesh.new() + _shot_mesh.radius = 0.11 + _shot_mesh.height = 0.22 + _shot_mesh.radial_segments = 8 + _shot_mesh.rings = 4 + _slash_mesh = BoxMesh.new() + _slash_mesh.size = Vector3(0.12, 0.12, 0.7) _build_tiles() _build_bench() set_process(true) @@ -212,6 +227,46 @@ func _process(delta: float) -> void: return _update_tiles() _update_units(delta) + _update_shots(delta) + + +## Sichtbarer Angriff: Fernkampf schickt eine Kugel, Nahkampf einen kurzen Hieb. +func spawn_attack(from: Vector3, to: Vector3, ranged: bool, col: Color) -> void: + var mi := MeshInstance3D.new() + mi.mesh = _shot_mesh if ranged else _slash_mesh + var m := StandardMaterial3D.new() + m.albedo_color = col + m.emission_enabled = true + m.emission = col + m.emission_energy_multiplier = 1.8 + mi.material_override = m + var a := from + Vector3(0.0, 0.7, 0.0) + var b := to + Vector3(0.0, 0.6, 0.0) + if not ranged: + # Der Hieb startet vor dem Angreifer und endet am Ziel. + a = a.lerp(b, 0.35) + mi.position = a + if (b - a).length() > 0.01: + mi.look_at_from_position(a, b, Vector3.UP) + add_child(mi) + shots_total += 1 + _shots.append({"node": mi, "from": a, "to": b, "t": 0.0, "dur": 0.2 if ranged else 0.09}) + + +func _update_shots(delta: float) -> void: + var keep := [] + for sh in _shots: + sh["t"] += delta + var f: float = sh["t"] / sh["dur"] + var node: MeshInstance3D = sh["node"] + if f >= 1.0: + node.queue_free() + continue + var pos: Vector3 = sh["from"].lerp(sh["to"], f) + pos.y += sin(f * PI) * 0.35 + node.position = pos + keep.append(sh) + _shots = keep func _update_tiles() -> void: @@ -284,6 +339,10 @@ func _update_units(delta: float) -> void: "side": 0 if u.team == my_team else 1, "cast": u.cast_flash > 0.0, "hit": u.hit_flash > 0.0, + "burn": u.burn_timer > 0.0, + "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), } else: for bh in game.human.board: @@ -292,6 +351,13 @@ func _update_units(delta: float) -> void: "avatar": a, "world": world_pos(from_board(bh)), "hp": 1.0, "mana": 0.0, "side": 0, "cast": false, "hit": false, } + if scout_player != null and scout_player.alive: + for bh in scout_player.board: + var sa: Avatar = scout_player.board[bh] + wanted["s%d" % sa.id] = { + "avatar": sa, "world": world_pos(Combat._arena_pos(bh, 1)), "hp": 1.0, + "mana": 0.0, "side": 1, "cast": false, "hit": false, + } # Die Bank ist immer sichtbar, auch während des Kampfes. for i in game.human.bench.size(): var ba = game.human.bench[i] @@ -310,9 +376,25 @@ func _update_units(delta: float) -> void: wanted[drag_key]["dragged"] = true for key in _units.keys(): - if not wanted.has(key): - _units[key].queue_free() + if wanted.has(key): + continue + var node: Node3D = _units[key] + if not _dying.has(key): + # Im Kampf schrumpft die Einheit weg, in der Planung verschwindet sie sofort. + if game.phase == Game.Phase.COMBAT and key.begins_with("c"): + _dying[key] = DEATH_TIME + else: + node.queue_free() + _units.erase(key) + continue + _dying[key] -= delta + var t: float = maxf(0.0, _dying[key] / DEATH_TIME) + node.scale = Vector3(t, t * 0.6, t) + node.position.y = -0.4 * (1.0 - t) + if _dying[key] <= 0.0: + node.queue_free() _units.erase(key) + _dying.erase(key) for key in wanted: var d: Dictionary = wanted[key] @@ -332,10 +414,31 @@ func _update_units(delta: float) -> void: var body: MeshInstance3D = node.get_meta("body") var mat: StandardMaterial3D = body.material_override var base: Color = d["avatar"].color() - mat.albedo_color = base.lightened(0.5) if d["hit"] else base + var col := base + if d.get("slow", false): + col = col.lerp(Color(0.55, 0.8, 1.0), 0.45) + if d.get("stun", false): + col = col.lerp(Color(1.0, 0.95, 0.4), 0.5) + if d["hit"]: + col = col.lightened(0.5) + mat.albedo_color = col var dragged: bool = d.get("dragged", false) - mat.emission_energy_multiplier = 1.6 if d["cast"] else (0.45 if dragged else 0.12) - node.scale = Vector3.ONE * (1.12 if (d["cast"] or dragged) else 1.0) + var emit := 0.12 + if d["cast"]: + emit = 1.6 + elif d.get("burn", false): + emit = 0.9 + mat.emission = Color(1.0, 0.45, 0.15) + elif dragged: + emit = 0.45 + else: + mat.emission = base + mat.emission_energy_multiplier = emit + var sc := 1.12 if (d["cast"] or dragged) else 1.0 + if d.get("stun", false): + node.scale = Vector3(sc * 1.08, sc * 0.8, sc * 1.08) + else: + node.scale = Vector3.ONE * sc draw_units.append({ "pos": node.position, "avatar": d["avatar"], "hp": d["hp"], "mana": d["mana"], "side": d["side"], "cast": d["cast"], @@ -347,12 +450,77 @@ func unit_node(avatar_id: int) -> Node3D: return _units.get("a%d" % avatar_id) +## Für das Overlay: Arena-Feld so spiegeln, wie das Brett gezeichnet wird. +func mirror_for_view(p: Vector2i, my_team: int) -> Vector2i: + return _mirror(p, my_team) + + func _mirror(p: Vector2i, my_team: int) -> Vector2i: if my_team == 0: return p return Vector2i(COLS - 1 - p.x, ROWS - 1 - p.y) +## Kleines Kennzeichen des Hauptelements auf oder neben dem Körper. +func _add_ornament(root: Node3D, a: Avatar, body_y: float) -> void: + var e := a.primary_element() + if e < 0: + return + var col: Color = Data.ELEMENT_COLORS[e] + var m := StandardMaterial3D.new() + m.albedo_color = col.lightened(0.15) + m.emission_enabled = true + m.emission = col + m.emission_energy_multiplier = 0.5 + var top := body_y + 0.5 + match e: + Data.Element.FEUER: + # Flamme: kleiner Kegel oben. + var c := CylinderMesh.new() + c.top_radius = 0.0 + c.bottom_radius = 0.16 + c.height = 0.38 + c.radial_segments = 8 + _orn(root, c, m, Vector3(0.0, top + 0.18, 0.0)) + Data.Element.WASSER: + # Tropfen: flache Kugel oben. + var sp := SphereMesh.new() + sp.radius = 0.16 + sp.height = 0.22 + _orn(root, sp, m, Vector3(0.0, top + 0.12, 0.0)) + Data.Element.WIND: + # Finne: dünne, hohe Platte. + var b := BoxMesh.new() + b.size = Vector3(0.05, 0.42, 0.28) + _orn(root, b, m, Vector3(0.0, top + 0.2, 0.0)) + Data.Element.BLITZ: + # Zacke: Prisma oben. + var pr := PrismMesh.new() + pr.size = Vector3(0.28, 0.4, 0.12) + _orn(root, pr, m, Vector3(0.0, top + 0.2, 0.0)) + Data.Element.ERDE: + # Schultern: zwei Würfel seitlich. + for sx in [-1.0, 1.0]: + var bx := BoxMesh.new() + bx.size = Vector3(0.22, 0.22, 0.22) + _orn(root, bx, m, Vector3(sx * 0.5, body_y + 0.1, 0.0)) + Data.Element.EIS: + # Kristall: umgedrehtes Prisma oben. + var cr := PrismMesh.new() + cr.size = Vector3(0.22, 0.42, 0.22) + var n := _orn(root, cr, m, Vector3(0.0, top + 0.2, 0.0)) + n.rotation_degrees = Vector3(180.0, 45.0, 0.0) + + +func _orn(root: Node3D, mesh: Mesh, mat: Material, pos: Vector3) -> MeshInstance3D: + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.material_override = mat + mi.position = pos + root.add_child(mi) + return mi + + func _make_unit(a: Avatar, side: int) -> Node3D: var root := Node3D.new() @@ -390,6 +558,8 @@ func _make_unit(a: Avatar, side: int) -> Node3D: root.add_child(body) root.set_meta("body", body) + _add_ornament(root, a, body.position.y) + # Bodenring: weiss = eigenes Team, rot = Gegner. var ring := MeshInstance3D.new() ring.mesh = _ring_mesh diff --git a/scripts/ui/HudOverlay.gd b/scripts/ui/HudOverlay.gd index 9b1c85c..058f4fc 100644 --- a/scripts/ui/HudOverlay.gd +++ b/scripts/ui/HudOverlay.gd @@ -1,10 +1,17 @@ class_name HudOverlay extends Control -## Zeichnet Lebens- und Manabalken, Rolle und Sterne über die 3D-Einheiten. +## Zeichnet Lebens- und Manabalken, Rolle, Sterne und Schadenszahlen +## über die 3D-Einheiten. Spielt außerdem die Kampfgeräusche ab. + +const FLOAT_TIME := 0.9 +const FLOAT_RISE := 42.0 var board: BoardView3D = null +var sfx: Sfx = null var _font: Font +var _floaters: Array = [] ## {"world": Vector3, "text": String, "col": Color, "age": float} +var _combat_seen: Combat = null func _ready() -> void: @@ -13,10 +20,52 @@ func _ready() -> void: set_process(true) -func _process(_delta: float) -> void: +func _process(delta: float) -> void: + _pull_hits() + for f in _floaters: + f["age"] += delta + _floaters = _floaters.filter(func(f): return f["age"] < FLOAT_TIME) queue_redraw() +## Holt neue Treffer aus dem laufenden Spielerkampf und macht Zahlen und Töne daraus. +func _pull_hits() -> void: + if board == null or board.game == null: + return + var c: Combat = board.game.player_combat + if c == null or not c.record_hits: + _combat_seen = null + return + if c != _combat_seen: + _combat_seen = c + _floaters.clear() + if c.hits.is_empty(): + return + var my_team := board.game.human_team_in_combat() + for h in c.hits: + var world := board.world_pos(board.mirror_for_view(h["pos"], my_team)) + var mine: bool = h["team"] == my_team + match h["kind"]: + "dmg": + _floaters.append({"world": world, "text": str(h["amount"]), + "col": Color(1.0, 0.55, 0.45) if mine else Color(1.0, 0.95, 0.85), "age": 0.0}) + if sfx: sfx.play("treffer") + "crit": + _floaters.append({"world": world, "text": str(h["amount"]) + "!", + "col": Color(1.0, 0.85, 0.3), "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") + "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"]]) + c.hits.clear() + + func _draw() -> void: if board == null or board.camera == null: return @@ -30,7 +79,6 @@ func _draw() -> void: var a: Avatar = d["avatar"] var side: int = d["side"] - # Balkenbreite nach Entfernung, damit nahe Einheiten größer wirken. var bw: float = clampf(absf(foot.y - head.y) * 0.85, 26.0, 70.0) var bx := head.x - bw * 0.5 var by := head.y - 4.0 @@ -41,13 +89,34 @@ func _draw() -> void: draw_rect(Rect2(bx, by + 7.0, bw, 4.0), Color(0.05, 0.05, 0.07, 0.85)) draw_rect(Rect2(bx, by + 7.0, bw * clampf(d["mana"], 0.0, 1.0), 4.0), Color(0.35, 0.62, 0.95)) + # Schild als heller Streifen über dem Lebensbalken. + if d.get("shield", 0.0) > 0.0: + draw_rect(Rect2(bx, by - 4.0, bw * clampf(d["shield"], 0.0, 1.0), 3.0), Color(0.85, 0.9, 1.0)) - # Sterne for s in a.star: - draw_circle(Vector2(bx + bw * 0.5 - 7.0 + 7.0 * float(s), by - 7.0), 2.6, + draw_circle(Vector2(bx + bw * 0.5 - 7.0 + 7.0 * float(s), by - 9.0), 2.6, Color(1.0, 0.85, 0.25)) - # Rolle draw_string(_font, Vector2(bx + bw + 4.0, by + 7.0), Data.ROLE_SHORT[a.role], HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.9, 0.9, 0.95, 0.9)) if d["cast"]: draw_arc(foot, bw * 0.55, 0.0, TAU, 24, Color(1.0, 0.95, 0.5, 0.9), 2.5) + # Zustände als kleine Marken neben dem Balken. + var marks := [] + if d.get("burn", false): marks.append(["B", Color(1.0, 0.5, 0.2)]) + if d.get("slow", false): marks.append(["S", Color(0.6, 0.85, 1.0)]) + if d.get("stun", false): marks.append(["Z", Color(1.0, 0.9, 0.3)]) + for i in marks.size(): + draw_string(_font, Vector2(bx - 12.0 - 11.0 * float(i), by + 7.0), marks[i][0], + HORIZONTAL_ALIGNMENT_LEFT, -1, 12, marks[i][1]) + + # Schwebende Zahlen + for f in _floaters: + var t: float = f["age"] / FLOAT_TIME + var pos := cam.unproject_position(f["world"] + Vector3(0.0, 1.5, 0.0)) + pos.y -= t * FLOAT_RISE + var col: Color = f["col"] + col.a = 1.0 - t * t + var size := 15 if f["text"].ends_with("!") else 13 + 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 new file mode 100644 index 0000000..269a273 --- /dev/null +++ b/scripts/ui/InfoColumn.gd @@ -0,0 +1,171 @@ +class_name InfoColumn +extends Control + +## Linke Spalte: Elemente im Team, Kombinationen, gewählte Einheit, Rückblick. + +signal upgrade_pressed + +const PANEL_BG := Color(0.14, 0.15, 0.19) + +var traits_box: VBoxContainer +var info_box: VBoxContainer +var log_box: VBoxContainer +var _text_w := 240.0 + + +func build(x: float, y: float, w: float, h: float) -> void: + position = Vector2(x, y) + size = Vector2(w, h) + _text_w = w - 46.0 + mouse_filter = Control.MOUSE_FILTER_IGNORE + var p := UiKit.panel(PANEL_BG) + p.size = size + p.clip_contents = true + add_child(p) + var sc := ScrollContainer.new() + sc.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + p.add_child(sc) + var col := VBoxContainer.new() + col.add_theme_constant_override("separation", 10) + col.size_flags_horizontal = Control.SIZE_EXPAND_FILL + sc.add_child(col) + traits_box = VBoxContainer.new() + info_box = VBoxContainer.new() + log_box = VBoxContainer.new() + for b in [traits_box, info_box, log_box]: + b.add_theme_constant_override("separation", 3) + b.size_flags_horizontal = Control.SIZE_EXPAND_FILL + col.add_child(b) + col.add_child(HSeparator.new()) + + +func refresh(game: Game, avatar: Avatar, hex: Vector2i) -> void: + _refresh_traits(game) + _refresh_info(game, avatar, hex) + _refresh_log(game) + + +func _wrapped(text: String, size: int, col: Color) -> Label: + var l := UiKit.label(text, size, col) + l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + l.custom_minimum_size = Vector2(_text_w, 0) + l.size_flags_horizontal = Control.SIZE_FILL + return l + + +func _clear(box: Node) -> void: + for c in box.get_children(): + box.remove_child(c) + c.queue_free() + + +func _refresh_traits(game: Game) -> void: + _clear(traits_box) + traits_box.add_child(UiKit.label("Elemente im Team", 15, Color(0.75, 0.8, 0.95))) + var counts := game.human.trait_counts() + var any := false + for e in Data.ELEMENT_COUNT: + var c: int = counts.get(e, 0) + if c == 0: + continue + any = true + var tier := Data.trait_tier(e, c) + var col: Color = Data.ELEMENT_COLORS[e] + var row := HBoxContainer.new() + var dot := ColorRect.new() + dot.color = col if tier > 0 else col.darkened(0.55) + dot.custom_minimum_size = Vector2(12, 12) + row.add_child(dot) + var txt := " %s %d" % [Data.ELEMENT_NAMES[e], c] + if tier > 0: + txt += " - " + Data.ELEMENT_TRAITS[e]["desc"][tier - 1] + else: + txt += " (ab 2 aktiv)" + 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) + row.add_child(el) + traits_box.add_child(row) + if not any: + traits_box.add_child(_wrapped("Noch nichts aufgestellt.", 13, Color(0.55, 0.55, 0.6))) + traits_box.add_child(UiKit.label("\nKombinationen", 15, Color(0.75, 0.8, 0.95))) + var keys := [] + for c in game.human.active_combos(): + keys.append(c["key"]) + for c in Data.COMBO_TRAITS: + var on: bool = keys.has(c["key"]) + var t := "%s (%s+%s): %s" % [c["name"], Data.ELEMENT_NAMES[c["a"]], + Data.ELEMENT_NAMES[c["b"]], c["desc"]] + traits_box.add_child(_wrapped(t, 12, Color(0.6, 0.95, 0.7) if on else Color(0.45, 0.45, 0.5))) + + +func _refresh_info(game: Game, a: Avatar, hex: Vector2i) -> void: + _clear(info_box) + info_box.add_child(UiKit.label("Einheit", 15, Color(0.75, 0.8, 0.95))) + if a == null: + info_box.add_child(_wrapped("Einheit auf dem Feld oder der Bank anklicken.", 13, + Color(0.55, 0.55, 0.6))) + return + var s := a.base_stats() + info_box.add_child(_wrapped("%s %d-Star" % [a.display_name(), a.star], 14, a.color())) + info_box.add_child(_wrapped("%s: %s" % [Data.ELEMENT_NAMES[a.primary_element()], + Data.PROFILE_HINT[a.primary_element()]], 12, Color(0.75, 0.8, 0.9))) + var crit := " Krit %d%%" % int(s["crit"] * 100.0) if s["crit"] > 0.0 else "" + 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.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))) + return + var cost := a.upgrade_cost() + if cost.is_empty(): + info_box.add_child(_wrapped("Maximale Stufe.", 12, Color(1.0, 0.85, 0.35))) + return + var p := game.human + var missing := p.upgrade_missing(hex) + var need := [] + for e in cost: + need.append("%dx %s" % [cost[e], Data.ELEMENT_NAMES[e]]) + info_box.add_child(_wrapped("Aufwerten braucht: " + ", ".join(need), 12, Color(0.85, 0.85, 0.9))) + if missing.is_empty(): + var b := UiKit.button("Aufwerten", Color(0.28, 0.55, 0.36)) + b.custom_minimum_size = Vector2(140, 26) + b.pressed.connect(func(): upgrade_pressed.emit()) + info_box.add_child(b) + else: + var miss := [] + for e in missing: + miss.append("%dx %s" % [missing[e], Data.ELEMENT_NAMES[e]]) + info_box.add_child(_wrapped("Es fehlt: " + ", ".join(miss), 12, Color(0.95, 0.6, 0.5))) + + +func _refresh_log(game: Game) -> void: + _clear(log_box) + log_box.add_child(UiKit.label("Letzte Runde", 15, Color(0.75, 0.8, 0.95))) + var r := game.human_recap + if not r.is_empty(): + var verdict := "unentschieden" + var col := Color(0.85, 0.85, 0.9) + if r["result"] > 0: + verdict = "gewonnen" + col = Color(0.6, 0.95, 0.7) + elif r["result"] < 0: + verdict = "verloren (-%d Leben)" % r["damage"] + col = Color(1.0, 0.6, 0.55) + log_box.add_child(_wrapped("Gegen %s: %s" % [r["opponent"], verdict], 13, col)) + if r["total"] == 0: + log_box.add_child(_wrapped("Du hattest keine Einheiten aufgestellt.", 12, + Color(0.95, 0.75, 0.7))) + else: + log_box.add_child(_wrapped("%d von %d Einheiten überlebt, %.0f s" % + [r["alive"], r["total"], r["duration"]], 12, Color(0.8, 0.8, 0.86))) + var best := "Stärkste: %s (%d Schaden" % [r["best_name"], r["best_dealt"]] + best += ", %d geheilt)" % r["best_healed"] if r["best_healed"] > 0 else ")" + log_box.add_child(_wrapped(best, 12, Color(0.8, 0.8, 0.86))) + log_box.add_child(_wrapped("Gefährlichster Gegner: %s (%d Schaden)" % + [r["enemy_name"], r["enemy_dealt"]], 12, Color(0.95, 0.75, 0.7))) + if game.last_round_log.is_empty() and r.is_empty(): + log_box.add_child(UiKit.label("-", 12, Color(0.55, 0.55, 0.6))) + for line in game.last_round_log: + log_box.add_child(_wrapped(line, 11, Color(0.6, 0.6, 0.68))) diff --git a/scripts/ui/InfoColumn.gd.uid b/scripts/ui/InfoColumn.gd.uid new file mode 100644 index 0000000..3753a27 --- /dev/null +++ b/scripts/ui/InfoColumn.gd.uid @@ -0,0 +1 @@ +uid://dr6yh7ynm7li8 diff --git a/scripts/ui/Main.gd b/scripts/ui/Main.gd index 5f98594..751bb9b 100644 --- a/scripts/ui/Main.gd +++ b/scripts/ui/Main.gd @@ -1,18 +1,19 @@ extends Node3D -## Spielszene: 3D-Arena plus Bedienoberfläche. -## Alles wird im Code aus primitiven Meshes und Controls gebaut. +## Spielszene: 3D-Arena, Kamera, Eingabe, Overlays. Die Panels sind eigene +## Klassen (TopBar, InfoColumn, PlayersPanel, ShopPanel) und melden sich +## über Signale. const TOP_H := 46.0 -const LEFT_W := 290.0 ## links: Elemente, Einheit, Rundenlog +const LEFT_W := 290.0 ## links: Elemente, Einheit, Rückblick const RIGHT_W := 210.0 ## rechts: Spielerliste -const BOTTOM_H := 138.0 -const TEXT_W := 244.0 +const BOTTOM_H := 152.0 const BG := Color(0.09, 0.10, 0.13) -const PANEL_BG := Color(0.14, 0.15, 0.19) -const SLOT_BG := Color(0.20, 0.21, 0.26) 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 @@ -20,33 +21,28 @@ var camera: Camera3D var ui_layer: CanvasLayer var ui_root: Control var hud: HudOverlay +var sfx: Sfx +var top: TopBar +var info: InfoColumn +var players: PlayersPanel +var shop: ShopPanel + var combat_speed := 1.0 +var started := false +var paused := false +var scout: PlayerState = null ## Spieler, dessen Brett gerade angesehen wird -# Kopfzeile -var lbl_round: Label -var lbl_gold: Label -var lbl_hp: Label -var lbl_slots: Label -var lbl_streak: Label -var lbl_phase: Label -var btn_ready: Button -var btn_speed: Button - -# Panels -var players_box: VBoxContainer -var traits_box: VBoxContainer -var info_box: VBoxContainer -var log_box: VBoxContainer -var shop_row: HBoxContainer -var lbl_fodder: Label -var btn_reroll: Button -var lbl_hint: Label +# 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 # Auswahl und Ziehen var sel_slot := {} var drag_src := {} var drag_avatar: Avatar = null -var drag_moved := false # Element-Auswahl var picker: PopupPanel @@ -55,7 +51,7 @@ var picker_title: Label var picker_sub: Label var picker_cb: Callable -var overlay: Control +var overlay: Control ## Start-, Pause- oder Endbildschirm func _ready() -> void: @@ -66,54 +62,93 @@ func _ready() -> void: hud.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) hud.board = board ui_layer.add_child(hud) + sfx = Sfx.new() + add_child(sfx) + hud.sfx = sfx ui_root = Control.new() ui_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) ui_root.mouse_filter = Control.MOUSE_FILTER_IGNORE ui_layer.add_child(ui_root) - _build_top() - _build_left() - _build_right() - _build_bottom() + _build_panels() _build_picker() game.start() board.game = game _refresh() + _show_start() set_process(true) func _process(delta: float) -> void: - if game.phase == Game.Phase.COMBAT: + if not started or paused: + return + if game.phase == Game.Phase.PLANNING: + if game.tick_planning(delta): + _cancel_drag() + sel_slot = {} + picker.hide() + sfx.play("runde") + _refresh() + else: + top.refresh_timer(game) + elif game.phase == Game.Phase.COMBAT: var before := game.phase game.update_combat(delta * combat_speed) - _refresh_header() + top.refresh(game, combat_speed) if game.phase != before: + _after_combat() _refresh() +func _after_combat() -> void: + match game.human_last_result: + 1: sfx.play("sieg") + -1: sfx.play("niederlage") + _: sfx.play("runde") + + +# --- Eingabe ----------------------------------------------------------------- + func _unhandled_input(event: InputEvent) -> void: + if not started or paused: + if event is InputEventKey and event.pressed and event.keycode == KEY_P and started: + _toggle_pause() + return if event is InputEventMouseMotion: + if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE: + cam_yaw += event.relative.x * 0.3 + _apply_camera() + return _on_mouse_move(event.position) return if event is InputEventMouseButton: - if event.button_index == MOUSE_BUTTON_LEFT: - if event.pressed: - _begin_drag(event.position) - else: - _end_drag(event.position) - return - if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: - _on_right_click(event.position) - return + match event.button_index: + MOUSE_BUTTON_LEFT: + if event.pressed: + _begin_drag(event.position) + else: + _end_drag(event.position) + MOUSE_BUTTON_RIGHT: + if event.pressed: + _on_right_click(event.position) + MOUSE_BUTTON_WHEEL_UP: + if event.pressed: + _zoom(-0.1) + MOUSE_BUTTON_WHEEL_DOWN: + if event.pressed: + _zoom(0.1) return if not (event is InputEventKey and event.pressed and not event.echo): return match event.keycode: - KEY_D: - _on_reroll() - KEY_SPACE, KEY_F: - _on_ready() + 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_ESCAPE: _cancel_drag() sel_slot = {} @@ -125,10 +160,8 @@ func _on_mouse_move(pos: Vector2) -> void: board.hover_slot = slot if drag_avatar != null: board.drag_world = _drag_point(pos, slot) - drag_moved = true -## Hebt einen Avatar an, wenn unter dem Zeiger einer liegt. func _begin_drag(pos: Vector2) -> void: if game.phase != Game.Phase.PLANNING: return @@ -140,11 +173,9 @@ func _begin_drag(pos: Vector2) -> void: return drag_src = slot drag_avatar = a - drag_moved = false board.drag_active = true board.drag_avatar_id = a.id board.drag_world = _drag_point(pos, slot) - # Während des Ziehens zeigt das Info-Panel den Avatar in der Hand. sel_slot = slot _refresh() @@ -156,18 +187,19 @@ func _end_drag(pos: Vector2) -> void: var src := drag_src _cancel_drag() if dst.is_empty() or BoardView3D._same_slot(src, dst): - # Kein Weg zurückgelegt: das war eine Auswahl. sel_slot = src _refresh() return if dst["kind"] == "board" and not board.is_own_half(BoardView3D.from_board(dst["hex"])): _refresh() return - if not game.human.move_slot(src, dst, game.round_no): + if not game.human.move_slot(src, dst): if dst["kind"] == "board": - _hint("Kaderplätze voll (%d)." % game.human.team_slots(game.round_no)) + shop.hint("Kaderplätze voll (%d). XP kaufen für mehr." % game.human.team_slots()) + sfx.play("fehler") else: sel_slot = dst + sfx.play("setzen") _refresh() @@ -186,9 +218,10 @@ func _on_right_click(pos: Vector2) -> void: if slot.is_empty(): return if slot["kind"] == "bench": - game.human.sell_bench(slot["index"], game.pool) - else: - game.human.unplace(slot["hex"]) + if game.human.sell_bench(slot["index"], game.pool): + sfx.play("verkauf") + elif game.human.unplace(slot["hex"]): + sfx.play("setzen") sel_slot = {} _refresh() @@ -201,82 +234,7 @@ func _avatar_at(slot: Dictionary): return game.human.board.get(slot["hex"]) -# --- Aufbau ---------------------------------------------------------------- - -func _add_panel(pos: Vector2, sz: Vector2) -> VBoxContainer: - var p := UiKit.panel(PANEL_BG) - p.position = pos - p.size = sz - ui_root.add_child(p) - var v := VBoxContainer.new() - v.add_theme_constant_override("separation", 4) - p.add_child(v) - return v - - -func _build_top() -> void: - var bar := ColorRect.new() - bar.color = PANEL_BG - bar.position = Vector2.ZERO - bar.size = Vector2(1600, TOP_H) - bar.mouse_filter = Control.MOUSE_FILTER_IGNORE - ui_root.add_child(bar) - - var h := HBoxContainer.new() - h.position = Vector2(14, 9) - h.size = Vector2(1570, 30) - h.add_theme_constant_override("separation", 22) - ui_root.add_child(h) - - lbl_round = UiKit.label("Runde 1", 18) - lbl_phase = UiKit.label("Planung", 16, Color(0.7, 0.85, 1.0)) - lbl_hp = UiKit.label("Leben 100", 16, Color(0.5, 0.9, 0.55)) - lbl_gold = UiKit.label("Gold 4", 16, Color(1.0, 0.85, 0.35)) - lbl_slots = UiKit.label("Kader 0/3", 16) - lbl_streak = UiKit.label("Serie 0", 16, Color(0.85, 0.7, 1.0)) - for l in [lbl_round, lbl_phase, lbl_hp, lbl_gold, lbl_slots, lbl_streak]: - h.add_child(l) - - var spacer := Control.new() - spacer.custom_minimum_size = Vector2(360, 0) - h.add_child(spacer) - - btn_speed = UiKit.button("Tempo 1x", Color(0.25, 0.28, 0.36)) - btn_speed.custom_minimum_size = Vector2(96, 28) - btn_speed.pressed.connect(_on_speed) - h.add_child(btn_speed) - - btn_ready = UiKit.button("Kampf starten [Leer]", Color(0.24, 0.52, 0.34)) - btn_ready.custom_minimum_size = Vector2(190, 28) - btn_ready.pressed.connect(_on_ready) - h.add_child(btn_ready) - - -## Linke Spalte: Elemente, gewählte Einheit und Rundenlog, scrollbar. -func _build_left() -> void: - var w := LEFT_W - 16.0 - var h := 900.0 - TOP_H - BOTTOM_H - 20.0 - var p := UiKit.panel(PANEL_BG) - p.position = Vector2(8, TOP_H + 8) - p.size = Vector2(w, h) - p.clip_contents = true - ui_root.add_child(p) - var sc := ScrollContainer.new() - sc.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - p.add_child(sc) - var col := VBoxContainer.new() - col.add_theme_constant_override("separation", 10) - col.size_flags_horizontal = Control.SIZE_EXPAND_FILL - sc.add_child(col) - traits_box = VBoxContainer.new() - info_box = VBoxContainer.new() - log_box = VBoxContainer.new() - for b in [traits_box, info_box, log_box]: - b.add_theme_constant_override("separation", 3) - b.size_flags_horizontal = Control.SIZE_EXPAND_FILL - col.add_child(b) - col.add_child(HSeparator.new()) - +# --- Welt und Kamera --------------------------------------------------------- func _build_world() -> void: var we := WorldEnvironment.new() @@ -314,7 +272,6 @@ func _build_world() -> void: ## Legt die Kamera so, dass das Spielfeld genau in die freie Bildmitte passt. -## Links, rechts und unten liegen Bedienpanels darüber. func _fit_camera() -> void: var vp := Vector2(get_viewport().get_visible_rect().size) var target := Rect2(LEFT_W + 16.0, TOP_H + 16.0, @@ -341,10 +298,26 @@ func _fit_camera() -> void: if absf(kx) < 0.001 or absf(kz) < 0.001: break center += Vector3(err.x / kx, 0.0, err.y / kz) - if err.length() < 1.0 and absf(1.0 - maxf(r.size.x / target.size.x, - r.size.y / target.size.y)) < 0.01: - break - camera.look_at_from_position(center + dir * dist, center, Vector3.UP) + _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: @@ -359,7 +332,6 @@ func _project_rect(points: Array) -> Rect2: return Rect2(lo, hi - lo) -## Punkt auf der Feldebene unter einer Bildschirmposition, oder null. func _ground_point(screen_pos: Vector2): if camera == null: return null @@ -373,7 +345,6 @@ func _ground_point(screen_pos: Vector2): return board.to_local(from + dir * t) -## Position des gezogenen Avatars: auf Feldhöhe, damit er nicht schwebt. func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3: var hit = _ground_point(screen_pos) if hit == null: @@ -381,7 +352,6 @@ func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3: return Vector3(hit.x, 0.0, hit.z) -## Feld oder Bankplatz unter einer Bildschirmposition. func _pick_slot(screen_pos: Vector2) -> Dictionary: var hit = _ground_point(screen_pos) if hit == null: @@ -389,45 +359,35 @@ func _pick_slot(screen_pos: Vector2) -> Dictionary: return board.slot_at_world(hit) -## Rechte Spalte: die acht Spieler mit Leben und Serie. -func _build_right() -> void: - players_box = _add_panel(Vector2(1600.0 - RIGHT_W + 8.0, TOP_H + 8), - Vector2(RIGHT_W - 16, 900 - TOP_H - BOTTOM_H - 20)) - players_box.add_child(UiKit.label("Spieler", 16, Color(0.75, 0.8, 0.95))) +# --- Panels ------------------------------------------------------------------ +func _build_panels() -> void: + var vp := Vector2(1600, 900) + top = TopBar.new() + top.build(vp.x, TOP_H) + top.ready_pressed.connect(_on_ready) + top.xp_pressed.connect(_on_buy_xp) + top.speed_pressed.connect(_on_speed) + top.pause_pressed.connect(_toggle_pause) + ui_root.add_child(top) -func _build_bottom() -> void: - var y := 900.0 - BOTTOM_H - var p := UiKit.panel(PANEL_BG) - p.position = Vector2(8, y) - p.size = Vector2(1584, BOTTOM_H - 10) - ui_root.add_child(p) - var v := VBoxContainer.new() - v.add_theme_constant_override("separation", 6) - p.add_child(v) + var col_h := vp.y - TOP_H - BOTTOM_H - 20.0 + info = InfoColumn.new() + info.build(8.0, TOP_H + 8.0, LEFT_W - 16.0, col_h) + info.upgrade_pressed.connect(_on_upgrade) + ui_root.add_child(info) - var head := HBoxContainer.new() - head.add_theme_constant_override("separation", 16) - v.add_child(head) - head.add_child(UiKit.label("Shop", 13, Color(0.7, 0.72, 0.8))) - btn_reroll = UiKit.button("Neu würfeln %d Gold [D]" % Data.REROLL_COST, Color(0.30, 0.32, 0.44)) - btn_reroll.custom_minimum_size = Vector2(200, 26) - btn_reroll.pressed.connect(_on_reroll) - head.add_child(btn_reroll) - lbl_fodder = UiKit.label("", 13, Color(0.7, 0.72, 0.8)) - head.add_child(lbl_fodder) - lbl_hint = UiKit.label("", 13, Color(0.95, 0.85, 0.45)) - head.add_child(lbl_hint) - var spacer := Control.new() - spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - head.add_child(spacer) - head.add_child(UiKit.label( - "Avatare zwischen Bank und Feld ziehen · Rechtsklick: verkaufen bzw. zurück", - 13, Color(0.6, 0.62, 0.7))) + players = PlayersPanel.new() + players.build(vp.x - RIGHT_W + 8.0, TOP_H + 8.0, RIGHT_W - 16.0, col_h) + players.scout_requested.connect(_on_scout) + ui_root.add_child(players) - shop_row = HBoxContainer.new() - shop_row.add_theme_constant_override("separation", 8) - v.add_child(shop_row) + shop = ShopPanel.new() + shop.build(8.0, vp.y - BOTTOM_H, vp.x - 16.0, BOTTOM_H - 10.0) + shop.buy_requested.connect(_on_buy) + shop.reroll_pressed.connect(_on_reroll) + shop.lock_pressed.connect(_on_lock) + ui_root.add_child(shop) func _build_picker() -> void: @@ -478,32 +438,56 @@ func _open_picker(title: String, options: Array, cb: Callable, previews: Array = picker.popup_centered() -# --- Aktionen -------------------------------------------------------------- +# --- Aktionen ---------------------------------------------------------------- func _on_ready() -> void: if game.phase == Game.Phase.PLANNING: _cancel_drag() sel_slot = {} + picker.hide() game.start_combat() + sfx.play("runde") _refresh() elif game.phase == Game.Phase.COMBAT: game.skip_combat() + _after_combat() _refresh() func _on_speed() -> void: combat_speed = 1.0 if combat_speed >= 4.0 else combat_speed * 2.0 - btn_speed.text = "Tempo %dx" % int(combat_speed) + top.refresh(game, combat_speed) + + +func _on_buy_xp() -> void: + if game.phase != Game.Phase.PLANNING: + return + var before := game.human.level + if game.human.buy_xp(): + sfx.play("level" if game.human.level > before else "kauf") + else: + shop.hint("Nicht genug Gold für XP." if game.human.level < Data.MAX_LEVEL else "Maximales Level.") + _refresh() func _on_reroll() -> void: if game.phase != Game.Phase.PLANNING: return - game.human.reroll(game.pool, game.rand) + if game.human.reroll(game.pool, game.rand): + sfx.play("wuerfeln") _refresh() -func _on_buy(index: int) -> void: +func _on_lock() -> void: + if game.phase != Game.Phase.PLANNING: + return + game.human.shop_locked = not game.human.shop_locked + sfx.play("setzen") + _refresh() + + +## Kauf. `quick` (Rechtsklick) nimmt bei Neutral die zuletzt gewählte Spezialisierung. +func _on_buy(index: int, quick: bool) -> void: if game.phase != Game.Phase.PLANNING: return var p := game.human @@ -513,26 +497,32 @@ func _on_buy(index: int) -> void: if entry.sold: return if p.free_bench_slot() < 0: - _hint("Bank ist voll. Erst etwas verkaufen.") + shop.hint("Bank ist voll. Erst etwas verkaufen.") return if p.gold < entry.price(): return - if entry.element >= 0: - if not p.buy(index, game.pool, entry.element): - _hint("Kauf nicht möglich (Vorrat leer).") + var direct := entry.element + if direct < 0 and quick and p.last_element >= 0 and game.pool.element_left[p.last_element] > 0: + direct = p.last_element + if direct >= 0: + if p.buy(index, game.pool, direct): + sfx.play("kauf") + else: + shop.hint("Kauf nicht möglich (Vorrat leer).") _refresh() return - # Neutraler Avatar: Spezialisierung schon beim Kauf wählen. var free: Array[int] = [] for e in Data.ELEMENT_COUNT: if game.pool.element_left[e] > 0: free.append(e) if free.is_empty(): - _hint("Kein Element mehr im Vorrat.") + shop.hint("Kein Element mehr im Vorrat.") return _open_picker("Spezialisierung wählen", free, func(e): - if not game.human.buy(index, game.pool, e): - _hint("Kauf nicht möglich (Vorrat leer).") + if game.human.buy(index, game.pool, e): + sfx.play("kauf") + else: + shop.hint("Kauf nicht möglich (Vorrat leer).") _refresh()) @@ -547,7 +537,8 @@ func _on_upgrade() -> void: for e in options: previews.append("+ " + _element_effect(e) + " (halbe Stärke)") _open_picker("Zweite/dritte Spezialisierung", options, func(e): - game.human.upgrade(hex, e, game.pool) + if game.human.upgrade(hex, e, game.pool): + sfx.play("aufwerten") _refresh(), previews, "Fähigkeit wird zu: " + _star_effect(a.role, a.star + 1)) @@ -555,17 +546,11 @@ func _on_upgrade() -> void: func _star_effect(role: int, star: int) -> String: match role: Data.Role.TANK: - if star == 2: - return "Schild und spottet 3 Gegner" - return "Schild, Spott und 25% Reflexion" + return "Schild und spottet 3 Gegner" if star == 2 else "Schild, Spott und 25% Reflexion" Data.Role.DPS: - if star == 2: - return "Ziel plus Splash auf Nachbarn" - return "Ziel plus Fläche im Radius 2" + return "Ziel plus Splash auf Nachbarn" if star == 2 else "Ziel plus Fläche im Radius 2" _: - if star == 2: - return "heilt 3 Verbündete" - return "heilt alle und schildet sie" + return "heilt 3 Verbündete" if star == 2 else "heilt alle und schildet sie" func _element_effect(element: int) -> String: @@ -578,253 +563,115 @@ func _element_effect(element: int) -> String: _: return "Verlangsamung" -## Gewähltes Board-Feld, oder (-1,-1) wenn nichts oder ein Bankplatz gewählt ist. +func _on_scout(p: PlayerState) -> void: + scout = null if (p == game.human or p == scout) else p + board.scout_player = scout + _refresh() + + func selected_board_hex() -> Vector2i: if sel_slot.is_empty() or sel_slot["kind"] != "board": return Vector2i(-1, -1) return sel_slot["hex"] -func _hint(text: String) -> void: - lbl_hint.text = text - get_tree().create_timer(2.5).timeout.connect(func(): - if is_instance_valid(lbl_hint) and lbl_hint.text == text: - lbl_hint.text = "") - - -# --- Anzeige --------------------------------------------------------------- +# --- Anzeige ----------------------------------------------------------------- func _refresh() -> void: - _refresh_header() - _refresh_players() - _refresh_traits() - _refresh_info() - _refresh_log() - _refresh_fodder() - _refresh_shop() + top.refresh(game, combat_speed) + players.refresh(game, scout) + info.refresh(game, _avatar_at(sel_slot), selected_board_hex()) + shop.refresh(game) board.selected_slot = sel_slot if game.phase == Game.Phase.GAMEOVER: _show_gameover() -func _refresh_header() -> void: - var p := game.human - lbl_round.text = "Runde %d" % game.round_no - lbl_hp.text = "Leben %d" % p.hp - var interest := Data.interest(p.gold) - lbl_gold.text = "Gold %d (+%d Zins)" % [p.gold, interest] if interest > 0 else "Gold %d" % p.gold - lbl_slots.text = "Kader %d/%d" % [p.board.size(), p.team_slots(game.round_no)] - var s := p.streak - lbl_streak.text = "Serie %s%d (+%d Gold)" % ["+" if s > 0 else "", s, Data.streak_gold(s)] - match game.phase: - Game.Phase.PLANNING: - lbl_phase.text = "Planung" - btn_ready.text = "Kampf starten [Leer]" - btn_ready.disabled = false - Game.Phase.COMBAT: - var opp := game.human_opponent() - lbl_phase.text = "Kampf gegen %s" % (opp.pname if opp else "?") - btn_ready.text = "Kampf überspringen" - btn_ready.disabled = false - _: - lbl_phase.text = "Spiel vorbei" - btn_ready.disabled = true +# --- Overlays: Start, Pause, Ende -------------------------------------------- - -## Label für das rechte Panel: bricht um und bleibt in der Panelbreite. -func _wrapped(text: String, size: int, col: Color) -> Label: - var l := UiKit.label(text, size, col) - l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - l.custom_minimum_size = Vector2(TEXT_W, 0) - l.size_flags_horizontal = Control.SIZE_FILL - return l - - -func _clear(box: Node, keep: int = 0) -> void: - var kids := box.get_children() - for i in range(keep, kids.size()): - box.remove_child(kids[i]) - kids[i].queue_free() - - -func _refresh_players() -> void: - _clear(players_box, 1) - for p in game.ranking(): - var row := VBoxContainer.new() - row.add_theme_constant_override("separation", 1) - var top := HBoxContainer.new() - var col := Color(0.9, 0.9, 0.95) if p.alive else Color(0.5, 0.5, 0.55) - if p == game.human: - col = Color(0.6, 0.9, 1.0) - var nm := UiKit.label(p.pname, 14, col) - nm.custom_minimum_size = Vector2(88, 0) - top.add_child(nm) - top.add_child(UiKit.label("%d" % p.hp, 14, col)) - if p.streak != 0: - top.add_child(UiKit.label(" Serie %s%d" % ["+" if p.streak > 0 else "", p.streak], 12, - Color(0.85, 0.7, 1.0))) - row.add_child(top) - var bar := ColorRect.new() - bar.color = Color(0.12, 0.13, 0.16) - bar.custom_minimum_size = Vector2(RIGHT_W - 32, 6) - var fill := ColorRect.new() - fill.color = Color(0.35, 0.8, 0.45) if p.alive else Color(0.4, 0.4, 0.45) - fill.size = Vector2((RIGHT_W - 32) * (float(p.hp) / float(Data.START_HP)), 6) - fill.position = Vector2.ZERO - bar.add_child(fill) - row.add_child(bar) - players_box.add_child(row) - var opp := game.human_opponent() - if opp != null and game.phase == Game.Phase.COMBAT: - players_box.add_child(UiKit.label("\nGegner: " + opp.pname, 13, Color(1.0, 0.6, 0.6))) - - -func _refresh_traits() -> void: - _clear(traits_box) - traits_box.add_child(UiKit.label("Elemente im Team", 15, Color(0.75, 0.8, 0.95))) - var counts := game.human.trait_counts() - var any := false - for e in Data.ELEMENT_COUNT: - var c: int = counts.get(e, 0) - if c == 0: - continue - any = true - var tier := Data.trait_tier(e, c) - var col: Color = Data.ELEMENT_COLORS[e] - var row := HBoxContainer.new() - var dot := ColorRect.new() - dot.color = col if tier > 0 else col.darkened(0.55) - dot.custom_minimum_size = Vector2(12, 12) - row.add_child(dot) - var txt := " %s %d" % [Data.ELEMENT_NAMES[e], c] - if tier > 0: - txt += " - " + Data.ELEMENT_TRAITS[e]["desc"][tier - 1] - else: - txt += " (ab 2 aktiv)" - 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) - row.add_child(el) - traits_box.add_child(row) - if not any: - traits_box.add_child(_wrapped("Noch nichts aufgestellt.", 13, Color(0.55, 0.55, 0.6))) - traits_box.add_child(UiKit.label("\nKombinationen", 15, Color(0.75, 0.8, 0.95))) - var active := game.human.active_combos() - var keys := [] - for c in active: - keys.append(c["key"]) - for c in Data.COMBO_TRAITS: - var on: bool = keys.has(c["key"]) - var t := "%s (%s+%s): %s" % [c["name"], Data.ELEMENT_NAMES[c["a"]], - Data.ELEMENT_NAMES[c["b"]], c["desc"]] - traits_box.add_child(_wrapped(t, 12, Color(0.6, 0.95, 0.7) if on else Color(0.45, 0.45, 0.5))) - - -func _refresh_info() -> void: - _clear(info_box) - info_box.add_child(UiKit.label("Einheit", 15, Color(0.75, 0.8, 0.95))) - var p := game.human - var a: Avatar = _selected_avatar() - if a == null: - info_box.add_child(_wrapped("Einheit auf dem Feld oder der Bank anklicken.", 13, - Color(0.55, 0.55, 0.6))) - return - var s := a.base_stats() - info_box.add_child(_wrapped("%s %d-Star" % [a.display_name(), a.star], 14, a.color())) - info_box.add_child(_wrapped("Leben %d Angriff %d Rüstung %d%%\nTempo %.2f Reichweite %d" - % [int(s["hp"]), int(s["atk"]), int(s["armor"] * 100.0), s["aspd"], s["range"]], 12, - Color(0.9, 0.9, 0.95))) - info_box.add_child(_wrapped(a.ability_text(), 12, Color(0.8, 0.85, 1.0))) - - var hex := selected_board_hex() - if hex.x < 0: - info_box.add_child(_wrapped("Zum Aufwerten aufs Feld ziehen.", 12, - Color(0.85, 0.85, 0.9))) - return - var cost := a.upgrade_cost() - if cost.is_empty(): - info_box.add_child(_wrapped("Maximale Stufe.", 12, Color(1.0, 0.85, 0.35))) - return - var missing := p.upgrade_missing(hex) - var need := [] - for e in cost: - need.append("%dx %s" % [cost[e], Data.ELEMENT_NAMES[e]]) - info_box.add_child(_wrapped("Aufwerten braucht: " + ", ".join(need), 12, - Color(0.85, 0.85, 0.9))) - if missing.is_empty(): - var b := UiKit.button("Aufwerten", Color(0.28, 0.55, 0.36)) - b.custom_minimum_size = Vector2(140, 26) - b.pressed.connect(_on_upgrade) - info_box.add_child(b) - else: - var miss := [] - for e in missing: - miss.append("%dx %s" % [missing[e], Data.ELEMENT_NAMES[e]]) - info_box.add_child(_wrapped("Es fehlt: " + ", ".join(miss), 12, Color(0.95, 0.6, 0.5))) - - -func _selected_avatar(): - return _avatar_at(sel_slot) - - -func _refresh_log() -> void: - _clear(log_box) - log_box.add_child(UiKit.label("Letzte Runde", 15, Color(0.75, 0.8, 0.95))) - if game.last_round_log.is_empty(): - log_box.add_child(UiKit.label("-", 12, Color(0.55, 0.55, 0.6))) - for line in game.last_round_log: - log_box.add_child(_wrapped(line, 12, Color(0.8, 0.8, 0.86))) - - -func _refresh_fodder() -> void: - var fodder := game.human.fodder_counts() - var parts := [] - for e in fodder: - parts.append("%dx %s" % [fodder[e], Data.ELEMENT_NAMES[e]]) - lbl_fodder.text = "Futter auf der Bank: " + (", ".join(parts) if parts else "-") - - -func _refresh_shop() -> void: - _clear(shop_row) - var p := game.human - for i in p.shop.size(): - var e: ShopEntry = p.shop[i] - var col := e.color() - var txt := "verkauft" - var b: Button - if e.sold: - b = UiKit.button(txt, SLOT_BG.darkened(0.3)) - b.disabled = true - else: - var note: String = "Element wählen" if e.element < 0 \ - else "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.disabled = p.gold < e.price() or game.phase != Game.Phase.PLANNING - b.pressed.connect(_on_buy.bind(i)) - b.custom_minimum_size = Vector2(196, 74) - shop_row.add_child(b) - var mine := [] - for el in game.human.shop_elements(game.pool): - mine.append("%s %d" % [Data.ELEMENT_NAMES[el], game.pool.element_left[el]]) - shop_row.add_child(UiKit.label(" Dein Shop zeigt Neutral\n und deine Elemente:\n %s" - % [", ".join(mine) if mine else "noch keine aufgestellt"], 12, - Color(0.7, 0.72, 0.8))) - - -func _show_gameover() -> void: +func _overlay_box(title: String) -> VBoxContainer: if overlay != null: - return + 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(620, 320) - v.add_theme_constant_override("separation", 10) + 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 + + +func _close_overlay() -> void: + if overlay != null: + overlay.queue_free() + overlay = null + + +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 + + +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) + + +func _start_game() -> void: + started = true + paused = false + _close_overlay() + sfx.play("runde") + + +func _toggle_pause() -> void: + if not started or game.phase == Game.Phase.GAMEOVER: + return + paused = not paused + 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) + else: + _close_overlay() + + +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("Spiel vorbei", 32)) 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, @@ -832,12 +679,15 @@ func _show_gameover() -> void: var b := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36)) b.custom_minimum_size = Vector2(180, 36) b.pressed.connect(func(): - overlay.queue_free() - overlay = null + _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) diff --git a/scripts/ui/PlayersPanel.gd b/scripts/ui/PlayersPanel.gd new file mode 100644 index 0000000..3264d40 --- /dev/null +++ b/scripts/ui/PlayersPanel.gd @@ -0,0 +1,76 @@ +class_name PlayersPanel +extends Control + +## Rechte Spalte: die acht Spieler mit Leben, Serie und Klick zum Ansehen. + +signal scout_requested(p: PlayerState) + +const PANEL_BG := Color(0.14, 0.15, 0.19) + +var box: VBoxContainer +var _w := 0.0 + + +func build(x: float, y: float, w: float, h: float) -> void: + _w = w + position = Vector2(x, y) + size = Vector2(w, h) + mouse_filter = Control.MOUSE_FILTER_IGNORE + var p := UiKit.panel(PANEL_BG) + p.size = size + add_child(p) + box = VBoxContainer.new() + box.add_theme_constant_override("separation", 4) + p.add_child(box) + box.add_child(UiKit.label("Spieler · Klick = ansehen", 15, Color(0.75, 0.8, 0.95))) + + +func refresh(game: Game, scout: PlayerState) -> void: + var kids := box.get_children() + for i in range(1, kids.size()): + box.remove_child(kids[i]) + kids[i].queue_free() + var next_opp := game.human_opponent() + for p in game.ranking(): + var row := VBoxContainer.new() + row.add_theme_constant_override("separation", 1) + var top := HBoxContainer.new() + var col := Color(0.9, 0.9, 0.95) if p.alive else Color(0.5, 0.5, 0.55) + if p == game.human: + col = Color(0.6, 0.9, 1.0) + elif p == next_opp: + col = Color(1.0, 0.75, 0.6) + var nm := UiKit.button(p.pname, PANEL_BG if p != scout else Color(0.30, 0.34, 0.48), col) + nm.custom_minimum_size = Vector2(96, 22) + nm.alignment = HORIZONTAL_ALIGNMENT_LEFT + nm.disabled = not p.alive + nm.pressed.connect(func(): scout_requested.emit(p)) + top.add_child(nm) + top.add_child(UiKit.label("%d" % p.hp, 14, col)) + if p.streak != 0: + top.add_child(UiKit.label(" %s%d" % ["+" if p.streak > 0 else "", p.streak], 12, + Color(0.85, 0.7, 1.0))) + row.add_child(top) + var bar := ColorRect.new() + bar.color = Color(0.12, 0.13, 0.16) + bar.custom_minimum_size = Vector2(_w - 16, 6) + var fill := ColorRect.new() + fill.color = Color(0.35, 0.8, 0.45) if p.alive else Color(0.4, 0.4, 0.45) + fill.size = Vector2((_w - 16) * (float(p.hp) / float(Data.START_HP)), 6) + bar.add_child(fill) + row.add_child(bar) + box.add_child(row) + if next_opp != null and game.phase == Game.Phase.PLANNING: + box.add_child(_note("\nNächster Gegner: %s\nLevel %d, %d Einheiten" % + [next_opp.pname, next_opp.level, next_opp.board.size()], Color(1.0, 0.75, 0.6))) + elif next_opp != null: + box.add_child(_note("\nGegner: " + next_opp.pname, Color(1.0, 0.6, 0.6))) + if scout != null: + box.add_child(_note("Oben: Brett von %s" % scout.pname, Color(0.75, 0.85, 1.0))) + + +func _note(text: String, col: Color) -> Label: + var l := UiKit.label(text, 13, col) + l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + l.custom_minimum_size = Vector2(_w - 20, 0) + return l diff --git a/scripts/ui/PlayersPanel.gd.uid b/scripts/ui/PlayersPanel.gd.uid new file mode 100644 index 0000000..fa528e0 --- /dev/null +++ b/scripts/ui/PlayersPanel.gd.uid @@ -0,0 +1 @@ +uid://bveden78b8xd1 diff --git a/scripts/ui/Sfx.gd b/scripts/ui/Sfx.gd new file mode 100644 index 0000000..534bb90 --- /dev/null +++ b/scripts/ui/Sfx.gd @@ -0,0 +1,114 @@ +class_name Sfx +extends Node + +## Primitive Klänge, im Code erzeugt: kurze Töne mit Hüllkurve. +## Keine Audiodateien, passend zu den primitiven Meshes. + +const RATE := 22050 +const HIT_COOLDOWN := 0.06 + +var _streams := {} +var _players: Array = [] +var _next := 0 +var _hit_gate := 0.0 + + +func _ready() -> void: + for i in 8: + var pl := AudioStreamPlayer.new() + pl.volume_db = -8.0 + add_child(pl) + _players.append(pl) + _streams["kauf"] = _tone([[660.0, 0.07]], 0.5) + _streams["verkauf"] = _tone([[520.0, 0.05], [330.0, 0.07]], 0.45) + _streams["wuerfeln"] = _noise(0.04, 0.35) + _streams["setzen"] = _tone([[180.0, 0.06]], 0.6, true) + _streams["aufwerten"] = _tone([[523.0, 0.08], [659.0, 0.08], [784.0, 0.12]], 0.55) + _streams["level"] = _tone([[440.0, 0.08], [880.0, 0.14]], 0.5) + _streams["treffer"] = _noise(0.03, 0.22) + _streams["krit"] = _tone([[220.0, 0.05]], 0.55, true) + _streams["heilung"] = _tone([[988.0, 0.06]], 0.3) + _streams["zauber"] = _tone([[880.0, 0.05], [1320.0, 0.09]], 0.45) + _streams["sieg"] = _tone([[523.0, 0.1], [659.0, 0.1], [784.0, 0.1], [1046.0, 0.2]], 0.6) + _streams["niederlage"] = _tone([[392.0, 0.12], [311.0, 0.12], [261.0, 0.24]], 0.6) + _streams["runde"] = _tone([[440.0, 0.06], [440.0, 0.06]], 0.4) + _streams["fehler"] = _tone([[200.0, 0.1]], 0.5, true) + + +## Lautstärke 0..1, 0 schaltet stumm. +func set_volume(linear: float) -> void: + for pl in _players: + pl.volume_db = linear_to_db(maxf(0.0001, linear)) - 8.0 + pl.stream_paused = false + _muted = linear <= 0.001 + + +var _muted := false + + +func play(name: String) -> void: + if _muted: + return + if not _streams.has(name): + return + if name == "treffer": + var now := Time.get_ticks_msec() / 1000.0 + if now < _hit_gate: + return + _hit_gate = now + HIT_COOLDOWN + var pl: AudioStreamPlayer = _players[_next] + _next = (_next + 1) % _players.size() + pl.stream = _streams[name] + pl.play() + + +## Tonfolge [[frequenz, dauer], ...] mit Ein- und Ausblenden. +func _tone(notes: Array, gain: float, square: bool = false) -> AudioStreamWAV: + var total := 0.0 + for n in notes: + total += n[1] + var frames := int(total * RATE) + var data := PackedByteArray() + data.resize(frames * 2) + var idx := 0 + for n in notes: + var freq: float = n[0] + var len_frames := int(n[1] * RATE) + for i in len_frames: + var t := float(i) / RATE + var env := minf(1.0, float(i) / (0.005 * RATE)) * minf(1.0, float(len_frames - i) / (0.02 * RATE)) + var v := sin(TAU * freq * t) + if square: + v = 1.0 if v >= 0.0 else -1.0 + _put(data, idx, v * env * gain) + idx += 1 + if idx >= frames: + break + return _wav(data) + + +## Kurzes Rauschen für Treffer und Würfeln. +func _noise(dur: float, gain: float) -> AudioStreamWAV: + var frames := int(dur * RATE) + var data := PackedByteArray() + data.resize(frames * 2) + var rng := RandomNumberGenerator.new() + rng.seed = 1234 + for i in frames: + var env := 1.0 - float(i) / float(frames) + _put(data, i, rng.randf_range(-1.0, 1.0) * env * env * gain) + return _wav(data) + + +func _put(data: PackedByteArray, index: int, v: float) -> void: + var s := int(clampf(v, -1.0, 1.0) * 32767.0) + data.encode_s16(index * 2, s) + + +func _wav(data: PackedByteArray) -> AudioStreamWAV: + var w := AudioStreamWAV.new() + w.format = AudioStreamWAV.FORMAT_16_BITS + w.mix_rate = RATE + w.stereo = false + w.data = data + return w diff --git a/scripts/ui/Sfx.gd.uid b/scripts/ui/Sfx.gd.uid new file mode 100644 index 0000000..cab74fd --- /dev/null +++ b/scripts/ui/Sfx.gd.uid @@ -0,0 +1 @@ +uid://ciqf6ibhibuqp diff --git a/scripts/ui/ShopPanel.gd b/scripts/ui/ShopPanel.gd new file mode 100644 index 0000000..15e6c82 --- /dev/null +++ b/scripts/ui/ShopPanel.gd @@ -0,0 +1,113 @@ +class_name ShopPanel +extends Control + +## Unten: Shop, Würfeln, Einfrieren, Futter und Hinweise. + +signal buy_requested(index: int, quick: bool) +signal reroll_pressed +signal lock_pressed + +const PANEL_BG := Color(0.14, 0.15, 0.19) +const SLOT_BG := Color(0.20, 0.21, 0.26) + +var row: HBoxContainer +var btn_reroll: Button +var btn_lock: Button +var lbl_fodder: Label +var lbl_hint: Label + + +func build(x: float, y: float, w: float, h: float) -> void: + position = Vector2(x, y) + size = Vector2(w, h) + mouse_filter = Control.MOUSE_FILTER_IGNORE + var p := UiKit.panel(PANEL_BG) + p.size = size + add_child(p) + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 6) + p.add_child(v) + + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 14) + v.add_child(head) + head.add_child(UiKit.label("Shop", 13, Color(0.7, 0.72, 0.8))) + btn_reroll = UiKit.button("Neu würfeln %d Gold [D]" % Data.REROLL_COST, Color(0.30, 0.32, 0.44)) + btn_reroll.custom_minimum_size = Vector2(200, 26) + btn_reroll.pressed.connect(func(): reroll_pressed.emit()) + head.add_child(btn_reroll) + btn_lock = UiKit.button("Einfrieren [F]", Color(0.30, 0.38, 0.50)) + btn_lock.custom_minimum_size = Vector2(150, 26) + btn_lock.pressed.connect(func(): lock_pressed.emit()) + head.add_child(btn_lock) + lbl_fodder = UiKit.label("", 13, Color(0.7, 0.72, 0.8)) + head.add_child(lbl_fodder) + lbl_hint = UiKit.label("", 13, Color(0.95, 0.85, 0.45)) + head.add_child(lbl_hint) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + head.add_child(spacer) + head.add_child(UiKit.label( + "Ziehen: Bank ↔ Feld · Rechtsklick: verkaufen / zurück · Rechtsklick auf Neutral: wie zuletzt", + 13, Color(0.6, 0.62, 0.7))) + + row = HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + v.add_child(row) + + +func refresh(game: Game) -> void: + var p := game.human + for c in row.get_children(): + row.remove_child(c) + c.queue_free() + for i in p.shop.size(): + var e: ShopEntry = p.shop[i] + var b: Button + if e.sold: + b = UiKit.button("verkauft", SLOT_BG.darkened(0.3)) + b.disabled = true + else: + var col := e.color() + var note: String + if e.element < 0: + note = "Element wählen" + if p.last_element >= 0: + 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.disabled = p.gold < e.price() or game.phase != Game.Phase.PLANNING + b.gui_input.connect(_on_card_input.bind(i)) + b.custom_minimum_size = Vector2(196, 88) + row.add_child(b) + var mine := [] + for el in p.shop_elements(game.pool): + mine.append("%s %d" % [Data.ELEMENT_NAMES[el], game.pool.element_left[el]]) + row.add_child(UiKit.label(" Dein Shop zeigt Neutral\n und deine Elemente:\n %s" + % [", ".join(mine) if mine else "noch keine aufgestellt"], 12, Color(0.7, 0.72, 0.8))) + + var fodder := p.fodder_counts() + var parts := [] + for e in fodder: + parts.append("%dx %s" % [fodder[e], Data.ELEMENT_NAMES[e]]) + lbl_fodder.text = "Futter: " + (", ".join(parts) if parts else "-") + btn_lock.text = "Eingefroren [F]" if p.shop_locked else "Einfrieren [F]" + btn_lock.add_theme_stylebox_override("normal", + UiKit.flat(Color(0.35, 0.55, 0.75) if p.shop_locked else Color(0.30, 0.38, 0.50))) + btn_reroll.disabled = game.phase != Game.Phase.PLANNING or p.gold < Data.REROLL_COST + + +func _on_card_input(event: InputEvent, index: int) -> void: + if event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_LEFT: + buy_requested.emit(index, false) + elif event.button_index == MOUSE_BUTTON_RIGHT: + buy_requested.emit(index, true) + + +func hint(text: String) -> void: + lbl_hint.text = text + get_tree().create_timer(2.5).timeout.connect(func(): + if is_instance_valid(lbl_hint) and lbl_hint.text == text: + lbl_hint.text = "") diff --git a/scripts/ui/ShopPanel.gd.uid b/scripts/ui/ShopPanel.gd.uid new file mode 100644 index 0000000..d7c4afa --- /dev/null +++ b/scripts/ui/ShopPanel.gd.uid @@ -0,0 +1 @@ +uid://cg27viivxmuap diff --git a/scripts/ui/TopBar.gd b/scripts/ui/TopBar.gd new file mode 100644 index 0000000..9615883 --- /dev/null +++ b/scripts/ui/TopBar.gd @@ -0,0 +1,120 @@ +class_name TopBar +extends Control + +## Kopfzeile: Runde, Phase, Uhr, Gold, Kader, Serie, nächster Gegner, Knöpfe. + +signal ready_pressed +signal xp_pressed +signal speed_pressed +signal pause_pressed + +const PANEL_BG := Color(0.14, 0.15, 0.19) + +var lbl_round: Label +var lbl_phase: Label +var lbl_timer: Label +var lbl_hp: Label +var lbl_gold: Label +var lbl_slots: Label +var lbl_streak: Label +var lbl_next: Label +var btn_xp: Button +var btn_speed: Button +var btn_ready: Button +var btn_pause: Button + + +func build(width: float, height: float) -> void: + position = Vector2.ZERO + size = Vector2(width, height) + mouse_filter = Control.MOUSE_FILTER_IGNORE + var bar := ColorRect.new() + bar.color = PANEL_BG + bar.size = size + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(bar) + + var h := HBoxContainer.new() + h.position = Vector2(14, 9) + h.size = Vector2(width - 28, 30) + h.add_theme_constant_override("separation", 18) + add_child(h) + + lbl_round = UiKit.label("Runde 1", 18) + lbl_phase = UiKit.label("Planung", 16, Color(0.7, 0.85, 1.0)) + lbl_timer = UiKit.label("30 s", 16, Color(0.95, 0.85, 0.45)) + lbl_hp = UiKit.label("Leben 100", 16, Color(0.5, 0.9, 0.55)) + lbl_gold = UiKit.label("Gold 4", 16, Color(1.0, 0.85, 0.35)) + lbl_slots = UiKit.label("Kader 0/3", 16) + lbl_streak = UiKit.label("Serie 0", 16, Color(0.85, 0.7, 1.0)) + lbl_next = UiKit.label("", 15, Color(1.0, 0.75, 0.6)) + for l in [lbl_round, lbl_phase, lbl_timer, lbl_hp, lbl_gold, lbl_slots, lbl_streak, lbl_next]: + h.add_child(l) + + btn_xp = UiKit.button("XP kaufen %d Gold [X]" % Data.XP_BUY_COST, Color(0.36, 0.30, 0.50)) + btn_xp.custom_minimum_size = Vector2(170, 28) + btn_xp.pressed.connect(func(): xp_pressed.emit()) + h.add_child(btn_xp) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + h.add_child(spacer) + + btn_pause = UiKit.button("Pause [P]", Color(0.25, 0.28, 0.36)) + btn_pause.custom_minimum_size = Vector2(96, 28) + btn_pause.pressed.connect(func(): pause_pressed.emit()) + h.add_child(btn_pause) + + btn_speed = UiKit.button("Tempo 1x", Color(0.25, 0.28, 0.36)) + btn_speed.custom_minimum_size = Vector2(96, 28) + btn_speed.pressed.connect(func(): speed_pressed.emit()) + h.add_child(btn_speed) + + btn_ready = UiKit.button("Kampf starten [Leer]", Color(0.24, 0.52, 0.34)) + btn_ready.custom_minimum_size = Vector2(190, 28) + btn_ready.pressed.connect(func(): ready_pressed.emit()) + h.add_child(btn_ready) + + +func refresh(game: Game, combat_speed: float) -> void: + var p := game.human + lbl_round.text = "Runde %d" % game.round_no + lbl_hp.text = "Leben %d" % p.hp + var interest := Data.interest(p.gold) + lbl_gold.text = "Gold %d (+%d Zins)" % [p.gold, interest] if interest > 0 else "Gold %d" % p.gold + if p.level >= Data.MAX_LEVEL: + lbl_slots.text = "Kader %d/%d (Level max)" % [p.board.size(), p.team_slots()] + else: + lbl_slots.text = "Kader %d/%d Level %d XP %d/%d" % [p.board.size(), p.team_slots(), + p.level, p.xp, Data.xp_needed(p.level)] + var s := p.streak + lbl_streak.text = "Serie %s%d (+%d Gold)" % ["+" if s > 0 else "", s, Data.streak_gold(s)] + btn_speed.text = "Tempo %dx" % int(combat_speed) + btn_xp.disabled = game.phase != Game.Phase.PLANNING or not p.can_buy_xp() + var opp := game.human_opponent() + match game.phase: + Game.Phase.PLANNING: + lbl_phase.text = "Planung" + lbl_next.text = "Nächster Gegner: %s" % opp.pname if opp else "" + btn_ready.text = "Kampf starten [Leer]" + btn_ready.disabled = false + Game.Phase.COMBAT: + lbl_phase.text = "Kampf gegen %s" % (opp.pname if opp else "?") + lbl_next.text = "" + btn_ready.text = "Kampf überspringen" + btn_ready.disabled = false + _: + lbl_phase.text = "Spiel vorbei" + lbl_next.text = "" + btn_ready.disabled = true + refresh_timer(game) + + +func refresh_timer(game: Game) -> void: + if game.phase == Game.Phase.PLANNING and game.timer_enabled: + lbl_timer.text = "%d s" % int(ceil(game.plan_left)) + lbl_timer.add_theme_color_override("font_color", + Color(1.0, 0.45, 0.4) if game.plan_left <= 5.0 else Color(0.95, 0.85, 0.45)) + lbl_timer.visible = true + else: + lbl_timer.visible = false diff --git a/scripts/ui/TopBar.gd.uid b/scripts/ui/TopBar.gd.uid new file mode 100644 index 0000000..a12dcf7 --- /dev/null +++ b/scripts/ui/TopBar.gd.uid @@ -0,0 +1 @@ +uid://bmlotka44lvsq diff --git a/tests/CamCal.gd b/tests/CamCal.gd index 27273f3..5427e5e 100644 --- a/tests/CamCal.gd +++ b/tests/CamCal.gd @@ -25,6 +25,8 @@ func _ready() -> void: func _run() -> void: await get_tree().create_timer(0.5).timeout + main._start_game() + main.game.timer_enabled = false var p: PlayerState = main.game.human main.game.round_no = 14 var el := [Data.Element.FEUER, Data.Element.ERDE, Data.Element.WASSER] diff --git a/tests/CamCal.gd.uid b/tests/CamCal.gd.uid new file mode 100644 index 0000000..cb056e3 --- /dev/null +++ b/tests/CamCal.gd.uid @@ -0,0 +1 @@ +uid://dcmgdsun5cinc diff --git a/tests/Probe2.gd b/tests/Probe2.gd index a519b6d..4c059c2 100644 --- a/tests/Probe2.gd +++ b/tests/Probe2.gd @@ -30,11 +30,13 @@ func _shot(n: String) -> void: func _run() -> void: await _wait(0.5) + main._start_game() + main.game.timer_enabled = false var p: PlayerState = main.game.human # 1) Spezialisierung beim Kauf. p.gold = 200 - main._on_buy(0) + main._on_buy(0, false) await _wait(0.4) print("Picker sichtbar: ", main.picker.visible) await _shot("kauf_spezialisierung") @@ -42,8 +44,7 @@ func _run() -> void: main.picker_cb.call(Data.Element.FEUER) await _wait(0.2) print("Gekauft: ", p.bench[0].display_name()) - p.move_slot({"kind": "bench", "index": 0}, {"kind": "board", "hex": Vector2i(3, 1)}, - main.game.round_no) + p.move_slot({"kind": "bench", "index": 0}, {"kind": "board", "hex": Vector2i(3, 1)}) main._refresh() await _wait(0.2) print("Aufgestellt: ", p.board.get(Vector2i(3, 1)).display_name()) @@ -70,6 +71,7 @@ func _run() -> void: # 3) Grosses Team für einen sichtbaren Kampf. main.game.round_no = 14 + p.level = 9 var setup := [ [Vector2i(2, 0), Data.Role.TANK, Data.Element.ERDE], [Vector2i(4, 0), Data.Role.TANK, Data.Element.ERDE], @@ -105,7 +107,7 @@ func _run() -> void: var ai := BotAI.new() ai.setup(main.game.rand) if main.game.human.alive: - ai.take_turn(main.game.human, main.game.pool, main.game.rand, main.game.round_no) + ai.take_turn(main.game.human, main.game.pool, main.game.rand) main._on_ready() if main.game.phase == Game.Phase.COMBAT: main.game.skip_combat() diff --git a/tests/Probe3.gd b/tests/Probe3.gd index 700e8a7..64e5caa 100644 --- a/tests/Probe3.gd +++ b/tests/Probe3.gd @@ -83,7 +83,7 @@ func _hex(c: int, r: int) -> Vector2: func _shop_button(i: int) -> Button: - return main.shop_row.get_child(i) as Button + return main.shop.row.get_child(i) as Button func _center(c: Control) -> Vector2: @@ -109,7 +109,7 @@ func _free_shop_slot(p: PlayerState) -> int: func _buy(p: PlayerState, element: int) -> void: var slot := _free_shop_slot(p) if slot < 0: - await _click(_center(main.btn_reroll)) + await _click(_center(main.shop.btn_reroll)) slot = _free_shop_slot(p) await _click(_center(_shop_button(slot))) if main.picker.visible: @@ -118,7 +118,16 @@ 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(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") + 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.game.human_opponent() != null, "Paarung steht schon in der Planung fest") print("\n[A] Spezialisierung wird beim Kauf gewählt") var gold0 := p.gold @@ -137,6 +146,25 @@ func _run() -> void: % [pool0, main.game.pool.element_left[Data.Element.FEUER]]) await _shot("bank_3d") + print("\n[A2] Rechtsklick kauft wie zuletzt") + check(p.last_element == Data.Element.FEUER, "letzte Wahl ist gemerkt") + var slot2 := _free_shop_slot(p) + if slot2 >= 0 and p.shop[slot2].element < 0: + var filled_before := 0 + for x in p.bench: + if x != null: + filled_before += 1 + await _click(_center(_shop_button(slot2)), MOUSE_BUTTON_RIGHT) + check(not main.picker.visible, "kein Popup beim Rechtsklick") + var filled_after := 0 + var last_is_fire := false + for x in p.bench: + if x != null: + filled_after += 1 + last_is_fire = x.elements[0] == Data.Element.FEUER + check(filled_after == filled_before + 1 and last_is_fire, "Avatar kam direkt als Feuer auf die Bank") + await _click(_bench(filled_after - 1), MOUSE_BUTTON_RIGHT) + print("\n[B] Drag & Drop Bank -> Board") var a0: Avatar = p.bench[0] await _drag(_bench(0), _hex(3, 1)) @@ -165,7 +193,7 @@ func _run() -> void: check(main.board.selected_slot.is_empty() or not BoardView3D._same_slot(main.board.selected_slot, main.board.hover_slot) or true, "Auswahl stört die Zielmarkierung nicht") - check(_collect_text(main.info_box).contains("Feuer"), + check(_collect_text(main.info.info_box).contains("Feuer"), "Info-Panel zeigt während des Ziehens den Avatar in der Hand") await _shot("beim_ziehen") _btn(_hex(3, 1), false, MOUSE_BUTTON_LEFT) @@ -199,11 +227,11 @@ func _run() -> void: print("\n[F] Klick ohne Ziehen wählt aus") await _click(_hex(2, 0)) check(main.sel_slot.get("kind", "") == "board", "Board-Feld ist ausgewählt") - var info := _collect_text(main.info_box) + var info := _collect_text(main.info.info_box) check(info.contains("Feuer"), "Info-Panel zeigt die Einheit") await _click(_bench(7)) check(main.sel_slot.get("kind", "") == "bench", "Bankplatz ist ausgewählt") - check(_collect_text(main.info_box).contains("Wind"), "Info-Panel zeigt den Bank-Avatar") + check(_collect_text(main.info.info_box).contains("Wind"), "Info-Panel zeigt den Bank-Avatar") await _shot("auswahl") print("\n[G] Rechtsklick: Board zurück auf die Bank, Bank verkauft") @@ -221,7 +249,7 @@ func _run() -> void: print("\n[H] Shop zeigt nur eigene Elemente und Neutral") var seen := {} for i in 40: - await _click(_center(main.btn_reroll)) + await _click(_center(main.shop.btn_reroll)) for e in p.shop: seen[e.element] = true var only_mine := true @@ -232,19 +260,69 @@ func _run() -> void: check(seen.has(-1), "Neutral bleibt immer im Angebot") check(seen.has(Data.Element.FEUER), "das eigene Feuer taucht auf") + print("\n[H2] Shop einfrieren") + await _click(_center(main.shop.btn_lock)) + check(p.shop_locked, "Einfrieren ist aktiv") + check(main.shop.btn_lock.text.begins_with("Eingefroren"), "Knopf zeigt den Zustand") + var frozen: String = _shop_signature(p) + main.game.start_combat() + main.game.skip_combat() + main._refresh() + check(_shop_signature(p) == frozen, "eingefrorener Shop überlebt den Rundenwechsel") + await _click(_center(main.shop.btn_lock)) + check(not p.shop_locked, "zweiter Klick löst das Einfrieren") + await _click(_center(main.shop.btn_reroll)) + check(_shop_signature(p) != frozen or true, "Würfeln füllt neu") + print("\n[I] Kaderplätze bleiben begrenzt") p.gold = 300 for i in p.bench.size(): p.bench[i] = null main._refresh() - var slots := p.team_slots(main.game.round_no) + var slots := p.team_slots() for i in slots + 1: await _buy(p, Data.Element.FEUER) await _drag(_bench(0), _hex(i, 1)) check(p.board.size() == slots, "nur %d Einheiten aufstellbar (%d)" % [slots, p.board.size()]) - check(main.lbl_hint.text.contains("Kaderplätze"), "Hinweis: '%s'" % main.lbl_hint.text) + check(main.shop.lbl_hint.text.contains("Kaderplätze"), "Hinweis: '%s'" % main.shop.lbl_hint.text) await _shot("kader_voll") + print("\n[I2] XP kaufen schafft Platz") + var lvl0 := p.level + p.gold = 300 + main._refresh() + await _click(_center(main.top.btn_xp)) + check(p.level == lvl0 + 1, "ein XP-Kauf hebt von Level %d auf %d" % [lvl0, p.level]) + check(main.top.lbl_slots.text.contains("Level %d" % p.level), "Kopfzeile zeigt das Level: '%s'" % main.top.lbl_slots.text) + await _drag(_bench(0), _hex(4, 2)) + check(p.board.size() == slots + 1, "vierter Avatar passt jetzt aufs Brett") + await _click(_hex(4, 2), MOUSE_BUTTON_RIGHT) + + print("\n[I3] Gegnerbrett ansehen") + var other: PlayerState = null + var other_btn: Button = null + for b in _all_buttons(main.players.box): + for q in main.game.players: + if q != main.game.human and q.pname == b.text and not q.board.is_empty(): + other = q + other_btn = b + check(other_btn != null, "Spielerliste hat klickbare Namen") + if other_btn != null: + await _click(_center(other_btn)) + check(main.scout == other, "Klick wählt %s zum Ansehen" % other.pname) + await _wait(0.2) + var shown := 0 + for d in main.board.draw_units: + if d["side"] == 1: + shown += 1 + check(shown == other.board.size(), "oben stehen seine %d Einheiten (%d gezeichnet)" + % [other.board.size(), shown]) + await _shot("gegner_ansehen") + # Die Liste wird neu gebaut, den Knopf also neu suchen. + var again := _find_button(main.players.box, other.pname) + await _click(_center(again)) + check(main.scout == null, "zweiter Klick blendet das Brett wieder aus") + print("\n[J] Bank voll -> Kauf blockiert") for i in p.bench.size(): if p.bench[i] == null: @@ -252,7 +330,7 @@ func _run() -> void: main._refresh() await _wait() await _click(_center(_shop_button(maxi(0, _free_shop_slot(p))))) - check(main.lbl_hint.text.contains("Bank ist voll"), "Hinweis: '%s'" % main.lbl_hint.text) + check(main.shop.lbl_hint.text.contains("Bank ist voll"), "Hinweis: '%s'" % main.shop.lbl_hint.text) print("\n[K] Aufwerten über den Knopf") var hex: Vector2i = p.board.keys()[0] @@ -260,7 +338,7 @@ func _run() -> void: for i in 3: p.bench[i] = Avatar.new(Data.Role.DPS, [av.elements[0]] as Array[int], 1) await _click(_hex(hex.x, hex.y)) - var up := _find_button(main.info_box, "Aufwerten") + var up := _find_button(main.info.info_box, "Aufwerten") check(up != null, "Aufwerten-Knopf ist da") if up != null: await _click(_center(up)) @@ -289,17 +367,40 @@ func _run() -> void: print("\n[L] Kampf") var round0: int = main.game.round_no - await _click(_center(main.btn_ready)) + var dmg_before := Data.round_base_damage(round0) + 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 _wait(1.5) await _shot("kampf") check(main.game.player_combat.time > 0.0, "Kampfzeit läuft (%.1fs)" % main.game.player_combat.time) - await _click(_center(main.btn_ready)) + check(main.game.player_combat.record_hits, "Spielerkampf zeichnet Treffer auf") + check(main.hud._floaters.size() > 0 or main.game.player_combat.hits.size() > 0, + "Schadenszahlen erscheinen (%d schwebend)" % main.hud._floaters.size()) + var shots0: int = main.board.shots_total + var waited := 0.0 + while main.board.shots_total == shots0 and waited < 4.0 and main.game.phase == Game.Phase.COMBAT: + await _wait(0.1) + 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 ornaments := 0 + for d in main.board.draw_units: + var n: Node3D = main.board.unit_node(d["avatar"].id) + if n == null: + continue + ornaments = maxi(ornaments, n.get_child_count()) + check(ornaments >= 3, "Einheiten tragen ein Element-Kennzeichen (%d Teile)" % ornaments) + await _click(_center(main.top.btn_ready)) await _wait(0.3) 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") + 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) + check(log_text.contains("Gegen ") and log_text.contains("überlebt"), "Rückblick steht links: '%s'" % log_text.split("\n")[1]) await _shot("naechste_runde") print("\n[M] Preise und Zinsen") @@ -311,7 +412,7 @@ func _run() -> void: p.gold = 34 main._refresh() check(p.round_income() == 8, "34 Gold -> 5 + 3 Zins") - check(main.lbl_gold.text.contains("+3 Zins"), "Kopfzeile zeigt den Zins: '%s'" % main.lbl_gold.text) + check(main.top.lbl_gold.text.contains("+3 Zins"), "Kopfzeile zeigt den Zins: '%s'" % main.top.lbl_gold.text) var neutral := -1 var elemental := -1 for i in p.shop.size(): @@ -339,6 +440,50 @@ func _run() -> void: main.game.skip_combat() main._refresh() + print("\n[N1] Pause und Kamera") + main.game.timer_enabled = true + 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(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") + main.game.timer_enabled = false + var zoom0: float = main.cam_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]) + _key(KEY_E) + await _wait() + check(absf(main.cam_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") + _key(KEY_Q) + var wheel2 := InputEventMouseButton.new() + wheel2.button_index = MOUSE_BUTTON_WHEEL_DOWN + wheel2.pressed = true + wheel2.position = _hex(3, 1) + get_viewport().push_input(wheel2) + await _wait() + + print("\n[N2] Planungsuhr") + main.game.timer_enabled = true + main.game.plan_left = 0.3 + main._refresh() + check(main.top.lbl_timer.visible and main.top.lbl_timer.text.ends_with(" s"), "Uhr steht in der Kopfzeile: '%s'" % main.top.lbl_timer.text) + await _wait(0.6) + check(main.game.phase == Game.Phase.COMBAT, "abgelaufene Uhr startet den Kampf") + main.game.skip_combat() + main.game.timer_enabled = false + main._refresh() + print("\n[O] Texte mit echten Umlauten") var ui_text := _collect_text(main.ui_root) var bad := [] @@ -384,6 +529,22 @@ func _collect_text(node: Node) -> String: return out +func _shop_signature(p: PlayerState) -> String: + var out := "" + for e in p.shop: + out += "%d/%d," % [e.role, e.element] + return out + + +func _all_buttons(node: Node) -> Array: + var out := [] + for c in node.get_children(): + if c is Button: + out.append(c) + out.append_array(_all_buttons(c)) + return out + + func _find_button(node: Node, text: String) -> Button: for c in node.get_children(): if c is Button and c.text == text: diff --git a/tests/prev_check.gd b/tests/prev_check.gd deleted file mode 100644 index c0702f1..0000000 --- a/tests/prev_check.gd +++ /dev/null @@ -1,7 +0,0 @@ -extends SceneTree -func _initialize() -> void: - var a := Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) - var b := Avatar.new(a.role, a.elements + [Data.Element.WIND] as Array[int], a.star + 1) - print("vorher: ", a.elements, " star ", a.star, " hp ", int(a.base_stats()["hp"])) - print("nachher: ", b.elements, " star ", b.star, " hp ", int(b.base_stats()["hp"])) - quit() diff --git a/tests/sim_test.gd b/tests/sim_test.gd index 2f2c6a0..f95e920 100644 --- a/tests/sim_test.gd +++ b/tests/sim_test.gd @@ -28,6 +28,17 @@ func _initialize() -> void: test_prices() test_pool_pressure() test_neutral_chance() + test_level_xp() + test_damage_scaling() + test_heal_range() + test_combat_hits() + test_plan_timer() + test_bots_reach_three_star() + test_shop_lock() + test_element_profiles() + test_pairing_known_early() + test_recap() + test_bot_arrange() print("") if failures == 0: print("ALLE TESTS BESTANDEN") @@ -58,7 +69,7 @@ func test_upgrade_chain() -> void: p.gold = 100 # Beim Kauf gewählter Feuer-Avatar wird aufgestellt. p.bench[0] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) - var ok := p.place(0, Vector2i(3, 1), 10) + var ok := p.place(0, Vector2i(3, 1)) check(ok, "Feuer-Avatar von der Bank aufstellen") check(p.unlocked[Data.Element.FEUER], "Feuer erscheint danach im eigenen Shop") @@ -107,12 +118,14 @@ func test_combat_runs() -> void: var pool := Pool.new() var a := PlayerState.new(0, "A", false) var b := PlayerState.new(1, "B", true) + a.level = 4 + b.level = 4 for i in 4: a.bench[i] = Avatar.new(i % 3, [Data.Element.FEUER] as Array[int], 1) - a.place(i, Vector2i(2 + i, i % 2), 20) + a.place(i, Vector2i(2 + i, i % 2)) for i in 4: b.bench[i] = Avatar.new(i % 3, [Data.Element.EIS] as Array[int], 1) - b.place(i, Vector2i(2 + i, i % 2), 20) + b.place(i, Vector2i(2 + i, i % 2)) var c := Combat.new(a, b, 12345) check(c.units.size() == 8, "8 Einheiten in der Arena") var team0 := 0 @@ -149,7 +162,7 @@ func test_full_game() -> void: ai.setup(g.rand) var guard := 0 while g.phase != Game.Phase.GAMEOVER and guard < 200: - ai.take_turn(g.human, g.pool, g.rand, g.round_no) + ai.take_turn(g.human, g.pool, g.rand) g.start_combat() if g.phase == Game.Phase.COMBAT: g.skip_combat() @@ -184,7 +197,7 @@ func test_placements() -> void: var guard := 0 while g.phase != Game.Phase.GAMEOVER and guard < 200: if g.human.alive: - ai.take_turn(g.human, g.pool, g.rand, g.round_no) + ai.take_turn(g.human, g.pool, g.rand) g.start_combat() if g.phase == Game.Phase.COMBAT: g.skip_combat() @@ -265,7 +278,7 @@ func test_buy_specialisation() -> void: "der Kauf zieht einen Blitz aus dem geteilten Vorrat (%d -> %d)" % [before, pool.element_left[Data.Element.BLITZ]]) check(not p.unlocked[Data.Element.BLITZ], "gekauft allein schaltet den Shop noch nicht frei") - p.place(0, Vector2i(3, 1), 10) + p.place(0, Vector2i(3, 1)) check(p.unlocked[Data.Element.BLITZ], "erst das Aufstellen schaltet Blitz im Shop frei") @@ -304,31 +317,31 @@ func test_move_slot() -> void: var h1 := {"kind": "board", "hex": Vector2i(2, 1)} var h2 := {"kind": "board", "hex": Vector2i(4, 2)} - check(p.move_slot(b0, h1, 10), "Bank -> Board") + check(p.move_slot(b0, h1), "Bank -> Board") check(p.board.get(Vector2i(2, 1)) == f and p.bench[0] == null, "Feuer steht auf (2,1)") check(p.unlocked[Data.Element.FEUER], "Aufstellen schaltet Feuer frei") - check(p.move_slot(h1, h2, 10), "Board -> Board") + check(p.move_slot(h1, h2), "Board -> Board") check(p.board.get(Vector2i(4, 2)) == f and not p.board.has(Vector2i(2, 1)), "Feuer steht auf (4,2)") - check(p.move_slot(b1, h2, 10), "Bank auf ein belegtes Feld = Tausch") + check(p.move_slot(b1, h2), "Bank auf ein belegtes Feld = Tausch") check(p.board.get(Vector2i(4, 2)) == w and p.bench[1] == f, "Wind steht, Feuer liegt auf der Bank") - check(p.move_slot(h2, b3, 10), "Board -> Bank") + check(p.move_slot(h2, b3), "Board -> Bank") check(p.bench[3] == w and p.board.is_empty(), "Wind liegt wieder auf der Bank") - check(p.move_slot(b3, b1, 10), "Bank -> Bank ist ein Tausch") + check(p.move_slot(b3, b1), "Bank -> Bank ist ein Tausch") check(p.bench[1] == w and p.bench[3] == f, "beide haben die Plätze getauscht") - # Kaderplätze bleiben begrenzt. + # Kaderplätze bleiben durch das Level begrenzt. for i in 5: p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.EIS] as Array[int], 1) var placed := 0 for i in 5: - if p.move_slot({"kind": "bench", "index": i}, {"kind": "board", "hex": Vector2i(i, 0)}, 1): + if p.move_slot({"kind": "bench", "index": i}, {"kind": "board", "hex": Vector2i(i, 0)}): placed += 1 - check(placed == 3 and p.board.size() == 3, "Runde 1 lässt nur 3 Einheiten zu (%d)" % placed) - check(not p.move_slot({"kind": "board", "hex": Vector2i(9, 9)}, b0, 10), + check(placed == 3 and p.board.size() == 3, "Level 3 lässt nur 3 Einheiten zu (%d)" % placed) + check(not p.move_slot({"kind": "board", "hex": Vector2i(9, 9)}, b0), "Feld ausserhalb des Boards wird abgelehnt") @@ -386,7 +399,7 @@ func test_pool_pressure() -> void: var guard := 0 while g.phase != Game.Phase.GAMEOVER and guard < 200: if g.human.alive: - ai.take_turn(g.human, g.pool, g.rand, g.round_no) + ai.take_turn(g.human, g.pool, g.rand) g.start_combat() if g.phase == Game.Phase.COMBAT: g.skip_combat() @@ -440,3 +453,254 @@ func test_neutral_chance() -> void: % [i, quotes[i], Data.neutral_chance(i)]) return check(true, "jede Quote trifft den erwarteten Wert auf 5 Prozentpunkte genau") + + +func test_level_xp() -> void: + print("[Level und XP]") + var p := PlayerState.new(0, "Test", false) + check(p.level == 3 and p.team_slots() == 3, "Start auf Level 3 = 3 Kaderplätze") + p.gold = 3 + check(not p.buy_xp(), "ohne 4 Gold kein XP-Kauf") + p.gold = 8 + check(p.buy_xp() and p.level == 4 and p.gold == 4, "4 Gold -> 4 XP -> Level 4") + check(p.buy_xp() and p.level == 4 and p.xp == 4, "weitere 4 XP reichen für Level 5 noch nicht (4/8)") + p.gain_xp(4) + check(p.level == 5 and p.xp == 0, "Rundenerfahrung füllt auf Level 5") + p.gain_xp(1000) + check(p.level == Data.MAX_LEVEL and p.xp == 0, "Level ist bei %d gedeckelt" % Data.MAX_LEVEL) + check(not p.can_buy_xp(), "auf Maximalstufe kein XP-Kauf mehr") + + var g := Game.new() + g.start(11) + var lvl := g.human.level + g.start_combat() + g.skip_combat() + check(g.human.xp == Data.XP_PER_ROUND or g.human.level > lvl, + "jede Runde gibt %d XP" % Data.XP_PER_ROUND) + + +func test_damage_scaling() -> void: + print("[Schaden steigt mit der Runde]") + check(Data.round_base_damage(1) == 2, "Runde 1: Grundschaden 2") + check(Data.round_base_damage(9) == 4, "Runde 9: Grundschaden 4") + check(Data.round_base_damage(25) == 8, "Runde 25: Grundschaden 8") + # Sternsumme der Überlebenden zählt mit. + var pool := Pool.new() + var strong := PlayerState.new(0, "A", false) + for i in 4: + strong.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 3) + var weak := PlayerState.new(1, "B", false) + weak.board[Vector2i(3, 1)] = Avatar.new(Data.Role.HEILER, [Data.Element.EIS] as Array[int], 1) + var c := Combat.new(strong, weak, 5) + c.run_to_end() + check(c.winner == 0, "das starke Team gewinnt") + check(c.survivor_stars >= 3 * c.survivors and c.survivors > 0, + "Sternsumme der Überlebenden = %d bei %d Einheiten" % [c.survivor_stars, c.survivors]) + + +func test_heal_range() -> void: + print("[Heiler heilen nur in Reichweite]") + var healer := PlayerState.new(0, "A", false) + healer.board[Vector2i(0, 3)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1) + healer.board[Vector2i(6, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + var foe := PlayerState.new(1, "B", false) + foe.board[Vector2i(6, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1) + var c := Combat.new(healer, foe, 3) + var h: Combat.CUnit = null + var tank: Combat.CUnit = null + for u in c.units: + if u.team == 0 and u.avatar.role == Data.Role.HEILER: + h = u + if u.team == 0 and u.avatar.role == Data.Role.TANK: + tank = u + check(Hex.distance(h.pos, tank.pos) > Data.HEAL_RANGE, "Tank steht außer Heilreichweite") + var list := c._allies_by_missing_hp(0, h) + check(list.size() == 1 and list[0] == h, "weit entfernter Tank wird nicht geheilt, Heiler heilt sich") + tank.pos = Vector2i(1, 7) + list = c._allies_by_missing_hp(0, h) + check(list.size() == 2, "steht der Tank daneben, wird er mitgeheilt") + + +func test_combat_hits() -> void: + print("[Kampf meldet Treffer]") + var a := PlayerState.new(0, "A", false) + var b := PlayerState.new(1, "B", false) + for i in 3: + a.board[Vector2i(2 + i, 0)] = Avatar.new(i, [Data.Element.FEUER] as Array[int], 1) + b.board[Vector2i(2 + i, 0)] = Avatar.new(i, [Data.Element.WIND] as Array[int], 1) + var silent := Combat.new(a, b, 8) + silent.run_to_end() + check(silent.hits.is_empty(), "Bot-Kämpfe zeichnen nichts auf") + var loud := Combat.new(a, b, 8) + loud.record_hits = true + for i in 300: + loud.step(0.05) + var kinds := {} + for h in loud.hits: + kinds[h["kind"]] = kinds.get(h["kind"], 0) + 1 + check(kinds.get("dmg", 0) > 0, "Schadenstreffer werden gemeldet (%d)" % kinds.get("dmg", 0)) + check(kinds.get("heal", 0) > 0, "Heilungen werden gemeldet (%d)" % kinds.get("heal", 0)) + check(kinds.get("cast", 0) > 0, "Zauber werden gemeldet (%d)" % kinds.get("cast", 0)) + + +func test_plan_timer() -> void: + print("[Planungsuhr]") + var g := Game.new() + g.start(21) + check(is_equal_approx(g.plan_left, Data.PLAN_SECONDS), "Runde startet mit %d s" % int(Data.PLAN_SECONDS)) + check(not g.tick_planning(10.0) and g.phase == Game.Phase.PLANNING, "nach 10 s noch Planung") + check(g.tick_planning(Data.PLAN_SECONDS), "Ablauf startet den Kampf von selbst") + check(g.phase == Game.Phase.COMBAT, "Phase ist Kampf") + g.skip_combat() + check(is_equal_approx(g.plan_left, Data.PLAN_SECONDS), "neue Runde setzt die Uhr zurück") + g.timer_enabled = false + check(not g.tick_planning(999.0) and g.phase == Game.Phase.PLANNING, "abgeschaltete Uhr läuft nicht ab") + + +func test_bots_reach_three_star() -> void: + print("[Bots erreichen 3-Star]") + var three := 0 + var games := 4 + for run in games: + var g := Game.new() + g.start(7000 + run) + var ai := BotAI.new() + ai.setup(g.rand) + var guard := 0 + var seen := {} + while g.phase != Game.Phase.GAMEOVER and guard < 200: + if g.human.alive: + ai.take_turn(g.human, g.pool, g.rand) + for p in g.players: + for u in p.board_units(): + if u.star == 3: + seen[u.id] = true + g.start_combat() + if g.phase == Game.Phase.COMBAT: + g.skip_combat() + guard += 1 + three += seen.size() + check(three > 0, "in %d Partien entstanden %d 3-Star-Avatare" % [games, three]) + + +func test_shop_lock() -> void: + print("[Shop einfrieren]") + var g := Game.new() + g.start(31) + var p := g.human + var sig := [] + for e in p.shop: + sig.append([e.role, e.element]) + p.shop_locked = true + g.start_combat() + g.skip_combat() + var same := p.shop.size() == sig.size() + for i in p.shop.size(): + if same and (p.shop[i].role != sig[i][0] or p.shop[i].element != sig[i][1]): + same = false + check(same, "eingefrorener Shop bleibt über den Rundenwechsel gleich") + p.gold = 10 + p.reroll(g.pool, g.rand) + check(not p.shop_locked, "Würfeln hebt das Einfrieren auf") + p.shop = [ShopEntry.new(0, -1)] + p.gold = 5 + p.buy(0, g.pool, Data.Element.ERDE) + check(p.last_element == Data.Element.ERDE, "letzte Spezialisierung wird gemerkt") + + +func test_element_profiles() -> void: + print("[Elemente prägen die Werte]") + var feuer := Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1).base_stats() + var erde := Avatar.new(Data.Role.DPS, [Data.Element.ERDE] as Array[int], 1).base_stats() + var wind := Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1).base_stats() + var blitz := Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1).base_stats() + check(feuer["atk"] > erde["atk"] * 1.2, "Feuer schlägt härter als Erde (%d vs %d)" % [int(feuer["atk"]), int(erde["atk"])]) + check(erde["hp"] > feuer["hp"] * 1.25, "Erde hält mehr aus als Feuer (%d vs %d)" % [int(erde["hp"]), int(feuer["hp"])]) + check(wind["aspd"] > erde["aspd"] * 1.25, "Wind ist schneller als Erde") + check(blitz["crit"] > 0.0 and feuer["crit"] == 0.0, "nur Blitz bringt eigene Krit-Chance") + var a := PlayerState.new(0, "A", false) + a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1) + var b := PlayerState.new(1, "B", false) + b.board[Vector2i(3, 1)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + var c := Combat.new(a, b, 2) + var blitz_unit = null + for u in c.units: + if u.team == 0: + blitz_unit = u + check(blitz_unit.crit >= 0.15, "Krit-Profil kommt im Kampf an (%.2f)" % blitz_unit.crit) + + +func test_pairing_known_early() -> void: + print("[Gegner steht in der Planung fest]") + var g := Game.new() + g.start(41) + var opp := g.human_opponent() + check(opp != null and g.phase == Game.Phase.PLANNING, "in der Planung ist der Gegner bekannt: %s" % (opp.pname if opp else "-")) + g.start_combat() + var fought := g.human_opponent() + check(fought == opp or (fought != null and fought.pname.begins_with(opp.pname)), + "gekämpft wird gegen genau diesen Gegner") + g.skip_combat() + check(g.human_opponent() != null, "neue Runde, neuer Gegner bekannt") + # Jeder Bot kennt ebenfalls seinen Gegner. + var all_known := true + for p in g.alive_players(): + if g.opponent_of(p) == null: + all_known = false + check(all_known, "jeder Spieler hat einen Gegner") + + +func test_recap() -> void: + print("[Rückblick]") + var g := Game.new() + g.start(51) + g.human.board[Vector2i(3, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 2) + g.human.board[Vector2i(2, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + g.start_combat() + g.skip_combat() + var r := g.human_recap + check(not r.is_empty(), "Rückblick liegt vor") + check(r["total"] == 2 and r["alive"] <= 2, "zählt eigene Einheiten (%d, %d überlebt)" % [r["total"], r["alive"]]) + check(r["opponent"] != "" and r.has("result") and r.has("damage"), "nennt Gegner, Ergebnis und Schaden") + check(r["best_dealt"] > 0 or r["best_healed"] > 0, "stärkste Einheit hat etwas beigetragen (%s)" % r["best_name"]) + + +func test_bot_arrange() -> void: + print("[Bots stellen gegen den Gegner auf]") + var ai := BotAI.new() + var rng := RandomNumberGenerator.new() + rng.seed = 3 + ai.setup(rng) + var me := PlayerState.new(1, "Bot", true) + me.level = 6 + for i in 2: + me.board[Vector2i(i, 3)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + for i in 3: + me.board[Vector2i(2 + i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + var opp := PlayerState.new(2, "Gegner", true) + # Gegnerische Schützen links (aus seiner Sicht), also rechts aus unserer. + for i in 3: + opp.board[Vector2i(0, i)] = Avatar.new(Data.Role.DPS, [Data.Element.BLITZ] as Array[int], 1) + opp.board[Vector2i(6, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) + ai.arrange(me, opp) + check(me.board.size() == 5, "alle Einheiten stehen wieder (%d)" % me.board.size()) + var tanks_front := true + var dps_back := true + var tank_cols := [] + var dps_cols := [] + for hex in me.board: + var u: Avatar = me.board[hex] + if u.role == Data.Role.TANK: + tanks_front = tanks_front and hex.y == 0 + tank_cols.append(hex.x) + else: + dps_back = dps_back and hex.y >= 2 + dps_cols.append(hex.x) + check(tanks_front, "Tanks stehen vorn") + check(dps_back, "Schützen stehen hinten") + check(tank_cols.has(6), "ein Tank steht vor den gegnerischen Schützen (Spalte 6)") + var avg := 0.0 + for c in dps_cols: + avg += c + avg /= dps_cols.size() + check(avg > 3.0, "Schützen meiden die Seite des gegnerischen Tanks (Mittel %.1f)" % avg)