Dinger: Kartenspiel in Godot 4.7

Elemente, Kombos, Fallen, KI mit Vorausschau, Kampagne, Hotseat und Netzwerk,
Werkstatt, Erfolge, Tagesaufgaben, Tests und Exporte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
This commit is contained in:
Team3
2026-09-16 13:22:07 +02:00
commit 536a523f50
84 changed files with 9859 additions and 0 deletions

35
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Tests
on: [push, pull_request]
jobs:
headless:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Godot laden
run: |
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot
- name: Tests
run: ./tests/run_headless.sh ./godot
fenster:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Godot und Xvfb laden
run: |
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
- name: Import
run: ./godot --headless --path . --import || true
- name: Fenster-Test (virtueller Bildschirm)
run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
- name: Ergebnis
run: grep -q " 0 fehlgeschlagen" fenster.log

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.godot/
build/
python/.venv/
python/**/__pycache__/

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# Dinger
Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln.
## Start
Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal:
```
godot --path .
```
Der Spielstand liegt unter `user://spielstand.json`
(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`).
## Spielsysteme
- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen
- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus
- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht)
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern, Erfolge, Tagesaufgabe
- Lokal zu mehreren: bis zu 4 Menschen am selben Rechner plus KI
- Booster, fünf Deck-Slots, Werkstatt (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen)
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
- Anleitung (geführte erste Partie), Mulligan im ersten Zug, Tastatur (Leertaste, 1 bis 9, Esc, Enter)
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten
- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand)
- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch
## Tests
`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`).
```
godot --headless --path . --import # einmalig: Ressourcen importieren
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
godot --headless --path . -s tests/dump_cards.gd # Kartenpool aus Code nach JSON schreiben (nur bei Bedarf)
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
```
## Export
Export-Vorlagen für Godot 4.7 installieren (Editor: Editor → Export-Vorlagen verwalten), dann:
```
godot --headless --path . --export-release Linux build/dinger-linux.x86_64
godot --headless --path . --export-release Windows build/dinger-windows.exe
```
## Aufbau
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Gegner, Hotseat
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton
- `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet)
- `translations/ui.csv` – englische Oberflächentexte

24
anforderungen.md Normal file
View File

@@ -0,0 +1,24 @@
# Dinger
## Grundidee
- Kartenspiel wie zum Beispiel Magic
- PC-Spiel
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich
- Geführte erste Partie als Einstieg
## Sammeln
- Booster öffnen
- Karten haben Seltenheiten
- Aus der Sammlung werden Decks gebaut (mehrere Slots)
- Überschüssige Karten lassen sich zerlegen und neue herstellen
## Elemente
- Karten gehören zu Elementen
- Elemente sind kombinierbar, daraus entstehen viele Varianten
- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer)
- Jedes Element und jede Kombo hat eine eigene Spielweise
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus
- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://jqogbchdskrc"
path="res://.godot/imported/DejaVuSans-Bold.ttf-28339d4fde761e3a49fa62b16c3f5900.fontdata"
[deps]
source_file="res://assets/fonts/DejaVuSans-Bold.ttf"
dest_files=["res://.godot/imported/DejaVuSans-Bold.ttf-28339d4fde761e3a49fa62b16c3f5900.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

BIN
assets/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://l7leg081apqp"
path="res://.godot/imported/DejaVuSans.ttf-0dcd47e426510e02f045e926ff1bfdf0.fontdata"
[deps]
source_file="res://assets/fonts/DejaVuSans.ttf"
dest_files=["res://.godot/imported/DejaVuSans.ttf-0dcd47e426510e02f045e926ff1bfdf0.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

2002
data/karten.json Normal file

File diff suppressed because it is too large Load Diff

73
export_presets.cfg Normal file
View File

@@ -0,0 +1,73 @@
[preset.0]
name="Linux"
platform="Linux"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="*.json"
exclude_filter="python/*"
export_path="build/dinger-linux.x86_64"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
binary_format/architecture="x86_64"
[preset.1]
name="Windows"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="*.json"
exclude_filter="python/*"
export_path="build/dinger-windows.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
binary_format/architecture="x86_64"
application/modify_resources=true
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name="Dinger"
application/product_name="Dinger"
application/file_description="Dinger Kartenspiel"
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
application/d3d12_agility_sdk_multiarch=true

28
project.godot Normal file
View File

@@ -0,0 +1,28 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
config_version=5
[application]
config/name="Dinger"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
[display]
window/size/viewport_width=1280
window/size/viewport_height=800
window/stretch/mode="canvas_items"
window/stretch/aspect="keep"
[internationalization]
locale/translations=PackedStringArray("res://translations/ui.en.translation")
locale/fallback="de"
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"

12
scenes/main.tscn Normal file
View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/ui/main.gd" id="1"]
[node name="Main" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

269
scripts/ai.gd Normal file
View File

@@ -0,0 +1,269 @@
class_name AI
## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des
## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante
## (choose_action_greedy) dient als Vergleich.
# --- Bewertung -------------------------------------------------------------------
static func creature_value(c: Game.Creature) -> float:
var v := float(c.power() + c.health)
if c.taunt():
v += 2.0
if c.shield:
v += 1.5
if c.attacks_per_turn() == 2:
v += c.power()
if c.frozen:
v -= c.power() * 0.5
return v
static func board_value(p: Game.Player) -> float:
var v := 0.0
for c in p.board:
v += creature_value(c)
return v
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv,
## die der Gegner negativ. Ein besiegter Gegner ist viel wert.
static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive():
return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
var alive_enemies := 0
for e in g.players:
if e == p:
continue
if not e.alive():
score += 40.0
continue
alive_enemies += 1
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
if e.life <= 6:
score += 3.0 # fast besiegt: dranbleiben
if alive_enemies == 0:
return 10000.0
return score
# --- Vorausschau ---------------------------------------------------------------
static func candidate_actions(game: Game, p: Game.Player) -> Array:
var out := []
for card in p.hand:
if not game.can_play(p, card)[0]:
continue
var tt: String = card.target_type()
if tt == "":
out.append({"type": "play", "card": card, "target": null})
else:
var valid := game.valid_targets(p, tt)
if valid.is_empty():
out.append({"type": "play", "card": card, "target": null})
for t in valid:
out.append({"type": "play", "card": card, "target": t})
for cr in p.board:
if not cr.can_attack():
continue
for e in game.enemies_of(p):
for t in e.board + [e]:
if game.can_attack(cr, t)[0]:
out.append({"type": "attack", "creature": cr, "target": t})
return out
## Führt eine Aktion des Originals auf der Kopie aus.
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
var p2: Game.Player = g2.players[game.current_idx]
var target = null
if action.get("target") != null:
target = map_target(g2, game, action.target)
if action.type == "play":
g2.play_card(p2, action.card, target)
else:
g2.attack(g2.find_creature(action.creature.uid), target)
static func map_target(g2: Game, game: Game, t):
if t is Game.Creature:
return g2.find_creature(t.uid)
return g2.players[game.players.find(t)]
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
static func mulligan_cards(p: Game.Player) -> Array:
return p.hand.filter(func(c): return c.cost >= 5)
## Beste Aktion nach einer Vorausschau von zwei Schritten: jede Aktion wird auf einer
## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen).
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current():
return {"type": "mulligan", "cards": mulligan_cards(p)}
var best := {"type": "end"}
var best_score := evaluate(game, p) + 0.01
for action in candidate_actions(game, p):
var g2 := game.clone_for(p)
apply_on_clone(g2, game, action)
var p2: Game.Player = g2.players[game.current_idx]
var score := evaluate(g2, p2)
if g2.winner == "":
var count := 0
for f in candidate_actions(g2, p2):
if count >= FOLLOW_UPS:
break
count += 1
var g3 := g2.clone_for(p2)
apply_on_clone(g3, g2, f)
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001)
if score > best_score:
best_score = score
best = action
return best
# --- Gierige Vergleichs-KI -------------------------------------------------------
static func _kills(attacker, defender) -> bool:
return not defender.shield and defender.health <= attacker.power()
static func _value(c) -> int:
return c.power() + c.health + (2 if c.taunt() else 0)
static func _best(arr: Array, key: Callable):
var best = null
var best_v := 0
for x in arr:
var v: int = key.call(x)
if best == null or v > best_v:
best = x
best_v = v
return best
static func _threat_score(h) -> int:
var board_attack := 0
for c in h.board:
board_attack += c.power()
return h.life - board_attack
static func _weakest(heroes: Array):
var best = null
for h in heroes:
if best == null or _threat_score(h) < _threat_score(best):
best = h
return best
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current():
return {"type": "mulligan", "cards": mulligan_cards(p)}
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
if not playable.is_empty():
Game.shuffle(playable, game.rng)
var card = playable[0]
for c in playable:
if c.cost > card.cost:
card = c
return {"type": "play", "card": card, "target": pick_target(game, p, card)}
for cr in p.board:
if cr.can_attack():
var t = pick_attack_target(game, p, cr)
if t != null:
return {"type": "attack", "creature": cr, "target": t}
return {"type": "end"}
static func pick_target(game: Game, p: Game.Player, card: Cards.Card):
var tt := card.target_type()
if tt == "":
return null
var valid := game.valid_targets(p, tt)
if valid.is_empty():
return null
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var creatures := valid.filter(func(t): return t is Game.Creature)
var heroes := valid.filter(func(t): return t is Game.Player)
match fx.kind:
"damage":
var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount)
if not killable.is_empty():
return _best(killable, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
return _best(creatures, Callable(AI, "_value"))
"freeze", "bounce":
return _best(creatures, func(c): return c.card.cost * 100 + c.power())
"buff", "shield":
return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power())
return valid[0]
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
var enemies := game.enemies_of(p)
var total := 0
for c in p.board:
if c.can_attack():
total += c.power() * c.attacks_left
var options := []
for e in enemies:
var taunts: Array = e.board.filter(func(c): return c.taunt())
if not taunts.is_empty():
options.append_array(taunts)
else:
if total >= e.life:
return e
options.append_array(e.board)
options.append(e)
var creatures := options.filter(func(t): return t is Game.Creature)
var heroes := options.filter(func(t): return t is Game.Player)
var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr))
if not good.is_empty():
return _best(good, Callable(AI, "_value"))
var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr))
if not trade.is_empty():
return _best(trade, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
if not creatures.is_empty():
return _best(creatures, func(c): return 1000 - c.health)
return null
# --- Ausführung -------------------------------------------------------------------
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
match action.type:
"play":
game.play_card(p, action.card, action.target)
"attack":
game.attack(action.creature, action.target)
"mulligan":
game.mulligan(p, action.cards)
_:
game.end_turn()
## Spielt den ganzen Zug (für Simulationen und Tests). greedy: gierige KI statt Vorausschau.
static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: int = 100) -> void:
for i in limit:
var action := choose_action_greedy(game, p) if greedy else choose_action(game, p)
apply_action(game, p, action)
if action.type == "end" or game.winner != "":
return

1
scripts/ai.gd.uid Normal file
View File

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

59
scripts/campaign.gd Normal file
View File

@@ -0,0 +1,59 @@
class_name Campaign
## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch.
const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "waechter_leben"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "flink_angriff"},
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "zauber_schaden"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "extra_karten"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "resonanz_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "bias": 2.5, "reward": 300, "perk": "alle_flink"},
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "bias": 3.0, "reward": 500, "perk": "start_energie"},
]
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück.
const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["leben_35", "extra_karten"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["extra_karten", "flink_angriff"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "bias": 3.5, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "bias": 4.0, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]},
]
static func total() -> int:
return STAGES.size() + STAGES2.size()
static func chapter(stage: int) -> int:
return 1 if stage < STAGES.size() else 2
## Stufe mit einheitlichem Feld "perks".
static func stage(i: int) -> Dictionary:
if i < STAGES.size():
var st: Dictionary = STAGES[i].duplicate()
st["perks"] = [st.perk]
return st
return STAGES2[i - STAGES.size()]
static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array:
var st := stage(stage_index)
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
var boss := Game.Player.new(st.name, Game.build_opponent_deck(r, st.elements, st.bias), false, life, st.title)
boss.perks = Array(st.perks)
var out := [boss]
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
for k in extra:
out.append(Game.random_opponent(r, k + 1))
return out

1
scripts/campaign.gd.uid Normal file
View File

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

292
scripts/cards.gd Normal file
View File

@@ -0,0 +1,292 @@
class_name Cards
## Elemente, Kombos und der Kartenpool.
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
const ELEMENT_COLORS := {
"Feuer": Color("be3c23"),
"Wasser": Color("2d64be"),
"Erde": Color("73552d"),
"Luft": Color("78aac8"),
}
# Jede Paarung ist eine Kombo, auch gleiche Elemente.
const COMBOS := {
"Feuer+Feuer": "Inferno",
"Wasser+Wasser": "Tiefsee",
"Erde+Erde": "Berg",
"Luft+Luft": "Orkan",
"Feuer+Wasser": "Dampf",
"Feuer+Erde": "Lava",
"Feuer+Luft": "Blitz",
"Wasser+Erde": "Schlamm",
"Wasser+Luft": "Sturm",
"Erde+Luft": "Sand",
# Dreifach-Kombos: die mythische Stufe
"Feuer+Feuer+Feuer": "Sonne",
"Wasser+Wasser+Wasser": "Ozean",
"Erde+Erde+Erde": "Gebirge",
"Luft+Luft+Luft": "Himmel",
}
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
const FIELD_COMBO_TEXT := {
"Dampf": "Friere das stärkste gegnerische Wesen ein",
"Lava": "1 Schaden an alle gegnerischen Wesen",
"Blitz": "2 Schaden an den schwächsten Gegner",
"Schlamm": "Heile dich um 3",
"Sturm": "Ziehe 1 Karte",
"Sand": "Das neue Wesen erhält Schild",
}
const RESONANCE_TEXT := "Resonanz: Ein Wesen hat +1 Angriff je anderem eigenen Wesen, das ein Element mit ihm teilt (höchstens +4)."
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
const RARITY_COLORS := {
"gewöhnlich": Color("aaaaaa"),
"selten": Color("4682e6"),
"episch": Color("aa46dc"),
"legendär": Color("f0aa28"),
"mythisch": Color("ff5a8c"),
}
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"]
const KEYWORD_HELP := {
"Wächter": "Muss zuerst angegriffen werden.",
"Flink": "Kann sofort angreifen.",
"Schild": "Blockt den nächsten Schaden.",
"Doppelschlag": "Greift zweimal pro Zug an.",
}
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
const DATA_PATH := "res://data/karten.json"
static func combo_key(elements: Array) -> String:
var sorted := elements.duplicate()
sorted.sort_custom(func(a, b): return ELEMENTS.find(a) < ELEMENTS.find(b))
return "+".join(sorted)
static func combo_name(elements: Array) -> String:
if elements.size() == 1:
return elements[0]
return COMBOS[combo_key(elements)]
static func field_combo_effect(name: String) -> Effect:
match name:
"Dampf":
return Effect.new("freeze", "strongest_enemy_creature")
"Lava":
return Effect.new("damage", "all_enemy_creatures", 1)
"Blitz":
return Effect.new("damage", "weakest_enemy_hero", 2)
"Schlamm":
return Effect.new("heal", "self_hero", 3)
"Sturm":
return Effect.new("draw", "none", 1)
"Sand":
return Effect.new("shield", "self_creature")
return null
const TRIGGER_TEXT := {
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift",
"creature_played": "Wenn ein Gegner ein Wesen ausspielt",
}
class Effect extends RefCounted:
var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel
var target: String # siehe CHOSEN_TARGETS und Game.resolve()
var amount: int
var amount2: int
var card_id: String # für summon
func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void:
kind = k
target = t
amount = a
amount2 = a2
card_id = cid
func to_dict() -> Dictionary:
var d := {"kind": kind, "target": target}
if amount != 0:
d["amount"] = amount
if amount2 != 0:
d["amount2"] = amount2
if card_id != "":
d["card_id"] = card_id
return d
static func from_dict(d: Dictionary) -> Effect:
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
func text() -> String:
var n := str(amount)
if target == "trigger_source":
match kind:
"damage":
return n + " Schaden an den Angreifer"
"freeze":
return "Der Angreifer wird eingefroren"
"bounce":
return "Es geht zurück auf die Hand"
match kind:
"cancel":
return "Der Angriff verpufft"
"summon":
return "Beschwöre einen " + Cards.by_id(card_id).name
"damage":
match target:
"enemy_creature":
return n + " Schaden an ein gegnerisches Wesen"
"enemy_any":
return n + " Schaden an ein Ziel"
"all_enemy_creatures":
return n + " Schaden an alle gegnerischen Wesen"
"all_enemy_heroes":
return n + " Schaden an alle Gegner"
"all_enemies":
return n + " Schaden an alle Gegner und ihre Wesen"
"heal":
return "Heile dich um " + n
"draw":
return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten"
"freeze":
if target == "enemy_creature":
return "Friere ein gegnerisches Wesen ein"
return "Friere alle gegnerischen Wesen ein"
"bounce":
if target == "enemy_creature":
return "Ein gegnerisches Wesen zurück auf die Hand"
return "Alle gegnerischen Wesen zurück auf die Hand"
"buff":
var b := "+%d/+%d" % [amount, amount2]
if target == "friendly_creature":
return "Ein befreundetes Wesen erhält " + b
return "Alle befreundeten Wesen erhalten " + b
"shield":
if target == "friendly_creature":
return "Ein befreundetes Wesen erhält Schild"
if target == "self_creature":
return "Erhält Schild"
return "Alle befreundeten Wesen erhalten Schild"
return ""
class Card extends RefCounted:
var id: String
var name: String
var elements: Array
var cost: int
var rarity: String
var kind: String # "Wesen", "Zauber" oder "Falle"
var attack: int = 0
var health: int = 0
var keywords: Array = []
var effects: Array = []
var trigger: String = "" # nur Fallen: attacked, creature_played
func is_creature() -> bool:
return kind == "Wesen"
func is_trap() -> bool:
return kind == "Falle"
func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
if is_creature():
d["attack"] = attack
d["health"] = health
if not keywords.is_empty():
d["keywords"] = keywords
if not effects.is_empty():
d["effects"] = effects.map(func(e): return e.to_dict())
if trigger != "":
d["trigger"] = trigger
return d
static func from_dict(d: Dictionary) -> Card:
var c := Card.new()
c.id = d.id
c.name = d.name
c.elements = Array(d.elements)
c.cost = int(d.cost)
c.rarity = d.rarity
c.kind = d.kind
c.attack = int(d.get("attack", 0))
c.health = int(d.get("health", 0))
c.keywords = Array(d.get("keywords", []))
for e in d.get("effects", []):
c.effects.append(Effect.from_dict(e))
c.trigger = str(d.get("trigger", ""))
return c
func is_combo() -> bool:
return elements.size() >= 2
func combo() -> String:
return Cards.combo_name(elements)
func target_type() -> String:
for fx in effects:
if fx.target in Cards.CHOSEN_TARGETS:
return fx.target
return ""
func text() -> String:
var parts: Array[String] = []
if not keywords.is_empty():
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in keywords:
kws.append(k)
parts.append(", ".join(kws) + ".")
if not effects.is_empty():
var fx: Array[String] = []
for f in effects:
fx.append(f.text())
var s := ", ".join(fx) + "."
if is_trap():
if trigger == "creature_played":
s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird")
parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s])
else:
parts.append(("Beim Ausspielen: " + s) if is_creature() else s)
return " ".join(parts)
static var _all: Array = []
static var _by_id: Dictionary = {}
static func all() -> Array:
if _all.is_empty():
_build()
return _all
static func by_id(id: String) -> Card:
if _all.is_empty():
_build()
return _by_id[id]
static func has_id(id) -> bool:
if _all.is_empty():
_build()
return _by_id.has(id)
static func of_rarity(r: String) -> Array:
return all().filter(func(c): return c.rarity == r)
## Lädt den Kartenpool aus data/karten.json.
static func _build() -> void:
var json := JSON.new()
var ok := json.parse(FileAccess.get_file_as_string(DATA_PATH))
assert(ok == OK and json.data is Dictionary and json.data.has("karten"), "data/karten.json fehlt oder ist ungültig")
_all = []
_by_id = {}
for d in json.data.karten:
var c := Card.from_dict(d)
assert(not _by_id.has(c.id), "Karten-ID doppelt: " + c.id)
_by_id[c.id] = c
_all.append(c)

1
scripts/cards.gd.uid Normal file
View File

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

33
scripts/challenge.gd Normal file
View File

@@ -0,0 +1,33 @@
class_name Challenge
## Wöchentliche Herausforderung: feste Regelvariante, einmal pro Woche belohnt.
const LIST := [
{"id": "eile", "name": "Eile", "text": "Alle beginnen mit 3 Energie und 20 Leben, drei Gegner.", "n_opp": 3,
"rules": {"everyone_perk": "start_energie", "start_life": 20}, "reward": 200},
{"id": "resonanz", "name": "Resonanzsturm", "text": "Resonanz zählt für alle doppelt, zwei Gegner.", "n_opp": 2,
"rules": {"everyone_perk": "resonanz_doppelt"}, "reward": 180},
{"id": "flink", "name": "Blitzkrieg", "text": "Alle Wesen sind flink, vier Gegner.", "n_opp": 4,
"rules": {"everyone_perk": "alle_flink"}, "reward": 250},
{"id": "schild", "name": "Panzerschlacht", "text": "Alle Wesen kommen mit Schild, Duell mit 40 Leben.", "n_opp": 1,
"rules": {"everyone_perk": "schild_wesen", "start_life": 40}, "reward": 150},
{"id": "karten", "name": "Kartenflut", "text": "Jeder zieht 2 Karten pro Zug, fünf Gegner am Tisch.", "n_opp": 5,
"rules": {"everyone_perk": "extra_karten"}, "reward": 300},
{"id": "mono", "name": "Reine Lehre", "text": "Nur Decks mit einem Element sind erlaubt, drei Gegner.", "n_opp": 3,
"rules": {"mono_only": true}, "reward": 220},
]
static func week() -> int:
var t := Profile.today()
return int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 604800)
static func current() -> Dictionary:
return LIST[week() % LIST.size()]
## Prüft, ob das Deck zur Herausforderung passt. Gibt "" oder eine Fehlermeldung zurück.
static func deck_allowed(ch: Dictionary, deck: Array) -> String:
if ch.rules.get("mono_only", false) and Game.deck_elements(deck).size() != 1:
return "Diese Woche sind nur Decks mit einem Element erlaubt"
return ""

1
scripts/challenge.gd.uid Normal file
View File

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

854
scripts/game.gd Normal file
View File

