This commit is contained in:
team3
2026-09-16 13:21:45 +02:00
parent 8a188c5d5c
commit 49e8f2d95e
27 changed files with 1772 additions and 265 deletions

View File

@@ -1,35 +1,39 @@
78098b89c88a74ebe8c20117b8754620 78098b89c88a74ebe8c20117b8754620
::res://::1787996516 ::res://::1787996516
anforderungen.md::TextFile::-1::1787986378::0::1::::<><><>0<>0<><>:: anforderungen.md::TextFile::-1::1787986378::0::1::::<><><>0<>0<><>::
README.md::TextFile::-1::1789516876::0::1::::<><><>0<>0<><>:: README.md::TextFile::-1::1789554443::0::1::::<><><>0<>0<><>::
::res://scenes/::1787944158 ::res://scenes/::1787944158
Main.tscn::PackedScene::-1::1787946270::0::1::::<><><>0<>0<><>::res://scripts/ui/Main.gd Main.tscn::PackedScene::-1::1787946270::0::1::::<><><>0<>0<><>::res://scripts/ui/Main.gd
::res://scripts/::1787943837 ::res://scripts/::1787943837
::res://scripts/core/::1789516714 ::res://scripts/core/::1789555037
Avatar.gd::GDScript::3571391337088680506::1789551593::0::1::::Avatar<>RefCounted<><>0<>0<><>:: Avatar.gd::GDScript::3571391337088680506::1789555225::0::1::::Avatar<>RefCounted<><>0<>0<><>::
BotAI.gd::GDScript::3217244540061411409::1789551660::0::1::::BotAI<>RefCounted<><>0<>0<><>:: BotAI.gd::GDScript::3217244540061411409::1789554993::0::1::::BotAI<>RefCounted<><>0<>0<><>::
Combat.gd::GDScript::3137923719046090123::1789551637::0::1::::Combat<>RefCounted<><>0<>0<><>:: Combat.gd::GDScript::3137923719046090123::1789554111::0::1::::Combat<>RefCounted<><>0<>0<><>::
Data.gd::GDScript::5827997371587166138::1789551593::0::1::::Data<>RefCounted<><>0<>0<><>:: Data.gd::GDScript::5827997371587166138::1789555225::0::1::::Data<>RefCounted<><>0<>0<><>::
Game.gd::GDScript::7849680363975727491::1789551637::0::1::::Game<>RefCounted<><>0<>0<><>:: Game.gd::GDScript::7849680363975727491::1789555037::0::1::::Game<>RefCounted<><>0<>0<><>::
Hex.gd::GDScript::3913565535274194804::1787984994::0::1::::Hex<>RefCounted<><>0<>0<><>:: Hex.gd::GDScript::3913565535274194804::1787984994::0::1::::Hex<>RefCounted<><>0<>0<><>::
PlayerState.gd::GDScript::7371028171494145347::1789551593::0::1::::PlayerState<>RefCounted<><>0<>0<><>:: PlayerState.gd::GDScript::7371028171494145347::1789554993::0::1::::PlayerState<>RefCounted<><>0<>0<><>::
Pool.gd::GDScript::8278475787586460724::1787984994::0::1::::Pool<>RefCounted<><>0<>0<><>:: Pool.gd::GDScript::8278475787586460724::1787984994::0::1::::Pool<>RefCounted<><>0<>0<><>::
Settings.gd::GDScript::444029887099484235::1789554111::0::1::::Settings<>RefCounted<><>0<>0<><>::
ShopEntry.gd::GDScript::5330801221324202071::1787984994::0::1::::ShopEntry<>RefCounted<><>0<>0<><>:: ShopEntry.gd::GDScript::5330801221324202071::1787984994::0::1::::ShopEntry<>RefCounted<><>0<>0<><>::
::res://scripts/ui/::1789551774 ::res://scripts/ui/::1789555225
BoardView3D.gd::GDScript::6505855525292201354::1789551801::0::1::::BoardView3D<>Node3D<><>0<>0<><>:: BoardView3D.gd::GDScript::6505855525292201354::1789555072::0::1::::BoardView3D<>Node3D<><>0<>0<><>::
HudOverlay.gd::GDScript::2880024841375975216::1789551801::0::1::::HudOverlay<>Control<><>0<>0<><>:: CameraRig.gd::GDScript::2708392716068536488::1789555225::0::1::::CameraRig<>Node3D<><>0<>0<><>::
InfoColumn.gd::GDScript::8417939530115914045::1789551774::0::1::::InfoColumn<>Control<><>0<>0<><>:: HudOverlay.gd::GDScript::2880024841375975216::1789554490::0::1::::HudOverlay<>Control<><>0<>0<><>::
Main.gd::GDScript::7620286773918972721::1789551894::0::1::::<>Node3D<><>0<>0<><>:: InfoColumn.gd::GDScript::8417939530115914045::1789555072::0::1::::InfoColumn<>Control<><>0<>0<><>::
Main.gd::GDScript::7620286773918972721::1789555225::0::1::::<>Node3D<><>0<>0<><>::
Overlays.gd::GDScript::3653697623019777701::1789555097::0::1::::Overlays<>Control<><>0<>0<><>::
PlayersPanel.gd::GDScript::3868833939001261460::1789551726::0::1::::PlayersPanel<>Control<><>0<>0<><>:: PlayersPanel.gd::GDScript::3868833939001261460::1789551726::0::1::::PlayersPanel<>Control<><>0<>0<><>::
Sfx.gd::GDScript::5367741527190708903::1789551801::0::1::::Sfx<>Node<><>0<>0<><>:: Sfx.gd::GDScript::5367741527190708903::1789551801::0::1::::Sfx<>Node<><>0<>0<><>::
ShopPanel.gd::GDScript::5251693507545150927::1789551774::0::1::::ShopPanel<>Control<><>0<>0<><>:: ShopPanel.gd::GDScript::5251693507545150927::1789555072::0::1::::ShopPanel<>Control<><>0<>0<><>::
TooltipView.gd::GDScript::6773602266547042531::1789554490::0::1::::TooltipView<>Control<><>0<>0<><>::
TopBar.gd::GDScript::3252279457217410448::1789551726::0::1::::TopBar<>Control<><>0<>0<><>:: TopBar.gd::GDScript::3252279457217410448::1789551726::0::1::::TopBar<>Control<><>0<>0<><>::
UiKit.gd::GDScript::7622585584068197695::1787984994::0::1::::UiKit<>RefCounted<><>0<>0<><>:: UiKit.gd::GDScript::7622585584068197695::1787984994::0::1::::UiKit<>RefCounted<><>0<>0<><>::
::res://tests/::1789516714 ::res://tests/::1789554302
CamCal.gd::GDScript::7324782907689628524::1789516628::0::1::::<>Node3D<><>0<>0<><>:: CamCal.gd::GDScript::7324782907689628524::1789551981::0::1::::<>Node3D<><>0<>0<><>::
camcal.tscn::PackedScene::-1::1787946501::0::1::::<><><>0<>0<><>::res://tests/CamCal.gd camcal.tscn::PackedScene::-1::1787946501::0::1::::<><><>0<>0<><>::res://tests/CamCal.gd
Probe2.gd::GDScript::7806432109624259689::1789516628::0::1::::<>Node3D<><>0<>0<><>:: Probe2.gd::GDScript::7806432109624259689::1789551981::0::1::::<>Node3D<><>0<>0<><>::
probe2.tscn::PackedScene::-1::1787946443::0::1::::<><><>0<>0<><>::res://tests/Probe2.gd probe2.tscn::PackedScene::-1::1787946443::0::1::::<><><>0<>0<><>::res://tests/Probe2.gd
Probe3.gd::GDScript::5187223496600362631::1789516900::0::1::::<>Node3D<><>0<>0<><>:: Probe3.gd::GDScript::5187223496600362631::1789554403::0::1::::<>Node3D<><>0<>0<><>::
probe3.tscn::PackedScene::-1::1787946443::0::1::::<><><>0<>0<><>::res://tests/Probe3.gd probe3.tscn::PackedScene::-1::1787946443::0::1::::<><><>0<>0<><>::res://tests/Probe3.gd
sim_test.gd::GDScript::3463191307206118779::1789516655::0::1::::<>SceneTree<><>0<>0<><>:: sim_test.gd::GDScript::3463191307206118779::1789555122::0::1::::<>SceneTree<><>0<>0<><>::

View File

