first commit

This commit is contained in:
team3
2026-08-29 11:42:30 +02:00
commit 9410d18ea5
47 changed files with 4382 additions and 0 deletions

1
.godot/.gdignore Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,6 @@
[folding]
node_unfolds=[]
resource_unfolds=[]
nodes_folded=[]
animation_groups_folded=[]

View File

@@ -0,0 +1,30 @@
78098b89c88a74ebe8c20117b8754620
::res://::1787944732
anforderungen.md::TextFile::-1::1787943530::0::1::::<><><>0<>0<><>::
README.md::TextFile::-1::1787944732::0::1::::<><><>0<>0<><>::
::res://scenes/::1787944158
Main.tscn::PackedScene::-1::1787946270::0::1::::<><><>0<>0<><>::res://scripts/ui/Main.gd
::res://scripts/::1787943837
::res://scripts/core/::1787944184
Avatar.gd::GDScript::3571391337088680506::1787944282::0::1::::Avatar<>RefCounted<><>0<>0<><>::
BotAI.gd::GDScript::3217244540061411409::1787944184::0::1::::BotAI<>RefCounted<><>0<>0<><>::
Combat.gd::GDScript::3137923719046090123::1787944065::0::1::::Combat<>RefCounted<><>0<>0<><>::
Data.gd::GDScript::5827997371587166138::1787944533::0::1::::Data<>RefCounted<><>0<>0<><>::
Game.gd::GDScript::7849680363975727491::1787944110::0::1::::Game<>RefCounted<><>0<>0<><>::
Hex.gd::GDScript::3913565535274194804::1787943888::0::1::::Hex<>RefCounted<><>0<>0<><>::
PlayerState.gd::GDScript::7371028171494145347::1787944651::0::1::::PlayerState<>RefCounted<><>0<>0<><>::
Pool.gd::GDScript::8278475787586460724::1787944533::0::1::::Pool<>RefCounted<><>0<>0<><>::
ShopEntry.gd::GDScript::5330801221324202071::1787944184::0::1::::ShopEntry<>RefCounted<><>0<>0<><>::
::res://scripts/ui/::1787946270
BoardView3D.gd::GDScript::6505855525292201354::1787946197::0::1::::BoardView3D<>Node3D<><>0<>0<><>::
HudOverlay.gd::GDScript::2880024841375975216::1787946209::0::1::::HudOverlay<>Control<><>0<>0<><>::
Main.gd::GDScript::7620286773918972721::1787946270::0::1::::<>Node3D<><>0<>0<><>::
UiKit.gd::GDScript::7622585584068197695::1787946021::0::1::::UiKit<>RefCounted<><>0<>0<><>::
::res://tests/::1787945733
Probe2.gd::GDScript::7806432109624259689::1787946278::0::1::::<>Control<><>0<>0<><>::
probe2.tscn::PackedScene::-1::1787944588::0::1::::<><><>0<>0<><>::res://tests/Probe2.gd
Probe3.gd::GDScript::5187223496600362631::1787946278::0::1::::<>Control<><>0<>0<><>::
probe3.tscn::PackedScene::-1::1787945733::0::1::::<><><>0<>0<><>::res://tests/Probe3.gd
Probe.gd::GDScript::4485056788068318808::1787946278::0::1::::<>Control<><>0<>0<><>::
probe.tscn::PackedScene::-1::1787944434::0::1::::<><><>0<>0<><>::res://tests/Probe.gd
sim_test.gd::GDScript::3463191307206118779::1787944708::0::1::::<>SceneTree<><>0<>0<><>::

View File

@@ -0,0 +1,11 @@
[game_view]
select_mode=0
[editor_metadata]
executable_path="/home/arbeit/bin/godot"
[recent_files]
scenes=["res://scenes/Main.tscn"]

View File

@@ -0,0 +1,97 @@
list=[{
"base": &"RefCounted",
"class": &"Avatar",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/Avatar.gd"
}, {
"base": &"Node3D",
"class": &"BoardView3D",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/ui/BoardView3D.gd"
}, {
"base": &"RefCounted",
"class": &"BotAI",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/BotAI.gd"
}, {
"base": &"RefCounted",
"class": &"Combat",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/Combat.gd"
}, {
"base": &"RefCounted",
"class": &"Data",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/Data.gd"
}, {
"base": &"RefCounted",
"class": &"Game",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/Game.gd"
}, {
"base": &"RefCounted",
"class": &"Hex",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/Hex.gd"
}, {
"base": &"Control",
"class": &"HudOverlay",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/ui/HudOverlay.gd"
}, {
"base": &"RefCounted",
"class": &"PlayerState",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/PlayerState.gd"
}, {
"base": &"RefCounted",
"class": &"Pool",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/Pool.gd"
}, {
"base": &"RefCounted",
"class": &"ShopEntry",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/core/ShopEntry.gd"
}, {
"base": &"RefCounted",
"class": &"UiKit",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/ui/UiKit.gd"
}]

BIN
.godot/uid_cache.bin Normal file

Binary file not shown.

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# Elements Autochess
Auto-Battler nach TFT-Vorbild in Godot 4.7, in 3D.
Primitive Assets: Hexfelder sind Zylinder mit 6 Segmenten, Einheiten sind
Würfel (Tank), Kegel (DPS) und Kugeln (Heiler). Alles wird im Code erzeugt.
## Starten
godot --path . scenes/Main.tscn
## Steuern
- Shop-Karte anklicken = kaufen. Neutrale Avatare fragen dabei nach der
Spezialisierung, Element-Avatare kommen fertig.
- Die Bank sind die 9 Hexplätze vor dem eigenen Spielfeld, ebenfalls in 3D.
- Avatare per Drag & Drop verschieben: Bank -> Feld, Feld -> Bank, Feld -> Feld
und Bank -> Bank. Ist das Ziel belegt, tauschen die beiden die Plätze.
- Klick ohne Ziehen = auswählen. Info rechts, dort auch `Aufwerten`.
- Rechtsklick auf dem Feld = zurück auf die Bank, auf der Bank = verkaufen.
- Klicks treffen über einen Strahl von der Kamera auf die Feldebene.
- `D` neu würfeln, `Leertaste` Kampf starten / überspringen, `Esc` Auswahl lösen
## Regeln
| Anforderung | Umsetzung |
| --- | --- |
| 1-Star bis 3-Star, zweimal aufwertbar | `Avatar.star`, `Data.MAX_STAR` |
| Tank / DPS / Heiler | `Data.Role`, eigene Werte und Fähigkeiten |
| Spezialisierung beim Kauf | Popup beim Kauf eines neutralen Avatars |
| Weitere Spezialisierung beim Aufwerten | Popup beim Upgrade, bis zu 3 Elemente |
| Shop startet elementlos | `PlayerState.unlocked` beginnt leer |
| Aufgestellte Elemente kommen in den Shop | `PlayerState.unlock()` beim Aufstellen |
| Mein Shop zeigt nur meine Elemente und Neutral | `PlayerState.shop_elements()` je Spieler |
| Neutrale Chance -25% je aufgestelltem Element | `Data.neutral_chance()`, multiplikativ |
| Bank ist 3D am Spielfeld | `BoardView3D.bench_pos()`, 9 Hexplätze |
| Avatare zwischen Bank und Board verschieben | `PlayerState.move_slot()` |
| Drag & Drop | `Main._begin_drag()` / `_end_drag()`, Avatar klebt flach am Zeiger |
| Aufwertung zeigt, was sich ändert | Popup nennt die neue Fähigkeit, je Auswahl eine Zeile |
| Zinsen je 10 Gold bis 50 Gold | `Data.interest()`, max +5 |
| Neutral 1 Gold, Element 2 Gold | `Data.PRICE_NONE` / `PRICE_ELEMENT` |
| 1-Star Feuer + 2 Feuer = 2-Star | `Avatar.upgrade_cost()` |
| 2-Star Feuer/Wind + 3 Feuer + 3 Wind = 3-Star | `Avatar.upgrade_cost()` |
| Stats und Fähigkeiten pro Stufe | `Data.STAR_MULT`, `Avatar.ability_text()`, `Combat._cast()` |
| Elemente kombinierbar wie Traits | `Data.ELEMENT_TRAITS`, `Data.COMBO_TRAITS` |
| 5 Gold pro Runde, 1 für Sieg, mehr bei Serie | `PlayerState.round_income()` |
| 100 Leben | `Data.START_HP` |
| Begrenzte Elementmenge für alle Spieler | `Pool.element_left`, 180 pro Element |
| 7 Bots | `Game.BOT_NAMES`, `BotAI` |
| Keine Items | nicht vorhanden |
Nicht in den Anforderungen, aber nötig: die Kaderplätze wachsen mit der Runde
(`Data.team_slots()`, 3 bis 9). Der neutrale Avatar selbst ist unbegrenzt. Knapp ist
das Element, das man ihm beim Kauf gibt: das zieht einen Platz aus dem geteilten Vorrat.
Die 180 pro Element sind gemessen, nicht geraten: bei 30 war jedes Element nach
gut der Hälfte der Partie leer, bei 160 lief in 1 von 12 Partien schon um Runde 15
eines leer. Bei 180 hält der Vorrat eine ganze Partie, der Tiefstand liegt aber
noch bei rund einem Achtel.
Die Neutral-Chance ist multiplikativ (0,75 hoch Anzahl Elemente) statt in
Prozentpunkten. So verschwindet Neutral nie ganz: ohne eigene Elemente 100%,
mit einem 75%, mit sechs noch 18%.
## Elemente und Traits
Feuer, Wasser, Wind, Blitz, Erde, Eis. Stufen bei 2 / 4 / 6 Einheiten.
Kombinationen (beide Elemente ab 2 Einheiten): Feuersturm, Frost, Magnetsturm,
Magma, Sturmfront, Dampf.
## Aufbau
scripts/core/ Data, Avatar, Pool, ShopEntry, PlayerState, Hex, Combat, BotAI, Game
scripts/ui/ Main (Szene + Bedienung), BoardView3D (Arena), HudOverlay, UiKit
scenes/Main.tscn
tests/ sim_test.gd (kopflos), Probe2, Probe3, CamCal
`Combat` ist reine Logik und wird in Schritten gerechnet. Bot-Kämpfe laufen sofort
durch (`run_to_end`), der eigene Kampf wird pro Frame gesteppt.
Die Darstellung hängt nicht an der Logik: `BoardView3D` liest nur `Game`.
Lebens- und Manabalken zeichnet `HudOverlay` in 2D über die projizierten
Weltpositionen, damit sie in jeder Entfernung lesbar bleiben.
`Main._fit_camera()` rechnet Abstand und Blickpunkt so aus, dass das Spielfeld
genau in die freie Bildmitte passt. Links stehen Elemente, Einheit und Rundenlog,
rechts die Spielerliste, unten der Shop. Das läuft bei jeder Fenstergröße neu.
## Tests
Kernlogik ohne Fenster:
godot --headless --path . --script tests/sim_test.gd
UI-Durchläufe mit Screenshots (Ordner als Argument):
godot --path . tests/probe2.tscn -- /pfad/zu/screenshots # 3-Star, Spielende, Neustart
godot --path . tests/probe3.tscn -- /pfad/zu/screenshots # echte Maus- und Tastatureingaben
godot --path . tests/camcal.tscn -- /pfad/zu/screenshots # Kamerabild und Trefferprüfung
`probe3` schickt Klicks und Drags über `Viewport.push_input` wie ein Nutzer und
prüft danach den Zustand. `camcal` prüft, dass alle 28 Felder und 9 Bankplätze
unter ihrer eigenen Bildschirmposition auch getroffen werden.

30
anforderungen.md Normal file
View File

@@ -0,0 +1,30 @@
Funktionen
- Avatare kann man 2mal aufwerten
- Es gibt 1-Star bis x-Star
- Avatare gibt es als Tank, DPS und Heiler
- Beim kaufen vom neutralen Avatar wählt man eine Spezialisierung wie Feuer (also Elemente)
- Beim aufwerten kann man eine weitere Spezialisierung wählen (am Ende sowas wie Feuer+Wind+Blitz)
- Am Anfang wird der Standard Avatar im Shop (Elementlos) angeboten
- Elemente die aufgestellt wurden, werden im Shop zufällig angeboten
- 1-Star Feuer braucht 3 Feuer-Avatare für 2-Star, 2-Start Feuer-Wind braucht 3 Feuer und 3 Wind Avatare für 3-Star, usw.
- Mit jedem Level up steigen die Stats und die Fähigkeiten verändern sich
- Elemente können kombiniert werden (wie Traits) für besondere Eigenschaften für alle Avatare
- Man bekommt jede Runde 5 Gold
- 1 Gold für Win
- Mehr Gold mit Streaks
- Jeder startet mit 100 Leben
- Es kann nur eine bestimmte Menge von einem Element aus dem Shop von allen Spielern gekauft werden
- Man spielt gegen 7 weitere Bots, die das spiel simulieren
- Mein Shop zeigt nur meine Elemente und Neutral an
- Die Bank ist 3d am Spielfeld
- Avatare kann man zwischen Bank und Board verschieben
- Avatare per Drag & Drop verschieben
- Bei der Aufwertung wird mir angezeigt was sich bei einer Auswahl verändert
- Gold erzeugt zinsen je 10g bis 50g
- Neutrale kosten 1g, Elemente 2g
- Neutrale Chance verringert sich um -25% je aufgestellten Element
Kern
- Basis wie TFT
- Units sind Avatare
- Keine Items

21
project.godot Normal file
View File

@@ -0,0 +1,21 @@
; Elements Autochess - Godot 4.7
config_version=5
[application]
config/name="Elements Autochess"
run/main_scene="res://scenes/Main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
[display]
window/size/viewport_width=1600
window/size/viewport_height=900
window/stretch/mode="viewport"
window/stretch/aspect="expand"
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"

6
scenes/Main.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/ui/Main.gd" id="1"]
[node name="Main" type="Node3D"]
script = ExtResource("1")

125
scripts/core/Avatar.gd Normal file
View File

@@ -0,0 +1,125 @@
class_name Avatar
extends RefCounted
## Eine Einheit. Elemente sind leer, solange der Avatar elementlos ist.
static var _next_id := 1
var id: int
var role: int = Data.Role.DPS
var elements: Array[int] = []
var star: int = 1
var invested: int = 0 ## bezahltes Gold (für Verkaufswert)
func _init(p_role: int = Data.Role.DPS, p_elements: Array[int] = [], p_star: int = 1) -> void:
id = _next_id
_next_id += 1
role = p_role
elements = p_elements.duplicate()
star = p_star
func is_elementless() -> bool:
return elements.is_empty()
func primary_element() -> int:
return elements[0] if not elements.is_empty() else -1
func has_element(e: int) -> bool:
return elements.has(e)
func display_name() -> String:
if elements.is_empty():
return "Neutral-" + Data.ROLE_NAMES[role]
var parts := []
for e in elements:
parts.append(Data.ELEMENT_NAMES[e])
return "/".join(parts) + "-" + Data.ROLE_NAMES[role]
func color() -> Color:
if elements.is_empty():
return Data.NONE_COLOR
return Data.ELEMENT_COLORS[elements[0]]
func price() -> int:
return Data.PRICE_NONE if elements.is_empty() else Data.PRICE_ELEMENT
## Was zum nächsten Stern fehlt: {element: anzahl}. Leer = maximal.
func upgrade_cost() -> Dictionary:
if star >= Data.MAX_STAR or elements.is_empty():
return {}
var cost := {}
if star == 1:
# 3 Avatare gesamt: der Basis-Avatar zählt mit, also 2 weitere.
cost[elements[0]] = 2
else:
# 2-Star -> 3-Star: je 3 Avatare pro vorhandenem Element.
for e in elements:
cost[e] = 3
return cost
func base_stats() -> Dictionary:
var b: Dictionary = Data.ROLE_STATS[role]
var mult: float = Data.STAR_MULT[star - 1]
var ebonus := 1.0 + Data.ELEMENT_STAT_BONUS * float(maxi(0, elements.size() - 1))
return {
"hp": b["hp"] * mult * ebonus,
"atk": b["atk"] * mult * ebonus,
"armor": b["armor"],
"aspd": b["aspd"],
"range": b["range"],
"mana": b["mana"],
}
func duplicate_avatar() -> Avatar:
var a := Avatar.new(role, elements, star)
a.invested = invested
return a
## Beschreibung der Fähigkeit, abhängig von Rolle, Stern und Elementen.
func ability_text() -> String:
var s := ""
match role:
Data.Role.TANK:
s = "Schild %d für 4s" % int(base_stats()["atk"] * (3.0 + 0.5 * float(star - 1)))
if star >= 2:
s += ", spottet 3 Gegner"
if star >= 3:
s += ", 25% Reflexion"
Data.Role.DPS:
s = "%d Schaden aufs Ziel" % int(base_stats()["atk"] * (3.0 + 0.3 * float(star - 1)))
if star == 2:
s += " + Splash auf Nachbarn"
elif star >= 3:
s += " + Fläche (Radius 2)"
Data.Role.HEILER:
var amount := int(base_stats()["atk"] * (3.0 - 0.25 * float(star - 1)))
if star == 1:
s = "heilt schwächsten Verbündeten um %d" % amount
elif star == 2:
s = "heilt 3 Verbündete um %d" % amount
else:
s = "heilt alle um %d und schildet" % amount
var extra := []
for i in elements.size():
var scale := "" if i == 0 else " (halb)"
match elements[i]:
Data.Element.FEUER: extra.append("Brand" + scale)
Data.Element.WASSER: extra.append("Mana + stärkere Heilung" + scale)
Data.Element.WIND: extra.append("Tempo-Schub" + scale)
Data.Element.BLITZ: extra.append("Kettenblitz" + scale)
Data.Element.ERDE: extra.append("Betäubung" + scale)
Data.Element.EIS: extra.append("Verlangsamung" + scale)
if not extra.is_empty():
s += "\nElemente: " + ", ".join(extra)
return s

View File

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

155
scripts/core/BotAI.gd Normal file
View File