@@ -0,0 +1,854 @@
class_name Game
extends RefCounted
## Spielregeln: Spieler, Wesen, Züge, Angriffe, Effekte, Feldkombos, Resonanz.
const MAX_BOARD := 5
const MAX_HAND := 8
const MAX_ENERGY := 10
const START_HAND := 4
# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben.
const START_LIFE := 30
const MAX_OPPONENTS := 5
const RESONANCE_MAX := 4
const MAX_TRAPS := 2
# Merkmale der Kampagnen-Bosse (Player.perks).
const PERKS := {
"feuer_angriff": "Feuerwesen haben +1 Angriff",
"flink_angriff": "Flinke Wesen haben +1 Angriff",
"waechter_leben": "Wächter kommen mit +2 Leben",
"extra_karten": "Zieht 2 Karten pro Zug",
"leben_35": "Beginnt mit 35 Leben",
"zauber_schaden": "Schadenszauber machen +1 Schaden",
"schild_wesen": "Wesen kommen mit Schild",
"alle_flink": "Alle Wesen sind flink",
"start_energie": "Beginnt mit 3 Energie",
"resonanz_doppelt": "Resonanz zählt doppelt",
}
## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch).
static var test_seed := 0
static func make_rng() -> RandomNumberGenerator:
var r := RandomNumberGenerator.new()
if test_seed != 0:
r.seed = test_seed
test_seed += 1
else:
r.randomize()
return r
const DECK_SIZE := 20
const MAX_COPIES := 3
const MAX_DECK_ELEMENTS := 2
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
class Creature extends RefCounted:
var card: Cards.Card
var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen
var name: String
var uid: int
var attack: int
var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt
var health: int
var max_health: int
var shield: bool
var frozen := false
var ready: bool
var attacks_left: int
func _init(c: Cards.Card, o, id: int) -> void:
card = c
owner_ref = weakref(o)
name = c.name
uid = id
attack = c.attack
health = c.health
max_health = c.health
shield = "Schild" in c.keywords
ready = "Flink" in c.keywords
attacks_left = attacks_per_turn() if ready else 0
func owner():
return owner_ref.get_ref()
## Angriffswert inklusive Resonanz und Boss-Merkmalen.
func power() -> int:
var v := attack + resonance
var o = owner()
if o != null:
if "feuer_angriff" in o.perks and "Feuer" in card.elements:
v += 1
if "flink_angriff" in o.perks and "Flink" in card.keywords:
v += 1
return v
func attacks_per_turn() -> int:
return 2 if "Doppelschlag" in card.keywords else 1
func taunt() -> bool:
return "Wächter" in card.keywords
func can_attack() -> bool:
return ready and attacks_left > 0 and not frozen and power() > 0
func clone(new_owner) -> Creature:
var c := Creature.new(card, new_owner, uid)
c.attack = attack
c.resonance = resonance
c.health = health
c.max_health = max_health
c.shield = shield
c.frozen = frozen
c.ready = ready
c.attacks_left = attacks_left
return c
class Player extends RefCounted:
var name: String
var title: String
var is_human: bool
var life: int
var max_life: int
var deck: Array = []
var hand: Array = []
var board: Array = []
var energy := 0
var max_energy := 0
var fatigue := 0
var start_bonus := 0 # einmalige Extra-Energie im ersten Zug (Ausgleich fürs Nachziehen)
var traps: Array = [] # verdeckt liegende Fallenkarten
var order := 0 # Platz in der Zugreihenfolge
var perks: Array = [] # Boss-Merkmale (siehe PERKS)
var turns := 0 # begonnene eigene Züge
var mulligan_done := false
func _init(n: String, d: Array, h: bool, l: int, t: String = "") -> void:
name = n
title = t
is_human = h
life = l
max_life = l
deck = d.duplicate()
func alive() -> bool:
return life > 0
func clone() -> Player:
var p := Player.new(name, deck, is_human, life, title)
p.max_life = max_life
p.hand = hand.duplicate()
p.energy = energy
p.max_energy = max_energy
p.fatigue = fatigue
p.start_bonus = start_bonus
p.traps = traps.duplicate()
p.order = order
p.perks = perks.duplicate()
p.turns = turns
p.mulligan_done = mulligan_done
for c in board:
p.board.append(c.clone(p))
return p
var human: Player # der (erste) menschliche Spieler
var humans: Array = [] # alle menschlichen Spieler (Hotseat: mehrere)
var opponents: Array = [] # alle außer dem ersten Menschen
var players: Array = [] # in Zugreihenfolge
var winner_player: Player = null
var rng: RandomNumberGenerator
var turn := 0
var current_idx := 0
var log: Array[String] = []
var events: Array = [] # strukturierte Ereignisse für Animationen
var winner := "" # "", "human" oder "opponents"
var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse
var next_uid := 1
func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void:
human = h
opponents = opps
players = [h] + opps
humans = players.filter(func(p): return p.is_human)
if r == null:
r = make_rng()
rng = r
func current() -> Player:
return players[current_idx]
func say(t: String) -> void:
if not quiet:
log.append(t)
func emit(ev: Dictionary) -> void:
if not quiet:
events.append(ev)
func ref_of(target) -> Dictionary:
if target is Creature:
return {"kind": "creature", "uid": target.uid}
return {"kind": "player", "index": players.find(target)}
## Jeder gegen jeden: alle anderen lebenden Spieler sind Gegner.
func enemies_of(p: Player) -> Array:
return players.filter(func(o): return o != p and o.alive())
func find_creature(uid: int) -> Creature:
for p in players:
for c in p.board:
if c.uid == uid:
return c
return null
## Unabhängige Kopie des Spielzustands (für die KI-Vorausschau).
func clone() -> Game:
var copies := []
for p in players:
copies.append(p.clone())
var idx := players.find(human)
var g := Game.new(copies[idx], copies.slice(0, idx) + copies.slice(idx + 1), RandomNumberGenerator.new())
g.players = copies # gleiche Zugreihenfolge wie das Original
g.turn = turn
g.current_idx = current_idx
g.winner = winner
g.next_uid = next_uid
g.quiet = true
return g
## Kopie aus Sicht von p: fremde Fallen sind unbekannt und fehlen (die KI schummelt nicht).
func clone_for(p: Player) -> Game:
var g := clone()
var p2: Player = g.players[players.find(p)]
for q in g.players:
if q != p2:
q.traps = []
return g
# --- Zugablauf ---------------------------------------------------------------
func start(fixed_order: bool = false) -> void:
if not fixed_order:
shuffle(players, rng) # zufällige Zugreihenfolge
for i in players.size():
var p: Player = players[i]
p.order = i
if "start_energie" in p.perks:
p.max_energy = 2
shuffle(p.deck, rng)
# Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug
# einmalig 1 Energie extra (Ausgleich für den Startvorteil).
draw(p, START_HAND + (0 if i == 0 else 1))
p.start_bonus = 0 if i == 0 else 1
turn = 1
current_idx = 0
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
for p in players:
for perk in p.perks:
say("%s: %s." % [p.name, PERKS[perk]])
begin_turn(current())
func draw(p: Player, n: int = 1) -> void:
for i in n:
if p.deck.is_empty():
p.fatigue += 1
say("%s hat keine Karten mehr und erleidet %d Schaden." % [p.name, p.fatigue])
damage(p, p.fatigue)
continue
var card = p.deck.pop_back()
if p.hand.size() >= MAX_HAND:
say("%s: Hand voll, %s verbrennt." % [p.name, card.name])
else:
p.hand.append(card)
check_end()
func begin_turn(p: Player) -> void:
p.max_energy = mini(MAX_ENERGY, p.max_energy + 1)
p.energy = p.max_energy
if p.start_bonus > 0:
p.energy += p.start_bonus
say("%s erhält %d Energie extra für den späteren Start." % [p.name, p.start_bonus])
p.start_bonus = 0
for c in p.board:
c.ready = true
if c.frozen:
c.frozen = false
c.attacks_left = 0
say("%s taut auf und setzt aus." % c.name)
else:
c.attacks_left = c.attacks_per_turn()
p.turns += 1
say("--- Zug %d: %s ---" % [turn, p.name])
emit({"type": "turn", "player": players.find(p)})
draw(p, 2 if "extra_karten" in p.perks else 1)
## Tauscht einmalig im ersten eigenen Zug Karten der Hand: zurück ins Deck, mischen, nachziehen.
func mulligan(p: Player, cards: Array) -> String:
if p.mulligan_done:
return "Schon getauscht"
if p != current() or p.turns != 1:
return "Nur im ersten eigenen Zug"
p.mulligan_done = true
var n := 0
for c in cards:
if c in p.hand:
p.hand.erase(c)
p.deck.append(c)
n += 1
if n > 0:
shuffle(p.deck, rng)
draw(p, n)
say("%s tauscht %d Karten." % [p.name, n] if n > 0 else "%s behält die Hand." % p.name)
emit({"type": "mulligan", "player": players.find(p), "count": n})
return ""
func end_turn() -> void:
if winner != "":
return
var n := players.size()
for i in range(1, n + 1):
var idx := (current_idx + i) % n
if players[idx].alive():
if idx <= current_idx:
turn += 1
current_idx = idx
break
begin_turn(current())
# --- Karten spielen ----------------------------------------------------------
func can_play(p: Player, card: Cards.Card) -> Array:
if winner != "" or p != current():
return [false, "Nicht dein Zug"]
if p.energy < card.cost:
return [false, "Nicht genug Energie"]
if card.is_creature() and p.board.size() >= MAX_BOARD:
return [false, "Dein Feld ist voll"]
if card.is_trap() and p.traps.size() >= MAX_TRAPS:
return [false, "Höchstens %d Fallen" % MAX_TRAPS]
var tt := card.target_type()
if tt != "" and not card.is_creature() and valid_targets(p, tt).is_empty():
return [false, "Kein gültiges Ziel"]
return [true, ""]
func valid_targets(p: Player, target_type: String) -> Array:
var enemies := enemies_of(p)
var creatures := []
for e in enemies:
creatures.append_array(e.board)
match target_type:
"enemy_creature":
return creatures
"enemy_any":
return creatures + enemies
"friendly_creature":
return p.board.duplicate()
return []
## Spielt eine Karte. Gibt "" zurück oder eine Fehlermeldung.
func play_card(p: Player, card: Cards.Card, target = null) -> String:
var ok := can_play(p, card)
if not ok[0]:
return ok[1]
var tt := card.target_type()
if tt != "":
var valid := valid_targets(p, tt)
if not valid.is_empty() and not (target in valid):
return "Ungültiges Ziel"
if valid.is_empty():
target = null
p.hand.erase(card)
p.energy -= card.cost
if card.is_trap():
p.traps.append(card)
say("%s legt eine Falle." % p.name)
emit({"type": "trap_set", "player": players.find(p)})
check_end()
return ""
say("%s spielt %s." % [p.name, card.name])
var cr: Creature = null
var bonus := 0
if card.is_creature():
cr = _enter(p, card)
else:
emit({"type": "spell", "player": players.find(p), "card": card})
if "zauber_schaden" in p.perks:
bonus = 1
for fx in card.effects:
resolve(p, fx, target, cr, bonus)
if cr != null and cr in p.board:
for e in enemies_of(p):
if not (cr in p.board):
break
trigger_trap(e, "creature_played", cr)
if cr != null and cr in p.board:
trigger_field_combos(p, cr)
update_resonance()
cleanup()
check_end()
return ""
## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem
## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus.
func trigger_field_combos(p: Player, cr: Creature) -> void:
for other in p.board:
if other == cr:
continue
for e1 in cr.card.elements:
for e2 in other.card.elements:
if e1 == e2:
continue
var name := Cards.combo_name([e1, e2])
say("Feldkombo %s: %s." % [name, Cards.FIELD_COMBO_TEXT[name]])
emit({"type": "combo", "name": name, "player": players.find(p), "uid": cr.uid})
resolve(p, Cards.field_combo_effect(name), null, cr)
return
## Löst die erste passende Falle von owner aus. Gibt true zurück, wenn der
## auslösende Angriff abgebrochen wird.
func trigger_trap(owner: Player, trigger: String, source: Creature) -> bool:
for trap in owner.traps:
if trap.trigger != trigger:
continue
owner.traps.erase(trap)
say("Falle von %s: %s!" % [owner.name, trap.name])
emit({"type": "trap", "player": players.find(owner), "card": trap, "source": ref_of(source)})
var cancel := false
for fx in trap.effects:
if fx.kind == "cancel":
cancel = true
else:
resolve(owner, fx, null, source)
cleanup()
return cancel
return false
## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt.
func _enter(p: Player, card: Cards.Card) -> Creature:
var cr := Creature.new(card, p, next_uid)
next_uid += 1
if "waechter_leben" in p.perks and cr.taunt():
cr.health += 2
cr.max_health += 2
if "schild_wesen" in p.perks:
cr.shield = true
if "alle_flink" in p.perks:
cr.ready = true
cr.attacks_left = cr.attacks_per_turn()
p.board.append(cr)
emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card})
return cr
## Setzt ein Wesen aufs Feld (Fallen, Effekte).
func summon(p: Player, card: Cards.Card) -> void:
if p.board.size() >= MAX_BOARD:
say("%s: kein Platz für %s." % [p.name, card.name])
return
_enter(p, card)
say("%s beschwört %s." % [p.name, card.name])
update_resonance()
## Resonanz: +1 Angriff je anderem eigenen Wesen, das ein Element teilt (höchstens +4).
func update_resonance() -> void:
for p in players:
for c in p.board:
var bonus := 0
for o in p.board:
if o != c and o.card.elements.any(func(e): return e in c.card.elements):
bonus += 1
c.resonance = mini(bonus, RESONANCE_MAX) * (2 if "resonanz_doppelt" in p.perks else 1)
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void:
var enemies := enemies_of(p)
var enemy_creatures := []
for e in enemies:
enemy_creatures.append_array(e.board)
var targets := []
if fx.target in Cards.CHOSEN_TARGETS:
targets = [target] if target != null else []
elif fx.target == "all_enemy_creatures":
targets = enemy_creatures
elif fx.target == "all_enemy_heroes":
targets = enemies.duplicate()
elif fx.target == "all_enemies":
targets = enemy_creatures + enemies
elif fx.target == "all_friendly_creatures":
targets = p.board.duplicate()
elif fx.target == "self_hero":
targets = [p]
elif fx.target == "self_creature" or fx.target == "trigger_source":
targets = [source] if source != null else []
elif fx.target == "strongest_enemy_creature":
var best: Creature = null
for c in enemy_creatures:
if best == null or c.power() > best.power():
best = c
targets = [best] if best != null else []
elif fx.target == "weakest_enemy_hero":
var weakest: Player = null
for e in enemies:
if weakest == null or e.life < weakest.life:
weakest = e
targets = [weakest] if weakest != null else []
else:
targets = [null]
for t in targets:
match fx.kind:
"damage":
damage(t, fx.amount + bonus)
"heal":
t.life = mini(t.max_life, t.life + fx.amount)
say("%s heilt sich um %d." % [t.name, fx.amount])
emit({"type": "heal", "target": ref_of(t), "amount": fx.amount})
"draw":
draw(p, fx.amount)
"freeze":
t.frozen = true
t.attacks_left = 0
say("%s ist eingefroren." % t.name)
emit({"type": "freeze", "target": ref_of(t)})
"bounce":
var o = t.owner()
o.board.erase(t)
emit({"type": "bounce", "target": ref_of(t)})
if o.hand.size() < MAX_HAND:
o.hand.append(t.card)
say("%s kehrt auf die Hand zurück." % t.name)
else:
say("%s wird weggeweht (Hand voll)." % t.name)
"buff":
t.attack += fx.amount
t.health += fx.amount2
t.max_health += fx.amount2
say("%s erhält +%d/+%d." % [t.name, fx.amount, fx.amount2])
emit({"type": "buff", "target": ref_of(t), "text": "+%d/+%d" % [fx.amount, fx.amount2]})
"shield":
t.shield = true
say("%s erhält Schild." % t.name)
emit({"type": "buff", "target": ref_of(t), "text": "Schild"})
"summon":
summon(p, Cards.by_id(fx.card_id))
# --- Kampf -------------------------------------------------------------------
func can_attack(cr: Creature, target) -> Array:
if winner != "" or cr.owner() != current():
return [false, "Nicht dein Zug"]
if not cr.can_attack():
return [false, "%s kann nicht angreifen" % cr.name]
var tgt_owner = target.owner() if target is Creature else target
if not (tgt_owner in enemies_of(cr.owner())):
return [false, "Kein gültiges Ziel"]
var has_taunt := false
for c in tgt_owner.board:
if c.taunt():
has_taunt = true
if has_taunt and not (target is Creature and target.taunt()):
return [false, "Ein Wächter muss zuerst angegriffen werden"]
return [true, ""]
## Greift an. Gibt "" zurück oder eine Fehlermeldung.
func attack(cr: Creature, target) -> String:
var ok := can_attack(cr, target)
if not ok[0]:
return ok[1]
cr.attacks_left -= 1
say("%s greift %s an." % [cr.name, target.name])
emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)})
var tgt_owner: Player = target.owner() if target is Creature else target
var cancelled := trigger_trap(tgt_owner, "attacked", cr)
if cancelled or not (cr in cr.owner().board) or (target is Creature and not (target in tgt_owner.board)):
check_end()
return ""
damage(target, cr.power())
if target is Creature:
damage(cr, target.power())
cleanup()
check_end()
return ""
func damage(target, amount: int) -> void:
if amount <= 0:
return
if target is Creature:
if target.shield:
target.shield = false
say("Schild von %s blockt den Schaden." % target.name)
emit({"type": "blocked", "target": ref_of(target)})
return
target.health -= amount
else:
target.life -= amount
say("%s erleidet %d Schaden." % [target.name, amount])
emit({"type": "damage", "target": ref_of(target), "amount": amount})
func cleanup() -> void:
var died := false
for p in players:
for c in p.board.duplicate():
if c.health <= 0:
p.board.erase(c)
say("%s stirbt." % c.name)
emit({"type": "death", "target": ref_of(c)})
died = true
if died:
update_resonance()
## Ende: nur noch ein Spieler übrig, oder kein Mensch mehr am Tisch.
func check_end() -> void:
if winner != "":
return
var alive := players.filter(func(p): return p.alive())
var humans_alive := alive.filter(func(p): return p.is_human)
if humans_alive.is_empty():
winner = "opponents"
winner_player = alive[0] if alive.size() == 1 else null
say("Du wurdest besiegt." if humans.size() == 1 else "Alle Spieler sind besiegt, die KI gewinnt.")
elif alive.size() == 1:
winner = "human"
winner_player = alive[0]
say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name)
# --- Zustand für das Netzwerk -------------------------------------------------
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
func snapshot(viewer: int) -> Dictionary:
var ps := []
for i in players.size():
var p: Player = players[i]
var mine := i == viewer
var board := []
for c in p.board:
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
"ready": c.ready, "attacks_left": c.attacks_left})
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
"deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
"board": board})
var evs := []
for ev in events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = e.card.id
evs.append(e)
return {"players": ps, "human": players.find(human), "turn": turn, "current_idx": current_idx,
"winner": winner, "winner_player": players.find(winner_player) if winner_player != null else -1,
"next_uid": next_uid, "log": log.slice(maxi(0, log.size() - 80)), "events": evs}
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
static func from_snapshot(d: Dictionary) -> Game:
var placeholder: Cards.Card = Cards.all()[0]
var list := []
for pd in d.players:
var p := Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
p.max_life = int(pd.max_life)
p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy)
p.fatigue = int(pd.fatigue)
p.start_bonus = int(pd.start_bonus)
p.order = int(pd.order)
p.perks = Array(pd.perks)
p.turns = int(pd.turns)
p.mulligan_done = bool(pd.mulligan_done)
if pd.hand is Array:
for cid in pd.hand:
p.hand.append(Cards.by_id(cid))
else:
for i in int(pd.hand):
p.hand.append(placeholder)
for i in int(pd.deck):
p.deck.append(placeholder)
if pd.traps is Array:
for cid in pd.traps:
p.traps.append(Cards.by_id(cid))
else:
for i in int(pd.traps):
p.traps.append(placeholder)
for cd in pd.board:
var c := Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
c.attack = int(cd.attack)
c.resonance = int(cd.resonance)
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.shield = bool(cd.shield)
c.frozen = bool(cd.frozen)
c.ready = bool(cd.ready)
c.attacks_left = int(cd.attacks_left)
p.board.append(c)
list.append(p)
var hi := int(d.human)
var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new())
g.players = list
g.turn = int(d.turn)
g.current_idx = int(d.current_idx)
g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid)
for line in d.log:
g.log.append(str(line))
for ev in d.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = Cards.by_id(e.card)
for key in ["player", "uid", "count", "amount"]:
if e.has(key):
e[key] = int(e[key])
for key in ["target", "attacker", "source"]:
if e.has(key):
var r: Dictionary = e[key]
if r.has("uid"):
r["uid"] = int(r.uid)
if r.has("index"):
r["index"] = int(r.index)
g.events.append(e)
return g
# --- Deckregeln und Gegner ---------------------------------------------------
static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
for i in range(arr.size() - 1, 0, -1):
var j := r.randi_range(0, i)
var tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
static func deck_elements(cards: Array) -> Array:
var out := []
for c in cards:
for e in c.elements:
if not (e in out):
out.append(e)
return out
## Prüft ein Deck. owned: Dictionary id -> Anzahl (null = keine Besitzprüfung).
static func validate_deck(cards: Array, owned = null) -> Array:
if cards.size() != DECK_SIZE:
return [false, "Deck braucht genau %d Karten (%d)" % [DECK_SIZE, cards.size()]]
var counts := {}
for c in cards:
counts[c.id] = counts.get(c.id, 0) + 1
for cid in counts:
if counts[cid] > MAX_COPIES:
return [false, "Höchstens %dx dieselbe Karte" % MAX_COPIES]
if owned != null and owned.get(cid, 0) < counts[cid]:
return [false, "Karte nicht oft genug in der Sammlung"]
if deck_elements(cards).size() > MAX_DECK_ELEMENTS:
return [false, "Höchstens %d Elemente im Deck" % MAX_DECK_ELEMENTS]
return [true, ""]
## Gegnerdeck aus den Elementen. bias > 1 bevorzugt seltene Karten (Kampagne).
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements))
var weights := PackedFloat32Array()
for c in pool:
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity]
weights.append(w / bias if c.rarity == "gewöhnlich" else w * bias)
var deck := []
var counts := {}
while deck.size() < DECK_SIZE:
var c = pool[r.rand_weighted(weights)]
if counts.get(c.id, 0) < MAX_COPIES:
counts[c.id] = counts.get(c.id, 0) + 1
deck.append(c)
return deck
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE) -> Player:
var elements: Array
if r.randf() < 0.4:
elements = [Cards.ELEMENTS[r.randi_range(0, Cards.ELEMENTS.size() - 1)]]
else:
var pool: Array = Cards.ELEMENTS.duplicate()
shuffle(pool, r)
elements = [pool[0], pool[1]]
return Player.new("Gegner %d" % index, build_opponent_deck(r, elements), false, life,
Cards.combo_name(elements) + "-Magier")
## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int}
static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner")
if r == null:
r = make_rng()
var opps := []
for i in n_opp:
opps.append(random_opponent(r, i + 1))
return new_battle_with(deck, opps, r, false, rules)
static func new_battle_with(deck: Array, opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
if r == null:
r = make_rng()
var human := Player.new("Du", deck, true, START_LIFE)
human.perks = Array(rules.get("human_perks", []))
var g := Game.new(human, opps, r)
var everyone: String = rules.get("everyone_perk", "")
for p in g.players:
if everyone != "" and not (everyone in p.perks):
p.perks.append(everyone)
if rules.has("start_life"):
p.life = int(rules.start_life)
p.max_life = p.life
elif "leben_35" in p.perks and p.life == START_LIFE:
p.life = 35
p.max_life = 35
g.start(fixed_order)
return g
## Hotseat: mehrere Menschen am selben Rechner, dazu KI-Gegner.
static func new_hotseat(human_decks: Array, n_ai: int, r: RandomNumberGenerator = null) -> Game:
assert(human_decks.size() >= 1 and human_decks.size() + n_ai <= MAX_OPPONENTS + 1, "höchstens 6 am Tisch")
if r == null:
r = make_rng()
var first := Player.new("Spieler 1", human_decks[0], true, START_LIFE)
var others := []
for i in range(1, human_decks.size()):
others.append(Player.new("Spieler %d" % (i + 1), human_decks[i], true, START_LIFE))
for i in n_ai:
others.append(random_opponent(r, i + 1))
var g := Game.new(first, others, r)
g.start()
return g

1
scripts/game.gd.uid Normal file
View File

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

279
scripts/net.gd Normal file
View File

@@ -0,0 +1,279 @@
class_name Net
extends Node
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
signal lobby_changed
signal started(seat: int)
signal snapshot_received(snap: Dictionary)
signal action_applied
signal ended(reason: String)
const PORT := 24455
var peer: ENetMultiplayerPeer
var is_host := false
var connected := false
var my_name := "Spieler"
var my_deck: Array = [] # Karten-IDs
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
var decks := {} # peer_id -> Karten-IDs (Host)
var seats := {} # peer_id -> Spielerindex (Host)
var my_seat := -1
var game: Game # Host
var status := ""
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
const REJOIN_SECONDS := 60.0
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet"))
func host(port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_server(port, Game.MAX_OPPONENTS)
if err != OK:
status = "Kann nicht hosten (Port %d belegt?)" % port
return err
multiplayer.multiplayer_peer = peer
is_host = true
connected = true
names = {1: my_name}
decks = {1: my_deck}
status = "Warte auf Mitspieler …"
lobby_changed.emit()
return OK
func join(ip: String, port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_client(ip, port)
if err != OK:
status = "Adresse ungültig"
return err
multiplayer.multiplayer_peer = peer
is_host = false
status = "Verbinde mit %s …" % ip
return OK
func leave() -> void:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
peer = null
is_host = false
connected = false
names = {}
decks = {}
seats = {}
waiting = {}
my_seat = -1
game = null
status = ""
func local_addresses() -> Array:
return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127."))
func _fail(reason: String) -> void:
status = reason
ended.emit(reason)
leave()
func _on_connected() -> void:
connected = true
status = "Verbunden"
register.rpc_id(1, my_name, my_deck)
func _on_peer_connected(_id: int) -> void:
pass
func _on_peer_disconnected(id: int) -> void:
if not is_host:
return
var pname: String = names.get(id, "")
names.erase(id)
decks.erase(id)
if game != null and seats.has(id):
var p: Game.Player = game.players[seats[id]]
if game.winner == "":
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)])
seats.erase(id)
action_applied.emit()
lobby_update.rpc(names)
lobby_changed.emit()
## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus.
func tick() -> void:
if not is_host or game == null or game.winner != "":
return
var now := Time.get_unix_time_from_system()
for pname in waiting.keys():
var w: Dictionary = waiting[pname]
var p: Game.Player = game.players[w.seat]
if now - float(w.since) > REJOIN_SECONDS:
waiting.erase(pname)
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
game.damage(p, 999)
game.check_end()
action_applied.emit()
elif game.current() == p:
p.mulligan_done = true
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
game.end_turn()
action_applied.emit()
@rpc("any_peer", "reliable")
func register(name: String, deck_ids: Array) -> void:
if not is_host:
return
var id := multiplayer.get_remote_sender_id()
if game != null:
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
if waiting.has(name):
seats[id] = waiting[name].seat
names[id] = name
waiting.erase(name)
game.say("%s ist zurück." % name)
start_game.rpc_id(id, seats[id])
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
action_applied.emit()
else:
status = "%s wollte beitreten, das Spiel läuft schon" % name
return
names[id] = name
decks[id] = deck_ids
lobby_update.rpc(names)
lobby_changed.emit()
@rpc("authority", "reliable")
func lobby_update(n: Dictionary) -> void:
names = n
lobby_changed.emit()
## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten.
func start_battle(n_ai: int) -> void:
var ids: Array = names.keys()
ids.sort()
var r := Game.make_rng()
var players := []
seats = {}
for id in ids:
var deck_ids: Array = decks.get(id, [])
var deck := []
for cid in deck_ids:
if Cards.has_id(cid):
deck.append(Cards.by_id(cid))
if Game.validate_deck(deck)[0] == false:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, r)
deck = Game.build_opponent_deck(r, [pool[0], pool[1]])
players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE))
for i in n_ai:
players.append(Game.random_opponent(r, i + 1))
game = Game.new(players[0], players.slice(1), r)
game.start()
for id in ids:
seats[id] = game.players.find(players[ids.find(id)])
my_seat = seats[1]
for id in ids:
if id != 1:
start_game.rpc_id(id, seats[id])
started.emit(my_seat)
@rpc("authority", "reliable")
func start_game(seat: int) -> void:
my_seat = seat
started.emit(seat)
## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht).
func broadcast() -> void:
if not is_host or game == null:
return
for id in seats:
if id != 1:
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
@rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void:
snapshot_received.emit(snap)
## Mitspieler: Aktion an den Host schicken.
func send_action(action: Dictionary) -> void:
if is_host:
_apply(1, action)
else:
request_action.rpc_id(1, action)
@rpc("any_peer", "reliable")
func request_action(action: Dictionary) -> void:
if not is_host:
return
_apply(multiplayer.get_remote_sender_id(), action)
func _apply(id: int, action: Dictionary) -> void:
if game == null or not seats.has(id):
return
var p: Game.Player = game.players[seats[id]]
if game.current() != p or game.winner != "":
return
match str(action.get("type", "")):
"play":
var idx := int(action.get("hand", -1))
if idx < 0 or idx >= p.hand.size():
return
game.play_card(p, p.hand[idx], resolve_ref(action.get("target")))
"attack":
var cr := game.find_creature(int(action.get("uid", -1)))
var t = resolve_ref(action.get("target"))
if cr != null and t != null:
game.attack(cr, t)
"mulligan":
var cards := []
for i in action.get("hand", []):
if int(i) >= 0 and int(i) < p.hand.size():
cards.append(p.hand[int(i)])
game.mulligan(p, cards)
"end":
game.end_turn()
_:
return
action_applied.emit()
func resolve_ref(ref):
if ref == null or not (ref is Dictionary):
return null
if ref.get("kind") == "creature":
return game.find_creature(int(ref.get("uid", -1)))
var i := int(ref.get("index", -1))
if i >= 0 and i < game.players.size():
return game.players[i]
return null
static func ref_dict(target) -> Dictionary:
if target is Game.Creature:
return {"kind": "creature", "uid": target.uid}
return {"kind": "player", "index": target.order}