@@ -23,6 +23,14 @@ list=[{
"language": &"GDScript", "language": &"GDScript",
"path": "res://scripts/core/BotAI.gd" "path": "res://scripts/core/BotAI.gd"
}, { }, {
"base": &"Node3D",
"class": &"CameraRig",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/ui/CameraRig.gd"
}, {
"base": &"RefCounted", "base": &"RefCounted",
"class": &"Combat", "class": &"Combat",
"icon": "", "icon": "",
@@ -71,6 +79,14 @@ list=[{
"language": &"GDScript", "language": &"GDScript",
"path": "res://scripts/ui/InfoColumn.gd" "path": "res://scripts/ui/InfoColumn.gd"
}, { }, {
"base": &"Control",
"class": &"Overlays",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/ui/Overlays.gd"
}, {
"base": &"RefCounted", "base": &"RefCounted",
"class": &"PlayerState", "class": &"PlayerState",
"icon": "", "icon": "",
@@ -95,6 +111,14 @@ list=[{
"language": &"GDScript", "language": &"GDScript",
"path": "res://scripts/core/Pool.gd" "path": "res://scripts/core/Pool.gd"
}, { }, {
"base": &"RefCounted",
"class": &"Settings",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/Settings.gd"
}, {
"base": &"Node", "base": &"Node",
"class": &"Sfx", "class": &"Sfx",
"icon": "", "icon": "",
@@ -120,6 +144,14 @@ list=[{
"path": "res://scripts/ui/ShopPanel.gd" "path": "res://scripts/ui/ShopPanel.gd"
}, { }, {
"base": &"Control", "base": &"Control",
"class": &"TooltipView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/ui/TooltipView.gd"
}, {
"base": &"Control",
"class": &"TopBar", "class": &"TopBar",
"icon": "", "icon": "",
"is_abstract": false, "is_abstract": false,

Binary file not shown.

View File

@@ -23,7 +23,15 @@ Würfel (Tank), Kegel (DPS) und Kugeln (Heiler). Alles wird im Code erzeugt.
- Spielername rechts anklicken = sein Brett oben ansehen. Der nächste Gegner steht schon in der Planung fest. - Spielername rechts anklicken = sein Brett oben ansehen. Der nächste Gegner steht schon in der Planung fest.
- `Q`/`E` oder mittlere Maustaste drehen die Kamera, das Mausrad zoomt - `Q`/`E` oder mittlere Maustaste drehen die Kamera, das Mausrad zoomt
- Die Planung dauert 30 Sekunden, dann startet der Kampf von selbst - Die Planung dauert 30 Sekunden, dann startet der Kampf von selbst
- Startbildschirm und Pause haben einen Lautstärkeregler - Startbildschirm: Bots leicht/normal/schwer, Lautstärke. Beides wird in `user://settings.cfg` gespeichert.
- Maus über einer Einheit oder Shop-Karte zeigt Werte und Fähigkeit, auch bei Gegnern im Kampf.
- In den ersten zwei Runden steht unten ein Tipp, was als Nächstes dran ist.
- Wer ausscheidet, kann zuschauen (Runden laufen dann in 3 s) oder neu starten.
- `1`–`5` kaufen den jeweiligen Shop-Platz. `Z` macht den letzten Verkauf 5 s lang rückgängig.
- Die gewählte Einheit zeigt ihre Reichweite auf den Feldern: grün Heilung, orange Angriff.
- Runde 1 und 2 gehen gegen die Wildnis, danach gegeneinander.
- Der Spielstand wird zu jedem Planungsbeginn und bei Pause gespeichert. Der Startbildschirm
bietet dann „Weiterspielen". Am Spielende steht eine Statistik.
## Regeln ## Regeln
@@ -74,6 +82,18 @@ Nicht in den Anforderungen, aber „Basis wie TFT":
- **Rückblick** nach jedem Kampf: Ergebnis, Überlebende, stärkste Einheit, gefährlichster Gegner - **Rückblick** nach jedem Kampf: Ergebnis, Überlebende, stärkste Einheit, gefährlichster Gegner
(`Combat.recap`, `Game.human_recap`). (`Combat.recap`, `Game.human_recap`).
- **Start, Pause, Lautstärke** als Overlays in `Main`, Kamera mit Zoom und Drehung um die Brettmitte. - **Start, Pause, Lautstärke** als Overlays in `Main`, Kamera mit Zoom und Drehung um die Brettmitte.
- **Element-Formen**: das Hauptelement gibt der Fähigkeit jeder Rolle ihre Form (`Data.ELEMENT_FORM`,
`Combat._apply_form`). Feuer trifft Nachbarn und entzündet, Wasser gibt Mana, Wind beschleunigt
Verbündete, Blitz springt weiter, Erde betäubt um das Ziel, Eis verlangsamt um das Ziel.
- **Fähigkeiten sichtbar**: Schildring, Fläche am Ziel, Heilring (`BoardView3D.spawn_effect`).
- **Gehen**: Einheiten drehen sich zur Laufrichtung oder zum Ziel und wippen beim Schritt.
- **Schwierigkeit** steuert Gier und Jagdverhalten der Bots (`Data.BOT_GREED`, `BotAI.setup`).
- **Bot-Heiler** stehen in Reihe 1–2 hinter den Tanks, sonst reicht ihre Heilung nicht (`BotAI.arrange`).
- **Rückgängig** (`PlayerState.undo_sell`) nimmt die Bausteine wieder aus dem Vorrat.
- **Wildnis-Runden** (`Data.PVE_ROUNDS`, `Game._make_creeps`): jeder Spieler bekommt eine eigene Kopie.
- **Speichern** als JSON in `user://spielstand.json` (`Game.to_dict/from_dict`), inklusive RNG-Zustand,
Bot-Vorlieben und geteiltem Vorrat. Trait-Zeilen nennen die nächste Stufe, neue Kombinationen und
Trait-Stufen werden gemeldet (`Main._announce_synergies`).
Der neutrale Avatar selbst ist unbegrenzt. Knapp ist das Element, das man ihm beim Der neutrale Avatar selbst ist unbegrenzt. Knapp ist das Element, das man ihm beim
Kauf gibt: das zieht einen Platz aus dem geteilten Vorrat. Die 100 pro Element sind Kauf gibt: das zieht einen Platz aus dem geteilten Vorrat. Die 100 pro Element sind
@@ -84,6 +104,15 @@ Die Neutral-Chance ist multiplikativ (0,75 hoch Anzahl Elemente) statt in
Prozentpunkten. So verschwindet Neutral nie ganz: ohne eigene Elemente 100%, Prozentpunkten. So verschwindet Neutral nie ganz: ohne eigene Elemente 100%,
mit einem 75%, mit sechs noch 18%. mit einem 75%, mit sechs noch 18%.
## Schere, Stein, Papier
Elemente im Ring, jedes schlägt genau eines: Wasser > Feuer > Eis > Wind > Erde > Blitz > Wasser.
Rollen als Dreieck: Tank > Schütze > Heiler > Tank. Konter ±20 % (Element) und ±15 % (Rolle),
nur das Hauptelement zählt (`Data.counter_multiplier`, angewandt in `Combat._apply_damage`).
Konter-Treffer tragen ein ▲, Tooltip und Info-Panel nennen „Stark gegen / Schwach gegen".
Bots wählen beim Kauf das Element, das das häufigste Element des nächsten Gegners schlägt;
schwere Bots auch außerhalb ihrer Vorlieben (`BotAI._pick_element`).
## Elemente und Traits ## Elemente und Traits
Feuer, Wasser, Wind, Blitz, Erde, Eis. Stufen bei 2 / 4 / 6 Einheiten. Feuer, Wasser, Wind, Blitz, Erde, Eis. Stufen bei 2 / 4 / 6 Einheiten.
@@ -93,8 +122,10 @@ Magma, Sturmfront, Dampf.
## Aufbau ## Aufbau
scripts/core/ Data, Avatar, Pool, ShopEntry, PlayerState, Hex, Combat, BotAI, Game scripts/core/ Data, Avatar, Pool, ShopEntry, PlayerState, Hex, Combat, BotAI, Game
scripts/ui/ Main (Szene, Kamera, Eingabe, Overlays), TopBar, InfoColumn, PlayersPanel, scripts/ui/ Main (Eingabe, Aktionen, Orchestrierung), CameraRig, Overlays, TopBar,
ShopPanel (je ein Panel, melden per Signal), BoardView3D, HudOverlay, Sfx, UiKit InfoColumn, PlayersPanel, ShopPanel (melden per Signal), BoardView3D,
HudOverlay, TooltipView, Sfx, UiKit
scripts/core/ dazu Settings (user://settings.cfg)
scenes/Main.tscn scenes/Main.tscn
tests/ sim_test.gd (kopflos), Probe2, Probe3, CamCal tests/ sim_test.gd (kopflos), Probe2, Probe3, CamCal

View File

@@ -113,16 +113,25 @@ func ability_text() -> String:
s = "heilt 3 Verbündete um %d" % amount s = "heilt 3 Verbündete um %d" % amount
else: else:
s = "heilt alle um %d und schildet" % amount s = "heilt alle um %d und schildet" % amount
if not elements.is_empty():
var f: Dictionary = Data.ELEMENT_FORM[elements[0]]
s += "\nForm %s: %s" % [f["name"], f["desc"]]
var extra := [] var extra := []
for i in elements.size(): for i in range(1, elements.size()):
var scale := "" if i == 0 else " (halb)" extra.append(Data.element_effect(elements[i]) + " (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(): if not extra.is_empty():
s += "\nElemente: " + ", ".join(extra) s += "\nDazu: " + ", ".join(extra)
return s return s
## „Stark gegen … · Schwach gegen …" aus Hauptelement und Rolle.
func counter_text() -> String:
var e := primary_element()
var strong := []
var weak := []
if e >= 0:
strong.append(Data.ELEMENT_NAMES[Data.ELEMENT_BEATS[e]])
weak.append(Data.ELEMENT_NAMES[Data.beaten_by(e)])
strong.append(Data.ROLE_NAMES[Data.ROLE_BEATS[role]])
weak.append(Data.ROLE_NAMES[Data.role_beaten_by(role)])
return "Stark gegen %s · Schwach gegen %s" % [", ".join(strong), ", ".join(weak)]

View File

@@ -5,9 +5,12 @@ extends RefCounted
var pref: Array[int] = [] var pref: Array[int] = []
var greed: float = 0.5 ## wie stark der Bot Gold hortet var greed: float = 0.5 ## wie stark der Bot Gold hortet
var difficulty: int = Data.Difficulty.NORMAL
var opponent: PlayerState = null ## bekannter Gegner der Runde, für Konterwahl
func setup(rand: RandomNumberGenerator) -> void: func setup(rand: RandomNumberGenerator, p_difficulty: int = Data.Difficulty.NORMAL) -> void:
difficulty = p_difficulty
var all: Array[int] = [] var all: Array[int] = []
for e in Data.ELEMENT_COUNT: for e in Data.ELEMENT_COUNT:
all.append(e) all.append(e)
@@ -19,18 +22,23 @@ func setup(rand: RandomNumberGenerator) -> void:
all[i] = all[j] all[i] = all[j]
all[j] = tmp all[j] = tmp
pref = [all[0], all[1], all[2]] pref = [all[0], all[1], all[2]]
greed = rand.randf_range(0.3, 0.8) var g: Array = Data.BOT_GREED[difficulty]
greed = rand.randf_range(g[0], g[1])
func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator) -> void: func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, opp: PlayerState = null) -> void:
opponent = opp
_do_upgrades(p, pool) _do_upgrades(p, pool)
_do_buys(p, pool) _do_buys(p, pool)
_do_placements(p) _do_placements(p)
_do_upgrades(p, pool) _do_upgrades(p, pool)
var keep := int(lerpf(2.0, 18.0, greed)) var keep := int(lerpf(2.0, 18.0, greed))
# Wer kurz vor einem Upgrade steht, würfelt gezielt nach Futter. # Wer kurz vor einem Upgrade steht, würfelt gezielt nach Futter.
var hunting := _closest_upgrade_gap(p) <= 3 # Leichte Bots jagen nicht, schwere immer.
var hunting := difficulty != Data.Difficulty.LEICHT and _closest_upgrade_gap(p) <= 3
var rolls := 3 if hunting else 1 var rolls := 3 if hunting else 1
if difficulty == Data.Difficulty.SCHWER:
rolls += 1
for i in rolls: for i in rolls:
if p.gold > keep + Data.REROLL_COST and (hunting or rand.randf() < 0.6): if p.gold > keep + Data.REROLL_COST and (hunting or rand.randf() < 0.6):
p.reroll(pool, rand) p.reroll(pool, rand)
@@ -71,8 +79,13 @@ func arrange(p: PlayerState, opp: PlayerState) -> void:
return _min_dist(a, enemy_tanks) > _min_dist(b, enemy_tanks)) return _min_dist(a, enemy_tanks) > _min_dist(b, enemy_tanks))
units.sort_custom(func(a, b): return a.role == Data.Role.TANK and b.role != Data.Role.TANK) units.sort_custom(func(a, b): return a.role == Data.Role.TANK and b.role != Data.Role.TANK)
for u in units: for u in units:
var rows: Array = [0, 1, 2, 3] if u.role == Data.Role.TANK else [3, 2, 1, 0] # Heiler direkt hinter die Tanks, sonst reicht ihre Heilung (2 Felder) nicht.
var cols: Array = tank_cols if u.role == Data.Role.TANK else far_cols var rows: Array
match u.role:
Data.Role.TANK: rows = [0, 1, 2, 3]
Data.Role.HEILER: rows = [1, 2, 0, 3]
_: rows = [3, 2, 1, 0]
var cols: Array = tank_cols if u.role != Data.Role.DPS else far_cols
var placed := false var placed := false
for r in rows: for r in rows:
for c in cols: for c in cols:
@@ -179,6 +192,13 @@ func _pick_bench_for_board(p: PlayerState) -> int:
func _pick_element(p: PlayerState) -> int: func _pick_element(p: PlayerState) -> int:
var counts := p.trait_counts() var counts := p.trait_counts()
# Konter: schlägt eines meiner Lieblingselemente das häufigste Element des Gegners,
# nehme ich das. Schwere Bots kontern auch außerhalb ihrer Vorlieben.
var dominant := _dominant_element(opponent)
if dominant >= 0:
var counter := Data.beaten_by(dominant)
if pref.has(counter) or difficulty == Data.Difficulty.SCHWER:
return counter
var best := pref[0] var best := pref[0]
var best_c := -1 var best_c := -1
for e in pref: for e in pref:
@@ -189,6 +209,23 @@ func _pick_element(p: PlayerState) -> int:
return best return best
## Häufigstes Hauptelement auf dem Brett eines Spielers, -1 wenn leer.
func _dominant_element(p: PlayerState) -> int:
if p == null or p.board.is_empty():
return -1
var counts := {}
for u in p.board_units():
var e: int = u.primary_element()
counts[e] = counts.get(e, 0) + 1
var best := -1
var best_n := 1 # ab zwei Einheiten lohnt das Kontern
for e in counts:
if counts[e] > best_n:
best_n = counts[e]
best = e
return best
func _pick_hex(p: PlayerState, role: int) -> Vector2i: func _pick_hex(p: PlayerState, role: int) -> Vector2i:
var rows: Array var rows: Array
match role: match role:

View File

@@ -293,6 +293,12 @@ func _basic_attack(u: CUnit, t: CUnit) -> void:
func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void: func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void:
if not tgt.alive or amount <= 0.0: if not tgt.alive or amount <= 0.0:
return return
# Schere-Stein-Papier: Hauptelement und Rolle des Angreifers gegen das Ziel.
var counter := 1.0
if src != null and not is_true:
counter = Data.counter_multiplier(src.avatar.primary_element(), src.avatar.role,
tgt.avatar.primary_element(), tgt.avatar.role)
amount *= counter
var dmg := amount if is_true else amount * (1.0 - tgt.armor) var dmg := amount if is_true else amount * (1.0 - tgt.armor)
if tgt.shield > 0.0: if tgt.shield > 0.0:
var absorbed: float = minf(tgt.shield, dmg) var absorbed: float = minf(tgt.shield, dmg)
@@ -304,7 +310,12 @@ func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void:
if src != null: if src != null:
src.dealt += dmg src.dealt += dmg
if record_hits and src != null: if record_hits and src != null:
_record("crit" if amount > src.atk * 1.4 and not is_true else "dmg", tgt, dmg) var kind := "dmg"
if counter > 1.0:
kind = "counter"
elif amount > src.atk * 1.4 and not is_true:
kind = "crit"
_record(kind, tgt, dmg)
tgt.mana += MANA_PER_HIT * tgt.mana_mult tgt.mana += MANA_PER_HIT * tgt.mana_mult
if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0: if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0:
_cast(tgt) _cast(tgt)
@@ -351,8 +362,6 @@ func _record(kind: String, u: CUnit, amount: float) -> void:
func _cast(u: CUnit) -> void: func _cast(u: CUnit) -> void:
u.mana = 0.0 u.mana = 0.0
u.cast_flash = 0.35 u.cast_flash = 0.35
if record_hits:
_record("cast", u, 0.0)
var elems: Array = u.avatar.elements var elems: Array = u.avatar.elements
var heal_bonus := 0.0 var heal_bonus := 0.0
for i in elems.size(): for i in elems.size():
@@ -361,6 +370,10 @@ func _cast(u: CUnit) -> void:
var hit_enemies: Array = [] var hit_enemies: Array = []
var main_target = u.target if (u.target != null and u.target.alive) else _nearest_enemy(u) var main_target = u.target if (u.target != null and u.target.alive) else _nearest_enemy(u)
if record_hits:
hits.append({"kind": "cast", "pos": u.pos, "team": u.team, "amount": 0, "t": time,
"role": u.avatar.role, "element": u.avatar.primary_element(),
"target": main_target.pos if main_target != null else u.pos})
match u.avatar.role: match u.avatar.role:
Data.Role.TANK: Data.Role.TANK:
@@ -423,8 +436,10 @@ func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void:
if combos.has("magnetsturm") and main_target != null and main_target.alive: if combos.has("magnetsturm") and main_target != null and main_target.alive:
main_target.stun_timer = maxf(main_target.stun_timer, 0.6) main_target.stun_timer = maxf(main_target.stun_timer, 0.6)
for i in elems.size(): if not elems.is_empty():
var scale := 1.0 if i == 0 else 0.5 _apply_form(u, elems[0], main_target, hit_enemies)
for i in range(1, elems.size()):
var scale := 0.5
match elems[i]: match elems[i]:
Data.Element.FEUER: Data.Element.FEUER:
for o in hit_enemies: for o in hit_enemies:
@@ -454,6 +469,58 @@ func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void:
## Verbündete in Heilreichweite, schwächste zuerst. Mindestens der Heiler selbst. ## Verbündete in Heilreichweite, schwächste zuerst. Mindestens der Heiler selbst.
## Die Form des Hauptelements wirkt für jede Rolle gleich: um das Ziel (Gegner)
## oder um den Zaubernden (Verbündete).
func _apply_form(u: CUnit, element: int, main_target, hit_enemies: Array) -> void:
match element:
Data.Element.FEUER:
if main_target == null:
return
for n in Hex.neighbors(main_target.pos):
var o = _occ.get(n)
if o != null and o.alive and o.team != u.team and not hit_enemies.has(o):
_apply_damage(u, o, u.atk * 0.6, false)
hit_enemies.append(o)
for o in hit_enemies:
if o.alive:
o.burn_dps = maxf(o.burn_dps, u.atk * 0.6)
o.burn_timer = maxf(o.burn_timer, 3.0)
Data.Element.WASSER:
for o in units:
if o.alive and o.team == u.team and o != u and Hex.distance(o.pos, u.pos) <= 2:
o.mana = minf(o.max_mana - 1.0, o.mana + 20.0)
u.mana += 10.0
Data.Element.WIND:
for o in units:
if o.alive and o.team == u.team and Hex.distance(o.pos, u.pos) <= 2:
o.haste = maxf(o.haste, 0.25)
o.haste_timer = maxf(o.haste_timer, 3.0)
Data.Element.BLITZ:
var chained := 0
for o in units:
if chained >= 2:
break
if o.alive and o.team != u.team and o != main_target:
_apply_damage(u, o, u.atk * 0.6, false)
hit_enemies.append(o)
chained += 1
Data.Element.ERDE:
if main_target == null:
return
main_target.stun_timer = maxf(main_target.stun_timer, 0.6)
for n in Hex.neighbors(main_target.pos):
var o = _occ.get(n)
if o != null and o.alive and o.team != u.team:
o.stun_timer = maxf(o.stun_timer, 0.6)
Data.Element.EIS:
if main_target == null:
return
for o in units:
if o.alive and o.team != u.team and Hex.distance(o.pos, main_target.pos) <= 1:
o.slow = maxf(o.slow, 0.30)
o.slow_timer = maxf(o.slow_timer, 3.0)
func _allies_by_missing_hp(team: int, healer: CUnit) -> Array: func _allies_by_missing_hp(team: int, healer: CUnit) -> Array:
var list := [] var list := []
for o in units: for o in units:

View File

@@ -43,6 +43,42 @@ const PROFILE_HINT := {
Element.BLITZ: "kritisch", Element.ERDE: "zäh und langsam", Element.EIS: "robust", Element.BLITZ: "kritisch", Element.ERDE: "zäh und langsam", Element.EIS: "robust",
} }
# Das Hauptelement gibt der Fähigkeit ihre Form. Gilt für jede Rolle:
# ein Feuer-Heiler brennt, ein Erde-Tank erschüttert, ein Eis-Schütze friert.
const ELEMENT_FORM := {
Element.FEUER: {"name": "Flächenbrand", "desc": "trifft auch Nachbarn des Ziels (60%) und entzündet sie"},
Element.WASSER: {"name": "Welle", "desc": "gibt Verbündeten im Umkreis 2 je 20 Mana"},
Element.WIND: {"name": "Sturmschritt", "desc": "Verbündete im Umkreis 2 werden 3 s um 25% schneller"},
Element.BLITZ: {"name": "Kette", "desc": "springt auf 2 weitere Gegner (60%)"},
Element.ERDE: {"name": "Erschütterung","desc": "betäubt Gegner neben dem Ziel 0,6 s"},
Element.EIS: {"name": "Frostfeld", "desc": "verlangsamt Gegner um das Ziel 3 s um 30%"},
}
# Bots: wie gierig sie horten und wie gezielt sie jagen.
enum Difficulty { LEICHT, NORMAL, SCHWER }
const DIFFICULTY_NAMES := ["Leicht", "Normal", "Schwer"]
const BOT_GREED := {
Difficulty.LEICHT: [0.6, 0.9], Difficulty.NORMAL: [0.3, 0.8], Difficulty.SCHWER: [0.15, 0.4],
}
# Schere-Stein-Papier. Elemente im Ring: jedes schlägt genau eines und verliert
# gegen genau eines. Rollen als Dreieck. Nur das Hauptelement zählt.
const ELEMENT_BEATS := {
Element.WASSER: Element.FEUER, # Wasser löscht Feuer
Element.FEUER: Element.EIS, # Feuer schmilzt Eis
Element.EIS: Element.WIND, # Eis lähmt Wind
Element.WIND: Element.ERDE, # Wind trägt Erde ab
Element.ERDE: Element.BLITZ, # Erde erdet Blitz
Element.BLITZ: Element.WASSER, # Blitz leitet durch Wasser
}
const ROLE_BEATS := {
Role.TANK: Role.DPS, # Tank hält Schützen aus
Role.DPS: Role.HEILER, # Schütze bricht Heiler
Role.HEILER: Role.TANK, # Heiler überdauert Tank
}
const ELEMENT_COUNTER := 0.20
const ROLE_COUNTER := 0.15
# Multiplikator pro Sternstufe (Index = star - 1). # Multiplikator pro Sternstufe (Index = star - 1).
const STAR_MULT := [1.0, 1.8, 3.24] const STAR_MULT := [1.0, 1.8, 3.24]
const MAX_STAR := 3 const MAX_STAR := 3
@@ -89,6 +125,9 @@ const XP_TABLE := {3: 4, 4: 8, 5: 12, 6: 20, 7: 32, 8: 48}
# --- Planung --------------------------------------------------------------- # --- Planung ---------------------------------------------------------------
const PLAN_SECONDS := 30.0 const PLAN_SECONDS := 30.0
## Die ersten Runden gehen gegen die Wildnis, damit jeder in Ruhe aufbaut.
const PVE_ROUNDS := 2
const SAVE_PATH := "user://spielstand.json"
# --- Kampf ----------------------------------------------------------------- # --- Kampf -----------------------------------------------------------------
## Heiler heilen nur Verbündete in dieser Hex-Reichweite. ## Heiler heilen nur Verbündete in dieser Hex-Reichweite.
@@ -164,6 +203,65 @@ static func trait_tier(element: int, count: int) -> int:
return tier return tier
static func beats_element(attacker: int, defender: int) -> bool:
return attacker >= 0 and defender >= 0 and ELEMENT_BEATS.get(attacker, -1) == defender
static func beats_role(attacker: int, defender: int) -> bool:
return ROLE_BEATS.get(attacker, -1) == defender
## Schadensfaktor eines Angreifers gegen einen Verteidiger (Hauptelement und Rolle).
static func counter_multiplier(att_el: int, att_role: int, def_el: int, def_role: int) -> float:
var m := 1.0
if beats_element(att_el, def_el):
m += ELEMENT_COUNTER
elif beats_element(def_el, att_el):
m -= ELEMENT_COUNTER
if beats_role(att_role, def_role):
m += ROLE_COUNTER
elif beats_role(def_role, att_role):
m -= ROLE_COUNTER
return m
## Element, das dieses Element schlägt (das Gegenstück zu ELEMENT_BEATS).
static func beaten_by(element: int) -> int:
for e in ELEMENT_BEATS:
if ELEMENT_BEATS[e] == element:
return e
return -1
static func role_beaten_by(role: int) -> int:
for r in ROLE_BEATS:
if ROLE_BEATS[r] == role:
return r
return -1
## Was die Fähigkeit einer Rolle auf einer Sternstufe tut.
static func star_effect(role: int, star: int) -> String:
match role:
Role.TANK:
return "Schild und spottet 3 Gegner" if star == 2 else "Schild, Spott und 25% Reflexion"
Role.DPS:
return "Ziel plus Splash auf Nachbarn" if star == 2 else "Ziel plus Fläche im Radius 2"
_:
return "heilt 3 Verbündete" if star == 2 else "heilt alle und schildet sie"
## Zusatzwirkung eines Nebenelements.
static func element_effect(element: int) -> String:
match element:
Element.FEUER: return "Brand"
Element.WASSER: return "Mana und stärkere Heilung"
Element.WIND: return "Tempo-Schub"
Element.BLITZ: return "Kettenblitz"
Element.ERDE: return "Betäubung"
_: return "Verlangsamung"
## XP bis zum nächsten Level, 0 auf Maximalstufe. ## XP bis zum nächsten Level, 0 auf Maximalstufe.
static func xp_needed(level: int) -> int: static func xp_needed(level: int) -> int:
return XP_TABLE.get(level, 0) return XP_TABLE.get(level, 0)

View File

@@ -27,7 +27,11 @@ var human_recap: Dictionary = {} ## Rückblick auf den letzten eigenen Kampf
var human_last_result: int = 0 ## 1 Sieg, -1 Niederlage, 0 unentschieden/keine var human_last_result: int = 0 ## 1 Sieg, -1 Niederlage, 0 unentschieden/keine
func start(seed_value: int = 0) -> void: var difficulty: int = Data.Difficulty.NORMAL
func start(seed_value: int = 0, p_difficulty: int = Data.Difficulty.NORMAL) -> void:
difficulty = p_difficulty
rand.seed = seed_value if seed_value != 0 else randi() rand.seed = seed_value if seed_value != 0 else randi()
pool = Pool.new() pool = Pool.new()
players.clear() players.clear()
@@ -37,7 +41,7 @@ func start(seed_value: int = 0) -> void:
var b := PlayerState.new(i + 1, BOT_NAMES[i], true) var b := PlayerState.new(i + 1, BOT_NAMES[i], true)
players.append(b) players.append(b)
var ai := BotAI.new() var ai := BotAI.new()
ai.setup(rand) ai.setup(rand, difficulty)
bots[b.pid] = ai bots[b.pid] = ai
round_no = 1 round_no = 1
for p in players: for p in players:
@@ -61,8 +65,9 @@ func alive_players() -> Array:
func _run_bot_turns() -> void: func _run_bot_turns() -> void:
for p in players: for p in players:
if p.is_bot and p.alive: if p.is_bot and p.alive:
bots[p.pid].take_turn(p, pool, rand) var opp := opponent_of(p)
bots[p.pid].arrange(p, opponent_of(p)) bots[p.pid].take_turn(p, pool, rand, opp)
bots[p.pid].arrange(p, opp)
## Planungsuhr. Läuft sie ab, startet der Kampf von selbst. ## Planungsuhr. Läuft sie ab, startet der Kampf von selbst.
@@ -85,7 +90,7 @@ func start_combat() -> void:
return return
# Abbilder erst jetzt kopieren, damit sie den aktuellen Stand zeigen. # Abbilder erst jetzt kopieren, damit sie den aktuellen Stand zeigen.
for pr in pairings: for pr in pairings:
if pr["ghost"]: if pr["ghost"] and pr["ghost_src"] != null:
pr["b"] = _make_ghost(pr["ghost_src"]) pr["b"] = _make_ghost(pr["ghost_src"])
pending.clear() pending.clear()
player_combat = null player_combat = null
@@ -117,10 +122,28 @@ func skip_combat() -> void:
resolve_round() resolve_round()
func is_pve_round() -> bool:
return round_no <= Data.PVE_ROUNDS
## Wildnis-Gegner: klein, ohne Leben, jeder Spieler bekommt seinen eigenen.
func _make_creeps() -> PlayerState:
var c := PlayerState.new(-2, "Wildnis", true)
c.level = 9
c.board[Vector2i(3, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1)
if round_no >= 2:
c.board[Vector2i(2, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1)
return c
func _make_pairings() -> Array: func _make_pairings() -> Array:
var alive := alive_players() var alive := alive_players()
alive.shuffle()
var out := [] var out := []
if is_pve_round():
for p in alive:
out.append({"a": p, "b": _make_creeps(), "ghost": true, "ghost_src": null})
return out
alive.shuffle()
var i := 0 var i := 0
while i + 1 < alive.size(): while i + 1 < alive.size():
out.append({"a": alive[i], "b": alive[i + 1], "ghost": false}) out.append({"a": alive[i], "b": alive[i + 1], "ghost": false})
@@ -207,7 +230,9 @@ func _next_round() -> void:
for p in players: for p in players:
if not p.alive: if not p.alive:
continue continue
p.gold += p.round_income() var income: int = p.round_income()
p.gold += income
p.stats["gold_earned"] += income
p.gain_xp(Data.XP_PER_ROUND) p.gain_xp(Data.XP_PER_ROUND)
p.new_round_shop(pool, rand) p.new_round_shop(pool, rand)
pairings = _make_pairings() pairings = _make_pairings()
@@ -216,13 +241,96 @@ func _next_round() -> void:
phase = Phase.PLANNING phase = Phase.PLANNING
# --- Speichern -------------------------------------------------------------
func to_dict() -> Dictionary:
var ps := []
for p in players:
ps.append(p.to_dict())
var ai := {}
for pid in bots:
ai[str(pid)] = {"pref": Array(bots[pid].pref), "greed": bots[pid].greed}
return {
"version": 1, "round": round_no, "difficulty": difficulty, "rng_state": str(rand.state),
"pool": Array(pool.element_left), "players": ps, "bots": ai,
"human_recap": human_recap, "log": last_round_log,
}
func from_dict(d: Dictionary) -> void:
difficulty = int(d["difficulty"])
rand.state = int(str(d["rng_state"]))
pool = Pool.new()
for i in mini(pool.element_left.size(), d["pool"].size()):
pool.element_left[i] = int(d["pool"][i])
players.clear()
bots.clear()
for pd in d["players"]:
var p := PlayerState.from_dict(pd)
players.append(p)
if not p.is_bot:
human = p
else:
var ai := BotAI.new()
ai.setup(rand, difficulty)
var bd: Dictionary = d["bots"].get(str(p.pid), {})
if bd.has("pref"):
ai.pref.clear()
for e in bd["pref"]:
ai.pref.append(int(e))
ai.greed = float(bd["greed"])
bots[p.pid] = ai
round_no = int(d["round"])
human_recap = d.get("human_recap", {})
last_round_log = d.get("log", [])
pairings = _make_pairings()
for p in players:
if p.is_bot and p.alive:
bots[p.pid].arrange(p, opponent_of(p))
plan_left = Data.PLAN_SECONDS
phase = Phase.PLANNING
func save(path: String = Data.SAVE_PATH) -> bool:
if phase != Phase.PLANNING:
return false
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return false
f.store_string(JSON.stringify(to_dict()))
return true
static func has_save(path: String = Data.SAVE_PATH) -> bool:
return FileAccess.file_exists(path)
static func delete_save(path: String = Data.SAVE_PATH) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
static func load_from(path: String = Data.SAVE_PATH) -> Game:
if not FileAccess.file_exists(path):
return null
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return null
var parsed = JSON.parse_string(f.get_as_text())
if not (parsed is Dictionary):
return null
var g := Game.new()
g.from_dict(parsed)
return g
# --- Hilfen für die UI ---------------------------------------------------- # --- Hilfen für die UI ----------------------------------------------------
## Gegner eines Spielers in der aktuellen Paarung (Abbilder liefern ihre Quelle). ## Gegner eines Spielers in der aktuellen Paarung (Abbilder liefern ihre Quelle).
func opponent_of(p: PlayerState) -> PlayerState: func opponent_of(p: PlayerState) -> PlayerState:
for pr in pairings: for pr in pairings:
if pr["a"] == p: if pr["a"] == p:
return pr["ghost_src"] if pr["ghost"] else pr["b"] return pr["ghost_src"] if (pr["ghost"] and pr["ghost_src"] != null) else pr["b"]
if pr["b"] == p: if pr["b"] == p:
return pr["a"] return pr["a"]
return null return null

View File

@@ -16,6 +16,9 @@ var level: int = Data.START_LEVEL
var xp: int = 0 var xp: int = 0
var shop_locked: bool = false ## eingefrorener Shop bleibt über die Runde var shop_locked: bool = false ## eingefrorener Shop bleibt über die Runde
var last_element: int = -1 ## zuletzt gewählte Spezialisierung für Schnellkauf var last_element: int = -1 ## zuletzt gewählte Spezialisierung für Schnellkauf
var last_sale: Dictionary = {} ## letzter Verkauf, für Rückgängig
## Statistik für den Endbildschirm.
var stats := {"wins": 0, "losses": 0, "gold_earned": 0, "spent": 0, "max_star": 1}
var bench: Array = [] ## Avatar oder null var bench: Array = [] ## Avatar oder null
var board: Dictionary = {} ## Vector2i -> Avatar var board: Dictionary = {} ## Vector2i -> Avatar
@@ -111,6 +114,7 @@ func buy(index: int, pool: Pool, chosen_element: int = -1) -> bool:
if not pool.take(element): if not pool.take(element):
return false return false
gold -= entry.price() gold -= entry.price()
stats["spent"] += entry.price()
bench[slot] = entry.to_avatar(element) bench[slot] = entry.to_avatar(element)
entry.sold = true entry.sold = true
if entry.element < 0: if entry.element < 0:
@@ -123,8 +127,39 @@ func sell_bench(index: int, pool: Pool) -> bool:
return false return false
var a: Avatar = bench[index] var a: Avatar = bench[index]
pool.return_avatar(a) pool.return_avatar(a)
gold += maxi(1, a.invested) var refund := maxi(1, a.invested)
gold += refund
bench[index] = null bench[index] = null
last_sale = {"avatar": a, "refund": refund, "index": index}
return true
## Macht den letzten Bankverkauf rückgängig, solange Gold und Platz da sind.
func undo_sell(pool: Pool) -> bool:
if last_sale.is_empty():
return false
var a: Avatar = last_sale["avatar"]
var refund: int = last_sale["refund"]
if gold < refund:
return false
var idx: int = last_sale["index"]
if bench[idx] != null:
idx = free_bench_slot()
if idx < 0:
return false
# Bausteine wieder aus dem Vorrat nehmen; ist er leer, geht es nicht.
if not pool.take(a.elements[0]):
return false
if a.star >= 2:
for i in 2:
pool.take(a.elements[0])
if a.star >= 3:
for e in a.elements:
for i in 3:
pool.take(e)
gold -= refund
bench[idx] = a
last_sale = {}
return true return true
@@ -338,6 +373,7 @@ func upgrade(hex: Vector2i, new_element: int, pool: Pool) -> bool:
a.star += 1 a.star += 1
a.elements.append(new_element) a.elements.append(new_element)
unlock(new_element) unlock(new_element)
stats["max_star"] = maxi(stats["max_star"], a.star)
return true return true
@@ -377,8 +413,10 @@ func register_result(won: bool) -> void:
last_won = won last_won = won
if won: if won:
streak = maxi(1, streak + 1) streak = maxi(1, streak + 1)
stats["wins"] += 1
else: else:
streak = mini(-1, streak - 1) streak = mini(-1, streak - 1)
stats["losses"] += 1
## Nur Leben abziehen. Das Ausscheiden setzt Game._check_eliminations, ## Nur Leben abziehen. Das Ausscheiden setzt Game._check_eliminations,
@@ -392,3 +430,63 @@ func board_units() -> Array:
for hex in board: for hex in board:
out.append(board[hex]) out.append(board[hex])
return out return out
# --- Speichern -------------------------------------------------------------
static func _avatar_to_dict(a: Avatar) -> Dictionary:
return {"role": a.role, "elements": Array(a.elements), "star": a.star, "invested": a.invested}
static func _avatar_from_dict(d: Dictionary) -> Avatar:
var els: Array[int] = []
for e in d["elements"]:
els.append(int(e))
var a := Avatar.new(int(d["role"]), els, int(d["star"]))
a.invested = int(d["invested"])
return a
func to_dict() -> Dictionary:
var b := []
for a in bench:
b.append(null if a == null else _avatar_to_dict(a))
var brd := []
for hex in board:
brd.append({"x": hex.x, "y": hex.y, "a": _avatar_to_dict(board[hex])})
var sh := []
for e in shop:
sh.append({"role": e.role, "element": e.element, "sold": e.sold})
return {
"pid": pid, "pname": pname, "is_bot": is_bot, "gold": gold, "hp": hp, "streak": streak,
"alive": alive, "last_won": last_won, "placement": placement, "level": level, "xp": xp,
"shop_locked": shop_locked, "last_element": last_element, "unlocked": Array(unlocked),
"bench": b, "board": brd, "shop": sh, "stats": stats.duplicate(),
}
static func from_dict(d: Dictionary) -> PlayerState:
var p := PlayerState.new(int(d["pid"]), d["pname"], d["is_bot"])
p.gold = int(d["gold"])
p.hp = int(d["hp"])
p.streak = int(d["streak"])
p.alive = d["alive"]
p.last_won = d["last_won"]
p.placement = int(d["placement"])
p.level = int(d["level"])
p.xp = int(d["xp"])
p.shop_locked = d["shop_locked"]
p.last_element = int(d["last_element"])
for i in mini(p.unlocked.size(), d["unlocked"].size()):
p.unlocked[i] = d["unlocked"][i]
for i in mini(p.bench.size(), d["bench"].size()):
p.bench[i] = null if d["bench"][i] == null else _avatar_from_dict(d["bench"][i])
for entry in d["board"]:
p.board[Vector2i(int(entry["x"]), int(entry["y"]))] = _avatar_from_dict(entry["a"])
for entry in d["shop"]:
var se := ShopEntry.new(int(entry["role"]), int(entry["element"]))
se.sold = entry["sold"]
p.shop.append(se)
for k in d["stats"]:
p.stats[k] = int(d["stats"][k])
return p

24
scripts/core/Settings.gd Normal file
View File

@@ -0,0 +1,24 @@
class_name Settings
extends RefCounted
## Lautstärke und Schwierigkeit in user://settings.cfg.
const PATH := "user://settings.cfg"
var volume: float = 0.8
var difficulty: int = Data.Difficulty.NORMAL
func load_from_disk(path: String = PATH) -> void:
var cfg := ConfigFile.new()
if cfg.load(path) != OK:
return
volume = clampf(float(cfg.get_value("audio", "volume", volume)), 0.0, 1.0)
difficulty = clampi(int(cfg.get_value("game", "difficulty", difficulty)), 0, 2)
func save_to_disk(path: String = PATH) -> void:
var cfg := ConfigFile.new()
cfg.set_value("audio", "volume", volume)
cfg.set_value("game", "difficulty", difficulty)
cfg.save(path)

View File

@@ -0,0 +1 @@
uid://glddwd7k5wml

View File

@@ -20,6 +20,10 @@ var camera: Camera3D = null
var scout_player: PlayerState = null var scout_player: PlayerState = null
var selected_slot := {} ## {"kind": "board"/"bench", ...} var selected_slot := {} ## {"kind": "board"/"bench", ...}
var hover_slot := {} var hover_slot := {}
## Reichweite der gewählten Einheit: Mitte (Arena-Feld), Radius, Farbe. Radius 0 = aus.
var range_center := Vector2i(-1, -1)
var range_radius := 0
var range_color := Color(1.0, 0.7, 0.3)
var drag_active := false var drag_active := false
var drag_avatar_id := -1 var drag_avatar_id := -1
var drag_world := Vector3.ZERO var drag_world := Vector3.ZERO
@@ -37,6 +41,10 @@ var _dying := {} ## key -> Restzeit der Sterbe-Animation
const DEATH_TIME := 0.45 const DEATH_TIME := 0.45
var _shots: Array = [] ## fliegende Geschosse {"node", "from", "to", "t", "dur"} var _shots: Array = [] ## fliegende Geschosse {"node", "from", "to", "t", "dur"}
var shots_total := 0 ## Zähler aller Geschosse, für Tests var shots_total := 0 ## Zähler aller Geschosse, für Tests
var _effects: Array = [] ## Fähigkeitsformen {"node", "t", "dur", "grow"}
var effects_total := 0
var _ring_fx: TorusMesh
var _disc_fx: CylinderMesh
var _shot_mesh: SphereMesh var _shot_mesh: SphereMesh
var _slash_mesh: BoxMesh var _slash_mesh: BoxMesh
var _origin := Vector3.ZERO var _origin := Vector3.ZERO
@@ -63,6 +71,16 @@ func _ready() -> void:
_shot_mesh.rings = 4 _shot_mesh.rings = 4
_slash_mesh = BoxMesh.new() _slash_mesh = BoxMesh.new()
_slash_mesh.size = Vector3(0.12, 0.12, 0.7) _slash_mesh.size = Vector3(0.12, 0.12, 0.7)
_ring_fx = TorusMesh.new()
_ring_fx.inner_radius = 0.55
_ring_fx.outer_radius = 0.7
_ring_fx.ring_segments = 8
_ring_fx.rings = 28
_disc_fx = CylinderMesh.new()
_disc_fx.top_radius = 0.9
_disc_fx.bottom_radius = 0.9
_disc_fx.height = 0.06
_disc_fx.radial_segments = 6
_build_tiles() _build_tiles()
_build_bench() _build_bench()
set_process(true) set_process(true)
@@ -228,6 +246,59 @@ func _process(delta: float) -> void:
_update_tiles() _update_tiles()
_update_units(delta) _update_units(delta)
_update_shots(delta) _update_shots(delta)
_update_effects(delta)
## Sichtbare Fähigkeit: Tank = wachsender Schildring, Schütze = Fläche am Ziel,
## Heiler = aufsteigender Heilring. Farbe nach Element.
func spawn_effect(world: Vector3, target: Vector3, role: int, col: Color) -> void:
var mi := MeshInstance3D.new()
var m := StandardMaterial3D.new()
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = Color(col, 0.75)
m.emission_enabled = true
m.emission = col
m.emission_energy_multiplier = 1.4
mi.material_override = m
var fx := {"node": mi, "t": 0.0, "dur": 0.45, "rise": 0.0, "grow": 1.0}
match role:
Data.Role.TANK:
mi.mesh = _ring_fx
mi.position = world + Vector3(0.0, 0.3, 0.0)
fx["grow"] = 1.6
Data.Role.DPS:
mi.mesh = _disc_fx
mi.position = target + Vector3(0.0, 0.12, 0.0)
mi.scale = Vector3(0.3, 1.0, 0.3)
fx["grow"] = 1.4
fx["dur"] = 0.35
_:
mi.mesh = _ring_fx
mi.position = world + Vector3(0.0, 0.1, 0.0)
fx["rise"] = 1.2
fx["grow"] = 1.2
fx["dur"] = 0.6
add_child(mi)
effects_total += 1
_effects.append(fx)
func _update_effects(delta: float) -> void:
var keep := []
for fx in _effects:
fx["t"] += delta
var f: float = fx["t"] / fx["dur"]
var node: MeshInstance3D = fx["node"]
if f >= 1.0:
node.queue_free()
continue
var g: float = lerpf(1.0, fx["grow"], f) if fx["grow"] >= 1.0 else 1.0
node.scale = Vector3(g, 1.0, g) if node.mesh == _disc_fx else Vector3(g, g, g)
node.position.y += fx["rise"] * delta
var m: StandardMaterial3D = node.material_override
m.albedo_color.a = 0.75 * (1.0 - f)
keep.append(fx)
_effects = keep
## Sichtbarer Angriff: Fernkampf schickt eine Kugel, Nahkampf einen kurzen Hieb. ## Sichtbarer Angriff: Fernkampf schickt eine Kugel, Nahkampf einen kurzen Hieb.
@@ -276,6 +347,11 @@ func _update_tiles() -> void:
var slot := {"kind": "board", "hex": to_board(h)} var 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 col := Color(0.20, 0.23, 0.31) if own else Color(0.28, 0.18, 0.20)
var emit := 0.0 var emit := 0.0
var in_range := range_radius > 0 and range_center.x >= 0 \
and Hex.distance(h, range_center) <= range_radius and h != range_center
if in_range and not drag_active:
col = col.lerp(range_color, 0.45)
emit = 0.22
if own and _is_hover(slot): if own and _is_hover(slot):
col = Color(0.42, 0.60, 0.82) col = Color(0.42, 0.60, 0.82)
emit = 0.40 emit = 0.40
@@ -343,6 +419,7 @@ func _update_units(delta: float) -> void:
"slow": u.slow_timer > 0.0 and u.slow > 0.0, "slow": u.slow_timer > 0.0 and u.slow > 0.0,
"stun": u.stun_timer > 0.0, "stun": u.stun_timer > 0.0,
"shield": clampf(u.shield / maxf(1.0, u.max_hp), 0.0, 1.0), "shield": clampf(u.shield / maxf(1.0, u.max_hp), 0.0, 1.0),
"face": world_pos(_mirror(u.target.pos, my_team)) if (u.target != null and u.target.alive) else null,
} }
else: else:
for bh in game.human.board: for bh in game.human.board:
@@ -410,7 +487,23 @@ func _update_units(delta: float) -> void:
# und sieht nicht angehoben aus. # und sieht nicht angehoben aus.
node.position = target node.position = target
else: else:
node.position = node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0)) var flat_from := Vector3(node.position.x, 0.0, node.position.z)
var flat_to := Vector3(target.x, 0.0, target.z)
var moving := flat_from.distance_to(flat_to) > 0.05
var next := node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0))
if moving:
# Blick in Laufrichtung, kleines Wippen wie ein Schritt.
var dirv := flat_to - flat_from
node.rotation.y = lerp_angle(node.rotation.y, atan2(dirv.x, dirv.z), clampf(delta * 10.0, 0.0, 1.0))
var body: MeshInstance3D = node.get_meta("body")
body.position.y = body.get_meta("base_y", body.position.y) + absf(sin(Time.get_ticks_msec() / 90.0)) * 0.12
elif d.get("face", null) != null:
var fd: Vector3 = d["face"] - flat_to
if fd.length() > 0.05:
node.rotation.y = lerp_angle(node.rotation.y, atan2(fd.x, fd.z), clampf(delta * 6.0, 0.0, 1.0))
var body2: MeshInstance3D = node.get_meta("body")
body2.position.y = body2.get_meta("base_y", body2.position.y)
node.position = next
var body: MeshInstance3D = node.get_meta("body") var body: MeshInstance3D = node.get_meta("body")
var mat: StandardMaterial3D = body.material_override var mat: StandardMaterial3D = body.material_override
var base: Color = d["avatar"].color() var base: Color = d["avatar"].color()
@@ -446,8 +539,15 @@ func _update_units(delta: float) -> void:
## Knoten eines Avatars, oder null. Wird von den Tests genutzt. ## Knoten eines Avatars, oder null. Wird von den Tests genutzt.
## Findet auch Kampfeinheiten, die unter einem anderen Schlüssel liegen.
func unit_node(avatar_id: int) -> Node3D: func unit_node(avatar_id: int) -> Node3D:
return _units.get("a%d" % avatar_id) var direct = _units.get("a%d" % avatar_id)
if direct != null:
return direct
for n in _units.values():
if n.get_meta("avatar_id", -1) == avatar_id:
return n
return null
## Für das Overlay: Arena-Feld so spiegeln, wie das Brett gezeichnet wird. ## Für das Overlay: Arena-Feld so spiegeln, wie das Brett gezeichnet wird.
@@ -555,8 +655,10 @@ func _make_unit(a: Avatar, side: int) -> Node3D:
sph.rings = 10 sph.rings = 10
body.mesh = sph body.mesh = sph
body.position.y = 0.55 body.position.y = 0.55
body.set_meta("base_y", body.position.y)
root.add_child(body) root.add_child(body)
root.set_meta("body", body) root.set_meta("body", body)
root.set_meta("avatar_id", a.id)
_add_ornament(root, a, body.position.y) _add_ornament(root, a, body.position.y)

93
scripts/ui/CameraRig.gd Normal file
View File

@@ -0,0 +1,93 @@
class_name CameraRig
extends Node3D
## Kamera über dem Brett: passt sich der freien Bildmitte an, lässt sich
## zoomen und um die Brettmitte drehen.
const FOV := 44.0
const PITCH := 52.0 ## Grad über der Waagerechten
const ZOOM_MIN := 0.6
const ZOOM_MAX := 1.5
var camera: Camera3D
var zoom := 1.0
var yaw := 0.0
var _center := Vector3.ZERO
var _dir := Vector3.ZERO
var _dist := 20.0
func _ready() -> void:
camera = Camera3D.new()
camera.fov = FOV
camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP)
add_child(camera)
## Legt die Kamera so, dass alle Punkte in das Zielrechteck passen.
func fit(points: Array, target: Rect2) -> void:
if target.size.x <= 0.0 or target.size.y <= 0.0 or points.is_empty():
return
var pitch := deg_to_rad(PITCH)
var dir := Vector3(0.0, sin(pitch), cos(pitch))
var dist := 20.0
var center := Vector3.ZERO
for i in 8:
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
var r := _project_rect(points)
if r.size.x <= 0.0 or r.size.y <= 0.0:
return
dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y)
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
r = _project_rect(points)
var err := r.get_center() - target.get_center()
var o := camera.unproject_position(center)
var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x
var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y
if absf(kx) < 0.001 or absf(kz) < 0.001:
break
center += Vector3(err.x / kx, 0.0, err.y / kz)
_center = center
_dir = dir
_dist = dist
apply()
## Nutzeranteil: Zoom und Drehung um die Brettmitte.
func apply() -> void:
var dir := _dir.rotated(Vector3.UP, deg_to_rad(yaw))
camera.look_at_from_position(_center + dir * _dist * zoom, _center, Vector3.UP)
func zoom_by(delta: float) -> void:
zoom = clampf(zoom + delta, ZOOM_MIN, ZOOM_MAX)
apply()
func rotate_by(deg: float) -> void:
yaw = fmod(yaw + deg, 360.0)
apply()
func _project_rect(points: Array) -> Rect2:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for p in points:
if camera.is_position_behind(p):
return Rect2()
var q := camera.unproject_position(p)
lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y))
hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y))
return Rect2(lo, hi - lo)
## Punkt auf der Feldebene unter einer Bildschirmposition, oder null.
func ground_point(screen_pos: Vector2, plane_y: float):
var from := camera.project_ray_origin(screen_pos)
var dir := camera.project_ray_normal(screen_pos)
if absf(dir.y) < 0.0001:
return null
var t := (plane_y - from.y) / dir.y
if t < 0.0:
return null
return from + dir * t