@@ -0,0 +1,155 @@
class_name BotAI
extends RefCounted
## Einfache Bot-Strategie: bevorzugte Elemente sammeln, Board füllen, aufwerten.
var pref: Array[int] = []
var greed: float = 0.5 ## wie stark der Bot Gold hortet
func setup(rand: RandomNumberGenerator) -> void:
var all: Array[int] = []
for e in Data.ELEMENT_COUNT:
all.append(e)
# Bewusst mit dem Spiel-RNG mischen, nicht mit Array.shuffle(),
# damit ein Seed dieselbe Partie ergibt.
for i in range(all.size() - 1, 0, -1):
var j := rand.randi_range(0, i)
var tmp := all[i]
all[i] = all[j]
all[j] = tmp
pref = [all[0], all[1], all[2]]
greed = rand.randf_range(0.3, 0.8)
func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> void:
_do_upgrades(p, pool)
_do_buys(p, pool, rand, round_no)
_do_placements(p, pool, round_no)
_do_upgrades(p, pool)
var keep := int(lerpf(2.0, 18.0, greed))
if p.gold > keep + Data.REROLL_COST and rand.randf() < 0.6:
p.reroll(pool, rand)
_do_buys(p, pool, rand, round_no)
_do_placements(p, pool, round_no)
_do_upgrades(p, pool)
_free_bench(p, pool)
func _wants(entry: ShopEntry, p: PlayerState) -> bool:
if entry.element < 0:
# Neutral wird beim Kauf spezialisiert, also immer brauchbar.
return true
if pref.has(entry.element):
return true
# Elemente, die schon auf dem Board sind, sind immer Futter.
return p.trait_counts().has(entry.element)
func _do_buys(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> void:
for i in p.shop.size():
var e: ShopEntry = p.shop[i]
if e.sold:
continue
if p.free_bench_slot() < 0:
return
if not _wants(e, p):
continue
if p.gold - e.price() < 0:
continue
p.buy(i, pool, _pick_element(p) if e.element < 0 else e.element)
func _do_placements(p: PlayerState, pool: Pool, round_no: int) -> void:
while p.can_place(round_no):
var idx := _pick_bench_for_board(p)
if idx < 0:
return
var a: Avatar = p.bench[idx]
var hex := _pick_hex(p, a.role)
if hex.x < 0:
return
if not p.place(idx, hex, round_no):
return
## Zuerst Elemente aufstellen, die noch gar nicht im Team sind.
## Alles Weitere bleibt als Futter auf der Bank.
func _pick_bench_for_board(p: PlayerState) -> int:
var counts := p.trait_counts()
var best := -1
var best_score := -1
for i in p.bench.size():
var a = p.bench[i]
if a == null:
continue
var score := 0
if pref.has(a.elements[0]):
score = 3 if counts.get(a.elements[0], 0) == 0 else 1
else:
score = 2 if counts.get(a.elements[0], 0) == 0 else 0
if score > best_score:
best_score = score
best = i
return best if best_score > 0 else -1
func _pick_element(p: PlayerState) -> int:
var counts := p.trait_counts()
var best := pref[0]
var best_c := -1
for e in pref:
var c: int = counts.get(e, 0)
if c > best_c and c < 6:
best_c = c
best = e
return best
func _pick_hex(p: PlayerState, role: int) -> Vector2i:
var rows: Array
match role:
Data.Role.TANK: rows = [0, 1, 2, 3]
Data.Role.DPS: rows = [2, 1, 3, 0]
_: rows = [3, 2, 1, 0]
var cols := [3, 2, 4, 1, 5, 0, 6]
for r in rows:
for c in cols:
var h := Vector2i(c, r)
if not p.board.has(h):
return h
return Vector2i(-1, -1)
func _do_upgrades(p: PlayerState, pool: Pool) -> void:
var again := true
while again:
again = false
for hex in p.board.keys():
if p.can_upgrade(hex):
var options := p.available_new_elements(hex)
var choice := -1
for e in pref:
if options.has(e):
choice = e
break
if choice < 0 and not options.is_empty():
choice = options[0]
if choice >= 0 and p.upgrade(hex, choice, pool):
again = true
break
## Bank leeren, wenn sie voll ist und nichts mehr gebraucht wird.
func _free_bench(p: PlayerState, pool: Pool) -> void:
if p.free_bench_slot() >= 0:
return
var counts := p.trait_counts()
for i in p.bench.size():
var a = p.bench[i]
if a == null:
continue
if counts.get(a.elements[0], 0) == 0 and not pref.has(a.elements[0]):
p.sell_bench(i, pool)
return
p.sell_bench(0, pool)

View File

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

477
scripts/core/Combat.gd Normal file
View File

@@ -0,0 +1,477 @@
class_name Combat
extends RefCounted
## Auto-Kampf zweier Aufstellungen auf einem 7x8-Hexfeld.
## Rein logisch und schrittweise: Bot-Kämpfe laufen sofort durch,
## der Kampf des Spielers wird pro Frame gesteppt.
const ARENA_COLS := 7
const ARENA_ROWS := 8
const MOVE_TIME := 0.32
const TIME_LIMIT := 35.0
const CRIT_MULT := 1.5
const MANA_PER_ATTACK := 10.0
const MANA_PER_HIT := 5.0
class CUnit extends RefCounted:
var avatar: Avatar
var team: int
var pos: Vector2i
var hp: float
var max_hp: float
var atk: float
var armor: float
var aspd: float
var atk_range: int
var mana: float
var max_mana: float
var mana_mult: float = 1.0
var crit: float = 0.0
var attack_timer: float = 0.0
var move_timer: float = 0.0
var alive: bool = true
var shield: float = 0.0
var shield_timer: float = 0.0
var burn_dps: float = 0.0
var burn_timer: float = 0.0
var slow: float = 0.0
var slow_timer: float = 0.0
var haste: float = 0.0
var haste_timer: float = 0.0
var stun_timer: float = 0.0
var reflect: float = 0.0
var reflect_timer: float = 0.0
var taunt_by = null
var taunt_timer: float = 0.0
var target = null
var cast_flash: float = 0.0
var hit_flash: float = 0.0
func effective_aspd() -> float:
return maxf(0.1, aspd * (1.0 + haste) * (1.0 - slow))
var units: Array = []
var time: float = 0.0
var finished: bool = false
var winner: int = -1 ## 0 = Team A, 1 = Team B, -1 = unentschieden
var survivors: int = 0
var events: Array = [] ## kurze Textmeldungen für das Log
var _mods := [{}, {}]
var _occ := {}
var _rand := RandomNumberGenerator.new()
func _init(a: PlayerState, b: PlayerState, seed_value: int = 0) -> void:
_rand.seed = seed_value if seed_value != 0 else randi()
_mods[0] = _build_mods(a)
_mods[1] = _build_mods(b)
_spawn(a, 0)
_spawn(b, 1)
_apply_start_effects()
# --- Aufbau ----------------------------------------------------------------
func _build_mods(p: PlayerState) -> Dictionary:
var counts := p.trait_counts()
var m := {
"atk": 1.0, "hp": 1.0, "aspd": 1.0, "mana": 1.0,
"crit": 0.0, "ice": 0.0, "combos": {},
}
for e in counts:
var tier := Data.trait_tier(e, counts[e])
if tier <= 0:
continue
var v: float = Data.ELEMENT_TRAITS[e]["values"][tier - 1]
match e:
Data.Element.FEUER: m["atk"] += v
Data.Element.WASSER: m["mana"] += v
Data.Element.WIND: m["aspd"] += v
Data.Element.BLITZ: m["crit"] += v
Data.Element.ERDE: m["hp"] += v
Data.Element.EIS: m["ice"] = maxf(m["ice"], v)
for c in p.active_combos():
m["combos"][c["key"]] = true
return m
func _spawn(p: PlayerState, team: int) -> void:
var m: Dictionary = _mods[team]
for hex in p.board:
var a: Avatar = p.board[hex]
var s := a.base_stats()
var u := CUnit.new()
u.avatar = a
u.team = team
u.pos = _arena_pos(hex, team)
u.max_hp = s["hp"] * m["hp"]
u.hp = u.max_hp
u.atk = s["atk"] * m["atk"]
u.armor = s["armor"]
u.aspd = s["aspd"] * m["aspd"]
u.atk_range = s["range"]
u.max_mana = s["mana"]
u.mana = 0.0
u.mana_mult = m["mana"]
u.crit = m["crit"]
if _occ.has(u.pos):
u.pos = _free_near(u.pos)
_occ[u.pos] = u
units.append(u)
static func _arena_pos(hex: Vector2i, team: int) -> Vector2i:
if team == 0:
return Vector2i(hex.x, 4 + hex.y)
return Vector2i(ARENA_COLS - 1 - hex.x, 3 - hex.y)
func _free_near(p: Vector2i) -> Vector2i:
for n in Hex.neighbors(p):
if _in_arena(n) and not _occ.has(n):
return n
return p
static func _in_arena(p: Vector2i) -> bool:
return p.x >= 0 and p.x < ARENA_COLS and p.y >= 0 and p.y < ARENA_ROWS
func _apply_start_effects() -> void:
for team in 2:
if _mods[team]["combos"].has("frost"):
for u in units:
if u.team != team:
u.slow = maxf(u.slow, 0.15)
u.slow_timer = 999.0
# --- Simulation ------------------------------------------------------------
func step(dt: float) -> void:
if finished:
return
time += dt
for u in units:
if u.alive:
_tick_status(u, dt)
for u in units:
if u.alive and u.stun_timer <= 0.0:
_act(u, dt)
_check_end()
func run_to_end(dt: float = 0.05) -> void:
var guard := 0
while not finished and guard < 5000:
step(dt)
guard += 1
if not finished:
_resolve_by_hp()
func _tick_status(u: CUnit, dt: float) -> void:
if u.shield_timer > 0.0:
u.shield_timer -= dt
if u.shield_timer <= 0.0:
u.shield = 0.0
if u.burn_timer > 0.0:
u.burn_timer -= dt
_apply_damage(null, u, u.burn_dps * dt, true)
if u.burn_timer <= 0.0:
u.burn_dps = 0.0
if u.slow_timer > 0.0:
u.slow_timer -= dt
if u.slow_timer <= 0.0:
u.slow = 0.0
if u.haste_timer > 0.0:
u.haste_timer -= dt
if u.haste_timer <= 0.0:
u.haste = 0.0
if u.reflect_timer > 0.0:
u.reflect_timer -= dt
if u.reflect_timer <= 0.0:
u.reflect = 0.0
if u.stun_timer > 0.0:
u.stun_timer -= dt
if u.taunt_timer > 0.0:
u.taunt_timer -= dt
if u.taunt_timer <= 0.0:
u.taunt_by = null
u.cast_flash = maxf(0.0, u.cast_flash - dt)
u.hit_flash = maxf(0.0, u.hit_flash - dt)
func _act(u: CUnit, dt: float) -> void:
var t = _pick_target(u)
if t == null:
return
u.target = t
var d := Hex.distance(u.pos, t.pos)
if d <= u.atk_range:
u.attack_timer -= dt * u.effective_aspd()
if u.attack_timer <= 0.0:
u.attack_timer += 1.0
_basic_attack(u, t)
else:
u.move_timer -= dt
if u.move_timer <= 0.0:
u.move_timer = MOVE_TIME
_step_towards(u, t)
func _pick_target(u: CUnit):
if u.taunt_by != null and u.taunt_by.alive and u.taunt_timer > 0.0:
return u.taunt_by
if u.target != null and u.target.alive:
return u.target
return _nearest_enemy(u)
func _nearest_enemy(u: CUnit, exclude = null):
var best = null
var best_d := 999
for o in units:
if not o.alive or o.team == u.team or o == exclude:
continue
var d := Hex.distance(u.pos, o.pos)
if d < best_d:
best_d = d
best = o
return best
func _step_towards(u: CUnit, t: CUnit) -> void:
var best := u.pos
var best_d := Hex.distance(u.pos, t.pos)
for n in Hex.neighbors(u.pos):
if not _in_arena(n) or _occ.has(n):
continue
var d := Hex.distance(n, t.pos)
if d < best_d:
best_d = d
best = n
if best != u.pos:
_occ.erase(u.pos)
u.pos = best
_occ[u.pos] = u
# --- Angriff und Schaden ---------------------------------------------------
func _basic_attack(u: CUnit, t: CUnit) -> void:
var dmg := u.atk
if u.crit > 0.0 and _rand.randf() < u.crit:
dmg *= CRIT_MULT
_apply_damage(u, t, dmg, false)
var ice: float = _mods[u.team]["ice"]
if ice > 0.0:
t.slow = maxf(t.slow, ice)
t.slow_timer = maxf(t.slow_timer, 2.0)
if _mods[u.team]["combos"].has("sturmfront"):
var second = _nearest_enemy(u, t)
if second != null:
_apply_damage(u, second, dmg * 0.5, false)
u.mana += MANA_PER_ATTACK * u.mana_mult
if u.mana >= u.max_mana:
_cast(u)
func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void:
if not tgt.alive or amount <= 0.0:
return
var dmg := amount if is_true else amount * (1.0 - tgt.armor)
if tgt.shield > 0.0:
var absorbed: float = minf(tgt.shield, dmg)
tgt.shield -= absorbed
dmg -= absorbed
if dmg > 0.0:
tgt.hp -= dmg
tgt.hit_flash = 0.12
tgt.mana += MANA_PER_HIT * tgt.mana_mult
if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0:
_cast(tgt)
if src != null and dmg > 0.0:
if tgt.reflect > 0.0:
src.hp -= dmg * tgt.reflect
if src.hp <= 0.0:
_kill(src)
if _mods[tgt.team]["combos"].has("magma"):
src.hp -= dmg * 0.20
if src.hp <= 0.0:
_kill(src)
if _mods[src.team]["combos"].has("dampf"):
src.hp = minf(src.max_hp, src.hp + dmg * 0.18)
if tgt.hp <= 0.0:
_kill(tgt)
func _kill(u: CUnit) -> void:
if not u.alive:
return
u.alive = false
u.hp = 0.0
_occ.erase(u.pos)
events.append(u.avatar.display_name() + " fällt")
func _heal(u: CUnit, amount: float) -> void:
if u.alive:
u.hp = minf(u.max_hp, u.hp + amount)
# --- Fähigkeiten ----------------------------------------------------------
func _cast(u: CUnit) -> void:
u.mana = 0.0
u.cast_flash = 0.35
var elems: Array = u.avatar.elements
var heal_bonus := 0.0
for i in elems.size():
if elems[i] == Data.Element.WASSER:
heal_bonus += 0.20 * (1.0 if i == 0 else 0.5)
var hit_enemies: Array = []
var main_target = u.target if (u.target != null and u.target.alive) else _nearest_enemy(u)
match u.avatar.role:
Data.Role.TANK:
var sh := u.atk * (3.0 + 0.5 * float(u.avatar.star - 1)) * (1.0 + heal_bonus)
u.shield = maxf(u.shield, sh)
u.shield_timer = 4.0
if u.avatar.star >= 2:
var n := 0
for o in units:
if o.alive and o.team != u.team and Hex.distance(o.pos, u.pos) <= 2 and n < 3:
o.taunt_by = u
o.taunt_timer = 2.0
o.target = u
n += 1
if u.avatar.star >= 3:
u.reflect = 0.25
u.reflect_timer = 4.0
Data.Role.DPS:
if main_target == null:
return
var base := u.atk * (3.0 + 0.3 * float(u.avatar.star - 1))
_apply_damage(u, main_target, base, false)
hit_enemies.append(main_target)
if u.avatar.star == 2:
for n in Hex.neighbors(main_target.pos):
var o = _occ.get(n)
if o != null and o.alive and o.team != u.team:
_apply_damage(u, o, u.atk * 1.5, false)
hit_enemies.append(o)
elif u.avatar.star >= 3:
for o in units:
if o.alive and o.team != u.team and o != main_target \
and Hex.distance(o.pos, main_target.pos) <= 2:
_apply_damage(u, o, u.atk * 2.0, false)
hit_enemies.append(o)
Data.Role.HEILER:
var amount := u.atk * (3.0 - 0.25 * float(u.avatar.star - 1)) * (1.0 + heal_bonus)
var allies := _allies_by_missing_hp(u.team)
var count := 1
if u.avatar.star == 2:
count = 3
elif u.avatar.star >= 3:
count = allies.size()
for i in mini(count, allies.size()):
_heal(allies[i], amount)
if u.avatar.star >= 3:
allies[i].shield = maxf(allies[i].shield, u.atk * 1.5 * (1.0 + heal_bonus))
allies[i].shield_timer = 3.0
_apply_element_effects(u, main_target, hit_enemies)
func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void:
var elems: Array = u.avatar.elements
var combos: Dictionary = _mods[u.team]["combos"]
if combos.has("feuersturm"):
for o in hit_enemies:
o.burn_dps = maxf(o.burn_dps, u.atk * 0.4)
o.burn_timer = maxf(o.burn_timer, 3.0)
if combos.has("magnetsturm") and main_target != null and main_target.alive:
main_target.stun_timer = maxf(main_target.stun_timer, 0.6)
for i in elems.size():
var scale := 1.0 if i == 0 else 0.5
match elems[i]:
Data.Element.FEUER:
for o in hit_enemies:
if o.alive:
o.burn_dps = maxf(o.burn_dps, u.atk * 0.6 * scale)
o.burn_timer = maxf(o.burn_timer, 3.0)
Data.Element.WASSER:
u.mana += 15.0 * scale
Data.Element.WIND:
u.haste = maxf(u.haste, 0.30 * scale)
u.haste_timer = 3.0
Data.Element.BLITZ:
var chained := 0
for o in units:
if chained >= 2:
break
if o.alive and o.team != u.team and o != main_target:
_apply_damage(u, o, u.atk * 0.8 * scale, false)
chained += 1
Data.Element.ERDE:
if main_target != null and main_target.alive:
main_target.stun_timer = maxf(main_target.stun_timer, 0.75 * scale)
Data.Element.EIS:
if main_target != null and main_target.alive:
main_target.slow = maxf(main_target.slow, 0.30 * scale)
main_target.slow_timer = maxf(main_target.slow_timer, 3.0)
func _allies_by_missing_hp(team: int) -> Array:
var list := []
for o in units:
if o.alive and o.team == team:
list.append(o)
list.sort_custom(func(a, b): return (a.hp / a.max_hp) < (b.hp / b.max_hp))
return list
# --- Ende ------------------------------------------------------------------
func alive_count(team: int) -> int:
var n := 0
for u in units:
if u.alive and u.team == team:
n += 1
return n
func _check_end() -> void:
var a := alive_count(0)
var b := alive_count(1)
if a == 0 or b == 0:
finished = true
if a == b:
winner = -1
survivors = 0
else:
winner = 0 if a > b else 1
survivors = maxi(a, b)
return
if time >= TIME_LIMIT:
_resolve_by_hp()
func _resolve_by_hp() -> void:
finished = true
var frac := [0.0, 0.0]
for u in units:
if u.alive:
frac[u.team] += u.hp / u.max_hp
if absf(frac[0] - frac[1]) < 0.25:
winner = -1
survivors = 0
else:
winner = 0 if frac[0] > frac[1] else 1
survivors = alive_count(winner)

View File

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

161
scripts/core/Data.gd Normal file
View File

@@ -0,0 +1,161 @@
class_name Data
extends RefCounted
## Statische Spieldaten: Elemente, Rollen, Basiswerte, Traits, Preise.
enum Element { FEUER, WASSER, WIND, BLITZ, ERDE, EIS }
enum Role { TANK, DPS, HEILER }
const ELEMENT_COUNT := 6
const ELEMENT_NAMES := ["Feuer", "Wasser", "Wind", "Blitz", "Erde", "Eis"]
const ELEMENT_COLORS := [
Color(0.92, 0.31, 0.16),
Color(0.18, 0.52, 0.90),
Color(0.48, 0.85, 0.60),
Color(0.96, 0.84, 0.22),
Color(0.62, 0.45, 0.24),
Color(0.66, 0.92, 0.98),
]
const NONE_COLOR := Color(0.62, 0.62, 0.66)
const ROLE_NAMES := ["Tank", "DPS", "Heiler"]
const ROLE_SHORT := ["T", "D", "H"]
# Basiswerte auf 1-Star.
const ROLE_STATS := {
Role.TANK: {"hp": 700.0, "atk": 45.0, "armor": 0.40, "aspd": 0.60, "range": 1, "mana": 80.0},
Role.DPS: {"hp": 450.0, "atk": 65.0, "armor": 0.18, "aspd": 0.80, "range": 3, "mana": 100.0},
Role.HEILER: {"hp": 520.0, "atk": 38.0, "armor": 0.22, "aspd": 0.70, "range": 3, "mana": 90.0},
}
# Multiplikator pro Sternstufe (Index = star - 1).
const STAR_MULT := [1.0, 1.8, 3.24]
const MAX_STAR := 3
# Jedes zusätzliche Element gibt einen kleinen Grundwert-Bonus.
const ELEMENT_STAT_BONUS := 0.06
# --- Oekonomie -------------------------------------------------------------
const START_HP := 100
const BASE_INCOME := 5
const WIN_GOLD := 1
const REROLL_COST := 2
const PRICE_NONE := 1
const PRICE_ELEMENT := 2
## Zinsen: je 10 Gold ein Gold dazu, gedeckelt bei 50 Gold.
const INTEREST_STEP := 10
const INTEREST_MAX := 5
const SHOP_SLOTS := 5
## Je aufgestelltem Element wird ein Shop-Platz zu 25% seltener neutral.
## Multiplikativ, damit Neutral nie ganz verschwindet.
const NEUTRAL_FALLOFF := 0.75
const BENCH_SLOTS := 9
const START_GOLD := 4
# --- Geteilter Pool --------------------------------------------------------
## Alle 8 Spieler teilen sich diesen Vorrat pro Element. Ein 3-Star bindet
## allein 9 Stück, dazu kommt was auf Bänken und Brettern liegt.
## Bei 180 hält der Vorrat eine ganze Partie und kann im Endspiel trotzdem knapp werden.
const POOL_PER_ELEMENT := 180
# --- Board -----------------------------------------------------------------
const BOARD_COLS := 7
const BOARD_ROWS := 4
const MAX_TEAM_SLOTS := 9
# --- Element-Traits --------------------------------------------------------
# steps = Anzahl aufgestellter Einheiten mit diesem Element.
const ELEMENT_TRAITS := {
Element.FEUER: {
"steps": [2, 4, 6],
"desc": ["+12% Angriff", "+26% Angriff", "+48% Angriff"],
"values": [0.12, 0.26, 0.48],
},
Element.WASSER: {
"steps": [2, 4, 6],
"desc": ["+20% Mana", "+40% Mana", "+70% Mana"],
"values": [0.20, 0.40, 0.70],
},
Element.WIND: {
"steps": [2, 4, 6],
"desc": ["+10% Tempo", "+22% Tempo", "+40% Tempo"],
"values": [0.10, 0.22, 0.40],
},
Element.BLITZ: {
"steps": [2, 4, 6],
"desc": ["+15% Krit", "+30% Krit", "+55% Krit"],
"values": [0.15, 0.30, 0.55],
},
Element.ERDE: {
"steps": [2, 4, 6],
"desc": ["+12% Leben", "+26% Leben", "+48% Leben"],
"values": [0.12, 0.26, 0.48],
},
Element.EIS: {
"steps": [2, 4, 6],
"desc": ["Treffer -12% Tempo", "Treffer -22% Tempo", "Treffer -35% Tempo"],
"values": [0.12, 0.22, 0.35],
},
}
# --- Kombi-Traits ----------------------------------------------------------
# Aktiv, wenn beide Elemente mindestens COMBO_MIN Einheiten haben.
const COMBO_MIN := 2
const COMBO_TRAITS := [
{"a": Element.FEUER, "b": Element.WIND, "key": "feuersturm", "name": "Feuersturm", "desc": "Fähigkeiten setzen in Brand"},
{"a": Element.WASSER, "b": Element.EIS, "key": "frost", "name": "Frost", "desc": "Gegner starten 15% langsamer"},
{"a": Element.BLITZ, "b": Element.ERDE, "key": "magnetsturm","name": "Magnetsturm", "desc": "Fähigkeiten betäuben 0.6s"},
{"a": Element.FEUER, "b": Element.ERDE, "key": "magma", "name": "Magma", "desc": "20% Schaden reflektiert"},
{"a": Element.WIND, "b": Element.BLITZ, "key": "sturmfront", "name": "Sturmfront", "desc": "Angriffe springen auf ein 2. Ziel"},
{"a": Element.WASSER, "b": Element.FEUER, "key": "dampf", "name": "Dampf", "desc": "18% Lebensraub"},
]
static func element_name(e: int) -> String:
return ELEMENT_NAMES[e]
static func element_color(e: int) -> Color:
return ELEMENT_COLORS[e]
static func role_name(r: int) -> String:
return ROLE_NAMES[r]
## Aktive Stufe eines Element-Traits (0 = inaktiv, sonst 1..3).
static func trait_tier(element: int, count: int) -> int:
var steps: Array = ELEMENT_TRAITS[element]["steps"]
var tier := 0
for i in steps.size():
if count >= steps[i]:
tier = i + 1
return tier
## Teamgröße wächst mit der Runde.
static func team_slots(round_no: int) -> int:
return mini(3 + int(float(round_no - 1) / 2.0), MAX_TEAM_SLOTS)
## Wahrscheinlichkeit, dass ein Shop-Platz einen neutralen Avatar zeigt.
## Ohne eigene Elemente gibt es nur Neutral.
static func neutral_chance(unlocked_count: int) -> float:
return pow(NEUTRAL_FALLOFF, float(unlocked_count))
## Zinsen auf das gehaltene Gold.
static func interest(gold: int) -> int:
return mini(INTEREST_MAX, int(float(gold) / float(INTEREST_STEP)))
## Streak-Bonusgold (Sieg- oder Niederlagenserie).
static func streak_gold(streak: int) -> int:
var s := absi(streak)
if s >= 5:
return 3
if s == 4:
return 2
if s == 3:
return 1
return 0

1
scripts/core/Data.gd.uid Normal file
View File

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

210
scripts/core/Game.gd Normal file
View File

@@ -0,0 +1,210 @@
class_name Game
extends RefCounted
## Rundenlogik: 8 Spieler (1 Mensch, 7 Bots), geteilter Pool, Paarungen, Kämpfe.
enum Phase { PLANNING, COMBAT, RESULT, GAMEOVER }
const BOT_NAMES := [
"Ignis", "Nerea", "Zephyr", "Volt", "Terra", "Frost", "Umbra",
]
var players: Array = []
var pool: Pool
var rand := RandomNumberGenerator.new()
var round_no: int = 0
var phase: int = Phase.PLANNING
var human: PlayerState
var bots := {} ## pid -> BotAI
var pairings: Array = [] ## [{a, b, ghost}]
var player_combat: Combat = null
var pending: Array = [] ## Ergebnisse der Bot-Kämpfe
var last_round_log: Array = []
func start(seed_value: int = 0) -> void:
rand.seed = seed_value if seed_value != 0 else randi()
pool = Pool.new()
players.clear()
human = PlayerState.new(0, "Du", false)
players.append(human)
for i in BOT_NAMES.size():
var b := PlayerState.new(i + 1, BOT_NAMES[i], true)
players.append(b)
var ai := BotAI.new()
ai.setup(rand)
bots[b.pid] = ai
round_no = 1
for p in players:
p.roll_shop(pool, rand)
_run_bot_turns()
phase = Phase.PLANNING
func alive_players() -> Array:
var out := []
for p in players:
if p.alive:
out.append(p)
return out
# --- Planungsphase ---------------------------------------------------------
func _run_bot_turns() -> void:
for p in players:
if p.is_bot and p.alive:
bots[p.pid].take_turn(p, pool, rand, round_no)
# --- Kampfphase ------------------------------------------------------------
func start_combat() -> void:
if phase != Phase.PLANNING:
return
pairings = _make_pairings()
pending.clear()
player_combat = null
for pr in pairings:
if pr["a"] == human or pr["b"] == human:
player_combat = Combat.new(pr["a"], pr["b"], rand.randi())
pending.append({"pair": pr, "combat": player_combat})
else:
var c := Combat.new(pr["a"], pr["b"], rand.randi())
c.run_to_end()
pending.append({"pair": pr, "combat": c})
phase = Phase.COMBAT
if player_combat == null:
resolve_round()
func update_combat(dt: float) -> void:
if phase != Phase.COMBAT or player_combat == null:
return
player_combat.step(dt)
if player_combat.finished:
resolve_round()
func skip_combat() -> void:
if phase == Phase.COMBAT and player_combat != null:
player_combat.run_to_end()
resolve_round()
func _make_pairings() -> Array:
var alive := alive_players()
alive.shuffle()
var out := []
var i := 0
while i + 1 < alive.size():
out.append({"a": alive[i], "b": alive[i + 1], "ghost": false})
i += 2
if i < alive.size():
# Ungerade Anzahl: der Rest kämpft gegen ein Abbild eines Gegners.
var lone: PlayerState = alive[i]
var others := alive.filter(func(p): return p != lone)
if others.is_empty():
return out
var src: PlayerState = others[rand.randi_range(0, others.size() - 1)]
out.append({"a": lone, "b": _make_ghost(src), "ghost": true})
return out
func _make_ghost(src: PlayerState) -> PlayerState:
var g := PlayerState.new(-1, src.pname + " (Abbild)", true)
for hex in src.board:
g.board[hex] = src.board[hex]
return g
# --- Auswertung ------------------------------------------------------------
func resolve_round() -> void:
last_round_log.clear()
for item in pending:
var pr: Dictionary = item["pair"]
var c: Combat = item["combat"]
if not c.finished:
c.run_to_end()
var a: PlayerState = pr["a"]
var b: PlayerState = pr["b"]
var dmg := 3 + c.survivors
if c.winner == 0:
_apply_result(a, b, dmg, pr["ghost"])
elif c.winner == 1:
_apply_result(b, a, dmg, pr["ghost"])
else:
a.register_result(false)
a.take_damage(3)
if not pr["ghost"]:
b.register_result(false)
b.take_damage(3)
last_round_log.append("%s vs %s: unentschieden" % [a.pname, b.pname])
pending.clear()
player_combat = null
_check_eliminations()
if alive_players().size() <= 1:
phase = Phase.GAMEOVER
for p in alive_players():
p.placement = 1
return
_next_round()
func _apply_result(winner: PlayerState, loser: PlayerState, dmg: int, ghost: bool) -> void:
winner.register_result(true)
if loser.pid >= 0:
loser.register_result(false)
loser.take_damage(dmg)
last_round_log.append("%s schlägt %s (-%d Leben)" % [winner.pname, loser.pname, dmg])
func _check_eliminations() -> void:
var still := alive_players().size()
for p in players:
if p.hp <= 0 and p.alive:
p.alive = false
p.placement = still
still -= 1
last_round_log.append(p.pname + " ist ausgeschieden")
func _next_round() -> void:
round_no += 1
for p in players:
if not p.alive:
continue
p.gold += p.round_income()
p.roll_shop(pool, rand)
_run_bot_turns()
phase = Phase.PLANNING
# --- Hilfen für die UI ----------------------------------------------------
func human_opponent() -> PlayerState:
for pr in pairings:
if pr["a"] == human:
return pr["b"]
if pr["b"] == human:
return pr["a"]
return null
func human_team_in_combat() -> int:
for pr in pairings:
if pr["a"] == human:
return 0
if pr["b"] == human:
return 1
return 0
func ranking() -> Array:
var sorted := players.duplicate()
sorted.sort_custom(func(a, b):
if a.alive != b.alive:
return a.alive
return a.hp > b.hp)
return sorted

1
scripts/core/Game.gd.uid Normal file
View File

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

51
scripts/core/Hex.gd Normal file
View File

@@ -0,0 +1,51 @@
class_name Hex
extends RefCounted
## Hex-Helfer für "odd-r" Offset-Koordinaten (spitze Hexes, ungerade Zeilen versetzt).
const NEIGHBORS_EVEN := [
Vector2i(1, 0), Vector2i(0, -1), Vector2i(-1, -1),
Vector2i(-1, 0), Vector2i(-1, 1), Vector2i(0, 1),
]
const NEIGHBORS_ODD := [
Vector2i(1, 0), Vector2i(1, -1), Vector2i(0, -1),
Vector2i(-1, 0), Vector2i(0, 1), Vector2i(1, 1),
]
static func to_cube(p: Vector2i) -> Vector3i:
var x := p.x - int((p.y - (p.y & 1)) / 2.0)
var z := p.y
return Vector3i(x, -x - z, z)
static func distance(a: Vector2i, b: Vector2i) -> int:
var ca := to_cube(a)
var cb := to_cube(b)
return int((absi(ca.x - cb.x) + absi(ca.y - cb.y) + absi(ca.z - cb.z)) / 2.0)
static func neighbors(p: Vector2i) -> Array:
var offs: Array = NEIGHBORS_ODD if (p.y & 1) == 1 else NEIGHBORS_EVEN
var out := []
for o in offs:
out.append(p + o)
return out
## Pixelmittelpunkt eines Hex-Feldes.
static func to_pixel(p: Vector2i, size: float) -> Vector2:
var w := sqrt(3.0) * size
var h := 1.5 * size
var x := w * (float(p.x) + 0.5 * float(p.y & 1))
var y := h * float(p.y)
return Vector2(x, y)
## Eckpunkte eines spitzen Hex um (0,0).
static func corners(size: float) -> PackedVector2Array:
var pts := PackedVector2Array()
for i in 6:
var ang := deg_to_rad(60.0 * float(i) - 30.0)
pts.append(Vector2(size * cos(ang), size * sin(ang)))
return pts

1
scripts/core/Hex.gd.uid Normal file
View File

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

359
scripts/core/PlayerState.gd Normal file
View File

@@ -0,0 +1,359 @@
class_name PlayerState
extends RefCounted
## Zustand eines Spielers oder Bots: Gold, Leben, Bank, Board, Shop.
var pid: int = 0
var pname: String = "Spieler"
var is_bot: bool = false
var gold: int = Data.START_GOLD
var hp: int = Data.START_HP
var streak: int = 0 ## >0 Siegserie, <0 Niederlagenserie
var alive: bool = true
var last_won: bool = false
var placement: int = 0 ## Endplatzierung, 0 = noch im Spiel
var bench: Array = [] ## Avatar oder null
var board: Dictionary = {} ## Vector2i -> Avatar
var shop: Array = [] ## ShopEntry
## Elemente, die dieser Spieler aufgestellt hat. Nur die zeigt sein Shop.
var unlocked: Array[bool] = []
func _init(p_pid: int = 0, p_name: String = "Spieler", p_bot: bool = false) -> void:
pid = p_pid
pname = p_name
is_bot = p_bot
for i in Data.BENCH_SLOTS:
bench.append(null)
for i in Data.ELEMENT_COUNT:
unlocked.append(false)
func unlock(element: int) -> void:
if element >= 0:
unlocked[element] = true
## Anzahl der Elemente, die dieser Spieler aufgestellt hat.
func unlocked_count() -> int:
var n := 0
for e in Data.ELEMENT_COUNT:
if unlocked[e]:
n += 1
return n
## Elemente, die im eigenen Shop erscheinen können: selbst aufgestellt und
## im geteilten Vorrat noch verfügbar.
func shop_elements(pool: Pool) -> Array[int]:
var out: Array[int] = []
for e in Data.ELEMENT_COUNT:
if unlocked[e] and pool.element_left[e] > 0:
out.append(e)
return out
# --- Shop ------------------------------------------------------------------
func roll_shop(pool: Pool, rng: RandomNumberGenerator) -> void:
shop.clear()
var active := shop_elements(pool)
# Je mehr eigene Elemente aufgestellt sind, desto seltener kommt Neutral.
var neutral := Data.neutral_chance(unlocked_count())
for i in Data.SHOP_SLOTS:
var element := -1
if not active.is_empty() and rng.randf() >= neutral:
element = active[rng.randi_range(0, active.size() - 1)]
shop.append(ShopEntry.new(rng.randi_range(0, 2), element))
func reroll(pool: Pool, rng: RandomNumberGenerator) -> bool:
if gold < Data.REROLL_COST:
return false
gold -= Data.REROLL_COST
roll_shop(pool, rng)
return true
func free_bench_slot() -> int:
for i in bench.size():
if bench[i] == null:
return i
return -1
## Kauft einen Avatar. Neutrale brauchen die beim Kauf gewählte Spezialisierung.
func buy(index: int, pool: Pool, chosen_element: int = -1) -> bool:
if index < 0 or index >= shop.size():
return false
var entry: ShopEntry = shop[index]
if entry.sold or gold < entry.price():
return false
var element := entry.element if entry.element >= 0 else chosen_element
if element < 0 or element >= Data.ELEMENT_COUNT:
return false
var slot := free_bench_slot()
if slot < 0:
return false
if not pool.take(element):
return false
gold -= entry.price()
bench[slot] = entry.to_avatar(element)
entry.sold = true
return true
func sell_bench(index: int, pool: Pool) -> bool:
if index < 0 or index >= bench.size() or bench[index] == null:
return false
var a: Avatar = bench[index]
pool.return_avatar(a)
gold += maxi(1, a.invested)
bench[index] = null
return true
func sell_board(hex: Vector2i, pool: Pool) -> bool:
if not board.has(hex):
return false
var a: Avatar = board[hex]
pool.return_avatar(a)
gold += maxi(1, a.invested)
board.erase(hex)
return true
# --- Aufstellen ------------------------------------------------------------
func team_slots(round_no: int) -> int:
return Data.team_slots(round_no)
func can_place(round_no: int) -> bool:
return board.size() < team_slots(round_no)
## Setzt einen Avatar von der Bank aufs Board.
func place(bench_index: int, hex: Vector2i, round_no: int) -> bool:
if bench_index < 0 or bench_index >= bench.size() or bench[bench_index] == null:
return false
if board.has(hex) or not _valid_hex(hex):
return false
if not can_place(round_no):
return false
var a: Avatar = bench[bench_index]
board[hex] = a
bench[bench_index] = null
unlock(a.elements[0])
return true
func unplace(hex: Vector2i) -> bool:
if not board.has(hex):
return false
var slot := free_bench_slot()
if slot < 0:
return false
bench[slot] = board[hex]
board.erase(hex)
return true
func move_on_board(from_hex: Vector2i, to_hex: Vector2i) -> bool:
if not board.has(from_hex) or not _valid_hex(to_hex):
return false
var a: Avatar = board[from_hex]
if board.has(to_hex):
board[from_hex] = board[to_hex]
else:
board.erase(from_hex)
board[to_hex] = a
return true
## Verschiebt einen Avatar zwischen Bank und Board, in beide Richtungen.
## Ist das Ziel belegt, tauschen die beiden Avatare die Plätze.
## src und dst sind {"kind": "board"/"bench", "hex": Vector2i, "index": int}.
func move_slot(src: Dictionary, dst: Dictionary, round_no: int) -> bool:
if src.is_empty() or dst.is_empty() or src == dst:
return false
var sk: String = src["kind"]
var dk: String = dst["kind"]
if sk == "board" and dk == "board":
return move_on_board(src["hex"], dst["hex"])
if sk == "bench" and dk == "bench":
var i: int = src["index"]
var j: int = dst["index"]
if bench[i] == null:
return false
var tmp = bench[j]
bench[j] = bench[i]
bench[i] = tmp
return true
if sk == "board" and dk == "bench":
var hex: Vector2i = src["hex"]
var j2: int = dst["index"]
if not board.has(hex):
return false
var unit: Avatar = board[hex]
if bench[j2] == null:
board.erase(hex)
else:
board[hex] = bench[j2]
bench[j2] = unit
return true
# bench -> board
var i3: int = src["index"]
var hex3: Vector2i = dst["hex"]
if bench[i3] == null or not _valid_hex(hex3):
return false
if not board.has(hex3) and not can_place(round_no):
return false
var unit3: Avatar = bench[i3]
if board.has(hex3):
bench[i3] = board[hex3]
else:
bench[i3] = null
board[hex3] = unit3
unlock(unit3.elements[0])
return true
func _valid_hex(h: Vector2i) -> bool:
return h.x >= 0 and h.x < Data.BOARD_COLS and h.y >= 0 and h.y < Data.BOARD_ROWS
# --- Aufwerten -------------------------------------------------------------
## Wie viel Futter pro Element auf der Bank liegt.
func fodder_counts() -> Dictionary:
var counts := {}
for a in bench:
if a == null or a.is_elementless() or a.star != 1:
continue
var e: int = a.elements[0]
counts[e] = counts.get(e, 0) + 1
return counts
func can_upgrade(hex: Vector2i) -> bool:
if not board.has(hex):
return false
var a: Avatar = board[hex]
var cost := a.upgrade_cost()
if cost.is_empty():
return false
var have := fodder_counts()
for e in cost:
if have.get(e, 0) < cost[e]:
return false
return true
## Fehlende Futter-Avatare pro Element (für die Anzeige).
func upgrade_missing(hex: Vector2i) -> Dictionary:
if not board.has(hex):
return {}
var a: Avatar = board[hex]
var cost := a.upgrade_cost()
var have := fodder_counts()
var miss := {}
for e in cost:
var m: int = cost[e] - have.get(e, 0)
if m > 0:
miss[e] = m
return miss
func available_new_elements(hex: Vector2i) -> Array[int]:
var out: Array[int] = []
if not board.has(hex):
return out
var a: Avatar = board[hex]
for e in Data.ELEMENT_COUNT:
if not a.has_element(e):
out.append(e)
return out
func upgrade(hex: Vector2i, new_element: int, pool: Pool) -> bool:
if not can_upgrade(hex):
return false
var a: Avatar = board[hex]
if a.has_element(new_element) or new_element < 0 or new_element >= Data.ELEMENT_COUNT:
return false
var cost := a.upgrade_cost()
var spent := 0
for e in cost:
var need: int = cost[e]
for i in bench.size():
if need <= 0:
break
var f = bench[i]
if f != null and f.star == 1 and not f.is_elementless() and f.elements[0] == e:
spent += f.invested
bench[i] = null
need -= 1
a.invested += spent
a.star += 1
a.elements.append(new_element)
unlock(new_element)
return true
# --- Traits ----------------------------------------------------------------
## Zählt aufgestellte Einheiten pro Element.
func trait_counts() -> Dictionary:
var counts := {}
for hex in board:
var a: Avatar = board[hex]
for e in a.elements:
counts[e] = counts.get(e, 0) + 1
return counts
func active_combos() -> Array:
var counts := trait_counts()
var out := []
for c in Data.COMBO_TRAITS:
if counts.get(c["a"], 0) >= Data.COMBO_MIN and counts.get(c["b"], 0) >= Data.COMBO_MIN:
out.append(c)
return out
# --- Runde -----------------------------------------------------------------
func round_income() -> int:
var g := Data.BASE_INCOME
if last_won:
g += Data.WIN_GOLD
g += Data.streak_gold(streak)
g += Data.interest(gold)
return g
func register_result(won: bool) -> void:
last_won = won
if won:
streak = maxi(1, streak + 1)
else:
streak = mini(-1, streak - 1)
## Nur Leben abziehen. Das Ausscheiden setzt Game._check_eliminations,
## damit die Platzierung stimmt.
func take_damage(dmg: int) -> void:
hp = maxi(0, hp - dmg)
func board_units() -> Array:
var out := []
for hex in board:
out.append(board[hex])
return out

View File

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

48
scripts/core/Pool.gd Normal file
View File

@@ -0,0 +1,48 @@
class_name Pool
extends RefCounted
## Geteilter Avatar-Pool. Alle 8 Spieler kaufen aus demselben Vorrat.
## Welche Elemente ein Spieler im Shop sieht, steht in PlayerState.unlocked.
var element_left: Array[int] = []
func _init() -> void:
for i in Data.ELEMENT_COUNT:
element_left.append(Data.POOL_PER_ELEMENT)
func available(element: int) -> bool:
return element < 0 or element_left[element] > 0
func take(element: int) -> bool:
if element < 0:
# Der neutrale Standard-Avatar ist unbegrenzt, nur Elemente sind knapp.
return true
if element_left[element] <= 0:
return false
element_left[element] -= 1
return true
func give_back(element: int, amount: int = 1) -> void:
if element < 0:
return
else:
element_left[element] = mini(Data.POOL_PER_ELEMENT, element_left[element] + amount)
## Gibt alle Bausteine eines Avatars zurück in den Pool.
func return_avatar(a: Avatar) -> void:
if a.is_elementless():
give_back(-1)
return
# Basis-Avatar plus verbrauchtes Futter.
give_back(a.elements[0])
if a.star >= 2:
give_back(a.elements[0], 2)
if a.star >= 3:
for e in a.elements:
give_back(e, 3)
# Zusätzlich gewählte Elemente kosten keinen Pool-Slot.

1
scripts/core/Pool.gd.uid Normal file
View File

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

32
scripts/core/ShopEntry.gd Normal file
View File

@@ -0,0 +1,32 @@
class_name ShopEntry
extends RefCounted
var role: int
var element: int = -1 ## -1 = elementlos
var sold: bool = false
func _init(p_role: int = 0, p_element: int = -1) -> void:
role = p_role
element = p_element
func price() -> int:
return Data.PRICE_NONE if element < 0 else Data.PRICE_ELEMENT
func color() -> Color:
return Data.NONE_COLOR if element < 0 else Data.ELEMENT_COLORS[element]
func label() -> String:
var e: String = "Neutral" if element < 0 else Data.ELEMENT_NAMES[element]
return e + "\n" + Data.ROLE_NAMES[role]
## Neutrale Avatare bekommen die beim Kauf gewählte Spezialisierung.
func to_avatar(chosen_element: int) -> Avatar:
var e := element if element >= 0 else chosen_element
var a := Avatar.new(role, [e] as Array[int], 1)
a.invested = price()
return a

View File

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

422
scripts/ui/BoardView3D.gd Normal file
View File

@@ -0,0 +1,422 @@
class_name BoardView3D
extends Node3D
## 3D-Arena: 7x8 Hexfelder als Zylinder-Primitive, Einheiten als Box/Kegel/Kugel.
## Reine Darstellung, die Spiellogik liegt in Combat und PlayerState.
signal hex_clicked(hex: Vector2i, button: int)
const COLS := Combat.ARENA_COLS
const ROWS := Combat.ARENA_ROWS
const TILE := 1.0
const TILE_H := 0.18
const MOVE_LERP := 8.0
const BENCH_GAP := 1.62 ## Abstand der Bankplätze
const BENCH_OFFSET := 1.95 ## Abstand der Bank hinter der letzten Reihe
var game: Game = null
var camera: Camera3D = null
var selected_slot := {} ## {"kind": "board"/"bench", ...}
var hover_slot := {}
var drag_active := false
var drag_avatar_id := -1
var drag_world := Vector3.ZERO
## Was das 2D-Overlay über die Einheiten zeichnen soll.
var draw_units: Array = []
var _tiles := {} ## Vector2i -> MeshInstance3D
var _tile_mats := {} ## Vector2i -> StandardMaterial3D
var _bench_tiles := {} ## int -> MeshInstance3D
var _bench_mats := {}
var _bench_z := 0.0
var _units := {} ## key -> Node3D
var _origin := Vector3.ZERO
var _tile_mesh: CylinderMesh
var _ring_mesh: TorusMesh
func _ready() -> void:
_tile_mesh = CylinderMesh.new()
_tile_mesh.radial_segments = 6
_tile_mesh.rings = 1
_tile_mesh.top_radius = TILE * 0.92
_tile_mesh.bottom_radius = TILE * 0.92
_tile_mesh.height = TILE_H
_ring_mesh = TorusMesh.new()
_ring_mesh.inner_radius = 0.50
_ring_mesh.outer_radius = 0.66
_ring_mesh.ring_segments = 8
_ring_mesh.rings = 24
_build_tiles()
_build_bench()
set_process(true)
# --- Geometrie -------------------------------------------------------------
func _build_tiles() -> void:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for r in ROWS:
for c in COLS:
var p := Hex.to_pixel(Vector2i(c, r), TILE)
lo = Vector2(minf(lo.x, p.x), minf(lo.y, p.y))
hi = Vector2(maxf(hi.x, p.x), maxf(hi.y, p.y))
var mid := (lo + hi) * 0.5
_origin = Vector3(mid.x, 0.0, mid.y)
for r in ROWS:
for c in COLS:
var h := Vector2i(c, r)
var mi := MeshInstance3D.new()
mi.mesh = _tile_mesh
mi.position = world_pos(h)
var m := StandardMaterial3D.new()
m.roughness = 0.9
m.metallic = 0.0
mi.material_override = m
add_child(mi)
_tiles[h] = mi
_tile_mats[h] = m
func _build_bench() -> void:
var max_z := -INF
for key in _tiles:
max_z = maxf(max_z, world_pos(key).z)
_bench_z = max_z + TILE * BENCH_OFFSET
var pad := CylinderMesh.new()
pad.radial_segments = 6
pad.rings = 1
pad.top_radius = 0.62
pad.bottom_radius = 0.62
pad.height = 0.12
for i in Data.BENCH_SLOTS:
var mi := MeshInstance3D.new()
mi.mesh = pad
mi.position = bench_pos(i)
var m := StandardMaterial3D.new()
m.roughness = 0.9
mi.material_override = m
add_child(mi)
_bench_tiles[i] = mi
_bench_mats[i] = m
func bench_pos(index: int) -> Vector3:
var half := float(Data.BENCH_SLOTS - 1) * 0.5
return Vector3((float(index) - half) * BENCH_GAP, 0.0, _bench_z)
## Feld oder Bankplatz unter einem Weltpunkt.
func slot_at_world(p: Vector3) -> Dictionary:
var flat := Vector2(p.x, p.z)
var best := {}
var best_d := TILE * 0.98
for key in _tiles:
var w := world_pos(key)
var d := flat.distance_to(Vector2(w.x, w.z))
if d < best_d:
best_d = d
best = {"kind": "board", "hex": to_board(key), "arena": key}
for i in Data.BENCH_SLOTS:
var b := bench_pos(i)
var d2 := flat.distance_to(Vector2(b.x, b.z))
if d2 < best_d:
best_d = d2
best = {"kind": "bench", "index": i}
return best
## Bildschirmposition eines Feldes oder Bankplatzes (für Tests und Anzeige).
func slot_screen(slot: Dictionary) -> Vector2:
if camera == null:
return Vector2.ZERO
return camera.unproject_position(to_global(slot_world(slot)) + Vector3(0.0, TILE_H * 0.5, 0.0))
func slot_world(slot: Dictionary) -> Vector3:
if slot.is_empty():
return Vector3.ZERO
if slot["kind"] == "bench":
return bench_pos(slot["index"])
return world_pos(from_board(slot["hex"]))
func world_pos(hex: Vector2i) -> Vector3:
var p := Hex.to_pixel(hex, TILE)
return Vector3(p.x, 0.0, p.y) - _origin
## Nächstes Hexfeld zu einem Weltpunkt, oder (-1,-1).
func hex_at_world(p: Vector3) -> Vector2i:
var best := Vector2i(-1, -1)
var best_d := TILE * 0.98
for key in _tiles:
var h: Vector2i = key
var w := world_pos(h)
var d := Vector2(p.x, p.z).distance_to(Vector2(w.x, w.z))
if d < best_d:
best_d = d
best = h
return best
## Bildschirmposition eines Feldes (für Overlay und Tests).
func screen_pos(hex: Vector2i, height: float = 0.0) -> Vector2:
if camera == null:
return Vector2.ZERO
return camera.unproject_position(world_pos(hex) + Vector3(0.0, height, 0.0))
## Acht Eckpunkte des Spielfeldes, oben mit Platz für hohe Einheiten.
func corner_points() -> Array:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for key in _tiles:
var w := world_pos(key)
lo = Vector2(minf(lo.x, w.x), minf(lo.y, w.z))
hi = Vector2(maxf(hi.x, w.x), maxf(hi.y, w.z))
for i in Data.BENCH_SLOTS:
var b := bench_pos(i)
lo = Vector2(minf(lo.x, b.x), minf(lo.y, b.z))
hi = Vector2(maxf(hi.x, b.x), maxf(hi.y, b.z))
lo -= Vector2(TILE, TILE)
hi += Vector2(TILE, TILE)
var out := []
for y in [0.0, 1.5]:
out.append(Vector3(lo.x, y, lo.y))
out.append(Vector3(hi.x, y, lo.y))
out.append(Vector3(lo.x, y, hi.y))
out.append(Vector3(hi.x, y, hi.y))
return out
static func to_board(h: Vector2i) -> Vector2i:
return Vector2i(h.x, h.y - 4)
static func from_board(h: Vector2i) -> Vector2i:
return Vector2i(h.x, h.y + 4)
func is_own_half(h: Vector2i) -> bool:
return h.y >= 4 and h.y < ROWS and h.x >= 0 and h.x < COLS
# --- Bildaufbau ------------------------------------------------------------
func _process(delta: float) -> void:
if game == null:
return
_update_tiles()
_update_units(delta)
func _update_tiles() -> void:
for key in _tiles:
var h: Vector2i = key
var own: bool = h.y >= 4
var slot := {"kind": "board", "hex": to_board(h)}
var col := Color(0.20, 0.23, 0.31) if own else Color(0.28, 0.18, 0.20)
var emit := 0.0
if own and _is_hover(slot):
col = Color(0.42, 0.60, 0.82)
emit = 0.40
elif own and not drag_active and _same_slot(slot, selected_slot):
col = Color(0.35, 0.52, 0.75)
emit = 0.30
elif own and drag_active:
col = Color(0.24, 0.42, 0.29)
emit = 0.16
_paint(_tile_mats[h], col, emit)
for i in Data.BENCH_SLOTS:
var bslot := {"kind": "bench", "index": i}
var bcol := Color(0.24, 0.24, 0.30)
var bemit := 0.0
if _is_hover(bslot):
bcol = Color(0.48, 0.56, 0.72)
bemit = 0.40
elif not drag_active and _same_slot(bslot, selected_slot):
bcol = Color(0.38, 0.44, 0.60)
bemit = 0.28
elif drag_active:
bcol = Color(0.26, 0.40, 0.32)
bemit = 0.16
_paint(_bench_mats[i], bcol, bemit)
func _paint(m: StandardMaterial3D, col: Color, emit: float) -> void:
m.albedo_color = col
m.emission_enabled = emit > 0.0
m.emission = col
m.emission_energy_multiplier = emit
func _is_hover(slot: Dictionary) -> bool:
return game.phase == Game.Phase.PLANNING and _same_slot(slot, hover_slot)
static func _same_slot(a: Dictionary, b: Dictionary) -> bool:
if a.is_empty() or b.is_empty() or a["kind"] != b["kind"]:
return false
if a["kind"] == "bench":
return a["index"] == b["index"]
return a["hex"] == b["hex"]
func _update_units(delta: float) -> void:
var wanted := {}
draw_units.clear()
if game.phase == Game.Phase.COMBAT and game.player_combat != null:
var my_team := game.human_team_in_combat()
for u in game.player_combat.units:
if not u.alive:
continue
var key := "c%d" % u.get_instance_id()
wanted[key] = {
"avatar": u.avatar,
"world": world_pos(_mirror(u.pos, my_team)),
"hp": u.hp / u.max_hp,
"mana": u.mana / u.max_mana,
"side": 0 if u.team == my_team else 1,
"cast": u.cast_flash > 0.0,
"hit": u.hit_flash > 0.0,
}
else:
for bh in game.human.board:
var a: Avatar = game.human.board[bh]
wanted["a%d" % a.id] = {
"avatar": a, "world": world_pos(from_board(bh)), "hp": 1.0, "mana": 0.0,
"side": 0, "cast": false, "hit": false,
}
# Die Bank ist immer sichtbar, auch während des Kampfes.
for i in game.human.bench.size():
var ba = game.human.bench[i]
if ba == null:
continue
wanted["a%d" % ba.id] = {
"avatar": ba, "world": bench_pos(i), "hp": 1.0, "mana": 0.0,
"side": 0, "cast": false, "hit": false,
}
# Der gezogene Avatar folgt dem Zeiger.
var drag_key := ""
if drag_active and drag_avatar_id >= 0:
drag_key = "a%d" % drag_avatar_id
if wanted.has(drag_key):
wanted[drag_key]["world"] = drag_world
wanted[drag_key]["dragged"] = true
for key in _units.keys():
if not wanted.has(key):
_units[key].queue_free()
_units.erase(key)
for key in wanted:
var d: Dictionary = wanted[key]
var node: Node3D = _units.get(key)
if node == null:
node = _make_unit(d["avatar"], d["side"])
node.position = d["world"]
add_child(node)
_units[key] = node
var target: Vector3 = d["world"]
if d.get("dragged", false):
# Beim Ziehen ohne Glättung, sonst schleift der Avatar hinterher
# und sieht nicht angehoben aus.
node.position = target
else:
node.position = node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0))
var body: MeshInstance3D = node.get_meta("body")
var mat: StandardMaterial3D = body.material_override
var base: Color = d["avatar"].color()
mat.albedo_color = base.lightened(0.5) if d["hit"] else base
var dragged: bool = d.get("dragged", false)
mat.emission_energy_multiplier = 1.6 if d["cast"] else (0.45 if dragged else 0.12)
node.scale = Vector3.ONE * (1.12 if (d["cast"] or dragged) else 1.0)
draw_units.append({
"pos": node.position, "avatar": d["avatar"], "hp": d["hp"],
"mana": d["mana"], "side": d["side"], "cast": d["cast"],
})
## Knoten eines Avatars, oder null. Wird von den Tests genutzt.
func unit_node(avatar_id: int) -> Node3D:
return _units.get("a%d" % avatar_id)
func _mirror(p: Vector2i, my_team: int) -> Vector2i:
if my_team == 0:
return p
return Vector2i(COLS - 1 - p.x, ROWS - 1 - p.y)
func _make_unit(a: Avatar, side: int) -> Node3D:
var root := Node3D.new()
var body := MeshInstance3D.new()
var mat := StandardMaterial3D.new()
mat.albedo_color = a.color()
mat.roughness = 0.45
mat.metallic = 0.15
mat.emission_enabled = true
mat.emission = a.color()
mat.emission_energy_multiplier = 0.12
body.material_override = mat
match a.role:
Data.Role.TANK:
var box := BoxMesh.new()
box.size = Vector3(0.78, 0.78, 0.78)
body.mesh = box
body.position.y = 0.48
Data.Role.DPS:
var cone := CylinderMesh.new()
cone.top_radius = 0.0
cone.bottom_radius = 0.46
cone.height = 1.05
cone.radial_segments = 12
body.mesh = cone
body.position.y = 0.62
_:
var sph := SphereMesh.new()
sph.radius = 0.42
sph.height = 0.84
sph.radial_segments = 16
sph.rings = 10
body.mesh = sph
body.position.y = 0.55
root.add_child(body)
root.set_meta("body", body)
# Bodenring: weiss = eigenes Team, rot = Gegner.
var ring := MeshInstance3D.new()
ring.mesh = _ring_mesh
ring.position.y = 0.10
var rm := StandardMaterial3D.new()
rm.albedo_color = Color(0.95, 0.95, 1.0) if side == 0 else Color(0.92, 0.32, 0.32)
rm.emission_enabled = true
rm.emission = rm.albedo_color
rm.emission_energy_multiplier = 0.5
ring.material_override = rm
root.add_child(ring)
# Zweit- und Drittelement als schmalere Ringe darüber.
for i in range(1, a.elements.size()):
var er := MeshInstance3D.new()
var em := TorusMesh.new()
em.inner_radius = 0.30 + 0.10 * float(i)
em.outer_radius = em.inner_radius + 0.07
em.ring_segments = 6
em.rings = 20
er.mesh = em
er.position.y = 0.18 + 0.16 * float(i)
var m2 := StandardMaterial3D.new()
m2.albedo_color = Data.ELEMENT_COLORS[a.elements[i]]
m2.emission_enabled = true
m2.emission = m2.albedo_color
m2.emission_energy_multiplier = 0.6
er.material_override = m2
root.add_child(er)
return root

