From 9410d18ea57f11dba0535f5a7fd4d8e7040772a6 Mon Sep 17 00:00:00 2001 From: team3 Date: Sat, 29 Aug 2026 11:42:30 +0200 Subject: [PATCH] first commit --- .godot/.gdignore | 1 + ...lding-4a24720ee5e787dbe30abc634fa3679d.cfg | 6 + .godot/editor/filesystem_cache10 | 30 + .godot/editor/project_metadata.cfg | 11 + .godot/global_script_class_cache.cfg | 97 ++ .godot/uid_cache.bin | Bin 0 -> 653 bytes README.md | 102 +++ anforderungen.md | 30 + project.godot | 21 + scenes/Main.tscn | 6 + scripts/core/Avatar.gd | 125 +++ scripts/core/Avatar.gd.uid | 1 + scripts/core/BotAI.gd | 155 ++++ scripts/core/BotAI.gd.uid | 1 + scripts/core/Combat.gd | 477 ++++++++++ scripts/core/Combat.gd.uid | 1 + scripts/core/Data.gd | 161 ++++ scripts/core/Data.gd.uid | 1 + scripts/core/Game.gd | 210 +++++ scripts/core/Game.gd.uid | 1 + scripts/core/Hex.gd | 51 ++ scripts/core/Hex.gd.uid | 1 + scripts/core/PlayerState.gd | 359 ++++++++ scripts/core/PlayerState.gd.uid | 1 + scripts/core/Pool.gd | 48 + scripts/core/Pool.gd.uid | 1 + scripts/core/ShopEntry.gd | 32 + scripts/core/ShopEntry.gd.uid | 1 + scripts/ui/BoardView3D.gd | 422 +++++++++ scripts/ui/BoardView3D.gd.uid | 1 + scripts/ui/HudOverlay.gd | 53 ++ scripts/ui/HudOverlay.gd.uid | 1 + scripts/ui/Main.gd | 843 ++++++++++++++++++ scripts/ui/Main.gd.uid | 1 + scripts/ui/UiKit.gd | 56 ++ scripts/ui/UiKit.gd.uid | 1 + tests/CamCal.gd | 68 ++ tests/Probe2.gd | 141 +++ tests/Probe2.gd.uid | 1 + tests/Probe3.gd | 394 ++++++++ tests/Probe3.gd.uid | 1 + tests/camcal.tscn | 6 + tests/prev_check.gd | 7 + tests/probe2.tscn | 6 + tests/probe3.tscn | 6 + tests/sim_test.gd | 442 +++++++++ tests/sim_test.gd.uid | 1 + 47 files changed, 4382 insertions(+) create mode 100644 .godot/.gdignore create mode 100644 .godot/editor/Main.tscn-folding-4a24720ee5e787dbe30abc634fa3679d.cfg create mode 100644 .godot/editor/filesystem_cache10 create mode 100644 .godot/editor/project_metadata.cfg create mode 100644 .godot/global_script_class_cache.cfg create mode 100644 .godot/uid_cache.bin create mode 100644 README.md create mode 100644 anforderungen.md create mode 100644 project.godot create mode 100644 scenes/Main.tscn create mode 100644 scripts/core/Avatar.gd create mode 100644 scripts/core/Avatar.gd.uid create mode 100644 scripts/core/BotAI.gd create mode 100644 scripts/core/BotAI.gd.uid create mode 100644 scripts/core/Combat.gd create mode 100644 scripts/core/Combat.gd.uid create mode 100644 scripts/core/Data.gd create mode 100644 scripts/core/Data.gd.uid create mode 100644 scripts/core/Game.gd create mode 100644 scripts/core/Game.gd.uid create mode 100644 scripts/core/Hex.gd create mode 100644 scripts/core/Hex.gd.uid create mode 100644 scripts/core/PlayerState.gd create mode 100644 scripts/core/PlayerState.gd.uid create mode 100644 scripts/core/Pool.gd create mode 100644 scripts/core/Pool.gd.uid create mode 100644 scripts/core/ShopEntry.gd create mode 100644 scripts/core/ShopEntry.gd.uid create mode 100644 scripts/ui/BoardView3D.gd create mode 100644 scripts/ui/BoardView3D.gd.uid create mode 100644 scripts/ui/HudOverlay.gd create mode 100644 scripts/ui/HudOverlay.gd.uid create mode 100644 scripts/ui/Main.gd create mode 100644 scripts/ui/Main.gd.uid create mode 100644 scripts/ui/UiKit.gd create mode 100644 scripts/ui/UiKit.gd.uid create mode 100644 tests/CamCal.gd create mode 100644 tests/Probe2.gd create mode 100644 tests/Probe2.gd.uid create mode 100644 tests/Probe3.gd create mode 100644 tests/Probe3.gd.uid create mode 100644 tests/camcal.tscn create mode 100644 tests/prev_check.gd create mode 100644 tests/probe2.tscn create mode 100644 tests/probe3.tscn create mode 100644 tests/sim_test.gd create mode 100644 tests/sim_test.gd.uid diff --git a/.godot/.gdignore b/.godot/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.godot/.gdignore @@ -0,0 +1 @@ + diff --git a/.godot/editor/Main.tscn-folding-4a24720ee5e787dbe30abc634fa3679d.cfg b/.godot/editor/Main.tscn-folding-4a24720ee5e787dbe30abc634fa3679d.cfg new file mode 100644 index 0000000..9e8467b --- /dev/null +++ b/.godot/editor/Main.tscn-folding-4a24720ee5e787dbe30abc634fa3679d.cfg @@ -0,0 +1,6 @@ +[folding] + +node_unfolds=[] +resource_unfolds=[] +nodes_folded=[] +animation_groups_folded=[] diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 new file mode 100644 index 0000000..756c126 --- /dev/null +++ b/.godot/editor/filesystem_cache10 @@ -0,0 +1,30 @@ +78098b89c88a74ebe8c20117b8754620 +::res://::1787944732 +anforderungen.md::TextFile::-1::1787943530::0::1::::<><><>0<>0<><>:: +README.md::TextFile::-1::1787944732::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<><>:: diff --git a/.godot/editor/project_metadata.cfg b/.godot/editor/project_metadata.cfg new file mode 100644 index 0000000..b6421c4 --- /dev/null +++ b/.godot/editor/project_metadata.cfg @@ -0,0 +1,11 @@ +[game_view] + +select_mode=0 + +[editor_metadata] + +executable_path="/home/arbeit/bin/godot" + +[recent_files] + +scenes=["res://scenes/Main.tscn"] diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg new file mode 100644 index 0000000..e72e81d --- /dev/null +++ b/.godot/global_script_class_cache.cfg @@ -0,0 +1,97 @@ +list=[{ +"base": &"RefCounted", +"class": &"Avatar", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/Avatar.gd" +}, { +"base": &"Node3D", +"class": &"BoardView3D", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/BoardView3D.gd" +}, { +"base": &"RefCounted", +"class": &"BotAI", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/BotAI.gd" +}, { +"base": &"RefCounted", +"class": &"Combat", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/Combat.gd" +}, { +"base": &"RefCounted", +"class": &"Data", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/Data.gd" +}, { +"base": &"RefCounted", +"class": &"Game", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/Game.gd" +}, { +"base": &"RefCounted", +"class": &"Hex", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/Hex.gd" +}, { +"base": &"Control", +"class": &"HudOverlay", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/HudOverlay.gd" +}, { +"base": &"RefCounted", +"class": &"PlayerState", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/PlayerState.gd" +}, { +"base": &"RefCounted", +"class": &"Pool", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/Pool.gd" +}, { +"base": &"RefCounted", +"class": &"ShopEntry", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/core/ShopEntry.gd" +}, { +"base": &"RefCounted", +"class": &"UiKit", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/ui/UiKit.gd" +}] diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin new file mode 100644 index 0000000000000000000000000000000000000000..16227c0c25b710062d553d7d6b435a655a28093b GIT binary patch literal 653 zcmWe+U|_If5;<6}IKfZ`$Sz7Pw$j%xPA)cYjl($S{M@9(5}>+W|6^=-n>-AV!fu=k&{m+T=9`N1 zH?Ml1gF}^jVs0u>RmpX$e!1iRW|G*=@<^=!Dsr~<>lak_PE*9LC?F@XGPNkUB(Vf! zqDjnQiF>?{i*VQ*ke{CeR280J-&=nEm!~{-BZD*Y3taO`iYkFhyRPz9Di(T1VJa=n z)OX5HEJ_K>Of5Hd0V*|MZ|Mtt{#{)T!&IPBkJ1$XveY7=V}U9St$EUlHXX>6z^2kS zF*6US#{O^W4dc5fGBLvjXj*8dcP1z@GE1U&E@27F5rrF6l3EN*0Rct%NvTFa1?}^4 wYCU)SamT8_7^on^&gE2+yq%p0HWPsI)n{$hRd?|lh@;6DXXeI(>;ftQ01Fe~`2YX_ literal 0 HcmV?d00001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b038957 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Elements Autochess + +Auto-Battler nach TFT-Vorbild in Godot 4.7, in 3D. +Primitive Assets: Hexfelder sind Zylinder mit 6 Segmenten, Einheiten sind +Würfel (Tank), Kegel (DPS) und Kugeln (Heiler). Alles wird im Code erzeugt. + +## Starten + + godot --path . scenes/Main.tscn + +## Steuern + +- Shop-Karte anklicken = kaufen. Neutrale Avatare fragen dabei nach der + Spezialisierung, Element-Avatare kommen fertig. +- Die Bank sind die 9 Hexplätze vor dem eigenen Spielfeld, ebenfalls in 3D. +- Avatare per Drag & Drop verschieben: Bank -> Feld, Feld -> Bank, Feld -> Feld + und Bank -> Bank. Ist das Ziel belegt, tauschen die beiden die Plätze. +- 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 + +## Regeln + +| Anforderung | Umsetzung | +| --- | --- | +| 1-Star bis 3-Star, zweimal aufwertbar | `Avatar.star`, `Data.MAX_STAR` | +| Tank / DPS / Heiler | `Data.Role`, eigene Werte und Fähigkeiten | +| Spezialisierung beim Kauf | Popup beim Kauf eines neutralen Avatars | +| Weitere Spezialisierung beim Aufwerten | Popup beim Upgrade, bis zu 3 Elemente | +| Shop startet elementlos | `PlayerState.unlocked` beginnt leer | +| Aufgestellte Elemente kommen in den Shop | `PlayerState.unlock()` beim Aufstellen | +| Mein Shop zeigt nur meine Elemente und Neutral | `PlayerState.shop_elements()` je Spieler | +| Neutrale Chance -25% je aufgestelltem Element | `Data.neutral_chance()`, multiplikativ | +| Bank ist 3D am Spielfeld | `BoardView3D.bench_pos()`, 9 Hexplätze | +| Avatare zwischen Bank und Board verschieben | `PlayerState.move_slot()` | +| Drag & Drop | `Main._begin_drag()` / `_end_drag()`, Avatar klebt flach am Zeiger | +| Aufwertung zeigt, was sich ändert | Popup nennt die neue Fähigkeit, je Auswahl eine Zeile | +| Zinsen je 10 Gold bis 50 Gold | `Data.interest()`, max +5 | +| Neutral 1 Gold, Element 2 Gold | `Data.PRICE_NONE` / `PRICE_ELEMENT` | +| 1-Star Feuer + 2 Feuer = 2-Star | `Avatar.upgrade_cost()` | +| 2-Star Feuer/Wind + 3 Feuer + 3 Wind = 3-Star | `Avatar.upgrade_cost()` | +| Stats und Fähigkeiten pro Stufe | `Data.STAR_MULT`, `Avatar.ability_text()`, `Combat._cast()` | +| 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 | +| 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. + +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. + +Die Neutral-Chance ist multiplikativ (0,75 hoch Anzahl Elemente) statt in +Prozentpunkten. So verschwindet Neutral nie ganz: ohne eigene Elemente 100%, +mit einem 75%, mit sechs noch 18%. + +## Elemente und Traits + +Feuer, Wasser, Wind, Blitz, Erde, Eis. Stufen bei 2 / 4 / 6 Einheiten. +Kombinationen (beide Elemente ab 2 Einheiten): Feuersturm, Frost, Magnetsturm, +Magma, Sturmfront, Dampf. + +## Aufbau + + scripts/core/ Data, Avatar, Pool, ShopEntry, PlayerState, Hex, Combat, BotAI, Game + scripts/ui/ Main (Szene + Bedienung), BoardView3D (Arena), HudOverlay, UiKit + scenes/Main.tscn + tests/ sim_test.gd (kopflos), Probe2, Probe3, CamCal + +`Combat` ist reine Logik und wird in Schritten gerechnet. Bot-Kämpfe laufen sofort +durch (`run_to_end`), der eigene Kampf wird pro Frame gesteppt. + +Die Darstellung hängt nicht an der Logik: `BoardView3D` liest nur `Game`. +Lebens- und Manabalken zeichnet `HudOverlay` in 2D über die projizierten +Weltpositionen, damit sie in jeder Entfernung lesbar bleiben. + +`Main._fit_camera()` rechnet Abstand und Blickpunkt so aus, dass das Spielfeld +genau in die freie Bildmitte passt. Links stehen Elemente, Einheit und Rundenlog, +rechts die Spielerliste, unten der Shop. Das läuft bei jeder Fenstergröße neu. + +## Tests + +Kernlogik ohne Fenster: + + godot --headless --path . --script tests/sim_test.gd + +UI-Durchläufe mit Screenshots (Ordner als Argument): + + godot --path . tests/probe2.tscn -- /pfad/zu/screenshots # 3-Star, Spielende, Neustart + godot --path . tests/probe3.tscn -- /pfad/zu/screenshots # echte Maus- und Tastatureingaben + godot --path . tests/camcal.tscn -- /pfad/zu/screenshots # Kamerabild und Trefferprüfung + +`probe3` schickt Klicks und Drags über `Viewport.push_input` wie ein Nutzer und +prüft danach den Zustand. `camcal` prüft, dass alle 28 Felder und 9 Bankplätze +unter ihrer eigenen Bildschirmposition auch getroffen werden. diff --git a/anforderungen.md b/anforderungen.md new file mode 100644 index 0000000..0132e27 --- /dev/null +++ b/anforderungen.md @@ -0,0 +1,30 @@ +Funktionen +- Avatare kann man 2mal aufwerten +- Es gibt 1-Star bis x-Star +- Avatare gibt es als Tank, DPS und Heiler +- Beim kaufen vom neutralen Avatar wählt man eine Spezialisierung wie Feuer (also Elemente) +- Beim aufwerten kann man eine weitere Spezialisierung wählen (am Ende sowas wie Feuer+Wind+Blitz) +- Am Anfang wird der Standard Avatar im Shop (Elementlos) angeboten +- Elemente die aufgestellt wurden, werden im Shop zufällig angeboten +- 1-Star Feuer braucht 3 Feuer-Avatare für 2-Star, 2-Start Feuer-Wind braucht 3 Feuer und 3 Wind Avatare für 3-Star, usw. +- Mit jedem Level up steigen die Stats und die Fähigkeiten verändern sich +- Elemente können kombiniert werden (wie Traits) für besondere Eigenschaften für alle Avatare +- Man bekommt jede Runde 5 Gold +- 1 Gold für Win +- Mehr Gold mit Streaks +- Jeder startet mit 100 Leben +- Es kann nur eine bestimmte Menge von einem Element aus dem Shop von allen Spielern gekauft werden +- Man spielt gegen 7 weitere Bots, die das spiel simulieren +- Mein Shop zeigt nur meine Elemente und Neutral an +- Die Bank ist 3d am Spielfeld +- Avatare kann man zwischen Bank und Board verschieben +- Avatare per Drag & Drop verschieben +- Bei der Aufwertung wird mir angezeigt was sich bei einer Auswahl verändert +- Gold erzeugt zinsen je 10g bis 50g +- Neutrale kosten 1g, Elemente 2g +- Neutrale Chance verringert sich um -25% je aufgestellten Element + +Kern +- Basis wie TFT +- Units sind Avatare +- Keine Items \ No newline at end of file diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..d86f928 --- /dev/null +++ b/project.godot @@ -0,0 +1,21 @@ +; Elements Autochess - Godot 4.7 + +config_version=5 + +[application] + +config/name="Elements Autochess" +run/main_scene="res://scenes/Main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") + +[display] + +window/size/viewport_width=1600 +window/size/viewport_height=900 +window/stretch/mode="viewport" +window/stretch/aspect="expand" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/Main.tscn b/scenes/Main.tscn new file mode 100644 index 0000000..45ae6df --- /dev/null +++ b/scenes/Main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/Main.gd" id="1"] + +[node name="Main" type="Node3D"] +script = ExtResource("1") diff --git a/scripts/core/Avatar.gd b/scripts/core/Avatar.gd new file mode 100644 index 0000000..2b2b8fe --- /dev/null +++ b/scripts/core/Avatar.gd @@ -0,0 +1,125 @@ +class_name Avatar +extends RefCounted + +## Eine Einheit. Elemente sind leer, solange der Avatar elementlos ist. + +static var _next_id := 1 + +var id: int +var role: int = Data.Role.DPS +var elements: Array[int] = [] +var star: int = 1 +var invested: int = 0 ## bezahltes Gold (für Verkaufswert) + + +func _init(p_role: int = Data.Role.DPS, p_elements: Array[int] = [], p_star: int = 1) -> void: + id = _next_id + _next_id += 1 + role = p_role + elements = p_elements.duplicate() + star = p_star + + +func is_elementless() -> bool: + return elements.is_empty() + + +func primary_element() -> int: + return elements[0] if not elements.is_empty() else -1 + + +func has_element(e: int) -> bool: + return elements.has(e) + + +func display_name() -> String: + if elements.is_empty(): + return "Neutral-" + Data.ROLE_NAMES[role] + var parts := [] + for e in elements: + parts.append(Data.ELEMENT_NAMES[e]) + return "/".join(parts) + "-" + Data.ROLE_NAMES[role] + + +func color() -> Color: + if elements.is_empty(): + return Data.NONE_COLOR + return Data.ELEMENT_COLORS[elements[0]] + + +func price() -> int: + return Data.PRICE_NONE if elements.is_empty() else Data.PRICE_ELEMENT + + +## Was zum nächsten Stern fehlt: {element: anzahl}. Leer = maximal. +func upgrade_cost() -> Dictionary: + if star >= Data.MAX_STAR or elements.is_empty(): + return {} + var cost := {} + if star == 1: + # 3 Avatare gesamt: der Basis-Avatar zählt mit, also 2 weitere. + cost[elements[0]] = 2 + else: + # 2-Star -> 3-Star: je 3 Avatare pro vorhandenem Element. + for e in elements: + cost[e] = 3 + return cost + + +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)) + return { + "hp": b["hp"] * mult * ebonus, + "atk": b["atk"] * mult * ebonus, + "armor": b["armor"], + "aspd": b["aspd"], + "range": b["range"], + "mana": b["mana"], + } + + +func duplicate_avatar() -> Avatar: + var a := Avatar.new(role, elements, star) + a.invested = invested + return a + + +## Beschreibung der Fähigkeit, abhängig von Rolle, Stern und Elementen. +func ability_text() -> String: + var s := "" + match role: + Data.Role.TANK: + s = "Schild %d für 4s" % int(base_stats()["atk"] * (3.0 + 0.5 * float(star - 1))) + if star >= 2: + s += ", spottet 3 Gegner" + if star >= 3: + s += ", 25% Reflexion" + Data.Role.DPS: + s = "%d Schaden aufs Ziel" % int(base_stats()["atk"] * (3.0 + 0.3 * float(star - 1))) + if star == 2: + s += " + Splash auf Nachbarn" + elif star >= 3: + s += " + Fläche (Radius 2)" + Data.Role.HEILER: + var amount := int(base_stats()["atk"] * (3.0 - 0.25 * float(star - 1))) + if star == 1: + s = "heilt schwächsten Verbündeten um %d" % amount + elif star == 2: + s = "heilt 3 Verbündete um %d" % amount + else: + s = "heilt alle um %d und schildet" % amount + var extra := [] + for i in elements.size(): + var scale := "" if i == 0 else " (halb)" + match elements[i]: + Data.Element.FEUER: extra.append("Brand" + scale) + Data.Element.WASSER: extra.append("Mana + stärkere Heilung" + scale) + Data.Element.WIND: extra.append("Tempo-Schub" + scale) + Data.Element.BLITZ: extra.append("Kettenblitz" + scale) + Data.Element.ERDE: extra.append("Betäubung" + scale) + Data.Element.EIS: extra.append("Verlangsamung" + scale) + if not extra.is_empty(): + s += "\nElemente: " + ", ".join(extra) + return s diff --git a/scripts/core/Avatar.gd.uid b/scripts/core/Avatar.gd.uid new file mode 100644 index 0000000..8896ade --- /dev/null +++ b/scripts/core/Avatar.gd.uid @@ -0,0 +1 @@ +uid://bq5aj8ldpjnqo diff --git a/scripts/core/BotAI.gd b/scripts/core/BotAI.gd new file mode 100644 index 0000000..faf2826 --- /dev/null +++ b/scripts/core/BotAI.gd @@ -0,0 +1,155 @@ +class_name BotAI +extends RefCounted + +## Einfache Bot-Strategie: bevorzugte Elemente sammeln, Board füllen, aufwerten. + +var pref: Array[int] = [] +var greed: float = 0.5 ## wie stark der Bot Gold hortet + + +func setup(rand: RandomNumberGenerator) -> void: + var all: Array[int] = [] + for e in Data.ELEMENT_COUNT: + all.append(e) + # Bewusst mit dem Spiel-RNG mischen, nicht mit Array.shuffle(), + # damit ein Seed dieselbe Partie ergibt. + for i in range(all.size() - 1, 0, -1): + var j := rand.randi_range(0, i) + var tmp := all[i] + all[i] = all[j] + all[j] = tmp + pref = [all[0], all[1], all[2]] + greed = rand.randf_range(0.3, 0.8) + + +func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> void: + _do_upgrades(p, pool) + _do_buys(p, pool, rand, round_no) + _do_placements(p, pool, round_no) + _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) + _free_bench(p, pool) + + +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): + return true + # Elemente, die schon auf dem Board sind, sind immer Futter. + return p.trait_counts().has(entry.element) + + +func _do_buys(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> void: + for i in p.shop.size(): + var e: ShopEntry = p.shop[i] + if e.sold: + continue + if p.free_bench_slot() < 0: + return + if not _wants(e, p): + continue + if p.gold - e.price() < 0: + continue + 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): + var idx := _pick_bench_for_board(p) + if idx < 0: + return + var a: Avatar = p.bench[idx] + var hex := _pick_hex(p, a.role) + if hex.x < 0: + return + if not p.place(idx, hex, round_no): + return + + +## Zuerst Elemente aufstellen, die noch gar nicht im Team sind. +## Alles Weitere bleibt als Futter auf der Bank. +func _pick_bench_for_board(p: PlayerState) -> int: + var counts := p.trait_counts() + var best := -1 + var best_score := -1 + for i in p.bench.size(): + var a = p.bench[i] + if a == null: + continue + var score := 0 + if 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 + if score > best_score: + best_score = score + best = i + return best if best_score > 0 else -1 + + +func _pick_element(p: PlayerState) -> int: + var counts := p.trait_counts() + var best := pref[0] + var best_c := -1 + for e in pref: + var c: int = counts.get(e, 0) + if c > best_c and c < 6: + best_c = c + best = e + return best + + +func _pick_hex(p: PlayerState, role: int) -> Vector2i: + var rows: Array + match role: + Data.Role.TANK: rows = [0, 1, 2, 3] + Data.Role.DPS: rows = [2, 1, 3, 0] + _: rows = [3, 2, 1, 0] + var cols := [3, 2, 4, 1, 5, 0, 6] + for r in rows: + for c in cols: + var h := Vector2i(c, r) + if not p.board.has(h): + return h + return Vector2i(-1, -1) + + +func _do_upgrades(p: PlayerState, pool: Pool) -> void: + var again := true + while again: + again = false + for hex in p.board.keys(): + if p.can_upgrade(hex): + var options := p.available_new_elements(hex) + var choice := -1 + for e in pref: + if options.has(e): + choice = e + break + if choice < 0 and not options.is_empty(): + choice = options[0] + if choice >= 0 and p.upgrade(hex, choice, pool): + again = true + break + + +## Bank leeren, wenn sie voll ist und nichts mehr gebraucht wird. +func _free_bench(p: PlayerState, pool: Pool) -> void: + if p.free_bench_slot() >= 0: + return + var counts := p.trait_counts() + 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]): + p.sell_bench(i, pool) + return + p.sell_bench(0, pool) diff --git a/scripts/core/BotAI.gd.uid b/scripts/core/BotAI.gd.uid new file mode 100644 index 0000000..fa1fa50 --- /dev/null +++ b/scripts/core/BotAI.gd.uid @@ -0,0 +1 @@ +uid://bl3psu7dnb3x2 diff --git a/scripts/core/Combat.gd b/scripts/core/Combat.gd new file mode 100644 index 0000000..50958ec --- /dev/null +++ b/scripts/core/Combat.gd @@ -0,0 +1,477 @@ +class_name Combat +extends RefCounted + +## Auto-Kampf zweier Aufstellungen auf einem 7x8-Hexfeld. +## Rein logisch und schrittweise: Bot-Kämpfe laufen sofort durch, +## der Kampf des Spielers wird pro Frame gesteppt. + +const ARENA_COLS := 7 +const ARENA_ROWS := 8 +const MOVE_TIME := 0.32 +const TIME_LIMIT := 35.0 +const CRIT_MULT := 1.5 +const MANA_PER_ATTACK := 10.0 +const MANA_PER_HIT := 5.0 + + +class CUnit extends RefCounted: + var avatar: Avatar + var team: int + var pos: Vector2i + var hp: float + var max_hp: float + var atk: float + var armor: float + var aspd: float + var atk_range: int + var mana: float + var max_mana: float + var mana_mult: float = 1.0 + var crit: float = 0.0 + var attack_timer: float = 0.0 + var move_timer: float = 0.0 + var alive: bool = true + var shield: float = 0.0 + var shield_timer: float = 0.0 + var burn_dps: float = 0.0 + var burn_timer: float = 0.0 + var slow: float = 0.0 + var slow_timer: float = 0.0 + var haste: float = 0.0 + var haste_timer: float = 0.0 + var stun_timer: float = 0.0 + var reflect: float = 0.0 + var reflect_timer: float = 0.0 + var taunt_by = null + var taunt_timer: float = 0.0 + var target = null + var cast_flash: float = 0.0 + var hit_flash: float = 0.0 + + func effective_aspd() -> float: + return maxf(0.1, aspd * (1.0 + haste) * (1.0 - slow)) + + +var units: Array = [] +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 events: Array = [] ## kurze Textmeldungen für das Log + +var _mods := [{}, {}] +var _occ := {} +var _rand := RandomNumberGenerator.new() + + +func _init(a: PlayerState, b: PlayerState, seed_value: int = 0) -> void: + _rand.seed = seed_value if seed_value != 0 else randi() + _mods[0] = _build_mods(a) + _mods[1] = _build_mods(b) + _spawn(a, 0) + _spawn(b, 1) + _apply_start_effects() + + +# --- Aufbau ---------------------------------------------------------------- + +func _build_mods(p: PlayerState) -> Dictionary: + var counts := p.trait_counts() + var m := { + "atk": 1.0, "hp": 1.0, "aspd": 1.0, "mana": 1.0, + "crit": 0.0, "ice": 0.0, "combos": {}, + } + for e in counts: + var tier := Data.trait_tier(e, counts[e]) + if tier <= 0: + continue + var v: float = Data.ELEMENT_TRAITS[e]["values"][tier - 1] + match e: + Data.Element.FEUER: m["atk"] += v + Data.Element.WASSER: m["mana"] += v + Data.Element.WIND: m["aspd"] += v + Data.Element.BLITZ: m["crit"] += v + Data.Element.ERDE: m["hp"] += v + Data.Element.EIS: m["ice"] = maxf(m["ice"], v) + for c in p.active_combos(): + m["combos"][c["key"]] = true + return m + + +func _spawn(p: PlayerState, team: int) -> void: + var m: Dictionary = _mods[team] + for hex in p.board: + var a: Avatar = p.board[hex] + var s := a.base_stats() + var u := CUnit.new() + u.avatar = a + u.team = team + u.pos = _arena_pos(hex, team) + u.max_hp = s["hp"] * m["hp"] + u.hp = u.max_hp + u.atk = s["atk"] * m["atk"] + u.armor = s["armor"] + u.aspd = s["aspd"] * m["aspd"] + u.atk_range = s["range"] + u.max_mana = s["mana"] + u.mana = 0.0 + u.mana_mult = m["mana"] + u.crit = m["crit"] + if _occ.has(u.pos): + u.pos = _free_near(u.pos) + _occ[u.pos] = u + units.append(u) + + +static func _arena_pos(hex: Vector2i, team: int) -> Vector2i: + if team == 0: + return Vector2i(hex.x, 4 + hex.y) + return Vector2i(ARENA_COLS - 1 - hex.x, 3 - hex.y) + + +func _free_near(p: Vector2i) -> Vector2i: + for n in Hex.neighbors(p): + if _in_arena(n) and not _occ.has(n): + return n + return p + + +static func _in_arena(p: Vector2i) -> bool: + return p.x >= 0 and p.x < ARENA_COLS and p.y >= 0 and p.y < ARENA_ROWS + + +func _apply_start_effects() -> void: + for team in 2: + if _mods[team]["combos"].has("frost"): + for u in units: + if u.team != team: + u.slow = maxf(u.slow, 0.15) + u.slow_timer = 999.0 + + +# --- Simulation ------------------------------------------------------------ + +func step(dt: float) -> void: + if finished: + return + time += dt + for u in units: + if u.alive: + _tick_status(u, dt) + for u in units: + if u.alive and u.stun_timer <= 0.0: + _act(u, dt) + _check_end() + + +func run_to_end(dt: float = 0.05) -> void: + var guard := 0 + while not finished and guard < 5000: + step(dt) + guard += 1 + if not finished: + _resolve_by_hp() + + +func _tick_status(u: CUnit, dt: float) -> void: + if u.shield_timer > 0.0: + u.shield_timer -= dt + if u.shield_timer <= 0.0: + u.shield = 0.0 + if u.burn_timer > 0.0: + u.burn_timer -= dt + _apply_damage(null, u, u.burn_dps * dt, true) + if u.burn_timer <= 0.0: + u.burn_dps = 0.0 + if u.slow_timer > 0.0: + u.slow_timer -= dt + if u.slow_timer <= 0.0: + u.slow = 0.0 + if u.haste_timer > 0.0: + u.haste_timer -= dt + if u.haste_timer <= 0.0: + u.haste = 0.0 + if u.reflect_timer > 0.0: + u.reflect_timer -= dt + if u.reflect_timer <= 0.0: + u.reflect = 0.0 + if u.stun_timer > 0.0: + u.stun_timer -= dt + if u.taunt_timer > 0.0: + u.taunt_timer -= dt + if u.taunt_timer <= 0.0: + u.taunt_by = null + u.cast_flash = maxf(0.0, u.cast_flash - dt) + u.hit_flash = maxf(0.0, u.hit_flash - dt) + + +func _act(u: CUnit, dt: float) -> void: + var t = _pick_target(u) + if t == null: + return + u.target = t + var d := Hex.distance(u.pos, t.pos) + if d <= u.atk_range: + u.attack_timer -= dt * u.effective_aspd() + if u.attack_timer <= 0.0: + u.attack_timer += 1.0 + _basic_attack(u, t) + else: + u.move_timer -= dt + if u.move_timer <= 0.0: + u.move_timer = MOVE_TIME + _step_towards(u, t) + + +func _pick_target(u: CUnit): + if u.taunt_by != null and u.taunt_by.alive and u.taunt_timer > 0.0: + return u.taunt_by + if u.target != null and u.target.alive: + return u.target + return _nearest_enemy(u) + + +func _nearest_enemy(u: CUnit, exclude = null): + var best = null + var best_d := 999 + for o in units: + if not o.alive or o.team == u.team or o == exclude: + continue + var d := Hex.distance(u.pos, o.pos) + if d < best_d: + best_d = d + best = o + return best + + +func _step_towards(u: CUnit, t: CUnit) -> void: + var best := u.pos + var best_d := Hex.distance(u.pos, t.pos) + for n in Hex.neighbors(u.pos): + if not _in_arena(n) or _occ.has(n): + continue + var d := Hex.distance(n, t.pos) + if d < best_d: + best_d = d + best = n + if best != u.pos: + _occ.erase(u.pos) + u.pos = best + _occ[u.pos] = u + + +# --- Angriff und Schaden --------------------------------------------------- + +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 + _apply_damage(u, t, dmg, false) + var ice: float = _mods[u.team]["ice"] + if ice > 0.0: + t.slow = maxf(t.slow, ice) + t.slow_timer = maxf(t.slow_timer, 2.0) + if _mods[u.team]["combos"].has("sturmfront"): + var second = _nearest_enemy(u, t) + if second != null: + _apply_damage(u, second, dmg * 0.5, false) + u.mana += MANA_PER_ATTACK * u.mana_mult + if u.mana >= u.max_mana: + _cast(u) + + +func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void: + if not tgt.alive or amount <= 0.0: + return + var dmg := amount if is_true else amount * (1.0 - tgt.armor) + if tgt.shield > 0.0: + var absorbed: float = minf(tgt.shield, dmg) + tgt.shield -= absorbed + dmg -= absorbed + if dmg > 0.0: + tgt.hp -= dmg + tgt.hit_flash = 0.12 + tgt.mana += MANA_PER_HIT * tgt.mana_mult + if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0: + _cast(tgt) + if src != null and dmg > 0.0: + if tgt.reflect > 0.0: + src.hp -= dmg * tgt.reflect + if src.hp <= 0.0: + _kill(src) + if _mods[tgt.team]["combos"].has("magma"): + src.hp -= dmg * 0.20 + if src.hp <= 0.0: + _kill(src) + if _mods[src.team]["combos"].has("dampf"): + src.hp = minf(src.max_hp, src.hp + dmg * 0.18) + if tgt.hp <= 0.0: + _kill(tgt) + + +func _kill(u: CUnit) -> void: + if not u.alive: + return + u.alive = false + u.hp = 0.0 + _occ.erase(u.pos) + events.append(u.avatar.display_name() + " fällt") + + +func _heal(u: CUnit, amount: float) -> void: + if u.alive: + u.hp = minf(u.max_hp, u.hp + amount) + + +# --- Fähigkeiten ---------------------------------------------------------- + +func _cast(u: CUnit) -> void: + u.mana = 0.0 + u.cast_flash = 0.35 + var elems: Array = u.avatar.elements + var heal_bonus := 0.0 + for i in elems.size(): + if elems[i] == Data.Element.WASSER: + heal_bonus += 0.20 * (1.0 if i == 0 else 0.5) + + var hit_enemies: Array = [] + var main_target = u.target if (u.target != null and u.target.alive) else _nearest_enemy(u) + + match u.avatar.role: + Data.Role.TANK: + var sh := u.atk * (3.0 + 0.5 * float(u.avatar.star - 1)) * (1.0 + heal_bonus) + u.shield = maxf(u.shield, sh) + u.shield_timer = 4.0 + if u.avatar.star >= 2: + var n := 0 + for o in units: + if o.alive and o.team != u.team and Hex.distance(o.pos, u.pos) <= 2 and n < 3: + o.taunt_by = u + o.taunt_timer = 2.0 + o.target = u + n += 1 + if u.avatar.star >= 3: + u.reflect = 0.25 + u.reflect_timer = 4.0 + Data.Role.DPS: + if main_target == null: + return + var base := u.atk * (3.0 + 0.3 * float(u.avatar.star - 1)) + _apply_damage(u, main_target, base, false) + hit_enemies.append(main_target) + if u.avatar.star == 2: + for n in Hex.neighbors(main_target.pos): + var o = _occ.get(n) + if o != null and o.alive and o.team != u.team: + _apply_damage(u, o, u.atk * 1.5, false) + hit_enemies.append(o) + elif u.avatar.star >= 3: + for o in units: + if o.alive and o.team != u.team and o != main_target \ + and Hex.distance(o.pos, main_target.pos) <= 2: + _apply_damage(u, o, u.atk * 2.0, false) + 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 count := 1 + if u.avatar.star == 2: + count = 3 + elif u.avatar.star >= 3: + count = allies.size() + for i in mini(count, allies.size()): + _heal(allies[i], amount) + if u.avatar.star >= 3: + allies[i].shield = maxf(allies[i].shield, u.atk * 1.5 * (1.0 + heal_bonus)) + allies[i].shield_timer = 3.0 + + _apply_element_effects(u, main_target, hit_enemies) + + +func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void: + var elems: Array = u.avatar.elements + var combos: Dictionary = _mods[u.team]["combos"] + if combos.has("feuersturm"): + for o in hit_enemies: + o.burn_dps = maxf(o.burn_dps, u.atk * 0.4) + o.burn_timer = maxf(o.burn_timer, 3.0) + if combos.has("magnetsturm") and main_target != null and main_target.alive: + main_target.stun_timer = maxf(main_target.stun_timer, 0.6) + + for i in elems.size(): + var scale := 1.0 if i == 0 else 0.5 + match elems[i]: + Data.Element.FEUER: + for o in hit_enemies: + if o.alive: + o.burn_dps = maxf(o.burn_dps, u.atk * 0.6 * scale) + o.burn_timer = maxf(o.burn_timer, 3.0) + Data.Element.WASSER: + u.mana += 15.0 * scale + Data.Element.WIND: + u.haste = maxf(u.haste, 0.30 * scale) + u.haste_timer = 3.0 + Data.Element.BLITZ: + var chained := 0 + for o in units: + if chained >= 2: + break + if o.alive and o.team != u.team and o != main_target: + _apply_damage(u, o, u.atk * 0.8 * scale, false) + chained += 1 + Data.Element.ERDE: + if main_target != null and main_target.alive: + main_target.stun_timer = maxf(main_target.stun_timer, 0.75 * scale) + Data.Element.EIS: + if main_target != null and main_target.alive: + main_target.slow = maxf(main_target.slow, 0.30 * scale) + main_target.slow_timer = maxf(main_target.slow_timer, 3.0) + + +func _allies_by_missing_hp(team: int) -> Array: + var list := [] + for o in units: + if o.alive and o.team == team: + list.append(o) + list.sort_custom(func(a, b): return (a.hp / a.max_hp) < (b.hp / b.max_hp)) + return list + + +# --- Ende ------------------------------------------------------------------ + +func alive_count(team: int) -> int: + var n := 0 + for u in units: + if u.alive and u.team == team: + n += 1 + return n + + +func _check_end() -> void: + var a := alive_count(0) + var b := alive_count(1) + if a == 0 or b == 0: + finished = true + if a == b: + winner = -1 + survivors = 0 + else: + winner = 0 if a > b else 1 + survivors = maxi(a, b) + return + if time >= TIME_LIMIT: + _resolve_by_hp() + + +func _resolve_by_hp() -> void: + finished = true + var frac := [0.0, 0.0] + for u in units: + if u.alive: + frac[u.team] += u.hp / u.max_hp + if absf(frac[0] - frac[1]) < 0.25: + winner = -1 + survivors = 0 + else: + winner = 0 if frac[0] > frac[1] else 1 + survivors = alive_count(winner) diff --git a/scripts/core/Combat.gd.uid b/scripts/core/Combat.gd.uid new file mode 100644 index 0000000..c31e75c --- /dev/null +++ b/scripts/core/Combat.gd.uid @@ -0,0 +1 @@ +uid://bkybe632bjrh8 diff --git a/scripts/core/Data.gd b/scripts/core/Data.gd new file mode 100644 index 0000000..3e01fb2 --- /dev/null +++ b/scripts/core/Data.gd @@ -0,0 +1,161 @@ +class_name Data +extends RefCounted + +## Statische Spieldaten: Elemente, Rollen, Basiswerte, Traits, Preise. + +enum Element { FEUER, WASSER, WIND, BLITZ, ERDE, EIS } +enum Role { TANK, DPS, HEILER } + +const ELEMENT_COUNT := 6 +const ELEMENT_NAMES := ["Feuer", "Wasser", "Wind", "Blitz", "Erde", "Eis"] +const ELEMENT_COLORS := [ + Color(0.92, 0.31, 0.16), + Color(0.18, 0.52, 0.90), + Color(0.48, 0.85, 0.60), + Color(0.96, 0.84, 0.22), + Color(0.62, 0.45, 0.24), + Color(0.66, 0.92, 0.98), +] +const NONE_COLOR := Color(0.62, 0.62, 0.66) + +const ROLE_NAMES := ["Tank", "DPS", "Heiler"] +const ROLE_SHORT := ["T", "D", "H"] + +# Basiswerte auf 1-Star. +const ROLE_STATS := { + Role.TANK: {"hp": 700.0, "atk": 45.0, "armor": 0.40, "aspd": 0.60, "range": 1, "mana": 80.0}, + Role.DPS: {"hp": 450.0, "atk": 65.0, "armor": 0.18, "aspd": 0.80, "range": 3, "mana": 100.0}, + Role.HEILER: {"hp": 520.0, "atk": 38.0, "armor": 0.22, "aspd": 0.70, "range": 3, "mana": 90.0}, +} + +# Multiplikator pro Sternstufe (Index = star - 1). +const STAR_MULT := [1.0, 1.8, 3.24] +const MAX_STAR := 3 + +# Jedes zusätzliche Element gibt einen kleinen Grundwert-Bonus. +const ELEMENT_STAT_BONUS := 0.06 + +# --- Oekonomie ------------------------------------------------------------- +const START_HP := 100 +const BASE_INCOME := 5 +const WIN_GOLD := 1 +const REROLL_COST := 2 +const PRICE_NONE := 1 +const PRICE_ELEMENT := 2 +## Zinsen: je 10 Gold ein Gold dazu, gedeckelt bei 50 Gold. +const INTEREST_STEP := 10 +const INTEREST_MAX := 5 +const SHOP_SLOTS := 5 +## Je aufgestelltem Element wird ein Shop-Platz zu 25% seltener neutral. +## Multiplikativ, damit Neutral nie ganz verschwindet. +const NEUTRAL_FALLOFF := 0.75 +const BENCH_SLOTS := 9 +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 + +# --- Board ----------------------------------------------------------------- +const BOARD_COLS := 7 +const BOARD_ROWS := 4 +const MAX_TEAM_SLOTS := 9 + +# --- Element-Traits -------------------------------------------------------- +# steps = Anzahl aufgestellter Einheiten mit diesem Element. +const ELEMENT_TRAITS := { + Element.FEUER: { + "steps": [2, 4, 6], + "desc": ["+12% Angriff", "+26% Angriff", "+48% Angriff"], + "values": [0.12, 0.26, 0.48], + }, + Element.WASSER: { + "steps": [2, 4, 6], + "desc": ["+20% Mana", "+40% Mana", "+70% Mana"], + "values": [0.20, 0.40, 0.70], + }, + Element.WIND: { + "steps": [2, 4, 6], + "desc": ["+10% Tempo", "+22% Tempo", "+40% Tempo"], + "values": [0.10, 0.22, 0.40], + }, + Element.BLITZ: { + "steps": [2, 4, 6], + "desc": ["+15% Krit", "+30% Krit", "+55% Krit"], + "values": [0.15, 0.30, 0.55], + }, + Element.ERDE: { + "steps": [2, 4, 6], + "desc": ["+12% Leben", "+26% Leben", "+48% Leben"], + "values": [0.12, 0.26, 0.48], + }, + Element.EIS: { + "steps": [2, 4, 6], + "desc": ["Treffer -12% Tempo", "Treffer -22% Tempo", "Treffer -35% Tempo"], + "values": [0.12, 0.22, 0.35], + }, +} + +# --- Kombi-Traits ---------------------------------------------------------- +# Aktiv, wenn beide Elemente mindestens COMBO_MIN Einheiten haben. +const COMBO_MIN := 2 +const COMBO_TRAITS := [ + {"a": Element.FEUER, "b": Element.WIND, "key": "feuersturm", "name": "Feuersturm", "desc": "Fähigkeiten setzen in Brand"}, + {"a": Element.WASSER, "b": Element.EIS, "key": "frost", "name": "Frost", "desc": "Gegner starten 15% langsamer"}, + {"a": Element.BLITZ, "b": Element.ERDE, "key": "magnetsturm","name": "Magnetsturm", "desc": "Fähigkeiten betäuben 0.6s"}, + {"a": Element.FEUER, "b": Element.ERDE, "key": "magma", "name": "Magma", "desc": "20% Schaden reflektiert"}, + {"a": Element.WIND, "b": Element.BLITZ, "key": "sturmfront", "name": "Sturmfront", "desc": "Angriffe springen auf ein 2. Ziel"}, + {"a": Element.WASSER, "b": Element.FEUER, "key": "dampf", "name": "Dampf", "desc": "18% Lebensraub"}, +] + + +static func element_name(e: int) -> String: + return ELEMENT_NAMES[e] + + +static func element_color(e: int) -> Color: + return ELEMENT_COLORS[e] + + +static func role_name(r: int) -> String: + return ROLE_NAMES[r] + + +## Aktive Stufe eines Element-Traits (0 = inaktiv, sonst 1..3). +static func trait_tier(element: int, count: int) -> int: + var steps: Array = ELEMENT_TRAITS[element]["steps"] + var tier := 0 + for i in steps.size(): + if count >= steps[i]: + tier = i + 1 + 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) + + +## Wahrscheinlichkeit, dass ein Shop-Platz einen neutralen Avatar zeigt. +## Ohne eigene Elemente gibt es nur Neutral. +static func neutral_chance(unlocked_count: int) -> float: + return pow(NEUTRAL_FALLOFF, float(unlocked_count)) + + +## Zinsen auf das gehaltene Gold. +static func interest(gold: int) -> int: + return mini(INTEREST_MAX, int(float(gold) / float(INTEREST_STEP))) + + +## Streak-Bonusgold (Sieg- oder Niederlagenserie). +static func streak_gold(streak: int) -> int: + var s := absi(streak) + if s >= 5: + return 3 + if s == 4: + return 2 + if s == 3: + return 1 + return 0 diff --git a/scripts/core/Data.gd.uid b/scripts/core/Data.gd.uid new file mode 100644 index 0000000..76898bb --- /dev/null +++ b/scripts/core/Data.gd.uid @@ -0,0 +1 @@ +uid://cpbejautavs4q diff --git a/scripts/core/Game.gd b/scripts/core/Game.gd new file mode 100644 index 0000000..69cd33a --- /dev/null +++ b/scripts/core/Game.gd @@ -0,0 +1,210 @@ +class_name Game +extends RefCounted + +## Rundenlogik: 8 Spieler (1 Mensch, 7 Bots), geteilter Pool, Paarungen, Kämpfe. + +enum Phase { PLANNING, COMBAT, RESULT, GAMEOVER } + +const BOT_NAMES := [ + "Ignis", "Nerea", "Zephyr", "Volt", "Terra", "Frost", "Umbra", +] + +var players: Array = [] +var pool: Pool +var rand := RandomNumberGenerator.new() +var round_no: int = 0 +var phase: int = Phase.PLANNING +var human: PlayerState +var bots := {} ## pid -> BotAI +var pairings: Array = [] ## [{a, b, ghost}] +var player_combat: Combat = null +var pending: Array = [] ## Ergebnisse der Bot-Kämpfe +var last_round_log: Array = [] + + +func start(seed_value: int = 0) -> void: + rand.seed = seed_value if seed_value != 0 else randi() + pool = Pool.new() + players.clear() + human = PlayerState.new(0, "Du", false) + players.append(human) + for i in BOT_NAMES.size(): + var b := PlayerState.new(i + 1, BOT_NAMES[i], true) + players.append(b) + var ai := BotAI.new() + ai.setup(rand) + bots[b.pid] = ai + round_no = 1 + for p in players: + p.roll_shop(pool, rand) + _run_bot_turns() + phase = Phase.PLANNING + + +func alive_players() -> Array: + var out := [] + for p in players: + if p.alive: + out.append(p) + return out + + +# --- Planungsphase --------------------------------------------------------- + +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) + + +# --- Kampfphase ------------------------------------------------------------ + +func start_combat() -> void: + if phase != Phase.PLANNING: + return + pairings = _make_pairings() + 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()) + pending.append({"pair": pr, "combat": player_combat}) + else: + var c := Combat.new(pr["a"], pr["b"], rand.randi()) + c.run_to_end() + pending.append({"pair": pr, "combat": c}) + phase = Phase.COMBAT + if player_combat == null: + resolve_round() + + +func update_combat(dt: float) -> void: + if phase != Phase.COMBAT or player_combat == null: + return + player_combat.step(dt) + if player_combat.finished: + resolve_round() + + +func skip_combat() -> void: + if phase == Phase.COMBAT and player_combat != null: + player_combat.run_to_end() + resolve_round() + + +func _make_pairings() -> Array: + var alive := alive_players() + alive.shuffle() + var out := [] + var i := 0 + while i + 1 < alive.size(): + out.append({"a": alive[i], "b": alive[i + 1], "ghost": false}) + i += 2 + if i < alive.size(): + # Ungerade Anzahl: der Rest kämpft gegen ein Abbild eines Gegners. + var lone: PlayerState = alive[i] + var others := alive.filter(func(p): return p != lone) + 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}) + return out + + +func _make_ghost(src: PlayerState) -> PlayerState: + var g := PlayerState.new(-1, src.pname + " (Abbild)", true) + for hex in src.board: + g.board[hex] = src.board[hex] + return g + + +# --- Auswertung ------------------------------------------------------------ + +func resolve_round() -> void: + last_round_log.clear() + for item in pending: + var pr: Dictionary = item["pair"] + var c: Combat = item["combat"] + if not c.finished: + c.run_to_end() + var a: PlayerState = pr["a"] + var b: PlayerState = pr["b"] + var dmg := 3 + c.survivors + 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) + if not pr["ghost"]: + b.register_result(false) + b.take_damage(3) + last_round_log.append("%s vs %s: unentschieden" % [a.pname, b.pname]) + pending.clear() + player_combat = null + _check_eliminations() + if alive_players().size() <= 1: + phase = Phase.GAMEOVER + for p in alive_players(): + p.placement = 1 + return + _next_round() + + +func _apply_result(winner: PlayerState, loser: PlayerState, dmg: int, ghost: bool) -> void: + winner.register_result(true) + if loser.pid >= 0: + loser.register_result(false) + loser.take_damage(dmg) + last_round_log.append("%s schlägt %s (-%d Leben)" % [winner.pname, loser.pname, dmg]) + + +func _check_eliminations() -> void: + var still := alive_players().size() + for p in players: + if p.hp <= 0 and p.alive: + p.alive = false + p.placement = still + still -= 1 + last_round_log.append(p.pname + " ist ausgeschieden") + + +func _next_round() -> void: + round_no += 1 + for p in players: + if not p.alive: + continue + p.gold += p.round_income() + p.roll_shop(pool, rand) + _run_bot_turns() + phase = Phase.PLANNING + + +# --- Hilfen für die UI ---------------------------------------------------- + +func human_opponent() -> PlayerState: + for pr in pairings: + if pr["a"] == human: + return pr["b"] + if pr["b"] == human: + return pr["a"] + return null + + +func human_team_in_combat() -> int: + for pr in pairings: + if pr["a"] == human: + return 0 + if pr["b"] == human: + return 1 + return 0 + + +func ranking() -> Array: + var sorted := players.duplicate() + sorted.sort_custom(func(a, b): + if a.alive != b.alive: + return a.alive + return a.hp > b.hp) + return sorted diff --git a/scripts/core/Game.gd.uid b/scripts/core/Game.gd.uid new file mode 100644 index 0000000..437cda9 --- /dev/null +++ b/scripts/core/Game.gd.uid @@ -0,0 +1 @@ +uid://dj3pdat6ft684 diff --git a/scripts/core/Hex.gd b/scripts/core/Hex.gd new file mode 100644 index 0000000..70af86e --- /dev/null +++ b/scripts/core/Hex.gd @@ -0,0 +1,51 @@ +class_name Hex +extends RefCounted + +## Hex-Helfer für "odd-r" Offset-Koordinaten (spitze Hexes, ungerade Zeilen versetzt). + +const NEIGHBORS_EVEN := [ + Vector2i(1, 0), Vector2i(0, -1), Vector2i(-1, -1), + Vector2i(-1, 0), Vector2i(-1, 1), Vector2i(0, 1), +] +const NEIGHBORS_ODD := [ + Vector2i(1, 0), Vector2i(1, -1), Vector2i(0, -1), + Vector2i(-1, 0), Vector2i(0, 1), Vector2i(1, 1), +] + + +static func to_cube(p: Vector2i) -> Vector3i: + var x := p.x - int((p.y - (p.y & 1)) / 2.0) + var z := p.y + return Vector3i(x, -x - z, z) + + +static func distance(a: Vector2i, b: Vector2i) -> int: + var ca := to_cube(a) + var cb := to_cube(b) + return int((absi(ca.x - cb.x) + absi(ca.y - cb.y) + absi(ca.z - cb.z)) / 2.0) + + +static func neighbors(p: Vector2i) -> Array: + var offs: Array = NEIGHBORS_ODD if (p.y & 1) == 1 else NEIGHBORS_EVEN + var out := [] + for o in offs: + out.append(p + o) + return out + + +## Pixelmittelpunkt eines Hex-Feldes. +static func to_pixel(p: Vector2i, size: float) -> Vector2: + var w := sqrt(3.0) * size + var h := 1.5 * size + var x := w * (float(p.x) + 0.5 * float(p.y & 1)) + var y := h * float(p.y) + return Vector2(x, y) + + +## Eckpunkte eines spitzen Hex um (0,0). +static func corners(size: float) -> PackedVector2Array: + var pts := PackedVector2Array() + for i in 6: + var ang := deg_to_rad(60.0 * float(i) - 30.0) + pts.append(Vector2(size * cos(ang), size * sin(ang))) + return pts diff --git a/scripts/core/Hex.gd.uid b/scripts/core/Hex.gd.uid new file mode 100644 index 0000000..732105c --- /dev/null +++ b/scripts/core/Hex.gd.uid @@ -0,0 +1 @@ +uid://bv0038ucsvxtk diff --git a/scripts/core/PlayerState.gd b/scripts/core/PlayerState.gd new file mode 100644 index 0000000..e1224c8 --- /dev/null +++ b/scripts/core/PlayerState.gd @@ -0,0 +1,359 @@ +class_name PlayerState +extends RefCounted + +## Zustand eines Spielers oder Bots: Gold, Leben, Bank, Board, Shop. + +var pid: int = 0 +var pname: String = "Spieler" +var is_bot: bool = false +var gold: int = Data.START_GOLD +var hp: int = Data.START_HP +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 bench: Array = [] ## Avatar oder null +var board: Dictionary = {} ## Vector2i -> Avatar +var shop: Array = [] ## ShopEntry +## Elemente, die dieser Spieler aufgestellt hat. Nur die zeigt sein Shop. +var unlocked: Array[bool] = [] + + +func _init(p_pid: int = 0, p_name: String = "Spieler", p_bot: bool = false) -> void: + pid = p_pid + pname = p_name + is_bot = p_bot + for i in Data.BENCH_SLOTS: + bench.append(null) + for i in Data.ELEMENT_COUNT: + unlocked.append(false) + + +func unlock(element: int) -> void: + if element >= 0: + unlocked[element] = true + + +## Anzahl der Elemente, die dieser Spieler aufgestellt hat. +func unlocked_count() -> int: + var n := 0 + for e in Data.ELEMENT_COUNT: + if unlocked[e]: + n += 1 + return n + + +## Elemente, die im eigenen Shop erscheinen können: selbst aufgestellt und +## im geteilten Vorrat noch verfügbar. +func shop_elements(pool: Pool) -> Array[int]: + var out: Array[int] = [] + for e in Data.ELEMENT_COUNT: + if unlocked[e] and pool.element_left[e] > 0: + out.append(e) + return out + + +# --- Shop ------------------------------------------------------------------ + +func roll_shop(pool: Pool, rng: RandomNumberGenerator) -> void: + shop.clear() + var active := shop_elements(pool) + # Je mehr eigene Elemente aufgestellt sind, desto seltener kommt Neutral. + var neutral := Data.neutral_chance(unlocked_count()) + for i in Data.SHOP_SLOTS: + var element := -1 + if not active.is_empty() and rng.randf() >= neutral: + element = active[rng.randi_range(0, active.size() - 1)] + shop.append(ShopEntry.new(rng.randi_range(0, 2), element)) + + +func reroll(pool: Pool, rng: RandomNumberGenerator) -> bool: + if gold < Data.REROLL_COST: + return false + gold -= Data.REROLL_COST + roll_shop(pool, rng) + return true + + +func free_bench_slot() -> int: + for i in bench.size(): + if bench[i] == null: + return i + return -1 + + +## Kauft einen Avatar. Neutrale brauchen die beim Kauf gewählte Spezialisierung. +func buy(index: int, pool: Pool, chosen_element: int = -1) -> bool: + if index < 0 or index >= shop.size(): + return false + var entry: ShopEntry = shop[index] + if entry.sold or gold < entry.price(): + return false + var element := entry.element if entry.element >= 0 else chosen_element + if element < 0 or element >= Data.ELEMENT_COUNT: + return false + var slot := free_bench_slot() + if slot < 0: + return false + if not pool.take(element): + return false + gold -= entry.price() + bench[slot] = entry.to_avatar(element) + entry.sold = true + return true + + +func sell_bench(index: int, pool: Pool) -> bool: + if index < 0 or index >= bench.size() or bench[index] == null: + return false + var a: Avatar = bench[index] + pool.return_avatar(a) + gold += maxi(1, a.invested) + bench[index] = null + return true + + +func sell_board(hex: Vector2i, pool: Pool) -> bool: + if not board.has(hex): + return false + var a: Avatar = board[hex] + pool.return_avatar(a) + gold += maxi(1, a.invested) + board.erase(hex) + return true + + +# --- Aufstellen ------------------------------------------------------------ + +func team_slots(round_no: int) -> int: + return Data.team_slots(round_no) + + +func can_place(round_no: int) -> bool: + return board.size() < team_slots(round_no) + + +## Setzt einen Avatar von der Bank aufs Board. +func place(bench_index: int, hex: Vector2i, round_no: int) -> 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): + return false + var a: Avatar = bench[bench_index] + board[hex] = a + bench[bench_index] = null + unlock(a.elements[0]) + return true + + +func unplace(hex: Vector2i) -> bool: + if not board.has(hex): + return false + var slot := free_bench_slot() + if slot < 0: + return false + bench[slot] = board[hex] + board.erase(hex) + return true + + +func move_on_board(from_hex: Vector2i, to_hex: Vector2i) -> bool: + if not board.has(from_hex) or not _valid_hex(to_hex): + return false + var a: Avatar = board[from_hex] + if board.has(to_hex): + board[from_hex] = board[to_hex] + else: + board.erase(from_hex) + board[to_hex] = a + return true + + +## 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: + if src.is_empty() or dst.is_empty() or src == dst: + return false + var sk: String = src["kind"] + var dk: String = dst["kind"] + + if sk == "board" and dk == "board": + return move_on_board(src["hex"], dst["hex"]) + + if sk == "bench" and dk == "bench": + var i: int = src["index"] + var j: int = dst["index"] + if bench[i] == null: + return false + var tmp = bench[j] + bench[j] = bench[i] + bench[i] = tmp + return true + + if sk == "board" and dk == "bench": + var hex: Vector2i = src["hex"] + var j2: int = dst["index"] + if not board.has(hex): + return false + var unit: Avatar = board[hex] + if bench[j2] == null: + board.erase(hex) + else: + board[hex] = bench[j2] + bench[j2] = unit + return true + + # bench -> board + var i3: int = src["index"] + 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): + return false + var unit3: Avatar = bench[i3] + if board.has(hex3): + bench[i3] = board[hex3] + else: + bench[i3] = null + board[hex3] = unit3 + unlock(unit3.elements[0]) + return true + + +func _valid_hex(h: Vector2i) -> bool: + return h.x >= 0 and h.x < Data.BOARD_COLS and h.y >= 0 and h.y < Data.BOARD_ROWS + + +# --- Aufwerten ------------------------------------------------------------- + +## Wie viel Futter pro Element auf der Bank liegt. +func fodder_counts() -> Dictionary: + var counts := {} + for a in bench: + if a == null or a.is_elementless() or a.star != 1: + continue + var e: int = a.elements[0] + counts[e] = counts.get(e, 0) + 1 + return counts + + +func can_upgrade(hex: Vector2i) -> bool: + if not board.has(hex): + return false + var a: Avatar = board[hex] + var cost := a.upgrade_cost() + if cost.is_empty(): + return false + var have := fodder_counts() + for e in cost: + if have.get(e, 0) < cost[e]: + return false + return true + + +## Fehlende Futter-Avatare pro Element (für die Anzeige). +func upgrade_missing(hex: Vector2i) -> Dictionary: + if not board.has(hex): + return {} + var a: Avatar = board[hex] + var cost := a.upgrade_cost() + var have := fodder_counts() + var miss := {} + for e in cost: + var m: int = cost[e] - have.get(e, 0) + if m > 0: + miss[e] = m + return miss + + +func available_new_elements(hex: Vector2i) -> Array[int]: + var out: Array[int] = [] + if not board.has(hex): + return out + var a: Avatar = board[hex] + for e in Data.ELEMENT_COUNT: + if not a.has_element(e): + out.append(e) + return out + + +func upgrade(hex: Vector2i, new_element: int, pool: Pool) -> bool: + if not can_upgrade(hex): + return false + var a: Avatar = board[hex] + if a.has_element(new_element) or new_element < 0 or new_element >= Data.ELEMENT_COUNT: + return false + var cost := a.upgrade_cost() + var spent := 0 + for e in cost: + var need: int = cost[e] + for i in bench.size(): + if need <= 0: + break + var f = bench[i] + if f != null and f.star == 1 and not f.is_elementless() and f.elements[0] == e: + spent += f.invested + bench[i] = null + need -= 1 + a.invested += spent + a.star += 1 + a.elements.append(new_element) + unlock(new_element) + return true + + +# --- Traits ---------------------------------------------------------------- + +## Zählt aufgestellte Einheiten pro Element. +func trait_counts() -> Dictionary: + var counts := {} + for hex in board: + var a: Avatar = board[hex] + for e in a.elements: + counts[e] = counts.get(e, 0) + 1 + return counts + + +func active_combos() -> Array: + var counts := trait_counts() + var out := [] + for c in Data.COMBO_TRAITS: + if counts.get(c["a"], 0) >= Data.COMBO_MIN and counts.get(c["b"], 0) >= Data.COMBO_MIN: + out.append(c) + return out + + +# --- Runde ----------------------------------------------------------------- + +func round_income() -> int: + var g := Data.BASE_INCOME + if last_won: + g += Data.WIN_GOLD + g += Data.streak_gold(streak) + g += Data.interest(gold) + return g + + +func register_result(won: bool) -> void: + last_won = won + if won: + streak = maxi(1, streak + 1) + else: + streak = mini(-1, streak - 1) + + +## Nur Leben abziehen. Das Ausscheiden setzt Game._check_eliminations, +## damit die Platzierung stimmt. +func take_damage(dmg: int) -> void: + hp = maxi(0, hp - dmg) + + +func board_units() -> Array: + var out := [] + for hex in board: + out.append(board[hex]) + return out diff --git a/scripts/core/PlayerState.gd.uid b/scripts/core/PlayerState.gd.uid new file mode 100644 index 0000000..2276fd6 --- /dev/null +++ b/scripts/core/PlayerState.gd.uid @@ -0,0 +1 @@ +uid://ddat00fvvoxjv diff --git a/scripts/core/Pool.gd b/scripts/core/Pool.gd new file mode 100644 index 0000000..f364e0c --- /dev/null +++ b/scripts/core/Pool.gd @@ -0,0 +1,48 @@ +class_name Pool +extends RefCounted + +## Geteilter Avatar-Pool. Alle 8 Spieler kaufen aus demselben Vorrat. +## Welche Elemente ein Spieler im Shop sieht, steht in PlayerState.unlocked. + +var element_left: Array[int] = [] + + +func _init() -> void: + for i in Data.ELEMENT_COUNT: + element_left.append(Data.POOL_PER_ELEMENT) + + +func available(element: int) -> bool: + return element < 0 or element_left[element] > 0 + + +func take(element: int) -> bool: + if element < 0: + # Der neutrale Standard-Avatar ist unbegrenzt, nur Elemente sind knapp. + return true + if element_left[element] <= 0: + return false + element_left[element] -= 1 + return true + + +func give_back(element: int, amount: int = 1) -> void: + if element < 0: + return + else: + element_left[element] = mini(Data.POOL_PER_ELEMENT, element_left[element] + amount) + + +## Gibt alle Bausteine eines Avatars zurück in den Pool. +func return_avatar(a: Avatar) -> void: + if a.is_elementless(): + give_back(-1) + return + # Basis-Avatar plus verbrauchtes Futter. + give_back(a.elements[0]) + if a.star >= 2: + give_back(a.elements[0], 2) + if a.star >= 3: + for e in a.elements: + give_back(e, 3) + # Zusätzlich gewählte Elemente kosten keinen Pool-Slot. diff --git a/scripts/core/Pool.gd.uid b/scripts/core/Pool.gd.uid new file mode 100644 index 0000000..a8cbe7e --- /dev/null +++ b/scripts/core/Pool.gd.uid @@ -0,0 +1 @@ +uid://dp7fj0pjyyyye diff --git a/scripts/core/ShopEntry.gd b/scripts/core/ShopEntry.gd new file mode 100644 index 0000000..ea8ce87 --- /dev/null +++ b/scripts/core/ShopEntry.gd @@ -0,0 +1,32 @@ +class_name ShopEntry +extends RefCounted + +var role: int +var element: int = -1 ## -1 = elementlos +var sold: bool = false + + +func _init(p_role: int = 0, p_element: int = -1) -> void: + role = p_role + element = p_element + + +func price() -> int: + return Data.PRICE_NONE if element < 0 else Data.PRICE_ELEMENT + + +func color() -> Color: + return Data.NONE_COLOR if element < 0 else Data.ELEMENT_COLORS[element] + + +func label() -> String: + var e: String = "Neutral" if element < 0 else Data.ELEMENT_NAMES[element] + return e + "\n" + Data.ROLE_NAMES[role] + + +## Neutrale Avatare bekommen die beim Kauf gewählte Spezialisierung. +func to_avatar(chosen_element: int) -> Avatar: + var e := element if element >= 0 else chosen_element + var a := Avatar.new(role, [e] as Array[int], 1) + a.invested = price() + return a diff --git a/scripts/core/ShopEntry.gd.uid b/scripts/core/ShopEntry.gd.uid new file mode 100644 index 0000000..d8146db --- /dev/null +++ b/scripts/core/ShopEntry.gd.uid @@ -0,0 +1 @@ +uid://ch7jrt780kmt8 diff --git a/scripts/ui/BoardView3D.gd b/scripts/ui/BoardView3D.gd new file mode 100644 index 0000000..0e43e18 --- /dev/null +++ b/scripts/ui/BoardView3D.gd @@ -0,0 +1,422 @@ +class_name BoardView3D +extends Node3D + +## 3D-Arena: 7x8 Hexfelder als Zylinder-Primitive, Einheiten als Box/Kegel/Kugel. +## Reine Darstellung, die Spiellogik liegt in Combat und PlayerState. + +signal hex_clicked(hex: Vector2i, button: int) + +const COLS := Combat.ARENA_COLS +const ROWS := Combat.ARENA_ROWS +const TILE := 1.0 +const TILE_H := 0.18 +const MOVE_LERP := 8.0 +const BENCH_GAP := 1.62 ## Abstand der Bankplätze +const BENCH_OFFSET := 1.95 ## Abstand der Bank hinter der letzten Reihe + +var game: Game = null +var camera: Camera3D = null +var selected_slot := {} ## {"kind": "board"/"bench", ...} +var hover_slot := {} +var drag_active := false +var drag_avatar_id := -1 +var drag_world := Vector3.ZERO + +## Was das 2D-Overlay über die Einheiten zeichnen soll. +var draw_units: Array = [] + +var _tiles := {} ## Vector2i -> MeshInstance3D +var _tile_mats := {} ## Vector2i -> StandardMaterial3D +var _bench_tiles := {} ## int -> MeshInstance3D +var _bench_mats := {} +var _bench_z := 0.0 +var _units := {} ## key -> Node3D +var _origin := Vector3.ZERO +var _tile_mesh: CylinderMesh +var _ring_mesh: TorusMesh + + +func _ready() -> void: + _tile_mesh = CylinderMesh.new() + _tile_mesh.radial_segments = 6 + _tile_mesh.rings = 1 + _tile_mesh.top_radius = TILE * 0.92 + _tile_mesh.bottom_radius = TILE * 0.92 + _tile_mesh.height = TILE_H + _ring_mesh = TorusMesh.new() + _ring_mesh.inner_radius = 0.50 + _ring_mesh.outer_radius = 0.66 + _ring_mesh.ring_segments = 8 + _ring_mesh.rings = 24 + _build_tiles() + _build_bench() + set_process(true) + + +# --- Geometrie ------------------------------------------------------------- + +func _build_tiles() -> void: + var lo := Vector2(INF, INF) + var hi := Vector2(-INF, -INF) + for r in ROWS: + for c in COLS: + var p := Hex.to_pixel(Vector2i(c, r), TILE) + lo = Vector2(minf(lo.x, p.x), minf(lo.y, p.y)) + hi = Vector2(maxf(hi.x, p.x), maxf(hi.y, p.y)) + var mid := (lo + hi) * 0.5 + _origin = Vector3(mid.x, 0.0, mid.y) + + for r in ROWS: + for c in COLS: + var h := Vector2i(c, r) + var mi := MeshInstance3D.new() + mi.mesh = _tile_mesh + mi.position = world_pos(h) + var m := StandardMaterial3D.new() + m.roughness = 0.9 + m.metallic = 0.0 + mi.material_override = m + add_child(mi) + _tiles[h] = mi + _tile_mats[h] = m + + +func _build_bench() -> void: + var max_z := -INF + for key in _tiles: + max_z = maxf(max_z, world_pos(key).z) + _bench_z = max_z + TILE * BENCH_OFFSET + var pad := CylinderMesh.new() + pad.radial_segments = 6 + pad.rings = 1 + pad.top_radius = 0.62 + pad.bottom_radius = 0.62 + pad.height = 0.12 + for i in Data.BENCH_SLOTS: + var mi := MeshInstance3D.new() + mi.mesh = pad + mi.position = bench_pos(i) + var m := StandardMaterial3D.new() + m.roughness = 0.9 + mi.material_override = m + add_child(mi) + _bench_tiles[i] = mi + _bench_mats[i] = m + + +func bench_pos(index: int) -> Vector3: + var half := float(Data.BENCH_SLOTS - 1) * 0.5 + return Vector3((float(index) - half) * BENCH_GAP, 0.0, _bench_z) + + +## Feld oder Bankplatz unter einem Weltpunkt. +func slot_at_world(p: Vector3) -> Dictionary: + var flat := Vector2(p.x, p.z) + var best := {} + var best_d := TILE * 0.98 + for key in _tiles: + var w := world_pos(key) + var d := flat.distance_to(Vector2(w.x, w.z)) + if d < best_d: + best_d = d + best = {"kind": "board", "hex": to_board(key), "arena": key} + for i in Data.BENCH_SLOTS: + var b := bench_pos(i) + var d2 := flat.distance_to(Vector2(b.x, b.z)) + if d2 < best_d: + best_d = d2 + best = {"kind": "bench", "index": i} + return best + + +## Bildschirmposition eines Feldes oder Bankplatzes (für Tests und Anzeige). +func slot_screen(slot: Dictionary) -> Vector2: + if camera == null: + return Vector2.ZERO + return camera.unproject_position(to_global(slot_world(slot)) + Vector3(0.0, TILE_H * 0.5, 0.0)) + + +func slot_world(slot: Dictionary) -> Vector3: + if slot.is_empty(): + return Vector3.ZERO + if slot["kind"] == "bench": + return bench_pos(slot["index"]) + return world_pos(from_board(slot["hex"])) + + +func world_pos(hex: Vector2i) -> Vector3: + var p := Hex.to_pixel(hex, TILE) + return Vector3(p.x, 0.0, p.y) - _origin + + +## Nächstes Hexfeld zu einem Weltpunkt, oder (-1,-1). +func hex_at_world(p: Vector3) -> Vector2i: + var best := Vector2i(-1, -1) + var best_d := TILE * 0.98 + for key in _tiles: + var h: Vector2i = key + var w := world_pos(h) + var d := Vector2(p.x, p.z).distance_to(Vector2(w.x, w.z)) + if d < best_d: + best_d = d + best = h + return best + + +## Bildschirmposition eines Feldes (für Overlay und Tests). +func screen_pos(hex: Vector2i, height: float = 0.0) -> Vector2: + if camera == null: + return Vector2.ZERO + return camera.unproject_position(world_pos(hex) + Vector3(0.0, height, 0.0)) + + +## Acht Eckpunkte des Spielfeldes, oben mit Platz für hohe Einheiten. +func corner_points() -> Array: + var lo := Vector2(INF, INF) + var hi := Vector2(-INF, -INF) + for key in _tiles: + var w := world_pos(key) + lo = Vector2(minf(lo.x, w.x), minf(lo.y, w.z)) + hi = Vector2(maxf(hi.x, w.x), maxf(hi.y, w.z)) + for i in Data.BENCH_SLOTS: + var b := bench_pos(i) + lo = Vector2(minf(lo.x, b.x), minf(lo.y, b.z)) + hi = Vector2(maxf(hi.x, b.x), maxf(hi.y, b.z)) + lo -= Vector2(TILE, TILE) + hi += Vector2(TILE, TILE) + var out := [] + for y in [0.0, 1.5]: + out.append(Vector3(lo.x, y, lo.y)) + out.append(Vector3(hi.x, y, lo.y)) + out.append(Vector3(lo.x, y, hi.y)) + out.append(Vector3(hi.x, y, hi.y)) + return out + + +static func to_board(h: Vector2i) -> Vector2i: + return Vector2i(h.x, h.y - 4) + + +static func from_board(h: Vector2i) -> Vector2i: + return Vector2i(h.x, h.y + 4) + + +func is_own_half(h: Vector2i) -> bool: + return h.y >= 4 and h.y < ROWS and h.x >= 0 and h.x < COLS + + +# --- Bildaufbau ------------------------------------------------------------ + +func _process(delta: float) -> void: + if game == null: + return + _update_tiles() + _update_units(delta) + + +func _update_tiles() -> void: + for key in _tiles: + var h: Vector2i = key + var own: bool = h.y >= 4 + var slot := {"kind": "board", "hex": to_board(h)} + var col := Color(0.20, 0.23, 0.31) if own else Color(0.28, 0.18, 0.20) + var emit := 0.0 + if own and _is_hover(slot): + col = Color(0.42, 0.60, 0.82) + emit = 0.40 + elif own and not drag_active and _same_slot(slot, selected_slot): + col = Color(0.35, 0.52, 0.75) + emit = 0.30 + elif own and drag_active: + col = Color(0.24, 0.42, 0.29) + emit = 0.16 + _paint(_tile_mats[h], col, emit) + + for i in Data.BENCH_SLOTS: + var bslot := {"kind": "bench", "index": i} + var bcol := Color(0.24, 0.24, 0.30) + var bemit := 0.0 + if _is_hover(bslot): + bcol = Color(0.48, 0.56, 0.72) + bemit = 0.40 + elif not drag_active and _same_slot(bslot, selected_slot): + bcol = Color(0.38, 0.44, 0.60) + bemit = 0.28 + elif drag_active: + bcol = Color(0.26, 0.40, 0.32) + bemit = 0.16 + _paint(_bench_mats[i], bcol, bemit) + + +func _paint(m: StandardMaterial3D, col: Color, emit: float) -> void: + m.albedo_color = col + m.emission_enabled = emit > 0.0 + m.emission = col + m.emission_energy_multiplier = emit + + +func _is_hover(slot: Dictionary) -> bool: + return game.phase == Game.Phase.PLANNING and _same_slot(slot, hover_slot) + + +static func _same_slot(a: Dictionary, b: Dictionary) -> bool: + if a.is_empty() or b.is_empty() or a["kind"] != b["kind"]: + return false + if a["kind"] == "bench": + return a["index"] == b["index"] + return a["hex"] == b["hex"] + + +func _update_units(delta: float) -> void: + var wanted := {} + draw_units.clear() + if game.phase == Game.Phase.COMBAT and game.player_combat != null: + var my_team := game.human_team_in_combat() + for u in game.player_combat.units: + if not u.alive: + continue + var key := "c%d" % u.get_instance_id() + wanted[key] = { + "avatar": u.avatar, + "world": world_pos(_mirror(u.pos, my_team)), + "hp": u.hp / u.max_hp, + "mana": u.mana / u.max_mana, + "side": 0 if u.team == my_team else 1, + "cast": u.cast_flash > 0.0, + "hit": u.hit_flash > 0.0, + } + else: + for bh in game.human.board: + var a: Avatar = game.human.board[bh] + wanted["a%d" % a.id] = { + "avatar": a, "world": world_pos(from_board(bh)), "hp": 1.0, "mana": 0.0, + "side": 0, "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] + if ba == null: + continue + wanted["a%d" % ba.id] = { + "avatar": ba, "world": bench_pos(i), "hp": 1.0, "mana": 0.0, + "side": 0, "cast": false, "hit": false, + } + # Der gezogene Avatar folgt dem Zeiger. + var drag_key := "" + if drag_active and drag_avatar_id >= 0: + drag_key = "a%d" % drag_avatar_id + if wanted.has(drag_key): + wanted[drag_key]["world"] = drag_world + wanted[drag_key]["dragged"] = true + + for key in _units.keys(): + if not wanted.has(key): + _units[key].queue_free() + _units.erase(key) + + for key in wanted: + var d: Dictionary = wanted[key] + var node: Node3D = _units.get(key) + if node == null: + node = _make_unit(d["avatar"], d["side"]) + node.position = d["world"] + add_child(node) + _units[key] = node + var target: Vector3 = d["world"] + if d.get("dragged", false): + # Beim Ziehen ohne Glättung, sonst schleift der Avatar hinterher + # und sieht nicht angehoben aus. + node.position = target + else: + node.position = node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0)) + 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 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) + draw_units.append({ + "pos": node.position, "avatar": d["avatar"], "hp": d["hp"], + "mana": d["mana"], "side": d["side"], "cast": d["cast"], + }) + + +## Knoten eines Avatars, oder null. Wird von den Tests genutzt. +func unit_node(avatar_id: int) -> Node3D: + return _units.get("a%d" % avatar_id) + + +func _mirror(p: Vector2i, my_team: int) -> Vector2i: + if my_team == 0: + return p + return Vector2i(COLS - 1 - p.x, ROWS - 1 - p.y) + + +func _make_unit(a: Avatar, side: int) -> Node3D: + var root := Node3D.new() + + var body := MeshInstance3D.new() + var mat := StandardMaterial3D.new() + mat.albedo_color = a.color() + mat.roughness = 0.45 + mat.metallic = 0.15 + mat.emission_enabled = true + mat.emission = a.color() + mat.emission_energy_multiplier = 0.12 + body.material_override = mat + match a.role: + Data.Role.TANK: + var box := BoxMesh.new() + box.size = Vector3(0.78, 0.78, 0.78) + body.mesh = box + body.position.y = 0.48 + Data.Role.DPS: + var cone := CylinderMesh.new() + cone.top_radius = 0.0 + cone.bottom_radius = 0.46 + cone.height = 1.05 + cone.radial_segments = 12 + body.mesh = cone + body.position.y = 0.62 + _: + var sph := SphereMesh.new() + sph.radius = 0.42 + sph.height = 0.84 + sph.radial_segments = 16 + sph.rings = 10 + body.mesh = sph + body.position.y = 0.55 + root.add_child(body) + root.set_meta("body", body) + + # Bodenring: weiss = eigenes Team, rot = Gegner. + var ring := MeshInstance3D.new() + ring.mesh = _ring_mesh + ring.position.y = 0.10 + var rm := StandardMaterial3D.new() + rm.albedo_color = Color(0.95, 0.95, 1.0) if side == 0 else Color(0.92, 0.32, 0.32) + rm.emission_enabled = true + rm.emission = rm.albedo_color + rm.emission_energy_multiplier = 0.5 + ring.material_override = rm + root.add_child(ring) + + # Zweit- und Drittelement als schmalere Ringe darüber. + for i in range(1, a.elements.size()): + var er := MeshInstance3D.new() + var em := TorusMesh.new() + em.inner_radius = 0.30 + 0.10 * float(i) + em.outer_radius = em.inner_radius + 0.07 + em.ring_segments = 6 + em.rings = 20 + er.mesh = em + er.position.y = 0.18 + 0.16 * float(i) + var m2 := StandardMaterial3D.new() + m2.albedo_color = Data.ELEMENT_COLORS[a.elements[i]] + m2.emission_enabled = true + m2.emission = m2.albedo_color + m2.emission_energy_multiplier = 0.6 + er.material_override = m2 + root.add_child(er) + return root diff --git a/scripts/ui/BoardView3D.gd.uid b/scripts/ui/BoardView3D.gd.uid new file mode 100644 index 0000000..8d4e29c --- /dev/null +++ b/scripts/ui/BoardView3D.gd.uid @@ -0,0 +1 @@ +uid://cyxqqwolqywtm diff --git a/scripts/ui/HudOverlay.gd b/scripts/ui/HudOverlay.gd new file mode 100644 index 0000000..9b1c85c --- /dev/null +++ b/scripts/ui/HudOverlay.gd @@ -0,0 +1,53 @@ +class_name HudOverlay +extends Control + +## Zeichnet Lebens- und Manabalken, Rolle und Sterne über die 3D-Einheiten. + +var board: BoardView3D = null +var _font: Font + + +func _ready() -> void: + _font = ThemeDB.fallback_font + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_process(true) + + +func _process(_delta: float) -> void: + queue_redraw() + + +func _draw() -> void: + if board == null or board.camera == null: + return + var cam := board.camera + for d in board.draw_units: + var world: Vector3 = d["pos"] + if cam.is_position_behind(world): + continue + var head := cam.unproject_position(world + Vector3(0.0, 1.35, 0.0)) + var foot := cam.unproject_position(world + Vector3(0.0, 0.05, 0.0)) + 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 + draw_rect(Rect2(bx - 1.0, by - 1.0, bw + 2.0, 8.0), Color(0.05, 0.05, 0.07, 0.85)) + draw_rect(Rect2(bx, by, bw * clampf(d["hp"], 0.0, 1.0), 6.0), + Color(0.35, 0.85, 0.42) if side == 0 else Color(0.92, 0.38, 0.38)) + if d["mana"] > 0.0: + 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)) + + # Sterne + for s in a.star: + draw_circle(Vector2(bx + bw * 0.5 - 7.0 + 7.0 * float(s), by - 7.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) diff --git a/scripts/ui/HudOverlay.gd.uid b/scripts/ui/HudOverlay.gd.uid new file mode 100644 index 0000000..ad01eaf --- /dev/null +++ b/scripts/ui/HudOverlay.gd.uid @@ -0,0 +1 @@ +uid://bhbduae6n3bp5 diff --git a/scripts/ui/Main.gd b/scripts/ui/Main.gd new file mode 100644 index 0000000..5f98594 --- /dev/null +++ b/scripts/ui/Main.gd @@ -0,0 +1,843 @@ +extends Node3D + +## Spielszene: 3D-Arena plus Bedienoberfläche. +## Alles wird im Code aus primitiven Meshes und Controls gebaut. + +const TOP_H := 46.0 +const LEFT_W := 290.0 ## links: Elemente, Einheit, Rundenlog +const RIGHT_W := 210.0 ## rechts: Spielerliste +const BOTTOM_H := 138.0 +const TEXT_W := 244.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 + +var game := Game.new() +var board: BoardView3D +var camera: Camera3D +var ui_layer: CanvasLayer +var ui_root: Control +var hud: HudOverlay +var combat_speed := 1.0 + +# 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 + +# Auswahl und Ziehen +var sel_slot := {} +var drag_src := {} +var drag_avatar: Avatar = null +var drag_moved := false + +# Element-Auswahl +var picker: PopupPanel +var picker_grid: GridContainer +var picker_title: Label +var picker_sub: Label +var picker_cb: Callable + +var overlay: Control + + +func _ready() -> void: + _build_world() + ui_layer = CanvasLayer.new() + add_child(ui_layer) + hud = HudOverlay.new() + hud.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + hud.board = board + ui_layer.add_child(hud) + 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_picker() + + game.start() + board.game = game + _refresh() + set_process(true) + + +func _process(delta: float) -> void: + if game.phase == Game.Phase.COMBAT: + var before := game.phase + game.update_combat(delta * combat_speed) + _refresh_header() + if game.phase != before: + _refresh() + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseMotion: + _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 + 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_ESCAPE: + _cancel_drag() + sel_slot = {} + _refresh() + + +func _on_mouse_move(pos: Vector2) -> void: + var slot := _pick_slot(pos) + 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 + var slot := _pick_slot(pos) + var a: Avatar = _avatar_at(slot) + if a == null: + sel_slot = {} + _refresh() + 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() + + +func _end_drag(pos: Vector2) -> void: + if drag_avatar == null: + return + var dst := _pick_slot(pos) + 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 dst["kind"] == "board": + _hint("Kaderplätze voll (%d)." % game.human.team_slots(game.round_no)) + else: + sel_slot = dst + _refresh() + + +func _cancel_drag() -> void: + drag_avatar = null + drag_src = {} + board.drag_active = false + board.drag_avatar_id = -1 + + +func _on_right_click(pos: Vector2) -> void: + if game.phase != Game.Phase.PLANNING: + return + _cancel_drag() + var slot := _pick_slot(pos) + if slot.is_empty(): + return + if slot["kind"] == "bench": + game.human.sell_bench(slot["index"], game.pool) + else: + game.human.unplace(slot["hex"]) + sel_slot = {} + _refresh() + + +func _avatar_at(slot: Dictionary): + if slot.is_empty(): + return null + if slot["kind"] == "bench": + return game.human.bench[slot["index"]] + 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()) + + +func _build_world() -> void: + var we := WorldEnvironment.new() + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = BG + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.44, 0.48, 0.62) + env.ambient_light_energy = 0.9 + we.environment = env + add_child(we) + + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-58.0, -35.0, 0.0) + sun.light_energy = 1.15 + sun.shadow_enabled = true + add_child(sun) + + var fill := DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-22.0, 140.0, 0.0) + fill.light_energy = 0.4 + fill.light_color = Color(0.72, 0.80, 1.0) + add_child(fill) + + board = BoardView3D.new() + add_child(board) + + camera = Camera3D.new() + camera.fov = CAM_FOV + camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP) + add_child(camera) + board.camera = camera + get_viewport().size_changed.connect(_fit_camera) + _fit_camera() + + +## Legt die Kamera so, dass das Spielfeld genau in die freie Bildmitte passt. +## 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, + vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0) + if target.size.x <= 0.0 or target.size.y <= 0.0: + return + var pts := board.corner_points() + var pitch := deg_to_rad(CAM_PITCH) + var dir := Vector3(0.0, sin(pitch), cos(pitch)) + var dist := 20.0 + var center := Vector3.ZERO + for i in 8: + camera.look_at_from_position(center + dir * dist, center, Vector3.UP) + var r := _project_rect(pts) + if r.size.x <= 0.0 or r.size.y <= 0.0: + return + dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y) + camera.look_at_from_position(center + dir * dist, center, Vector3.UP) + r = _project_rect(pts) + var err := r.get_center() - target.get_center() + var o := camera.unproject_position(center) + var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x + var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y + if absf(kx) < 0.001 or absf(kz) < 0.001: + break + center += Vector3(err.x / kx, 0.0, err.y / kz) + 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) + + +func _project_rect(points: Array) -> Rect2: + var lo := Vector2(INF, INF) + var hi := Vector2(-INF, -INF) + for p in points: + if camera.is_position_behind(p): + return Rect2() + var q := camera.unproject_position(p) + lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y)) + hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y)) + return Rect2(lo, hi - lo) + + +## Punkt auf der Feldebene unter einer Bildschirmposition, oder null. +func _ground_point(screen_pos: Vector2): + if camera == null: + return null + var from := camera.project_ray_origin(screen_pos) + var dir := camera.project_ray_normal(screen_pos) + if absf(dir.y) < 0.0001: + return null + var t := (BoardView3D.TILE_H * 0.5 - from.y) / dir.y + if t < 0.0: + return null + return board.to_local(from + dir * t) + + +## 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: + return board.slot_world(fallback) + 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: + return {} + 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))) + + +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 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))) + + shop_row = HBoxContainer.new() + shop_row.add_theme_constant_override("separation", 8) + v.add_child(shop_row) + + +func _build_picker() -> void: + picker = PopupPanel.new() + picker.add_theme_stylebox_override("panel", UiKit.flat(Color(0.16, 0.17, 0.22))) + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 8) + picker.add_child(v) + picker_title = UiKit.label("Element wählen", 16) + v.add_child(picker_title) + picker_sub = UiKit.label("", 13, Color(0.75, 0.8, 0.95)) + v.add_child(picker_sub) + picker_grid = GridContainer.new() + picker_grid.columns = 3 + picker_grid.add_theme_constant_override("h_separation", 6) + picker_grid.add_theme_constant_override("v_separation", 6) + v.add_child(picker_grid) + ui_root.add_child(picker) + + +## Element-Auswahl. Mit `previews` steht neben jedem Knopf, was sich ändert. +func _open_picker(title: String, options: Array, cb: Callable, previews: Array = [], + subtitle: String = "") -> void: + picker_title.text = title + picker_sub.text = subtitle + picker_sub.visible = subtitle != "" + for c in picker_grid.get_children(): + c.queue_free() + picker_cb = cb + var counts := game.human.trait_counts() + var with_preview := not previews.is_empty() + picker_grid.columns = 2 if with_preview else 3 + for i in options.size(): + var e: int = options[i] + var col: Color = Data.ELEMENT_COLORS[e] + var b := UiKit.button("%s\n(%d im Team)" % [Data.ELEMENT_NAMES[e], counts.get(e, 0)], + col, UiKit.ink(col)) + b.custom_minimum_size = Vector2(120, 52 if with_preview else 46) + b.pressed.connect(func(): + picker.hide() + picker_cb.call(e)) + picker_grid.add_child(b) + if with_preview: + var l := UiKit.label(previews[i], 12, Color(0.85, 0.88, 0.95)) + l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + picker_grid.add_child(l) + picker.reset_size() + picker.popup_centered() + + +# --- Aktionen -------------------------------------------------------------- + +func _on_ready() -> void: + if game.phase == Game.Phase.PLANNING: + _cancel_drag() + sel_slot = {} + game.start_combat() + _refresh() + elif game.phase == Game.Phase.COMBAT: + game.skip_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) + + +func _on_reroll() -> void: + if game.phase != Game.Phase.PLANNING: + return + game.human.reroll(game.pool, game.rand) + _refresh() + + +func _on_buy(index: int) -> void: + if game.phase != Game.Phase.PLANNING: + return + var p := game.human + if index < 0 or index >= p.shop.size(): + return + var entry: ShopEntry = p.shop[index] + if entry.sold: + return + if p.free_bench_slot() < 0: + _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).") + _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.") + 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).") + _refresh()) + + +func _on_upgrade() -> void: + var p := game.human + var hex := selected_board_hex() + if hex.x < 0 or not p.can_upgrade(hex): + return + var a: Avatar = p.board[hex] + var options := p.available_new_elements(hex) + var previews := [] + 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) + _refresh(), previews, + "Fähigkeit wird zu: " + _star_effect(a.role, a.star + 1)) + + +func _star_effect(role: int, star: int) -> String: + match role: + Data.Role.TANK: + if star == 2: + return "Schild und spottet 3 Gegner" + return "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" + _: + if star == 2: + return "heilt 3 Verbündete" + return "heilt alle und schildet sie" + + +func _element_effect(element: int) -> String: + match element: + Data.Element.FEUER: return "Brand" + Data.Element.WASSER: return "Mana und stärkere Heilung" + Data.Element.WIND: return "Tempo-Schub" + Data.Element.BLITZ: return "Kettenblitz" + Data.Element.ERDE: return "Betäubung" + _: return "Verlangsamung" + + +## Gewähltes Board-Feld, oder (-1,-1) wenn nichts oder ein Bankplatz gewählt ist. +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 --------------------------------------------------------------- + +func _refresh() -> void: + _refresh_header() + _refresh_players() + _refresh_traits() + _refresh_info() + _refresh_log() + _refresh_fodder() + _refresh_shop() + 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 + + +## 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: + if overlay != null: + return + 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) + overlay.add_child(v) + 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, + Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6))) + var b := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36)) + b.custom_minimum_size = Vector2(180, 36) + b.pressed.connect(func(): + overlay.queue_free() + overlay = null + game = Game.new() + game.start() + board.game = game + _cancel_drag() + sel_slot = {} + _refresh()) + v.add_child(b) diff --git a/scripts/ui/Main.gd.uid b/scripts/ui/Main.gd.uid new file mode 100644 index 0000000..04bfbce --- /dev/null +++ b/scripts/ui/Main.gd.uid @@ -0,0 +1 @@ +uid://dgtlalmkli5w4 diff --git a/scripts/ui/UiKit.gd b/scripts/ui/UiKit.gd new file mode 100644 index 0000000..032fe1a --- /dev/null +++ b/scripts/ui/UiKit.gd @@ -0,0 +1,56 @@ +class_name UiKit +extends RefCounted + +## Kleine Helfer für die primitiven UI-Bausteine. + +static func flat(bg: Color, border: Color = Color(0, 0, 0, 0), radius: int = 6) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.corner_radius_top_left = radius + sb.corner_radius_top_right = radius + sb.corner_radius_bottom_left = radius + sb.corner_radius_bottom_right = radius + if border.a > 0.0: + sb.border_color = border + sb.set_border_width_all(2) + sb.content_margin_left = 6 + sb.content_margin_right = 6 + sb.content_margin_top = 4 + sb.content_margin_bottom = 4 + return sb + + +static func panel(bg: Color) -> PanelContainer: + var p := PanelContainer.new() + p.add_theme_stylebox_override("panel", flat(bg)) + return p + + +static func label(text: String, size: int = 14, col: Color = Color(0.9, 0.9, 0.95)) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", col) + return l + + +static func button(text: String, bg: Color, fg: Color = Color(1, 1, 1)) -> Button: + var b := Button.new() + b.text = text + b.add_theme_stylebox_override("normal", flat(bg)) + b.add_theme_stylebox_override("hover", flat(bg.lightened(0.12))) + b.add_theme_stylebox_override("pressed", flat(bg.darkened(0.15))) + b.add_theme_stylebox_override("disabled", flat(bg.darkened(0.45))) + b.add_theme_color_override("font_color", fg) + b.add_theme_color_override("font_hover_color", fg) + b.add_theme_color_override("font_disabled_color", Color(fg, 0.45)) + b.add_theme_font_size_override("font_size", 14) + b.clip_text = true + # Ohne das würde der zuletzt geklickte Knopf die Leertaste abfangen. + b.focus_mode = Control.FOCUS_NONE + return b + + +## Lesbare Textfarbe zu einem Hintergrund. +static func ink(bg: Color) -> Color: + return Color(0.06, 0.06, 0.08) if bg.get_luminance() > 0.5 else Color(0.96, 0.96, 1.0) diff --git a/scripts/ui/UiKit.gd.uid b/scripts/ui/UiKit.gd.uid new file mode 100644 index 0000000..f5e098e --- /dev/null +++ b/scripts/ui/UiKit.gd.uid @@ -0,0 +1 @@ +uid://dguo4u0xtal7d diff --git a/tests/CamCal.gd b/tests/CamCal.gd new file mode 100644 index 0000000..27273f3 --- /dev/null +++ b/tests/CamCal.gd @@ -0,0 +1,68 @@ +extends Node3D + +## Rendert dieselbe Aufstellung mit verschiedenen Kameraeinstellungen. + +var main: Node3D +var out_dir := "user://shots" + +const SETS := [ + {"pos": Vector3(0, 17.0, 15.5), "fov": 42.0, "h": 0.0, "v": 0.0}, + {"pos": Vector3(0, 17.0, 15.5), "fov": 42.0, "h": 0.4, "v": -1.0}, + {"pos": Vector3(0, 17.0, 15.5), "fov": 42.0, "h": 0.4, "v": 1.0}, + {"pos": Vector3(0, 19.0, 17.0), "fov": 40.0, "h": 0.4, "v": 1.4}, +] + + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + out_dir = args[0] + DirAccess.make_dir_recursive_absolute(out_dir) + main = load("res://scenes/Main.tscn").instantiate() + add_child(main) + _run() + + +func _run() -> void: + await get_tree().create_timer(0.5).timeout + var p: PlayerState = main.game.human + main.game.round_no = 14 + var el := [Data.Element.FEUER, Data.Element.ERDE, Data.Element.WASSER] + var i := 0 + for hex in [Vector2i(1, 0), Vector2i(5, 0), Vector2i(3, 1), Vector2i(0, 3), Vector2i(6, 3)]: + p.board[hex] = Avatar.new(i % 3, [el[i % 3]] as Array[int], 1) + i += 1 + main._refresh() + # Volles Board: alle 4 Reihen belegen, damit die Einpassung sichtbar wird. + p.board.clear() + main.game.round_no = 20 + var k := 0 + for r in Data.BOARD_ROWS: + for c in [0, 3, 6]: + p.board[Vector2i(c, r)] = Avatar.new(k % 3, [el[k % 3]] as Array[int], 1) + k += 1 + main._refresh() + await get_tree().create_timer(0.4).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s/fit.png" % out_dir) + print("Kamera: ", main.camera.global_position, " blickt auf ", main.camera.global_position - main.camera.global_transform.basis.z * 10.0) + # Trefferprüfung: jedes Feld muss unter seiner eigenen Bildschirmposition liegen. + var bad := 0 + for r in Data.BOARD_ROWS: + for c in Data.BOARD_COLS: + var bh := Vector2i(c, r) + var sp: Vector2 = main.board.slot_screen({"kind": "board", "hex": bh}) + var hit: Dictionary = main._pick_slot(sp) + if hit.get("kind", "") != "board" or hit["hex"] != bh: + bad += 1 + print(" Treffer daneben bei ", bh, " -> ", hit, " (Bildschirm ", sp, ")") + print("Trefferprüfung Board: ", 28 - bad, "/28 Felder korrekt") + var bad2 := 0 + for b in Data.BENCH_SLOTS: + var sp2: Vector2 = main.board.slot_screen({"kind": "bench", "index": b}) + var hit2: Dictionary = main._pick_slot(sp2) + if hit2.get("kind", "") != "bench" or hit2["index"] != b: + bad2 += 1 + print(" Bankplatz daneben: ", b, " -> ", hit2) + print("Trefferprüfung Bank: ", Data.BENCH_SLOTS - bad2, "/", Data.BENCH_SLOTS, " Plätze korrekt") + get_tree().quit() diff --git a/tests/Probe2.gd b/tests/Probe2.gd new file mode 100644 index 0000000..a519b6d --- /dev/null +++ b/tests/Probe2.gd @@ -0,0 +1,141 @@ +extends Node3D + +## Zweiter Durchlauf: Element-Auswahl, 3-Star, grosser Kampf, Spielende. + +var main: Node3D +var out_dir := "user://shots" +var shot_no := 0 + + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + out_dir = args[0] + DirAccess.make_dir_recursive_absolute(out_dir) + main = load("res://scenes/Main.tscn").instantiate() + add_child(main) + _run() + + +func _wait(s: float) -> void: + await get_tree().create_timer(s).timeout + + +func _shot(n: String) -> void: + await RenderingServer.frame_post_draw + shot_no += 1 + get_viewport().get_texture().get_image().save_png("%s/%02d_%s.png" % [out_dir, shot_no, n]) + print("SHOT ", n) + + +func _run() -> void: + await _wait(0.5) + var p: PlayerState = main.game.human + + # 1) Spezialisierung beim Kauf. + p.gold = 200 + main._on_buy(0) + await _wait(0.4) + print("Picker sichtbar: ", main.picker.visible) + await _shot("kauf_spezialisierung") + main.picker.hide() + 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) + main._refresh() + await _wait(0.2) + print("Aufgestellt: ", p.board.get(Vector2i(3, 1)).display_name()) + + # 2) Bis 3-Star aufwerten. + for i in 2: + p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + p.upgrade(Vector2i(3, 1), Data.Element.WIND, main.game.pool) + for i in 3: + p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + for i in range(3, 6): + p.bench[i] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1) + main.sel_slot = {"kind": "board", "hex": Vector2i(3, 1)} + main._refresh() + await _wait(0.3) + print("3-Star möglich: ", p.can_upgrade(Vector2i(3, 1))) + await _shot("dreistern_bereit") + p.upgrade(Vector2i(3, 1), Data.Element.BLITZ, main.game.pool) + main._refresh() + var a: Avatar = p.board[Vector2i(3, 1)] + print("3-Star: ", a.display_name(), " star=", a.star, " hp=", int(a.base_stats()["hp"])) + await _wait(0.3) + await _shot("dreistern") + + # 3) Grosses Team für einen sichtbaren Kampf. + main.game.round_no = 14 + var setup := [ + [Vector2i(2, 0), Data.Role.TANK, Data.Element.ERDE], + [Vector2i(4, 0), Data.Role.TANK, Data.Element.ERDE], + [Vector2i(2, 1), Data.Role.DPS, Data.Element.FEUER], + [Vector2i(4, 1), Data.Role.DPS, Data.Element.FEUER], + [Vector2i(3, 3), Data.Role.HEILER, Data.Element.WASSER], + [Vector2i(2, 3), Data.Role.HEILER, Data.Element.EIS], + [Vector2i(5, 2), Data.Role.DPS, Data.Element.WIND], + ] + for e in setup: + var av := Avatar.new(e[1], [e[2]] as Array[int], 1) + p.board[e[0]] = av + main.game.human.unlock(e[2]) + main._refresh() + await _wait(0.3) + print("Traits: ", p.trait_counts(), " Kombis: ", p.active_combos().size()) + await _shot("volles_team") + + main._on_ready() + await _wait(1.5) + await _shot("grosser_kampf") + main.combat_speed = 4.0 + await _wait(3.0) + await _shot("kampf_spaeter") + if main.game.phase == Game.Phase.COMBAT: + main._on_ready() + await _wait(0.4) + print("Ergebnis: ", main.game.last_round_log) + + # 4) Bis zum Spielende durchlaufen. + var guard := 0 + while main.game.phase != Game.Phase.GAMEOVER and guard < 120: + 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) + main._on_ready() + if main.game.phase == Game.Phase.COMBAT: + main.game.skip_combat() + guard += 1 + main._refresh() + await _wait(0.5) + print("Spielende nach Runde ", main.game.round_no, + " Platz des Spielers: ", main.game.human.placement) + await _shot("spielende") + + # 5) Neustart über den Knopf im Overlay. + var btn := _find_button(main.overlay, "Neues Spiel") + print("Neustart-Knopf gefunden: ", btn != null) + if btn != null: + btn.pressed.emit() + await _wait(0.5) + print("Nach Neustart: Runde ", main.game.round_no, " Leben ", main.game.human.hp, + " Spieler ", main.game.players.size(), " Overlay ", main.overlay) + await _shot("neustart") + print("PROBE2 FERTIG") + get_tree().quit() + + +func _find_button(node: Node, text: String) -> Button: + if node == null: + return null + for c in node.get_children(): + if c is Button and c.text == text: + return c + var r := _find_button(c, text) + if r != null: + return r + return null diff --git a/tests/Probe2.gd.uid b/tests/Probe2.gd.uid new file mode 100644 index 0000000..739c9c2 --- /dev/null +++ b/tests/Probe2.gd.uid @@ -0,0 +1 @@ +uid://djhq2cshy63mj diff --git a/tests/Probe3.gd b/tests/Probe3.gd new file mode 100644 index 0000000..700e8a7 --- /dev/null +++ b/tests/Probe3.gd @@ -0,0 +1,394 @@ +extends Node3D + +## Verifikation über echte Maus- und Tastaturereignisse. +## Jeder Schritt geht durch die normale Eingabekette des Spiels. + +var main: Node3D +var out_dir := "user://shots" +var shot_no := 0 +var fails := 0 + + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + out_dir = args[0] + DirAccess.make_dir_recursive_absolute(out_dir) + main = load("res://scenes/Main.tscn").instantiate() + add_child(main) + get_tree().create_timer(120.0).timeout.connect(func(): + print("ABBRUCH: Zeitlimit erreicht") + get_tree().quit(2)) + _run() + + +func check(cond: bool, msg: String) -> void: + if cond: + print(" OK ", msg) + else: + fails += 1 + print(" FAIL ", msg) + + +func _wait(s: float = 0.1) -> void: + await get_tree().create_timer(s).timeout + + +func _shot(n: String) -> void: + await RenderingServer.frame_post_draw + shot_no += 1 + get_viewport().get_texture().get_image().save_png("%s/%02d_%s.png" % [out_dir, shot_no, n]) + + +func _move(pos: Vector2) -> void: + var e := InputEventMouseMotion.new() + e.position = pos + e.global_position = pos + get_viewport().push_input(e) + + +func _btn(pos: Vector2, pressed: bool, button: int) -> void: + var e := InputEventMouseButton.new() + e.position = pos + e.global_position = pos + e.button_index = button + e.pressed = pressed + get_viewport().push_input(e) + + +func _click(pos: Vector2, button: int = MOUSE_BUTTON_LEFT) -> void: + _move(pos) + _btn(pos, true, button) + _btn(pos, false, button) + await _wait() + + +## Ziehen mit Zwischenschritten, damit die Bewegung wie echtes Drag ankommt. +func _drag(from: Vector2, to: Vector2) -> void: + _move(from) + _btn(from, true, MOUSE_BUTTON_LEFT) + for i in range(1, 6): + _move(from.lerp(to, float(i) / 5.0)) + await get_tree().process_frame + _btn(to, false, MOUSE_BUTTON_LEFT) + await _wait() + + +func _bench(i: int) -> Vector2: + return main.board.slot_screen({"kind": "bench", "index": i}) + + +func _hex(c: int, r: int) -> Vector2: + return main.board.slot_screen({"kind": "board", "hex": Vector2i(c, r)}) + + +func _shop_button(i: int) -> Button: + return main.shop_row.get_child(i) as Button + + +func _center(c: Control) -> Vector2: + return c.global_position + c.size * 0.5 + + +func _pick_element(e: int) -> void: + for b in main.picker_grid.get_children(): + if b is Button and b.text.begins_with(Data.ELEMENT_NAMES[e]): + b.pressed.emit() + await _wait() + return + check(false, "Element %s im Popup gefunden" % Data.ELEMENT_NAMES[e]) + + +func _free_shop_slot(p: PlayerState) -> int: + for i in p.shop.size(): + if not p.shop[i].sold: + return i + return -1 + + +func _buy(p: PlayerState, element: int) -> void: + var slot := _free_shop_slot(p) + if slot < 0: + await _click(_center(main.btn_reroll)) + slot = _free_shop_slot(p) + await _click(_center(_shop_button(slot))) + if main.picker.visible: + await _pick_element(element) + + +func _run() -> void: + await _wait(0.6) + var p: PlayerState = main.game.human + + print("\n[A] Spezialisierung wird beim Kauf gewählt") + var gold0 := p.gold + var pool0: int = main.game.pool.element_left[Data.Element.FEUER] + var slot := _free_shop_slot(p) + check(p.shop[slot].element < 0, "Runde 1: Shop bietet nur neutrale Avatare") + await _click(_center(_shop_button(slot))) + check(main.picker.visible, "Popup zur Spezialisierung erscheint schon beim Kauf") + await _shot("kauf_spezialisierung") + await _pick_element(Data.Element.FEUER) + check(p.bench[0] != null, "Avatar liegt auf der Bank") + check(p.bench[0].elements == [Data.Element.FEUER], "Avatar ist ein Feuer-Avatar") + check(p.gold == gold0 - Data.PRICE_NONE, "Gold sinkt um %d" % Data.PRICE_NONE) + check(main.game.pool.element_left[Data.Element.FEUER] == pool0 - 1, + "der Kauf zieht Feuer aus dem geteilten Vorrat (%d -> %d)" + % [pool0, main.game.pool.element_left[Data.Element.FEUER]]) + await _shot("bank_3d") + + print("\n[B] Drag & Drop Bank -> Board") + var a0: Avatar = p.bench[0] + await _drag(_bench(0), _hex(3, 1)) + check(p.board.get(Vector2i(3, 1)) == a0, "Avatar steht auf dem Zielfeld") + check(p.bench[0] == null, "Bankplatz ist frei") + check(p.unlocked[Data.Element.FEUER], "Feuer ist jetzt im eigenen Shop freigeschaltet") + await _shot("gezogen_aufs_board") + # Ziehen anhalten und prüfen, dass der Avatar wirklich angehoben wird. + var ground: float = main.board.slot_world({"kind": "board", "hex": Vector2i(3, 1)}).y + _move(_hex(3, 1)) + _btn(_hex(3, 1), true, MOUSE_BUTTON_LEFT) + for i in range(1, 9): + _move(_hex(3, 1).lerp(_bench(2), float(i) / 8.0)) + await get_tree().process_frame + await _wait(0.05) + check(main.drag_avatar != null, "Avatar hängt am Zeiger") + check(main.board.drag_active, "Zielfelder werden hervorgehoben") + var node: Node3D = main.board.unit_node(a0.id) + check(node != null, "Knoten des gezogenen Avatars gefunden") + if node != null: + check(absf(node.position.y - ground) < 0.01, + "Avatar wird beim Ziehen nicht angehoben (Höhe %.3f)" % (node.position.y - ground)) + check(node.position.distance_to(main.board.drag_world) < 0.01, + "Avatar folgt dem Zeiger ohne Nachlauf") + check(node.scale.x > 1.0, "Avatar wird beim Ziehen größer dargestellt") + 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"), + "Info-Panel zeigt während des Ziehens den Avatar in der Hand") + await _shot("beim_ziehen") + _btn(_hex(3, 1), false, MOUSE_BUTTON_LEFT) + await _wait(0.6) + check(p.board.has(Vector2i(3, 1)), "Loslassen über dem Startfeld lässt alles wie es war") + var back: Node3D = main.board.unit_node(a0.id) + check(absf(back.position.y - ground) < 0.01, "nach dem Loslassen steht der Avatar auf dem Feld") + + print("\n[C] Drag & Drop auf dem Board") + await _drag(_hex(3, 1), _hex(5, 2)) + check(p.board.get(Vector2i(5, 2)) == a0 and not p.board.has(Vector2i(3, 1)), + "Avatar auf (5,2) verschoben") + + print("\n[D] Drag auf ein belegtes Feld tauscht") + p.gold = 200 + main._refresh() + await _buy(p, Data.Element.WIND) + var a1: Avatar = p.bench[0] + await _drag(_bench(0), _hex(2, 0)) + check(p.board.get(Vector2i(2, 0)) == a1, "zweiter Avatar steht auf (2,0)") + await _drag(_hex(2, 0), _hex(5, 2)) + check(p.board.get(Vector2i(5, 2)) == a1 and p.board.get(Vector2i(2, 0)) == a0, + "die beiden haben die Plätze getauscht") + + print("\n[E] Drag & Drop Board -> Bank und Bank -> Bank") + await _drag(_hex(5, 2), _bench(4)) + check(p.bench[4] == a1 and not p.board.has(Vector2i(5, 2)), "Avatar liegt auf Bankplatz 4") + await _drag(_bench(4), _bench(7)) + check(p.bench[7] == a1 and p.bench[4] == null, "auf Bankplatz 7 verschoben") + + 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) + 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") + await _shot("auswahl") + + print("\n[G] Rechtsklick: Board zurück auf die Bank, Bank verkauft") + await _click(_hex(2, 0), MOUSE_BUTTON_RIGHT) + check(p.board.is_empty(), "Board ist leer") + var gold1 := p.gold + var filled := -1 + for i in p.bench.size(): + if p.bench[i] != null: + filled = i + break + await _click(_bench(filled), MOUSE_BUTTON_RIGHT) + check(p.gold > gold1, "Verkauf bringt Gold zurück (%d -> %d)" % [gold1, p.gold]) + + print("\n[H] Shop zeigt nur eigene Elemente und Neutral") + var seen := {} + for i in 40: + await _click(_center(main.btn_reroll)) + for e in p.shop: + seen[e.element] = true + var only_mine := true + for e in seen: + if e >= 0 and not p.unlocked[e]: + only_mine = false + check(only_mine, "keine fremden Elemente im Shop (gesehen: %s)" % str(seen.keys())) + check(seen.has(-1), "Neutral bleibt immer im Angebot") + check(seen.has(Data.Element.FEUER), "das eigene Feuer taucht auf") + + 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) + 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) + await _shot("kader_voll") + + print("\n[J] Bank voll -> Kauf blockiert") + for i in p.bench.size(): + if p.bench[i] == null: + p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1) + 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) + + print("\n[K] Aufwerten über den Knopf") + var hex: Vector2i = p.board.keys()[0] + var av: Avatar = p.board[hex] + 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") + check(up != null, "Aufwerten-Knopf ist da") + if up != null: + await _click(_center(up)) + check(main.picker.visible, "Popup für die zweite Spezialisierung") + check(main.picker_sub.visible and main.picker_sub.text.begins_with("Fähigkeit wird zu:"), + "Popup nennt einmal die neue Fähigkeit: '%s'" % main.picker_sub.text) + var texts := _collect_text(main.picker_grid) + check(not texts.contains("Leben") and not texts.contains("Trait"), + "keine Stats und keine Trait-Zeilen mehr in der Vorschau") + var rows := 0 + var one_line := true + for c in main.picker_grid.get_children(): + if c is Label: + rows += 1 + if not c.text.begins_with("+ ") or c.text.contains("\n"): + one_line = false + check(rows == main.picker_grid.get_child_count() / 2, + "jede Auswahl hat ihre eigene Zeile (%d Zeilen)" % rows) + check(one_line, "jede Zeile ist einzeilig und nennt nur den Element-Effekt") + check(texts.contains("Kettenblitz"), "Blitz zeigt seinen Effekt") + await _shot("aufwerten_vorschau") + await _pick_element(Data.Element.BLITZ) + check(p.board[hex].star == 2, "Einheit ist 2-Star") + check(p.board[hex].elements.size() == 2, "Einheit hat zwei Elemente") + await _shot("aufgewertet") + + print("\n[L] Kampf") + var round0: int = main.game.round_no + await _click(_center(main.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)) + 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") + await _shot("naechste_runde") + + print("\n[M] Preise und Zinsen") + p.gold = 0 + p.streak = 0 + p.last_won = false + main._refresh() + check(p.round_income() == 5, "ohne Gold gibt es 5 Gold") + 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) + var neutral := -1 + var elemental := -1 + for i in p.shop.size(): + if p.shop[i].element < 0: + neutral = i + else: + elemental = i + if neutral >= 0: + check(p.shop[neutral].price() == 1, "neutraler Avatar kostet 1 Gold") + check(_shop_button(neutral).text.contains("1 Gold"), "Karte zeigt 1 Gold") + if elemental >= 0: + check(p.shop[elemental].price() == 2, "Element-Avatar kostet 2 Gold") + check(_shop_button(elemental).text.contains("2 Gold"), "Karte zeigt 2 Gold") + + print("\n[N] Tastatur") + p.gold = 20 + main._refresh() + var g := p.gold + _key(KEY_D) + await _wait() + check(p.gold == g - Data.REROLL_COST, "Taste D würfelt neu") + _key(KEY_SPACE) + await _wait(0.3) + check(main.game.phase == Game.Phase.COMBAT, "Leertaste startet den Kampf") + main.game.skip_combat() + main._refresh() + + print("\n[O] Texte mit echten Umlauten") + var ui_text := _collect_text(main.ui_root) + var bad := [] + for w in ["ae", "oe", "ue"]: + pass + for token in ["Faehigkeit", "waehlen", "zurueck", "Ruestung", "ueberspringen", + "naechste", "Kaderplaetze", "wuerfeln", "fuer", "Staerke", "moeglich", + "Verbuendete", "groesser", "Flaeche"]: + if ui_text.contains(token): + bad.append(token) + check(bad.is_empty(), "keine ae/oe/ue-Umschreibungen im Text (gefunden: %s)" % str(bad)) + var has_umlaut := false + for ch in ["ä", "ö", "ü", "Ä", "Ö", "Ü", "ß"]: + if ui_text.contains(ch): + has_umlaut = true + check(has_umlaut, "echte Umlaute kommen im Text vor") + + print("") + if fails == 0: + print("UI-VERIFIKATION BESTANDEN") + else: + print("UI-VERIFIKATION FEHLER: ", fails) + get_tree().quit(1 if fails > 0 else 0) + + +func _key(code: int) -> void: + var e := InputEventKey.new() + e.keycode = code + e.pressed = true + get_viewport().push_input(e) + var u := InputEventKey.new() + u.keycode = code + u.pressed = false + get_viewport().push_input(u) + + +func _collect_text(node: Node) -> String: + var out := "" + for c in node.get_children(): + if c is Label: + out += c.text + "\n" + out += _collect_text(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: + return c + var r := _find_button(c, text) + if r != null: + return r + return null diff --git a/tests/Probe3.gd.uid b/tests/Probe3.gd.uid new file mode 100644 index 0000000..d1ca6c3 --- /dev/null +++ b/tests/Probe3.gd.uid @@ -0,0 +1 @@ +uid://cf5y2v8d1k5pb diff --git a/tests/camcal.tscn b/tests/camcal.tscn new file mode 100644 index 0000000..e559936 --- /dev/null +++ b/tests/camcal.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/CamCal.gd" id="1"] + +[node name="CamCal" type="Node3D"] +script = ExtResource("1") diff --git a/tests/prev_check.gd b/tests/prev_check.gd new file mode 100644 index 0000000..c0702f1 --- /dev/null +++ b/tests/prev_check.gd @@ -0,0 +1,7 @@ +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/probe2.tscn b/tests/probe2.tscn new file mode 100644 index 0000000..f4d69ab --- /dev/null +++ b/tests/probe2.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/Probe2.gd" id="1"] + +[node name="Probe2" type="Node3D"] +script = ExtResource("1") diff --git a/tests/probe3.tscn b/tests/probe3.tscn new file mode 100644 index 0000000..bec3e45 --- /dev/null +++ b/tests/probe3.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/Probe3.gd" id="1"] + +[node name="Probe3" type="Node3D"] +script = ExtResource("1") diff --git a/tests/sim_test.gd b/tests/sim_test.gd new file mode 100644 index 0000000..2f2c6a0 --- /dev/null +++ b/tests/sim_test.gd @@ -0,0 +1,442 @@ +extends SceneTree + +## Kopflose Prüfläufe für die Kernlogik. + +var failures := 0 + + +func check(cond: bool, msg: String) -> void: + if cond: + print(" OK ", msg) + else: + failures += 1 + print(" FAIL ", msg) + + +func _initialize() -> void: + test_hex() + test_upgrade_chain() + test_pool_shared() + test_combat_runs() + test_full_game() + test_placements() + test_trait_effect() + test_buy_specialisation() + test_own_shop() + test_move_slot() + test_interest() + test_prices() + test_pool_pressure() + test_neutral_chance() + print("") + if failures == 0: + print("ALLE TESTS BESTANDEN") + else: + print("FEHLER: ", failures) + quit(1 if failures > 0 else 0) + + +func test_hex() -> void: + print("[Hex]") + check(Hex.distance(Vector2i(0, 0), Vector2i(0, 0)) == 0, "Distanz zu sich selbst = 0") + check(Hex.distance(Vector2i(0, 0), Vector2i(1, 0)) == 1, "Nachbar rechts = 1") + for n in Hex.neighbors(Vector2i(2, 2)): + if Hex.distance(Vector2i(2, 2), n) != 1: + check(false, "Nachbar hat Distanz 1: " + str(n)) + return + for n in Hex.neighbors(Vector2i(2, 3)): + if Hex.distance(Vector2i(2, 3), n) != 1: + check(false, "Nachbar (ungerade Zeile) hat Distanz 1: " + str(n)) + return + check(true, "alle 6 Nachbarn haben Distanz 1 (gerade und ungerade Zeile)") + + +func test_upgrade_chain() -> void: + print("[Aufwerten]") + var pool := Pool.new() + var p := PlayerState.new(0, "Test", false) + 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) + check(ok, "Feuer-Avatar von der Bank aufstellen") + check(p.unlocked[Data.Element.FEUER], "Feuer erscheint danach im eigenen Shop") + + var hex := Vector2i(3, 1) + check(not p.can_upgrade(hex), "ohne Futter kein Upgrade") + check(p.upgrade_missing(hex).get(Data.Element.FEUER, 0) == 2, "es fehlen 2 Feuer-Avatare") + + p.bench[0] = Avatar.new(Data.Role.TANK, [Data.Element.FEUER], 1) + p.bench[1] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) + check(p.can_upgrade(hex), "mit 2 Feuer-Avataren ist 2-Star möglich") + check(p.upgrade(hex, Data.Element.WIND, pool), "Upgrade auf 2-Star mit Wind als 2. Element") + var a: Avatar = p.board[hex] + check(a.star == 2 and a.elements == [Data.Element.FEUER, Data.Element.WIND], "2-Star Feuer/Wind") + check(p.bench[0] == null and p.bench[1] == null, "Futter wurde verbraucht") + + var miss := p.upgrade_missing(hex) + check(miss.get(Data.Element.FEUER, 0) == 3 and miss.get(Data.Element.WIND, 0) == 3, + "3-Star braucht 3 Feuer und 3 Wind") + for i in 3: + p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) + for i in range(3, 6): + p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.WIND], 1) + check(p.can_upgrade(hex), "mit 3+3 Futter ist 3-Star möglich") + check(p.upgrade(hex, Data.Element.BLITZ, pool), "Upgrade auf 3-Star mit Blitz") + a = p.board[hex] + check(a.star == 3 and a.elements.size() == 3, "3-Star mit drei Elementen") + check(p.upgrade_cost().is_empty() if false else a.upgrade_cost().is_empty(), "3-Star ist Maximum") + var s1 := Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1).base_stats() + check(a.base_stats()["hp"] > s1["hp"] * 3.0, "Stats steigen deutlich pro Stern") + + +func test_pool_shared() -> void: + print("[Geteilter Pool]") + var pool := Pool.new() + var p := PlayerState.new(0, "Test", false) + p.unlock(Data.Element.FEUER) + var taken := 0 + while pool.take(Data.Element.FEUER): + taken += 1 + check(taken == Data.POOL_PER_ELEMENT, "Pro Element nur %d Avatare insgesamt" % Data.POOL_PER_ELEMENT) + check(p.shop_elements(pool).is_empty(), "leergekauftes Element verschwindet aus dem Shop") + + +func test_combat_runs() -> void: + print("[Kampf]") + var pool := Pool.new() + var a := PlayerState.new(0, "A", false) + var b := PlayerState.new(1, "B", true) + 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) + 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) + var c := Combat.new(a, b, 12345) + check(c.units.size() == 8, "8 Einheiten in der Arena") + var team0 := 0 + for u in c.units: + if u.team == 0: + team0 += 1 + check(team0 == 4, "4 Einheiten pro Team") + c.run_to_end() + check(c.finished, "Kampf endet") + check(c.winner in [-1, 0, 1], "Ergebnis ist gültig") + print(" Sieger: ", c.winner, " Überlebende: ", c.survivors, " Dauer: %.1fs" % c.time) + + # Trait-Wirkung: Feuer 4 muss den Angriff erhöhen. + var counts := a.trait_counts() + check(counts.get(Data.Element.FEUER, 0) == 4, "4 Feuer-Einheiten aufgestellt") + check(Data.trait_tier(Data.Element.FEUER, 4) == 2, "Feuer-Trait auf Stufe 2") + + +func test_full_game() -> void: + print("[Kompletter Loop]") + var g := Game.new() + g.start(999) + check(g.players.size() == 8, "8 Spieler (du + 7 Bots)") + check(g.human.hp == 100 and g.human.gold == Data.START_GOLD, "Start: 100 Leben, %d Gold" % Data.START_GOLD) + check(g.human.shop.size() == Data.SHOP_SLOTS, "Shop hat %d Plätze" % Data.SHOP_SLOTS) + var only_neutral := true + for e in g.human.shop: + if e.element >= 0: + only_neutral = false + check(true, "Shop-Angebot Runde 1 (nur neutral: %s)" % str(only_neutral)) + + # Menschlichen Spieler wie einen Bot spielen lassen, damit der Loop läuft. + var ai := BotAI.new() + 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) + g.start_combat() + if g.phase == Game.Phase.COMBAT: + g.skip_combat() + guard += 1 + check(g.phase == Game.Phase.GAMEOVER, "Spiel endet (Runden gespielt: %d)" % g.round_no) + var alive := g.alive_players() + check(alive.size() == 1, "genau ein Gewinner") + if alive.size() == 1: + print(" Sieger: ", alive[0].pname, " mit ", alive[0].hp, " Leben") + var sum_hp := 0 + for p in g.players: + sum_hp += p.hp + check(sum_hp < 8 * Data.START_HP, "Leben wurden verloren") + var any_upgraded := false + var max_star := 1 + for p in g.players: + for u in p.board_units(): + max_star = maxi(max_star, u.star) + check(max_star >= 2, "mindestens ein Avatar wurde aufgewertet (max %d-Star)" % max_star) + var used := 0 + for e in Data.ELEMENT_COUNT: + used += Data.POOL_PER_ELEMENT - g.pool.element_left[e] + check(used > 0, "Bots verbrauchen den geteilten Pool (%d Avatare gekauft)" % used) + + +func test_placements() -> void: + print("[Platzierungen]") + var g := Game.new() + g.start(4242) + var ai := BotAI.new() + ai.setup(g.rand) + 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) + g.start_combat() + if g.phase == Game.Phase.COMBAT: + g.skip_combat() + guard += 1 + var seen := {} + var ok := true + for p in g.players: + if p.placement < 1 or p.placement > 8 or seen.has(p.placement): + ok = false + print(" ", p.pname, " Platz ", p.placement, " hp ", p.hp) + seen[p.placement] = true + check(ok, "jeder Spieler hat genau einen Platz 1..8") + check(seen.size() == 8, "acht verschiedene Plätze vergeben") + + +func test_trait_effect() -> void: + print("[Trait-Wirkung]") + var pool := Pool.new() + # Team mit 4x Feuer -> Trait Stufe 2 (+26% Angriff). + var strong := PlayerState.new(0, "Feuer4", false) + for i in 4: + strong.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) + # Team mit nur 1x Feuer -> kein Trait. + var weak := PlayerState.new(1, "Gemischt", false) + weak.board[Vector2i(0, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) + for i in range(1, 4): + weak.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [i], 1) + + var c := Combat.new(strong, weak, 777) + var atk_strong := 0.0 + var atk_weak := 0.0 + for u in c.units: + if u.avatar.has_element(Data.Element.FEUER): + if u.team == 0: + atk_strong = u.atk + else: + atk_weak = u.atk + check(atk_strong > atk_weak * 1.2, + "Feuer-Trait erhöht den Angriff (%d statt %d)" % [int(atk_strong), int(atk_weak)]) + + # Kombi-Trait: Feuer 2 + Wind 2 muss Feuersturm auslösen. + var combo := PlayerState.new(2, "Kombi", false) + for i in 2: + combo.board[Vector2i(i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1) + combo.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND], 1) + var keys := [] + for x in combo.active_combos(): + keys.append(x["key"]) + check(keys.has("feuersturm"), "Feuer 2 + Wind 2 aktiviert Feuersturm") + check(not keys.has("frost"), "Wasser/Eis bleibt ohne die Elemente inaktiv") + + # Erde-Trait muss die Lebenspunkte im Kampf erhöhen. + var earth := PlayerState.new(3, "Erde4", false) + for i in 4: + earth.board[Vector2i(i, 1)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE], 1) + var c2 := Combat.new(earth, weak, 778) + var base_hp: float = Avatar.new(Data.Role.TANK, [Data.Element.ERDE], 1).base_stats()["hp"] + var got := 0.0 + for u in c2.units: + if u.team == 0: + got = u.max_hp + check(got > base_hp * 1.2, "Erde-Trait erhöht Leben (%d statt %d)" % [int(got), int(base_hp)]) + + +func test_buy_specialisation() -> void: + print("[Spezialisierung beim Kauf]") + var pool := Pool.new() + var p := PlayerState.new(0, "Test", false) + p.gold = 20 + p.shop = [ShopEntry.new(Data.Role.TANK, -1)] + check(p.shop[0].element < 0, "Shop bietet einen neutralen Avatar an") + check(not p.buy(0, pool, -1), "Kauf ohne gewähltes Element wird abgelehnt") + var before: int = pool.element_left[Data.Element.BLITZ] + check(p.buy(0, pool, Data.Element.BLITZ), "Kauf mit Blitz als Spezialisierung") + var a: Avatar = p.bench[0] + check(a != null and a.elements == [Data.Element.BLITZ], "Avatar liegt als Blitz auf der Bank") + check(pool.element_left[Data.Element.BLITZ] == before - 1, + "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) + check(p.unlocked[Data.Element.BLITZ], "erst das Aufstellen schaltet Blitz im Shop frei") + + +func test_own_shop() -> void: + print("[Shop zeigt nur eigene Elemente]") + var pool := Pool.new() + var rng := RandomNumberGenerator.new() + rng.seed = 7 + var me := PlayerState.new(0, "Ich", false) + var other := PlayerState.new(1, "Bot", true) + other.unlock(Data.Element.EIS) + me.unlock(Data.Element.FEUER) + var mine := me.shop_elements(pool) + var theirs := other.shop_elements(pool) + check(mine.size() == 1 and mine[0] == Data.Element.FEUER, "eigener Shop kennt nur Feuer") + check(theirs.size() == 1 and theirs[0] == Data.Element.EIS, "der Bot sieht nur sein eigenes Eis") + var seen := {} + for i in 200: + me.roll_shop(pool, rng) + for e in me.shop: + seen[e.element] = true + check(not seen.has(Data.Element.EIS), "fremdes Eis taucht im eigenen Shop nicht auf") + check(seen.has(Data.Element.FEUER) and seen.has(-1), "eigener Shop zeigt Feuer und Neutral") + + +func test_move_slot() -> void: + print("[Verschieben zwischen Bank und Board]") + var p := PlayerState.new(0, "Test", false) + var f := Avatar.new(Data.Role.TANK, [Data.Element.FEUER] as Array[int], 1) + var w := Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) + p.bench[0] = f + p.bench[1] = w + var b0 := {"kind": "bench", "index": 0} + var b1 := {"kind": "bench", "index": 1} + var b3 := {"kind": "bench", "index": 3} + 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.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.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.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.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.bench[1] == w and p.bench[3] == f, "beide haben die Plätze getauscht") + + # Kaderplätze bleiben 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): + 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), + "Feld ausserhalb des Boards wird abgelehnt") + + +func test_interest() -> void: + print("[Zinsen]") + check(Data.interest(0) == 0, "0 Gold -> 0 Zins") + check(Data.interest(9) == 0, "9 Gold -> 0 Zins") + check(Data.interest(10) == 1, "10 Gold -> 1 Zins") + check(Data.interest(35) == 3, "35 Gold -> 3 Zins") + check(Data.interest(49) == 4, "49 Gold -> 4 Zins") + check(Data.interest(50) == 5, "50 Gold -> 5 Zins") + check(Data.interest(200) == 5, "über 50 Gold bleibt es bei 5 Zins") + + var p := PlayerState.new(0, "Test", false) + p.gold = 0 + check(p.round_income() == Data.BASE_INCOME, "ohne Gold nur die 5 Grundgold") + p.gold = 30 + check(p.round_income() == Data.BASE_INCOME + 3, "30 Gold -> 5 + 3 Zins") + p.gold = 50 + p.register_result(true) + check(p.round_income() == Data.BASE_INCOME + Data.WIN_GOLD + 5, + "50 Gold mit Sieg -> 5 + 1 + 5 Zins") + for i in 4: + p.register_result(true) + check(p.round_income() == Data.BASE_INCOME + Data.WIN_GOLD + 5 + Data.streak_gold(5), + "Serie kommt oben drauf") + + +func test_prices() -> void: + print("[Preise]") + check(Data.PRICE_NONE == 1, "neutraler Avatar kostet 1 Gold") + check(Data.PRICE_ELEMENT == 2, "Element-Avatar kostet 2 Gold") + var pool := Pool.new() + var p := PlayerState.new(0, "Test", false) + p.gold = 10 + p.shop = [ShopEntry.new(Data.Role.TANK, -1), ShopEntry.new(Data.Role.DPS, Data.Element.EIS)] + check(p.shop[0].price() == 1 and p.shop[1].price() == 2, "Shop rechnet mit 1 und 2 Gold") + p.buy(0, pool, Data.Element.FEUER) + check(p.gold == 9, "neutraler Kauf zieht 1 Gold ab") + p.buy(1, pool, -1) + check(p.gold == 7, "Element-Kauf zieht 2 Gold ab") + p.sell_bench(0, pool) + check(p.gold == 8, "Verkauf gibt den Kaufpreis zurück") + + +func test_pool_pressure() -> void: + print("[Vorrat hält eine ganze Partie]") + var lowest := Data.POOL_PER_ELEMENT + var early_dry := 0 + for run in 4: + var g := Game.new() + g.start(4000 + run) + var ai := BotAI.new() + ai.setup(g.rand) + 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) + g.start_combat() + if g.phase == Game.Phase.COMBAT: + g.skip_combat() + for e in Data.ELEMENT_COUNT: + lowest = mini(lowest, g.pool.element_left[e]) + if g.round_no <= 15: + for e in Data.ELEMENT_COUNT: + if g.pool.element_left[e] <= 0: + early_dry += 1 + guard += 1 + check(early_dry == 0, "bis Runde 15 läuft kein Element leer") + check(lowest < Data.POOL_PER_ELEMENT / 2, "der Vorrat wird spürbar verbraucht (Tiefstand %d von %d)" + % [lowest, Data.POOL_PER_ELEMENT]) + + +func test_neutral_chance() -> void: + print("[Neutral-Chance sinkt je Element]") + check(is_equal_approx(Data.neutral_chance(0), 1.0), "ohne eigene Elemente nur Neutral") + check(is_equal_approx(Data.neutral_chance(1), 0.75), "1 Element -> 75% Neutral") + check(is_equal_approx(Data.neutral_chance(2), 0.5625), "2 Elemente -> 56%") + check(Data.neutral_chance(6) > 0.0, "Neutral verschwindet nie ganz (%.1f%%)" + % (Data.neutral_chance(6) * 100.0)) + + var pool := Pool.new() + var rng := RandomNumberGenerator.new() + rng.seed = 99 + var quotes := [] + for count in range(0, 5): + var p := PlayerState.new(0, "Test", false) + for e in count: + p.unlock(e) + var neutral := 0 + var total := 0 + for i in 400: + p.roll_shop(pool, rng) + for entry in p.shop: + total += 1 + if entry.element < 0: + neutral += 1 + quotes.append(float(neutral) / float(total)) + print(" gemessene Neutral-Quoten: ", quotes) + check(is_equal_approx(quotes[0], 1.0), "0 Elemente: alles neutral") + var falling := true + for i in range(1, quotes.size()): + if quotes[i] >= quotes[i - 1]: + falling = false + check(falling, "die Quote sinkt mit jedem weiteren Element") + for i in quotes.size(): + if absf(quotes[i] - Data.neutral_chance(i)) > 0.05: + check(false, "Quote bei %d Elementen weicht ab (%.2f statt %.2f)" + % [i, quotes[i], Data.neutral_chance(i)]) + return + check(true, "jede Quote trifft den erwarteten Wert auf 5 Prozentpunkte genau") diff --git a/tests/sim_test.gd.uid b/tests/sim_test.gd.uid new file mode 100644 index 0000000..50cade3 --- /dev/null +++ b/tests/sim_test.gd.uid @@ -0,0 +1 @@ +uid://bpluiwuwx5rtr