1
scripts/net.gd.uid Normal file
View File

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

519
scripts/profile.gd Normal file
View File

@@ -0,0 +1,519 @@
class_name Profile
extends RefCounted
## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Statistik, Erfolge, Tagesaufgabe, Einstellungen.
const SAVE_VERSION := 5
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100
const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
const START_COINS := 150
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
const REWARD_LOSS := 50
const DECK_SLOTS := 5
# Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub.
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
const ACHIEVEMENTS := [
{"id": "erster_sieg", "name": "Erster Sieg", "text": "Gewinne einen Kampf.", "reward": 50},
{"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50},
{"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100},
{"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200},
{"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100},
{"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500},
{"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100},
{"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100},
{"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100},
{"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300},
{"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500},
{"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150},
{"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
]
const DAILY_TASKS := [
{"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80},
{"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60},
{"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40},
{"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50},
{"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50},
{"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60},
{"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50},
{"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120},
{"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90},
{"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90},
{"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90},
{"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90},
]
static var today_override := "" # für Tests
var coins := 0
var dust := 0
var collection := {} # id -> Anzahl
var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids}
var active_deck := 0
var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
var achievements: Array = []
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var daily := {"date": "", "task": "", "progress": 0, "done": false}
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de"}
var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe)
var card_stats := {} # id -> {"played", "games", "wins"}
var booster_since_mythic := 0
var tutorial_done := false
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0}
var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung
## Das aktive Deck (Kartenliste des gewählten Slots).
var deck: Array:
get:
return decks[active_deck].cards
set(value):
decks[active_deck].cards = value
func _init() -> void:
for i in DECK_SLOTS:
decks.append({"name": "Deck %d" % (i + 1), "cards": []})
static func starter() -> Profile:
var p := Profile.new()
p.coins = START_COINS
for c in Cards.of_rarity("gewöhnlich"):
if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"):
p.collection[c.id] = 2
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
for cid in starter_ids:
p.deck.append(cid)
p.deck.append(cid)
return p
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
static func load_or_new(path: String = SAVE_PATH) -> Profile:
for candidate in [path, path + ".bak"]:
if FileAccess.file_exists(candidate):
var json := JSON.new()
if json.parse(FileAccess.get_file_as_string(candidate)) == OK and json.data is Dictionary:
return from_dict(json.data)
if FileAccess.file_exists(path):
DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".corrupt")
var p := starter()
p.save(path)
return p
## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration).
static func from_dict(d: Dictionary) -> Profile:
var p := Profile.new()
var version := int(d.get("version", 1))
p.coins = int(d.get("coins", 0))
p.dust = int(d.get("dust", 0))
var coll = d.get("collection", {})
for cid in coll:
if Cards.has_id(cid):
p.collection[cid] = int(coll[cid])
if version >= 3:
var list = d.get("decks", [])
for i in mini(list.size(), DECK_SLOTS):
p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1)))
p.decks[i].cards = []
for cid in list[i].get("cards", []):
if Cards.has_id(cid):
p.decks[i].cards.append(cid)
p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1)
else:
for cid in d.get("deck", []):
if Cards.has_id(cid):
p.decks[0].cards.append(cid)
if version >= 2:
var st = d.get("stats", {})
p.stats.games = int(st.get("games", 0))
p.stats.wins = int(st.get("wins", 0))
p.stats.boosters = int(st.get("boosters", 0))
for k in st.get("wins_by_opp", {}):
p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k])
for a in d.get("achievements", []):
p.achievements.append(str(a))
p.campaign_stage = int(d.get("campaign_stage", 0))
var dl = d.get("daily", {})
p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")),
"progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))}
var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
"language": str(se.get("language", "de"))}
if version >= 4:
for cid in d.get("card_stats", {}):
if Cards.has_id(cid):
var st = d.card_stats[cid]
p.card_stats[cid] = {"played": int(st.get("played", 0)), "games": int(st.get("games", 0)), "wins": int(st.get("wins", 0))}
p.booster_since_mythic = int(d.get("booster_since_mythic", 0))
p.tutorial_done = bool(d.get("tutorial_done", false))
if version >= 5:
p.perk = str(d.get("perk", ""))
if not Game.PERKS.has(p.perk):
p.perk = ""
var rc = d.get("records", {})
p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)),
"fastest_win": int(rc.get("fastest_win", 0))}
p.challenge_week = int(d.get("challenge_week", -1))
return p
func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
"decks": decks, "active_deck": active_deck,
"stats": stats, "achievements": achievements, "campaign_stage": campaign_stage,
"daily": daily, "settings": settings, "card_stats": card_stats,
"booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done,
"perk": perk, "records": records, "challenge_week": challenge_week}
## Speichert und sichert den vorherigen Stand als .bak.
func save(path: String = SAVE_PATH) -> void:
var abs := ProjectSettings.globalize_path(path)
if FileAccess.file_exists(path):
DirAccess.copy_absolute(abs, abs + ".bak")
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(JSON.stringify(to_dict(), "\t"))
f.close()
func take_notices() -> Array[String]:
var out := notices.duplicate()
notices.clear()
return out
# --- Decks und Sammlung -----------------------------------------------------------
func deck_cards(slot: int = -1) -> Array:
var ids: Array = decks[slot if slot >= 0 else active_deck].cards
var out := []
for cid in ids:
out.append(Cards.by_id(cid))
return out
func deck_valid(slot: int = -1) -> Array:
return Game.validate_deck(deck_cards(slot), collection)
func deck_name(slot: int = -1) -> String:
return decks[slot if slot >= 0 else active_deck].name
func rename_deck(slot: int, name: String) -> String:
var n := name.strip_edges()
if n.is_empty() or n.length() > 20:
return "Name muss 1 bis 20 Zeichen haben"
decks[slot].name = n
return ""
## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve.
## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt.
func autofill_deck(slot: int) -> String:
var cards: Array = decks[slot].cards
var elems := Game.deck_elements(deck_cards(slot))
if elems.is_empty():
var count := {}
for c in owned_cards():
for e in c.elements:
count[e] = count.get(e, 0) + 1
var ranked: Array = count.keys()
ranked.sort_custom(func(a, b): return count[a] > count[b])
elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS)
var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems))
pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
var counts := {}
for cid in cards:
counts[cid] = counts.get(cid, 0) + 1
var progressed := true
while cards.size() < Game.DECK_SIZE and progressed:
progressed = false
for cost in range(1, 11):
if cards.size() >= Game.DECK_SIZE:
break
for c in pool:
if c.cost != cost:
continue
var have: int = counts.get(c.id, 0)
if have < mini(collection.get(c.id, 0), Game.MAX_COPIES):
cards.append(c.id)
counts[c.id] = have + 1
progressed = true
break
var ok := Game.validate_deck(deck_cards(slot), collection)
return "" if ok[0] else ok[1]
## Bestenliste: Karten mit den meisten Siegen (mindestens eine Partie).
func top_cards(n: int) -> Array:
var rows := []
for cid in card_stats:
var st: Dictionary = card_stats[cid]
if st.games > 0:
rows.append({"card": Cards.by_id(cid), "games": st.games, "wins": st.wins, "played": st.played,
"rate": float(st.wins) / st.games})
rows.sort_custom(func(a, b): return a.wins > b.wins or (a.wins == b.wins and a.rate > b.rate))
return rows.slice(0, n)
func owned_cards() -> Array:
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
func distinct_owned() -> int:
var n := 0
for cid in collection:
if collection[cid] > 0:
n += 1
return n
# --- Werkstatt --------------------------------------------------------------------
## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung.
func dismantle(cid: String, n: int = 1) -> String:
var owned: int = collection.get(cid, 0)
if owned < n:
return "Nicht genug Kopien"
var needed := 0
for d in decks:
needed = maxi(needed, d.cards.count(cid))
if owned - n < needed:
return "Wird in einem Deck gebraucht"
collection[cid] = owned - n
dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n
return ""
## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung.
func craft(cid: String) -> String:
var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity]
if dust < cost:
return "Nicht genug Staub (%d nötig)" % cost
dust -= cost
collection[cid] = collection.get(cid, 0) + 1
daily_event("craft")
check_achievements()
return ""
## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück.
func dismantle_extras() -> int:
var before := dust
for cid in collection.keys():
var extra: int = collection[cid] - Game.MAX_COPIES
if extra > 0:
dismantle(cid, extra)
return dust - before
# --- Booster ----------------------------------------------------------------------
static func roll_booster(r: RandomNumberGenerator) -> Array:
var rarities := RARITY_WEIGHTS.keys()
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var cards := []
for i in BOOSTER_SIZE:
var rarity: String = rarities[r.rand_weighted(weights)]
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
and cards.all(func(c): return c.rarity == "gewöhnlich"):
rarity = "selten" # mindestens eine seltene Karte pro Booster
var pool := Cards.of_rarity(rarity)
cards.append(pool[r.randi_range(0, pool.size() - 1)])
return cards
## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
func open_booster(r: RandomNumberGenerator = null) -> Array:
if coins < BOOSTER_COST:
return []
if r == null:
r = Game.make_rng()
coins -= BOOSTER_COST
var cards := roll_booster(r)
booster_since_mythic += 1
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
var pool := Cards.of_rarity("mythisch")
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
has_mythic = true
if has_mythic:
booster_since_mythic = 0
for c in cards:
collection[c.id] = collection.get(c.id, 0) + 1
stats.boosters += 1
daily_event("booster")
check_achievements()
return cards
# --- Kampfergebnis, Erfolge, Tagesaufgabe -------------------------------------------
## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück.
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche.
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
turns: int = 0, challenge: Dictionary = {}) -> int:
stats.games += 1
if won:
records.streak += 1
records.best_streak = maxi(records.best_streak, records.streak)
if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win):
records.fastest_win = turns
else:
records.streak = 0
for cid in played_ids:
_stat(cid).played += 1
var seen := {}
for c in deck_used:
if not seen.has(c.id):
seen[c.id] = true
_stat(c.id).games += 1
if won:
_stat(c.id).wins += 1
var reward := REWARD_LOSS
if won:
stats.wins += 1
var key := str(n_opp)
stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1
reward = REWARD_WIN[n_opp]
if stage >= 0 and stage == campaign_stage:
campaign_stage += 1
reward = Campaign.stage(stage).reward
notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name)
if not challenge.is_empty() and challenge_week != Challenge.week():
challenge_week = Challenge.week()
reward = challenge.reward
notices.append("Herausforderung bestanden: %s (+%d Münzen)" % [challenge.name, challenge.reward])
coins += reward
daily_event("game")
if won:
daily_event("win")
if n_opp >= 3:
daily_event("win_vs3")
for e in Game.deck_elements(deck_used):
daily_event("win_element:" + e)
check_achievements({"won": won, "n_opp": n_opp, "deck": deck_used})
return reward
func _stat(cid: String) -> Dictionary:
if not card_stats.has(cid):
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
return card_stats[cid]
func check_achievements(ctx: Dictionary = {}) -> Array:
var unlocked := []
for a in ACHIEVEMENTS:
if a.id in achievements:
continue
if _achieved(a.id, ctx):
achievements.append(a.id)
coins += a.reward
unlocked.append(a)
notices.append("Erfolg: %s (+%d Münzen)" % [a.name, a.reward])
return unlocked
func _achieved(id: String, ctx: Dictionary) -> bool:
var won: bool = ctx.get("won", false)
var n: int = ctx.get("n_opp", 0)
var deck_used: Array = ctx.get("deck", [])
match id:
"erster_sieg":
return stats.wins >= 1
"duellant":
return won and n == 1
"tischherr":
return won and n >= 3
"chaosmeister":
return won and n == 5
"sammler":
return distinct_owned() >= 40
"vollstaendig":
return distinct_owned() >= Cards.all().size()
"boosterfreund":
return stats.boosters >= 10
"elementarist":
return won and Game.deck_elements(deck_used).size() == 1
"kombokoenig":
return won and deck_used.filter(func(c): return c.is_combo()).size() >= 6
"kampagnenheld":
return campaign_stage >= Campaign.STAGES.size()
"elementmeister":
return campaign_stage >= Campaign.total()
"serie":
return records.best_streak >= 5
"wochenheld":
return challenge_week >= 0
return false
func perk_unlocked() -> bool:
return campaign_stage >= Campaign.STAGES.size()
## Schreibt Statistik als CSV (Kartenwerte und Gesamtzahlen), z. B. für Spieltests.
func export_stats(path: String) -> String:
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return "Datei nicht schreibbar"
f.store_line("karte;seltenheit;gespielt;partien;siege")
for cid in card_stats:
var st: Dictionary = card_stats[cid]
var c := Cards.by_id(cid)
f.store_line("%s;%s;%d;%d;%d" % [c.name, c.rarity, st.played, st.games, st.wins])
f.store_line("")
f.store_line("kaempfe;%d" % stats.games)
f.store_line("siege;%d" % stats.wins)
f.store_line("booster;%d" % stats.boosters)
f.store_line("beste_serie;%d" % records.best_streak)
f.store_line("schnellster_sieg;%d" % records.fastest_win)
f.close()
return ""
static func today() -> String:
return today_override if today_override != "" else Time.get_date_string_from_system()
## Wählt die Tagesaufgabe neu, wenn ein neuer Tag begonnen hat.
func refresh_daily() -> void:
var t := today()
if daily.date != t:
var day_number := int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 86400)
var task: Dictionary = DAILY_TASKS[day_number % DAILY_TASKS.size()]
daily = {"date": t, "task": task.id, "progress": 0, "done": false}
func daily_task() -> Dictionary:
refresh_daily()
for t in DAILY_TASKS:
if t.id == daily.task:
return t
return DAILY_TASKS[0]
## Zählt ein Ereignis (siehe DAILY_TASKS) für die Tagesaufgabe. Gibt die Belohnung zurück.
func daily_event(kind: String, n: int = 1) -> int:
var task := daily_task()
if daily.done or task.kind != kind:
return 0
daily.progress += n
if daily.progress >= task.goal:
daily.done = true
coins += task.reward
notices.append("Tagesaufgabe erfüllt: %s (+%d Münzen)" % [task.text, task.reward])
return task.reward
return 0

1
scripts/profile.gd.uid Normal file
View File

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

View File

@@ -0,0 +1,45 @@
class_name AchievementsScreen
extends Control
var app
func _ready() -> void:
var p: Profile = app.profile
var col := Style.column(self, 900, 20, 5)
Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true)
var st: Dictionary = p.stats
Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [
st.games, st.wins, st.boosters, p.campaign_stage, Campaign.STAGES.size()], 16, Style.MUTED)
var task := p.daily_task()
var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \
"Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
Style.clabel(col, daily_text, 16, Style.BLUE)
Style.spacer(col, 8)
for a in Profile.ACHIEVEMENTS:
var done: bool = a.id in p.achievements
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6))
col.add_child(panel)
var r := Style.row(panel, 12)
r.custom_minimum_size = Vector2(0, 42)
Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40)
Style.clabel(r, a.name, 18, Style.TEXT if done else Style.MUTED, "left", true, 250)
Style.clabel(r, a.text, 15, Style.MUTED, "left", false, 380)
Style.clabel(r, "+%d" % a.reward, 16, Style.GOLD, "right", true, 90)
var top := p.top_cards(5)
if not top.is_empty():
Style.spacer(col, 4)
var parts: Array[String] = []
for row in top:
parts.append("%s %d/%d" % [row.card.name, row.wins, row.games])
Style.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED)
Style.spacer(col, 6)
var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())