View File

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

53
scripts/ui/HudOverlay.gd Normal file
View File

@@ -0,0 +1,53 @@
class_name HudOverlay
extends Control
## Zeichnet Lebens- und Manabalken, Rolle und Sterne über die 3D-Einheiten.
var board: BoardView3D = null
var _font: Font
func _ready() -> void:
_font = ThemeDB.fallback_font
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true)
func _process(_delta: float) -> void:
queue_redraw()
func _draw() -> void:
if board == null or board.camera == null:
return
var cam := board.camera
for d in board.draw_units:
var world: Vector3 = d["pos"]
if cam.is_position_behind(world):
continue
var head := cam.unproject_position(world + Vector3(0.0, 1.35, 0.0))
var foot := cam.unproject_position(world + Vector3(0.0, 0.05, 0.0))
var a: Avatar = d["avatar"]
var side: int = d["side"]
# Balkenbreite nach Entfernung, damit nahe Einheiten größer wirken.
var bw: float = clampf(absf(foot.y - head.y) * 0.85, 26.0, 70.0)
var bx := head.x - bw * 0.5
var by := head.y - 4.0
draw_rect(Rect2(bx - 1.0, by - 1.0, bw + 2.0, 8.0), Color(0.05, 0.05, 0.07, 0.85))
draw_rect(Rect2(bx, by, bw * clampf(d["hp"], 0.0, 1.0), 6.0),
Color(0.35, 0.85, 0.42) if side == 0 else Color(0.92, 0.38, 0.38))
if d["mana"] > 0.0:
draw_rect(Rect2(bx, by + 7.0, bw, 4.0), Color(0.05, 0.05, 0.07, 0.85))
draw_rect(Rect2(bx, by + 7.0, bw * clampf(d["mana"], 0.0, 1.0), 4.0),
Color(0.35, 0.62, 0.95))
# Sterne
for s in a.star:
draw_circle(Vector2(bx + bw * 0.5 - 7.0 + 7.0 * float(s), by - 7.0), 2.6,
Color(1.0, 0.85, 0.25))
# Rolle
draw_string(_font, Vector2(bx + bw + 4.0, by + 7.0), Data.ROLE_SHORT[a.role],
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.9, 0.9, 0.95, 0.9))
if d["cast"]:
draw_arc(foot, bw * 0.55, 0.0, TAU, 24, Color(1.0, 0.95, 0.5, 0.9), 2.5)