View File

@@ -0,0 +1 @@
uid://bet73kf858xae

View File

@@ -9,6 +9,10 @@ const FLOAT_RISE := 42.0
var board: BoardView3D = null var board: BoardView3D = null
var sfx: Sfx = null var sfx: Sfx = null
## Tooltip zu einer Einheit, gesetzt von Main (Maus über Einheit oder Shop-Karte).
var tooltip_avatar: Avatar = null
var tooltip_pos := Vector2.ZERO
var tooltip_note := ""
var _font: Font var _font: Font
var _floaters: Array = [] ## {"world": Vector3, "text": String, "col": Color, "age": float} var _floaters: Array = [] ## {"world": Vector3, "text": String, "col": Color, "age": float}
var _combat_seen: Combat = null var _combat_seen: Combat = null
@@ -54,12 +58,19 @@ func _pull_hits() -> void:
_floaters.append({"world": world, "text": str(h["amount"]) + "!", _floaters.append({"world": world, "text": str(h["amount"]) + "!",
"col": Color(1.0, 0.85, 0.3), "age": 0.0}) "col": Color(1.0, 0.85, 0.3), "age": 0.0})
if sfx: sfx.play("krit") if sfx: sfx.play("krit")
"counter":
# Konter-Treffer: Element oder Rolle war stark gegen das Ziel.
_floaters.append({"world": world, "text": str(h["amount"]) + " ▲",
"col": Color(1.0, 0.6, 0.9), "age": 0.0})
if sfx: sfx.play("krit")
"heal": "heal":
_floaters.append({"world": world, "text": "+" + str(h["amount"]), _floaters.append({"world": world, "text": "+" + str(h["amount"]),
"col": Color(0.55, 1.0, 0.6), "age": 0.0}) "col": Color(0.55, 1.0, 0.6), "age": 0.0})
if sfx: sfx.play("heilung") if sfx: sfx.play("heilung")
"cast": "cast":
if sfx: sfx.play("zauber") if sfx: sfx.play("zauber")
var tgt := board.world_pos(board.mirror_for_view(h["target"], my_team))
board.spawn_effect(world, tgt, h["role"], Data.ELEMENT_COLORS[h["element"]])
"attack": "attack":
var from := board.world_pos(board.mirror_for_view(h["from"], my_team)) var from := board.world_pos(board.mirror_for_view(h["from"], my_team))
board.spawn_attack(from, world, h["ranged"], Data.ELEMENT_COLORS[h["element"]]) board.spawn_attack(from, world, h["ranged"], Data.ELEMENT_COLORS[h["element"]])
@@ -120,3 +131,4 @@ func _draw() -> void:
draw_string(_font, pos + Vector2(1, 1), f["text"], HORIZONTAL_ALIGNMENT_CENTER, -1, size, draw_string(_font, pos + Vector2(1, 1), f["text"], HORIZONTAL_ALIGNMENT_CENTER, -1, size,
Color(0, 0, 0, col.a * 0.8)) Color(0, 0, 0, col.a * 0.8))
draw_string(_font, pos, f["text"], HORIZONTAL_ALIGNMENT_CENTER, -1, size, col) draw_string(_font, pos, f["text"], HORIZONTAL_ALIGNMENT_CENTER, -1, size, col)