View File

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

159
scripts/ui/battle_fx.gd Normal file
View File

@@ -0,0 +1,159 @@
class_name BattleFx
extends RefCounted
## Animationen und Töne des Kampfes aus den Spielereignissen.
var screen # BattleScreen
var enabled := true
func _init(s) -> void:
screen = s
func sfx(kind: String) -> void:
screen.app.sfx.play(kind)
## Spielt die Ereignisse einer Aktion ab. centers: vorher gemerkte Positionen (für gestorbene Wesen).
func play_events(evs: Array, centers: Dictionary) -> void:
var g: Game = screen.game
var delay := 0.0
for ev in evs:
match ev.type:
"attack":
sfx("attack")
var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
lunge(atk, center_of(ev.target, centers))
flash_target(ev.target)
delay = 0.12
"damage":
sfx("damage")
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay)
"heal":
sfx("heal")
popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay)
"buff":
popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay)
"blocked":
popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay)
"freeze":
popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay)
"combo":
sfx("combo")
var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid})
if where.x < 0:
where = screen.target_center({"kind": "player", "index": ev.player})
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
"play":
sfx("play")
"spell":
sfx("play")
var caster: Game.Player = g.players[ev.player]
popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
if caster != screen.perspective:
screen.show_preview(ev.card, true)
screen.preview_timer = 1.6
"trap_set":
sfx("click")
popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"trap":
sfx("combo")
popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0)
screen.show_preview(ev.card, true)
screen.preview_timer = 1.8
"mulligan":
if ev.count > 0:
popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
var c: Vector2 = centers.get(screen.ref_key(ref), Vector2(-1, -1))
if c.x < 0:
c = screen.target_center(ref)
return c
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
if not enabled or at.x < 0:
return
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 22)
l.add_theme_font_override("font", Style.FONT_BOLD)
l.add_theme_color_override("font_color", color)
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
l.add_theme_constant_override("outline_size", 6)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
screen.fx_layer.add_child(l)
l.reset_size()
l.position = at - l.size / 2
l.modulate.a = 0.0
var tw: Tween = screen.create_tween()
tw.tween_interval(delay)
tw.tween_property(l, "modulate:a", 1.0, 0.08)
tw.tween_property(l, "position:y", l.position.y - 44, 0.8).set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(l, "modulate:a", 0.0, 0.5).set_delay(0.35)
tw.tween_callback(l.queue_free)
func lunge(tile: CreatureView, target: Vector2) -> void:
if tile == null or not enabled or target.x < 0:
return
tile.busy = true
var start := tile.slot
var dest := start + (target - (start + tile.size / 2)) * 0.35
var tw := tile.create_tween()
tw.tween_property(tile, "position", dest, 0.12).set_ease(Tween.EASE_OUT)
tw.tween_property(tile, "position", start, 0.15).set_ease(Tween.EASE_IN)
tw.tween_callback(func():
tile.busy = false
tile.position = tile.slot)
func flash_target(ref: Dictionary) -> void:
if not enabled:
return
if ref.kind == "creature":
var tile: CreatureView = screen.tiles.get(ref.uid)
if tile == null:
return
tile.flash = true
tile.queue_redraw()
var tw := tile.create_tween()
tw.tween_interval(0.35)
tw.tween_callback(func():
tile.flash = false
tile.queue_redraw())
else:
screen.flash_until[int(ref.index)] = Time.get_ticks_msec() + 350
var tw: Tween = screen.create_tween()
tw.tween_interval(0.37)
tw.tween_callback(screen.refresh)
func pop_in(tile: CreatureView) -> void:
if not enabled:
return
tile.scale = Vector2(0.2, 0.2)
tile.modulate.a = 0.0
var tw := tile.create_tween().set_parallel(true)
tw.tween_property(tile, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tw.tween_property(tile, "modulate:a", 1.0, 0.15)
func fade_out(tile: CreatureView) -> void:
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
if not enabled:
tile.queue_free()
return
var tw := tile.create_tween().set_parallel(true)
tw.tween_property(tile, "modulate:a", 0.0, 0.3)
tw.tween_property(tile, "scale", Vector2(0.6, 0.6), 0.3)
tw.chain().tween_callback(tile.queue_free)
func move_to(tile: CreatureView, pos: Vector2) -> void:
if enabled:
screen.create_tween().tween_property(tile, "position", pos, 0.2)
else:
tile.position = pos

View File

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

View File

@@ -0,0 +1,40 @@
class_name BattleLayout
## Feste Bildschirmaufteilung des Kampfes (1280x800).
const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]}
const HAND_W := 130
const HAND_H := 185
const HAND_GAP := 8
const BAR := Rect2(335, 415, 610, 68)
const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2
static func compact(n_others: int) -> bool:
return n_others >= 4
static func hero_box_rect(n_others: int, i: int) -> Rect2:
var col_w := 1280 / n_others
var margin := 6 if compact(n_others) else 10
return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64)
static func own_slot(j: int) -> Rect2:
return Rect2(OWN_X + j * 120, 250, 110, 140)
static func other_slot(n_others: int, i: int, j: int) -> Rect2:
var col_w := 1280 / n_others
var sz: Array = SIZES[n_others]
var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
static func hand_rect(count: int, i: int) -> Rect2:
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
var x := (1280 - total) / 2
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
static func trap_rect(i: int) -> Rect2:
return Rect2(400 + i * 78, 490, 70, 98)

View File

@@ -0,0 +1 @@
uid://5sjeymvcgfl7

945
scripts/ui/battle_screen.gd Normal file
View File

@@ -0,0 +1,945 @@
class_name BattleScreen
extends Control
## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx.
## Modi: Einzelspieler, Kampagne, Anleitung, Hotseat (Perspektivwechsel), Netzwerk (Host oder Mitspieler).
var app
var n_opp: int
var stage := -1 # Kampagnenstufe oder -1
var tutorial := false
var hotseat_decks: Array = []
var hotseat_ai := 0
var online_seat := -1 # Netzwerk: eigener Platz, sonst -1
var net: Net
var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt
var fx: BattleFx
var selected = null # Game.Creature
var pending = null # Cards.Card, wartet auf Ziel
var msg_t := 0.0
var ai_delay := 0.75
var ai_timer := 0.0
var reward := -1
var ended := false
var zoom_player = null
var preview_timer := 0.0
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
var mulligan_selected: Array = []
var tutorial_step := 0
# Ansichten (auch für Tests)
var tiles := {}
var hand_views: Array = []
var own_views: Array = []
var enemy_views: Array = []
var trap_views: Array = []
var hero_panels := {} # Player -> Panel
var hero_labels := {}
var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
var hero_layer: Control
var tile_layer: Control
var hand_layer: Control
var trap_layer: Control
var zoom_layer: Control
var fx_layer: Control
var log_box: RichTextLabel
var preview_holder: Control
var preview_hint: Label
var bar_panel: Panel
var name_label: Label
var life_label: Label
var energy_label: Label
var deck_label: Label
var status_label: Label
var msg_label: Label
var b_end: Button
var b_giveup: Button
var overlay: Control
var result_label: Label
var reward_label: Label
var notice_label: Label
var confirm: Control
var handover: Control
var handover_label: Label
var mulligan_box: Control
var mulligan_layer: Control
var b_keep: Button
var b_swap: Button
var tutorial_panel: Panel
var tutorial_text: RichTextLabel
var b_tut_next: Button
var wait_label: Label
const TUTORIAL_STEPS := [
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
"played_creature"],
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig +1 Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an alle gegnerischen Wesen.", "combo"],
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
]
func _init(n: int = 2, campaign_stage: int = -1) -> void:
n_opp = n
stage = campaign_stage
static func hotseat(decks: Array, n_ai: int) -> BattleScreen:
var b := BattleScreen.new(0, -1)
b.hotseat_decks = decks
b.hotseat_ai = n_ai
return b
static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(0, -1)
b.online_seat = seat
return b
static func guided() -> BattleScreen:
var b := BattleScreen.new(1, -1)
b.tutorial = true
return b
func is_hotseat() -> bool:
return game != null and game.humans.size() > 1 and online_seat < 0
func is_online() -> bool:
return online_seat >= 0
func is_client() -> bool:
return is_online() and not net.is_host
func counts_for_profile() -> bool:
return not is_hotseat() and not is_online()
func _ready() -> void:
var s: Dictionary = app.profile.settings
fx = BattleFx.new(self)
fx.enabled = s.animations
ai_delay = 0.75 / float(s.ai_speed)
_build_static()
if is_online():
net = app.net
net.ended.connect(_on_net_ended)
if net.is_host:
game = net.game
net.action_applied.connect(after_action)
else:
net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true
return # der erste Zustand kommt vom Host
elif not hotseat_decks.is_empty():
game = Game.new_hotseat(hotseat_decks, hotseat_ai)
elif tutorial:
var r := Game.make_rng()
var opps := Campaign.opponents(0, r)
opps[0].perks = []
game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true)
elif stage >= 0:
var r := Game.make_rng()
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r)
else:
game = Game.new_battle(app.profile.deck_cards(), n_opp)
_begin()
func _begin() -> void:
n_opp = game.players.size() - 1
if is_online():
perspective = game.players[online_seat]
else:
game.events.clear()
perspective = game.current() if game.current().is_human else game.humans[0]
build_hero_boxes()
if is_online() and net.is_host:
net.broadcast()
game.events.clear()
refresh()
func _build_static() -> void:
Style.panel(self, Rect2(10, 205, 320, 340))
Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20))
log_box = RichTextLabel.new()
log_box.position = Vector2(18, 230)
log_box.size = Vector2(304, 308)
log_box.bbcode_enabled = true
log_box.scroll_following = true
log_box.add_theme_font_size_override("normal_font_size", 13)
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(log_box)
if tutorial:
tutorial_panel = Style.panel(self, Rect2(10, 205, 320, 150), Style.PANEL, 8, Style.GOLD, 2)
tutorial_panel.mouse_filter = Control.MOUSE_FILTER_STOP
Style.label(self, "Anleitung", 14, Style.GOLD, Rect2(20, 209, 200, 20), "left", true)
tutorial_text = RichTextLabel.new()
tutorial_text.position = Vector2(20, 230)
tutorial_text.size = Vector2(300, 90)
tutorial_text.bbcode_enabled = false
tutorial_text.scroll_active = false
tutorial_text.add_theme_font_size_override("normal_font_size", 13)
tutorial_text.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tutorial_text)
b_tut_next = Style.button(self, "Weiter", Rect2(230, 318, 90, 30), tutorial_next)
b_tut_next.add_theme_font_size_override("font_size", 14)
log_box.position = Vector2(18, 365)
log_box.size = Vector2(304, 173)
b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true)
Style.panel(self, Rect2(950, 205, 320, 380))
preview_hint = Style.label(self, "Karte überfahren für Details", 14, Style.MUTED, Rect2(950, 345, 320, 24), "center")
Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10)
bar_panel = Style.panel(self, BattleLayout.BAR)
name_label = Style.label(self, "Du", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true)
life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true)
energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true)
deck_label = Style.label(self, "", 16, Style.MUTED, Rect2(780, 424, 150, 24), "right")
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24))
msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true)
b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c"))
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false
hero_layer = _layer()
tile_layer = _layer()
trap_layer = _layer()
hand_layer = _layer()
preview_holder = _layer()
zoom_layer = _layer()
fx_layer = _layer()
# Mulligan
mulligan_box = _overlay()
Style.panel(mulligan_box, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
Style.label(mulligan_box, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
Style.label(mulligan_box, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
mulligan_layer = Control.new()
mulligan_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
mulligan_box.add_child(mulligan_layer)
b_keep = Style.button(mulligan_box, "Behalten", Rect2(430, 515, 200, 44), func(): do_mulligan(false), Color("285a3c"))
b_swap = Style.button(mulligan_box, "Tauschen", Rect2(650, 515, 200, 44), func(): do_mulligan(true))
# Spielende
overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
reward_label = Style.label(overlay, "", 26, Style.GOLD, Rect2(0, 335, 1280, 40), "center")
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center")
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
# Aufgeben bestätigen
confirm = _overlay()
Style.panel(confirm, Rect2(390, 300, 500, 180), Style.PANEL, 10, Style.MUTED, 2)
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(390, 320, 500, 40), "center", true)
Style.label(confirm, "Der Kampf endet ohne Münzen.", 16, Style.MUTED, Rect2(390, 365, 500, 24), "center")
Style.button(confirm, "Ja, aufgeben", Rect2(420, 410, 200, 46), leave, Color("5a2828"))
Style.button(confirm, "Weiterspielen", Rect2(660, 410, 200, 46), func(): confirm.visible = false)
# Übergabe zwischen Menschen (Hotseat)
handover = _overlay()
Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2)
handover_label = Style.label(handover, "", 30, Style.TEXT, Rect2(340, 310, 600, 44), "center", true)
Style.label(handover, "Maus weitergeben, dann weiter. Die Hand des Vorgängers ist verdeckt.", 16, Style.MUTED, Rect2(340, 362, 600, 24), "center")
Style.button(handover, "Weiter", Rect2(540, 420, 200, 50), take_over, Color("285a3c"))
func _layer() -> Control:
var c := Control.new()
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(c)
return c
func _overlay() -> Control:
var o := Control.new()
o.set_anchors_preset(Control.PRESET_FULL_RECT)
o.visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
o.add_child(dark)
add_child(o)
return o
func leave() -> void:
if is_online():
net.leave()
if tutorial and not app.profile.tutorial_done:
app.profile.tutorial_done = true
app.save()
app.show_screen(MenuScreen.new())
# --- Layout-Helfer ------------------------------------------------------------------
func others() -> Array:
var out := []
var n := game.players.size()
var start := game.players.find(perspective)
for i in range(1, n):
out.append(game.players[(start + i) % n])
return out
func compact() -> bool:
return BattleLayout.compact(others().size())
func slot_rect(p: Game.Player, j: int) -> Rect2:
if p == perspective:
return BattleLayout.own_slot(j)
var os := others()
return BattleLayout.other_slot(os.size(), os.find(p), j)
func ref_key(ref: Dictionary) -> String:
return "c%d" % ref.uid if ref.kind == "creature" else "p%d" % ref.index
func target_center(ref: Dictionary) -> Vector2:
if ref.kind == "creature":
if tiles.has(ref.uid):
var t: CreatureView = tiles[ref.uid]
return t.slot + t.size / 2
return Vector2(-1, -1)
var p: Game.Player = game.players[ref.index]
if p == perspective:
return BattleLayout.BAR.position + BattleLayout.BAR.size / 2
if hero_panels.has(p):
var panel: Panel = hero_panels[p]
return panel.position + panel.size / 2
return Vector2(-1, -1)
func build_hero_boxes() -> void:
Style.clear(hero_layer)
hero_panels = {}
hero_labels = {}
var os := others()
var c := compact()
for i in os.size():
var o: Game.Player = os[i]
var box := BattleLayout.hero_box_rect(os.size(), i)
var panel := Style.panel(hero_layer, box)
panel.gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
on_click("hero", o))
if not o.perks.is_empty():
panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k]))
hero_panels[o] = panel
var labels := {}
var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name
if not o.perks.is_empty():
title += " ★"
labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT,
Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (44 if c else 20), 26), "left", true)
labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED,
Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true)
labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED,
Rect2(box.position.x + 90, box.position.y + 38, box.size.x - 100, 20), "right")
hero_labels[o] = labels
if c:
var b := Style.button(hero_layer, "Lupe", Rect2(box.end.x - 44, box.position.y + 5, 38, 22), func():
zoom_player = o
refresh())
b.add_theme_font_size_override("font_size", 11)
# --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool:
return game != null and game.winner == "" and game.current() == perspective
func any_overlay() -> bool:
return confirm.visible or handover.visible or mulligan_box.visible or overlay.visible
func _input(event: InputEvent) -> void:
if game == null:
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
cancel_all()
return
if not (event is InputEventKey and event.pressed and not event.echo):
return
match event.keycode:
KEY_ESCAPE:
if confirm.visible:
confirm.visible = false
elif selected != null or pending != null or zoom_player != null:
cancel_all()
elif not any_overlay():
confirm.visible = true
KEY_SPACE:
if my_turn() and not any_overlay():
end_turn()
KEY_ENTER, KEY_KP_ENTER:
if handover.visible:
take_over()
elif mulligan_box.visible:
do_mulligan(false)
elif overlay.visible:
leave()
elif tutorial and b_tut_next.visible:
tutorial_next()
_:
if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay():
var idx: int = event.keycode - KEY_1
if idx < perspective.hand.size():
on_click("hand", perspective.hand[idx])
func cancel_all() -> void:
if confirm.visible:
confirm.visible = false
cancel()
zoom_player = null
refresh()
func _process(delta: float) -> void:
if game == null:
return
msg_t = maxf(0.0, msg_t - delta)
if msg_t <= 0.0 and msg_label.text != "":
msg_label.text = ""
if preview_timer > 0.0:
preview_timer -= delta
if preview_timer <= 0.0:
show_preview(null, false)
if game.winner != "":
if not ended:
ended = true
_finish()
return
var cur := game.current()
if cur.is_human:
if cur != perspective and is_hotseat() and not handover.visible:
handover_label.text = "%s ist dran" % cur.name
handover.visible = true
hand_layer.visible = false
trap_layer.visible = false
elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan_box.visible and not handover.visible:
show_mulligan()
return
if is_client():
return
ai_timer -= delta
if ai_timer > 0:
return
var action := AI.choose_action(game, cur)
AI.apply_action(game, cur, action)
ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0)
after_action()
func _finish() -> void:
var won := game.winner == "human"
if counts_for_profile():
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids)
if tutorial:
app.profile.tutorial_done = true
app.save()
app.sfx.play("win" if won else "lose")
else:
var wp := game.winner_player
var mine: bool = wp != null and wp == perspective
app.sfx.play("win" if mine else "lose")
refresh()
func take_over() -> void:
handover.visible = false
hand_layer.visible = true
trap_layer.visible = true
perspective = game.current()
cancel()
zoom_player = null
build_hero_boxes()
refresh()
func say(m: String) -> void:
msg_t = 2.5
msg_label.text = m
func cancel() -> void:
selected = null
pending = null
func play(card: Cards.Card, target) -> void:
if is_client():
net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null})
return
var err := game.play_card(perspective, card, target)
if err != "":
say(err)
func end_turn() -> void:
if not my_turn():
return
cancel()
zoom_player = null
if is_client():
net.send_action({"type": "end"})
return
game.end_turn()
ai_timer = ai_delay
after_action()
func show_mulligan() -> void:
mulligan_selected = []
mulligan_box.visible = true
Style.clear(mulligan_layer)
var hand: Array = perspective.hand
for i in hand.size():
var r := BattleLayout.hand_rect(hand.size(), i)
var v := CardView.new(hand[i], r.size)
v.position = Vector2(r.position.x, 322)
var idx := i
v.clicked.connect(func(_view): toggle_mulligan(idx))
mulligan_layer.add_child(v)
_mulligan_borders()
func toggle_mulligan(idx: int) -> void:
if idx in mulligan_selected:
mulligan_selected.erase(idx)
else:
mulligan_selected.append(idx)
_mulligan_borders()
func _mulligan_borders() -> void:
var views := mulligan_layer.get_children()
for i in views.size():
views[i].border = Style.YELLOW if i in mulligan_selected else null
views[i].queue_redraw()
b_swap.text = "Tauschen (%d)" % mulligan_selected.size() if not mulligan_selected.is_empty() else "Tauschen"
func do_mulligan(swap: bool) -> void:
mulligan_box.visible = false
var idxs: Array = mulligan_selected.duplicate() if swap else []
if is_client():
net.send_action({"type": "mulligan", "hand": idxs})
return
var cards := []
for i in idxs:
cards.append(perspective.hand[i])
game.mulligan(perspective, cards)
after_action()
func on_click(kind: String, obj) -> void:
var g := game
if not my_turn() or any_overlay():
return
if pending != null:
var card = pending
pending = null
if obj in g.valid_targets(perspective, card.target_type()):
play(card, obj)
else:
say("Abgebrochen")
zoom_player = null
after_action()
return
if selected != null:
var cr = selected
selected = null
if kind == "enemy" or kind == "hero":
if is_client():
net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)})
else:
var err := g.attack(cr, obj)
if err != "":
say(err)
zoom_player = null
after_action()
return
match kind:
"hand":
var ok := g.can_play(perspective, obj)
if not ok[0]:
say(ok[1])
elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty():
pending = obj
say("Wähle ein Ziel")
else:
play(obj, null)
"own":
if obj.can_attack():
selected = obj
say("Wähle ein Angriffsziel")
else:
say("%s kann nicht angreifen" % obj.name)
after_action()
func on_tile_click(cr: Game.Creature) -> void:
var o = cr.owner()
if o == null:
return
if o == perspective:
on_click("own", cr)
elif o.alive():
on_click("enemy", cr)
func show_preview(card, entered: bool) -> void:
Style.clear(preview_holder)
if entered and card != null:
var v := CardView.new(card, Vector2(210, 294))
v.position = Vector2(1005, 215)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
preview_hint.visible = not (entered and card != null)
## Vorschau einer Handkarte mit Hinweisen auf Resonanz und Feldkombos mit dem eigenen Feld.
func show_hand_preview(card: Cards.Card, entered: bool) -> void:
Style.clear(preview_holder)
preview_hint.visible = not entered
if not entered:
return
var hints: Array[String] = combo_hints(card)
var v := CardView.new(card, Vector2(190, 266) if not hints.is_empty() else Vector2(210, 294))
v.position = Vector2(1015, 212) if not hints.is_empty() else Vector2(1005, 215)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
if not hints.is_empty():
var box := RichTextLabel.new()
box.position = Vector2(958, 480)
box.size = Vector2(304, 46)
box.bbcode_enabled = true
box.scroll_active = false
box.fit_content = false
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_theme_font_size_override("normal_font_size", 11)
box.add_theme_color_override("default_color", Style.GOLD)
box.text = "[center]" + "\n".join(hints.slice(0, 2)) + "[/center]"
preview_holder.add_child(box)
func combo_hints(card: Cards.Card) -> Array[String]:
var out: Array[String] = []
if not card.is_creature():
return out
var partners: Array[String] = []
var combos := {}
for other in perspective.board:
if other.card.elements.any(func(e): return e in card.elements):
partners.append(other.name)
for e1 in card.elements:
for e2 in other.card.elements:
if e1 != e2:
var name := Cards.combo_name([e1, e2])
if not combos.has(name):
combos[name] = other.name
if not partners.is_empty():
out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)])
for name in combos:
out.append("Feldkombo %s mit %s: %s" % [name, combos[name], Cards.FIELD_COMBO_TEXT[name]])
return out
# --- Netzwerk -------------------------------------------------------------------------------
func _on_snapshot(snap: Dictionary) -> void:
var first := game == null
game = Game.from_snapshot(snap)
wait_label.visible = false
if first:
_begin()
return
perspective = game.players[online_seat]
build_hero_boxes()
after_action()
func _on_net_ended(reason: String) -> void:
if game == null or game.winner == "":
say(reason)
await get_tree().create_timer(1.5).timeout
if is_inside_tree():
app.show_screen(MenuScreen.new())
# --- Ereignisse ----------------------------------------------------------------------------
## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen.
func after_action() -> void:
if is_online() and net.is_host:
net.broadcast()
var evs := game.events.duplicate()
game.events.clear()
if counts_for_profile():
_note_events(evs)
var centers := {}
for ev in evs:
for key in ["target", "attacker", "source"]:
if ev.has(key):
centers[ref_key(ev[key])] = target_center(ev[key])
refresh()
fx.play_events(evs, centers)
func _note_events(evs: Array) -> void:
var hi := game.players.find(game.human)
for ev in evs:
if ev.get("player", -1) != hi:
continue
match ev.type:
"play", "spell":
played_ids.append(ev.card.id)
if ev.type == "play":
app.profile.daily_event("creature_played")
"trap_set":
app.profile.daily_event("trap_set")
"combo":
app.profile.daily_event("combo")
# --- Anleitung ------------------------------------------------------------------------------
func tutorial_condition(kind: String) -> bool:
var h := game.human
match kind:
"played_creature":
return game.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
"turn_passed":
return h.turns >= 2
"attacked":
return game.log.any(func(l): return l.ends_with("greift Fenn an.") or (" greift " in l and l.begins_with(h.board[0].name) if not h.board.is_empty() else false))
"resonance":
return h.board.any(func(c): return c.resonance > 0)
"combo":
return game.log.any(func(l): return l.begins_with("Feldkombo"))
"spell":
return game.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
return false
func tutorial_next() -> void:
if tutorial_step < TUTORIAL_STEPS.size() - 1:
tutorial_step += 1
app.sfx.play("click")
refresh()
func _tutorial_refresh() -> void:
while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]):
tutorial_step += 1
tutorial_text.text = "%d/%d %s" % [tutorial_step + 1, TUTORIAL_STEPS.size(), TUTORIAL_STEPS[tutorial_step][0]]
b_tut_next.visible = tutorial_step < TUTORIAL_STEPS.size() - 1
# --- Ansicht angleichen --------------------------------------------------------------------
func refresh() -> void:
if game == null:
return
var g := game
var h := perspective
var now := Time.get_ticks_msec()
var mine := my_turn()
var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else []
hand_views = []
own_views = []
enemy_views = []
trap_views = []
for o in others():
var panel: Panel = hero_panels[o]
var bcol = null
var bw := 0
if g.current() == o:
bcol = Style.BLUE
bw = 3
if o in valid or (selected != null and o.alive() and g.can_attack(selected, o)[0]):
bcol = Style.ORANGE
bw = 3
if int(flash_until.get(g.players.find(o), 0)) > now:
bcol = Style.RED
bw = 4
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if o.alive() else Style.darker(Style.PANEL, 0.6), 8, bcol, bw))
panel.mouse_filter = Control.MOUSE_FILTER_STOP if o.alive() else Control.MOUSE_FILTER_IGNORE
var labels: Dictionary = hero_labels[o]
labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
if o.alive():
labels.life.text = "♥ %d" % maxi(o.life, 0)
var traps_txt := (" F %d" if compact() else " Fallen %d") % o.traps.size() if not o.traps.is_empty() else ""
labels.stats.text = ("H %d D %d%s ◆ %d" if compact() else "Hand %d Deck %d%s ◆ %d") % [o.hand.size(), o.deck.size(), traps_txt, o.max_energy]
else:
labels.life.text = "besiegt"
labels.life.add_theme_color_override("font_color", Style.MUTED)
labels.stats.text = ""
var seen := {}
for p in g.players:
for j in p.board.size():
var cr: Game.Creature = p.board[j]
var rect := slot_rect(p, j)
seen[cr.uid] = true
var tile: CreatureView
if tiles.has(cr.uid):
tile = tiles[cr.uid]
tile.creature = cr
if tile.size != rect.size:
tile.size = rect.size
tile.custom_minimum_size = rect.size
tile.pivot_offset = rect.size / 2
else:
tile = CreatureView.new(cr, rect.size)
tile.position = rect.position
tile.slot = rect.position
tiles[cr.uid] = tile
tile_layer.add_child(tile)
tile.hover.connect(show_preview)
var uid := cr.uid
tile.clicked.connect(func(_v):
var c := game.find_creature(uid)
if c != null:
on_tile_click(c))
fx.pop_in(tile)
tile.slot = rect.position
if not tile.busy and tile.position != rect.position:
fx.move_to(tile, rect.position)
var b = null
var w := 2
if cr == selected:
b = Style.YELLOW
w = 4
elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
b = Style.ORANGE
w = 3
elif p == h and mine and cr.can_attack():
b = Style.GREEN
w = 3
tile.border = b
tile.width = w
tile.queue_redraw()
if p == h:
own_views.append(tile)
else:
enemy_views.append(tile)
for uid in tiles.keys():
if not seen.has(uid):
var t: CreatureView = tiles[uid]
tiles.erase(uid)
fx.fade_out(t)
Style.clear(trap_layer)
if not h.traps.is_empty():
Style.label(trap_layer, "Fallen", 13, Style.MUTED, Rect2(335, 492, 60, 20))
for i in h.traps.size():
var tv := CardView.new(h.traps[i], BattleLayout.trap_rect(i).size)
tv.position = BattleLayout.trap_rect(i).position
tv.hover.connect(show_preview)
trap_layer.add_child(tv)
trap_views.append(tv)
Style.clear(hand_layer)
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0]
var r := BattleLayout.hand_rect(h.hand.size(), i)
var v := CardView.new(card, r.size)
v.position = r.position
if card == pending:
v.border = Style.YELLOW
elif playable:
v.border = Style.GREEN
v.dim = not playable
v.hover.connect(show_hand_preview)
v.clicked.connect(func(_view): on_click("hand", card))
hand_layer.add_child(v)
hand_views.append(v)
var bar_border = Style.BLUE if mine else null
if int(flash_until.get(g.players.find(h), 0)) > now:
bar_border = Style.RED
bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy]
deck_label.text = "Deck %d" % h.deck.size()
status_label.text = "Zug %d · %s" % [g.turn, "Du bist dran" if mine else g.current().name + " ist dran …"]
b_end.disabled = not mine
var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()):
var line := g.log[i]
if line.begins_with("---"):
lines.append("[color=#6eaaf0]%s[/color]" % line)
elif line.begins_with("Feldkombo") or line.begins_with("Falle von"):
lines.append("[color=#f5c850]%s[/color]" % line)
else:
lines.append(line)
log_box.text = "\n".join(lines)
if tutorial:
_tutorial_refresh()
Style.clear(zoom_layer)
if zoom_player != null and zoom_player.alive():
build_zoom(valid)
if g.winner != "":
var wp := g.winner_player
if counts_for_profile():
var won := g.winner == "human"
result_label.text = "Sieg!" if won else "Niederlage"
result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED)
reward_label.text = "+%d Münzen" % maxi(reward, 0)
var notes: Array[String] = app.profile.take_notices()
if not notes.is_empty():
notice_label.text = "\n".join(notes)
else:
var mine_win: bool = wp != null and wp == perspective
result_label.text = ("%s gewinnt!" % wp.name) if wp != null and wp.is_human else "Die KI gewinnt"
result_label.add_theme_color_override("font_color", Style.GREEN if mine_win else Style.RED)
reward_label.text = "Gemeinsame Partie, keine Münzen"
overlay.visible = true
confirm.visible = false
handover.visible = false
mulligan_box.visible = false
func build_zoom(valid: Array) -> void:
var o: Game.Player = zoom_player
var g := game
var box := Rect2(190, 190, 900, 300)
var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2)
back.mouse_filter = Control.MOUSE_FILTER_STOP
Style.label(zoom_layer, "%s · %s ♥ %d Hand %d Deck %d Fallen %d ◆ %d" % [o.name, o.title, o.life, o.hand.size(), o.deck.size(), o.traps.size(), o.max_energy],
18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true)
for j in o.board.size():
var cr: Game.Creature = o.board[j]
var b = null
if cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
b = Style.ORANGE
var v := CreatureView.new(cr, Vector2(110, 140), b, 3 if b != null else 2)
v.position = Vector2(box.position.x + 20 + j * 122, box.position.y + 56)
v.hover.connect(show_preview)
v.clicked.connect(func(_x): on_tile_click(cr))
zoom_layer.add_child(v)
if o.board.is_empty():
Style.label(zoom_layer, "Keine Wesen auf dem Feld", 16, Style.MUTED, Rect2(box.position.x + 20, box.position.y + 110, 600, 30))
var hero_ok: bool = (o in valid) or (selected != null and g.can_attack(selected, o)[0])
var bh := Style.button(zoom_layer, "Held als Ziel", Rect2(box.position.x + 20, box.end.y - 60, 180, 44), func(): on_click("hero", o), Color("5a3c28"))
bh.disabled = not hero_ok
Style.button(zoom_layer, "Schließen", Rect2(box.end.x - 180, box.end.y - 60, 160, 44), func():
zoom_player = null
refresh())