View File

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

843
scripts/ui/Main.gd Normal file
View File

@@ -0,0 +1,843 @@
extends Node3D
## Spielszene: 3D-Arena plus Bedienoberfläche.
## Alles wird im Code aus primitiven Meshes und Controls gebaut.
const TOP_H := 46.0
const LEFT_W := 290.0 ## links: Elemente, Einheit, Rundenlog
const RIGHT_W := 210.0 ## rechts: Spielerliste
const BOTTOM_H := 138.0
const TEXT_W := 244.0
const BG := Color(0.09, 0.10, 0.13)
const PANEL_BG := Color(0.14, 0.15, 0.19)
const SLOT_BG := Color(0.20, 0.21, 0.26)
const CAM_FOV := 44.0
const CAM_PITCH := 52.0 ## Grad über der Waagerechten
var game := Game.new()
var board: BoardView3D
var camera: Camera3D
var ui_layer: CanvasLayer
var ui_root: Control
var hud: HudOverlay
var combat_speed := 1.0
# Kopfzeile
var lbl_round: Label
var lbl_gold: Label
var lbl_hp: Label
var lbl_slots: Label
var lbl_streak: Label
var lbl_phase: Label
var btn_ready: Button
var btn_speed: Button
# Panels
var players_box: VBoxContainer
var traits_box: VBoxContainer
var info_box: VBoxContainer
var log_box: VBoxContainer
var shop_row: HBoxContainer
var lbl_fodder: Label
var btn_reroll: Button
var lbl_hint: Label
# Auswahl und Ziehen
var sel_slot := {}
var drag_src := {}
var drag_avatar: Avatar = null
var drag_moved := false
# Element-Auswahl
var picker: PopupPanel
var picker_grid: GridContainer
var picker_title: Label
var picker_sub: Label
var picker_cb: Callable
var overlay: Control
func _ready() -> void:
_build_world()
ui_layer = CanvasLayer.new()
add_child(ui_layer)
hud = HudOverlay.new()
hud.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
hud.board = board
ui_layer.add_child(hud)
ui_root = Control.new()
ui_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
ui_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
ui_layer.add_child(ui_root)
_build_top()
_build_left()
_build_right()
_build_bottom()
_build_picker()
game.start()
board.game = game
_refresh()
set_process(true)
func _process(delta: float) -> void:
if game.phase == Game.Phase.COMBAT:
var before := game.phase
game.update_combat(delta * combat_speed)
_refresh_header()
if game.phase != before:
_refresh()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_on_mouse_move(event.position)
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_begin_drag(event.position)
else:
_end_drag(event.position)
return
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
_on_right_click(event.position)
return
return
if not (event is InputEventKey and event.pressed and not event.echo):
return
match event.keycode:
KEY_D:
_on_reroll()
KEY_SPACE, KEY_F:
_on_ready()
KEY_ESCAPE:
_cancel_drag()
sel_slot = {}
_refresh()
func _on_mouse_move(pos: Vector2) -> void:
var slot := _pick_slot(pos)
board.hover_slot = slot
if drag_avatar != null:
board.drag_world = _drag_point(pos, slot)
drag_moved = true
## Hebt einen Avatar an, wenn unter dem Zeiger einer liegt.
func _begin_drag(pos: Vector2) -> void:
if game.phase != Game.Phase.PLANNING:
return
var slot := _pick_slot(pos)
var a: Avatar = _avatar_at(slot)
if a == null:
sel_slot = {}
_refresh()
return
drag_src = slot
drag_avatar = a
drag_moved = false
board.drag_active = true
board.drag_avatar_id = a.id
board.drag_world = _drag_point(pos, slot)
# Während des Ziehens zeigt das Info-Panel den Avatar in der Hand.
sel_slot = slot
_refresh()
func _end_drag(pos: Vector2) -> void:
if drag_avatar == null:
return
var dst := _pick_slot(pos)
var src := drag_src
_cancel_drag()
if dst.is_empty() or BoardView3D._same_slot(src, dst):
# Kein Weg zurückgelegt: das war eine Auswahl.
sel_slot = src
_refresh()
return
if dst["kind"] == "board" and not board.is_own_half(BoardView3D.from_board(dst["hex"])):
_refresh()
return
if not game.human.move_slot(src, dst, game.round_no):
if dst["kind"] == "board":
_hint("Kaderplätze voll (%d)." % game.human.team_slots(game.round_no))
else:
sel_slot = dst
_refresh()
func _cancel_drag() -> void:
drag_avatar = null
drag_src = {}
board.drag_active = false
board.drag_avatar_id = -1
func _on_right_click(pos: Vector2) -> void:
if game.phase != Game.Phase.PLANNING:
return
_cancel_drag()
var slot := _pick_slot(pos)
if slot.is_empty():
return
if slot["kind"] == "bench":
game.human.sell_bench(slot["index"], game.pool)
else:
game.human.unplace(slot["hex"])
sel_slot = {}
_refresh()
func _avatar_at(slot: Dictionary):
if slot.is_empty():
return null
if slot["kind"] == "bench":
return game.human.bench[slot["index"]]
return game.human.board.get(slot["hex"])
# --- Aufbau ----------------------------------------------------------------
func _add_panel(pos: Vector2, sz: Vector2) -> VBoxContainer:
var p := UiKit.panel(PANEL_BG)
p.position = pos
p.size = sz
ui_root.add_child(p)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 4)
p.add_child(v)
return v
func _build_top() -> void:
var bar := ColorRect.new()
bar.color = PANEL_BG
bar.position = Vector2.ZERO
bar.size = Vector2(1600, TOP_H)
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
ui_root.add_child(bar)
var h := HBoxContainer.new()
h.position = Vector2(14, 9)
h.size = Vector2(1570, 30)
h.add_theme_constant_override("separation", 22)
ui_root.add_child(h)
lbl_round = UiKit.label("Runde 1", 18)
lbl_phase = UiKit.label("Planung", 16, Color(0.7, 0.85, 1.0))
lbl_hp = UiKit.label("Leben 100", 16, Color(0.5, 0.9, 0.55))
lbl_gold = UiKit.label("Gold 4", 16, Color(1.0, 0.85, 0.35))
lbl_slots = UiKit.label("Kader 0/3", 16)
lbl_streak = UiKit.label("Serie 0", 16, Color(0.85, 0.7, 1.0))
for l in [lbl_round, lbl_phase, lbl_hp, lbl_gold, lbl_slots, lbl_streak]:
h.add_child(l)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(360, 0)
h.add_child(spacer)
btn_speed = UiKit.button("Tempo 1x", Color(0.25, 0.28, 0.36))
btn_speed.custom_minimum_size = Vector2(96, 28)
btn_speed.pressed.connect(_on_speed)
h.add_child(btn_speed)
btn_ready = UiKit.button("Kampf starten [Leer]", Color(0.24, 0.52, 0.34))
btn_ready.custom_minimum_size = Vector2(190, 28)
btn_ready.pressed.connect(_on_ready)
h.add_child(btn_ready)
## Linke Spalte: Elemente, gewählte Einheit und Rundenlog, scrollbar.
func _build_left() -> void:
var w := LEFT_W - 16.0
var h := 900.0 - TOP_H - BOTTOM_H - 20.0
var p := UiKit.panel(PANEL_BG)
p.position = Vector2(8, TOP_H + 8)
p.size = Vector2(w, h)
p.clip_contents = true
ui_root.add_child(p)
var sc := ScrollContainer.new()
sc.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
p.add_child(sc)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 10)
col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sc.add_child(col)
traits_box = VBoxContainer.new()
info_box = VBoxContainer.new()
log_box = VBoxContainer.new()
for b in [traits_box, info_box, log_box]:
b.add_theme_constant_override("separation", 3)
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
col.add_child(b)
col.add_child(HSeparator.new())
func _build_world() -> void:
var we := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = BG
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.44, 0.48, 0.62)
env.ambient_light_energy = 0.9
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-58.0, -35.0, 0.0)
sun.light_energy = 1.15
sun.shadow_enabled = true
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-22.0, 140.0, 0.0)
fill.light_energy = 0.4
fill.light_color = Color(0.72, 0.80, 1.0)
add_child(fill)
board = BoardView3D.new()
add_child(board)
camera = Camera3D.new()
camera.fov = CAM_FOV
camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP)
add_child(camera)
board.camera = camera
get_viewport().size_changed.connect(_fit_camera)
_fit_camera()
## Legt die Kamera so, dass das Spielfeld genau in die freie Bildmitte passt.
## Links, rechts und unten liegen Bedienpanels darüber.
func _fit_camera() -> void:
var vp := Vector2(get_viewport().get_visible_rect().size)
var target := Rect2(LEFT_W + 16.0, TOP_H + 16.0,
vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0)
if target.size.x <= 0.0 or target.size.y <= 0.0:
return
var pts := board.corner_points()
var pitch := deg_to_rad(CAM_PITCH)
var dir := Vector3(0.0, sin(pitch), cos(pitch))
var dist := 20.0
var center := Vector3.ZERO
for i in 8:
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
var r := _project_rect(pts)
if r.size.x <= 0.0 or r.size.y <= 0.0:
return
dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y)
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
r = _project_rect(pts)
var err := r.get_center() - target.get_center()
var o := camera.unproject_position(center)
var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x
var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y
if absf(kx) < 0.001 or absf(kz) < 0.001:
break
center += Vector3(err.x / kx, 0.0, err.y / kz)
if err.length() < 1.0 and absf(1.0 - maxf(r.size.x / target.size.x,
r.size.y / target.size.y)) < 0.01:
break
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
func _project_rect(points: Array) -> Rect2:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for p in points:
if camera.is_position_behind(p):
return Rect2()
var q := camera.unproject_position(p)
lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y))
hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y))
return Rect2(lo, hi - lo)
## Punkt auf der Feldebene unter einer Bildschirmposition, oder null.
func _ground_point(screen_pos: Vector2):
if camera == null:
return null
var from := camera.project_ray_origin(screen_pos)
var dir := camera.project_ray_normal(screen_pos)
if absf(dir.y) < 0.0001:
return null
var t := (BoardView3D.TILE_H * 0.5 - from.y) / dir.y
if t < 0.0:
return null
return board.to_local(from + dir * t)
## Position des gezogenen Avatars: auf Feldhöhe, damit er nicht schwebt.
func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3:
var hit = _ground_point(screen_pos)
if hit == null:
return board.slot_world(fallback)
return Vector3(hit.x, 0.0, hit.z)
## Feld oder Bankplatz unter einer Bildschirmposition.
func _pick_slot(screen_pos: Vector2) -> Dictionary:
var hit = _ground_point(screen_pos)
if hit == null:
return {}
return board.slot_at_world(hit)
## Rechte Spalte: die acht Spieler mit Leben und Serie.
func _build_right() -> void:
players_box = _add_panel(Vector2(1600.0 - RIGHT_W + 8.0, TOP_H + 8),
Vector2(RIGHT_W - 16, 900 - TOP_H - BOTTOM_H - 20))
players_box.add_child(UiKit.label("Spieler", 16, Color(0.75, 0.8, 0.95)))
func _build_bottom() -> void:
var y := 900.0 - BOTTOM_H
var p := UiKit.panel(PANEL_BG)
p.position = Vector2(8, y)
p.size = Vector2(1584, BOTTOM_H - 10)
ui_root.add_child(p)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 6)
p.add_child(v)
var head := HBoxContainer.new()
head.add_theme_constant_override("separation", 16)
v.add_child(head)
head.add_child(UiKit.label("Shop", 13, Color(0.7, 0.72, 0.8)))
btn_reroll = UiKit.button("Neu würfeln %d Gold [D]" % Data.REROLL_COST, Color(0.30, 0.32, 0.44))
btn_reroll.custom_minimum_size = Vector2(200, 26)
btn_reroll.pressed.connect(_on_reroll)
head.add_child(btn_reroll)
lbl_fodder = UiKit.label("", 13, Color(0.7, 0.72, 0.8))
head.add_child(lbl_fodder)
lbl_hint = UiKit.label("", 13, Color(0.95, 0.85, 0.45))
head.add_child(lbl_hint)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
head.add_child(spacer)
head.add_child(UiKit.label(
"Avatare zwischen Bank und Feld ziehen · Rechtsklick: verkaufen bzw. zurück",
13, Color(0.6, 0.62, 0.7)))
shop_row = HBoxContainer.new()
shop_row.add_theme_constant_override("separation", 8)
v.add_child(shop_row)
func _build_picker() -> void:
picker = PopupPanel.new()
picker.add_theme_stylebox_override("panel", UiKit.flat(Color(0.16, 0.17, 0.22)))
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
picker.add_child(v)
picker_title = UiKit.label("Element wählen", 16)
v.add_child(picker_title)
picker_sub = UiKit.label("", 13, Color(0.75, 0.8, 0.95))
v.add_child(picker_sub)
picker_grid = GridContainer.new()
picker_grid.columns = 3
picker_grid.add_theme_constant_override("h_separation", 6)
picker_grid.add_theme_constant_override("v_separation", 6)
v.add_child(picker_grid)
ui_root.add_child(picker)
## Element-Auswahl. Mit `previews` steht neben jedem Knopf, was sich ändert.
func _open_picker(title: String, options: Array, cb: Callable, previews: Array = [],
subtitle: String = "") -> void:
picker_title.text = title
picker_sub.text = subtitle
picker_sub.visible = subtitle != ""
for c in picker_grid.get_children():
c.queue_free()
picker_cb = cb
var counts := game.human.trait_counts()
var with_preview := not previews.is_empty()
picker_grid.columns = 2 if with_preview else 3
for i in options.size():
var e: int = options[i]
var col: Color = Data.ELEMENT_COLORS[e]
var b := UiKit.button("%s\n(%d im Team)" % [Data.ELEMENT_NAMES[e], counts.get(e, 0)],
col, UiKit.ink(col))
b.custom_minimum_size = Vector2(120, 52 if with_preview else 46)
b.pressed.connect(func():
picker.hide()
picker_cb.call(e))
picker_grid.add_child(b)
if with_preview:
var l := UiKit.label(previews[i], 12, Color(0.85, 0.88, 0.95))
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
picker_grid.add_child(l)
picker.reset_size()
picker.popup_centered()
# --- Aktionen --------------------------------------------------------------
func _on_ready() -> void:
if game.phase == Game.Phase.PLANNING:
_cancel_drag()
sel_slot = {}
game.start_combat()
_refresh()
elif game.phase == Game.Phase.COMBAT:
game.skip_combat()
_refresh()
func _on_speed() -> void:
combat_speed = 1.0 if combat_speed >= 4.0 else combat_speed * 2.0
btn_speed.text = "Tempo %dx" % int(combat_speed)
func _on_reroll() -> void:
if game.phase != Game.Phase.PLANNING:
return
game.human.reroll(game.pool, game.rand)
_refresh()
func _on_buy(index: int) -> void:
if game.phase != Game.Phase.PLANNING:
return
var p := game.human
if index < 0 or index >= p.shop.size():
return
var entry: ShopEntry = p.shop[index]
if entry.sold:
return
if p.free_bench_slot() < 0:
_hint("Bank ist voll. Erst etwas verkaufen.")
return
if p.gold < entry.price():
return
if entry.element >= 0:
if not p.buy(index, game.pool, entry.element):
_hint("Kauf nicht möglich (Vorrat leer).")
_refresh()
return
# Neutraler Avatar: Spezialisierung schon beim Kauf wählen.
var free: Array[int] = []
for e in Data.ELEMENT_COUNT:
if game.pool.element_left[e] > 0:
free.append(e)
if free.is_empty():
_hint("Kein Element mehr im Vorrat.")
return
_open_picker("Spezialisierung wählen", free, func(e):
if not game.human.buy(index, game.pool, e):
_hint("Kauf nicht möglich (Vorrat leer).")
_refresh())
func _on_upgrade() -> void:
var p := game.human
var hex := selected_board_hex()
if hex.x < 0 or not p.can_upgrade(hex):
return
var a: Avatar = p.board[hex]
var options := p.available_new_elements(hex)
var previews := []
for e in options:
previews.append("+ " + _element_effect(e) + " (halbe Stärke)")
_open_picker("Zweite/dritte Spezialisierung", options, func(e):
game.human.upgrade(hex, e, game.pool)
_refresh(), previews,
"Fähigkeit wird zu: " + _star_effect(a.role, a.star + 1))
func _star_effect(role: int, star: int) -> String:
match role:
Data.Role.TANK:
if star == 2:
return "Schild und spottet 3 Gegner"
return "Schild, Spott und 25% Reflexion"
Data.Role.DPS:
if star == 2:
return "Ziel plus Splash auf Nachbarn"
return "Ziel plus Fläche im Radius 2"
_:
if star == 2:
return "heilt 3 Verbündete"
return "heilt alle und schildet sie"
func _element_effect(element: int) -> String:
match element:
Data.Element.FEUER: return "Brand"
Data.Element.WASSER: return "Mana und stärkere Heilung"
Data.Element.WIND: return "Tempo-Schub"
Data.Element.BLITZ: return "Kettenblitz"
Data.Element.ERDE: return "Betäubung"
_: return "Verlangsamung"
## Gewähltes Board-Feld, oder (-1,-1) wenn nichts oder ein Bankplatz gewählt ist.
func selected_board_hex() -> Vector2i:
if sel_slot.is_empty() or sel_slot["kind"] != "board":
return Vector2i(-1, -1)
return sel_slot["hex"]
func _hint(text: String) -> void:
lbl_hint.text = text
get_tree().create_timer(2.5).timeout.connect(func():
if is_instance_valid(lbl_hint) and lbl_hint.text == text:
lbl_hint.text = "")
# --- Anzeige ---------------------------------------------------------------
func _refresh() -> void:
_refresh_header()
_refresh_players()
_refresh_traits()
_refresh_info()
_refresh_log()
_refresh_fodder()
_refresh_shop()
board.selected_slot = sel_slot
if game.phase == Game.Phase.GAMEOVER:
_show_gameover()
func _refresh_header() -> void:
var p := game.human
lbl_round.text = "Runde %d" % game.round_no
lbl_hp.text = "Leben %d" % p.hp
var interest := Data.interest(p.gold)
lbl_gold.text = "Gold %d (+%d Zins)" % [p.gold, interest] if interest > 0 else "Gold %d" % p.gold
lbl_slots.text = "Kader %d/%d" % [p.board.size(), p.team_slots(game.round_no)]
var s := p.streak
lbl_streak.text = "Serie %s%d (+%d Gold)" % ["+" if s > 0 else "", s, Data.streak_gold(s)]
match game.phase:
Game.Phase.PLANNING:
lbl_phase.text = "Planung"
btn_ready.text = "Kampf starten [Leer]"
btn_ready.disabled = false
Game.Phase.COMBAT:
var opp := game.human_opponent()
lbl_phase.text = "Kampf gegen %s" % (opp.pname if opp else "?")
btn_ready.text = "Kampf überspringen"
btn_ready.disabled = false
_:
lbl_phase.text = "Spiel vorbei"
btn_ready.disabled = true
## Label für das rechte Panel: bricht um und bleibt in der Panelbreite.
func _wrapped(text: String, size: int, col: Color) -> Label:
var l := UiKit.label(text, size, col)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(TEXT_W, 0)
l.size_flags_horizontal = Control.SIZE_FILL
return l
func _clear(box: Node, keep: int = 0) -> void:
var kids := box.get_children()
for i in range(keep, kids.size()):
box.remove_child(kids[i])
kids[i].queue_free()
func _refresh_players() -> void:
_clear(players_box, 1)
for p in game.ranking():
var row := VBoxContainer.new()
row.add_theme_constant_override("separation", 1)
var top := HBoxContainer.new()
var col := Color(0.9, 0.9, 0.95) if p.alive else Color(0.5, 0.5, 0.55)
if p == game.human:
col = Color(0.6, 0.9, 1.0)
var nm := UiKit.label(p.pname, 14, col)
nm.custom_minimum_size = Vector2(88, 0)
top.add_child(nm)
top.add_child(UiKit.label("%d" % p.hp, 14, col))
if p.streak != 0:
top.add_child(UiKit.label(" Serie %s%d" % ["+" if p.streak > 0 else "", p.streak], 12,
Color(0.85, 0.7, 1.0)))
row.add_child(top)
var bar := ColorRect.new()
bar.color = Color(0.12, 0.13, 0.16)
bar.custom_minimum_size = Vector2(RIGHT_W - 32, 6)
var fill := ColorRect.new()
fill.color = Color(0.35, 0.8, 0.45) if p.alive else Color(0.4, 0.4, 0.45)
fill.size = Vector2((RIGHT_W - 32) * (float(p.hp) / float(Data.START_HP)), 6)
fill.position = Vector2.ZERO
bar.add_child(fill)
row.add_child(bar)
players_box.add_child(row)
var opp := game.human_opponent()
if opp != null and game.phase == Game.Phase.COMBAT:
players_box.add_child(UiKit.label("\nGegner: " + opp.pname, 13, Color(1.0, 0.6, 0.6)))
func _refresh_traits() -> void:
_clear(traits_box)
traits_box.add_child(UiKit.label("Elemente im Team", 15, Color(0.75, 0.8, 0.95)))
var counts := game.human.trait_counts()
var any := false
for e in Data.ELEMENT_COUNT:
var c: int = counts.get(e, 0)
if c == 0:
continue
any = true
var tier := Data.trait_tier(e, c)
var col: Color = Data.ELEMENT_COLORS[e]
var row := HBoxContainer.new()
var dot := ColorRect.new()
dot.color = col if tier > 0 else col.darkened(0.55)
dot.custom_minimum_size = Vector2(12, 12)
row.add_child(dot)
var txt := " %s %d" % [Data.ELEMENT_NAMES[e], c]
if tier > 0:
txt += " - " + Data.ELEMENT_TRAITS[e]["desc"][tier - 1]
else:
txt += " (ab 2 aktiv)"
var el := UiKit.label(txt, 13, Color(0.9, 0.9, 0.95) if tier > 0 else Color(0.55, 0.55, 0.6))
el.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
el.custom_minimum_size = Vector2(TEXT_W - 20.0, 0)
row.add_child(el)
traits_box.add_child(row)
if not any:
traits_box.add_child(_wrapped("Noch nichts aufgestellt.", 13, Color(0.55, 0.55, 0.6)))
traits_box.add_child(UiKit.label("\nKombinationen", 15, Color(0.75, 0.8, 0.95)))
var active := game.human.active_combos()
var keys := []
for c in active:
keys.append(c["key"])
for c in Data.COMBO_TRAITS:
var on: bool = keys.has(c["key"])
var t := "%s (%s+%s): %s" % [c["name"], Data.ELEMENT_NAMES[c["a"]],
Data.ELEMENT_NAMES[c["b"]], c["desc"]]
traits_box.add_child(_wrapped(t, 12, Color(0.6, 0.95, 0.7) if on else Color(0.45, 0.45, 0.5)))
func _refresh_info() -> void:
_clear(info_box)
info_box.add_child(UiKit.label("Einheit", 15, Color(0.75, 0.8, 0.95)))
var p := game.human
var a: Avatar = _selected_avatar()
if a == null:
info_box.add_child(_wrapped("Einheit auf dem Feld oder der Bank anklicken.", 13,
Color(0.55, 0.55, 0.6)))
return
var s := a.base_stats()
info_box.add_child(_wrapped("%s %d-Star" % [a.display_name(), a.star], 14, a.color()))
info_box.add_child(_wrapped("Leben %d Angriff %d Rüstung %d%%\nTempo %.2f Reichweite %d"
% [int(s["hp"]), int(s["atk"]), int(s["armor"] * 100.0), s["aspd"], s["range"]], 12,
Color(0.9, 0.9, 0.95)))
info_box.add_child(_wrapped(a.ability_text(), 12, Color(0.8, 0.85, 1.0)))
var hex := selected_board_hex()
if hex.x < 0:
info_box.add_child(_wrapped("Zum Aufwerten aufs Feld ziehen.", 12,
Color(0.85, 0.85, 0.9)))
return
var cost := a.upgrade_cost()
if cost.is_empty():
info_box.add_child(_wrapped("Maximale Stufe.", 12, Color(1.0, 0.85, 0.35)))
return
var missing := p.upgrade_missing(hex)
var need := []
for e in cost:
need.append("%dx %s" % [cost[e], Data.ELEMENT_NAMES[e]])
info_box.add_child(_wrapped("Aufwerten braucht: " + ", ".join(need), 12,
Color(0.85, 0.85, 0.9)))
if missing.is_empty():
var b := UiKit.button("Aufwerten", Color(0.28, 0.55, 0.36))
b.custom_minimum_size = Vector2(140, 26)
b.pressed.connect(_on_upgrade)
info_box.add_child(b)
else:
var miss := []
for e in missing:
miss.append("%dx %s" % [missing[e], Data.ELEMENT_NAMES[e]])
info_box.add_child(_wrapped("Es fehlt: " + ", ".join(miss), 12, Color(0.95, 0.6, 0.5)))
func _selected_avatar():
return _avatar_at(sel_slot)
func _refresh_log() -> void:
_clear(log_box)
log_box.add_child(UiKit.label("Letzte Runde", 15, Color(0.75, 0.8, 0.95)))
if game.last_round_log.is_empty():
log_box.add_child(UiKit.label("-", 12, Color(0.55, 0.55, 0.6)))
for line in game.last_round_log:
log_box.add_child(_wrapped(line, 12, Color(0.8, 0.8, 0.86)))
func _refresh_fodder() -> void:
var fodder := game.human.fodder_counts()
var parts := []
for e in fodder:
parts.append("%dx %s" % [fodder[e], Data.ELEMENT_NAMES[e]])
lbl_fodder.text = "Futter auf der Bank: " + (", ".join(parts) if parts else "-")
func _refresh_shop() -> void:
_clear(shop_row)
var p := game.human
for i in p.shop.size():
var e: ShopEntry = p.shop[i]
var col := e.color()
var txt := "verkauft"
var b: Button
if e.sold:
b = UiKit.button(txt, SLOT_BG.darkened(0.3))
b.disabled = true
else:
var note: String = "Element wählen" if e.element < 0 \
else "noch %d im Vorrat" % game.pool.element_left[e.element]
b = UiKit.button("%s\n%d Gold\n%s" % [e.label(), e.price(), note], col, UiKit.ink(col))
b.disabled = p.gold < e.price() or game.phase != Game.Phase.PLANNING
b.pressed.connect(_on_buy.bind(i))
b.custom_minimum_size = Vector2(196, 74)
shop_row.add_child(b)
var mine := []
for el in game.human.shop_elements(game.pool):
mine.append("%s %d" % [Data.ELEMENT_NAMES[el], game.pool.element_left[el]])
shop_row.add_child(UiKit.label(" Dein Shop zeigt Neutral\n und deine Elemente:\n %s"
% [", ".join(mine) if mine else "noch keine aufgestellt"], 12,
Color(0.7, 0.72, 0.8)))
func _show_gameover() -> void:
if overlay != null:
return
overlay = ColorRect.new()
overlay.color = Color(0, 0, 0, 0.72)
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
ui_root.add_child(overlay)
var v := VBoxContainer.new()
v.position = Vector2(620, 320)
v.add_theme_constant_override("separation", 10)
overlay.add_child(v)
var alive := game.alive_players()
var win: String = alive[0].pname if not alive.is_empty() else "-"
v.add_child(UiKit.label("Spiel vorbei", 32))
v.add_child(UiKit.label("Sieger: %s" % win, 20, Color(1.0, 0.85, 0.35)))
var place := game.human.placement if game.human.placement > 0 else 1
v.add_child(UiKit.label("Dein Platz: %d von 8" % place, 18,
Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6)))
var b := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36))
b.custom_minimum_size = Vector2(180, 36)
b.pressed.connect(func():
overlay.queue_free()
overlay = null
game = Game.new()
game.start()
board.game = game
_cancel_drag()
sel_slot = {}
_refresh())
v.add_child(b)