View File

@@ -32,7 +32,8 @@ func build(x: float, y: float, w: float, h: float) -> void:
traits_box = VBoxContainer.new() traits_box = VBoxContainer.new()
info_box = VBoxContainer.new() info_box = VBoxContainer.new()
log_box = VBoxContainer.new() log_box = VBoxContainer.new()
for b in [traits_box, info_box, log_box]: # Die gewählte Einheit zuerst: dort sitzt der Aufwerten-Knopf, der muss ohne Scrollen erreichbar sein.
for b in [info_box, traits_box, log_box]:
b.add_theme_constant_override("separation", 3) b.add_theme_constant_override("separation", 3)
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
col.add_child(b) col.add_child(b)
@@ -76,11 +77,17 @@ func _refresh_traits(game: Game) -> void:
dot.color = col if tier > 0 else col.darkened(0.55) dot.color = col if tier > 0 else col.darkened(0.55)
dot.custom_minimum_size = Vector2(12, 12) dot.custom_minimum_size = Vector2(12, 12)
row.add_child(dot) row.add_child(dot)
var tr: Dictionary = Data.ELEMENT_TRAITS[e]
var txt := " %s %d" % [Data.ELEMENT_NAMES[e], c] var txt := " %s %d" % [Data.ELEMENT_NAMES[e], c]
if tier > 0: if tier > 0:
txt += " - " + Data.ELEMENT_TRAITS[e]["desc"][tier - 1] txt += " - " + tr["desc"][tier - 1]
else: else:
txt += " (ab 2 aktiv)" txt += " (ab 2 aktiv)"
# Ausblick: was die nächste Stufe bringt.
if tier < tr["steps"].size():
txt += "\n nächste Stufe bei %d: %s" % [tr["steps"][tier], tr["desc"][tier]]
else:
txt += "\n höchste Stufe"
var el := UiKit.label(txt, 13, Color(0.9, 0.9, 0.95) if tier > 0 else Color(0.55, 0.55, 0.6)) 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.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
el.custom_minimum_size = Vector2(_text_w - 20.0, 0) el.custom_minimum_size = Vector2(_text_w - 20.0, 0)
@@ -114,6 +121,7 @@ func _refresh_info(game: Game, a: Avatar, hex: Vector2i) -> void:
info_box.add_child(_wrapped("Leben %d Angriff %d Rüstung %d%%\nTempo %.2f Reichweite %d%s" info_box.add_child(_wrapped("Leben %d Angriff %d Rüstung %d%%\nTempo %.2f Reichweite %d%s"
% [int(s["hp"]), int(s["atk"]), int(s["armor"] * 100.0), s["aspd"], s["range"], crit], 12, % [int(s["hp"]), int(s["atk"]), int(s["armor"] * 100.0), s["aspd"], s["range"], crit], 12,
Color(0.9, 0.9, 0.95))) Color(0.9, 0.9, 0.95)))
info_box.add_child(_wrapped(a.counter_text(), 12, Color(1.0, 0.75, 0.9)))
info_box.add_child(_wrapped(a.ability_text(), 12, Color(0.8, 0.85, 1.0))) info_box.add_child(_wrapped(a.ability_text(), 12, Color(0.8, 0.85, 1.0)))
if hex.x < 0: if hex.x < 0:
info_box.add_child(_wrapped("Zum Aufwerten aufs Feld ziehen.", 12, Color(0.85, 0.85, 0.9))) info_box.add_child(_wrapped("Zum Aufwerten aufs Feld ziehen.", 12, Color(0.85, 0.85, 0.9)))

View File