View File

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

View File

@@ -0,0 +1,122 @@
class_name BoosterScreen
extends Control
const CARD_W := 200
const CARD_H := 280
var app
var cards: Array = []
var revealed := {}
var new_ids := {}
var coins_label: Label
var msg_label: Label
var b_buy: Button
var b_all: Button
var slots: Control
class CardBack extends Control:
signal clicked
func _init(sz: Vector2) -> void:
size = sz
custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit()
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect)
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
func slot_rect(i: int) -> Rect2:
var gap := 20
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
func _ready() -> void:
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots)
msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center")
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628"))
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all)
Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new()))
refresh()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())
func buy() -> void:
var p: Profile = app.profile
var before: Dictionary = p.collection.duplicate()
var got: Array = p.open_booster()
if got.is_empty():
msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED)
return
app.save()
app.sfx.play("play")
cards = got
revealed = {}
new_ids = {}
for c in cards:
if before.get(c.id, 0) == 0:
new_ids[c.id] = true
var notes: Array[String] = p.take_notices()
msg_label.text = " · ".join(notes)
msg_label.add_theme_color_override("font_color", Style.GREEN)
refresh()
func reveal(i: int) -> void:
revealed[i] = true
app.sfx.play("click")
refresh()
func reveal_all() -> void:
for i in 5:
revealed[i] = true
refresh()
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= 5
Style.clear(slots)
for i in 5:
var r := slot_rect(i)
if cards.is_empty():
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
elif revealed.has(i):
var c: Cards.Card = cards[i]
var v := CardView.new(c, r.size)
v.position = r.position
slots.add_child(v)
Style.label(slots, c.rarity, 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if new_ids.has(c.id):
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else:
Style.label(slots, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
else:
var back := CardBack.new(r.size)
back.position = r.position
back.clicked.connect(reveal.bind(i))
slots.add_child(back)

View File

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

View File

@@ -0,0 +1,59 @@
class_name CampaignScreen
extends Control
var app
func _ready() -> void:
var p: Profile = app.profile
var col := Style.column(self, 1000, 24, 6)
Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true)
Style.clabel(col, "Zehn Magier warten. Jede Stufe bringt mehr Gegner an den Tisch, alle nach denselben Regeln.", 16, Style.MUTED)
Style.spacer(col, 6)
for i in Campaign.STAGES.size():
var st: Dictionary = Campaign.STAGES[i]
var done := i < p.campaign_stage
var next := i == p.campaign_stage
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6,
Style.GOLD if next else null, 2 if next else 0))
col.add_child(panel)
var r := Style.row(panel, 12)
r.custom_minimum_size = Vector2(0, 50)
Style.clabel(r, str(i + 1), 20, Style.GOLD if next else Style.MUTED, "center", true, 40)
Style.clabel(r, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300)
var dots := Control.new()
dots.custom_minimum_size = Vector2(60, 0)
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
var elems: Array = st.elements
dots.draw.connect(func():
for k in elems.size():
dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]]))
r.add_child(dots)
var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2)
Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300)
if done:
Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110)
Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i))
elif next:
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c"))
else:
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass)
b.disabled = true
if p.campaign_stage >= Campaign.STAGES.size():
Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true)
Style.spacer(col, 4)
var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
func _start(stage: int) -> void:
app.show_screen(BattleScreen.new(0, stage))
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())

View File

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

50
scripts/ui/card_grid.gd Normal file
View File

@@ -0,0 +1,50 @@
class_name CardGrid
extends ScrollContainer
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
signal card_clicked(card: Cards.Card)
signal card_hover(card: Cards.Card, entered: bool)
const COLS := 7
const CW := 100
const CH := 140
const GAP := 12
var grid: GridContainer
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
grid = GridContainer.new()
grid.columns = COLS
grid.add_theme_constant_override("h_separation", GAP)
grid.add_theme_constant_override("v_separation", GAP)
add_child(grid)
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
Style.clear(grid)
for c in cards:
var cell := Control.new()
cell.custom_minimum_size = Vector2(CW, CH + 16)
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var v := CardView.new(c, Vector2(CW, CH))
v.dim = dim_fn.call(c)
v.clicked.connect(func(_view): card_clicked.emit(c))
v.hover.connect(func(card, entered): card_hover.emit(card, entered))
cell.add_child(v)
var text: String = label_fn.call(c)
Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")
grid.add_child(cell)
func views() -> Array:
var out := []
for cell in grid.get_children():
for v in cell.get_children():
if v is CardView:
out.append(v)
return out

View File

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

65
scripts/ui/card_view.gd Normal file
View File

@@ -0,0 +1,65 @@
class_name CardView
extends Control
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau).
signal clicked(view: CardView)
signal hover(card: Cards.Card, entered: bool)
var card: Cards.Card
var border = null # Color oder null (dann Seltenheitsfarbe)
var dim := false
func _init(c: Cards.Card, sz: Vector2) -> void:
card = c
custom_minimum_size = sz
size = sz
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(card, true))
mouse_exited.connect(func(): hover.emit(card, false))
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit(self)
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
var big := size.x >= 180
var mid := size.x >= 115
var f_name := 18 if big else (14 if mid else 12)
var f_text := 14 if big else (11 if mid else 10)
var f_stat := 24 if big else (18 if mid else 15)
Style.fill_elements(self, rect, card.elements, 8)
var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity]
Style.border(self, rect, bcol, 3 if big else 2, 8)
# Kosten
var r := 16 if big else 12
var cc := Vector2(r + 4, r + 4)
draw_circle(cc, r, Color("14141c"))
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
# Name und Kombo
var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12)
Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true)
if mid:
var sub: String = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
# Bild
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0)
var band_bottom: float = size.y * 0.42 - 4
if band_bottom - band_top > 14:
var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
# Textfeld
var box := Rect2(6, size.y * 0.42, size.x - 12, size.y * 0.42)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(f_text)),
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, f_text, 6 if big else 4, Style.TEXT)
# Werte
if card.is_creature():
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
if dim:
draw_rect(rect, Color(0, 0, 0, 0.47))

View File

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

View File

@@ -0,0 +1,92 @@
class_name CreatureView
extends Control
## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen).
signal clicked(view: CreatureView)
signal hover(card: Cards.Card, entered: bool)
var creature: Game.Creature
var border = null
var width := 2
var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren)
func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
creature = cr
border = b
width = w
custom_minimum_size = sz
size = sz
pivot_offset = sz / 2
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(creature.card, true))
mouse_exited.connect(func(): hover.emit(creature.card, false))
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit(self)
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
var small := size.x < 100
var mini := size.x < 60
Style.fill_elements(self, rect, creature.card.elements, 6)
var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity]
if flash:
bcol = Style.RED
Style.border(self, rect, bcol, 4 if flash else width, 6)
if mini:
_draw_mini()
return
var fs := 11 if small else 13
var nm := Style.fit(creature.name, true, fs, size.x - 6)
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
var badges: Array[String] = []
if creature.taunt():
badges.append("Wächter")
if creature.shield:
badges.append("Schild")
if creature.attacks_per_turn() == 2:
badges.append("x2")
if creature.frozen:
badges.append("Eis")
if creature.resonance > 0:
badges.append("Resonanz")
var y := 18.0 if small else 22.0
for b in badges:
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
y += 12 if small else 15
var f := 16 if small else 20
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true)
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
if creature.frozen:
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe.
func _draw_mini() -> void:
var letters: Array[String] = []
if creature.taunt():
letters.append("W")
if creature.shield:
letters.append("S")
if creature.attacks_per_turn() == 2:
letters.append("2")
if creature.frozen:
letters.append("E")
if creature.resonance > 0:
letters.append("R")
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true)
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true)
if creature.frozen:
draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35))

View File

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

351
scripts/ui/deck_screen.gd Normal file
View File

@@ -0,0 +1,351 @@
class_name DeckScreen
extends Control
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten.
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"]
var app
var slot := 0
var deck: Array = []
var filter := ""
var search := ""
var sort_mode := 0
var show_missing := false
var msg_label: Label
var grid: CardGrid
var deck_list: Control
var deck_label: Label
var elems_label: Label
var valid_label: Label
var active_label: Label
var curve: Control
var preview: CardView
var search_box: LineEdit
var sort_box: OptionButton
var missing_box: CheckButton
var slot_buttons: Array = []
var b_activate: Button
var b_autofill: Button
var name_box: LineEdit
class DeckRow extends Control:
signal clicked(card)
signal hover(card, entered: bool)
var card: Cards.Card
var count: int
var lit := false
func _init(c: Cards.Card, n: int, sz: Vector2) -> void:
card = c
count = n
size = sz
custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func():
lit = true
queue_redraw()
hover.emit(card, true))
mouse_exited.connect(func():
lit = false
queue_redraw()
hover.emit(card, false))
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit(card)
func _draw() -> void:
if lit:
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
for k in card.elements.size():
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
Style.text(self, "%dx %s" % [count, card.name], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
class EnergyCurve extends Control:
var cards: Array = []
func _draw() -> void:
Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0))
var counts := {}
for c in cards:
var k: int = mini(c.cost, 8)
counts[k] = counts.get(k, 0) + 1
for i in range(1, 9):
var n: int = counts.get(i, 0)
var x := (i - 1) * 48
draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE)
if n > 0:
Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom")
Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop")
func _ready() -> void:
slot = app.profile.active_deck
deck = app.profile.decks[slot].cards.duplicate()
Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.",
14, Style.MUTED, Rect2(300, 18, 700, 24))
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
search_box = LineEdit.new()
search_box.position = Vector2(400, 55)
search_box.size = Vector2(160, 34)
search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String):
search = t
refresh())
add_child(search_box)
sort_box = OptionButton.new()
sort_box.position = Vector2(570, 55)
sort_box.size = Vector2(130, 34)
for s in SORTS:
sort_box.add_item(s)
sort_box.item_selected.connect(func(i: int):
sort_mode = i
refresh())
add_child(sort_box)
missing_box = CheckButton.new()
missing_box.position = Vector2(710, 58)
missing_box.text = "Fehlende"
missing_box.toggled.connect(func(on: bool):
show_missing = on
refresh())
add_child(missing_box)
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
grid.card_clicked.connect(add)
grid.card_hover.connect(show_preview)
add_child(grid)
# Rechte Seite: Slots, Deckliste, Kurve
Style.panel(self, Rect2(845, 50, 420, 680))
for i in Profile.DECK_SLOTS:
slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i)))
b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate)
name_box = LineEdit.new()
name_box.position = Vector2(860, 98)
name_box.size = Vector2(200, 30)
name_box.max_length = 20
name_box.text_submitted.connect(func(t: String): rename(t))
name_box.focus_exited.connect(func(): rename(name_box.text))
add_child(name_box)
deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true)
active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right")
b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill)
b_autofill.add_theme_font_size_override("font_size", 13)
elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24))
valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22))
deck_list = Control.new()
deck_list.position = Vector2(860, 182)
deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(deck_list)
curve = EnergyCurve.new()
curve.position = Vector2(860, 604)
curve.size = Vector2(400, 125)
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(curve)
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c"))
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck)
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview.visible = false
add_child(preview)
refresh()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus():
app.show_screen(MenuScreen.new())
func _process(_delta: float) -> void:
if preview.visible:
var pos := get_local_mouse_position() + Vector2(16, 16)
pos.x = clampf(pos.x, 0, 1280 - preview.size.x)
pos.y = clampf(pos.y, 0, 800 - preview.size.y)
preview.position = pos
func set_filter(e: String) -> void:
filter = e
refresh()
func select_slot(i: int) -> void:
slot = i
deck = app.profile.decks[slot].cards.duplicate()
say("", true)
refresh()
func rename(t: String) -> void:
if t.strip_edges() == app.profile.deck_name(slot):
return
var err: String = app.profile.rename_deck(slot, t)
if err == "":
app.save()
say("Umbenannt in %s" % app.profile.deck_name(slot), true)
else:
say(err, false)
refresh()
func autofill() -> void:
app.profile.decks[slot].cards = deck.duplicate()
var err: String = app.profile.autofill_deck(slot)
deck = app.profile.decks[slot].cards.duplicate()
if err == "":
app.save()
say("Aufgefüllt und gespeichert", true)
else:
say(err, false)
refresh()
func activate() -> void:
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
if not ok[0]:
say("Erst ein spielbares Deck speichern", false)
return
app.profile.decks[slot].cards = deck.duplicate()
app.profile.active_deck = slot
app.save()
say("%s ist jetzt das aktive Deck" % app.profile.deck_name(slot), true)
refresh()
## Karten der Sammlung (oder alle, wenn Fehlende gezeigt werden), gefiltert und sortiert.
func cards() -> Array:
var out: Array = Cards.all().duplicate() if show_missing else app.profile.owned_cards()
if filter != "":
out = out.filter(func(c): return filter in c.elements)
if search != "":
var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
match sort_mode:
0:
out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
1:
out.sort_custom(func(a, b): return a.name < b.name)
3:
out.sort_custom(func(a, b):
var ra := Cards.RARITIES.find(a.rarity)
var rb := Cards.RARITIES.find(b.rarity)
return ra > rb or (ra == rb and a.cost < b.cost))
return out
func say(m: String, ok: bool) -> void:
msg_label.text = m
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
func add(card: Cards.Card) -> void:
var owned: int = app.profile.collection.get(card.id, 0)
var have := deck.count(card.id)
if owned == 0:
say("%s fehlt in deiner Sammlung (Werkstatt)" % card.name, false)
elif deck.size() >= Game.DECK_SIZE:
say("Das Deck ist voll (%d)" % Game.DECK_SIZE, false)
elif have >= mini(owned, Game.MAX_COPIES):
say("Nicht mehr Kopien möglich (max. %d)" % mini(owned, Game.MAX_COPIES), false)
else:
var elems := Game.deck_elements(deck_cards())
for e in card.elements:
if not (e in elems):
elems.append(e)
if elems.size() > Game.MAX_DECK_ELEMENTS:
say("Höchstens %d Elemente im Deck" % Game.MAX_DECK_ELEMENTS, false)
else:
deck.append(card.id)
app.sfx.play("click")
say("", true)
refresh()
func remove(card: Cards.Card) -> void:
deck.erase(card.id)
say("", true)
refresh()
func clear_deck() -> void:
deck = []
refresh()
func save_deck() -> void:
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
if ok[0]:
app.profile.decks[slot].cards = deck.duplicate()
app.save()
say("%s gespeichert" % app.profile.deck_name(slot), true)
else:
say(ok[1], false)
refresh()
func deck_cards() -> Array:
var out := []
for cid in deck:
out.append(Cards.by_id(cid))
return out
func show_preview(card: Cards.Card, entered: bool) -> void:
if entered:
preview.card = card
preview.visible = true
preview.queue_redraw()
elif preview.card == card:
preview.visible = false
func refresh() -> void:
var p: Profile = app.profile
grid.fill(cards(),
func(c):
var owned: int = p.collection.get(c.id, 0)
return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned],
func(c):
var owned: int = p.collection.get(c.id, 0)
return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES))
for i in slot_buttons.size():
var b: Button = slot_buttons[i]
var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED)
b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2))
active_label.text = "aktiv" if slot == p.active_deck else ""
# Deckliste
Style.clear(deck_list)
var counts := {}
for cid in deck:
counts[cid] = counts.get(cid, 0) + 1
var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid))
listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
for i in listed.size():
var c: Cards.Card = listed[i]
var row := DeckRow.new(c, counts[c.id], Vector2(400, 20))
row.position = Vector2(0, i * 21)
row.clicked.connect(remove)
row.hover.connect(show_preview)
deck_list.add_child(row)
var dc := deck_cards()
if not name_box.has_focus():
name_box.text = p.deck_name(slot)
deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE]
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
var elems := Game.deck_elements(dc)
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
elems_label.text = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–")
var ok: Array = Game.validate_deck(dc, p.collection)
valid_label.text = "spielbar" if ok[0] else ok[1]
valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE)
curve.cards = dc
curve.queue_redraw()