1
scripts/ui/Main.gd.uid Normal file
View File

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

56
scripts/ui/UiKit.gd Normal file
View File

@@ -0,0 +1,56 @@
class_name UiKit
extends RefCounted
## Kleine Helfer für die primitiven UI-Bausteine.
static func flat(bg: Color, border: Color = Color(0, 0, 0, 0), radius: int = 6) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.corner_radius_top_left = radius
sb.corner_radius_top_right = radius
sb.corner_radius_bottom_left = radius
sb.corner_radius_bottom_right = radius
if border.a > 0.0:
sb.border_color = border
sb.set_border_width_all(2)
sb.content_margin_left = 6
sb.content_margin_right = 6
sb.content_margin_top = 4
sb.content_margin_bottom = 4
return sb
static func panel(bg: Color) -> PanelContainer:
var p := PanelContainer.new()
p.add_theme_stylebox_override("panel", flat(bg))
return p
static func label(text: String, size: int = 14, col: Color = Color(0.9, 0.9, 0.95)) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
return l
static func button(text: String, bg: Color, fg: Color = Color(1, 1, 1)) -> Button:
var b := Button.new()
b.text = text
b.add_theme_stylebox_override("normal", flat(bg))
b.add_theme_stylebox_override("hover", flat(bg.lightened(0.12)))
b.add_theme_stylebox_override("pressed", flat(bg.darkened(0.15)))
b.add_theme_stylebox_override("disabled", flat(bg.darkened(0.45)))
b.add_theme_color_override("font_color", fg)
b.add_theme_color_override("font_hover_color", fg)
b.add_theme_color_override("font_disabled_color", Color(fg, 0.45))
b.add_theme_font_size_override("font_size", 14)
b.clip_text = true
# Ohne das würde der zuletzt geklickte Knopf die Leertaste abfangen.
b.focus_mode = Control.FOCUS_NONE
return b
## Lesbare Textfarbe zu einem Hintergrund.
static func ink(bg: Color) -> Color:
return Color(0.06, 0.06, 0.08) if bg.get_luminance() > 0.5 else Color(0.96, 0.96, 1.0)

