first commit
This commit is contained in:
1
.godot/.gdignore
Normal file
1
.godot/.gdignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[folding]
|
||||||
|
|
||||||
|
node_unfolds=[]
|
||||||
|
resource_unfolds=[]
|
||||||
|
nodes_folded=[]
|
||||||
|
animation_groups_folded=[]
|
||||||
30
.godot/editor/filesystem_cache10
Normal file
30
.godot/editor/filesystem_cache10
Normal 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<><>::
|
||||||
11
.godot/editor/project_metadata.cfg
Normal file
11
.godot/editor/project_metadata.cfg
Normal 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"]
|
||||||
97
.godot/global_script_class_cache.cfg
Normal file
97
.godot/global_script_class_cache.cfg
Normal 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
BIN
.godot/uid_cache.bin
Normal file
Binary file not shown.
102
README.md
Normal file
102
README.md
Normal 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
30
anforderungen.md
Normal 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
21
project.godot
Normal 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
6
scenes/Main.tscn
Normal 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
125
scripts/core/Avatar.gd
Normal 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
|
||||||
1
scripts/core/Avatar.gd.uid
Normal file
1
scripts/core/Avatar.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bq5aj8ldpjnqo
|
||||||
155
scripts/core/BotAI.gd
Normal file
155
scripts/core/BotAI.gd
Normal 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)
|
||||||
1
scripts/core/BotAI.gd.uid
Normal file
1
scripts/core/BotAI.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bl3psu7dnb3x2
|
||||||
477
scripts/core/Combat.gd
Normal file
477
scripts/core/Combat.gd
Normal 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)
|
||||||
1
scripts/core/Combat.gd.uid
Normal file
1
scripts/core/Combat.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bkybe632bjrh8
|
||||||
161
scripts/core/Data.gd
Normal file
161
scripts/core/Data.gd
Normal 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
1
scripts/core/Data.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cpbejautavs4q
|
||||||
210
scripts/core/Game.gd
Normal file
210
scripts/core/Game.gd
Normal 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
1
scripts/core/Game.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dj3pdat6ft684
|
||||||
51
scripts/core/Hex.gd
Normal file
51
scripts/core/Hex.gd
Normal 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
1
scripts/core/Hex.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bv0038ucsvxtk
|
||||||
359
scripts/core/PlayerState.gd
Normal file
359
scripts/core/PlayerState.gd
Normal 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
|
||||||
1
scripts/core/PlayerState.gd.uid
Normal file
1
scripts/core/PlayerState.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ddat00fvvoxjv
|
||||||
48
scripts/core/Pool.gd
Normal file
48
scripts/core/Pool.gd
Normal 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
1
scripts/core/Pool.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dp7fj0pjyyyye
|
||||||
32
scripts/core/ShopEntry.gd
Normal file
32
scripts/core/ShopEntry.gd
Normal 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
|
||||||
1
scripts/core/ShopEntry.gd.uid
Normal file
1
scripts/core/ShopEntry.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ch7jrt780kmt8
|
||||||
422
scripts/ui/BoardView3D.gd
Normal file
422
scripts/ui/BoardView3D.gd
Normal 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
|
||||||
1
scripts/ui/BoardView3D.gd.uid
Normal file
1
scripts/ui/BoardView3D.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cyxqqwolqywtm
|
||||||
53
scripts/ui/HudOverlay.gd
Normal file
53
scripts/ui/HudOverlay.gd
Normal 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)
|
||||||
1
scripts/ui/HudOverlay.gd.uid
Normal file
1
scripts/ui/HudOverlay.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bhbduae6n3bp5
|
||||||
843
scripts/ui/Main.gd
Normal file
843
scripts/ui/Main.gd
Normal 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
1
scripts/ui/Main.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dgtlalmkli5w4
|
||||||
56
scripts/ui/UiKit.gd
Normal file
56
scripts/ui/UiKit.gd
Normal 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
1
scripts/ui/UiKit.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dguo4u0xtal7d
|
||||||
68
tests/CamCal.gd
Normal file
68
tests/CamCal.gd
Normal 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
141
tests/Probe2.gd
Normal 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
1
tests/Probe2.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://djhq2cshy63mj
|
||||||
394
tests/Probe3.gd
Normal file
394
tests/Probe3.gd
Normal 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
1
tests/Probe3.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cf5y2v8d1k5pb
|
||||||
6
tests/camcal.tscn
Normal file
6
tests/camcal.tscn
Normal 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
7
tests/prev_check.gd
Normal 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
6
tests/probe2.tscn
Normal 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
6
tests/probe3.tscn
Normal 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
442
tests/sim_test.gd
Normal 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
1
tests/sim_test.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bpluiwuwx5rtr
|
||||||
Reference in New Issue
Block a user