View File

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

View File

@@ -0,0 +1,111 @@
class_name HotseatScreen
extends Control
## Lokal zu mehreren am selben Rechner: Spielerzahl, KI-Zahl und Decks wählen.
var app
var n_humans := 2
var n_ai := 0
var deck_choice: Array = [] # je Mensch: Slot-Index oder -1 für Zufallsdeck
var human_buttons: Array = []
var ai_buttons: Array = []
var deck_rows: VBoxContainer
var b_start: Button
var hint: Label
func _ready() -> void:
var col := Style.column(self, 760, 40, 10)
Style.clabel(col, "Lokal zu mehreren", 36, Style.GOLD, "center", true)
Style.clabel(col, "Alle spielen am selben Rechner und reichen die Maus weiter. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED)
Style.spacer(col, 10)
Style.clabel(col, "Spieler", 20, Style.TEXT, "center", true)
var hr := Style.row(col, 10)
hr.alignment = BoxContainer.ALIGNMENT_CENTER
for n in range(2, 5):
human_buttons.append(Style.cbutton(hr, "%d Spieler" % n, Vector2(150, 44), func(): set_humans(n)))
Style.clabel(col, "KI-Gegner", 20, Style.TEXT, "center", true)
var ar := Style.row(col, 10)
ar.alignment = BoxContainer.ALIGNMENT_CENTER
for n in range(0, 5):
ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 44), func(): set_ai(n)))
Style.spacer(col, 6)
Style.clabel(col, "Decks", 20, Style.TEXT, "center", true)
deck_rows = VBoxContainer.new()
deck_rows.add_theme_constant_override("separation", 6)
col.add_child(deck_rows)
Style.spacer(col, 6)
hint = Style.clabel(col, "", 15, Style.ORANGE)
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
b_start = Style.cbutton(br, "Start", Vector2(200, 48), start, Color("285a3c"))
Style.cbutton(br, "Zurück", Vector2(160, 48), func(): app.show_screen(MenuScreen.new()))
for i in 4:
deck_choice.append(-1)
var p: Profile = app.profile
if p.deck_valid()[0]:
deck_choice[0] = p.active_deck
refresh()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())
func set_humans(n: int) -> void:
n_humans = n
n_ai = mini(n_ai, Game.MAX_OPPONENTS + 1 - n_humans)
refresh()
func set_ai(n: int) -> void:
n_ai = n
refresh()
func valid_slots() -> Array:
var out := []
for i in Profile.DECK_SLOTS:
if app.profile.deck_valid(i)[0]:
out.append(i)
return out
func refresh() -> void:
var p: Profile = app.profile
for i in human_buttons.size():
human_buttons[i].disabled = (i + 2) == n_humans
for i in ai_buttons.size():
ai_buttons[i].disabled = i == n_ai or n_humans + i > Game.MAX_OPPONENTS + 1
Style.clear(deck_rows)
var slots := valid_slots()
for h in n_humans:
var r := Style.row(deck_rows, 12)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.clabel(r, "Spieler %d" % (h + 1), 16, Style.TEXT, "right", true, 140)
var ob := OptionButton.new()
ob.custom_minimum_size = Vector2(340, 36)
ob.add_item("Zufallsdeck", -1)
for slot in slots:
var elems := Game.deck_elements(p.deck_cards(slot))
ob.add_item("%s (%s)" % [p.deck_name(slot), ", ".join(elems)], slot)
ob.select(ob.get_item_index(deck_choice[h]) if deck_choice[h] >= 0 and slots.has(deck_choice[h]) else 0)
var idx := h
ob.item_selected.connect(func(i: int): deck_choice[idx] = ob.get_item_id(i))
r.add_child(ob)
hint.text = "%d am Tisch" % (n_humans + n_ai)
func start() -> void:
var p: Profile = app.profile
var r := Game.make_rng()
var decks := []
for h in n_humans:
var slot: int = deck_choice[h]
if slot >= 0 and p.deck_valid(slot)[0]:
decks.append(p.deck_cards(slot))
else:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, r)
decks.append(Game.build_opponent_deck(r, [pool[0], pool[1]]))
app.show_screen(BattleScreen.hotseat(decks, n_ai))

View File

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

112
scripts/ui/lobby_screen.gd Normal file
View File

@@ -0,0 +1,112 @@
class_name LobbyScreen
extends Control
## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten.
var app
var net: Net
var status_label: Label
var list_label: Label
var ip_box: LineEdit
var b_host: Button
var b_join: Button
var b_start: Button
var ai_buttons: Array = []
var n_ai := 0
var addr_label: Label
func _ready() -> void:
net = app.net
net.my_name = "Spieler"
net.my_deck = app.profile.deck.duplicate()
var col := Style.column(self, 760, 40, 10)
Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true)
Style.clabel(col, "Im selben Netz: einer eröffnet, die anderen treten mit seiner Adresse bei. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED)
Style.spacer(col, 10)
var hr := Style.row(col, 12)
hr.alignment = BoxContainer.ALIGNMENT_CENTER
b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c"))
ip_box = LineEdit.new()
ip_box.custom_minimum_size = Vector2(220, 46)
ip_box.placeholder_text = "Adresse des Hosts"
ip_box.text = "127.0.0.1"
hr.add_child(ip_box)
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
addr_label = Style.clabel(col, "", 14, Style.MUTED)
status_label = Style.clabel(col, "", 16, Style.BLUE)
Style.spacer(col, 6)
Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true)
list_label = Style.clabel(col, "", 16, Style.TEXT)
Style.spacer(col, 6)
Style.clabel(col, "KI-Gegner (nur Host)", 16, Style.MUTED)
var ar := Style.row(col, 10)
ar.alignment = BoxContainer.ALIGNMENT_CENTER
for n in range(0, 5):
ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 40), func(): _set_ai(n)))
Style.spacer(col, 10)
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
Style.cbutton(br, "Zurück", Vector2(160, 48), _back)
net.lobby_changed.connect(refresh)
net.started.connect(_started)
net.ended.connect(func(_reason): refresh())
refresh()
func _exit_tree() -> void:
if net.lobby_changed.is_connected(refresh):
net.lobby_changed.disconnect(refresh)
if net.started.is_connected(_started):
net.started.disconnect(_started)
func _host() -> void:
net.host()
refresh()
func _join() -> void:
net.join(ip_box.text.strip_edges())
refresh()
func _set_ai(n: int) -> void:
n_ai = n
refresh()
func _start() -> void:
net.start_battle(n_ai)
func _started(seat: int) -> void:
app.show_screen(BattleScreen.online(seat))
func _back() -> void:
net.leave()
app.show_screen(MenuScreen.new())
func refresh() -> void:
status_label.text = net.status
var lines: Array[String] = []
var ids: Array = net.names.keys()
ids.sort()
for id in ids:
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
b_host.disabled = net.connected
b_join.disabled = net.connected
ip_box.editable = not net.connected
var humans := ids.size()
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)
for i in ai_buttons.size():
ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1
addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else ""
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus():
_back()

View File

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

52
scripts/ui/main.gd Normal file
View File

@@ -0,0 +1,52 @@
extends Control
## Einstieg: hält den Spielstand und wechselt zwischen den Bildschirmen.
var profile: Profile
var screen: Control
var save_path: String = Profile.SAVE_PATH
var sfx: Sfx
var net: Net
func _ready() -> void:
theme = Style.make_theme()
var bg := Style.Backdrop.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
sfx = Sfx.new()
add_child(sfx)
# Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt,
# der bei Host und Mitspielern gleich sein muss.
net = Net.new()
net.name = "Net"
get_tree().root.add_child.call_deferred(net)
profile = Profile.load_or_new(save_path)
profile.refresh_daily()
sfx.set_volume(profile.settings.volume)
sfx.set_music_volume(profile.settings.music)
set_language(profile.settings.language)
show_screen(MenuScreen.new())
func set_language(code: String) -> void:
TranslationServer.set_locale(code)
func show_screen(s: Control) -> void:
if screen != null:
screen.queue_free()
screen = s
s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(s)
func save() -> void:
profile.save(save_path)
func _exit_tree() -> void:
if net != null and net.is_inside_tree():
net.leave()
net.queue_free()

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

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

84
scripts/ui/menu_screen.gd Normal file
View File

@@ -0,0 +1,84 @@
class_name MenuScreen
extends Control
var app
var b_single: Button
var b_campaign: Button
var b_hotseat: Button
var b_online: Button
var b_tutorial: Button
var coins_label: Label
var coll_label: Label
var deck_label: Label
var daily_label: Label
var warn_label: Label
var notice_label: Label
func _ready() -> void:
var col := Style.column(self, 640, 24, 5)
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true)
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED)
Style.spacer(col, 8)
coins_label = Style.clabel(col, "", 20, Style.GOLD)
coll_label = Style.clabel(col, "", 16, Style.MUTED)
deck_label = Style.clabel(col, "", 16, Style.MUTED)
daily_label = Style.clabel(col, "", 16, Style.BLUE)
Style.spacer(col, 8)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 12)
grid.add_theme_constant_override("v_separation", 8)
var center := CenterContainer.new()
center.add_child(grid)
col.add_child(center)
var sz := Vector2(270, 44)
var p0: Profile = app.profile
b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()),
Color("5a4628") if not p0.tutorial_done else null)
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c"))
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new()))
Style.cbutton(grid, "Erfolge", sz, func(): app.show_screen(AchievementsScreen.new()))
Style.cbutton(grid, "Regeln", sz, func(): app.show_screen(RulesScreen.new()))
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
Style.spacer(col, 10)
warn_label = Style.clabel(col, "", 16, Style.RED)
notice_label = Style.clabel(col, "", 16, Style.GREEN)
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
refresh()
func refresh() -> void:
var p: Profile = app.profile
var ok: Array = p.deck_valid()
b_single.disabled = not ok[0]
b_campaign.disabled = not ok[0]
b_tutorial.disabled = not ok[0]
b_online.disabled = not ok[0]
var total := 0
for cid in p.collection:
total += p.collection[cid]
coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust]
coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()]
var elems := Game.deck_elements(p.deck_cards())
deck_label.text = "Aktives Deck: %s (%s)" % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else "leer"]
var task := p.daily_task()
if p.daily.done:
daily_label.text = "Tagesaufgabe erledigt: %s ✓" % task.text
else:
daily_label.text = "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
warn_label.text = "" if ok[0] else "Aktives Deck nicht spielbar: " + ok[1]
if ok[0] and not p.tutorial_done:
warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos."
warn_label.add_theme_color_override("font_color", Style.GOLD)
var notes := p.take_notices()
notice_label.text = " · ".join(notes)
if not notes.is_empty():
app.save()

View File

@@ -0,0 +1 @@
uid://3ib6kcyl21f

View File

@@ -0,0 +1,29 @@
class_name OpponentSelectScreen
extends Control
## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird.
var app
func _ready() -> void:
var col := Style.column(self, 900, 50, 14)
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
Style.clabel(col, "Wie viele Gegner? Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben." % Game.START_LIFE, 18, Style.MUTED)
Style.clabel(col, "Du gewinnst, wenn alle anderen besiegt sind. Mehr Gegner, mehr Chaos, mehr Münzen.", 16, Style.MUTED)
Style.spacer(col, 10)
for i in range(1, Game.MAX_OPPONENTS + 1):
var n := i
var r := Style.row(col, 20)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "%d Gegner" % n, Vector2(200, 56), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c"))
var info := ("Duell" if n == 1 else "%d Spieler am Tisch" % (n + 1)) + " · Sieg: %d Münzen" % Profile.REWARD_WIN[n]
Style.clabel(r, info, 16, Style.MUTED, "left", false, 420)
Style.spacer(col, 20)
var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())

View File

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

View File

@@ -0,0 +1,25 @@
class_name RulesScreen
extends Control
var app
func _ready() -> void:
Style.label(self, "Regeln", 36, Style.GOLD, Rect2(0, 18, 1280, 50), "center", true)
var scroll := ScrollContainer.new()
scroll.position = Vector2(60, 78)
scroll.size = Vector2(1160, 640)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
var text := Label.new()
text.text = Style.rules_text()
text.autowrap_mode = TextServer.AUTOWRAP_WORD
text.custom_minimum_size = Vector2(1140, 0)
text.add_theme_font_size_override("font_size", 15)
scroll.add_child(text)
Style.button(self, "Zurück", Rect2(560, 735, 160, 44), func(): app.show_screen(MenuScreen.new()))
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())

View File

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

View File

@@ -0,0 +1,121 @@
class_name SettingsScreen
extends Control
var app
var volume: HSlider
var music: HSlider
var speed: HSlider
var language: OptionButton
var music_label: Label
var anim: CheckButton
var volume_label: Label
var speed_label: Label
func _ready() -> void:
var s: Dictionary = app.profile.settings
var col := Style.column(self, 700, 50, 24)
Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true)
Style.spacer(col, 30)
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 24)
grid.add_theme_constant_override("v_separation", 30)
col.add_child(grid)
Style.clabel(grid, "Lautstärke", 20, Style.TEXT, "left", false, 200)
volume = HSlider.new()
volume.custom_minimum_size = Vector2(280, 24)
volume.min_value = 0
volume.max_value = 1
volume.step = 0.05
volume.value = s.volume
volume.value_changed.connect(_on_volume)
grid.add_child(volume)
volume_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Musik", 20, Style.TEXT, "left", false, 200)
music = HSlider.new()
music.custom_minimum_size = Vector2(280, 24)
music.min_value = 0
music.max_value = 1
music.step = 0.05
music.value = s.music
music.value_changed.connect(_on_music)
grid.add_child(music)
music_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "KI-Tempo", 20, Style.TEXT, "left", false, 200)
speed = HSlider.new()
speed.custom_minimum_size = Vector2(280, 24)
speed.min_value = 0.5
speed.max_value = 3.0
speed.step = 0.25
speed.value = s.ai_speed
speed.value_changed.connect(_on_speed)
grid.add_child(speed)
speed_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Animationen", 20, Style.TEXT, "left", false, 200)
anim = CheckButton.new()
anim.button_pressed = s.animations
anim.toggled.connect(_on_anim)
grid.add_child(anim)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200)
language = OptionButton.new()
language.custom_minimum_size = Vector2(280, 36)
language.add_item("Deutsch", 0)
language.add_item("English (Oberfläche)", 1)
language.select(1 if s.language == "en" else 0)
language.item_selected.connect(_on_language)
grid.add_child(language)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.spacer(col, 20)
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
Style.spacer(col, 60)
var r := Style.row(col)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
_labels()
func _labels() -> void:
volume_label.text = "%d %%" % int(round(volume.value * 100))
music_label.text = "%d %%" % int(round(music.value * 100))
speed_label.text = "%.2fx" % speed.value
func _on_music(v: float) -> void:
app.profile.settings.music = v
app.sfx.set_music_volume(v)
app.save()
_labels()
func _on_language(i: int) -> void:
var code := "en" if i == 1 else "de"
app.profile.settings.language = code
app.save()
app.set_language(code)
app.show_screen(SettingsScreen.new())
func _on_volume(v: float) -> void:
app.profile.settings.volume = v
app.sfx.set_volume(v)
app.sfx.play("click")
app.save()
_labels()
func _on_speed(v: float) -> void:
app.profile.settings.ai_speed = v
app.save()
_labels()
func _on_anim(on: bool) -> void:
app.profile.settings.animations = on
app.save()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())

View File

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

107
scripts/ui/sfx.gd Normal file
View File

@@ -0,0 +1,107 @@
class_name Sfx
extends Node
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
static var inst: Sfx
var streams := {}
var players: Array[AudioStreamPlayer] = []
var volume := 0.8
var music_player: AudioStreamPlayer
var music_volume := 0.5
func _ready() -> void:
inst = self
streams["click"] = _tone(880, 0.05, "sine")
streams["play"] = _tone(440, 0.14, "sine", 660)
streams["attack"] = _tone(220, 0.16, "square", 90)
streams["damage"] = _tone(140, 0.12, "square")
streams["heal"] = _tone(523, 0.22, "sine", 784)
streams["combo"] = _tone(660, 0.2, "sine", 1100)
streams["win"] = _tone(400, 0.6, "sine", 1200)
streams["lose"] = _tone(320, 0.7, "square", 90)
for i in 4:
var p := AudioStreamPlayer.new()
add_child(p)
players.append(p)
music_player = AudioStreamPlayer.new()
music_player.stream = _music()
add_child(music_player)
set_music_volume(music_volume)
## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt.
func _music() -> AudioStreamWAV:
var rate := 22050
var chords := [[220.0, 261.63, 329.63], [174.61, 220.0, 261.63], [130.81, 164.81, 196.0], [196.0, 246.94, 293.66]]
var note_len := 0.25
var pattern := [0, 1, 2, 1, 0, 2, 1, 2]
var total := chords.size() * pattern.size() * note_len
var n := int(total * rate)
var data := PackedByteArray()
data.resize(n * 2)
for i in n:
var t := float(i) / rate
var step := int(t / note_len)
var chord: Array = chords[(step / pattern.size()) % chords.size()]
var f: float = chord[pattern[step % pattern.size()]]
var lt := t - step * note_len
var env := minf(1.0, lt * 30.0) * maxf(0.0, 1.0 - lt / note_len)
var v := sin(TAU * f * t) * 0.6 + sin(TAU * f * 2.0 * t) * 0.15 + sin(TAU * chord[0] * 0.5 * t) * 0.25
data.encode_s16(i * 2, int(clampf(v * env * 0.25, -1.0, 1.0) * 32767))
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = rate
wav.stereo = false
wav.data = data
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
wav.loop_begin = 0
wav.loop_end = n
return wav
func set_music_volume(v: float) -> void:
music_volume = clampf(v, 0.0, 1.0)
if music_volume <= 0.0:
music_player.stop()
else:
music_player.volume_db = linear_to_db(music_volume * 0.6)
if not music_player.playing:
music_player.play()
func set_volume(v: float) -> void:
volume = clampf(v, 0.0, 1.0)
func play(kind: String) -> void:
if volume <= 0.0 or not streams.has(kind):
return
for p in players:
if not p.playing:
p.stream = streams[kind]
p.volume_db = linear_to_db(volume)
p.play()
return
func _tone(freq: float, dur: float, shape: String, freq_end: float = -1.0) -> AudioStreamWAV:
var rate := 22050
var n := int(dur * rate)
var data := PackedByteArray()
data.resize(n * 2)
for i in n:
var t := float(i) / rate
var f := freq if freq_end < 0 else lerpf(freq, freq_end, t / dur)
var env := 1.0 - t / dur
var v := sin(TAU * f * t)
if shape == "square":
v = 1.0 if v >= 0 else -1.0
data.encode_s16(i * 2, int(clampf(v * env * 0.4, -1.0, 1.0) * 32767))
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = rate
wav.stereo = false
wav.data = data
return wav

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

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

365
scripts/ui/style.gd Normal file
View File