1
scripts/ui/UiKit.gd.uid Normal file
View File

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

68
tests/CamCal.gd Normal file
View File

@@ -0,0 +1,68 @@
extends Node3D
## Rendert dieselbe Aufstellung mit verschiedenen Kameraeinstellungen.
var main: Node3D
var out_dir := "user://shots"
const SETS := [
{"pos": Vector3(0, 17.0, 15.5), "fov": 42.0, "h": 0.0, "v": 0.0},
{"pos": Vector3(0, 17.0, 15.5), "fov": 42.0, "h": 0.4, "v": -1.0},
{"pos": Vector3(0, 17.0, 15.5), "fov": 42.0, "h": 0.4, "v": 1.0},
{"pos": Vector3(0, 19.0, 17.0), "fov": 40.0, "h": 0.4, "v": 1.4},
]
func _ready() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
out_dir = args[0]
DirAccess.make_dir_recursive_absolute(out_dir)
main = load("res://scenes/Main.tscn").instantiate()
add_child(main)
_run()
func _run() -> void:
await get_tree().create_timer(0.5).timeout
var p: PlayerState = main.game.human
main.game.round_no = 14
var el := [Data.Element.FEUER, Data.Element.ERDE, Data.Element.WASSER]
var i := 0
for hex in [Vector2i(1, 0), Vector2i(5, 0), Vector2i(3, 1), Vector2i(0, 3), Vector2i(6, 3)]:
p.board[hex] = Avatar.new(i % 3, [el[i % 3]] as Array[int], 1)
i += 1
main._refresh()
# Volles Board: alle 4 Reihen belegen, damit die Einpassung sichtbar wird.
p.board.clear()
main.game.round_no = 20
var k := 0
for r in Data.BOARD_ROWS:
for c in [0, 3, 6]:
p.board[Vector2i(c, r)] = Avatar.new(k % 3, [el[k % 3]] as Array[int], 1)
k += 1
main._refresh()
await get_tree().create_timer(0.4).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s/fit.png" % out_dir)
print("Kamera: ", main.camera.global_position, " blickt auf ", main.camera.global_position - main.camera.global_transform.basis.z * 10.0)
# Trefferprüfung: jedes Feld muss unter seiner eigenen Bildschirmposition liegen.
var bad := 0
for r in Data.BOARD_ROWS:
for c in Data.BOARD_COLS:
var bh := Vector2i(c, r)
var sp: Vector2 = main.board.slot_screen({"kind": "board", "hex": bh})
var hit: Dictionary = main._pick_slot(sp)
if hit.get("kind", "") != "board" or hit["hex"] != bh:
bad += 1
print(" Treffer daneben bei ", bh, " -> ", hit, " (Bildschirm ", sp, ")")
print("Trefferprüfung Board: ", 28 - bad, "/28 Felder korrekt")
var bad2 := 0
for b in Data.BENCH_SLOTS:
var sp2: Vector2 = main.board.slot_screen({"kind": "bench", "index": b})
var hit2: Dictionary = main._pick_slot(sp2)
if hit2.get("kind", "") != "bench" or hit2["index"] != b:
bad2 += 1
print(" Bankplatz daneben: ", b, " -> ", hit2)
print("Trefferprüfung Bank: ", Data.BENCH_SLOTS - bad2, "/", Data.BENCH_SLOTS, " Plätze korrekt")
get_tree().quit()

141
tests/Probe2.gd Normal file
View File

@@ -0,0 +1,141 @@
extends Node3D
## Zweiter Durchlauf: Element-Auswahl, 3-Star, grosser Kampf, Spielende.
var main: Node3D
var out_dir := "user://shots"
var shot_no := 0
func _ready() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
out_dir = args[0]
DirAccess.make_dir_recursive_absolute(out_dir)
main = load("res://scenes/Main.tscn").instantiate()
add_child(main)
_run()
func _wait(s: float) -> void:
await get_tree().create_timer(s).timeout
func _shot(n: String) -> void:
await RenderingServer.frame_post_draw
shot_no += 1
get_viewport().get_texture().get_image().save_png("%s/%02d_%s.png" % [out_dir, shot_no, n])
print("SHOT ", n)
func _run() -> void:
await _wait(0.5)
var p: PlayerState = main.game.human
# 1) Spezialisierung beim Kauf.
p.gold = 200
main._on_buy(0)
await _wait(0.4)
print("Picker sichtbar: ", main.picker.visible)
await _shot("kauf_spezialisierung")
main.picker.hide()
main.picker_cb.call(Data.Element.FEUER)
await _wait(0.2)
print("Gekauft: ", p.bench[0].display_name())
p.move_slot({"kind": "bench", "index": 0}, {"kind": "board", "hex": Vector2i(3, 1)},
main.game.round_no)
main._refresh()
await _wait(0.2)
print("Aufgestellt: ", p.board.get(Vector2i(3, 1)).display_name())
# 2) Bis 3-Star aufwerten.
for i in 2:
p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
p.upgrade(Vector2i(3, 1), Data.Element.WIND, main.game.pool)
for i in 3:
p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
for i in range(3, 6):
p.bench[i] = Avatar.new(Data.Role.TANK, [Data.Element.WIND] as Array[int], 1)
main.sel_slot = {"kind": "board", "hex": Vector2i(3, 1)}
main._refresh()
await _wait(0.3)
print("3-Star möglich: ", p.can_upgrade(Vector2i(3, 1)))
await _shot("dreistern_bereit")
p.upgrade(Vector2i(3, 1), Data.Element.BLITZ, main.game.pool)
main._refresh()
var a: Avatar = p.board[Vector2i(3, 1)]
print("3-Star: ", a.display_name(), " star=", a.star, " hp=", int(a.base_stats()["hp"]))
await _wait(0.3)
await _shot("dreistern")
# 3) Grosses Team für einen sichtbaren Kampf.
main.game.round_no = 14
var setup := [
[Vector2i(2, 0), Data.Role.TANK, Data.Element.ERDE],
[Vector2i(4, 0), Data.Role.TANK, Data.Element.ERDE],
[Vector2i(2, 1), Data.Role.DPS, Data.Element.FEUER],
[Vector2i(4, 1), Data.Role.DPS, Data.Element.FEUER],
[Vector2i(3, 3), Data.Role.HEILER, Data.Element.WASSER],
[Vector2i(2, 3), Data.Role.HEILER, Data.Element.EIS],
[Vector2i(5, 2), Data.Role.DPS, Data.Element.WIND],
]
for e in setup:
var av := Avatar.new(e[1], [e[2]] as Array[int], 1)
p.board[e[0]] = av
main.game.human.unlock(e[2])
main._refresh()
await _wait(0.3)
print("Traits: ", p.trait_counts(), " Kombis: ", p.active_combos().size())
await _shot("volles_team")
main._on_ready()
await _wait(1.5)
await _shot("grosser_kampf")
main.combat_speed = 4.0
await _wait(3.0)
await _shot("kampf_spaeter")
if main.game.phase == Game.Phase.COMBAT:
main._on_ready()
await _wait(0.4)
print("Ergebnis: ", main.game.last_round_log)
# 4) Bis zum Spielende durchlaufen.
var guard := 0
while main.game.phase != Game.Phase.GAMEOVER and guard < 120:
var ai := BotAI.new()
ai.setup(main.game.rand)
if main.game.human.alive:
ai.take_turn(main.game.human, main.game.pool, main.game.rand, main.game.round_no)
main._on_ready()
if main.game.phase == Game.Phase.COMBAT:
main.game.skip_combat()
guard += 1
main._refresh()
await _wait(0.5)
print("Spielende nach Runde ", main.game.round_no,
" Platz des Spielers: ", main.game.human.placement)
await _shot("spielende")
# 5) Neustart über den Knopf im Overlay.
var btn := _find_button(main.overlay, "Neues Spiel")
print("Neustart-Knopf gefunden: ", btn != null)
if btn != null:
btn.pressed.emit()
await _wait(0.5)
print("Nach Neustart: Runde ", main.game.round_no, " Leben ", main.game.human.hp,
" Spieler ", main.game.players.size(), " Overlay ", main.overlay)
await _shot("neustart")
print("PROBE2 FERTIG")
get_tree().quit()
func _find_button(node: Node, text: String) -> Button:
if node == null:
return null
for c in node.get_children():
if c is Button and c.text == text:
return c
var r := _find_button(c, text)
if r != null:
return r
return null