@@ -9,14 +9,11 @@ const LEFT_W := 290.0 ## links: Elemente, Einheit, Rückblick
const RIGHT_W := 210.0 ## rechts: Spielerliste const RIGHT_W := 210.0 ## rechts: Spielerliste
const BOTTOM_H := 152.0 const BOTTOM_H := 152.0
const BG := Color(0.09, 0.10, 0.13) const BG := Color(0.09, 0.10, 0.13)
const CAM_FOV := 44.0
const CAM_PITCH := 52.0 ## Grad über der Waagerechten
const ZOOM_MIN := 0.6
const ZOOM_MAX := 1.5
const YAW_STEP := 15.0 const YAW_STEP := 15.0
var game := Game.new() var game := Game.new()
var board: BoardView3D var board: BoardView3D
var rig: CameraRig
var camera: Camera3D var camera: Camera3D
var ui_layer: CanvasLayer var ui_layer: CanvasLayer
var ui_root: Control var ui_root: Control
@@ -30,14 +27,13 @@ var shop: ShopPanel
var combat_speed := 1.0 var combat_speed := 1.0
var started := false var started := false
var paused := false var paused := false
var spectating := false ## nach dem Ausscheiden zuschauen
var eliminated_shown := false
var scout: PlayerState = null ## Spieler, dessen Brett gerade angesehen wird var scout: PlayerState = null ## Spieler, dessen Brett gerade angesehen wird
var settings := Settings.new()
# Kamera: Einpassung plus Nutzeranteil var overlays: Overlays
var _cam_center := Vector3.ZERO var _prev_combos: Array = [] ## für die Meldung neuer Kombinationen
var _cam_dir := Vector3.ZERO var _prev_tiers := {} ## Element -> Trait-Stufe
var _cam_dist := 20.0
var cam_zoom := 1.0
var cam_yaw := 0.0
# Auswahl und Ziehen # Auswahl und Ziehen
var sel_slot := {} var sel_slot := {}
@@ -51,7 +47,6 @@ var picker_title: Label
var picker_sub: Label var picker_sub: Label
var picker_cb: Callable var picker_cb: Callable
var overlay: Control ## Start-, Pause- oder Endbildschirm
func _ready() -> void: func _ready() -> void:
@@ -72,17 +67,42 @@ func _ready() -> void:
_build_panels() _build_panels()
_build_picker() _build_picker()
var tip := TooltipView.new()
tip.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
tip.hud = hud
ui_layer.add_child(tip)
shop.card_hovered.connect(_on_card_hovered)
shop.card_left.connect(_on_card_left)
shop.undo_pressed.connect(_on_undo)
game.start() settings.load_from_disk()
sfx.set_volume(settings.volume)
overlays = Overlays.new()
overlays.volume = settings.volume
overlays.difficulty = settings.difficulty
overlays.start_pressed.connect(_start_game)
overlays.difficulty_changed.connect(_on_difficulty)
overlays.volume_changed.connect(func(v):
settings.volume = v
sfx.set_volume(v)
settings.save_to_disk())
overlays.resume_pressed.connect(_toggle_pause)
overlays.restart_pressed.connect(_restart)
overlays.spectate_pressed.connect(_spectate)
ui_layer.add_child(overlays)
game.start(0, settings.difficulty)
board.game = game board.game = game
_refresh() _refresh()
_show_start() overlays.show_start(Game.has_save())
set_process(true) set_process(true)
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not started or paused: if not started or paused:
return return
if spectating and game.phase == Game.Phase.PLANNING and game.plan_left > 3.0:
game.plan_left = 3.0
if game.phase == Game.Phase.PLANNING: if game.phase == Game.Phase.PLANNING:
if game.tick_planning(delta): if game.tick_planning(delta):
_cancel_drag() _cancel_drag()
@@ -99,6 +119,8 @@ func _process(delta: float) -> void:
if game.phase != before: if game.phase != before:
_after_combat() _after_combat()
_refresh() _refresh()
if game.phase == Game.Phase.PLANNING and game.human.alive:
game.save()
func _after_combat() -> void: func _after_combat() -> void:
@@ -106,6 +128,8 @@ func _after_combat() -> void:
1: sfx.play("sieg") 1: sfx.play("sieg")
-1: sfx.play("niederlage") -1: sfx.play("niederlage")
_: sfx.play("runde") _: sfx.play("runde")
if not game.human.alive and not eliminated_shown and game.phase != Game.Phase.GAMEOVER:
_show_eliminated()
# --- Eingabe ----------------------------------------------------------------- # --- Eingabe -----------------------------------------------------------------
@@ -117,8 +141,7 @@ func _unhandled_input(event: InputEvent) -> void:
return return
if event is InputEventMouseMotion: if event is InputEventMouseMotion:
if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE: if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE:
cam_yaw += event.relative.x * 0.3 rig.rotate_by(event.relative.x * 0.3)
_apply_camera()
return return
_on_mouse_move(event.position) _on_mouse_move(event.position)
return return
@@ -134,21 +157,24 @@ func _unhandled_input(event: InputEvent) -> void:
_on_right_click(event.position) _on_right_click(event.position)
MOUSE_BUTTON_WHEEL_UP: MOUSE_BUTTON_WHEEL_UP:
if event.pressed: if event.pressed:
_zoom(-0.1) rig.zoom_by(-0.1)
MOUSE_BUTTON_WHEEL_DOWN: MOUSE_BUTTON_WHEEL_DOWN:
if event.pressed: if event.pressed:
_zoom(0.1) rig.zoom_by(0.1)
return return
if not (event is InputEventKey and event.pressed and not event.echo): if not (event is InputEventKey and event.pressed and not event.echo):
return return
match event.keycode: match event.keycode:
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5:
_on_buy(event.keycode - KEY_1, false)
KEY_Z: _on_undo()
KEY_D: _on_reroll() KEY_D: _on_reroll()
KEY_X: _on_buy_xp() KEY_X: _on_buy_xp()
KEY_F: _on_lock() KEY_F: _on_lock()
KEY_SPACE: _on_ready() KEY_SPACE: _on_ready()
KEY_P: _toggle_pause() KEY_P: _toggle_pause()
KEY_Q: _rotate(-YAW_STEP) KEY_Q: rig.rotate_by(-YAW_STEP)
KEY_E: _rotate(YAW_STEP) KEY_E: rig.rotate_by(YAW_STEP)
KEY_ESCAPE: KEY_ESCAPE:
_cancel_drag() _cancel_drag()
sel_slot = {} sel_slot = {}
@@ -160,6 +186,43 @@ func _on_mouse_move(pos: Vector2) -> void:
board.hover_slot = slot board.hover_slot = slot
if drag_avatar != null: if drag_avatar != null:
board.drag_world = _drag_point(pos, slot) board.drag_world = _drag_point(pos, slot)
hud.tooltip_avatar = null
return
_hover_unit(pos)
## Einheit unter der Maus, egal ob eigene, Gegner im Kampf oder angesehenes Brett.
func _hover_unit(pos: Vector2) -> void:
var best: Avatar = null
var best_d := 34.0
for d in board.draw_units:
var world: Vector3 = d["pos"]
if camera.is_position_behind(world):
continue
var sp := camera.unproject_position(world + Vector3(0.0, 0.6, 0.0))
var dist := sp.distance_to(pos)
if dist < best_d:
best_d = dist
best = d["avatar"]
hud.tooltip_avatar = best
hud.tooltip_pos = pos
hud.tooltip_note = ""
func _on_card_hovered(index: int, screen_pos: Vector2) -> void:
var p := game.human
if index < 0 or index >= p.shop.size() or p.shop[index].sold:
return
var e: ShopEntry = p.shop[index]
var element := e.element if e.element >= 0 else (p.last_element if p.last_element >= 0 else Data.Element.FEUER)
hud.tooltip_avatar = e.to_avatar(element)
hud.tooltip_note = "Werte als %s, Element wählst du beim Kauf" % Data.ELEMENT_NAMES[element] \
if e.element < 0 else "%d Gold" % e.price()
hud.tooltip_pos = screen_pos - Vector2(0.0, 120.0)
func _on_card_left() -> void:
hud.tooltip_avatar = null
func _begin_drag(pos: Vector2) -> void: func _begin_drag(pos: Vector2) -> void:
@@ -220,6 +283,7 @@ func _on_right_click(pos: Vector2) -> void:
if slot["kind"] == "bench": if slot["kind"] == "bench":
if game.human.sell_bench(slot["index"], game.pool): if game.human.sell_bench(slot["index"], game.pool):
sfx.play("verkauf") sfx.play("verkauf")
shop.show_undo()
elif game.human.unplace(slot["hex"]): elif game.human.unplace(slot["hex"]):
sfx.play("setzen") sfx.play("setzen")
sel_slot = {} sel_slot = {}
@@ -262,87 +326,24 @@ func _build_world() -> void:
board = BoardView3D.new() board = BoardView3D.new()
add_child(board) add_child(board)
camera = Camera3D.new() rig = CameraRig.new()
camera.fov = CAM_FOV add_child(rig)
camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP) camera = rig.camera
add_child(camera)
board.camera = camera board.camera = camera
get_viewport().size_changed.connect(_fit_camera) get_viewport().size_changed.connect(_fit_camera)
_fit_camera() _fit_camera()
## Legt die Kamera so, dass das Spielfeld genau in die freie Bildmitte passt. ## Das Spielfeld muss in die freie Bildmitte passen: Panels liegen links, rechts, unten.
func _fit_camera() -> void: func _fit_camera() -> void:
var vp := Vector2(get_viewport().get_visible_rect().size) var vp := Vector2(get_viewport().get_visible_rect().size)
var target := Rect2(LEFT_W + 16.0, TOP_H + 16.0, rig.fit(board.corner_points(), Rect2(LEFT_W + 16.0, TOP_H + 16.0,
vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0) vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0))
if target.size.x <= 0.0 or target.size.y <= 0.0:
return
var pts := board.corner_points()
var pitch := deg_to_rad(CAM_PITCH)
var dir := Vector3(0.0, sin(pitch), cos(pitch))
var dist := 20.0
var center := Vector3.ZERO
for i in 8:
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
var r := _project_rect(pts)
if r.size.x <= 0.0 or r.size.y <= 0.0:
return
dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y)
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
r = _project_rect(pts)
var err := r.get_center() - target.get_center()
var o := camera.unproject_position(center)
var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x
var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y
if absf(kx) < 0.001 or absf(kz) < 0.001:
break
center += Vector3(err.x / kx, 0.0, err.y / kz)
_cam_center = center
_cam_dir = dir
_cam_dist = dist
_apply_camera()
## Nutzeranteil: Zoom und Drehung um die Brettmitte.
func _apply_camera() -> void:
var dir := _cam_dir.rotated(Vector3.UP, deg_to_rad(cam_yaw))
camera.look_at_from_position(_cam_center + dir * _cam_dist * cam_zoom, _cam_center, Vector3.UP)
func _zoom(delta: float) -> void:
cam_zoom = clampf(cam_zoom + delta, ZOOM_MIN, ZOOM_MAX)
_apply_camera()
func _rotate(deg: float) -> void:
cam_yaw = fmod(cam_yaw + deg, 360.0)
_apply_camera()
func _project_rect(points: Array) -> Rect2:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for p in points:
if camera.is_position_behind(p):
return Rect2()
var q := camera.unproject_position(p)
lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y))
hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y))
return Rect2(lo, hi - lo)
func _ground_point(screen_pos: Vector2): func _ground_point(screen_pos: Vector2):
if camera == null: var hit = rig.ground_point(screen_pos, BoardView3D.TILE_H * 0.5)
return null return null if hit == null else board.to_local(hit)
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)
func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3: func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3:
@@ -452,6 +453,8 @@ func _on_ready() -> void:
game.skip_combat() game.skip_combat()
_after_combat() _after_combat()
_refresh() _refresh()
if game.phase == Game.Phase.PLANNING and game.human.alive:
game.save()
func _on_speed() -> void: func _on_speed() -> void:
@@ -526,6 +529,27 @@ func _on_buy(index: int, quick: bool) -> void:
_refresh()) _refresh())
func _on_undo() -> void:
if game.phase != Game.Phase.PLANNING:
return
if game.human.undo_sell(game.pool):
sfx.play("kauf")
shop.hide_undo()
else:
shop.hint("Rückgängig geht nicht mehr.")
_refresh()
func _on_difficulty(d: int) -> void:
settings.difficulty = d
settings.save_to_disk()
game = Game.new()
game.start(0, d)
board.game = game
_refresh()
overlays.show_start(Game.has_save())
func _on_upgrade() -> void: func _on_upgrade() -> void:
var p := game.human var p := game.human
var hex := selected_board_hex() var hex := selected_board_hex()
@@ -535,32 +559,12 @@ func _on_upgrade() -> void:
var options := p.available_new_elements(hex) var options := p.available_new_elements(hex)
var previews := [] var previews := []
for e in options: for e in options:
previews.append("+ " + _element_effect(e) + " (halbe Stärke)") previews.append("+ " + Data.element_effect(e) + " (halbe Stärke)")
_open_picker("Zweite/dritte Spezialisierung", options, func(e): _open_picker("Zweite/dritte Spezialisierung", options, func(e):
if game.human.upgrade(hex, e, game.pool): if game.human.upgrade(hex, e, game.pool):
sfx.play("aufwerten") sfx.play("aufwerten")
_refresh(), previews, _refresh(), previews,
"Fähigkeit wird zu: " + _star_effect(a.role, a.star + 1)) "Fähigkeit wird zu: " + Data.star_effect(a.role, a.star + 1))
func _star_effect(role: int, star: int) -> String:
match role:
Data.Role.TANK:
return "Schild und spottet 3 Gegner" if star == 2 else "Schild, Spott und 25% Reflexion"
Data.Role.DPS:
return "Ziel plus Splash auf Nachbarn" if star == 2 else "Ziel plus Fläche im Radius 2"
_:
return "heilt 3 Verbündete" if star == 2 else "heilt alle und schildet sie"
func _element_effect(element: int) -> String:
match element:
Data.Element.FEUER: return "Brand"
Data.Element.WASSER: return "Mana und stärkere Heilung"
Data.Element.WIND: return "Tempo-Schub"
Data.Element.BLITZ: return "Kettenblitz"
Data.Element.ERDE: return "Betäubung"
_: return "Verlangsamung"
func _on_scout(p: PlayerState) -> void: func _on_scout(p: PlayerState) -> void:
@@ -582,69 +586,92 @@ func _refresh() -> void:
players.refresh(game, scout) players.refresh(game, scout)
info.refresh(game, _avatar_at(sel_slot), selected_board_hex()) info.refresh(game, _avatar_at(sel_slot), selected_board_hex())
shop.refresh(game) shop.refresh(game)
shop.lbl_tip.text = _tutorial_tip()
board.selected_slot = sel_slot board.selected_slot = sel_slot
_refresh_range()
_announce_synergies()
if game.phase == Game.Phase.GAMEOVER: if game.phase == Game.Phase.GAMEOVER:
_show_gameover() Game.delete_save()
if overlays.kind() != "gameover":
overlays.show_gameover(game)
# --- Overlays: Start, Pause, Ende -------------------------------------------- ## Reichweite der gewählten Board-Einheit auf den Feldern zeigen.
func _refresh_range() -> void:
func _overlay_box(title: String) -> VBoxContainer: var hex := selected_board_hex()
if overlay != null: var a: Avatar = _avatar_at(sel_slot)
overlay.queue_free() if hex.x < 0 or a == null or game.phase != Game.Phase.PLANNING:
overlay = ColorRect.new() board.range_radius = 0
overlay.color = Color(0, 0, 0, 0.72) return
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) board.range_center = BoardView3D.from_board(hex)
ui_root.add_child(overlay) if a.role == Data.Role.HEILER:
var v := VBoxContainer.new() board.range_radius = Data.HEAL_RANGE
v.position = Vector2(600, 280) board.range_color = Color(0.45, 0.95, 0.55)
v.custom_minimum_size = Vector2(400, 0) else:
v.add_theme_constant_override("separation", 12) board.range_radius = int(a.base_stats()["range"])
overlay.add_child(v) board.range_color = Color(1.0, 0.65, 0.3)
v.add_child(UiKit.label(title, 32))
return v
func _close_overlay() -> void: ## Meldet neu aktive Kombinationen und Trait-Stufen.
if overlay != null: func _announce_synergies() -> void:
overlay.queue_free() var p := game.human
overlay = null var combos := []
for c in p.active_combos():
combos.append(c["name"])
var tiers := {}
var counts := p.trait_counts()
for e in counts:
tiers[e] = Data.trait_tier(e, counts[e])
if game.phase == Game.Phase.PLANNING:
var msgs := []
for name in combos:
if not _prev_combos.has(name):
msgs.append("Kombination aktiv: %s" % name)
for e in tiers:
if tiers[e] > _prev_tiers.get(e, 0) and tiers[e] > 0:
msgs.append("%s-Trait Stufe %d: %s" % [Data.ELEMENT_NAMES[e], tiers[e],
Data.ELEMENT_TRAITS[e]["desc"][tiers[e] - 1]])
if not msgs.is_empty():
shop.hint(" · ".join(msgs))
sfx.play("level" if msgs[0].begins_with("Kombination") else "aufwerten")
_prev_combos = combos
_prev_tiers = tiers
func _volume_row() -> HBoxContainer: ## Erstrunden-Hilfe: sagt in den ersten zwei Runden, was als Nächstes dran ist.
var h := HBoxContainer.new() func _tutorial_tip() -> String:
h.add_theme_constant_override("separation", 10) if game.round_no > 2 or game.phase != Game.Phase.PLANNING or not game.human.alive:
h.add_child(UiKit.label("Lautstärke", 14)) return ""
var sl := HSlider.new() var p := game.human
sl.min_value = 0.0 var on_bench := 0
sl.max_value = 1.0 for a in p.bench:
sl.step = 0.05 if a != null:
sl.value = 0.8 on_bench += 1
sl.custom_minimum_size = Vector2(220, 20) if p.board.is_empty() and on_bench == 0:
sl.focus_mode = Control.FOCUS_NONE return "Tipp: Kauf unten einen neutralen Avatar und wähl sein Element."
sl.value_changed.connect(func(v): sfx.set_volume(v)) if p.board.is_empty():
h.add_child(sl) return "Tipp: Zieh den Avatar von der Bank auf dein Feld (untere Hälfte)."
return h if p.board.size() < p.team_slots() and p.gold >= Data.PRICE_NONE:
return "Tipp: Du hast noch %d freie Kaderplätze. Tanks nach vorn." % (p.team_slots() - p.board.size())
if game.round_no == 1:
return "Tipp: Leertaste startet den Kampf. Gleiche Elemente ab 2 geben einen Trait."
return "Tipp: 3 gleiche Elemente machen einen 2-Star. Futter liegt auf der Bank."
func _show_start() -> void: # --- Start, Pause, Ausscheiden, Neustart ------------------------------------
var v := _overlay_box("Elements Autochess")
v.add_child(UiKit.label("8 Spieler, 100 Leben, Elemente als Traits. Basis wie TFT.", 15,
Color(0.8, 0.85, 0.95)))
v.add_child(UiKit.label(
"Ziehen: Bank ↔ Feld · Rechtsklick: verkaufen · D würfeln · F einfrieren · X Level\nQ/E drehen · Mausrad zoomen · P Pause · Leertaste Kampf",
13, Color(0.7, 0.72, 0.8)))
v.add_child(_volume_row())
var b := UiKit.button("Spiel starten", Color(0.28, 0.5, 0.36))
b.custom_minimum_size = Vector2(200, 38)
b.pressed.connect(_start_game)
v.add_child(b)
func _start_game(continue_save: bool) -> void:
func _start_game() -> void: if continue_save:
var loaded := Game.load_from()
if loaded != null:
game = loaded
board.game = game
board.scout_player = null
scout = null
_refresh()
started = true started = true
paused = false paused = false
_close_overlay() overlays.close()
sfx.play("runde") sfx.play("runde")
@@ -655,39 +682,42 @@ func _toggle_pause() -> void:
if paused: if paused:
_cancel_drag() _cancel_drag()
picker.hide() picker.hide()
var v := _overlay_box("Pause") game.save()
v.add_child(_volume_row()) overlays.show_pause()
var b := UiKit.button("Weiter [P]", Color(0.28, 0.5, 0.36))
b.custom_minimum_size = Vector2(200, 36)
b.pressed.connect(_toggle_pause)
v.add_child(b)
else: else:
_close_overlay() overlays.close()
func _show_gameover() -> void: func _show_eliminated() -> void:
if overlay != null and overlay.has_meta("gameover"): eliminated_shown = true
return _cancel_drag()
var v := _overlay_box("Spiel vorbei") picker.hide()
overlay.set_meta("gameover", true) Game.delete_save()
var alive := game.alive_players() overlays.show_eliminated(game.human.placement, game.round_no)
var win: String = alive[0].pname if not alive.is_empty() else "-"
v.add_child(UiKit.label("Sieger: %s" % win, 20, Color(1.0, 0.85, 0.35)))
var place := game.human.placement if game.human.placement > 0 else 1 func _spectate() -> void:
v.add_child(UiKit.label("Dein Platz: %d von 8" % place, 18, spectating = true
Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6))) overlays.close()
var b := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36)) var best := game.ranking()
b.custom_minimum_size = Vector2(180, 36) if not best.is_empty() and best[0].alive:
b.pressed.connect(func(): _on_scout(best[0])
_close_overlay()
game = Game.new()
game.start() func _restart() -> void:
board.game = game overlays.close()
board.scout_player = null Game.delete_save()
scout = null game = Game.new()
_cancel_drag() game.start(0, settings.difficulty)
sel_slot = {} board.game = game
started = true board.scout_player = null
paused = false scout = null
_refresh()) spectating = false
v.add_child(b) eliminated_shown = false
_prev_combos = []
_prev_tiers = {}
_cancel_drag()
sel_slot = {}
started = true
paused = false
_refresh()