@@ -0,0 +1,365 @@
class_name Style
## Farben, Schriften, Theme und Zeichenhilfen.
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
const BG := Color("1a1c24")
const PANEL := Color("282b36")
const PANEL_LIGHT := Color("3a3e4c")
const TEXT := Color("ececf0")
const MUTED := Color("9698a5")
const GOLD := Color("f5c850")
const GREEN := Color("5ad282")
const RED := Color("e65555")
const BLUE := Color("6eaaf0")
const YELLOW := Color("ffe65a")
const ORANGE := Color("ff963c")
static func darker(c: Color, f: float = 0.65) -> Color:
return Color(c.r * f, c.g * f, c.b * f, c.a)
static func lighter(c: Color, d: float = 0.1) -> Color:
return Color(minf(1, c.r + d), minf(1, c.g + d), minf(1, c.b + d), c.a)
static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.set_corner_radius_all(radius)
if border_col != null:
sb.border_color = border_col
sb.set_border_width_all(border_w)
return sb
static func make_theme() -> Theme:
var t := Theme.new()
t.default_font = FONT
t.default_font_size = 16
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2))
t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2))
t.set_stylebox("disabled", "Button", box(darker(PANEL, 0.9), 8, PANEL, 2))
t.set_stylebox("focus", "Button", StyleBoxEmpty.new())
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
t.set_color(name, "Button", TEXT)
t.set_color("font_disabled_color", "Button", MUTED)
t.set_font_size("font_size", "Button", 18)
t.set_color("font_color", "Label", TEXT)
t.set_stylebox("panel", "Panel", box(PANEL, 8))
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
t.set_stylebox("focus", "RichTextLabel", StyleBoxEmpty.new())
t.set_stylebox("panel", "ScrollContainer", StyleBoxEmpty.new())
t.set_stylebox("normal", "LineEdit", box(PANEL_LIGHT, 6, MUTED, 1))
t.set_stylebox("focus", "LineEdit", box(PANEL_LIGHT, 6, BLUE, 2))
t.set_color("font_color", "LineEdit", TEXT)
t.set_color("font_placeholder_color", "LineEdit", MUTED)
t.set_stylebox("normal", "OptionButton", box(PANEL_LIGHT, 6, MUTED, 1))
t.set_stylebox("hover", "OptionButton", box(lighter(PANEL_LIGHT), 6, MUTED, 1))
t.set_stylebox("pressed", "OptionButton", box(darker(PANEL_LIGHT), 6, MUTED, 1))
t.set_stylebox("focus", "OptionButton", StyleBoxEmpty.new())
t.set_color("font_color", "OptionButton", TEXT)
t.set_color("font_color", "CheckButton", TEXT)
t.set_stylebox("focus", "CheckButton", StyleBoxEmpty.new())
return t
# --- Knoten-Hilfen -------------------------------------------------------------
static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8, border_col = null, border_w: int = 0) -> Panel:
var p := Panel.new()
p.position = rect.position
p.size = rect.size
p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w))
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(p)
return p
static func label(parent: Node, text: String, size: int, color: Color, rect: Rect2, align: String = "left", bold: bool = false) -> Label:
var l := Label.new()
l.text = text
l.position = rect.position
l.size = rect.size
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", color)
if bold:
l.add_theme_font_override("font", FONT_BOLD)
match align:
"center":
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
"right":
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(l)
return l
static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable, color = null) -> Button:
var b := Button.new()
b.text = text
b.position = rect.position
b.size = rect.size
b.pressed.connect(on_pressed)
if color != null:
b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2))
b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2))
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
parent.add_child(b)
return b
## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
var v := VBoxContainer.new()
v.anchor_left = 0.5
v.anchor_right = 0.5
v.anchor_top = 0
v.anchor_bottom = 0
v.offset_left = -width / 2
v.offset_right = width / 2
v.offset_top = top
v.add_theme_constant_override("separation", sep)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(v)
return v
static func row(parent: Node, sep: int = 12) -> HBoxContainer:
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", sep)
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(h)
return h
## Label in einem Container.
static func clabel(parent: Node, text: String, size: int, color: Color, align: String = "center", bold: bool = false, min_w: float = 0) -> Label:
var l := label(parent, text, size, color, Rect2(0, 0, 0, 0), align, bold)
l.size_flags_horizontal = Control.SIZE_EXPAND_FILL if min_w == 0 else Control.SIZE_FILL
if min_w > 0:
l.custom_minimum_size = Vector2(min_w, 0)
return l
## Knopf in einem Container.
static func cbutton(parent: Node, text: String, size: Vector2, on_pressed: Callable, color = null) -> Button:
var b := button(parent, text, Rect2(Vector2.ZERO, size), on_pressed, color)
b.custom_minimum_size = size
return b
static func spacer(parent: Node, h: float) -> Control:
var c := Control.new()
c.custom_minimum_size = Vector2(0, h)
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(c)
return c
static func clear(node: Node) -> void:
for c in node.get_children():
node.remove_child(c)
c.queue_free()
# --- Zeichenhilfen für _draw() ---------------------------------------------------
static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
var font: Font = FONT_BOLD if bold else FONT
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x
var y := pos.y
match anchor:
"center":
x -= dim.x / 2
y -= dim.y / 2
"midtop":
x -= dim.x / 2
"topright":
x -= dim.x
"midleft":
y -= dim.y / 2
"midright":
x -= dim.x
y -= dim.y / 2
"bottomleft":
y -= dim.y
"bottomright":
x -= dim.x
y -= dim.y
"midbottom":
x -= dim.x / 2
y -= dim.y
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
var font: Font = FONT_BOLD if bold else FONT
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
s = s.substr(0, s.length() - 2) + "…"
return s
static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius: int) -> void:
var sb := StyleBoxFlat.new()
sb.draw_center = false
sb.border_color = color
sb.set_border_width_all(width)
sb.set_corner_radius_all(radius)
ci.draw_style_box(sb, rect)
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
if elements.size() == 1:
ci.draw_style_box(box(a, radius), rect)
elif elements[0] == elements[1]:
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
ci.draw_style_box(box(a, radius), rect)
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3))
else:
var b: Color = Cards.ELEMENT_COLORS[elements[1]]
var half := floorf(rect.size.x / 2)
var left := StyleBoxFlat.new()
left.bg_color = a
left.corner_radius_top_left = radius
left.corner_radius_bottom_left = radius
var right := StyleBoxFlat.new()
right.bg_color = b
right.corner_radius_top_right = radius
right.corner_radius_bottom_right = radius
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
static func rules_text() -> String:
var lines := [
"Ziel: Besiege alle anderen. Du verlierst, wenn dein Leben auf 0 fällt.",
"Du spielst gegen 1 bis 5 Magier, jeder gegen jeden, alle nach denselben Regeln:",
"%d Leben, pro Zug 1 Energie mehr (höchstens 10) und 1 Karte. Die Zugreihenfolge wird ausgelost." % Game.START_LIFE,
"Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug 1 Energie extra.",
"Fallen liegen verdeckt (höchstens 2) und lösen aus, wenn dich jemand angreift oder ein Wesen ausspielt.",
"",
"Karten kosten Energie. Wesen kommen aufs Feld (max. 5) und greifen ab dem nächsten Zug an.",
"Zauber wirken sofort. Ein Wesen greift ein gegnerisches Wesen oder direkt den Gegner an;",
"beide Seiten verursachen ihren Angriffswert als Schaden.",
"",
"Elemente: Feuer (Schaden), Wasser (Kontrolle), Erde (Zähigkeit), Luft (Tempo).",
"Kombos verbinden zwei Elemente, auch das gleiche doppelt (Feuer+Feuer = Inferno).",
"Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.",
Cards.RESONANCE_TEXT,
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an alle gegnerischen Wesen, Blitz 2 Schaden",
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.",
"",
"Schlüsselwörter:",
]
for k in Cards.KEYWORDS:
lines.append(" %s: %s" % [k, Cards.KEYWORD_HELP[k]])
lines.append("")
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES])
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.")
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.")
lines.append("")
lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.")
lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.")
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.")
lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.")
return "\n".join(lines)
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
var variant := absi(hash(card.id)) % 3
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
col.a = 0.95
if card.is_trap():
# Falle: Kreuz im Kreis
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
var d := size * 0.28
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
return
if card.kind == "Zauber":
_star(ci, center, size * 0.55, Color(col, 0.35))
var elems: Array = card.elements
if elems.size() >= 2 and elems[0] != elems[1]:
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
col2.a = 0.95
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
else:
_element_glyph(ci, elems[0], center, size, variant, col)
if elems.size() >= 2:
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
if elems.size() >= 3:
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
var pts := PackedVector2Array()
for i in 8:
var a := TAU * i / 8 - PI / 2
var rr := r if i % 2 == 0 else r * 0.45
pts.append(c + Vector2(cos(a), sin(a)) * rr)
ci.draw_colored_polygon(pts, col)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
var h := size / 2
match element:
"Feuer":
var n := 1 + variant
for i in n:
var off := Vector2((i - (n - 1) / 2.0) * size * 0.32, 0)
var s := 1.0 if i == n / 2 else 0.7
_flame(ci, c + off, h * s, col)
"Wasser":
for i in 2 + variant % 2:
var y := c.y + (i - 0.5) * size * 0.3
for k in 3:
var x := c.x - size * 0.4 + k * size * 0.4
ci.draw_arc(Vector2(x, y), size * 0.2, PI, TAU, 12, col, maxf(2, size * 0.09))
"Erde":
var n := 1 + variant
for i in n:
var off := (i - (n - 1) / 2.0) * size * 0.4
var s := 1.0 if i == n / 2 else 0.65
var pts := PackedVector2Array([c + Vector2(off - h * s, h * 0.7), c + Vector2(off, -h * s), c + Vector2(off + h * s, h * 0.7)])
ci.draw_colored_polygon(pts, col)
ci.draw_line(c + Vector2(-h, h * 0.7), c + Vector2(h, h * 0.7), col, maxf(2, size * 0.08))
"Luft":
for i in 2 + variant:
var r := h * (1.0 - i * 0.28)
ci.draw_arc(c + Vector2(i * size * 0.06, 0), r, PI * 0.2 + i * 0.4, PI * 1.5 + i * 0.4, 20, col, maxf(2, size * 0.09))
static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
var w := h * 0.75
var pts := PackedVector2Array([
c + Vector2(0, -h), c + Vector2(w * 0.55, -h * 0.25), c + Vector2(w * 0.4, h * 0.45),
c + Vector2(0, h), c + Vector2(-w * 0.4, h * 0.45), c + Vector2(-w * 0.55, -h * 0.25)])
ci.draw_colored_polygon(pts, col)
var inner := PackedVector2Array([
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75))
## Dezenter Hintergrund: Farbverlauf und schräge Linien.
class Backdrop extends Control:
func _draw() -> void:
var sz := size
draw_rect(Rect2(Vector2.ZERO, sz), Style.BG)
var c := Vector2(sz.x * 0.5, sz.y * 0.35)
for i in range(8, 0, -1):
var r := sz.x * 0.09 * i
draw_circle(c, r, Color(0.16, 0.18, 0.26, 0.05))
for k in range(-20, 40):
var x := k * 80.0
draw_line(Vector2(x, 0), Vector2(x + sz.y * 0.6, sz.y), Color(1, 1, 1, 0.012), 1.0)

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

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

View File

@@ -0,0 +1,151 @@
class_name WorkshopScreen
extends Control
## Werkstatt: Karten zerlegen (Staub) und herstellen.
var app
var filter := ""
var search := ""
var selected: Cards.Card = null
var grid: CardGrid
var search_box: LineEdit
var dust_label: Label
var msg_label: Label
var preview_holder: Control
var info_label: Label
var b_dismantle: Button
var b_craft: Button
var b_extras: Button
func _ready() -> void:
Style.label(self, "Werkstatt", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES,
14, Style.MUTED, Rect2(240, 18, 600, 24))
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
search_box = LineEdit.new()
search_box.position = Vector2(400, 55)
search_box.size = Vector2(200, 34)
search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String):
search = t
refresh())
add_child(search_box)
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
grid.card_clicked.connect(select)
add_child(grid)
Style.panel(self, Rect2(845, 50, 420, 680))
dust_label = Style.label(self, "", 22, Style.BLUE, Rect2(860, 58, 390, 30), "left", true)
preview_holder = Control.new()
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(preview_holder)
info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center")
b_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28"))
b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c"))
b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras)
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "center")
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
refresh()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus():
app.show_screen(MenuScreen.new())
func set_filter(e: String) -> void:
filter = e
refresh()
func cards() -> Array:
var out: Array = Cards.all().duplicate()
if filter != "":
out = out.filter(func(c): return filter in c.elements)
if search != "":
var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
return out
func select(card: Cards.Card) -> void:
selected = card
msg_label.text = ""
refresh()
func say(m: String, ok: bool) -> void:
msg_label.text = m
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
func dismantle() -> void:
if selected == null:
return
var err: String = app.profile.dismantle(selected.id)
if err == "":
app.sfx.play("click")
app.save()
say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true)
else:
say(err, false)
refresh()
func craft() -> void:
if selected == null:
return
var err: String = app.profile.craft(selected.id)
if err == "":
app.sfx.play("play")
app.save()
var notes: Array[String] = app.profile.take_notices()
say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
else:
say(err, false)
refresh()
func dismantle_extras() -> void:
var got: int = app.profile.dismantle_extras()
if got > 0:
app.save()
say("Überschuss zerlegt: +%d Staub" % got, true)
else:
say("Kein Überschuss vorhanden", false)
refresh()
func refresh() -> void:
var p: Profile = app.profile
dust_label.text = "Staub: %d" % p.dust
grid.fill(cards(),
func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt",
func(c): return p.collection.get(c.id, 0) == 0)
Style.clear(preview_holder)
if selected == null:
info_label.text = "Keine Karte gewählt"
b_dismantle.text = "Zerlegen"
b_craft.text = "Herstellen"
b_dismantle.disabled = true
b_craft.disabled = true
else:
var v := CardView.new(selected, Vector2(200, 280))
v.position = Vector2(955, 100)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var owned: int = p.collection.get(selected.id, 0)
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
info_label.text = "%s · %s\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
selected.combo(), selected.rarity, owned, st.played, st.games, st.wins]
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity]
b_dismantle.disabled = owned == 0
b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity]
var extras := 0
for cid in p.collection:
extras += maxi(0, p.collection[cid] - Game.MAX_COPIES)
b_extras.text = "Überschuss zerlegen (%d Karten)" % extras
b_extras.disabled = extras == 0

View File

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

11
tests/dump_cards.gd Normal file
View File

@@ -0,0 +1,11 @@
extends SceneTree
## Schreibt den Kartenpool als JSON: godot --headless --path . -s tests/dump_cards.gd
func _initialize() -> void:
var list := Cards.all().map(func(c): return c.to_dict())
var f := FileAccess.open("res://data/karten.json", FileAccess.WRITE)
f.store_string(JSON.stringify({"karten": list}, "\t", false))
f.close()
print("%d Karten nach data/karten.json geschrieben" % list.size())
quit()

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

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

35
tests/matchup.gd Normal file
View File

@@ -0,0 +1,35 @@
extends SceneTree
## Misst Deckarchetypen im Duell (beide gierig): wer beginnt, Mono gegen Paar.
func deck(r: RandomNumberGenerator, mono: bool) -> Array:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, r)
return Game.build_opponent_deck(r, [pool[0]] if mono else [pool[0], pool[1]])
func duel(r: RandomNumberGenerator, first_mono: bool, second_mono: bool) -> bool:
var second := Game.Player.new("Gegner 1", deck(r, second_mono), false, Game.START_LIFE, "x")
var g := Game.new_battle_with(deck(r, first_mono), [second], r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current(), true)
guard += 1
return g.winner == "human"
func run(label: String, a_mono: bool, b_mono: bool, games: int, r: RandomNumberGenerator) -> void:
var wins := 0
for i in games:
if duel(r, a_mono, b_mono):
wins += 1
print("%-28s Startspieler gewinnt %3d%%" % [label, wins * 100 / games])
func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 5
run("Paar gegen Paar", false, false, 200, r)
run("Mono gegen Mono", true, true, 200, r)
run("Paar (startet) gegen Mono", false, true, 200, r)
run("Mono (startet) gegen Paar", true, false, 200, r)
quit()

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

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

65
tests/net_bot.gd Normal file
View File

@@ -0,0 +1,65 @@
extends Node
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge.
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
var net: Net
var seat := -1
var game: Game
var t := 0.0
func _ready() -> void:
var ip := "127.0.0.1"
var bot_name := "Bot"
for a in OS.get_cmdline_user_args():
if a.begins_with("--ip="):
ip = a.substr(5)
if a.begins_with("--name="):
bot_name = a.substr(7)
net = Net.new()
net.name = "Net"
get_tree().root.add_child.call_deferred(net)
net.my_name = bot_name
net.my_deck = Profile.starter().deck.duplicate()
net.started.connect(func(s): seat = s)
net.snapshot_received.connect(_on_snapshot)
net.ended.connect(func(_r): get_tree().quit())
await get_tree().process_frame
net.join(ip)
func _process(delta: float) -> void:
t += delta
if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
net.leave()
get_tree().quit()
func _on_snapshot(snap: Dictionary) -> void:
game = Game.from_snapshot(snap)
if game.winner != "":
await get_tree().create_timer(1.0).timeout
get_tree().quit()
return
if game.current_idx != seat:
return
var me: Game.Player = game.players[seat]
if not me.mulligan_done and me.turns == 1:
net.send_action({"type": "mulligan", "hand": []})
return
for i in me.hand.size():
var card: Cards.Card = me.hand[i]
if game.can_play(me, card)[0] and card.target_type() == "":
net.send_action({"type": "play", "hand": i, "target": null})
return
for cr in me.board:
if cr.can_attack():
for e in game.enemies_of(me):
if game.can_attack(cr, e)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}})
return
for c in e.board:
if game.can_attack(cr, c)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
return
net.send_action({"type": "end"})

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

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

6
tests/net_bot.tscn Normal file
View File

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

8
tests/run_headless.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
set -euo pipefail
GODOT="${1:-godot}"
cd "$(dirname "$0")/.."
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true
"$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true
grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log

43
tests/simulate.gd Normal file
View File

@@ -0,0 +1,43 @@
extends SceneTree
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
## ist etwa 1/(Gegner+1) Siege zu erwarten:
## godot --headless --path . -s tests/simulate.gd
func _initialize() -> void:
var games := 100
var r := RandomNumberGenerator.new()
r.seed = 0
var starter := Profile.starter().deck_cards()
var random_deck := func(rr: RandomNumberGenerator) -> Array:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, rr)
return Game.build_opponent_deck(rr, [pool[0], pool[1]])
var t0 := Time.get_ticks_msec()
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
for n in range(1, Game.MAX_OPPONENTS + 1):
_run("Zufallsdeck", random_deck, n, games, r, true, true)
print("Du mit Vorausschau, Gegner gierig:")
for n in [1, 2, 3]:
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true)
print("Alle mit Vorausschau:")
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false)
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false)
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
quit()
func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator,
human_greedy: bool, opp_greedy: bool) -> void:
var wins := 0
var turns := 0
for i in games:
var g := Game.new_battle(deck_fn.call(r), n_opp, r)
while g.winner == "":
var p := g.current()
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
assert(g.turn < 300, "Spiel endet nicht")
if g.winner == "human":
wins += 1
turns += g.turn
print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games])

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

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

1198
tests/test_runner.gd Normal file

File diff suppressed because it is too large Load Diff

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

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

674
tests/ui_test.gd Normal file
View File