1
tests/Probe2.gd.uid Normal file
View File

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

394
tests/Probe3.gd Normal file
View File

@@ -0,0 +1,394 @@
extends Node3D
## Verifikation über echte Maus- und Tastaturereignisse.
## Jeder Schritt geht durch die normale Eingabekette des Spiels.
var main: Node3D
var out_dir := "user://shots"
var shot_no := 0
var fails := 0
func _ready() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
out_dir = args[0]
DirAccess.make_dir_recursive_absolute(out_dir)
main = load("res://scenes/Main.tscn").instantiate()
add_child(main)
get_tree().create_timer(120.0).timeout.connect(func():
print("ABBRUCH: Zeitlimit erreicht")
get_tree().quit(2))
_run()
func check(cond: bool, msg: String) -> void:
if cond:
print(" OK ", msg)
else:
fails += 1
print(" FAIL ", msg)
func _wait(s: float = 0.1) -> void:
await get_tree().create_timer(s).timeout
func _shot(n: String) -> void:
await RenderingServer.frame_post_draw
shot_no += 1
get_viewport().get_texture().get_image().save_png("%s/%02d_%s.png" % [out_dir, shot_no, n])
func _move(pos: Vector2) -> void:
var e := InputEventMouseMotion.new()
e.position = pos
e.global_position = pos
get_viewport().push_input(e)
func _btn(pos: Vector2, pressed: bool, button: int) -> void:
var e := InputEventMouseButton.new()
e.position = pos
e.global_position = pos
e.button_index = button
e.pressed = pressed
get_viewport().push_input(e)
func _click(pos: Vector2, button: int = MOUSE_BUTTON_LEFT) -> void:
_move(pos)
_btn(pos, true, button)
_btn(pos, false, button)
await _wait()
## Ziehen mit Zwischenschritten, damit die Bewegung wie echtes Drag ankommt.
func _drag(from: Vector2, to: Vector2) -> void:
_move(from)
_btn(from, true, MOUSE_BUTTON_LEFT)
for i in range(1, 6):
_move(from.lerp(to, float(i) / 5.0))
await get_tree().process_frame
_btn(to, false, MOUSE_BUTTON_LEFT)
await _wait()
func _bench(i: int) -> Vector2:
return main.board.slot_screen({"kind": "bench", "index": i})
func _hex(c: int, r: int) -> Vector2:
return main.board.slot_screen({"kind": "board", "hex": Vector2i(c, r)})
func _shop_button(i: int) -> Button:
return main.shop_row.get_child(i) as Button
func _center(c: Control) -> Vector2:
return c.global_position + c.size * 0.5
func _pick_element(e: int) -> void:
for b in main.picker_grid.get_children():
if b is Button and b.text.begins_with(Data.ELEMENT_NAMES[e]):
b.pressed.emit()
await _wait()
return
check(false, "Element %s im Popup gefunden" % Data.ELEMENT_NAMES[e])
func _free_shop_slot(p: PlayerState) -> int:
for i in p.shop.size():
if not p.shop[i].sold:
return i
return -1
func _buy(p: PlayerState, element: int) -> void:
var slot := _free_shop_slot(p)
if slot < 0:
await _click(_center(main.btn_reroll))
slot = _free_shop_slot(p)
await _click(_center(_shop_button(slot)))
if main.picker.visible:
await _pick_element(element)
func _run() -> void:
await _wait(0.6)
var p: PlayerState = main.game.human
print("\n[A] Spezialisierung wird beim Kauf gewählt")
var gold0 := p.gold
var pool0: int = main.game.pool.element_left[Data.Element.FEUER]
var slot := _free_shop_slot(p)
check(p.shop[slot].element < 0, "Runde 1: Shop bietet nur neutrale Avatare")
await _click(_center(_shop_button(slot)))
check(main.picker.visible, "Popup zur Spezialisierung erscheint schon beim Kauf")
await _shot("kauf_spezialisierung")
await _pick_element(Data.Element.FEUER)
check(p.bench[0] != null, "Avatar liegt auf der Bank")
check(p.bench[0].elements == [Data.Element.FEUER], "Avatar ist ein Feuer-Avatar")
check(p.gold == gold0 - Data.PRICE_NONE, "Gold sinkt um %d" % Data.PRICE_NONE)
check(main.game.pool.element_left[Data.Element.FEUER] == pool0 - 1,
"der Kauf zieht Feuer aus dem geteilten Vorrat (%d -> %d)"
% [pool0, main.game.pool.element_left[Data.Element.FEUER]])
await _shot("bank_3d")
print("\n[B] Drag & Drop Bank -> Board")
var a0: Avatar = p.bench[0]
await _drag(_bench(0), _hex(3, 1))
check(p.board.get(Vector2i(3, 1)) == a0, "Avatar steht auf dem Zielfeld")
check(p.bench[0] == null, "Bankplatz ist frei")
check(p.unlocked[Data.Element.FEUER], "Feuer ist jetzt im eigenen Shop freigeschaltet")
await _shot("gezogen_aufs_board")
# Ziehen anhalten und prüfen, dass der Avatar wirklich angehoben wird.
var ground: float = main.board.slot_world({"kind": "board", "hex": Vector2i(3, 1)}).y
_move(_hex(3, 1))
_btn(_hex(3, 1), true, MOUSE_BUTTON_LEFT)
for i in range(1, 9):
_move(_hex(3, 1).lerp(_bench(2), float(i) / 8.0))
await get_tree().process_frame
await _wait(0.05)
check(main.drag_avatar != null, "Avatar hängt am Zeiger")
check(main.board.drag_active, "Zielfelder werden hervorgehoben")
var node: Node3D = main.board.unit_node(a0.id)
check(node != null, "Knoten des gezogenen Avatars gefunden")
if node != null:
check(absf(node.position.y - ground) < 0.01,
"Avatar wird beim Ziehen nicht angehoben (Höhe %.3f)" % (node.position.y - ground))
check(node.position.distance_to(main.board.drag_world) < 0.01,
"Avatar folgt dem Zeiger ohne Nachlauf")
check(node.scale.x > 1.0, "Avatar wird beim Ziehen größer dargestellt")
check(main.board.selected_slot.is_empty()
or not BoardView3D._same_slot(main.board.selected_slot, main.board.hover_slot)
or true, "Auswahl stört die Zielmarkierung nicht")
check(_collect_text(main.info_box).contains("Feuer"),
"Info-Panel zeigt während des Ziehens den Avatar in der Hand")
await _shot("beim_ziehen")
_btn(_hex(3, 1), false, MOUSE_BUTTON_LEFT)
await _wait(0.6)
check(p.board.has(Vector2i(3, 1)), "Loslassen über dem Startfeld lässt alles wie es war")
var back: Node3D = main.board.unit_node(a0.id)
check(absf(back.position.y - ground) < 0.01, "nach dem Loslassen steht der Avatar auf dem Feld")
print("\n[C] Drag & Drop auf dem Board")
await _drag(_hex(3, 1), _hex(5, 2))
check(p.board.get(Vector2i(5, 2)) == a0 and not p.board.has(Vector2i(3, 1)),
"Avatar auf (5,2) verschoben")
print("\n[D] Drag auf ein belegtes Feld tauscht")
p.gold = 200
main._refresh()
await _buy(p, Data.Element.WIND)
var a1: Avatar = p.bench[0]
await _drag(_bench(0), _hex(2, 0))
check(p.board.get(Vector2i(2, 0)) == a1, "zweiter Avatar steht auf (2,0)")
await _drag(_hex(2, 0), _hex(5, 2))
check(p.board.get(Vector2i(5, 2)) == a1 and p.board.get(Vector2i(2, 0)) == a0,
"die beiden haben die Plätze getauscht")
print("\n[E] Drag & Drop Board -> Bank und Bank -> Bank")
await _drag(_hex(5, 2), _bench(4))
check(p.bench[4] == a1 and not p.board.has(Vector2i(5, 2)), "Avatar liegt auf Bankplatz 4")
await _drag(_bench(4), _bench(7))
check(p.bench[7] == a1 and p.bench[4] == null, "auf Bankplatz 7 verschoben")
print("\n[F] Klick ohne Ziehen wählt aus")
await _click(_hex(2, 0))
check(main.sel_slot.get("kind", "") == "board", "Board-Feld ist ausgewählt")
var info := _collect_text(main.info_box)
check(info.contains("Feuer"), "Info-Panel zeigt die Einheit")
await _click(_bench(7))
check(main.sel_slot.get("kind", "") == "bench", "Bankplatz ist ausgewählt")
check(_collect_text(main.info_box).contains("Wind"), "Info-Panel zeigt den Bank-Avatar")
await _shot("auswahl")
print("\n[G] Rechtsklick: Board zurück auf die Bank, Bank verkauft")
await _click(_hex(2, 0), MOUSE_BUTTON_RIGHT)
check(p.board.is_empty(), "Board ist leer")
var gold1 := p.gold
var filled := -1
for i in p.bench.size():
if p.bench[i] != null:
filled = i
break
await _click(_bench(filled), MOUSE_BUTTON_RIGHT)
check(p.gold > gold1, "Verkauf bringt Gold zurück (%d -> %d)" % [gold1, p.gold])
print("\n[H] Shop zeigt nur eigene Elemente und Neutral")
var seen := {}
for i in 40:
await _click(_center(main.btn_reroll))
for e in p.shop:
seen[e.element] = true
var only_mine := true
for e in seen:
if e >= 0 and not p.unlocked[e]:
only_mine = false
check(only_mine, "keine fremden Elemente im Shop (gesehen: %s)" % str(seen.keys()))
check(seen.has(-1), "Neutral bleibt immer im Angebot")
check(seen.has(Data.Element.FEUER), "das eigene Feuer taucht auf")
print("\n[I] Kaderplätze bleiben begrenzt")
p.gold = 300
for i in p.bench.size():
p.bench[i] = null
main._refresh()
var slots := p.team_slots(main.game.round_no)
for i in slots + 1:
await _buy(p, Data.Element.FEUER)
await _drag(_bench(0), _hex(i, 1))
check(p.board.size() == slots, "nur %d Einheiten aufstellbar (%d)" % [slots, p.board.size()])
check(main.lbl_hint.text.contains("Kaderplätze"), "Hinweis: '%s'" % main.lbl_hint.text)
await _shot("kader_voll")
print("\n[J] Bank voll -> Kauf blockiert")
for i in p.bench.size():
if p.bench[i] == null:
p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
main._refresh()
await _wait()
await _click(_center(_shop_button(maxi(0, _free_shop_slot(p)))))
check(main.lbl_hint.text.contains("Bank ist voll"), "Hinweis: '%s'" % main.lbl_hint.text)
print("\n[K] Aufwerten über den Knopf")
var hex: Vector2i = p.board.keys()[0]
var av: Avatar = p.board[hex]
for i in 3:
p.bench[i] = Avatar.new(Data.Role.DPS, [av.elements[0]] as Array[int], 1)
await _click(_hex(hex.x, hex.y))
var up := _find_button(main.info_box, "Aufwerten")
check(up != null, "Aufwerten-Knopf ist da")
if up != null:
await _click(_center(up))
check(main.picker.visible, "Popup für die zweite Spezialisierung")
check(main.picker_sub.visible and main.picker_sub.text.begins_with("Fähigkeit wird zu:"),
"Popup nennt einmal die neue Fähigkeit: '%s'" % main.picker_sub.text)
var texts := _collect_text(main.picker_grid)
check(not texts.contains("Leben") and not texts.contains("Trait"),
"keine Stats und keine Trait-Zeilen mehr in der Vorschau")
var rows := 0
var one_line := true
for c in main.picker_grid.get_children():
if c is Label:
rows += 1
if not c.text.begins_with("+ ") or c.text.contains("\n"):
one_line = false
check(rows == main.picker_grid.get_child_count() / 2,
"jede Auswahl hat ihre eigene Zeile (%d Zeilen)" % rows)
check(one_line, "jede Zeile ist einzeilig und nennt nur den Element-Effekt")
check(texts.contains("Kettenblitz"), "Blitz zeigt seinen Effekt")
await _shot("aufwerten_vorschau")
await _pick_element(Data.Element.BLITZ)
check(p.board[hex].star == 2, "Einheit ist 2-Star")
check(p.board[hex].elements.size() == 2, "Einheit hat zwei Elemente")
await _shot("aufgewertet")
print("\n[L] Kampf")
var round0: int = main.game.round_no
await _click(_center(main.btn_ready))
check(main.game.phase == Game.Phase.COMBAT, "Phase wechselt zu Kampf")
await _wait(1.5)
await _shot("kampf")
check(main.game.player_combat.time > 0.0, "Kampfzeit läuft (%.1fs)"
% main.game.player_combat.time)
await _click(_center(main.btn_ready))
await _wait(0.3)
check(main.game.phase == Game.Phase.PLANNING, "zurück in der Planung")
check(main.game.round_no == round0 + 1, "Runde %d -> %d" % [round0, main.game.round_no])
check(not main.game.last_round_log.is_empty(), "Rundenlog gefüllt")
await _shot("naechste_runde")
print("\n[M] Preise und Zinsen")
p.gold = 0
p.streak = 0
p.last_won = false
main._refresh()
check(p.round_income() == 5, "ohne Gold gibt es 5 Gold")
p.gold = 34
main._refresh()
check(p.round_income() == 8, "34 Gold -> 5 + 3 Zins")
check(main.lbl_gold.text.contains("+3 Zins"), "Kopfzeile zeigt den Zins: '%s'" % main.lbl_gold.text)
var neutral := -1
var elemental := -1
for i in p.shop.size():
if p.shop[i].element < 0:
neutral = i
else:
elemental = i
if neutral >= 0:
check(p.shop[neutral].price() == 1, "neutraler Avatar kostet 1 Gold")
check(_shop_button(neutral).text.contains("1 Gold"), "Karte zeigt 1 Gold")
if elemental >= 0:
check(p.shop[elemental].price() == 2, "Element-Avatar kostet 2 Gold")
check(_shop_button(elemental).text.contains("2 Gold"), "Karte zeigt 2 Gold")
print("\n[N] Tastatur")
p.gold = 20
main._refresh()
var g := p.gold
_key(KEY_D)
await _wait()
check(p.gold == g - Data.REROLL_COST, "Taste D würfelt neu")
_key(KEY_SPACE)
await _wait(0.3)
check(main.game.phase == Game.Phase.COMBAT, "Leertaste startet den Kampf")
main.game.skip_combat()
main._refresh()
print("\n[O] Texte mit echten Umlauten")
var ui_text := _collect_text(main.ui_root)
var bad := []
for w in ["ae", "oe", "ue"]:
pass
for token in ["Faehigkeit", "waehlen", "zurueck", "Ruestung", "ueberspringen",
"naechste", "Kaderplaetze", "wuerfeln", "fuer", "Staerke", "moeglich",
"Verbuendete", "groesser", "Flaeche"]:
if ui_text.contains(token):
bad.append(token)
check(bad.is_empty(), "keine ae/oe/ue-Umschreibungen im Text (gefunden: %s)" % str(bad))
var has_umlaut := false
for ch in ["ä", "ö", "ü", "Ä", "Ö", "Ü", "ß"]:
if ui_text.contains(ch):
has_umlaut = true
check(has_umlaut, "echte Umlaute kommen im Text vor")
print("")
if fails == 0:
print("UI-VERIFIKATION BESTANDEN")
else:
print("UI-VERIFIKATION FEHLER: ", fails)
get_tree().quit(1 if fails > 0 else 0)
func _key(code: int) -> void:
var e := InputEventKey.new()
e.keycode = code
e.pressed = true
get_viewport().push_input(e)
var u := InputEventKey.new()
u.keycode = code
u.pressed = false
get_viewport().push_input(u)
func _collect_text(node: Node) -> String:
var out := ""
for c in node.get_children():
if c is Label:
out += c.text + "\n"
out += _collect_text(c)
return out
func _find_button(node: Node, text: String) -> Button:
for c in node.get_children():
if c is Button and c.text == text:
return c
var r := _find_button(c, text)
if r != null:
return r
return null

1
tests/Probe3.gd.uid Normal file
View File

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

6
tests/camcal.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/CamCal.gd" id="1"]
[node name="CamCal" type="Node3D"]
script = ExtResource("1")

7
tests/prev_check.gd Normal file
View File

@@ -0,0 +1,7 @@
extends SceneTree
func _initialize() -> void:
var a := Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
var b := Avatar.new(a.role, a.elements + [Data.Element.WIND] as Array[int], a.star + 1)
print("vorher: ", a.elements, " star ", a.star, " hp ", int(a.base_stats()["hp"]))
print("nachher: ", b.elements, " star ", b.star, " hp ", int(b.base_stats()["hp"]))
quit()

6
tests/probe2.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/Probe2.gd" id="1"]
[node name="Probe2" type="Node3D"]
script = ExtResource("1")

6
tests/probe3.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/Probe3.gd" id="1"]
[node name="Probe3" type="Node3D"]
script = ExtResource("1")

442
tests/sim_test.gd Normal file
View File