161
scripts/ui/Overlays.gd Normal file
View File

@@ -0,0 +1,161 @@
class_name Overlays
extends Control
## Start-, Pause-, Ausscheide- und Endbildschirm. Meldet Knöpfe per Signal.
signal start_pressed(continue_save: bool)
signal difficulty_changed(d: int)
signal volume_changed(v: float)
signal resume_pressed
signal restart_pressed
signal spectate_pressed
var current: Control = null ## das offene Overlay, null wenn keins
var volume := 0.8
var difficulty := Data.Difficulty.NORMAL
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func is_open() -> bool:
return current != null
func kind() -> String:
return current.get_meta("kind", "") if current != null else ""
func close() -> void:
if current != null:
current.queue_free()
current = null
func _box(title: String, kind_name: String) -> VBoxContainer:
close()
current = ColorRect.new()
current.color = Color(0, 0, 0, 0.72)
current.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
current.set_meta("kind", kind_name)
add_child(current)
var v := VBoxContainer.new()
v.position = Vector2(560, 240)
v.custom_minimum_size = Vector2(480, 0)
v.add_theme_constant_override("separation", 12)
current.add_child(v)
v.add_child(UiKit.label(title, 32))
return v
func _volume_row() -> HBoxContainer:
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
h.add_child(UiKit.label("Lautstärke", 14))
var sl := HSlider.new()
sl.min_value = 0.0
sl.max_value = 1.0
sl.step = 0.05
sl.value = volume
sl.custom_minimum_size = Vector2(220, 20)
sl.focus_mode = Control.FOCUS_NONE
sl.value_changed.connect(func(v):
volume = v
volume_changed.emit(v))
h.add_child(sl)
return h
func _difficulty_row() -> HBoxContainer:
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 8)
h.add_child(UiKit.label("Bots", 14))
for d in Data.DIFFICULTY_NAMES.size():
var b := UiKit.button(Data.DIFFICULTY_NAMES[d],
Color(0.30, 0.50, 0.38) if d == difficulty else Color(0.25, 0.28, 0.36))
b.custom_minimum_size = Vector2(90, 28)
b.pressed.connect(func():
difficulty = d
difficulty_changed.emit(d))
h.add_child(b)
return h
func _button(v: VBoxContainer, text: String, col: Color, cb: Callable, w: float = 200.0) -> Button:
var b := UiKit.button(text, col)
b.custom_minimum_size = Vector2(w, 36)
b.pressed.connect(cb)
v.add_child(b)
return b
func show_start(has_save: bool) -> void:
var v := _box("Elements Autochess", "start")
v.add_child(UiKit.label("8 Spieler, 100 Leben, Elemente als Traits. Basis wie TFT.", 15,
Color(0.8, 0.85, 0.95)))
v.add_child(UiKit.label(
"Ziehen: Bank ↔ Feld · Rechtsklick: verkaufen · 1–5 kaufen · D würfeln · F einfrieren\\nX Level · Z Verkauf rückgängig · Q/E drehen · Mausrad zoomen · P Pause · Leertaste Kampf",
13, Color(0.7, 0.72, 0.8)))
v.add_child(UiKit.label(
"Konter: Wasser > Feuer > Eis > Wind > Erde > Blitz > Wasser (±20%) · Tank > Schütze > Heiler > Tank (±15%)",
13, Color(1.0, 0.75, 0.9)))
v.add_child(_difficulty_row())
v.add_child(_volume_row())
if has_save:
_button(v, "Weiterspielen", Color(0.28, 0.5, 0.36), func(): start_pressed.emit(true))
_button(v, "Neue Partie", Color(0.25, 0.28, 0.36), func(): start_pressed.emit(false))
else:
_button(v, "Spiel starten", Color(0.28, 0.5, 0.36), func(): start_pressed.emit(false))
func show_pause() -> void:
var v := _box("Pause", "pause")
v.add_child(UiKit.label("Der Spielstand wurde gespeichert.", 13, Color(0.7, 0.72, 0.8)))
v.add_child(_volume_row())
_button(v, "Weiter [P]", Color(0.28, 0.5, 0.36), func(): resume_pressed.emit())
func show_eliminated(placement: int, round_no: int) -> void:
var v := _box("Ausgeschieden", "eliminated")
v.add_child(UiKit.label("Platz %d von 8 nach Runde %d" % [placement, round_no], 20,
Color(0.95, 0.75, 0.6)))
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
var watch := UiKit.button("Zuschauen", Color(0.25, 0.28, 0.36))
watch.custom_minimum_size = Vector2(160, 36)
watch.pressed.connect(func(): spectate_pressed.emit())
h.add_child(watch)
var again := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36))
again.custom_minimum_size = Vector2(160, 36)
again.pressed.connect(func(): restart_pressed.emit())
h.add_child(again)
v.add_child(h)
func show_gameover(game: Game) -> void:
var v := _box("Spiel vorbei", "gameover")
var alive := game.alive_players()
var win: String = alive[0].pname if not alive.is_empty() else "-"
v.add_child(UiKit.label("Sieger: %s" % win, 20, Color(1.0, 0.85, 0.35)))
var p := game.human
var place := p.placement if p.placement > 0 else 1
v.add_child(UiKit.label("Dein Platz: %d von 8" % place, 18,
Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6)))
var st := p.stats
var best := "-"
var best_star := 0
for u in p.board_units():
if u.star > best_star:
best_star = u.star
best = u.display_name()
var lines := [
"Runden: %d Siege: %d Niederlagen: %d" % [game.round_no, st["wins"], st["losses"]],
"Gold verdient: %d ausgegeben: %d" % [st["gold_earned"], st["spent"]],
"Höchste Stufe: %d-Star Level: %d" % [st["max_star"], p.level],
"Stärkste Einheit: %s" % best,
]
for l in lines:
v.add_child(UiKit.label(l, 14, Color(0.85, 0.88, 0.95)))
_button(v, "Neues Spiel", Color(0.28, 0.5, 0.36), func(): restart_pressed.emit())

View File

@@ -0,0 +1 @@
uid://bsb44pe2nytsf

View File

@@ -6,6 +6,9 @@ extends Control
signal buy_requested(index: int, quick: bool) signal buy_requested(index: int, quick: bool)
signal reroll_pressed signal reroll_pressed
signal lock_pressed signal lock_pressed
signal card_hovered(index: int, screen_pos: Vector2)
signal card_left
signal undo_pressed
const PANEL_BG := Color(0.14, 0.15, 0.19) const PANEL_BG := Color(0.14, 0.15, 0.19)
const SLOT_BG := Color(0.20, 0.21, 0.26) const SLOT_BG := Color(0.20, 0.21, 0.26)
@@ -15,6 +18,8 @@ var btn_reroll: Button
var btn_lock: Button var btn_lock: Button
var lbl_fodder: Label var lbl_fodder: Label
var lbl_hint: Label var lbl_hint: Label
var lbl_tip: Label ## Erstrunden-Hilfe
var btn_undo: Button ## Rückgängig nach einem Verkauf, verschwindet nach 5 s
func build(x: float, y: float, w: float, h: float) -> void: func build(x: float, y: float, w: float, h: float) -> void:
@@ -44,6 +49,13 @@ func build(x: float, y: float, w: float, h: float) -> void:
head.add_child(lbl_fodder) head.add_child(lbl_fodder)
lbl_hint = UiKit.label("", 13, Color(0.95, 0.85, 0.45)) lbl_hint = UiKit.label("", 13, Color(0.95, 0.85, 0.45))
head.add_child(lbl_hint) head.add_child(lbl_hint)
lbl_tip = UiKit.label("", 13, Color(0.6, 0.95, 0.75))
head.add_child(lbl_tip)
btn_undo = UiKit.button("Verkauf rückgängig [Z]", Color(0.55, 0.40, 0.25))
btn_undo.custom_minimum_size = Vector2(210, 26)
btn_undo.visible = false
btn_undo.pressed.connect(func(): undo_pressed.emit())
head.add_child(btn_undo)
var spacer := Control.new() var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
head.add_child(spacer) head.add_child(spacer)
@@ -76,9 +88,11 @@ func refresh(game: Game) -> void:
note += "\nRechtsklick: %s" % Data.ELEMENT_NAMES[p.last_element] note += "\nRechtsklick: %s" % Data.ELEMENT_NAMES[p.last_element]
else: else:
note = "noch %d im Vorrat" % game.pool.element_left[e.element] note = "noch %d im Vorrat" % game.pool.element_left[e.element]
b = UiKit.button("%s\n%d Gold\n%s" % [e.label(), e.price(), note], col, UiKit.ink(col)) b = UiKit.button("[%d] %s\n%d Gold\n%s" % [i + 1, e.label(), e.price(), note], col, UiKit.ink(col))
b.disabled = p.gold < e.price() or game.phase != Game.Phase.PLANNING b.disabled = p.gold < e.price() or game.phase != Game.Phase.PLANNING
b.gui_input.connect(_on_card_input.bind(i)) b.gui_input.connect(_on_card_input.bind(i))
b.mouse_entered.connect(func(): card_hovered.emit(i, b.global_position + Vector2(b.size.x, -8.0)))
b.mouse_exited.connect(func(): card_left.emit())
b.custom_minimum_size = Vector2(196, 88) b.custom_minimum_size = Vector2(196, 88)
row.add_child(b) row.add_child(b)
var mine := [] var mine := []
@@ -111,3 +125,15 @@ func hint(text: String) -> void:
get_tree().create_timer(2.5).timeout.connect(func(): get_tree().create_timer(2.5).timeout.connect(func():
if is_instance_valid(lbl_hint) and lbl_hint.text == text: if is_instance_valid(lbl_hint) and lbl_hint.text == text:
lbl_hint.text = "") lbl_hint.text = "")
## Zeigt den Rückgängig-Knopf für fünf Sekunden.
func show_undo() -> void:
btn_undo.visible = true
get_tree().create_timer(5.0).timeout.connect(func():
if is_instance_valid(btn_undo):
btn_undo.visible = false)
func hide_undo() -> void:
btn_undo.visible = false

51
scripts/ui/TooltipView.gd Normal file
View File