@@ -0,0 +1,674 @@
extends Node
## Oberflächentest mit echtem Fenster und Screenshots:
## DINGER_SHOTS=/pfad/zum/ordner godot --path . tests/ui_test.tscn
const SAVE := "user://test_ui_spielstand.json"
var main: Control
var shots := ""
var passed := 0
var failed := 0
func check(cond: bool, msg: String) -> void:
if cond:
passed += 1
else:
failed += 1
printerr("FEHLER: " + msg)
func frames(n: int = 2) -> void:
for i in n:
await get_tree().process_frame
func shot(name: String) -> void:
if shots == "":
return
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(shots.path_join(name + ".png"))
func click_at(pos: Vector2) -> void:
var down := InputEventMouseButton.new()
down.position = pos
down.global_position = pos
down.button_index = MOUSE_BUTTON_LEFT
down.pressed = true
get_viewport().push_input(down)
var up: InputEventMouseButton = down.duplicate()
up.pressed = false
get_viewport().push_input(up)
await frames(2)
func click(node: Control) -> void:
check(node != null, "Klickziel vorhanden")
if node != null:
await click_at(node.get_global_rect().get_center())
func right_click() -> void:
var down := InputEventMouseButton.new()
down.position = Vector2(5, 5)
down.global_position = down.position
down.button_index = MOUSE_BUTTON_RIGHT
down.pressed = true
get_viewport().push_input(down)
var up: InputEventMouseButton = down.duplicate()
up.pressed = false
get_viewport().push_input(up)
await frames(2)
func key(code: int) -> void:
var down := InputEventKey.new()
down.keycode = code
down.pressed = true
get_viewport().push_input(down)
var up := InputEventKey.new()
up.keycode = code
up.pressed = false
get_viewport().push_input(up)
await frames(2)
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
func clear_mulligan(scr) -> void:
if scr.mulligan_box.visible:
await click(scr.b_keep)
func hover(node: Control) -> void:
var ev := InputEventMouseMotion.new()
ev.position = node.get_global_rect().get_center()
ev.global_position = ev.position
get_viewport().push_input(ev)
await frames(2)
func screen():
return main.screen
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist.
func ensure_menu() -> void:
if main.net.connected:
main.net.leave()
if TranslationServer.get_locale().begins_with("en"):
main.set_language("de")
if not (screen() is MenuScreen):
main.show_screen(MenuScreen.new())
await frames(2)
func find_button(text: String, root: Node = null) -> Button:
if root == null:
root = screen()
for c in root.get_children():
if c is Button and (c.text == text or c.tr(c.text) == text) and c.is_visible_in_tree():
return c
var found := find_button(text, c)
if found != null:
return found
return null
func card_views(root: Node) -> Array:
var out := []
for c in root.get_children():
if c is CardView:
out.append(c)
out.append_array(card_views(c))
return out
## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz).
func settle(scr) -> void:
for i in 40:
if not scr.tiles.values().any(func(t): return t.busy):
return
await frames(1)
func wait_for_human(scr) -> void:
var g: Game = scr.game
var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1)
wait += 1
await frames(1)
await clear_mulligan(scr)
func _ready() -> void:
Game.test_seed = 4242 # wiederholbare Partien und Booster
shots = OS.get_environment("DINGER_SHOTS")
var abs := ProjectSettings.globalize_path(SAVE)
for suffix in ["", ".bak", ".corrupt"]:
if FileAccess.file_exists(SAVE + suffix):
DirAccess.remove_absolute(abs + suffix)
main = load("res://scenes/main.tscn").instantiate()
main.save_path = SAVE
add_child(main)
await frames(3)
for t in [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_hotseat,
test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]:
await ensure_menu()
await t.call()
for suffix in ["", ".bak", ".corrupt"]:
if FileAccess.file_exists(SAVE + suffix):
DirAccess.remove_absolute(abs + suffix)
print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed])
get_tree().quit(1 if failed > 0 else 0)
func test_menu_und_regeln() -> void:
check(screen() is MenuScreen, "Menü sichtbar")
await shot("01_menu")
await click(find_button("Regeln"))
check(screen() is RulesScreen, "Regeln geöffnet")
await shot("02_regeln")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_booster() -> void:
await click(find_button("Booster öffnen"))
var scr = screen()
check(scr is BoosterScreen, "Boosterbildschirm")
var coins: int = main.profile.coins
var ach_before: int = main.profile.achievements.size()
await click(scr.b_buy)
var bonus := 0
for a in Profile.ACHIEVEMENTS:
if a.id in main.profile.achievements.slice(ach_before):
bonus += a.reward
check(main.profile.coins == coins - Profile.BOOSTER_COST + bonus, "Booster bezahlt (plus Erfolgsmünzen %d)" % bonus)
check(scr.cards.size() == 5 and scr.revealed.is_empty(), "5 verdeckte Karten")
await shot("03_booster_zu")
var backs: Array = scr.slots.get_children().filter(func(c): return c is BoosterScreen.CardBack)
await click(backs[0])
check(scr.revealed.size() == 1, "eine Karte aufgedeckt")
await click(scr.b_all)
check(scr.revealed.size() == 5, "alle aufgedeckt")
await shot("04_booster_offen")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_deckbau() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
check(scr is DeckScreen, "Deckbildschirm")
check(scr.deck.size() == 20, "Deck geladen")
await shot("05_deck")
await click(find_button("Leeren"))
check(scr.deck.is_empty(), "geleert")
var first_id: String = card_views(scr.grid)[0].card.id
var allowed: int = mini(main.profile.collection[first_id], Game.MAX_COPIES)
await click(card_views(scr.grid)[0])
check(scr.deck == [first_id], "Karte hinzugefügt")
for i in allowed:
await click(card_views(scr.grid)[0])
check(scr.deck.count(first_id) == allowed and "Kopien" in scr.msg_label.text, "nur besessene Kopien (%d)" % allowed)
await click(find_button("Speichern"))
check(main.profile.deck.size() == 20 and scr.msg_label.text != "Deck gespeichert", "zu kleines Deck nicht gespeichert")
await click(find_button("Wasser"))
await click(card_views(scr.grid)[0])
await click(find_button("Erde"))
await click(card_views(scr.grid)[0])
check("Elemente" in scr.msg_label.text, "drittes Element abgelehnt")
await click(find_button("Alle"))
# Suche, Sortierung, fehlende Karten
var all_count := card_views(scr.grid).size()
scr.search_box.text = "Wächter"
scr.search_box.text_changed.emit("Wächter")
await frames(1)
var found := card_views(scr.grid)
check(found.size() > 0 and found.size() < all_count, "Suche filtert")
check(found.all(func(v): return "Wächter" in v.card.text() or "Wächter" in v.card.name), "Suche trifft Kartentext")
scr.search_box.text = ""
scr.search_box.text_changed.emit("")
scr.sort_box.select(1)
scr.sort_box.item_selected.emit(1)
await frames(1)
var names: Array = card_views(scr.grid).map(func(v): return v.card.name)
var sorted_names := names.duplicate()
sorted_names.sort()
check(names == sorted_names, "Sortierung nach Name")
scr.missing_box.button_pressed = true
await frames(1)
check(card_views(scr.grid).size() == Cards.all().size(), "Fehlende Karten eingeblendet")
var missing = card_views(scr.grid).filter(func(v): return main.profile.collection.get(v.card.id, 0) == 0)
check(not missing.is_empty(), "es gibt fehlende Karten")
await click(missing[0])
check("fehlt" in scr.msg_label.text, "fehlende Karte nicht ins Deck")
scr.missing_box.button_pressed = false
scr.sort_box.select(0)
scr.sort_box.item_selected.emit(0)
await frames(1)
var n: int = scr.deck.size()
await click(scr.deck_list.get_children()[0])
check(scr.deck.size() == n - 1, "Zeile entfernt Karte")
await hover(card_views(scr.grid)[0])
check(scr.preview.visible, "Vorschau bei Hover")
await shot("06_deck_bearbeitet")
# Slots: Deck 2 aus Deck 1 kopieren, speichern, aktiv setzen
await click(scr.slot_buttons[1])
if scr.slot != 1 or not scr.deck.is_empty():
print("DEBUG Slot: slot=", scr.slot, " deck=", scr.deck.size(), " rect=", scr.slot_buttons[1].get_global_rect(),
" hovered=", get_viewport().gui_get_hovered_control(), " decks1=", main.profile.decks[1].cards.size())
check(scr.slot == 1 and scr.deck.is_empty(), "Slot 2 leer")
scr.deck = main.profile.decks[0].cards.duplicate()
scr.refresh()
await click(find_button("Speichern"))
check(main.profile.decks[1].cards.size() == 20 and "gespeichert" in scr.msg_label.text, "Slot 2 gespeichert")
await click(find_button("Aktiv setzen"))
check(main.profile.active_deck == 1 and main.profile.deck_name() == "Deck 2", "Slot 2 aktiv")
check(Profile.load_or_new(SAVE).active_deck == 1, "aktiver Slot gespeichert")
scr.name_box.text = "Testdeck"
scr.name_box.text_submitted.emit("Testdeck")
check(main.profile.deck_name(1) == "Testdeck" and Profile.load_or_new(SAVE).deck_name(1) == "Testdeck", "Deck umbenannt")
await click(scr.slot_buttons[2])
check(scr.slot == 2 and scr.deck.is_empty(), "Slot 3 leer")
await click(find_button("Auto-Ausfüllen"))
check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar")
await shot("25_deck_slots")
await click(find_button("Zurück"))
check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen")
func target_view(scr, valid: Array) -> Control:
for v in scr.enemy_views:
if v.creature in valid:
return v
for v in scr.own_views:
if v.creature in valid:
return v
for p in scr.hero_panels:
if p in valid:
return scr.hero_panels[p]
return null
func human_turn(scr) -> void:
var g: Game = scr.game
await clear_mulligan(scr)
for i in 12:
var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0])
if playable.is_empty():
break
var card: Cards.Card = playable[0].card
var energy: int = g.human.energy
await click(playable[0])
if scr.pending != null:
await click(target_view(scr, g.valid_targets(g.human, card.target_type())))
check(g.human.energy == energy - card.cost, "Karte gespielt: " + card.name)
for i in 12:
var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack())
if ready.is_empty() or g.winner != "":
break
var cr: Game.Creature = ready[0].creature
await settle(scr)
await hover(ready[0])
var under = get_viewport().gui_get_hovered_control()
await click(ready[0])
if scr.selected != cr:
print("DEBUG Auswahl fehlgeschlagen: rect=", ready[0].get_global_rect(), " scale=", ready[0].scale,
" busy=", ready[0].busy, " unter Maus=", under, " Meldung='", scr.msg_label.text,
"' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected)
check(scr.selected == cr, "Wesen gewählt")
var targets := []
for o in g.opponents:
if o.alive() and g.can_attack(cr, o)[0]:
targets.append(o)
for c in o.board:
if g.can_attack(cr, c)[0]:
targets.append(c)
await settle(scr)
await click(target_view(scr, targets))
check(scr.selected == null and cr.attacks_left < cr.attacks_per_turn(), "Angriff ausgeführt")
if g.winner == "":
await click(scr.b_end)
func start_battle(n_opp: int) -> void:
await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl")
await click(find_button("%d Gegner" % n_opp))
func test_gegnerauswahl() -> void:
await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl geöffnet")
await shot("07_gegnerauswahl")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
for n in [1, 5]:
await start_battle(n)
var scr = screen()
check(scr is BattleScreen and scr.game.opponents.size() == n, "Kampf gegen %d gestartet" % n)
check(scr.game.players[0].max_energy == 1 and scr.game.opponents[0].life == Game.START_LIFE, "gleiche Regeln gegen %d" % n)
await shot("11_kampf_%d" % n)
await give_up()
func give_up() -> void:
var scr = screen()
await clear_mulligan(scr)
await click(find_button("Aufgeben"))
check(scr.confirm.visible, "Aufgeben fragt nach")
await click(find_button("Weiterspielen"))
check(not scr.confirm.visible and screen() == scr, "Weiterspielen bleibt im Kampf")
await click(find_button("Aufgeben"))
await click(find_button("Ja, aufgeben"))
check(screen() is MenuScreen, "Aufgeben führt ins Menü")
func test_einstellungen() -> void:
await click(find_button("Einstellungen"))
var scr = screen()
check(scr is SettingsScreen, "Einstellungen geöffnet")
scr.volume.value = 0.3
scr.speed.value = 2.0
scr.anim.button_pressed = false
await frames(1)
check(is_equal_approx(main.profile.settings.volume, 0.3) and is_equal_approx(main.profile.settings.ai_speed, 2.0), "Regler gespeichert")
check(main.profile.settings.animations == false, "Animationen abschaltbar")
check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte")
scr.anim.button_pressed = true
await shot("13_einstellungen")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_erfolge() -> void:
await click(find_button("Erfolge"))
check(screen() is AchievementsScreen, "Erfolge geöffnet")
await shot("14_erfolge")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_werkstatt() -> void:
await click(find_button("Werkstatt"))
var scr = screen()
check(scr is WorkshopScreen, "Werkstatt geöffnet")
var p: Profile = main.profile
p.dust = 0
var in_decks := {}
for d in p.decks:
for cid in d.cards:
in_decks[cid] = true
var free = scr.grid.views().filter(func(v): return p.collection.get(v.card.id, 0) > 0 and not in_decks.has(v.card.id))
check(not free.is_empty(), "eine freie Karte zum Zerlegen")
var card: Cards.Card = free[0].card
await click(free[0])
check(scr.selected == card, "Karte gewählt")
var before: int = p.collection[card.id]
await click(scr.b_dismantle)
check(p.collection[card.id] == before - 1 and p.dust == Profile.DUST_DISMANTLE[card.rarity], "zerlegt, Staub gutgeschrieben")
p.dust = 5000
scr.search_box.text = "Phönix"
scr.search_box.text_changed.emit("Phönix")
await frames(1)
var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0]
await click(phoenix)
await click(scr.b_craft)
check(p.collection.get("ff_phoenix", 0) == 1 and p.dust == 5000 - Profile.DUST_CRAFT["legendär"], "Phönix hergestellt")
check(Profile.load_or_new(SAVE).collection.get("ff_phoenix", 0) == 1, "Werkstatt gespeichert")
await shot("20_werkstatt")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_hotseat() -> void:
await click(find_button("Lokal zu mehreren"))
var scr = screen()
check(scr is HotseatScreen, "Hotseat-Auswahl")
await click(find_button("3 Spieler"))
await click(find_button("1 KI"))
check(scr.n_humans == 3 and scr.n_ai == 1, "3 Menschen, 1 KI")
await shot("17_hotseat")
await click(find_button("Start"))
var b = screen()
check(b is BattleScreen and b.is_hotseat() and b.game.players.size() == 4, "Hotseat-Kampf gestartet")
b.ai_delay = 0.0
var guard := 0
while not b.handover.visible and b.game.winner == "" and guard < 400:
if b.game.current() == b.perspective:
await clear_mulligan(b)
if b.game.current() == b.perspective:
await click(b.b_end)
await frames(1)
guard += 1
check(b.handover.visible, "Übergabe erscheint")
var cur: Game.Player = b.game.current()
check(cur.is_human and cur != b.perspective, "anderer Mensch ist dran")
await shot("18_hotseat_uebergabe")
await click(find_button("Weiter"))
check(b.perspective == cur and not b.handover.visible, "Perspektive gewechselt")
check(b.hand_views.size() == cur.hand.size() and b.name_label.text == cur.name, "Hand und Name des neuen Spielers")
check(b.mulligan_box.visible, "neuer Spieler bekommt seine Mulligan-Maske")
await clear_mulligan(b)
check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch")
await shot("19_hotseat_kampf")
await give_up()
func test_sprache() -> void:
await click(find_button("Einstellungen"))
var scr = screen()
scr.language.select(1)
scr.language.item_selected.emit(1)
await frames(2)
check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch")
check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen")
check(find_button("Back") != null, "Knöpfe übersetzt")
await shot("26_englisch")
await click(find_button("Back"))
check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch")
await click(find_button("Settings"))
var scr2 = screen()
scr2.language.select(0)
scr2.language.item_selected.emit(0)
await frames(2)
check(find_button("Zurück") != null, "zurück auf Deutsch")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_anleitung() -> void:
check(not main.profile.tutorial_done, "Anleitung noch offen")
await click(find_button("Anleitung"))
var scr = screen()
check(scr is BattleScreen and scr.tutorial, "Anleitung gestartet")
scr.ai_delay = 0.0
var g: Game = scr.game
check(g.players[0] == g.human and g.opponents[0].name == "Fenn" and g.opponents[0].perks.is_empty(), "Fenn ohne Merkmal, Mensch beginnt")
await frames(2)
check(scr.mulligan_box.visible, "Mulligan-Maske im ersten Zug")
var views = scr.mulligan_layer.get_children()
await click(views[0])
check(scr.mulligan_selected == [0], "Karte zum Tausch gewählt")
await shot("22_anleitung_mulligan")
await click(scr.b_swap)
check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht")
check(g.log.any(func(l): return "tauscht 1 Karten" in l), "Tausch im Verlauf")
check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar")
var idx := -1
for i in g.human.hand.size():
if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1:
idx = i
break
if idx >= 0:
await key(KEY_1 + idx)
check(g.human.board.size() == 1, "Wesen per Taste gespielt")
check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen")
await shot("22_anleitung")
await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
await wait_for_human(scr)
check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug")
var step_before: int = scr.tutorial_step
await click(scr.b_tut_next)
check(scr.tutorial_step == step_before + 1, "Weiter-Knopf")
await key(KEY_ESCAPE)
check(scr.confirm.visible, "Esc öffnet Aufgeben")
await key(KEY_ESCAPE)
check(not scr.confirm.visible, "Esc schließt den Dialog")
await give_up()
check(main.profile.tutorial_done, "Anleitung als erledigt gespeichert")
func test_netzwerk() -> void:
await click(find_button("Netzwerkspiel"))
var scr = screen()
check(scr is LobbyScreen, "Lobby geöffnet")
await click(find_button("Spiel eröffnen"))
check(main.net.is_host and main.net.connected, "Spiel eröffnet")
var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"]
var pid := OS.create_process(OS.get_executable_path(), args)
check(pid > 0, "Bot-Prozess gestartet")
var wait := 0
while main.net.names.size() < 2 and wait < 900:
await frames(1)
wait += 1
check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path())
await shot("23_lobby")
await click(find_button("Start"))
var b = screen()
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
b.ai_delay = 0.0
var rounds := 0
while b.game != null and b.game.winner == "" and rounds < 4:
if b.game.current() == b.perspective:
await clear_mulligan(b)
if b.game.current() == b.perspective:
await click(b.b_end)
var w2 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w2 < 900:
await frames(1)
w2 += 1
rounds += 1
check(b.game.turn >= 3 and b.game.winner == "", "mehrere Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
check(b.game.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt")
await shot("24_netzwerk_kampf")
OS.kill(pid)
var w3 := 0
while b.game.winner == "" and w3 < 1500:
await frames(1)
w3 += 1
if b.game.winner != "":
check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe")
await frames(2)
check(b.overlay.visible, "Spielende angezeigt")
await click(find_button("Zurück zum Menü"))
else:
print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen")
await give_up()
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
func test_kampagne() -> void:
await click(find_button("Kampagne"))
var scr = screen()
check(scr is CampaignScreen, "Kampagne geöffnet")
await shot("15_kampagne")
check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen")
await click(find_button("Kämpfen"))
var battle = screen()
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner")
await shot("16_kampagne_kampf")
await give_up()
func test_kampf(n_opp: int, shot_turn: int) -> void:
await start_battle(n_opp)
var scr = screen()
check(scr is BattleScreen, "Kampfbildschirm")
scr.ai_delay = 0.0
var g: Game = scr.game
check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost")
await wait_for_human(scr)
check(g.current() == g.human and g.human.mulligan_done, "Spieler ist dran, Mulligan erledigt")
check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar")
await shot("07_kampf_start")
var coins: int = main.profile.coins
var guard := 0
while g.winner == "" and guard < 60:
guard += 1
var turn_before := g.turn
await human_turn(scr)
if g.winner != "":
break
check(g.current() != g.human or g.turn > turn_before, "Zug beendet")
var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1)
wait += 1
if guard == shot_turn:
await shot("08_kampf_zug%d" % (shot_turn + 1))
check(g.winner != "", "Spiel beendet")
await frames(3)
await shot("09_kampf_ende")
check(scr.reward > 0 and main.profile.coins >= coins + scr.reward, "Belohnung gutgeschrieben")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Belohnung gespeichert")
await click(find_button("Zurück zum Menü"))
check(screen() is MenuScreen, "zurück im Menü")
func test_kampf_abbrechen() -> void:
await start_battle(5)
var scr = screen()
scr.ai_delay = 0.0
var g: Game = scr.game
await wait_for_human(scr)
check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner")
# Falle legen und Kombo-Hinweis prüfen
g.human.hand.append(Cards.by_id("f_falle"))
g.human.energy = 10
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
g.human.board.append(wolf)
g.human.hand.append(Cards.by_id("w_reiter"))
scr.refresh()
var trap_view = scr.hand_views.filter(func(v): return v.card.id == "f_falle")[0]
await click(trap_view)
check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar")
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
await hover(reiter_view)
var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel)
check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau")
await shot("21_kampf_falle_hinweis")
var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
if not spells.is_empty():
var card: Cards.Card = spells[0].card
await click(spells[0])
check(scr.pending == card, "Ziel wird erwartet")
await right_click()
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab")
await hover(scr.hand_views[0])
check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
await click(find_button("Lupe"))
check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte")
await shot("12_lupe")
await click(find_button("Schließen"))
check(scr.zoom_player == null, "Lupe geschlossen")
# Runden laufen lassen, damit die Gegner ihre Züge machen
for round in 4:
await click(scr.b_end)
var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1)
wait += 1
var ai_attacked: bool = g.log.any(func(l): return l.begins_with("Gegner") and " spielt " in l) \
and g.log.any(func(l): return " greift " in l)
check(ai_attacked, "Gegner spielen Karten und greifen an")
check(scr.tiles.size() == g.players.reduce(func(acc, p): return acc + p.board.size(), 0), "eine Kachel je Wesen")
await shot("10_kampf_fuenf")

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

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

6
tests/ui_test.tscn Normal file
View File

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

59
translations/ui.csv Normal file
View File

@@ -0,0 +1,59 @@
keys,en
Dinger,Dinger
Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all
Einzelspieler,Single player
Kampagne,Campaign
Lokal zu mehreren,Local multiplayer
Netzwerkspiel,Network game
Booster öffnen,Open boosters
Deck bauen,Build deck
Werkstatt,Workshop
Erfolge,Achievements
Regeln,Rules
Einstellungen,Settings
Anleitung,Tutorial
Beenden,Quit
Zurück,Back
Weiter,Continue
Start,Start
Speichern,Save
Leeren,Clear
Suche,Search
Fehlende,Missing
Alle,All
Aktiv setzen,Set active
Auto-Ausfüllen,Auto-fill
Umbenennen,Rename
Zug beenden,End turn
Aufgeben,Surrender
Weiterspielen,Keep playing
"Ja, aufgeben","Yes, surrender"
Wirklich aufgeben?,Really surrender?
Der Kampf endet ohne Münzen.,The battle ends without coins.
Zurück zum Menü,Back to menu
Lupe,Zoom
Schließen,Close
Held als Ziel,Target hero
Verlauf,Log
Karte überfahren für Details,Hover a card for details
Lautstärke,Volume
Musik,Music
KI-Tempo,AI speed
Animationen,Animations
Sprache,Language
Booster,Boosters
Booster kaufen (100 Münzen),Buy booster (100 coins)
Alle aufdecken,Reveal all
Zerlegen,Dismantle
Herstellen,Craft
Kämpfen,Fight
Wiederholen,Replay
Gesperrt,Locked
Spiel eröffnen,Host game
Beitreten,Join
Behalten,Keep
Tauschen,Swap
Hand tauschen?,Swap cards?
Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
Fallen,Traps
Du bist dran,Your turn
1 keys en
2 Dinger Dinger
3 Ein Kartenspiel: jeder gegen jeden A card game: free-for-all
4 Einzelspieler Single player
5 Kampagne Campaign
6 Lokal zu mehreren Local multiplayer
7 Netzwerkspiel Network game
8 Booster öffnen Open boosters
9 Deck bauen Build deck
10 Werkstatt Workshop
11 Erfolge Achievements
12 Regeln Rules
13 Einstellungen Settings
14 Anleitung Tutorial
15 Beenden Quit
16 Zurück Back
17 Weiter Continue
18 Start Start
19 Speichern Save
20 Leeren Clear
21 Suche Search
22 Fehlende Missing
23 Alle All
24 Aktiv setzen Set active
25 Auto-Ausfüllen Auto-fill
26 Umbenennen Rename
27 Zug beenden End turn
28 Aufgeben Surrender
29 Weiterspielen Keep playing
30 Ja, aufgeben Yes, surrender
31 Wirklich aufgeben? Really surrender?
32 Der Kampf endet ohne Münzen. The battle ends without coins.
33 Zurück zum Menü Back to menu
34 Lupe Zoom
35 Schließen Close
36 Held als Ziel Target hero
37 Verlauf Log
38 Karte überfahren für Details Hover a card for details
39 Lautstärke Volume
40 Musik Music
41 KI-Tempo AI speed
42 Animationen Animations
43 Sprache Language
44 Booster Boosters
45 Booster kaufen (100 Münzen) Buy booster (100 coins)
46 Alle aufdecken Reveal all
47 Zerlegen Dismantle
48 Herstellen Craft
49 Kämpfen Fight
50 Wiederholen Replay
51 Gesperrt Locked
52 Spiel eröffnen Host game
53 Beitreten Join
54 Behalten Keep
55 Tauschen Swap
56 Hand tauschen? Swap cards?
57 Klicke Karten an die du zurücklegen willst. Einmal pro Partie. Click the cards you want to put back. Once per game.
58 Fallen Traps
59 Du bist dran Your turn

View File

@@ -0,0 +1,19 @@
[remap]
importer="csv_translation"
type="Translation"
uid="uid://bcfsui5t8peik"
[deps]
files=["res://translations/ui.en.translation"]
source_file="res://translations/ui.csv"
dest_files=["res://translations/ui.en.translation"]
[params]
compress=1
delimiter=0
unescape_keys=false
unescape_translations=true

Binary file not shown.