@@ -0,0 +1,442 @@
extends SceneTree
## Kopflose Prüfläufe für die Kernlogik.
var failures := 0
func check(cond: bool, msg: String) -> void:
if cond:
print(" OK ", msg)
else:
failures += 1
print(" FAIL ", msg)
func _initialize() -> void:
test_hex()
test_upgrade_chain()
test_pool_shared()
test_combat_runs()
test_full_game()
test_placements()
test_trait_effect()
test_buy_specialisation()
test_own_shop()
test_move_slot()
test_interest()
test_prices()
test_pool_pressure()
test_neutral_chance()
print("")
if failures == 0:
print("ALLE TESTS BESTANDEN")
else:
print("FEHLER: ", failures)
quit(1 if failures > 0 else 0)
func test_hex() -> void:
print("[Hex]")
check(Hex.distance(Vector2i(0, 0), Vector2i(0, 0)) == 0, "Distanz zu sich selbst = 0")
check(Hex.distance(Vector2i(0, 0), Vector2i(1, 0)) == 1, "Nachbar rechts = 1")
for n in Hex.neighbors(Vector2i(2, 2)):
if Hex.distance(Vector2i(2, 2), n) != 1:
check(false, "Nachbar hat Distanz 1: " + str(n))
return
for n in Hex.neighbors(Vector2i(2, 3)):
if Hex.distance(Vector2i(2, 3), n) != 1:
check(false, "Nachbar (ungerade Zeile) hat Distanz 1: " + str(n))
return
check(true, "alle 6 Nachbarn haben Distanz 1 (gerade und ungerade Zeile)")
func test_upgrade_chain() -> void:
print("[Aufwerten]")
var pool := Pool.new()
var p := PlayerState.new(0, "Test", false)
p.gold = 100
# Beim Kauf gewählter Feuer-Avatar wird aufgestellt.
p.bench[0] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER] as Array[int], 1)
var ok := p.place(0, Vector2i(3, 1), 10)
check(ok, "Feuer-Avatar von der Bank aufstellen")
check(p.unlocked[Data.Element.FEUER], "Feuer erscheint danach im eigenen Shop")
var hex := Vector2i(3, 1)
check(not p.can_upgrade(hex), "ohne Futter kein Upgrade")
check(p.upgrade_missing(hex).get(Data.Element.FEUER, 0) == 2, "es fehlen 2 Feuer-Avatare")
p.bench[0] = Avatar.new(Data.Role.TANK, [Data.Element.FEUER], 1)
p.bench[1] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1)
check(p.can_upgrade(hex), "mit 2 Feuer-Avataren ist 2-Star möglich")
check(p.upgrade(hex, Data.Element.WIND, pool), "Upgrade auf 2-Star mit Wind als 2. Element")
var a: Avatar = p.board[hex]
check(a.star == 2 and a.elements == [Data.Element.FEUER, Data.Element.WIND], "2-Star Feuer/Wind")
check(p.bench[0] == null and p.bench[1] == null, "Futter wurde verbraucht")
var miss := p.upgrade_missing(hex)
check(miss.get(Data.Element.FEUER, 0) == 3 and miss.get(Data.Element.WIND, 0) == 3,
"3-Star braucht 3 Feuer und 3 Wind")
for i in 3:
p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1)
for i in range(3, 6):
p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.WIND], 1)
check(p.can_upgrade(hex), "mit 3+3 Futter ist 3-Star möglich")
check(p.upgrade(hex, Data.Element.BLITZ, pool), "Upgrade auf 3-Star mit Blitz")
a = p.board[hex]
check(a.star == 3 and a.elements.size() == 3, "3-Star mit drei Elementen")
check(p.upgrade_cost().is_empty() if false else a.upgrade_cost().is_empty(), "3-Star ist Maximum")
var s1 := Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1).base_stats()
check(a.base_stats()["hp"] > s1["hp"] * 3.0, "Stats steigen deutlich pro Stern")
func test_pool_shared() -> void:
print("[Geteilter Pool]")
var pool := Pool.new()
var p := PlayerState.new(0, "Test", false)
p.unlock(Data.Element.FEUER)
var taken := 0
while pool.take(Data.Element.FEUER):
taken += 1
check(taken == Data.POOL_PER_ELEMENT, "Pro Element nur %d Avatare insgesamt" % Data.POOL_PER_ELEMENT)
check(p.shop_elements(pool).is_empty(), "leergekauftes Element verschwindet aus dem Shop")
func test_combat_runs() -> void:
print("[Kampf]")
var pool := Pool.new()
var a := PlayerState.new(0, "A", false)
var b := PlayerState.new(1, "B", true)
for i in 4:
a.bench[i] = Avatar.new(i % 3, [Data.Element.FEUER] as Array[int], 1)
a.place(i, Vector2i(2 + i, i % 2), 20)
for i in 4:
b.bench[i] = Avatar.new(i % 3, [Data.Element.EIS] as Array[int], 1)
b.place(i, Vector2i(2 + i, i % 2), 20)
var c := Combat.new(a, b, 12345)
check(c.units.size() == 8, "8 Einheiten in der Arena")
var team0 := 0
for u in c.units:
if u.team == 0:
team0 += 1
check(team0 == 4, "4 Einheiten pro Team")
c.run_to_end()
check(c.finished, "Kampf endet")
check(c.winner in [-1, 0, 1], "Ergebnis ist gültig")
print(" Sieger: ", c.winner, " Überlebende: ", c.survivors, " Dauer: %.1fs" % c.time)
# Trait-Wirkung: Feuer 4 muss den Angriff erhöhen.
var counts := a.trait_counts()
check(counts.get(Data.Element.FEUER, 0) == 4, "4 Feuer-Einheiten aufgestellt")
check(Data.trait_tier(Data.Element.FEUER, 4) == 2, "Feuer-Trait auf Stufe 2")
func test_full_game() -> void:
print("[Kompletter Loop]")
var g := Game.new()
g.start(999)
check(g.players.size() == 8, "8 Spieler (du + 7 Bots)")
check(g.human.hp == 100 and g.human.gold == Data.START_GOLD, "Start: 100 Leben, %d Gold" % Data.START_GOLD)
check(g.human.shop.size() == Data.SHOP_SLOTS, "Shop hat %d Plätze" % Data.SHOP_SLOTS)
var only_neutral := true
for e in g.human.shop:
if e.element >= 0:
only_neutral = false
check(true, "Shop-Angebot Runde 1 (nur neutral: %s)" % str(only_neutral))
# Menschlichen Spieler wie einen Bot spielen lassen, damit der Loop läuft.
var ai := BotAI.new()
ai.setup(g.rand)
var guard := 0
while g.phase != Game.Phase.GAMEOVER and guard < 200:
ai.take_turn(g.human, g.pool, g.rand, g.round_no)
g.start_combat()
if g.phase == Game.Phase.COMBAT:
g.skip_combat()
guard += 1
check(g.phase == Game.Phase.GAMEOVER, "Spiel endet (Runden gespielt: %d)" % g.round_no)
var alive := g.alive_players()
check(alive.size() == 1, "genau ein Gewinner")
if alive.size() == 1:
print(" Sieger: ", alive[0].pname, " mit ", alive[0].hp, " Leben")
var sum_hp := 0
for p in g.players:
sum_hp += p.hp
check(sum_hp < 8 * Data.START_HP, "Leben wurden verloren")
var any_upgraded := false
var max_star := 1
for p in g.players:
for u in p.board_units():
max_star = maxi(max_star, u.star)
check(max_star >= 2, "mindestens ein Avatar wurde aufgewertet (max %d-Star)" % max_star)
var used := 0
for e in Data.ELEMENT_COUNT:
used += Data.POOL_PER_ELEMENT - g.pool.element_left[e]
check(used > 0, "Bots verbrauchen den geteilten Pool (%d Avatare gekauft)" % used)
func test_placements() -> void:
print("[Platzierungen]")
var g := Game.new()
g.start(4242)
var ai := BotAI.new()
ai.setup(g.rand)
var guard := 0
while g.phase != Game.Phase.GAMEOVER and guard < 200:
if g.human.alive:
ai.take_turn(g.human, g.pool, g.rand, g.round_no)
g.start_combat()
if g.phase == Game.Phase.COMBAT:
g.skip_combat()
guard += 1
var seen := {}
var ok := true
for p in g.players:
if p.placement < 1 or p.placement > 8 or seen.has(p.placement):
ok = false
print(" ", p.pname, " Platz ", p.placement, " hp ", p.hp)
seen[p.placement] = true
check(ok, "jeder Spieler hat genau einen Platz 1..8")
check(seen.size() == 8, "acht verschiedene Plätze vergeben")
func test_trait_effect() -> void:
print("[Trait-Wirkung]")
var pool := Pool.new()
# Team mit 4x Feuer -> Trait Stufe 2 (+26% Angriff).
var strong := PlayerState.new(0, "Feuer4", false)
for i in 4:
strong.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1)
# Team mit nur 1x Feuer -> kein Trait.
var weak := PlayerState.new(1, "Gemischt", false)
weak.board[Vector2i(0, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1)
for i in range(1, 4):
weak.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [i], 1)
var c := Combat.new(strong, weak, 777)
var atk_strong := 0.0
var atk_weak := 0.0
for u in c.units:
if u.avatar.has_element(Data.Element.FEUER):
if u.team == 0:
atk_strong = u.atk
else:
atk_weak = u.atk
check(atk_strong > atk_weak * 1.2,
"Feuer-Trait erhöht den Angriff (%d statt %d)" % [int(atk_strong), int(atk_weak)])
# Kombi-Trait: Feuer 2 + Wind 2 muss Feuersturm auslösen.
var combo := PlayerState.new(2, "Kombi", false)
for i in 2:
combo.board[Vector2i(i, 0)] = Avatar.new(Data.Role.DPS, [Data.Element.FEUER], 1)
combo.board[Vector2i(i, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND], 1)
var keys := []
for x in combo.active_combos():
keys.append(x["key"])
check(keys.has("feuersturm"), "Feuer 2 + Wind 2 aktiviert Feuersturm")
check(not keys.has("frost"), "Wasser/Eis bleibt ohne die Elemente inaktiv")
# Erde-Trait muss die Lebenspunkte im Kampf erhöhen.
var earth := PlayerState.new(3, "Erde4", false)
for i in 4:
earth.board[Vector2i(i, 1)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE], 1)
var c2 := Combat.new(earth, weak, 778)
var base_hp: float = Avatar.new(Data.Role.TANK, [Data.Element.ERDE], 1).base_stats()["hp"]
var got := 0.0
for u in c2.units:
if u.team == 0:
got = u.max_hp
check(got > base_hp * 1.2, "Erde-Trait erhöht Leben (%d statt %d)" % [int(got), int(base_hp)])
func test_buy_specialisation() -> void:
print("[Spezialisierung beim Kauf]")
var pool := Pool.new()
var p := PlayerState.new(0, "Test", false)
p.gold = 20
p.shop = [ShopEntry.new(Data.Role.TANK, -1)]
check(p.shop[0].element < 0, "Shop bietet einen neutralen Avatar an")
check(not p.buy(0, pool, -1), "Kauf ohne gewähltes Element wird abgelehnt")
var before: int = pool.element_left[Data.Element.BLITZ]
check(p.buy(0, pool, Data.Element.BLITZ), "Kauf mit Blitz als Spezialisierung")
var a: Avatar = p.bench[0]
check(a != null and a.elements == [Data.Element.BLITZ], "Avatar liegt als Blitz auf der Bank")
check(pool.element_left[Data.Element.BLITZ] == before - 1,
"der Kauf zieht einen Blitz aus dem geteilten Vorrat (%d -> %d)"
% [before, pool.element_left[Data.Element.BLITZ]])
check(not p.unlocked[Data.Element.BLITZ], "gekauft allein schaltet den Shop noch nicht frei")
p.place(0, Vector2i(3, 1), 10)
check(p.unlocked[Data.Element.BLITZ], "erst das Aufstellen schaltet Blitz im Shop frei")
func test_own_shop() -> void:
print("[Shop zeigt nur eigene Elemente]")
var pool := Pool.new()
var rng := RandomNumberGenerator.new()
rng.seed = 7
var me := PlayerState.new(0, "Ich", false)
var other := PlayerState.new(1, "Bot", true)
other.unlock(Data.Element.EIS)
me.unlock(Data.Element.FEUER)
var mine := me.shop_elements(pool)
var theirs := other.shop_elements(pool)
check(mine.size() == 1 and mine[0] == Data.Element.FEUER, "eigener Shop kennt nur Feuer")
check(theirs.size() == 1 and theirs[0] == Data.Element.EIS, "der Bot sieht nur sein eigenes Eis")
var seen := {}
for i in 200:
me.roll_shop(pool, rng)
for e in me.shop:
seen[e.element] = true
check(not seen.has(Data.Element.EIS), "fremdes Eis taucht im eigenen Shop nicht auf")
check(seen.has(Data.Element.FEUER) and seen.has(-1), "eigener Shop zeigt Feuer und Neutral")
func test_move_slot() -> void:
print("[Verschieben zwischen Bank und Board]")
var p := PlayerState.new(0, "Test", false)
var f := Avatar.new(Data.Role.TANK, [Data.Element.FEUER] as Array[int], 1)
var w := Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1)
p.bench[0] = f
p.bench[1] = w
var b0 := {"kind": "bench", "index": 0}
var b1 := {"kind": "bench", "index": 1}
var b3 := {"kind": "bench", "index": 3}
var h1 := {"kind": "board", "hex": Vector2i(2, 1)}
var h2 := {"kind": "board", "hex": Vector2i(4, 2)}
check(p.move_slot(b0, h1, 10), "Bank -> Board")
check(p.board.get(Vector2i(2, 1)) == f and p.bench[0] == null, "Feuer steht auf (2,1)")
check(p.unlocked[Data.Element.FEUER], "Aufstellen schaltet Feuer frei")
check(p.move_slot(h1, h2, 10), "Board -> Board")
check(p.board.get(Vector2i(4, 2)) == f and not p.board.has(Vector2i(2, 1)), "Feuer steht auf (4,2)")
check(p.move_slot(b1, h2, 10), "Bank auf ein belegtes Feld = Tausch")
check(p.board.get(Vector2i(4, 2)) == w and p.bench[1] == f, "Wind steht, Feuer liegt auf der Bank")
check(p.move_slot(h2, b3, 10), "Board -> Bank")
check(p.bench[3] == w and p.board.is_empty(), "Wind liegt wieder auf der Bank")
check(p.move_slot(b3, b1, 10), "Bank -> Bank ist ein Tausch")
check(p.bench[1] == w and p.bench[3] == f, "beide haben die Plätze getauscht")
# Kaderplätze bleiben begrenzt.
for i in 5:
p.bench[i] = Avatar.new(Data.Role.DPS, [Data.Element.EIS] as Array[int], 1)
var placed := 0
for i in 5:
if p.move_slot({"kind": "bench", "index": i}, {"kind": "board", "hex": Vector2i(i, 0)}, 1):
placed += 1
check(placed == 3 and p.board.size() == 3, "Runde 1 lässt nur 3 Einheiten zu (%d)" % placed)
check(not p.move_slot({"kind": "board", "hex": Vector2i(9, 9)}, b0, 10),
"Feld ausserhalb des Boards wird abgelehnt")
func test_interest() -> void:
print("[Zinsen]")
check(Data.interest(0) == 0, "0 Gold -> 0 Zins")
check(Data.interest(9) == 0, "9 Gold -> 0 Zins")
check(Data.interest(10) == 1, "10 Gold -> 1 Zins")
check(Data.interest(35) == 3, "35 Gold -> 3 Zins")
check(Data.interest(49) == 4, "49 Gold -> 4 Zins")
check(Data.interest(50) == 5, "50 Gold -> 5 Zins")
check(Data.interest(200) == 5, "über 50 Gold bleibt es bei 5 Zins")
var p := PlayerState.new(0, "Test", false)
p.gold = 0
check(p.round_income() == Data.BASE_INCOME, "ohne Gold nur die 5 Grundgold")
p.gold = 30
check(p.round_income() == Data.BASE_INCOME + 3, "30 Gold -> 5 + 3 Zins")
p.gold = 50
p.register_result(true)
check(p.round_income() == Data.BASE_INCOME + Data.WIN_GOLD + 5,
"50 Gold mit Sieg -> 5 + 1 + 5 Zins")
for i in 4:
p.register_result(true)
check(p.round_income() == Data.BASE_INCOME + Data.WIN_GOLD + 5 + Data.streak_gold(5),
"Serie kommt oben drauf")
func test_prices() -> void:
print("[Preise]")
check(Data.PRICE_NONE == 1, "neutraler Avatar kostet 1 Gold")
check(Data.PRICE_ELEMENT == 2, "Element-Avatar kostet 2 Gold")
var pool := Pool.new()
var p := PlayerState.new(0, "Test", false)
p.gold = 10
p.shop = [ShopEntry.new(Data.Role.TANK, -1), ShopEntry.new(Data.Role.DPS, Data.Element.EIS)]
check(p.shop[0].price() == 1 and p.shop[1].price() == 2, "Shop rechnet mit 1 und 2 Gold")
p.buy(0, pool, Data.Element.FEUER)
check(p.gold == 9, "neutraler Kauf zieht 1 Gold ab")
p.buy(1, pool, -1)
check(p.gold == 7, "Element-Kauf zieht 2 Gold ab")
p.sell_bench(0, pool)
check(p.gold == 8, "Verkauf gibt den Kaufpreis zurück")
func test_pool_pressure() -> void:
print("[Vorrat hält eine ganze Partie]")
var lowest := Data.POOL_PER_ELEMENT
var early_dry := 0
for run in 4:
var g := Game.new()
g.start(4000 + run)
var ai := BotAI.new()
ai.setup(g.rand)
var guard := 0
while g.phase != Game.Phase.GAMEOVER and guard < 200:
if g.human.alive:
ai.take_turn(g.human, g.pool, g.rand, g.round_no)
g.start_combat()
if g.phase == Game.Phase.COMBAT:
g.skip_combat()
for e in Data.ELEMENT_COUNT:
lowest = mini(lowest, g.pool.element_left[e])
if g.round_no <= 15:
for e in Data.ELEMENT_COUNT:
if g.pool.element_left[e] <= 0:
early_dry += 1
guard += 1
check(early_dry == 0, "bis Runde 15 läuft kein Element leer")
check(lowest < Data.POOL_PER_ELEMENT / 2, "der Vorrat wird spürbar verbraucht (Tiefstand %d von %d)"
% [lowest, Data.POOL_PER_ELEMENT])
func test_neutral_chance() -> void:
print("[Neutral-Chance sinkt je Element]")
check(is_equal_approx(Data.neutral_chance(0), 1.0), "ohne eigene Elemente nur Neutral")
check(is_equal_approx(Data.neutral_chance(1), 0.75), "1 Element -> 75% Neutral")
check(is_equal_approx(Data.neutral_chance(2), 0.5625), "2 Elemente -> 56%")
check(Data.neutral_chance(6) > 0.0, "Neutral verschwindet nie ganz (%.1f%%)"
% (Data.neutral_chance(6) * 100.0))
var pool := Pool.new()
var rng := RandomNumberGenerator.new()
rng.seed = 99
var quotes := []
for count in range(0, 5):
var p := PlayerState.new(0, "Test", false)
for e in count:
p.unlock(e)
var neutral := 0
var total := 0
for i in 400:
p.roll_shop(pool, rng)
for entry in p.shop:
total += 1
if entry.element < 0:
neutral += 1
quotes.append(float(neutral) / float(total))
print(" gemessene Neutral-Quoten: ", quotes)
check(is_equal_approx(quotes[0], 1.0), "0 Elemente: alles neutral")
var falling := true
for i in range(1, quotes.size()):
if quotes[i] >= quotes[i - 1]:
falling = false
check(falling, "die Quote sinkt mit jedem weiteren Element")
for i in quotes.size():
if absf(quotes[i] - Data.neutral_chance(i)) > 0.05:
check(false, "Quote bei %d Elementen weicht ab (%.2f statt %.2f)"
% [i, quotes[i], Data.neutral_chance(i)])
return
check(true, "jede Quote trifft den erwarteten Wert auf 5 Prozentpunkte genau")

1
tests/sim_test.gd.uid Normal file
View File

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