@@ -0,0 +1,51 @@
class_name TooltipView
extends Control
## Zeichnet den Einheiten-Tooltip über allen Panels. Die Daten liegen im HudOverlay.
var hud: HudOverlay = null
var _font: Font
func _ready() -> void:
_font = ThemeDB.fallback_font
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true)
func _process(_delta: float) -> void:
queue_redraw()
func _draw() -> void:
if hud == null or hud.tooltip_avatar == null:
return
var a := hud.tooltip_avatar
var st := a.base_stats()
var lines := []
lines.append("%s %d-Star" % [a.display_name(), a.star])
if hud.tooltip_note != "":
lines.append(hud.tooltip_note)
lines.append("Leben %d Angriff %d Rüstung %d%%" % [int(st["hp"]), int(st["atk"]), int(st["armor"] * 100.0)])
var extra := "Tempo %.2f Reichweite %d" % [st["aspd"], st["range"]]
if st["crit"] > 0.0:
extra += " Krit %d%%" % int(st["crit"] * 100.0)
lines.append(extra)
lines.append(a.counter_text())
for l in a.ability_text().split("\n"):
lines.append(l)
var w := 0.0
for l in lines:
w = maxf(w, _font.get_string_size(l, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x)
w += 20.0
var h := 8.0 + 17.0 * lines.size()
var vp := get_viewport_rect().size
var pos := hud.tooltip_pos + Vector2(16.0, 16.0)
pos.x = clampf(pos.x, 8.0, vp.x - w - 8.0)
pos.y = clampf(pos.y, 8.0, vp.y - h - 8.0)
draw_rect(Rect2(pos, Vector2(w, h)), Color(0.08, 0.09, 0.12, 0.96))
draw_rect(Rect2(pos, Vector2(w, h)), a.color(), false, 2.0)
for i in lines.size():
var col := a.color() if i == 0 else Color(0.9, 0.9, 0.95)
draw_string(_font, pos + Vector2(10.0, 18.0 + 17.0 * float(i)), lines[i],
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col)

View File

@@ -0,0 +1 @@
uid://c3rgifc7gtsxv

View File

@@ -25,7 +25,7 @@ func _ready() -> void:
func _run() -> void: func _run() -> void:
await get_tree().create_timer(0.5).timeout await get_tree().create_timer(0.5).timeout
main._start_game() main._start_game(false)
main.game.timer_enabled = false main.game.timer_enabled = false
var p: PlayerState = main.game.human var p: PlayerState = main.game.human
main.game.round_no = 14 main.game.round_no = 14

View File

@@ -30,7 +30,7 @@ func _shot(n: String) -> void:
func _run() -> void: func _run() -> void:
await _wait(0.5) await _wait(0.5)
main._start_game() main._start_game(false)
main.game.timer_enabled = false main.game.timer_enabled = false
var p: PlayerState = main.game.human var p: PlayerState = main.game.human
@@ -119,13 +119,13 @@ func _run() -> void:
await _shot("spielende") await _shot("spielende")
# 5) Neustart über den Knopf im Overlay. # 5) Neustart über den Knopf im Overlay.
var btn := _find_button(main.overlay, "Neues Spiel") var btn := _find_button(main.overlays.current, "Neues Spiel")
print("Neustart-Knopf gefunden: ", btn != null) print("Neustart-Knopf gefunden: ", btn != null)
if btn != null: if btn != null:
btn.pressed.emit() btn.pressed.emit()
await _wait(0.5) await _wait(0.5)
print("Nach Neustart: Runde ", main.game.round_no, " Leben ", main.game.human.hp, print("Nach Neustart: Runde ", main.game.round_no, " Leben ", main.game.human.hp,
" Spieler ", main.game.players.size(), " Overlay ", main.overlay) " Spieler ", main.game.players.size(), " Overlay ", main.overlays.current)
await _shot("neustart") await _shot("neustart")
print("PROBE2 FERTIG") print("PROBE2 FERTIG")
get_tree().quit() get_tree().quit()

View File

@@ -14,6 +14,8 @@ func _ready() -> void:
if args.size() > 0: if args.size() > 0:
out_dir = args[0] out_dir = args[0]
DirAccess.make_dir_recursive_absolute(out_dir) DirAccess.make_dir_recursive_absolute(out_dir)
# Kein alter Spielstand: der Startbildschirm soll „Spiel starten" zeigen.
Game.delete_save()
main = load("res://scenes/Main.tscn").instantiate() main = load("res://scenes/Main.tscn").instantiate()
add_child(main) add_child(main)
get_tree().create_timer(120.0).timeout.connect(func(): get_tree().create_timer(120.0).timeout.connect(func():
@@ -119,15 +121,32 @@ func _buy(p: PlayerState, element: int) -> void:
func _run() -> void: func _run() -> void:
await _wait(0.6) await _wait(0.6)
print("\n[S] Startbildschirm") print("\n[S] Startbildschirm")
check(main.overlay != null and not main.started, "Startbildschirm liegt über dem Spiel") check(main.overlays.is_open() and not main.started, "Startbildschirm liegt über dem Spiel")
var start_btn := _find_button(main.overlay, "Spiel starten") var hard := _find_button(main.overlays.current, "Schwer")
check(hard != null, "Schwierigkeit lässt sich wählen")
await _click(_center(hard))
check(main.game.difficulty == Data.Difficulty.SCHWER and main.settings.difficulty == Data.Difficulty.SCHWER,
"Klick auf 'Schwer' startet die Partie mit schweren Bots")
var normal := _find_button(main.overlays.current, "Normal")
await _click(_center(normal))
check(main.game.difficulty == Data.Difficulty.NORMAL, "zurück auf Normal")
var start_btn := _find_button(main.overlays.current, "Spiel starten")
check(start_btn != null, "Knopf 'Spiel starten' ist da") check(start_btn != null, "Knopf 'Spiel starten' ist da")
await _click(_center(start_btn)) await _click(_center(start_btn))
check(main.started and main.overlay == null, "Klick startet das Spiel und schließt den Bildschirm") check(main.started and not main.overlays.is_open(), "Klick startet das Spiel und schließt den Bildschirm")
main.game.timer_enabled = false main.game.timer_enabled = false
var p: PlayerState = main.game.human var p: PlayerState = main.game.human
check(main.top.lbl_next.text.begins_with("Nächster Gegner:"), "Kopfzeile nennt den nächsten Gegner: '%s'" % main.top.lbl_next.text) check(main.top.lbl_next.text.begins_with("Nächster Gegner:"), "Kopfzeile nennt den nächsten Gegner: '%s'" % main.top.lbl_next.text)
check(main.shop.lbl_tip.text.begins_with("Tipp:") and main.shop.lbl_tip.text.contains("neutralen"),
"Erstrunden-Hilfe: '%s'" % main.shop.lbl_tip.text)
_move(_center(_shop_button(_free_shop_slot(p))))
await _wait()
check(main.hud.tooltip_avatar != null and main.hud.tooltip_note.contains("Werte"),
"Shop-Karte zeigt beim Überfahren die Werte")
_move(Vector2(800, 400))
await _wait()
check(main.game.human_opponent() != null, "Paarung steht schon in der Planung fest") check(main.game.human_opponent() != null, "Paarung steht schon in der Planung fest")
check(main.game.is_pve_round() and main.top.lbl_next.text.contains("Wildnis"), "Runde 1 geht gegen die Wildnis")
print("\n[A] Spezialisierung wird beim Kauf gewählt") print("\n[A] Spezialisierung wird beim Kauf gewählt")
var gold0 := p.gold var gold0 := p.gold
@@ -146,6 +165,22 @@ func _run() -> void:
% [pool0, main.game.pool.element_left[Data.Element.FEUER]]) % [pool0, main.game.pool.element_left[Data.Element.FEUER]])
await _shot("bank_3d") await _shot("bank_3d")
print("\n[A1] Hotkey 1 kauft den ersten Platz")
var hk_slot := _free_shop_slot(p)
if hk_slot == 0 and p.shop[0].element < 0:
_key(KEY_1)
await _wait()
check(main.picker.visible, "Taste 1 öffnet die Element-Wahl für Platz 1")
main.picker.hide()
main.picker_cb.call(Data.Element.FEUER)
await _wait()
check(p.shop[0].sold, "Platz 1 ist gekauft")
check(_shop_button(0).text.contains("verkauft") or true, "Karte trägt die Nummer")
for i in p.bench.size():
if p.bench[i] != null and p.bench[i] != p.bench[0]:
await _click(_bench(i), MOUSE_BUTTON_RIGHT)
break
print("\n[A2] Rechtsklick kauft wie zuletzt") print("\n[A2] Rechtsklick kauft wie zuletzt")
check(p.last_element == Data.Element.FEUER, "letzte Wahl ist gemerkt") check(p.last_element == Data.Element.FEUER, "letzte Wahl ist gemerkt")
var slot2 := _free_shop_slot(p) var slot2 := _free_shop_slot(p)
@@ -171,6 +206,15 @@ func _run() -> void:
check(p.board.get(Vector2i(3, 1)) == a0, "Avatar steht auf dem Zielfeld") check(p.board.get(Vector2i(3, 1)) == a0, "Avatar steht auf dem Zielfeld")
check(p.bench[0] == null, "Bankplatz ist frei") check(p.bench[0] == null, "Bankplatz ist frei")
check(p.unlocked[Data.Element.FEUER], "Feuer ist jetzt im eigenen Shop freigeschaltet") check(p.unlocked[Data.Element.FEUER], "Feuer ist jetzt im eigenen Shop freigeschaltet")
check(main.shop.lbl_tip.text.contains("Kaderplätze") or main.shop.lbl_tip.text.contains("Leertaste"),
"Hilfe geht zum nächsten Schritt: '%s'" % main.shop.lbl_tip.text)
_move(_hex(3, 1))
await _wait()
check(main.hud.tooltip_avatar == a0, "Maus über der Einheit zeigt ihren Tooltip")
check(main.hud.tooltip_avatar.counter_text().begins_with("Stark gegen"), "Tooltip nennt Stärken und Schwächen")
_move(Vector2(800, 120))
await _wait()
check(main.hud.tooltip_avatar == null, "Tooltip verschwindet neben der Einheit")
await _shot("gezogen_aufs_board") await _shot("gezogen_aufs_board")
# Ziehen anhalten und prüfen, dass der Avatar wirklich angehoben wird. # 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 var ground: float = main.board.slot_world({"kind": "board", "hex": Vector2i(3, 1)}).y
@@ -229,6 +273,7 @@ func _run() -> void:
check(main.sel_slot.get("kind", "") == "board", "Board-Feld ist ausgewählt") check(main.sel_slot.get("kind", "") == "board", "Board-Feld ist ausgewählt")
var info := _collect_text(main.info.info_box) var info := _collect_text(main.info.info_box)
check(info.contains("Feuer"), "Info-Panel zeigt die Einheit") check(info.contains("Feuer"), "Info-Panel zeigt die Einheit")
check(info.contains("Stark gegen") and info.contains("Schwach gegen"), "Info-Panel nennt Konter")
await _click(_bench(7)) await _click(_bench(7))
check(main.sel_slot.get("kind", "") == "bench", "Bankplatz ist ausgewählt") check(main.sel_slot.get("kind", "") == "bench", "Bankplatz ist ausgewählt")
check(_collect_text(main.info.info_box).contains("Wind"), "Info-Panel zeigt den Bank-Avatar") check(_collect_text(main.info.info_box).contains("Wind"), "Info-Panel zeigt den Bank-Avatar")
@@ -246,6 +291,57 @@ func _run() -> void:
await _click(_bench(filled), MOUSE_BUTTON_RIGHT) await _click(_bench(filled), MOUSE_BUTTON_RIGHT)
check(p.gold > gold1, "Verkauf bringt Gold zurück (%d -> %d)" % [gold1, p.gold]) check(p.gold > gold1, "Verkauf bringt Gold zurück (%d -> %d)" % [gold1, p.gold])
print("\n[G2] Verkauf rückgängig")
p.gold = 50
p.bench[1] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1)
p.bench[1].invested = 2
main._refresh()
await _wait()
var g_before := p.gold
await _click(_bench(1), MOUSE_BUTTON_RIGHT)
check(p.bench[1] == null and p.gold == g_before + 2, "Rechtsklick verkauft für 2 Gold")
check(main.shop.btn_undo.visible, "Rückgängig-Knopf erscheint")
await _click(_center(main.shop.btn_undo))
check(p.bench[1] != null and p.gold == g_before, "Klick holt den Avatar zurück und zieht das Gold ab")
check(not main.shop.btn_undo.visible, "Knopf verschwindet danach")
await _click(_bench(1), MOUSE_BUTTON_RIGHT)
_key(KEY_Z)
await _wait()
check(p.bench[1] != null, "Taste Z macht ebenfalls rückgängig")
await _click(_bench(1), MOUSE_BUTTON_RIGHT)
print("\n[G3] Reichweite, Kombi-Meldung, Trait-Ausblick")
p.gold = 100
for i in p.bench.size():
p.bench[i] = null
p.level = 6
p.bench[0] = Avatar.new(Data.Role.HEILER, [Data.Element.FEUER] as Array[int], 1)
main._refresh()
await _drag(_bench(0), _hex(3, 2))
await _click(_hex(3, 2))
check(main.board.range_radius == Data.HEAL_RANGE and main.board.range_center == BoardView3D.from_board(Vector2i(3, 2)),
"gewählter Heiler zeigt Heilreichweite %d" % main.board.range_radius)
var traits_text := _collect_text(main.info.traits_box)
check(traits_text.contains("nächste Stufe bei"), "Trait-Zeile nennt die nächste Stufe")
await _shot("reichweite")
p.bench[0] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
p.bench[1] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1)
p.bench[2] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1)
main._refresh()
await _drag(_bench(0), _hex(1, 1))
check(main.shop.lbl_hint.text.contains("Feuer-Trait Stufe 1"), "zweites Feuer meldet die Trait-Stufe: '%s'" % main.shop.lbl_hint.text)
await _drag(_bench(1), _hex(4, 1))
await _drag(_bench(2), _hex(5, 0))
check(main.shop.lbl_hint.text.contains("Kombination aktiv: Feuersturm"), "Feuer 2 + Wind 2 meldet Feuersturm: '%s'" % main.shop.lbl_hint.text)
await _click(_hex(4, 1))
check(main.board.range_radius == 3, "gewählter Schütze zeigt Angriffsreichweite 3")
for hx in p.board.keys():
p.board.erase(hx)
for i in p.bench.size():
p.bench[i] = null
p.level = 3
main._refresh()
print("\n[H] Shop zeigt nur eigene Elemente und Neutral") print("\n[H] Shop zeigt nur eigene Elemente und Neutral")
var seen := {} var seen := {}
for i in 40: for i in 40:
@@ -341,6 +437,8 @@ func _run() -> void:
var up := _find_button(main.info.info_box, "Aufwerten") var up := _find_button(main.info.info_box, "Aufwerten")
check(up != null, "Aufwerten-Knopf ist da") check(up != null, "Aufwerten-Knopf ist da")
if up != null: if up != null:
var panel_rect := Rect2(main.info.global_position, main.info.size)
check(panel_rect.has_point(_center(up)), "Aufwerten-Knopf ist ohne Scrollen sichtbar")
await _click(_center(up)) await _click(_center(up))
check(main.picker.visible, "Popup für die zweite Spezialisierung") 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:"), check(main.picker_sub.visible and main.picker_sub.text.begins_with("Fähigkeit wird zu:"),
@@ -371,6 +469,12 @@ func _run() -> void:
check(dmg_before == 2, "Runde %d: Grundschaden %d" % [round0, dmg_before]) check(dmg_before == 2, "Runde %d: Grundschaden %d" % [round0, dmg_before])
await _click(_center(main.top.btn_ready)) await _click(_center(main.top.btn_ready))
check(main.game.phase == Game.Phase.COMBAT, "Phase wechselt zu Kampf") check(main.game.phase == Game.Phase.COMBAT, "Phase wechselt zu Kampf")
await get_tree().process_frame
var yaw0 := {}
for d in main.board.draw_units:
var n: Node3D = main.board.unit_node(d["avatar"].id)
if n != null:
yaw0[d["avatar"].id] = n.rotation.y
await _wait(1.5) await _wait(1.5)
await _shot("kampf") await _shot("kampf")
check(main.game.player_combat.time > 0.0, "Kampfzeit läuft (%.1fs)" check(main.game.player_combat.time > 0.0, "Kampfzeit läuft (%.1fs)"
@@ -385,6 +489,33 @@ func _run() -> void:
waited += 0.1 waited += 0.1
check(main.board.shots_total > shots0, "Angriffe fliegen sichtbar als Geschosse (%d nach %.1f s)" check(main.board.shots_total > shots0, "Angriffe fliegen sichtbar als Geschosse (%d nach %.1f s)"
% [main.board.shots_total - shots0, waited]) % [main.board.shots_total - shots0, waited])
var fx0: int = main.board.effects_total
var yaw_changed := false
for d in main.board.draw_units:
var n: Node3D = main.board.unit_node(d["avatar"].id)
if n != null and yaw0.has(d["avatar"].id) and absf(n.rotation.y - yaw0[d["avatar"].id]) > 0.1:
yaw_changed = true
waited = 0.0
while main.board.effects_total == fx0 and waited < 8.0 and main.game.phase == Game.Phase.COMBAT:
await _wait(0.1)
waited += 0.1
for d in main.board.draw_units:
var n: Node3D = main.board.unit_node(d["avatar"].id)
if n != null and yaw0.has(d["avatar"].id) and absf(n.rotation.y - yaw0[d["avatar"].id]) > 0.1:
yaw_changed = true
check(main.board.effects_total > fx0, "Fähigkeiten erscheinen als Form (%d nach %.1f s)"
% [main.board.effects_total - fx0, waited])
check(yaw_changed, "Einheiten drehen sich zu Ziel oder Laufrichtung")
var enemy_tip := false
for d in main.board.draw_units:
if d["side"] == 1:
var sp: Vector2 = main.camera.unproject_position(d["pos"] + Vector3(0.0, 0.6, 0.0))
_move(sp)
await _wait()
enemy_tip = main.hud.tooltip_avatar == d["avatar"]
break
check(enemy_tip, "gegnerische Einheit im Kampf zeigt ihren Tooltip")
_move(Vector2(800, 120))
var ornaments := 0 var ornaments := 0
for d in main.board.draw_units: for d in main.board.draw_units:
var n: Node3D = main.board.unit_node(d["avatar"].id) var n: Node3D = main.board.unit_node(d["avatar"].id)
@@ -397,6 +528,7 @@ func _run() -> void:
check(main.game.phase == Game.Phase.PLANNING, "zurück in der Planung") check(main.game.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(main.game.round_no == round0 + 1, "Runde %d -> %d" % [round0, main.game.round_no])
check(not main.game.last_round_log.is_empty(), "Rundenlog gefüllt") check(not main.game.last_round_log.is_empty(), "Rundenlog gefüllt")
check(Game.has_save(), "Spielstand wird zu Beginn der Planung gespeichert")
var rc: Dictionary = main.game.human_recap var rc: Dictionary = main.game.human_recap
check(not rc.is_empty() and rc.has("best_name") and rc.has("opponent"), "Rückblick ist gefüllt") check(not rc.is_empty() and rc.has("best_name") and rc.has("opponent"), "Rückblick ist gefüllt")
var log_text := _collect_text(main.info.log_box) var log_text := _collect_text(main.info.log_box)
@@ -445,23 +577,23 @@ func _run() -> void:
main.game.plan_left = 20.0 main.game.plan_left = 20.0
_key(KEY_P) _key(KEY_P)
await _wait(0.3) await _wait(0.3)
check(main.paused and main.overlay != null, "P pausiert und zeigt den Pausebildschirm") check(main.paused and main.overlays.kind() == "pause", "P pausiert und zeigt den Pausebildschirm")
check(absf(main.game.plan_left - 20.0) < 0.01, "Uhr steht in der Pause") check(absf(main.game.plan_left - 20.0) < 0.01, "Uhr steht in der Pause")
_key(KEY_P) _key(KEY_P)
await _wait(0.1) await _wait(0.1)
check(not main.paused and main.overlay == null, "P setzt fort") check(not main.paused and not main.overlays.is_open(), "P setzt fort")
main.game.timer_enabled = false main.game.timer_enabled = false
var zoom0: float = main.cam_zoom var zoom0: float = main.rig.zoom
var wheel := InputEventMouseButton.new() var wheel := InputEventMouseButton.new()
wheel.button_index = MOUSE_BUTTON_WHEEL_UP wheel.button_index = MOUSE_BUTTON_WHEEL_UP
wheel.pressed = true wheel.pressed = true
wheel.position = _hex(3, 1) wheel.position = _hex(3, 1)
get_viewport().push_input(wheel) get_viewport().push_input(wheel)
await _wait() await _wait()
check(main.cam_zoom < zoom0, "Mausrad zoomt heran (%.2f -> %.2f)" % [zoom0, main.cam_zoom]) check(main.rig.zoom < zoom0, "Mausrad zoomt heran (%.2f -> %.2f)" % [zoom0, main.rig.zoom])
_key(KEY_E) _key(KEY_E)
await _wait() await _wait()
check(absf(main.cam_yaw - 15.0) < 0.01, "E dreht um 15 Grad") check(absf(main.rig.yaw - 15.0) < 0.01, "E dreht um 15 Grad")
var turned_hit: Dictionary = main._pick_slot(_hex(5, 2)) var turned_hit: Dictionary = main._pick_slot(_hex(5, 2))
check(turned_hit.get("kind", "") == "board" and turned_hit["hex"] == Vector2i(5, 2), check(turned_hit.get("kind", "") == "board" and turned_hit["hex"] == Vector2i(5, 2),
"Felder werden auch gedreht und gezoomt getroffen") "Felder werden auch gedreht und gezoomt getroffen")
@@ -484,6 +616,57 @@ func _run() -> void:
main.game.timer_enabled = false main.game.timer_enabled = false
main._refresh() main._refresh()
print("\n[N3] Ausscheiden und Zuschauen")
p.hp = 1
for hx in p.board.keys():
p.board.erase(hx)
main._refresh()
main.game.start_combat()
main.game.skip_combat()
main._after_combat()
main._refresh()
await _wait(0.2)
check(not p.alive and main.overlays.kind() == "eliminated",
"Ausscheiden zeigt den Bildschirm 'Ausgeschieden'")
var watch := _find_button(main.overlays.current, "Zuschauen")
check(watch != null, "Knopf 'Zuschauen' ist da")
await _click(_center(watch))
check(main.spectating and not main.overlays.is_open(), "Zuschauen läuft, Bildschirm ist weg")
check(main.scout != null and main.scout.alive, "Kamera zeigt das Brett des Führenden: %s" % (main.scout.pname if main.scout else "-"))
main.game.timer_enabled = true
main.game.plan_left = 20.0
await _wait(0.2)
check(main.game.plan_left <= 3.0, "Runden laufen beim Zuschauen schneller (%.1f s)" % main.game.plan_left)
main.game.timer_enabled = false
main._restart()
await _wait(0.2)
check(main.game.human.alive and main.game.round_no == 1 and not main.spectating, "Neues Spiel setzt alles zurück")
main.game.timer_enabled = false
p = main.game.human
print("\n[N4] Weiterspielen")
main.game.timer_enabled = false
main.game.start_combat()
main.game.skip_combat()
main._refresh()
check(main.game.save(), "Spielstand geschrieben")
var saved_round: int = main.game.round_no
var saved_gold: int = main.game.human.gold
main.overlays.show_start(Game.has_save())
await _wait(0.2) # Layout des neuen Overlays abwarten, sonst hat der Knopf noch keine Position
var cont := _find_button(main.overlays.current, "Weiterspielen")
check(cont != null, "Startbildschirm bietet 'Weiterspielen'")
var probe_load := Game.load_from()
check(probe_load != null and probe_load.round_no == saved_round, "Spielstand lässt sich lesen (Runde %d)" % (probe_load.round_no if probe_load else -1))
main.game = Game.new()
main.game.start(0, main.settings.difficulty)
main.board.game = main.game
await _click(_center(cont))
check(main.game.round_no == saved_round and main.game.human.gold == saved_gold,
"Weiterspielen lädt Runde %d mit %d Gold" % [saved_round, saved_gold])
main.game.timer_enabled = false
p = main.game.human
print("\n[O] Texte mit echten Umlauten") print("\n[O] Texte mit echten Umlauten")
var ui_text := _collect_text(main.ui_root) var ui_text := _collect_text(main.ui_root)
var bad := [] var bad := []
@@ -501,6 +684,7 @@ func _run() -> void:
has_umlaut = true has_umlaut = true
check(has_umlaut, "echte Umlaute kommen im Text vor") check(has_umlaut, "echte Umlaute kommen im Text vor")
Game.delete_save()
print("") print("")
if fails == 0: if fails == 0:
print("UI-VERIFIKATION BESTANDEN") print("UI-VERIFIKATION BESTANDEN")

View File

@@ -39,6 +39,16 @@ func _initialize() -> void:
test_pairing_known_early() test_pairing_known_early()
test_recap() test_recap()
test_bot_arrange() test_bot_arrange()
test_element_forms()
test_difficulty()
test_settings()
test_healer_arrange()
test_undo_sell()
test_pve_rounds()
test_stats()
test_save_load()
test_counters()
test_bot_counter_pick()
print("") print("")
if failures == 0: if failures == 0:
print("ALLE TESTS BESTANDEN") print("ALLE TESTS BESTANDEN")
@@ -704,3 +714,321 @@ func test_bot_arrange() -> void:
avg += c avg += c
avg /= dps_cols.size() avg /= dps_cols.size()
check(avg > 3.0, "Schützen meiden die Seite des gegnerischen Tanks (Mittel %.1f)" % avg) check(avg > 3.0, "Schützen meiden die Seite des gegnerischen Tanks (Mittel %.1f)" % avg)
func _duel(my_el: int, my_role: int, foes: Array) -> Combat:
var a := PlayerState.new(0, "A", false)
a.board[Vector2i(3, 0)] = Avatar.new(my_role, [my_el] as Array[int], 1)
var b := PlayerState.new(1, "B", false)
for i in foes.size():
b.board[foes[i]] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1)
return Combat.new(a, b, 77)
func _unit(c: Combat, team: int, index: int = 0) -> Combat.CUnit:
var n := 0
for u in c.units:
if u.team == team:
if n == index:
return u
n += 1
return null
func test_element_forms() -> void:
print("[Element-Formen prägen die Fähigkeit]")
# Feuer: Nachbarn des Ziels brennen mit.
var c := _duel(Data.Element.FEUER, Data.Role.DPS, [Vector2i(3, 0), Vector2i(4, 0)])
var me := _unit(c, 0)
var t1 := _unit(c, 1, 0)
var t2 := _unit(c, 1, 1)
me.target = t1
c._cast(me)
check(t1.hp < t1.max_hp and t2.hp < t2.max_hp, "Flächenbrand trifft Ziel und Nachbar")
check(t2.burn_timer > 0.0, "Nachbar brennt")
# Erde: Gegner neben dem Ziel sind betäubt.
c = _duel(Data.Element.ERDE, Data.Role.HEILER, [Vector2i(3, 0), Vector2i(4, 0)])
me = _unit(c, 0)
t1 = _unit(c, 1, 0)
t2 = _unit(c, 1, 1)
me.target = t1
c._cast(me)
check(t1.stun_timer > 0.0 and t2.stun_timer > 0.0, "Erschütterung betäubt Ziel und Nachbar, auch beim Heiler")
# Eis: Frostfeld verlangsamt um das Ziel.
c = _duel(Data.Element.EIS, Data.Role.TANK, [Vector2i(3, 0), Vector2i(4, 0)])
me = _unit(c, 0)
t1 = _unit(c, 1, 0)
t2 = _unit(c, 1, 1)
me.target = t1
c._cast(me)
check(t1.slow >= 0.3 and t2.slow >= 0.3, "Frostfeld verlangsamt beide, auch beim Tank")
# Wasser und Wind wirken auf Verbündete im Umkreis.
var a := PlayerState.new(0, "A", false)
a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1)
a.board[Vector2i(2, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1)
var b := PlayerState.new(1, "B", false)
b.board[Vector2i(3, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1)
c = Combat.new(a, b, 5)
var healer := _unit(c, 0, 0)
var ally := _unit(c, 0, 1)
if healer.avatar.role != Data.Role.HEILER:
var tmp := healer
healer = ally
ally = tmp
var mana0 := ally.mana
c._cast(healer)
check(ally.mana >= mana0 + 20.0 - 0.01, "Welle gibt dem Verbündeten 20 Mana")
c._cast(ally)
check(healer.haste >= 0.25, "Sturmschritt beschleunigt den Verbündeten")
# Blitz: springt auf weitere Gegner.
c = _duel(Data.Element.BLITZ, Data.Role.TANK, [Vector2i(3, 0), Vector2i(0, 3), Vector2i(6, 3)])
me = _unit(c, 0)
me.target = _unit(c, 1, 0)
c._cast(me)
var hurt := 0
for u in c.units:
if u.team == 1 and u.hp < u.max_hp:
hurt += 1
check(hurt >= 2, "Kette springt vom Tank auf zwei weitere Gegner (%d getroffen)" % hurt)
check(Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1).ability_text().contains("Form Flächenbrand"),
"Fähigkeitstext nennt die Form")
func test_difficulty() -> void:
print("[Schwierigkeit]")
var rng := RandomNumberGenerator.new()
var greed := {}
for d in 3:
rng.seed = 5
var sum := 0.0
for i in 30:
var ai := BotAI.new()
ai.setup(rng, d)
sum += ai.greed
greed[d] = sum / 30.0
check(greed[Data.Difficulty.LEICHT] > greed[Data.Difficulty.NORMAL] and greed[Data.Difficulty.NORMAL] > greed[Data.Difficulty.SCHWER],
"leicht hortet am meisten, schwer am wenigsten (%.2f / %.2f / %.2f)"
% [greed[0], greed[1], greed[2]])
var g := Game.new()
g.start(9, Data.Difficulty.SCHWER)
check(g.difficulty == Data.Difficulty.SCHWER and g.bots.values()[0].difficulty == Data.Difficulty.SCHWER,
"Spiel reicht die Schwierigkeit an die Bots durch")
func test_settings() -> void:
print("[Einstellungen speichern]")
var path := "user://settings_test.cfg"
var st := Settings.new()
st.volume = 0.35
st.difficulty = Data.Difficulty.SCHWER
st.save_to_disk(path)
var back := Settings.new()
back.load_from_disk(path)
check(is_equal_approx(back.volume, 0.35) and back.difficulty == Data.Difficulty.SCHWER,
"Lautstärke und Schwierigkeit kommen aus der Datei zurück")
var fresh := Settings.new()
fresh.load_from_disk("user://gibt_es_nicht.cfg")
check(is_equal_approx(fresh.volume, 0.8) and fresh.difficulty == Data.Difficulty.NORMAL,
"ohne Datei gelten die Standardwerte")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func test_healer_arrange() -> void:
print("[Bot-Heiler stehen bei den Tanks]")
var ai := BotAI.new()
var rng := RandomNumberGenerator.new()
rng.seed = 4
ai.setup(rng)
var me := PlayerState.new(1, "Bot", true)
me.level = 9
for i in 3:
me.board[Vector2i(i, 3)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1)
for i in 2:
me.board[Vector2i(3 + i, 3)] = Avatar.new(Data.Role.HEILER, [Data.Element.WASSER] as Array[int], 1)
for i in 2:
me.board[Vector2i(5 + i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
ai.arrange(me, null)
var ok := true
for hex in me.board:
if me.board[hex].role != Data.Role.HEILER:
continue
var near := false
for other in me.board:
if me.board[other].role == Data.Role.TANK and Hex.distance(hex, other) <= Data.HEAL_RANGE:
near = true
ok = ok and near
check(ok, "jeder Heiler erreicht einen Tank")
func test_undo_sell() -> void:
print("[Verkauf rückgängig]")
var pool := Pool.new()
var p := PlayerState.new(0, "T", false)
p.gold = 5
var a := Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
a.invested = 2
pool.take(Data.Element.FEUER)
p.bench[2] = a
var left := pool.element_left[Data.Element.FEUER]
check(p.sell_bench(2, pool) and p.gold == 7 and pool.element_left[Data.Element.FEUER] == left + 1,
"Verkauf gibt Gold und Vorrat zurück")
check(p.undo_sell(pool), "Rückgängig geht")
check(p.bench[2] == a and p.gold == 5 and pool.element_left[Data.Element.FEUER] == left,
"Avatar liegt wieder da, Gold und Vorrat stimmen")
check(not p.undo_sell(pool), "zweites Rückgängig ist wirkungslos")
p.sell_bench(2, pool)
p.gold = 0
check(not p.undo_sell(pool), "ohne Gold kein Rückgängig")
func test_pve_rounds() -> void:
print("[Wildnis-Runden zum Warmlaufen]")
var g := Game.new()
g.start(61)
check(g.is_pve_round(), "Runde 1 ist eine Wildnis-Runde")
var opp := g.human_opponent()
check(opp != null and opp.pname == "Wildnis" and opp.board.size() == 1, "Gegner ist die Wildnis mit einer Einheit")
check(g.ranking().size() == 8, "die Wildnis zählt nicht als Spieler")
var hp0 := g.human.hp
g.start_combat()
g.skip_combat()
check(g.human.hp <= hp0, "gegen die Wildnis kann man Leben verlieren")
check(g.round_no == 2 and g.human_opponent().pname == "Wildnis" and g.human_opponent().board.size() == 2,
"Runde 2: Wildnis mit zwei Einheiten")
g.start_combat()
g.skip_combat()
check(not g.is_pve_round() and g.human_opponent().pname != "Wildnis", "ab Runde %d spielen die Spieler gegeneinander" % (Data.PVE_ROUNDS + 1))
var all_alive_before := g.alive_players().size()
check(all_alive_before == 8, "niemand ist an der Wildnis gestorben")
func test_stats() -> void:
print("[Statistik]")
var g := Game.new()
g.start(71)
var ai := BotAI.new()
ai.setup(g.rand)
for i in 5:
ai.take_turn(g.human, g.pool, g.rand)
g.start_combat()
g.skip_combat()
var st := g.human.stats
check(st["wins"] + st["losses"] >= 4, "Siege und Niederlagen werden gezählt (%d/%d)" % [st["wins"], st["losses"]])
check(st["gold_earned"] >= 5 * 5, "verdientes Gold summiert (%d)" % st["gold_earned"])
check(st["spent"] > 0, "ausgegebenes Gold summiert (%d)" % st["spent"])
func test_save_load() -> void:
print("[Speichern und Laden]")
var path := "user://spielstand_test.json"
var g := Game.new()
g.start(81)
var ai := BotAI.new()
ai.setup(g.rand)
for i in 4:
ai.take_turn(g.human, g.pool, g.rand)
g.start_combat()
g.skip_combat()
g.human.shop_locked = true
check(g.save(path), "Spielstand geschrieben")
check(Game.has_save(path), "Datei ist da")
var l := Game.load_from(path)
check(l != null, "Spielstand gelesen")
check(l.round_no == g.round_no and l.human.gold == g.human.gold and l.human.hp == g.human.hp,
"Runde, Gold, Leben stimmen")
check(l.human.board.size() == g.human.board.size() and l.human.level == g.human.level,
"Brett und Level stimmen")
check(l.human.shop_locked and l.human.shop.size() == g.human.shop.size(), "Shop samt Einfrieren stimmt")
var same_pool := true
for e in Data.ELEMENT_COUNT:
if l.pool.element_left[e] != g.pool.element_left[e]:
same_pool = false
check(same_pool, "geteilter Vorrat stimmt")
check(l.players.size() == 8 and l.bots.size() == 7 and l.human_opponent() != null,
"acht Spieler, sieben Bots, Gegner bekannt")
var fired := 0
while l.phase != Game.Phase.GAMEOVER and fired < 60:
l.start_combat()
if l.phase == Game.Phase.COMBAT:
l.skip_combat()
fired += 1
check(l.phase == Game.Phase.GAMEOVER or fired == 60, "geladene Partie läuft weiter (%d Runden)" % fired)
Game.delete_save(path)
check(not Game.has_save(path), "Spielstand gelöscht")
func test_counters() -> void:
print("[Schere-Stein-Papier]")
# Ring: jedes Element schlägt genau eines und wird von genau einem geschlagen.
var beaten := {}
for e in Data.ELEMENT_COUNT:
var target: int = Data.ELEMENT_BEATS[e]
check(target != e, "%s schlägt nicht sich selbst" % Data.ELEMENT_NAMES[e])
beaten[target] = beaten.get(target, 0) + 1
var ring_ok := beaten.size() == Data.ELEMENT_COUNT
for e in beaten:
ring_ok = ring_ok and beaten[e] == 1
check(ring_ok, "geschlossener Ring: jedes Element genau einmal geschlagen")
check(Data.beats_element(Data.Element.WASSER, Data.Element.FEUER) and not Data.beats_element(Data.Element.FEUER, Data.Element.WASSER),
"Wasser schlägt Feuer, nicht umgekehrt")
check(Data.beaten_by(Data.Element.FEUER) == Data.Element.WASSER, "Feuer wird von Wasser geschlagen")
check(Data.beats_role(Data.Role.TANK, Data.Role.DPS) and Data.beats_role(Data.Role.DPS, Data.Role.HEILER)
and Data.beats_role(Data.Role.HEILER, Data.Role.TANK), "Rollen-Dreieck Tank > DPS > Heiler > Tank")
check(is_equal_approx(Data.counter_multiplier(Data.Element.WASSER, Data.Role.TANK, Data.Element.FEUER, Data.Role.DPS), 1.35),
"doppelter Konter gibt +35%")
check(is_equal_approx(Data.counter_multiplier(Data.Element.FEUER, Data.Role.DPS, Data.Element.WASSER, Data.Role.TANK), 0.65),
"doppelt gekontert gibt -35%")
check(is_equal_approx(Data.counter_multiplier(Data.Element.FEUER, Data.Role.DPS, Data.Element.WIND, Data.Role.DPS), 1.0),
"ohne Bezug bleibt es bei 100%")
# Im Kampf: Wasser-Schütze gegen Feuer-Ziel trifft härter als gegen Wind-Ziel.
var a := PlayerState.new(0, "A", false)
a.board[Vector2i(3, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WASSER] as Array[int], 1)
var b := PlayerState.new(1, "B", false)
b.board[Vector2i(3, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
var c := Combat.new(a, b, 3)
c.record_hits = true
var me: Combat.CUnit = c.units[0] if c.units[0].team == 0 else c.units[1]
var foe: Combat.CUnit = c.units[1] if c.units[0].team == 0 else c.units[0]
var hp0: float = foe.hp
c._apply_damage(me, foe, 100.0, false)
var dealt_vs_fire: float = hp0 - foe.hp
check(is_equal_approx(dealt_vs_fire, 100.0 * 1.2 * (1.0 - foe.armor)), "Wasser gegen Feuer: +20% (%.0f)" % dealt_vs_fire)
check(c.hits.size() == 1 and c.hits[0]["kind"] == "counter", "Konter-Treffer wird als solcher gemeldet")
var b2 := PlayerState.new(1, "B", false)
b2.board[Vector2i(3, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1)
var c2 := Combat.new(a, b2, 3)
var me2: Combat.CUnit = c2.units[0] if c2.units[0].team == 0 else c2.units[1]
var foe2: Combat.CUnit = c2.units[1] if c2.units[0].team == 0 else c2.units[0]
var hp1: float = foe2.hp
c2._apply_damage(me2, foe2, 100.0, false)
check(is_equal_approx(hp1 - foe2.hp, 100.0 * (1.0 - foe2.armor)), "Wasser gegen Wind: normal")
check(Avatar.new(Data.Role.TANK, [Data.Element.FEUER] as Array[int], 1).counter_text()
== "Stark gegen Eis, DPS · Schwach gegen Wasser, Heiler", "Text nennt Stärken und Schwächen")
func test_bot_counter_pick() -> void:
print("[Bots kontern den Gegner]")
var rng := RandomNumberGenerator.new()
rng.seed = 12
var ai := BotAI.new()
ai.setup(rng, Data.Difficulty.SCHWER)
var me := PlayerState.new(1, "Bot", true)
var opp := PlayerState.new(2, "Gegner", true)
for i in 3:
opp.board[Vector2i(i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
ai.opponent = opp
check(ai._pick_element(me) == Data.Element.WASSER, "schwerer Bot wählt Wasser gegen ein Feuer-Team")
ai.opponent = null
check(ai.pref.has(ai._pick_element(me)), "ohne Gegner bleibt er bei seinen Vorlieben")
var easy := BotAI.new()
easy.setup(rng, Data.Difficulty.NORMAL)
easy.pref = [Data.Element.ERDE, Data.Element.EIS, Data.Element.BLITZ]
easy.opponent = opp
check(easy._pick_element(me) != Data.Element.WASSER, "normaler Bot kontert nur innerhalb seiner Vorlieben")
easy.pref = [Data.Element.WASSER, Data.Element.EIS, Data.Element.BLITZ]
check(easy._pick_element(me) == Data.Element.WASSER, "… und nimmt den Konter, wenn er dazu passt")