update
Some checks failed
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled

This commit is contained in:
team3
2026-10-10 14:30:29 +02:00
parent c245c7af87
commit a11cee2e0a
37 changed files with 2190 additions and 2414 deletions

View File

@@ -20,23 +20,22 @@ Der Spielstand liegt unter `user://spielstand.json`
## Spielsysteme
- 81 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos sind die Namen verschmolzener Wesen
- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet; je Element gibt es ein 0/2-Wesen für 0 Mana
- 120 Karten in `data/karten.json`: je Element alle Werte von 0/1 bis 5/5, die ID verrät sie (`f_3_2` = Feuer 3/2), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen
- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts)
- Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen und Verschmelzen
- Kein Deckbau: beide ziehen vom gemeinsamen Stapel (jede Karte dreimal). Zu Beginn fünf Karten, alle im ersten Zug spielbar. Auf dem Stapel liegen drei Karten offen, nie teurer als das Mana des Zugs; beim Ziehen wählt man eine, eine neue rückt nach. Das erste Ziehen im Zug ist frei, jedes weitere kostet 2 Mana; eine Handkarte abwerfen bringt 1 Mana (Rechtsklick)
- Jeder spielt mit seinem eigenen Stapel aus 40 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, „Auffüllen“ nimmt die teuersten des gewählten Filters). Zu Beginn fünf im ersten Zug spielbare Karten. Ziehen bringt eine Karte, die erste im Zug ist frei, dann 1, 2, 3 … Mana; ist der Stapel leer, kostet jeder Zug 1, 2, 3 … Leben (Erschöpfung); eine Handkarte abwerfen bringt 1 Mana (Rechtsklick)
- Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst
- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar und bekommt den Elementbonus erst offen; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf
- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Yu-Gi-Oh: Common matt, Rare silberner Name und Rahmen mit Glanzstreifen, Super Rare Holo-Bild, Ultra Rare goldener Name und Rahmen mit Holo-Bild, Ultimate Rare Relief, Holo auf Bild und Rahmen, Funkeln und schillernde Farben; auch auf dem Feld für alle sichtbar, Werte bleiben gleich. Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern
- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (50/25/15/8/2 %, mindestens eine Rare), höhere werten die Karte auf, der Rest wird zu Staub (50/100/200/500/1000). Aufwerten kostet 100/200/500/1000 Staub, eine Stufe abgeben bringt den Zerlegewert zurück
- Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je 10 Mana an ausgespielten Karten eines Elements geben den eigenen Wesen dieses Elements +1 Angriff (verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni. Zwei Leisten in der Mitte, oben die des Gegners, unten die eigene, zeigen je Element zwei Symbole, gefüllt je offenem eigenen Wesen, und das Mana der ausgespielten Karten
- Beim ersten Start wählt man eines von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen), der Rest kommt aus Boostern oder der Werkstatt. Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare) und ist je Stufe +1/+1 stärker, bei gleichen Kosten; die Stufe zeigt sich auch in der Optik (Glanz, Holo, Relief, Funkeln). Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern
- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (45/25/15/10/5 %, mindestens eine Rare); neue kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte Version (die wird zu Staub), der Rest wird zu Staub (50/100/200/500/1000). Werkstatt in der Sammlung: Aufwerten kostet 100/200/500/1000 Staub, Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je fünf ausgespielte Karten eines Elements geben den eigenen Wesen dieses Elements +1/+1 (verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni. Die Zähler stehen links neben dem jeweiligen Brett und zeigen nur Elemente, bei denen etwas passiert ist: zwei Symbole, gefüllt je offenem eigenen Wesen, und die Zahl der ausgespielten Karten
- Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter, gegen Helden nichts; so greift und kontert auch ein Wesen mit 0 Angriff, nur nicht ohne Vorteil; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn (grün mehr, rot weniger); über einem Ziel zeigt der Angreifer seinen Angriff mit Vorteil, und wer stirbt, trägt die Marke „stirbt“
- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch
- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde halten, auf dem Brett wie auf der Hand (Gamepad: Y, „Verschmelzen mit …“). Das neue Wesen hat je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten passend dazu, Elemente und Eigenschaften beider; kostet 1 Mana, verdoppelt je weiterer Verschmelzung (1, 2, 4, 8); drei oder mehr Elemente ergeben eine Chimäre
- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch. Den Zug beendet die Leertaste oder der Rechtsklick ins Leere; Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden)
- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde halten, auf dem Brett wie auf der Hand (Gamepad: Y, „Verschmelzen mit …“). Das neue Wesen hat je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten aus den neuen Werten, Elemente und Eigenschaften beider; es kostet die halbe Manasumme beider Karten (aufgerundet); drei oder mehr Elemente ergeben eine Chimäre
- Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; die Hand fasst 5 Karten, das Feld 5 Wesen
- Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
- KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
- Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen, eigenes Merkmal nach Kapitel 1
- Netzwerkspiel im lokalen Netz: Host führt das Spiel, Mitspieler sehen nur ihre Hand, Zuschauer keine. Fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung, die anderen verbinden neu (Platz bleibt 60 s frei)
- Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen und eigenen Stapeln (Kapitel 1 bis Kosten 3, Kapitel 2 bis 5 als Rare, Kapitel 3 alles als Super Rare), eigenes Merkmal nach Kapitel 1
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
- Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
- Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus
@@ -53,7 +52,6 @@ godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spiel
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse)
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
```
@@ -71,13 +69,11 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
- `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen
- `scripts/deckgen.gd` – gemeinsamer Stapel und Gegner
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
- `scripts/deckgen.gd` – Stapel: zufällige für Gegner, Standardstapel aus der Sammlung
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, Einstellungen
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Stapel, Booster, Werkstatt, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen
- `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `element_view.gd` den Elementzähler, `battle_layout.gd` die Aufteilung des Kampfes
- `translations/ui.csv` – englische Oberflächentexte
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs

View File

@@ -5,18 +5,17 @@
## Grundidee
- Kartenspiel wie zum Beispiel Magic
- PC-Spiel
- Duell: Spieler gegen einen KI-Gegner (oder einen Mitspieler im Netz), beide mit gleichen Regeln
- Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit
- Kampagne gegen benannte Magier als Fortschritt
- Im lokalen Netz zu zweit spielbar, weitere als Zuschauer
## Sammeln
- Jede Karte gehört jedem von Anfang an: wer zum ersten Mal spielt, kann so gut sein wie wer tausend Partien hat
- Gesammelt wird die Seltenheitsstufe einer Karte (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare), und die ist reine Optik wie bei Yu-Gi-Oh: silberner oder goldener Name, Holo-Bild, Relief, Funkeln, Schillern; auch im Netzwerkspiel sichtbar; Fusionen tragen die höhere Stufe der Eltern
- Booster öffnen (Chancen 50/25/15/8/2 %, mindestens eine Rare): höhere Stufen werten Karten auf, der Rest wird zu Staub (50/100/200/500/1000)
- Staub wertet gezielt auf (100/200/500/1000), eine Stufe abgeben bringt den Zerlegewert zurück
- Kein Deckbau: alle ziehen vom gemeinsamen Stapel (jede Karte dreimal); zu Beginn fünf Karten, alle im ersten Zug spielbar; auf dem Stapel liegen drei Karten offen, nie teurer als das Mana des Zugs, beim Ziehen wählt man eine, eine neue rückt nach; das erste Ziehen im Zug ist frei, jedes weitere kostet 2 Mana
- Mana für Ziehen, Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet; 0/2 kostet 0), und Verschmelzen, Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten
- Man startet mit einem von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen); alle Karten sammeln ist das lange Ziel
- Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare); je Stufe +1/+1 bei gleichen Kosten, dazu Optik (Glanz, Holo, Relief, Funkeln); Fusionen tragen die höhere Stufe der Eltern
- Booster öffnen (Chancen 45/25/15/10/5 %, mindestens eine Rare): neue Karten kommen in Sammlung und Stapel, von jeder Karte zählt die seltenste Version, die alte wird zu Staub, der Rest wird zu Staub (50/100/200/500/1000)
- Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Jeder hat sein Deck: 40 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter); zu Beginn fünf im ersten Zug spielbare Karten daraus; Ziehen bringt eine Karte, die erste im Zug frei, dann 1, 2, 3 … Mana; leerer Stapel: jeder Zug kostet 1, 2, 3 … Leben
- Mana für Ziehen, Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet, höchstens 10; 0/2 kostet 0) und Verschmelzen (halbe Manasumme beider Karten), Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten
- Karten haben nur Angriff und Leben; Eigenschaften kommen allein über Elementpaare: Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug)
- Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst
- Wesen lassen sich verdeckt legen (mittlere Maustaste oder Kartenmenü); aufdecken ist frei, frühestens im nächsten Zug, dann greift es sofort an; ein Angriff deckt auf
@@ -24,10 +23,10 @@
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
## Elemente
- Jede Karte gehört zu genau einem Element
- Jede Karte gehört zu genau einem Element; je Element gibt es alle Werte von 0/1 bis 5/5 (120 Karten)
- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
- Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag)
- Je 10 Mana an ausgespielten Karten eines Elements: +1 Angriff für die eigenen Wesen des Elements, jeder Spieler zählt für sich; verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni
- Je fünf ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements, jeder Spieler zählt für sich; verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht)
- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde stillhalten, auf dem Brett oder auf der Hand (Gamepad: über das Kartenmenü); das neue erbt Elemente und Eigenschaften beider, je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten folgen den Werten; Fusionskosten 1 Mana, verdoppelt je weiterer Verschmelzung
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw
- **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff
und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt
kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Kästen und Stapel, rechts oben Knöpfe, Wesen
kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Elementzähler und Meldungen, rechts Stapel und Kästen beider Seiten, Wesen
als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt.
- **Teamfight Tactics** – Synergien durch gleiche Elemente: zwei eigene geben den Effekt des
Elements, drei auf dem Tisch den Wertebonus; die Elementleiste zeigt den Stand.
@@ -41,14 +41,14 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
- **Hand** als bis zu fünf Karten in Feldgröße unten, in einem leichten Bogen, die des Gegners
gleich groß als Rückseiten oben; der
Stapel links auf halber Höhe als drei offene Karten übereinander (Klick zieht die Karte, das erste
Ziehen im Zug frei), darüber der Gegnerkasten, darunter der eigene.
eigener Stapel rechts unten als Kartenstoß mit den Kosten des nächsten Ziehens (das erste im Zug
frei), darüber der eigene Kasten; der Stapel des Gegners rechts oben, sein Kasten darunter.
- **Verschmelzen** per Ziehen: eine Hand- oder Brettkarte auf eine passende ziehen, ein
goldener Ring füllt sich eine Sekunde lang, dann verschmelzen die beiden. Mit Gamepad über
das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner).
- **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana,
Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist.
- **Elementleiste** je Spieler, die des Gegners darüber.
- **Elementzähler** je Spieler als Liste links neben seinem Brett, nur Elemente mit Wesen oder gespielten Karten.
Je Element zwei Symbole in seiner Form, gefüllt je offenem eigenen Wesen (beide voll: Sondereffekt),
daneben das Mana der ausgespielten Karten; je 10 gibt es +1 Angriff.
- **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick
@@ -59,7 +59,8 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
macht Ziele, Texte und Balance eindeutig.
- **Vorschau** steht fest rechts auf halber Höhe, darunter die Zusatzzeile, mit Resonanz, Feld
und Feldkombo darunter.
- **Verlauf**, **Aufgeben** und **Zug beenden** als Knöpfe oben rechts; der Verlauf öffnet als Überlagerung.
- **Optionen** per Esc als Überlagerung: Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden; der Verlauf
öffnet darüber. Zug beenden per Leertaste oder Rechtsklick ins Leere, oben rechts nur Rückgängig.
- Handkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen
steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht.
@@ -75,14 +76,47 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
## Menüs
- Hauptmenü: Titel und drei Spielarten links, darunter Booster, Sammlung, Einstellungen,
- Hauptmenü: Titel, Einzelspieler, Kampagne und Deck links, darunter Booster, Sammlung, Einstellungen,
Beenden; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und
Staub oben rechts.
- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter.
- Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte
Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale
stärker, nicht über Begleiter.
- Sammlung: sechs Spalten, aufgewertete Karten zuerst, rechts die große Vorschau.
- Sammlung: sechs Spalten, eigene Karten zuerst (höchste Stufen vorn), fehlende abgedunkelt, rechts
die große Vorschau mit Aufwerten, Zerlegen oder Herstellen. Deck: gleiches Raster, nur eigene Karten,
Stapelkarten hell, die anderen abgedunkelt; Klick wechselt.
## Grafik
Ziel
- Wie ein normales Godot/Unity-Spiel, nicht primitiv
- Keine Kunstwerke
Stil
- Dunkler Tisch, Elemente leuchten
- Rahmen wie Hearthstone, Glanz wie Pokémon TCG
- Eine Palette, ein Font, eine Formensprache
- Palette aus style.gd erweitern, nicht ersetzen
- Font DejaVu Sans
Regeln
- Zahlen groß, Kontrast hoch
- Zustände sichtbar: Hover, Pressed, Disabled, Fokus
- Icons als SVG, ein Strichstil
Verbote
- Flache graue Panels
- Lila Verläufe
- Effekte ohne Zweck
Entschieden
- Richtung Stein: warm, Holz und Bronze, Bevel-Rahmen, Vignette
- Werte und Kristall bleiben im Kartenrahmen
- Rundungen und Kanten glatt, keine Treppchen
Offen
- Radius, Ränder, Schatten im Spiel nachziehen
## Offen

View File

@@ -201,7 +201,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
best = firsts[i].action
if best.type == "end":
if game.can_draw(p)[0]:
return {"type": "draw", "index": pick_index(game, p)} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
return best
return map_action(game, view, best)
@@ -258,24 +258,10 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if t != null:
return {"type": "attack", "creature": cr, "target": t}
if game.can_draw(p)[0]:
return {"type": "draw", "index": pick_index(game, p)}
return {"type": "draw"}
return {"type": "end"}
## Welche der offenen Karten: die wertvollste, die nach dem Ziehen noch bezahlbar wäre, sonst die billigste.
static func pick_index(game: Game, p: Game.Player) -> int:
var left: int = p.energy - game.draw_cost(p)
var best := 0
var best_score := -INF
for i in p.offer.size():
var c: Cards.Card = p.offer[i]
var score := float(c.attack + c.health) if c.cost <= left else -10.0 - c.cost
if score > best_score:
best_score = score
best = i
return best
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
## der Held nur, wenn kein Wesen mehr steht.
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
@@ -302,7 +288,7 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void
"play":
game.play_card(p, action.card, bool(action.get("face_down", false)))
"draw":
game.draw(p, int(action.get("index", 0)))
game.draw(p)
"reveal":
game.reveal(p, action.creature)
"attack":

View File

@@ -64,9 +64,16 @@ static func stage(i: int) -> Dictionary:
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## Boss einer Stufe mit dem Leben und den Merkmalen der Stufe.
static func opponent(stage_index: int) -> Game.Player:
## Boss einer Stufe mit dem Leben, den Merkmalen und einem Stapel der Stufe; ab Kapitel 2 sind
## seine Karten eine Seltenheitsstufe höher (+1/+1), ab Kapitel 3 zwei.
static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player:
var st := stage(stage_index)
var boss := Game.Player.new(st.name, false, int(st.life), st.title)
boss.perks = Array(st.perks)
if r == null:
r = Game.make_rng()
var ch := chapter(stage_index)
boss.deck = DeckGen.opponent_deck(r, ch)
for id in boss.deck:
boss.looks[id] = ch - 1
return boss

View File

@@ -93,9 +93,9 @@ static func best_element(own: Array, other: Array) -> String:
return best
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet (0/2 kostet nichts).
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts).
static func cost_of(attack: int, health: int) -> int:
return (2 * attack + health) / 3
return mini(10, (2 * attack + health) / 3)
## Wert eines verschmolzenen Wesens: der bessere Wert plus ein Drittel des schwächeren (mindestens 1),
@@ -132,8 +132,7 @@ static func fused(a: Card, b: Card, attack: int = -1, health: int = -1) -> Card:
for k in KEYWORDS:
if k in a.keywords or k in b.keywords:
c.keywords.append(k)
c.fusions = maxi(a.fusions, b.fusions) + 1
c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt; Game.fuse rechnet die Sammlung ein
c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt
return c
@@ -157,19 +156,18 @@ class Card extends RefCounted:
var elements: Array
var attack: int = 0
var health: int = 0
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten)
var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit)
var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …)
var look := -1 # Seltenheitsstufe verschmolzener Karten (die höhere der Eltern); -1: aus der Sammlung des Besitzers
var look := -1 # Seltenheitsstufe des Exemplars (gibt +1/+1 je Stufe); -1: Grundkarte aus dem Pool
func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
if not keywords.is_empty():
d["keywords"] = keywords
if fusions > 0:
d["fusions"] = fusions
if look >= 0:
d["look"] = look
if cost != Cards.cost_of(attack, health):
d["cost"] = cost
return d
## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks).
@@ -184,9 +182,8 @@ class Card extends RefCounted:
c.elements = Array(d.elements)
c.attack = int(d.get("attack", 0))
c.health = int(d.get("health", 0))
c.cost = Cards.cost_of(c.attack, c.health)
c.cost = int(d.get("cost", Cards.cost_of(c.attack, c.health)))
c.keywords = Array(d.get("keywords", []))
c.fusions = int(d.get("fusions", 0))
c.look = int(d.get("look", -1))
return c
@@ -194,6 +191,15 @@ class Card extends RefCounted:
func copy() -> Card:
return Card.from_dict(to_dict())
## Exemplar in einer Seltenheitsstufe: +1/+1 je Stufe, die Kosten bleiben die der Grundkarte.
func leveled(level: int) -> Card:
var c := copy()
c.look = level
c.attack += level
c.health += level
c.cost = cost
return c
## Anzeigename in der aktuellen Sprache.
func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
@@ -206,10 +212,6 @@ class Card extends RefCounted:
func combo() -> String:
return Cards.combo_name(elements)
## Mana für die nächste Verschmelzung mit dieser Karte.
func fusion_cost() -> int:
return 1 << fusions
## Kartentext: die festen Eigenschaften.
func text() -> String:
if keywords.is_empty():

View File

@@ -1,16 +1,54 @@
class_name DeckGen
## Der gemeinsame Stapel (jede Karte dreimal) und Gegner.
## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner.
const COPIES := 3
const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
## Frisch gemischter Stapel; das Ende des Arrays ist oben.
static func deck(r: RandomNumberGenerator) -> Array:
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
var cheap := Cards.all().filter(func(c): return c.cost <= Game.START_MANA)
var rest := Cards.all().filter(func(c): return c.cost > Game.START_MANA and c.cost <= max_cost)
Game.shuffle(cheap, r)
Game.shuffle(rest, r)
var out := []
for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM):
if out.size() >= Game.DECK_MIN:
break
out.append(c.id)
return out
## Stapel eines Kampagnengegners: Kapitel 1 bis Kosten 3, Kapitel 2 bis 5, Kapitel 3 alles.
static func opponent_deck(r: RandomNumberGenerator, chapter: int) -> Array:
return random_deck(r, [3, 5, 99][clampi(chapter - 1, 0, 2)])
## Startdeck für ein Element: die 25 Karten des Elements bis Angriff 4, dazu je fünf billige der
## anderen Elemente (DECK_MIN Karten).
static func start_deck(element: String) -> Array:
var out := []
for c in Cards.all():
for i in COPIES:
out.append(c)
Game.shuffle(out, r)
if c.elements[0] == element and c.attack <= 4:
out.append(c.id)
for e in Cards.ELEMENTS:
if e == element:
continue
var cheap := Cards.all().filter(func(c): return c.elements[0] == e and c.cost <= Game.START_MANA)
cheap.sort_custom(func(a, b): return a.cost < b.cost if a.cost != b.cost else a.attack > b.attack)
for c in cheap.slice(0, 5):
out.append(c.id)
return out
## Füllt deck aus pool (Karten-IDs) bis limit auf, die teuersten zuerst; nichts doppelt.
static func fill(deck: Array, pool: Array, limit: int) -> Array:
var out := deck.duplicate()
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id))
cards.sort_custom(func(a, b): return a.cost > b.cost if a.cost != b.cost else a.id < b.id)
for c in cards:
if out.size() >= limit:
break
out.append(c.id)
return out

View File

@@ -7,11 +7,11 @@ const MAX_BOARD := 5
const MAX_HAND := 5
const START_HAND := 5 # Startkarten, alle im ersten Zug bezahlbar
const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt
const DRAW_COST := 2 # Ziehen: eine bezahlbare Karte vom Stapel
const DECK_MIN := 40 # Karten je Stapel: mindestens so viele (Gegner und Startdecks genau so viele)
const DECK_MAX := 60 # und höchstens so viele
const START_LIFE := 30
const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements
const ELEMENT_MANA := 10 # je so viel Mana an ausgespielten Karten eines Elements: +1 Angriff für die eigenen Wesen des Elements
const OFFER := 3 # offene Karten auf dem Stapel, höchstens so teuer wie das Mana des Zugs; beim Ziehen wählt man eine, eine neue rückt nach
const ELEMENT_COUNT := 5 # je so viele ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements
const DISCARD_GAIN := 1 # Abwerfen einer Handkarte bringt so viel Mana
# Merkmale der Kampagnen-Bosse (Player.perks).
@@ -129,17 +129,18 @@ class Player extends RefCounted:
var is_human: bool
var life: int
var max_life: int
var hand: Array = [] # eigene Exemplare (Cards.Card.copy)
var hand: Array = [] # eigene Exemplare (Cards.Card, in der eigenen Seltenheitsstufe)
var board: Array = []
var deck: Array = [] # eigener Stapel: Karten-IDs, das Ende ist oben
var energy := 0 # Mana in diesem Zug; der Rest verfällt
var max_energy := 0 # Mana zu Zugbeginn (START_MANA + eigene Züge)
var order := 0 # Platz in der Zugreihenfolge
var perks: Array = [] # Boss-Merkmale (siehe PERKS)
var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile)
var turns := 0 # begonnene eigene Züge
var played := {} # Element -> Mana der bisher ausgespielten Karten (je ELEMENT_MANA: +1 Angriff)
var offer: Array = [] # die OFFER offenen Karten auf dem Stapel (nur der Besitzer sieht sie)
var drew := false # in diesem Zug schon gezogen? Das erste Ziehen ist frei
var played := {} # Element -> bisher ausgespielte Karten (je ELEMENT_COUNT: +1/+1)
var draws := 0 # in diesem Zug gezogene Karten; die n-te kostet n-1 Mana
var fatigue := 0 # Züge ohne Karten im Stapel: so viel Leben kostet der nächste
func _init(n: String, h: bool, l: int, t: String = "") -> void:
name = n
@@ -153,6 +154,25 @@ class Player extends RefCounted:
func alive() -> bool:
return life > 0
## Eine Karte vom eigenen Stapel als Exemplar in der eigenen Stufe; null, wenn er leer ist.
## Mit max_cost kommt die oberste bezahlbare, zu teure wandern nach unten (Starthand).
func draw_from_deck(max_cost: int = 99) -> Cards.Card:
var skipped := []
var id := ""
while not deck.is_empty():
var top: String = deck.pop_back()
if Cards.by_id(top).cost <= max_cost:
id = top
break
skipped.append(top)
if id == "" and not skipped.is_empty(): # nichts Bezahlbares: dann eben die oberste
id = skipped.pop_front()
for sk in skipped:
deck.insert(0, sk)
if id == "":
return null
return Cards.by_id(id).leveled(int(looks.get(id, 0)))
## Offene eigene Wesen mit diesem Element.
func element_count(e: String) -> int:
var n := 0
@@ -172,20 +192,19 @@ class Player extends RefCounted:
p.looks = looks
p.turns = turns
p.played = played.duplicate()
p.offer = offer.duplicate()
p.drew = drew
p.deck = deck.duplicate()
p.draws = draws
p.fatigue = fatigue
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 (im Netz zwei)
var human: Player # der menschliche Spieler
var opponent: Player # der andere am Tisch
var players: Array = [] # in Zugreihenfolge
var winner_player: Player = null
var rng: RandomNumberGenerator
var deck: Array = [] # gemeinsamer Stapel, das Ende ist oben (nur Host und Einzelspieler)
var turn := 0
var current_idx := 0
var log: Array[String] = []
@@ -199,7 +218,6 @@ func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void:
human = h
opponent = o
players = [h, o]
humans = players.filter(func(p): return p.is_human)
if r == null:
r = make_rng()
rng = r
@@ -250,7 +268,6 @@ func clone() -> Game:
var idx := players.find(human)
var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new())
g.players = copies # gleiche Zugreihenfolge wie das Original
g.deck = deck.duplicate()
g.turn = turn
g.current_idx = current_idx
g.winner = winner
@@ -278,16 +295,21 @@ func masked_clone(viewer: Player) -> Game:
# --- Zugablauf ---------------------------------------------------------------
## Jeder spielt mit seinem eigenen Stapel (leer: ein zufälliger); die Starthand sind fünf im
## ersten Zug bezahlbare Karten daraus.
func start(fixed_order: bool = false) -> void:
if not fixed_order:
shuffle(players, rng) # zufällige Zugreihenfolge
deck = DeckGen.deck(rng)
for i in players.size():
var p: Player = players[i]
p.order = i
for c in draw_cards(START_HAND, START_MANA):
p.hand.append(c.copy())
p.offer = draw_cards(OFFER, START_MANA)
if p.deck.is_empty():
p.deck = DeckGen.random_deck(rng)
shuffle(p.deck, rng)
for k in START_HAND:
var c := p.draw_from_deck(START_MANA)
if c != null:
p.hand.append(c)
turn = 1
current_idx = 0
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
@@ -297,54 +319,31 @@ func start(fixed_order: bool = false) -> void:
begin_turn(current())
## Nimmt n Karten vom Stapel, die höchstens max_cost kosten; zu teure wandern unter den Stapel.
## Ist der Stapel leer, kommt ein frischer.
func draw_cards(n: int, max_cost: int = 999) -> Array:
var out := []
var skipped := []
var looked := 0
while out.size() < n:
if deck.is_empty():
deck = DeckGen.deck(rng)
say("Der Stapel wird neu gemischt.")
var c: Cards.Card = deck.pop_back()
looked += 1
if c.cost <= max_cost or looked > deck.size() + skipped.size() + n:
out.append(c)
else:
skipped.append(c)
for c in skipped:
deck.insert(0, c)
return out
## Ziehen: eine der offenen Karten kommt auf die Hand, eine neue rückt nach. Das erste Ziehen
## im Zug ist frei, jedes weitere kostet DRAW_COST.
## Ziehen: eine Karte vom eigenen Stapel. Die erste im Zug ist frei, jede weitere kostet eins
## mehr (0, 1, 2 …).
func can_draw(p: Player) -> Array:
if winner != "" or p != current():
return [false, "Nicht dein Zug"]
if p.hand.size() >= MAX_HAND:
return [false, "Deine Hand ist voll"]
if p.deck.is_empty():
return [false, "Dein Stapel ist leer"]
if p.energy < draw_cost(p):
return [false, "Nicht genug Mana"]
return [true, ""]
func draw_cost(p: Player) -> int:
return 0 if not p.drew or "ziehen_gratis" in p.perks else DRAW_COST
return maxi(0, p.draws - (1 if "ziehen_gratis" in p.perks else 0))
func draw(p: Player, index: int = 0) -> String:
func draw(p: Player) -> String:
var ok := can_draw(p)
if not ok[0]:
return ok[1]
if index < 0 or index >= p.offer.size():
return "Keine Karte gewählt"
p.energy -= draw_cost(p)
p.drew = true
var card: Cards.Card = p.offer[index]
p.hand.append(card.copy())
p.offer[index] = draw_cards(1, maxi(p.max_energy, START_MANA))[0] # nie teurer als das Mana dieses Zugs
p.draws += 1
p.hand.append(p.draw_from_deck())
say("%s zieht." % p.name)
emit({"type": "draw", "player": players.find(p)})
return ""
@@ -359,9 +358,15 @@ func begin_turn(p: Player) -> void:
c.ready = true # lag eine Runde: greift jetzt an, Verdecktes darf aufgedeckt werden
c.attacks_done = 0
p.turns += 1
p.drew = false # das erste Ziehen im Zug ist wieder frei
p.draws = 0 # das erste Ziehen im Zug ist wieder frei
say("--- Zug %d: %s ---" % [turn, p.name])
emit({"type": "turn", "player": players.find(p)})
if p.deck.is_empty(): # Erschöpfung: ohne Stapel kostet jeder Zug mehr Leben, damit es ein Ende gibt
p.fatigue += 1
p.life -= p.fatigue
say("%s hat keine Karten mehr und verliert %d Leben." % [p.name, p.fatigue])
emit({"type": "damage", "target": ref_of(p), "amount": p.fatigue})
check_end()
## Zugende: Wesen mit Regeneration heilen ganz, dann ist der andere dran.
@@ -417,7 +422,7 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String:
cr.max_health += 1
p.board.append(cr)
for e in card.elements:
p.played[e] = int(p.played.get(e, 0)) + card.cost
p.played[e] = int(p.played.get(e, 0)) + 1
if face_down:
say("%s legt ein Wesen verdeckt." % p.name)
else:
@@ -492,9 +497,9 @@ static func look_of(p: Player, c: Cards.Card) -> int:
return c.look if c.look >= 0 else int(p.looks.get(c.id, 0))
## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einer der beiden steckt.
## Kosten: die Hälfte der Manasumme beider Karten, aufgerundet.
static func fusion_cost(a, b) -> int:
return 1 << maxi(card_of(a).fusions, card_of(b).fusions)
return ceili((card_of(a).cost + card_of(b).cost) / 2.0)
## a und b: zwei eigene Wesen auf dem Brett oder zwei eigene Handkarten.
@@ -539,11 +544,11 @@ func fuse(p: Player, a, b) -> String:
## das Ergebnis ist nur angriffsbereit, wenn beide es waren.
func _fuse_board(p: Player, a: Creature, b: Creature) -> String:
var card := Cards.fused(a.card, b.card, Cards.fusion_value(a.attack, b.attack),
Cards.fusion_value(a.max_health, b.max_health))
Cards.fusion_value(a.max_health - a.aura, b.max_health - b.aura))
card.look = maxi(look_of(p, a.card), look_of(p, b.card))
var cr := Creature.new(card, p, next_uid)
next_uid += 1
cr.health = mini(card.health, Cards.fusion_value(a.health, b.health))
cr.health = mini(card.health, Cards.fusion_value(mini(a.health, a.max_health - a.aura), mini(b.health, b.max_health - b.aura)))
cr.ready = a.ready and b.ready
cr.attacks_done = maxi(a.attacks_done, b.attacks_done)
var at := p.board.find(a)
@@ -560,11 +565,11 @@ func _fuse_board(p: Player, a: Creature, b: Creature) -> String:
# --- Elementboni ---------------------------------------------------------------
## +1 Angriff je volle ELEMENT_MANA an ausgespielten Karten eines Elements des Spielers; mehrere Elemente zählen alle.
## +1/+1 je volle ELEMENT_COUNT ausgespielte Karten eines Elements des Spielers; mehrere Elemente zählen alle.
static func aura_of(elements: Array, played: Dictionary) -> int:
var a := 0
for e in elements:
a += int(played.get(e, 0)) / ELEMENT_MANA
a += int(played.get(e, 0)) / ELEMENT_COUNT
return a
@@ -579,18 +584,24 @@ func aura_for(c: Creature) -> int:
return a
## Rechnet die Elementboni neu; sie wirken nur auf den Angriff (Creature.power).
## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er
## (beim Verdecken), sinkt nur das Höchstleben, das Leben wird gekappt.
func apply_auras() -> void:
for p in players:
for c in p.board:
c.aura = aura_for(c)
var a := aura_for(c)
if a != c.aura:
var delta: int = a - c.aura
c.max_health += delta
c.health = mini(c.health + maxi(delta, 0), c.max_health)
c.aura = a
## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme (sie zählt selbst mit).
func forecast(p: Player, card: Cards.Card) -> int:
var played := p.played.duplicate()
for e in card.elements:
played[e] = int(played.get(e, 0)) + card.cost
played[e] = int(played.get(e, 0)) + 1
return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1)
@@ -685,20 +696,7 @@ func check_end() -> void:
return
winner_player = alive[0]
winner = "human" if winner_player.is_human else "opponents"
if humans.size() == 1:
say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.")
else:
say("%s gewinnt!" % winner_player.name)
# --- Zustand für das Netzwerk (siehe NetState) ------------------------------------
func snapshot(viewer: int) -> Dictionary:
return NetState.snapshot(self, viewer)
static func from_snapshot(d: Dictionary) -> Game:
return NetState.from_snapshot(d)
say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.")
# --- Gegner und Kampfstart ---------------------------------------------------
@@ -711,7 +709,7 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
arr[j] = tmp
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}}
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}, "deck": [ids]}
static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
if r == null:
r = make_rng()
@@ -724,6 +722,7 @@ static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_
var human := Player.new("Spieler", true, START_LIFE)
human.perks = Array(rules.get("human_perks", []))
human.looks = rules.get("looks", {})
human.deck = rules.get("deck", []).duplicate()
var g := Game.new(human, opp, r)
for p in g.players:
if "leben_35" in p.perks:

View File

@@ -1,443 +0,0 @@
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)
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
signal status_changed
const PORT := 24455
var peer: ENetMultiplayerPeer
var is_host := false
var connected := false
var my_name := "Spieler"
var my_pick := {"looks": {}} # eigene Kartenoptik (Seltenheit), die der Gegner sieht
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
var picks := {} # peer_id -> {"looks": {...}} (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
var rejoin_seconds := 60.0
var spectator := false # eigene Rolle: nur zuschauen
var spectators := {} # Host: peer_id -> Name
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
var i_am_successor := false
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
var reconnecting := false
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():
if not reconnecting:
_fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(_on_server_lost)
func host(port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_server(port, 8) # ein Mitspieler, der Rest Zuschauer
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}
picks = {1: my_pick}
spectators = {}
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 = {}
picks = {}
seats = {}
waiting = {}
my_seat = -1
game = null
status = ""
spectator = false
spectators = {}
successor_ip = ""
i_am_successor = false
backup = {}
backup_names = {}
last_winner = ""
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_pick, spectator)
# --- Host-Wechsel ---------------------------------------------------------------
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
func _successor_id() -> int:
var ids: Array = seats.keys().filter(func(id): return id != 1)
ids.sort()
return ids[0] if not ids.is_empty() else -1
func _update_successor() -> void:
if not is_host or game == null:
return
var succ := _successor_id()
var ip := ""
if succ > 0 and peer != null and peer.get_peer(succ) != null:
ip = peer.get_peer(succ).get_remote_address()
for id in seats:
if id != 1:
set_successor.rpc_id(id, ip, id == succ)
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
func _seat_names() -> Dictionary:
var out := {}
for id in seats:
out[seats[id]] = names.get(id, "")
for pname in waiting:
out[waiting[pname].seat] = pname
return out
@rpc("authority", "reliable")
func set_successor(ip: String, me: bool) -> void:
successor_ip = ip
i_am_successor = me
@rpc("authority", "reliable")
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
backup = snap
backup_names = seat_names
func _on_server_lost() -> void:
if is_host:
return
if last_winner != "":
_fail("Host hat das Spiel beendet")
elif i_am_successor and not backup.is_empty():
_become_host()
elif successor_ip != "":
status = "Host weg, verbinde mit dem Nachfolger …"
status_changed.emit()
_reconnect(successor_ip)
else:
_fail("Host hat das Spiel beendet")
func _become_host() -> void:
var snap := backup
var seat_names := backup_names
var seat := my_seat
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
var err := FAILED
for attempt in 5:
err = host()
if err == OK:
break
await get_tree().create_timer(1.0).timeout
if err != OK:
_fail("Host weg, Übernahme fehlgeschlagen")
return
game = Game.from_snapshot(snap)
seats = {1: seat}
my_seat = seat
waiting = {}
for s in seat_names:
if int(s) != seat:
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
game.say("%s übernimmt als Host." % my_name)
status = "Du bist jetzt Host, die anderen verbinden neu …"
successor_ip = ""
i_am_successor = false
backup = {}
migrated.emit()
func _reconnect(ip: String) -> void:
reconnecting = true
await get_tree().create_timer(2.0).timeout
for attempt in 5:
if join(ip) == OK:
var t := 0.0
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
await get_tree().process_frame
t += get_process_delta_time()
if connected:
reconnecting = false
return
await get_tree().create_timer(1.0).timeout
reconnecting = false
_fail("Nachfolge-Host nicht erreichbar")
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)
picks.erase(id)
spectators.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)
_update_successor()
action_applied.emit()
lobby_update.rpc(names, spectators)
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.cleanup()
game.check_end()
if game.winner == "" and game.current() == p:
game.end_turn() # sonst bliebe das Spiel beim Ausgeschiedenen hängen
action_applied.emit()
elif game.current() == p:
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, pick: Dictionary, as_spectator: bool = false) -> void:
if not is_host:
return
var id := multiplayer.get_remote_sender_id()
if as_spectator:
spectators[id] = name
lobby_update.rpc(names, spectators)
lobby_changed.emit()
if game != null:
start_game.rpc_id(id, -1)
receive_snapshot.rpc_id(id, game.snapshot(-2))
return
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]))
_update_successor()
action_applied.emit()
else:
status = "%s wollte beitreten, das Spiel läuft schon" % name
return
names[id] = name
picks[id] = pick
lobby_update.rpc(names, spectators)
lobby_changed.emit()
@rpc("authority", "reliable")
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
names = n
spectators = sp
lobby_changed.emit()
## Host: Duell mit dem ersten Mitspieler starten; allein gegen die KI.
func start_battle() -> void:
var ids: Array = names.keys()
ids.sort()
ids = ids.slice(0, 2)
var r := Game.make_rng()
var players := []
seats = {}
for id in ids:
var pick: Dictionary = picks.get(id, {})
var p := Game.Player.new(names[id], true, Game.START_LIFE)
for cid in pick.get("looks", {}):
if Cards.has_id(cid):
p.looks[cid] = int(pick.looks[cid])
players.append(p)
if players.size() < 2:
players.append(DeckGen.random_opponent())
game = Game.new(players[0], players[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])
for id in spectators:
start_game.rpc_id(id, -1)
_update_successor()
started.emit(my_seat)
@rpc("authority", "reliable")
func start_game(seat: int) -> void:
my_seat = seat
started.emit(seat)
## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
## der Nachfolger zusätzlich den vollen Spielstand).
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]))
for id in spectators:
receive_snapshot.rpc_id(id, game.snapshot(-2))
var succ := _successor_id()
if succ > 0:
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
@rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void:
last_winner = str(snap.get("winner", ""))
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.winner != "":
return
if game.current() != p:
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], bool(action.get("face_down", false)))
"reveal":
var cr := game.find_creature(int(action.get("uid", -1)))
if cr != null:
game.reveal(p, cr)
"discard":
var hi := int(action.get("hand", -1))
if hi >= 0 and hi < p.hand.size():
game.discard(p, p.hand[hi])
"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)
"draw":
game.draw(p, int(action.get("index", 0)))
"fuse": # zwei Wesen (uid, other) oder zwei Handkarten (hand, other_hand)
var a = game.find_creature(int(action.get("uid", -1)))
var b = game.find_creature(int(action.get("other", -1)))
var i := int(action.get("hand", -1))
var j := int(action.get("other_hand", -1))
if a == null and i >= 0 and j >= 0 and i < p.hand.size() and j < p.hand.size():
a = p.hand[i]
b = p.hand[j]
if a != null and b != null:
game.fuse(p, a, b)
"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}

View File

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

View File

@@ -1,109 +0,0 @@
class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
## Karten reisen als ID; eigene Exemplare mit Schlüsselwörtern und verschmolzene als Ganzes.
static func card_ref(c: Cards.Card):
return c.id if Cards.has_id(c.id) and c.keywords.is_empty() and c.fusions == 0 else c.to_dict()
static func card_of(ref) -> Cards.Card:
return Cards.Card.from_dict(ref) if ref is Dictionary else Cards.by_id(ref).copy()
## Zustand aus Sicht eines Spielers: fremde Hand nur als Anzahl, fremde
## verdeckte Wesen ohne Karte und Werte. viewer -1: alles sichtbar (Host-Wechsel); -2: Zuschauer.
static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := []
for i in g.players.size():
var p: Game.Player = g.players[i]
var mine := i == viewer or viewer == -1
var board := []
for c in p.board:
var masked: bool = c.hidden and not mine
board.append({"uid": c.uid, "card": card_ref(Cards.unknown() if masked else c.card),
"attack": 0 if masked else c.attack, "health": 0 if masked else c.health,
"max_health": 0 if masked else c.max_health, "attacks_done": c.attacks_done,
"ready": c.ready, "hidden": c.hidden, "aura": c.aura})
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,
"order": p.order, "perks": p.perks, "looks": p.looks, "turns": p.turns, "played": p.played,
"hand": p.hand.map(card_ref) if mine else p.hand.size(), "board": board,
"offer": p.offer.map(card_ref) if mine else [], "drew": p.drew})
var evs := []
for ev in g.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = card_ref(e.card)
evs.append(e)
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx,
"winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1,
"next_uid": g.next_uid, "deck_size": g.deck.size(),
"log": g.log.slice(maxi(0, g.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 := Game.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.order = int(pd.order)
p.perks = Array(pd.perks)
for cid in pd.get("looks", {}):
p.looks[cid] = int(pd.looks[cid])
p.turns = int(pd.turns)
for e in pd.get("played", {}):
p.played[e] = int(pd.played[e])
if pd.hand is Array:
for ref in pd.hand:
p.hand.append(card_of(ref))
else:
for i in int(pd.hand):
p.hand.append(placeholder)
for ref in pd.get("offer", []):
p.offer.append(card_of(ref))
p.drew = bool(pd.get("drew", false))
for cd in pd.board:
var card := card_of(cd.card)
var c := Game.Creature.new(card, p, int(cd.uid))
c.attack = int(cd.attack)
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.attacks_done = int(cd.get("attacks_done", 0))
c.ready = bool(cd.get("ready", false))
c.hidden = bool(cd.get("hidden", false))
c.aura = int(cd.get("aura", 0))
p.board.append(c)
list.append(p)
var hi := int(d.human)
var g := Game.new(list[hi], list[1 - hi], 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 i in int(d.get("deck_size", 0)):
g.deck.append(placeholder) # nur die Größe zählt; gezogen wird beim Host
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"] = card_of(e.card)
for key in ["player", "uid", "amount"]:
if e.has(key):
e[key] = int(e[key])
for key in ["target", "attacker"]:
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

View File

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

View File

@@ -1,14 +1,16 @@
class_name Profile
extends RefCounted
## Spielstand: Münzen, Staub, Sammlung, Kampagne, Einstellungen.
## Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die
## ist reine Optik: sie ändert weder Werte noch Text einer Karte.
## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
## Man startet mit einem von vier Startdecks (je ein Element im Mittelpunkt); Booster und die
## Werkstatt bringen den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
const SAVE_VERSION := 6
const SAVE_VERSION := 7
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100
const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"Common": 50.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 8.0, "Ultimate Rare": 2.0}
const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
const START_COINS := 150
const REWARD_WIN := 100
const REWARD_LOSS := 50
@@ -24,19 +26,34 @@ const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultim
var coins := 0
var dust := 0
var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = Common
var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start).
static func starter() -> Profile:
var p := Profile.new()
p.coins = START_COINS
return p
## Hat der Spieler schon ein Startdeck gewählt?
func started() -> bool:
return not collection.is_empty()
## Das Startdeck eines Elements wird Sammlung und Stapel.
func choose_start(element: String) -> void:
collection = {}
deck = DeckGen.start_deck(element)
for id in deck:
collection[id] = 0
## 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"]:
@@ -52,7 +69,7 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile:
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
## Einstellungen, die alte Sammlung (Anzahl je Karte) hat keine Entsprechung mehr.
## Einstellungen und bekommen die Startsammlung.
static func from_dict(d: Dictionary) -> Profile:
var p := Profile.new()
p.coins = int(d.get("coins", 0))
@@ -63,6 +80,11 @@ static func from_dict(d: Dictionary) -> Profile:
for cid in coll:
if Cards.has_id(cid):
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
var ids := []
for cid in d.get("deck", []):
if p.collection.has(cid) and not (cid in ids):
ids.append(cid)
p.deck = ids.slice(0, Game.DECK_MAX)
var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
@@ -77,7 +99,7 @@ static func from_dict(d: Dictionary) -> Profile:
func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck,
"campaign_stage": campaign_stage, "settings": settings, "perk": perk}
@@ -97,7 +119,11 @@ func take_notices() -> Array[String]:
return out
# --- Sammlung: Seltenheit als Aussehen --------------------------------------------
# --- Sammlung und Stapel -----------------------------------------------------------
func owns(cid: String) -> bool:
return collection.has(cid)
func level(cid: String) -> int:
return int(collection.get(cid, 0))
@@ -116,17 +142,29 @@ func looks() -> Dictionary:
return out
## Anzahl Karten über Common.
func upgraded_count() -> int:
var n := 0
for cid in collection:
if int(collection[cid]) > 0:
n += 1
return n
## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
## falls er zu klein ist (Karten können durch Zerlegen wegfallen).
func deck_cards() -> Array:
return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN)
## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
func toggle_deck(cid: String) -> String:
if cid in deck:
deck.erase(cid)
return ""
if not owns(cid):
return "Karte nicht in der Sammlung"
if deck.size() >= Game.DECK_MAX:
return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
deck.append(cid)
return ""
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
func upgrade(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
if lvl >= Cards.RARITIES.size() - 1:
return "Schon auf der höchsten Stufe"
@@ -138,13 +176,29 @@ func upgrade(cid: String) -> String:
return ""
## Gibt eine Stufe ab und bekommt Staub dafür. Gibt "" zurück oder eine Fehlermeldung.
## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
## Gibt "" zurück oder eine Fehlermeldung.
func dismantle(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
if lvl == 0:
return "Common-Karten lassen sich nicht zerlegen"
dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
collection[cid] = lvl - 1
if lvl == 0:
collection.erase(cid)
deck.erase(cid)
else:
collection[cid] = lvl - 1
return ""
## Herstellen: eine fehlende Karte für Staub als Common. Gibt "" zurück oder eine Fehlermeldung.
func craft(cid: String) -> String:
if owns(cid):
return "Karte schon in der Sammlung"
if dust < CRAFT_COST:
return "Nicht genug Staub (%d nötig)" % CRAFT_COST
dust -= CRAFT_COST
collection[cid] = 0
return ""
@@ -164,8 +218,9 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
return out
## Öffnet einen Booster. Höhere Stufen werten die Karte auf, alles andere wird zu Staub.
## Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen.
## Öffnet einen Booster. Neue Karten kommen in Sammlung und Stapel (solange Platz ist). Von jeder
## Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine gleiche
## oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen.
func open_booster(r: RandomNumberGenerator = null) -> Array:
if coins < BOOSTER_COST:
return []
@@ -175,14 +230,20 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
var out := roll_booster(r)
for res in out:
var lvl: int = Cards.RARITIES.find(res.rarity)
if lvl > level(res.card.id):
res["new"] = not owns(res.card.id)
res["upgraded"] = false
res["dust"] = 0
if res.new:
collection[res.card.id] = lvl
if deck.size() < Game.DECK_MAX:
deck.append(res.card.id)
elif lvl > level(res.card.id):
res["upgraded"] = true
res["dust"] = 0
res["dust"] = DUST_DISMANTLE[rarity(res.card.id)]
collection[res.card.id] = lvl
else:
res["upgraded"] = false
res["dust"] = DUST_DISMANTLE[res.rarity]
dust += res.dust
dust += res.dust
return out

View File

@@ -76,7 +76,7 @@ func _next_zone(step: int) -> void:
func items() -> Array:
match zone:
"deck":
return screen.offer_views
return [screen.deck] if screen.deck.visible else []
"hand":
return screen.hand_views
"own":
@@ -105,7 +105,7 @@ func confirm() -> void:
var item = items[index]
match zone:
"deck":
screen.on_click("deck", index)
screen.on_click("deck", null)
"hand":
screen.on_click("hand", item.card)
"own":

View File

@@ -1,31 +1,29 @@
class_name BattleLayout
## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners
## (Rückseiten), sein Brett, seine Elementleiste, die eigene Elementleiste (die Naht liegt auf halber
## Höhe), eigenes Brett, eigene Hand. Links: Kasten des Gegners, die drei offenen Karten des
## Stapels (mittig), eigener Kasten. Rechts oben die Knöpfe, rechts mittig die Vorschau, darunter Meldungen.
## (Rückseiten), sein Brett, eigenes Brett, eigene Hand. Links: Kasten des Gegners, daneben die
## Elementzähler beider Seiten je neben ihrem Brett, Zusatzzeile und Meldungen. Rechts von oben:
## Stapel und Kasten des Gegners, die Vorschau, eigener Kasten und Stapel. Optionen per Esc.
const B_GIVEUP := Rect2(1070, 10, 96, 32)
const B_LOG := Rect2(1172, 10, 96, 32)
const B_END := Rect2(1070, 48, 198, 32)
const OPP_BOX := Rect2(20, 11, 210, 96)
const B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug)
const B_SKIP := Rect2(20, 48, 210, 32)
const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben
const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter
const OPP_HAND_Y := 11
const FIELD_OPP := Rect2(240, 364, 800, 32)
const FIELD := Rect2(240, 404, 800, 32)
const MSG := Rect2(1040, 630, 220, 60)
const STATUS := Rect2(20, 694, 210, 96)
const B_UNDO := Rect2(1070, 86, 198, 32)
const OPP_ELEMENTS := Rect2(20, 220, 210, 128) # Elementzähler des Gegners, links von seinem Brett
const OWN_ELEMENTS := Rect2(20, 440, 210, 128) # eigene Elementzähler, links vom eigenen Brett
const INFO := Rect2(20, 580, 210, 60) # Zusatzzeile zur Vorschau
const MSG := Rect2(20, 650, 210, 80) # Meldungen
const STATUS := Rect2(1050, 526, 210, 70) # eigener Kasten rechts über dem Stapel
const DECK := Rect2(1090, 606, 130, 182) # eigener Stapel rechts unten
const OPTIONS := Rect2(440, 180, 400, 420) # Optionsmenü (Esc)
const OWN_X := 345
const HAND_W := 110 # Handkarten so groß wie die Wesen auf dem Feld
const HAND_H := 140
const HAND_GAP := 10
const HAND_Y := 610
const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen)
const OFFER_X := 70 # die drei offenen Karten des Stapels, links übereinander um die Mitte
const OFFER_Y := 180
const OFFER_GAP := 10
const DECK_LABEL := Rect2(20, 632, 210, 22)
const LOG_PANEL := Rect2(240, 60, 800, 560)
const PREVIEW_SIZE := Vector2(180, 252)
const PREVIEW_SIZE := Vector2(170, 238)
const CENTER_X := 240
const CENTER_W := 800
@@ -61,11 +59,11 @@ static func hand_arc(count: int, i: int, mirrored: bool = false) -> Array:
return [t * HAND_ARC * sign, t * t * HAND_ARC * sign]
## Vorschau der gezeigten Karte: fest rechts auf halber Höhe.
## Vorschau der gezeigten Karte: fest rechts zwischen den beiden Kästen.
static func preview_rect(_anchor: Rect2) -> Rect2:
return Rect2(Vector2(1050, 400 - PREVIEW_SIZE.y / 2), PREVIEW_SIZE)
return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
## Zusatzzeile (Elementbonus, Eigenschaften) unter der Vorschau.
static func info_rect(preview: Rect2) -> Rect2:
return Rect2(1030, preview.end.y + 8, 230, 80)
## Zusatzzeile (Elementbonus, Eigenschaften) links unter den Elementzählern.
static func info_rect(_preview: Rect2) -> Rect2:
return INFO

View File

@@ -1,17 +1,16 @@
class_name BattleScreen
extends Control
## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx.
## Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden); den Zug beendet
## die Leertaste oder der Rechtsklick ins Leere.
## Mittlere Maustaste auf eine Handkarte legt sie verdeckt. Rechtsklick auf eine Handkarte oder
## ein eigenes Wesen öffnet das Kartenmenü: verdeckt legen, aufdecken, Eigenschaften kaufen.
## Verschmelzen: eine Karte auf eine andere ziehen und dort eine Sekunde stillhalten. Mit
## Gamepad öffnet Y das Menü, dort zusätzlich mit „Verschmelzen mit …“.
## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer).
## Modi: Einzelspieler und Kampagne.
var app
var stage := -1 # Kampagnenstufe oder -1
var net_play := false # Netzwerkspiel (auch als Zuschauer)
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
var net: Net
var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt
var fx: BattleFx
@@ -60,12 +59,10 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
var hero_layer: Control
var tile_layer: Control
var hand_layer: Control
var offer_views: Array = [] # die offenen Karten des Stapels (CardView)
var offer_layer: Control
var deck_label: Label
var draw_enabled := false
var deck: Deck
var opp_deck: Deck # Stapel des Gegners, nur zum Ansehen
var log_overlay: Control
var b_log: Button
var options: Control # Optionsmenü (Esc): Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden
var status_panel: Panel
var fx_layer: Control
var log_box: RichTextLabel
@@ -74,8 +71,6 @@ var name_label: Label
var life_label: Label
var energy_label: Label
var msg_label: Label
var b_end: Button
var b_giveup: Button
var element_view: ElementView # eigene Elementleiste
var opp_element_view: ElementView # die des Gegners darüber
var info_label: Label # Vorschau: Elementbonus, Merkmale
@@ -89,7 +84,6 @@ var reward_label: Label
var notice_label: Label
var confirm: Control
var cursor_layer: Control
var wait_label: Label
var b_skip: Button
var b_undo: Button
@@ -98,30 +92,6 @@ func _init(campaign_stage: int = -1) -> void:
stage = campaign_stage
static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(-1)
b.net_play = true
b.online_seat = seat
return b
func is_online() -> bool:
return net_play
## Zuschauer im Netzwerk: kein eigener Platz, keine Hand.
func spectating() -> bool:
return is_online() and online_seat < 0
func is_client() -> bool:
return is_online() and not net.is_host
func counts_for_profile() -> bool:
return not is_online()
func _ready() -> void:
var s: Dictionary = app.profile.settings
fx = BattleFx.new(self)
@@ -129,31 +99,19 @@ func _ready() -> void:
pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed)
_build_static()
if is_online():
net = app.net
net.ended.connect(_on_net_ended)
net.migrated.connect(_on_migrated)
net.status_changed.connect(_on_net_status)
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 stage >= 0:
if stage >= 0:
var r := Game.make_rng()
game = Game.new_battle_with(Campaign.opponent(stage), r, false, own_rules())
game = Game.new_battle_with(Campaign.opponent(stage, r), r, false, own_rules())
else:
game = Game.new_battle(Game.make_rng(), own_rules())
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile()
smart_ai = bool(app.profile.difficulty().smart)
_begin()
## Kartenoptik, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
## Eigener Stapel und Kartenstufen, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
func own_rules() -> Dictionary:
var p: Profile = app.profile
var rules := {"looks": p.looks()}
var rules := {"looks": p.looks(), "deck": p.deck_cards()}
if stage < 0:
rules["opponent_life"] = int(p.difficulty().life)
if p.perk != "" and p.perk_unlocked():
@@ -162,37 +120,22 @@ func own_rules() -> Dictionary:
func _begin() -> void:
if is_online():
perspective = game.players[maxi(online_seat, 0)]
else:
game.events.clear()
perspective = game.current() if game.current().is_human else game.humans[0]
game.events.clear()
perspective = game.human
build_hero_boxes()
if is_online() and net.is_host:
net.broadcast()
game.events.clear()
refresh()
announce_turn()
func _build_static() -> void:
var L := BattleLayout
# oben links: Aufgeben und Verlauf, klein
b_giveup = Style.button(self, "Aufgeben", L.B_GIVEUP, func(): confirm.visible = true)
b_giveup.add_theme_font_size_override("font_size", 13)
b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log)
b_log.add_theme_font_size_override("font_size", 13)
b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY)
b_end.add_theme_font_size_override("font_size", 15)
b_skip = Style.button(self, "Überspringen", L.B_END, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 15)
b_skip = Style.button(self, "Überspringen", L.B_SKIP, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 13)
b_skip.visible = false
# Mitte: Elementzähler, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben
Style.panel(self, L.FIELD_OPP, Style.darker(Style.PANEL, 0.9), 8)
opp_element_view = ElementView.new(L.FIELD_OPP)
# Elementzähler beider Seiten links neben ihrem Brett
opp_element_view = ElementView.new(L.OPP_ELEMENTS)
add_child(opp_element_view)
Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8)
element_view = ElementView.new(L.FIELD)
element_view = ElementView.new(L.OWN_ELEMENTS)
add_child(element_view)
msg_label = Style.label(self, "", 14, Style.ORANGE, L.MSG, "center", true)
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -207,18 +150,21 @@ func _build_static() -> void:
var sx: float = L.STATUS.position.x
var sy: float = L.STATUS.position.y
var sw: float = L.STATUS.size.x
name_label = Style.label(self, "", 18, Style.TEXT, Rect2(sx + 8, sy + 6, sw - 16, 26), "center", true)
name_label = Style.label(self, "", 16, Style.TEXT, Rect2(sx + 8, sy + 4, sw - 16, 22), "center", true)
name_label.clip_text = true
life_label = Style.label(self, "", 30, Style.RED, Rect2(sx + 8, sy + 42, sw / 2 - 8, 40), "left", true)
energy_label = Style.label(self, "", 30, Style.BLUE, Rect2(sx + sw / 2, sy + 42, sw / 2 - 8, 40), "right", true)
life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 10, sy + 30, sw / 2 - 10, 34), "left", true)
energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + sw / 2, sy + 30, sw / 2 - 10, 34), "right", true)
b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 13)
b_undo.visible = false
deck_label = Style.label(self, "", 13, Style.MUTED, L.DECK_LABEL, "center")
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false
deck = Deck.new(L.DECK)
deck.clicked.connect(func(): on_click("deck", null))
add_child(deck)
opp_deck = Deck.new(L.OPP_DECK)
opp_deck.interactive = false
opp_deck.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(opp_deck)
hero_layer = _layer()
offer_layer = _layer()
tile_layer = _layer()
hand_layer = _layer()
preview_holder = _layer()
@@ -252,6 +198,28 @@ func _build_static() -> void:
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
log_overlay.add_child(log_box)
Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false)
# Optionsmenü (Esc)
options = Style.overlay(self)
options.get_child(0).gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed:
options.visible = false)
Style.panel(options, L.OPTIONS, Style.PANEL, 10, Style.GOLD, 2)
Style.label(options, "Optionen", 24, Style.GOLD, Rect2(L.OPTIONS.position.x, L.OPTIONS.position.y + 18, L.OPTIONS.size.x, 34), "center", true)
var oy := L.OPTIONS.position.y + 76
for entry in [["Verlauf", func():
options.visible = false
log_overlay.visible = true],
["Aufgeben", func():
options.visible = false
confirm.visible = true],
["Einstellungen", func():
options.visible = false
app.show_screen(SettingsScreen.new(), true)],
["Hauptmenü", leave],
["Beenden", func(): get_tree().quit()]]:
var ob := Style.button(options, entry[0], Rect2(L.OPTIONS.position.x + 60, oy, L.OPTIONS.size.x - 120, 48), entry[1])
ob.add_theme_font_size_override("font_size", 18)
oy += 60
# Spielende
overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
@@ -261,8 +229,8 @@ func _build_static() -> void:
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
func toggle_log() -> void:
log_overlay.visible = not log_overlay.visible
func toggle_options() -> void:
options.visible = not options.visible
func _layer() -> Control:
@@ -277,8 +245,6 @@ func _overlay() -> Control:
func leave() -> void:
if is_online():
net.leave()
app.show_screen(MenuScreen.new())
@@ -336,24 +302,22 @@ func build_hero_boxes() -> void:
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, 18, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 6, box.size.x - 16, 26), "center", true)
labels["name"] = Style.label(hero_layer, title, 16, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "center", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 30, Style.RED, Rect2(box.position.x + 8, box.position.y + 42, box.size.x / 2 - 8, 40), "left", true)
labels["stats"] = Style.label(hero_layer, "", 30, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 42, box.size.x / 2 - 8, 40), "right", true)
labels["life"] = Style.label(hero_layer, "", 26, Style.RED, Rect2(box.position.x + 10, box.position.y + 30, box.size.x / 2 - 10, 34), "left", true)
labels["stats"] = Style.label(hero_layer, "", 26, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 30, box.size.x / 2 - 10, 34), "right", true)
hero_labels[o] = labels
# --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool:
if spectating():
return false
return game != null and game.winner == "" and game.current() == perspective
func any_overlay() -> bool:
return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible
return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible or options.visible
func _input(event: InputEvent) -> void:
@@ -400,10 +364,12 @@ func _input(event: InputEvent) -> void:
close_menu()
elif log_overlay.visible:
log_overlay.visible = false
elif options.visible:
options.visible = false
elif selected != null or fuse_first != null:
cancel_all()
elif not any_overlay():
confirm.visible = true
options.visible = true
KEY_SPACE:
if my_turn() and not any_overlay():
end_turn()
@@ -451,8 +417,6 @@ func _process(delta: float) -> void:
_end_drag()
do_fuse(a, b)
after_action()
if is_online() and net.is_host:
net.tick()
if game.winner != "":
if not ended:
ended = true
@@ -461,8 +425,6 @@ func _process(delta: float) -> void:
var cur := game.current()
if cur.is_human:
return
if is_client():
return
if skip_ai:
ai_timer = 0.0
else:
@@ -506,15 +468,10 @@ func _exit_tree() -> void:
func _finish() -> void:
var won := game.winner == "human"
if counts_for_profile():
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, stage, factor)
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")
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, stage, factor)
app.save()
app.sfx.play("win" if won else "lose")
refresh()
@@ -631,10 +588,7 @@ func fusion_partners(target) -> Array:
func play(card: Cards.Card, face_down: bool = false) -> void:
if is_client():
net.send_action({"type": "play", "hand": perspective.hand.find(card), "face_down": face_down})
return
var before: Game = game.clone() if not is_online() else null
var before: Game = game.clone()
var log_len := game.log.size()
var from := _hand_rect_of(card)
var err := game.play_card(perspective, card, face_down)
@@ -655,9 +609,6 @@ func play(card: Cards.Card, face_down: bool = false) -> void:
## Deckt ein eigenes verdecktes Wesen auf (kostenlos).
func do_reveal(cr: Game.Creature) -> void:
clear_undo()
if is_client():
net.send_action({"type": "reveal", "uid": cr.uid})
return
var err := game.reveal(perspective, cr)
if err != "":
say(tr(err))
@@ -667,7 +618,7 @@ func do_reveal(cr: Game.Creature) -> void:
## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. full: auch verschmelzen anbieten
## (Gamepad, das nicht ziehen kann).
func open_menu(target, anchor: Rect2, full: bool = false) -> void:
if spectating() or not my_turn():
if not my_turn():
return
cancel()
Style.clear(menu_layer)
@@ -685,7 +636,7 @@ func open_menu(target, anchor: Rect2, full: bool = false) -> void:
entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]])
if full:
var partners := fusion_partners(target)
entries.append([tr("Verschmelzen mit … ◆ %d") % card.fusion_cost(), func():
entries.append([tr("Verschmelzen mit …"), func():
fuse_first = target
say(tr("Ziel wählen"))
refresh(), not partners.is_empty()])
@@ -729,22 +680,16 @@ func close_menu() -> void:
## Abwerfen: eine Handkarte gegen Mana loswerden.
func do_discard(card: Cards.Card) -> void:
clear_undo()
if is_client():
net.send_action({"type": "discard", "hand": perspective.hand.find(card)})
return
var err := game.discard(perspective, card)
if err != "":
say(tr(err))
after_action()
## Ziehen: kostet Mana, eine Karte kommt auf die Hand.
func do_draw(index: int = 0) -> void:
## Ziehen: eine Karte vom eigenen Stapel, die erste im Zug frei.
func do_draw() -> void:
clear_undo()
if is_client():
net.send_action({"type": "draw", "index": index})
return
var err := game.draw(perspective, index)
var err := game.draw(perspective)
if err != "":
say(tr(err))
else:
@@ -755,12 +700,6 @@ func do_draw(index: int = 0) -> void:
func do_fuse(a, b) -> void:
cancel()
clear_undo()
if is_client():
if a is Cards.Card:
net.send_action({"type": "fuse", "hand": perspective.hand.find(a), "other_hand": perspective.hand.find(b)})
else:
net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid})
return
var err := game.fuse(perspective, a, b)
if err != "":
say(tr(err))
@@ -784,8 +723,6 @@ func undo() -> void:
restored.say("Rückgängig.")
game = restored
perspective = game.players[idx]
if is_online() and net.is_host:
net.game = game
clear_undo()
cancel()
build_hero_boxes()
@@ -797,9 +734,6 @@ func end_turn() -> void:
return
cancel()
clear_undo()
if is_client():
net.send_action({"type": "end"})
return
game.end_turn()
ai_timer = ai_delay
after_action()
@@ -823,17 +757,14 @@ func on_click(kind: String, obj) -> void:
selected = null
if kind == "enemy" or kind == "hero":
clear_undo()
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(tr(err))
var err := g.attack(cr, obj)
if err != "":
say(tr(err))
after_action()
return
match kind:
"deck":
do_draw(int(obj) if obj != null else 0)
do_draw()
"hand":
var ok := g.can_play(perspective, obj)
if not ok[0]:
@@ -949,6 +880,41 @@ func update_attack_preview() -> void:
v.queue_redraw()
## Der eigene Stapel: Kartenstoß mit Kosten des nächsten Ziehens und Restzahl.
class Deck extends Control:
signal clicked
var enabled := true
var interactive := true # false: der Stapel des Gegners, nur Anzahl
var count := 0
var cost := 0
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
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:
for k in range(2, -1, -1):
var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12))
r.position.y += 6
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled and interactive else Style.MUTED, 2), r)
if k == 0:
Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6)
if interactive:
Style.text(self, tr("Ziehen"), 16, Style.TEXT if enabled else Style.MUTED, r.get_center() + Vector2(0, -12), "center", true)
Style.text(self, "◆ %d" % cost, 20, Style.BLUE if enabled else Style.MUTED, r.get_center() + Vector2(0, 14), "center", true)
Style.text(self, str(count), 12, Style.MUTED, Vector2(r.get_center().x, r.end.y - 18), "center")
else:
Style.text(self, str(count), 22, Style.TEXT, r.get_center(), "center", true)
if interactive and not enabled:
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
## Eine verdeckte Handkarte des Gegners in voller Größe.
class CardBack extends Control:
func _init(rect: Rect2) -> void:
@@ -967,51 +933,10 @@ class CardBack extends Control:
# --- 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[maxi(online_seat, 0)]
build_hero_boxes()
after_action()
## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter.
func _on_migrated() -> void:
game = net.game
if not net.action_applied.is_connected(after_action):
net.action_applied.connect(after_action)
wait_label.visible = false
perspective = game.players[online_seat]
build_hero_boxes()
say("Du bist jetzt Host.")
after_action()
func _on_net_status() -> void:
wait_label.text = net.status
wait_label.visible = true
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()
var centers := {}
@@ -1138,23 +1063,12 @@ func refresh() -> void:
tiles.erase(uid)
fx.fade_out(t)
update_attack_preview()
Style.clear(offer_layer)
offer_views = []
draw_enabled = mine and g.can_draw(h)[0]
if not spectating():
for i in h.offer.size():
var oc: Cards.Card = h.offer[i]
var orect := Rect2(BattleLayout.OFFER_X, BattleLayout.OFFER_Y + i * (BattleLayout.HAND_H + BattleLayout.OFFER_GAP), BattleLayout.HAND_W, BattleLayout.HAND_H)
var ov := CardView.new(oc, orect.size, false, oc.rarity_in(h.looks))
ov.position = orect.position
ov.dim = not draw_enabled
var idx := i
ov.clicked.connect(func(_v): on_click("deck", idx))
ov.hover.connect(func(c, e): show_preview(c, e, h.looks, "", orect))
offer_layer.add_child(ov)
offer_views.append(ov)
deck_label.text = "%s ◆ %d · %d" % [tr("Ziehen"), g.draw_cost(h), g.deck.size()]
deck_label.visible = not spectating()
deck.enabled = mine and g.can_draw(h)[0]
deck.count = h.deck.size()
deck.cost = g.draw_cost(h)
deck.queue_redraw()
opp_deck.count = opponent().deck.size()
opp_deck.queue_redraw()
Style.clear(hand_layer)
for o in others(): # die Hand des Gegners oben, nur Rückseiten, spiegelverkehrter Bogen
@@ -1165,9 +1079,7 @@ func refresh() -> void:
back.rotation_degrees = arc[0]
back.position.y += arc[1]
hand_layer.add_child(back)
if spectating():
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(240, BattleLayout.HAND_Y + 18, 800, 30), "center")
else:
if true:
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0]
@@ -1199,10 +1111,8 @@ func refresh() -> void:
name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d" % h.energy
b_end.disabled = not mine
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
var ai_running: bool = g.winner == "" and not g.current().is_human
b_skip.visible = ai_running
b_end.visible = not ai_running and not spectating()
b_undo.visible = undo_state != null and mine
element_view.update_from(g, h)
opp_element_view.update_from(g, opponent())
@@ -1222,20 +1132,13 @@ func refresh() -> void:
pad.draw_cursor()
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"
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)
overlay.visible = true
confirm.visible = false

View File

@@ -137,13 +137,15 @@ func refresh() -> void:
elif revealed.has(i):
var res: Dictionary = cards[i]
var c: Cards.Card = res.card
var v := CardView.new(c, r.size, false, res.rarity)
var v := CardView.new(c.leveled(Cards.RARITIES.find(res.rarity)), r.size, false, res.rarity)
v.position = r.position
slots.add_child(v)
if fresh.has(i):
flip(v, fresh[i], res.rarity)
Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if res.upgraded:
if res.get("new", false):
Style.label(slots, "Neu!", 16, Style.GOLD, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
elif res.upgraded:
Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else:
Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")

View File

@@ -1,7 +1,7 @@
class_name CollectionScreen
extends Control
## Sammlung: alle Karten gehören dir. Gesammelt wird die Seltenheitsstufe — reine Optik.
## Aufwerten kostet Staub, eine Stufe abgeben bringt Staub.
## Sammlung: alle Karten des Spiels, die fehlenden abgedunkelt. Eigene Karten lassen sich
## aufwerten (Staub) oder zerlegen (bringt Staub), fehlende herstellen (Staub).
var app
var filter := ""
@@ -15,6 +15,7 @@ var preview_holder: Control
var info_label: Label
var b_dismantle: Button
var b_upgrade: Button
var b_craft: Button
func _ready() -> void:
@@ -43,6 +44,7 @@ func _ready() -> void:
info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center")
b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY)
b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28"))
b_craft = Style.button(self, "", Rect2(840, 530, 380, 46), craft, Style.PRIMARY)
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center")
refresh()
@@ -57,7 +59,7 @@ func set_filter(e: String) -> void:
refresh()
## Aufgewertete Karten zuerst, sonst in Kartenreihenfolge.
## Eigene Karten zuerst (höhere Stufen vorn), dann die fehlenden, sonst in Kartenreihenfolge.
func cards() -> Array:
var out: Array = Cards.all().duplicate()
if filter != "":
@@ -69,7 +71,10 @@ func cards() -> Array:
var order := {}
for i in out.size():
order[out[i].id] = i
out.sort_custom(func(a, b): return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id])
out.sort_custom(func(a, b):
if p.owns(a.id) != p.owns(b.id):
return p.owns(a.id)
return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id])
return out
@@ -111,11 +116,31 @@ func dismantle() -> void:
refresh()
func craft() -> void:
if selected == null:
return
var err: String = app.profile.craft(selected.id)
if err == "":
app.sfx.play("combo")
app.save()
say(tr("%s hergestellt") % selected.title(), true)
else:
say(err, false)
refresh()
func refresh() -> void:
var p: Profile = app.profile
dust_label.text = "Staub: %d" % p.dust
grid.fill(cards(), func(c): return p.rarity(c.id))
var shown := cards()
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var views := grid.views()
for i in views.size():
views[i].dim = not p.owns(shown[i].id)
Style.clear(preview_holder)
b_craft.visible = false
b_upgrade.visible = true
b_dismantle.visible = true
if selected == null:
info_label.text = "Keine Karte gewählt"
b_upgrade.text = "Aufwerten"
@@ -123,11 +148,19 @@ func refresh() -> void:
b_upgrade.disabled = true
b_dismantle.disabled = true
return
var v := CardView.new(selected, Vector2(220, 308), false, p.rarity(selected.id))
var v := CardView.new(selected.leveled(p.level(selected.id)), Vector2(220, 308), false, p.rarity(selected.id))
v.position = Vector2(920, 152)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var lvl := p.level(selected.id)
if not p.owns(selected.id):
info_label.text = tr("Nicht in der Sammlung")
b_upgrade.visible = false
b_dismantle.visible = false
b_craft.visible = true
b_craft.text = tr("Herstellen (%d Staub)") % Profile.CRAFT_COST
b_craft.disabled = p.dust < Profile.CRAFT_COST
return
info_label.text = "%s · %s" % [tr(selected.combo()), tr(p.rarity(selected.id))]
if lvl >= Cards.RARITIES.size() - 1:
b_upgrade.text = "Höchste Stufe"
@@ -136,5 +169,5 @@ func refresh() -> void:
var next: String = Cards.RARITIES[lvl + 1]
b_upgrade.text = "Aufwerten zu %s (%d Staub)" % [tr(next), Profile.DUST_UPGRADE[next]]
b_upgrade.disabled = p.dust < Profile.DUST_UPGRADE[next]
b_dismantle.text = "Zerlegen" if lvl == 0 else "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]
b_dismantle.disabled = lvl == 0
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]
b_dismantle.disabled = false

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

@@ -0,0 +1,93 @@
class_name DeckScreen
extends Control
## Deck zusammenstellen: alle eigenen Karten im Raster, ein Klick nimmt eine Karte in den
## Stapel oder wieder heraus. Karten außerhalb des Stapels sind abgedunkelt. Der Stapel hält
## 40 bis 60 Karten; „Auffüllen“ nimmt die teuersten eigenen Karten des gewählten Filters.
var app
var filter := ""
var grid: CardGrid
var count_label: Label
var msg_label: Label
func _ready() -> void:
Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
grid.card_clicked.connect(toggle)
add_child(grid)
Style.panel(self, Rect2(820, 86, 420, 696))
count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
var hint := Style.label(self, "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters.", 15, Style.MUTED, Rect2(840, 160, 380, 60), "center")
hint.autowrap_mode = TextServer.AUTOWRAP_WORD
Style.button(self, "Auffüllen", Rect2(840, 240, 380, 46), fill_up)
Style.button(self, "Leeren", Rect2(840, 296, 380, 46), clear_deck, Color("5a3c28"))
msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 360, 380, 48), "center")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func set_filter(e: String) -> void:
filter = e
refresh()
func toggle(card: Cards.Card) -> void:
var err: String = app.profile.toggle_deck(card.id)
msg_label.text = err
if err == "":
app.sfx.play("click")
app.save()
refresh()
## Füllt mit den teuersten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
func fill_up() -> void:
var p: Profile = app.profile
var pool: Array = p.collection.keys()
if filter != "":
pool = pool.filter(func(id): return filter in Cards.by_id(id).elements)
p.deck = DeckGen.fill(p.deck, pool, Game.DECK_MAX)
app.save()
refresh()
func clear_deck() -> void:
app.profile.deck = []
app.save()
refresh()
## Eigene Karten, die im Stapel zuerst, sonst nach Kosten.
func cards() -> Array:
var p: Profile = app.profile
var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
if filter != "":
out = out.filter(func(c): return filter in c.elements)
out.sort_custom(func(a, b):
var ia: bool = a.id in p.deck
var ib: bool = b.id in p.deck
if ia != ib:
return ia
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
return out
func refresh() -> void:
var p: Profile = app.profile
count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX]
count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE)
var shown := cards()
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var views := grid.views()
for i in views.size():
views[i].dim = not (shown[i].id in p.deck)

View File

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

View File

@@ -1,10 +1,11 @@
class_name ElementView
extends Control
## Elementzähler der eigenen Seite, mittig: je Element zweimal das Symbol seines Sondereffekts in
## der Elementfarbe, leuchtend je offenem eigenen Wesen (beide: Effekt aktiv), daneben in der
## Elementfarbe das Mana der bisher ausgespielten Karten (je Game.ELEMENT_MANA gibt es +1 Angriff).
## Elementzähler einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente da, bei denen
## etwas passiert ist: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je
## offenem Wesen, beide: Effekt aktiv), die Zahl der ausgespielten Karten und ab
## Game.ELEMENT_COUNT der Bonus +1/+1.
const ENTRY_W := 180.0
const ROW := 32.0
var own := {} # offene eigene Wesen je Element
var played := {} # ausgespielte Karten je Element
@@ -31,37 +32,41 @@ func paired(e: String) -> bool:
func bonus(e: String) -> int:
return int(played.get(e, 0)) / Game.ELEMENT_MANA
return int(played.get(e, 0)) / Game.ELEMENT_COUNT
## Elemente, bei denen sich etwas getan hat (offene Wesen oder ausgespielte Karten).
func shown() -> Array:
return Cards.ELEMENTS.filter(func(e): return int(own.get(e, 0)) > 0 or int(played.get(e, 0)) > 0)
## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus.
func text() -> String:
var parts: Array[String] = []
for e in Cards.ELEMENTS:
for e in shown():
var s := "%s %d/%d" % [tr(e), int(own.get(e, 0)), int(played.get(e, 0))]
if paired(e):
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
if bonus(e) > 0:
s += " (+%d)" % bonus(e)
s += " (+%d/+%d)" % [bonus(e), bonus(e)]
parts.append(s)
return " · ".join(parts)
func _draw() -> void:
var x0 := (size.x - ENTRY_W * Cards.ELEMENTS.size()) / 2
var cy := size.y / 2
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var y := ROW / 2
for e in shown():
var col: Color = Cards.ELEMENT_COLORS[e]
var active := paired(e) or bonus(e) > 0
var px := x0 + i * ENTRY_W + 10
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, cy), "midleft", active)
px += Style.FONT_BOLD.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 10
draw_style_box(Style.box(Color(col, 0.18 if active else 0.1), 6, Color(col, 0.6 if active else 0.0), 1 if active else 0), Rect2(0, y - ROW / 2 + 2, size.x, ROW - 4))
var px := 10.0
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, y), "midleft", active)
px = 76
for k in Game.ELEMENT_PAIR:
var filled := k < int(own.get(e, 0))
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, cy), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, y), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
px += Game.ELEMENT_PAIR * 24 + 8
Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, cy), "midleft", true)
px += 20
Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
if bonus(e) > 0:
Style.text(self, "+%d" % bonus(e), 12, Style.GREEN, Vector2(px, cy), "midleft", true)
Style.text(self, "+%d/+%d" % [bonus(e), bonus(e)], 12, Style.GREEN, Vector2(size.x - 6, y), "midright", true)
y += ROW

View File

@@ -1,107 +0,0 @@
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_watch: Button
var b_start: Button
var addr_label: Label
func _ready() -> void:
net = app.net
net.my_name = "Spieler"
net.my_pick = {"looks": app.profile.looks()}
Style.header(self, "Netzwerkspiel", _back)
var col := Style.column(self, 760, 110, 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)
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
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, 10)
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
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()
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
func _watch() -> void:
net.spectator = true
net.join(ip_box.text.strip_edges())
refresh()
func _start() -> void:
net.start_battle()
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 ""])
for id in net.spectators:
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
b_host.disabled = net.connected
b_join.disabled = net.connected
b_watch.disabled = net.connected
ip_box.editable = not net.connected
var humans := ids.size()
b_start.disabled = not net.is_host
b_start.text = tr("Start") if humans >= 2 else tr("Start gegen die KI")
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

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

View File

@@ -5,7 +5,6 @@ var profile: Profile
var screen: Control
var save_path: String = Profile.SAVE_PATH
var sfx: Sfx
var net: Net
func _ready() -> void:
@@ -17,14 +16,9 @@ func _ready() -> void:
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)
apply_settings()
show_screen(MenuScreen.new())
show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new())
func apply_settings() -> void:
@@ -36,20 +30,32 @@ func apply_settings() -> void:
theme = Style.make_theme()
## Neues Profil: Sammlung und Fortschritt werden verworfen.
## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt.
func reset_profile() -> void:
profile = Profile.starter()
save()
apply_settings()
show_screen(StartDeckScreen.new())
func set_language(code: String) -> void:
TranslationServer.set_locale(code)
func show_screen(s: Control) -> void:
var kept: Array = [] # vorige Bildschirme, zu denen back() zurückführt (Einstellungen aus dem Kampf)
## keep: der aktuelle Bildschirm bleibt erhalten und kommt mit back() zurück.
func show_screen(s: Control, keep: bool = false) -> void:
if screen != null:
screen.queue_free()
if keep:
remove_child(screen)
kept.append(screen)
else:
screen.queue_free()
for k in kept:
k.queue_free()
kept.clear()
screen = s
s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -106,11 +112,17 @@ func _first_button(node: Node) -> Button:
return null
## Zurück: zum gemerkten Bildschirm, sonst ins Hauptmenü.
func back() -> void:
if kept.is_empty():
show_screen(MenuScreen.new())
return
var prev: Control = kept.pop_back()
screen.queue_free()
screen = prev
add_child(prev)
func save() -> void:
profile.save(save_path)
func _exit_tree() -> void:
if net != null and net.is_inside_tree():
net.leave()
net.queue_free()

View File

@@ -17,7 +17,7 @@ func _ready() -> void:
var y := 230
for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
["Kampagne", func(): app.show_screen(CampaignScreen.new())],
["Netzwerkspiel", func(): app.show_screen(LobbyScreen.new())]]:
["Deck", func(): app.show_screen(DeckScreen.new())]]:
var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
b.add_theme_font_size_override("font_size", 24)
y += 74
@@ -35,9 +35,9 @@ func _ready() -> void:
refresh()
## Drei Karten der Sammlung, die höchsten Stufen zuerst, leicht gefächert.
## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert.
func _showcase(p: Profile) -> void:
var cards: Array = Cards.all().duplicate()
var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
cards.sort_custom(func(a, b):
if p.level(a.id) != p.level(b.id):
return p.level(a.id) > p.level(b.id)
@@ -45,7 +45,7 @@ func _showcase(p: Profile) -> void:
var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
for i in 3:
var c: Cards.Card = cards[i]
var v := CardView.new(c, Vector2(180, 252), false, p.rarity(c.id))
var v := CardView.new(c.leveled(p.level(c.id)), Vector2(180, 252), false, p.rarity(c.id))
v.position = spots[i][0]
v.rotation = spots[i][1]
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -55,8 +55,8 @@ func _showcase(p: Profile) -> void:
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = tr("Aufgewertet: %d von %d Karten · Kampagne: %d/%d") % [
p.upgraded_count(), Cards.all().size(), p.campaign_stage, Campaign.total()]
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [
p.collection.size(), Cards.all().size(), p.campaign_stage, Campaign.total()]
var notes := p.take_notices()
notice_label.text = " · ".join(notes)
if not notes.is_empty():

View File

@@ -16,7 +16,7 @@ var speed_label: Label
func _ready() -> void:
var s: Dictionary = app.profile.settings
Style.header(self, "Einstellungen", func(): app.show_screen(MenuScreen.new()))
Style.header(self, "Einstellungen", func(): app.back())
var col := Style.column(self, 700, 110, 24)
var grid := GridContainer.new()
grid.columns = 3
@@ -86,7 +86,6 @@ func _ready() -> void:
func _reset() -> void:
app.reset_profile()
app.show_screen(MenuScreen.new())
func _on_big_font(on: bool) -> void:
@@ -139,4 +138,4 @@ func _on_anim(on: bool) -> void:
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())
app.back()

View File

@@ -0,0 +1,37 @@
class_name StartDeckScreen
extends Control
## Erster Start: eines von vier Startdecks wählen, je eines mit einem Element im Mittelpunkt
## (25 Karten davon, je fünf der anderen). Danach geht es ins Menü.
var app
func _ready() -> void:
Style.label(self, tr("Wähle dein Startdeck"), 40, Style.GOLD, Rect2(0, 60, 1280, 60), "center", true)
Style.label(self, tr("25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest."), 17, Style.MUTED, Rect2(140, 124, 1000, 30), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var x := 70 + i * 290
var col: Color = Cards.ELEMENT_COLORS[e]
Style.panel(self, Rect2(x, 190, 260, 470), Style.darker(col, 0.75), 12, col, 2)
var ids := DeckGen.start_deck(e)
var sample: Array = ids.filter(func(id): return Cards.by_id(id).elements[0] == e)
sample.sort_custom(func(a, b): return Cards.by_id(a).cost > Cards.by_id(b).cost)
for k in 3:
var c: Cards.Card = Cards.by_id(sample[k * 7])
var v := CardView.new(c, Vector2(110, 140), false, "Common")
v.position = Vector2(x + 20 + k * 40, 210 + k * 14)
v.rotation_degrees = -6 + k * 6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
Style.label(self, tr(e), 28, Style.TEXT, Rect2(x, 400, 260, 40), "center", true)
Style.label(self, tr(Cards.KEYWORD_TEXT[Cards.ELEMENT_KEYWORD[e]]), 15, Style.MUTED, Rect2(x + 16, 446, 228, 60), "center")
var b := Style.button(self, tr("%s wählen") % tr(e), Rect2(x + 30, 590, 200, 50), choose.bind(e), Style.PRIMARY)
b.add_theme_font_size_override("font_size", 18)
func choose(element: String) -> void:
app.profile.choose_start(element)
app.save()
app.sfx.play("combo")
app.show_screen(MenuScreen.new())

View File

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

View File

@@ -1,101 +0,0 @@
extends Node
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er
## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI).
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
## godot --path . tests/net_bot.tscn -- --host --name=BotHost
var net: Net
var seat := -1
var game: Game
var t := 0.0
var hosting := false
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)
if a == "--host":
hosting = true
net = Net.new()
net.name = "Net"
get_tree().root.add_child.call_deferred(net)
net.my_name = bot_name
net.my_pick = {"elements": ["Feuer", "Erde"], "looks": {}}
net.started.connect(func(s):
seat = s
if hosting:
net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut
_act_host.call_deferred())
net.snapshot_received.connect(_on_snapshot)
net.ended.connect(func(_r): get_tree().quit())
await get_tree().process_frame
if hosting:
net.host()
net.lobby_changed.connect(func():
if net.game == null and net.names.size() >= 2:
net.start_battle())
net.action_applied.connect(func():
net.broadcast()
_act_host.call_deferred())
else:
net.join(ip)
func _process(delta: float) -> void:
t += delta
if hosting and net.game != null:
net.tick()
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
_decide(game.players[seat])
func _act_host() -> void:
if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat:
return
game = net.game
_decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: das erste spielbare Wesen legen, dann angreifen,
## dann ziehen, sonst Zug beenden.
func _decide(me: Game.Player) -> void:
var g := game
await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand
for i in me.hand.size():
var card: Cards.Card = me.hand[i]
if game.can_play(me, card)[0]:
net.send_action({"type": "play", "hand": i})
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
if game.can_draw(me)[0]:
net.send_action({"type": "draw"})
return
net.send_action({"type": "end"})

View File

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

View File

@@ -1,6 +0,0 @@
[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")

View File

@@ -14,7 +14,7 @@ func _initialize() -> void:
print("Kartenpool laden: %.1f ms" % t)
var r := RandomNumberGenerator.new()
r.seed = 3
print("Stapel mischen: %.2f ms" % ms(func(): DeckGen.deck(r), 20))
print("Stapel würfeln: %.2f ms" % ms(func(): DeckGen.random_deck(r), 20))
print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
for n in [1]:
var g := Game.new_battle(r)
@@ -22,7 +22,7 @@ func _initialize() -> void:
var uid := 5000
for p in g.players:
for k in 4:
var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid)
var c := Game.Creature.new(Cards.by_id("f_3_2"), p, uid)
uid += 1
c.ready = true
p.board.append(c)
@@ -30,7 +30,7 @@ func _initialize() -> void:
h.energy = 10
h.max_energy = 10
for k in 6:
h.hand.append(Cards.by_id("f_ritter"))
h.hand.append(Cards.by_id("f_4_3"))
AI.time_budget_ms = 100000
var t_full := ms(func(): AI.choose_action(g, h))
AI.time_budget_ms = 150
@@ -39,5 +39,4 @@ func _initialize() -> void:
print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20))
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
print("Snapshot: %.2f ms" % ms(func(): Game.new_battle(r).snapshot(0), 10))
quit()

File diff suppressed because it is too large Load Diff

View File

@@ -117,10 +117,11 @@ func screen():
return main.screen
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist.
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist; ohne Startdeck wird
## Feuer gewählt.
func ensure_menu() -> void:
if main.net.connected:
main.net.leave()
if screen() is StartDeckScreen:
await click(find_button("Feuer wählen"))
if TranslationServer.get_locale().begins_with("en"):
main.set_language("de")
if not (screen() is MenuScreen):
@@ -169,15 +170,11 @@ func _ready() -> void:
add_child(main)
main.sfx.muted = true # Tests bleiben still
await frames(3)
var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen]
var suite := [test_start, test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
test_kampagne, test_sammlung, test_deck, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen]
suite += [test_kampf.bind(3), test_kampf_abbrechen]
# Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten.
# Sie laufen nur auf Wunsch: DINGER_NETZ=1 (oder gezielt über DINGER_ONLY).
if OS.get_environment("DINGER_NETZ") != "":
suite += [test_netzwerk, test_host_wechsel, test_zuschauer]
suite.append(test_profil_zuruecksetzen)
var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer
var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_sammlung,test_kampf
if only != "":
suite = suite.filter(func(t: Callable): return t.get_method() in only.split(","))
for t in suite:
@@ -190,6 +187,18 @@ func _ready() -> void:
get_tree().quit(1 if failed > 0 else 0)
## Erster Start: eines von vier Startdecks wählen (ensure_menu hat schon eins gewählt, darum frisch).
func test_start() -> void:
main.reset_profile()
await frames(2)
check(screen() is StartDeckScreen and not main.profile.started(), "neues Profil: Startdeck wählen")
await shot("00_startdeck")
await click(find_button("Feuer wählen"))
var p: Profile = main.profile
check(screen() is MenuScreen and p.started() and p.deck.size() == Game.DECK_MIN and p.deck.filter(func(id): return Cards.by_id(id).elements[0] == "Feuer").size() == 25, "Feuer gewählt: 40 Karten, 25 davon Feuer")
check(Profile.load_or_new(SAVE).started(), "gespeichert")
func test_menu() -> void:
check(screen() is MenuScreen, "Menü sichtbar")
check(find_button("Einzelspieler") != null and find_button("Beenden") != null, "Menüknöpfe vorhanden")
@@ -213,8 +222,8 @@ func test_booster() -> void:
await shot("04_booster_offen")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
for res in scr.cards:
if res.upgraded:
check(main.profile.level(res.card.id) == Cards.RARITIES.find(res.rarity), "aufgewertete Karte trägt ihre Stufe")
if res.new or res.upgraded:
check(main.profile.owns(res.card.id) and main.profile.level(res.card.id) == Cards.RARITIES.find(res.rarity), "neue oder aufgewertete Karte trägt ihre Stufe")
else:
check(res.dust > 0, "sonst gibt es Staub")
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
@@ -255,6 +264,11 @@ func drag_fuse(scr, from: Control, to: Control) -> void:
m.button_mask = MOUSE_BUTTON_MASK_LEFT
get_viewport().push_input(m)
await frames(1)
if scr.drag_ghost == null or scr.drag_target == null:
var fo = from.card if from is CardView else from.creature
var to_o = to.card if to is CardView else to.creature
print("DEBUG drag: ghost=", scr.drag_ghost, " target=", scr.drag_target, " press=", scr.press_target, " dran=", scr.my_turn(),
" overlay=", scr.any_overlay(), " mana=", scr.game.human.energy, " kann=", scr.game.can_fuse(scr.game.human, fo, to_o), " from=", from.get_global_rect(), " to=", to.get_global_rect())
check(scr.drag_ghost != null and scr.drag_target != null, "Karte schwebt über einem Verschmelzungsziel")
var waited := 0
while scr.drag_ghost != null and waited < 240:
@@ -297,7 +311,7 @@ func human_turn(scr) -> void:
var mana: int = g.human.energy
var hand: int = g.human.hand.size()
var cost: int = g.draw_cost(g.human)
await click(scr.offer_views[0])
await click(scr.deck)
check(g.human.energy == mana - cost and g.human.hand.size() == hand + 1, "gezogen: eine Karte")
var tried := [] # Wesen ohne lohnendes Ziel (0 Angriff ohne Vorteil) nicht wieder versuchen
for i in 12:
@@ -329,7 +343,7 @@ func human_turn(scr) -> void:
await click(target_view(scr, targets))
check(scr.selected == null and cr.attacks_done > 0, "Angriff ausgeführt")
if g.winner == "":
await click(scr.b_end)
await key(KEY_SPACE)
func start_battle() -> void:
@@ -385,11 +399,14 @@ func leave_battle() -> void:
func give_up() -> void:
var scr = screen()
await click(find_button("Aufgeben"))
check(scr.confirm.visible, "Aufgeben fragt nach")
await key(KEY_ESCAPE)
check(scr.options.visible, "Esc öffnet die Optionen")
await click(find_button("Aufgeben", scr.options))
check(scr.confirm.visible and not scr.options.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 key(KEY_ESCAPE)
await click(find_button("Aufgeben", scr.options))
await click(find_button("Ja, aufgeben"))
check(screen() is MenuScreen, "Aufgeben führt ins Menü")
@@ -419,33 +436,75 @@ func test_einstellungen() -> void:
check(screen() is MenuScreen, "zurück im Menü")
## Deck: eigene Karten rein und raus, Auffüllen nach Filter, Speichern. Beginnt mit dem frischen Startdeck.
func test_deck() -> void:
main.profile.choose_start("Feuer")
main.save()
await click(find_button("Deck"))
var scr = screen()
check(scr is DeckScreen, "Deck geöffnet")
var p: Profile = main.profile
check(scr.grid.views().size() == p.collection.size() and scr.count_label.text == "Stapel: %d Karten (%d bis %d)" % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX], "alle eigenen Karten, Zähler stimmt")
var views: Array = scr.grid.views()
check(views.all(func(v): return not v.dim) and p.deck.size() == Game.DECK_MIN, "nach dem Start sind alle eigenen Karten im Stapel")
var first_id: String = views[0].card.id
await click(views[0])
check(not (first_id in p.deck) and p.deck.size() == Game.DECK_MIN - 1 and scr.grid.views().any(func(v): return v.card.id == first_id and v.dim), "Klick nimmt die Karte aus dem Stapel, sie wird dunkel")
var back_view = scr.grid.views().filter(func(v): return v.card.id == first_id)[0]
scr.grid.ensure_control_visible(back_view) # sie liegt jetzt hinter den Stapelkarten, außerhalb des Sichtbereichs
await frames(2)
await click(back_view)
check(first_id in p.deck and p.deck.size() == Game.DECK_MIN, "Klick nimmt sie wieder hinein")
await shot("19_deck")
await click(find_button("Leeren"))
check(p.deck.is_empty(), "geleert")
await click(find_button("Wasser"))
await click(find_button("Auffüllen"))
check(p.deck.size() == 5 and p.deck.all(func(id): return Cards.by_id(id).elements[0] == "Wasser"), "Auffüllen hält sich an den Filter: nur die fünf Wasserkarten")
await click(find_button("Alle"))
await click(find_button("Auffüllen"))
check(p.deck.size() == Game.DECK_MIN and Profile.load_or_new(SAVE).deck == p.deck, "ohne Filter alles, gespeichert")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_sammlung() -> void:
await click(find_button("Sammlung"))
var scr = screen()
check(scr is CollectionScreen, "Sammlung geöffnet")
var p: Profile = main.profile
check(scr.grid.views().size() == Cards.all().size(), "alle Karten gehören dir")
check(scr.grid.views().size() == Cards.all().size(), "alle Karten des Spiels sind zu sehen")
check(scr.grid.views().slice(0, p.collection.size()).all(func(v): return not v.dim) and scr.grid.views().back().dim, "eigene Karten vorn, fehlende abgedunkelt")
p.dust = 5000
scr.search_box.text = "Phönix"
scr.search_box.text_changed.emit("Phönix")
scr.search_box.text = "Lodernder Funke"
scr.search_box.text_changed.emit("Lodernder Funke")
await frames(1)
var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0]
await click(phoenix)
check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "Common", "Karte gewählt, noch Common")
var funke = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0]
await click(funke)
check(scr.selected.id == "f_2_1" and p.rarity("f_2_1") == "Common" and scr.b_upgrade.visible, "eigene Karte gewählt, noch Common")
await click(scr.b_upgrade)
check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet")
check(p.level("f_2_1") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet")
scr.search_box.text = ""
scr.search_box.text_changed.emit("")
await frames(1)
var front: Array = scr.grid.views().slice(0, p.upgraded_count())
check(front.all(func(v): return v.rarity != "Common") and front.any(func(v): return v.card.id == "ff_phoenix" and v.rarity == "Rare"), "aufgewertete Karten stehen vorn in der Sammlung, der Phönix als Rare dabei")
scr.search_box.text = "Phönix"
scr.search_box.text_changed.emit("Phönix")
var front = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0]
check(front.rarity == "Rare" and front.card.attack == 3 and front.card.health == 2 and scr.grid.views()[0].rarity != "Common", "die Rare zeigt +1/+1, aufgewertete stehen vorn: %s %d/%d" % [front.rarity, front.card.attack, front.card.health])
check(Profile.load_or_new(SAVE).level("f_2_1") == 1, "Sammlung gespeichert")
scr.search_box.text = "Lodernder Funke"
scr.search_box.text_changed.emit("Lodernder Funke")
await frames(1)
check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert")
var dust: int = p.dust
await click(scr.b_dismantle)
check(p.level("ff_phoenix") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt")
check(p.level("f_2_1") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt")
# Herstellen einer fehlenden Karte
scr.search_box.text = "Infernalischer Drache"
scr.search_box.text_changed.emit("Infernalischer Drache")
await frames(1)
await click(scr.grid.views().filter(func(v): return v.card.id == "f_5_5")[0])
check(scr.b_craft.visible and not scr.b_upgrade.visible, "fehlende Karte: nur Herstellen")
dust = p.dust
await click(scr.b_craft)
check(p.owns("f_5_5") and p.dust == dust - Profile.CRAFT_COST, "hergestellt")
await shot("20_sammlung")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
@@ -489,7 +548,9 @@ func test_profil_zuruecksetzen() -> void:
check(not scr.confirm.visible, "abgebrochen")
await click(find_button("Profil zurücksetzen"))
await click(find_button("Ja, zurücksetzen"))
check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.is_empty(), "Profil zurückgesetzt")
check(screen() is StartDeckScreen and main.profile.coins == Profile.START_COINS and not main.profile.started(), "Profil zurückgesetzt: Startdeck neu wählen")
await click(find_button("Luft wählen"))
check(screen() is MenuScreen and main.profile.deck.filter(func(id): return Cards.by_id(id).elements[0] == "Luft").size() == 25, "Luft gewählt")
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
@@ -526,26 +587,26 @@ func test_kampf_steuerung() -> void:
await wait_for_human(scr)
var g: Game = scr.game
g.human.energy = 20 # die Starthand ist zufällig, hier soll nur die Steuerung geprüft werden
g.human.hand = [Cards.by_id("f_wolf").copy()]
g.human.hand = [Cards.by_id("f_3_2").copy()]
scr.refresh()
check(scr.hand_views.size() == 1 and scr.draw_enabled and scr.offer_views.size() == Game.OFFER, "eine Handkarte, drei offene auf dem Stapel")
# Ziehen: die gewählte offene Karte landet auf der Hand, das erste Ziehen ist frei
check(scr.hand_views.size() == 1 and scr.deck.enabled, "eine Handkarte, Stapel bereit")
# Ziehen: eine Karte vom eigenen Stapel, das erste Ziehen ist frei
var mana: int = g.human.energy
var wanted: Cards.Card = g.human.offer[1]
await click(scr.offer_views[1])
check(g.human.energy == mana and g.human.hand.size() == 2 and scr.hand_views.size() == 2 and g.human.hand[1].id == wanted.id, "gezogen: die gewählte Karte, frei")
check(g.human.offer.size() == Game.OFFER and g.human.offer.all(func(c): return c.cost <= g.human.max_energy) and g.draw_cost(g.human) == Game.DRAW_COST, "eine bezahlbare neue liegt nach, das nächste Ziehen kostet 2")
var deck_before: int = g.human.deck.size()
await click(scr.deck)
check(g.human.energy == mana and g.human.hand.size() == 2 and scr.hand_views.size() == 2 and g.human.deck.size() == deck_before - 1, "gezogen: eine Karte, frei")
check(g.draw_cost(g.human) == 1 and scr.deck.cost == 1, "das nächste Ziehen kostet 1")
# Ausspielen kostet die Kartenkosten; das Wesen greift noch nicht an
var board_before: int = g.human.board.size()
mana = g.human.energy
await key(KEY_1)
check(g.human.board.size() == board_before + 1 and g.human.energy == mana - Cards.by_id("f_wolf").cost, "Wesen per Taste gespielt, Kosten bezahlt")
check(g.human.board.size() == board_before + 1 and g.human.energy == mana - Cards.by_id("f_3_2").cost, "Wesen per Taste gespielt, Kosten bezahlt")
check(not g.human.board.back().can_attack(), "frisch gelegt greift es noch nicht an")
# Rückgängig: Wesen zurück auf die Hand, Mana zurück
check(scr.b_undo.visible, "Rückgängig angeboten")
await click(scr.b_undo)
g = scr.game
check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == "f_wolf") and g.human.energy == mana, "Rückgängig stellt Hand und Mana her")
check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == "f_3_2") and g.human.energy == mana, "Rückgängig stellt Hand und Mana her")
check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht")
await key(KEY_1)
check(g.human.board.size() == board_before + 1, "erneut gespielt")
@@ -553,7 +614,7 @@ func test_kampf_steuerung() -> void:
mana = g.human.energy
var hand_before: int = g.human.hand.size()
await key(KEY_Z)
check(g.human.energy == mana - Game.DRAW_COST and g.human.hand.size() == hand_before + 1, "Z zieht die erste offene Karte für 2")
check(g.human.energy == mana - 1 and g.human.hand.size() == hand_before + 1, "Z zieht, das zweite Ziehen für 1")
await shot("22_kampf_steuerung")
# Gamepad: A auf dem Stapel zieht
await pad(JOY_BUTTON_DPAD_LEFT)
@@ -565,7 +626,7 @@ func test_kampf_steuerung() -> void:
await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
await frames(2)
check(scr.b_skip.visible and not scr.b_end.visible, "Überspringen-Knopf während des Gegnerzugs")
check(scr.b_skip.visible, "Überspringen-Knopf während des Gegnerzugs")
await click(scr.b_skip)
var w := 0
while g.current() != g.human and g.winner == "" and w < 120:
@@ -574,9 +635,15 @@ func test_kampf_steuerung() -> void:
check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w)
scr.ai_delay = 0.0
await key(KEY_ESCAPE)
check(scr.confirm.visible, "Esc öffnet Aufgeben")
check(scr.options.visible and find_button("Hauptmenü", scr.options) != null and find_button("Beenden", scr.options) != null, "Esc öffnet die Optionen")
await key(KEY_ESCAPE)
check(not scr.confirm.visible, "Esc schließt den Dialog")
check(not scr.options.visible, "Esc schließt die Optionen")
# Einstellungen aus dem Kampf und zurück
await key(KEY_ESCAPE)
await click(find_button("Einstellungen", scr.options))
check(screen() is SettingsScreen and is_instance_valid(scr), "Einstellungen geöffnet, der Kampf bleibt erhalten")
await click(find_button("Zurück"))
check(screen() == scr and scr.game == g and not scr.options.visible, "zurück im selben Kampf")
await give_up()
@@ -594,21 +661,21 @@ func test_kampf_grenzen() -> void:
var guard := 0
while g.human.hand.size() < Game.MAX_HAND and guard < 30:
guard += 1
await click(scr.offer_views[0])
await click(scr.deck)
check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND)
check(not scr.draw_enabled, "bei voller Hand lässt sich nichts ziehen")
await click(scr.offer_views[0])
check(not scr.deck.enabled, "bei voller Hand lässt sich nichts ziehen")
await click(scr.deck)
check(scr.msg_label.text == tr("Deine Hand ist voll"), "Meldung bei voller Hand: " + scr.msg_label.text)
# Feld voll spielen
guard = 0
while g.human.board.size() < Game.MAX_BOARD and guard < 30:
guard += 1
if g.human.hand.is_empty():
await click(scr.offer_views[0])
await click(scr.deck)
await key(KEY_1)
check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD)
if g.human.hand.is_empty():
await click(scr.offer_views[0])
await click(scr.deck)
await key(KEY_1)
check(scr.msg_label.text == tr("Dein Feld ist voll"), "Meldung bei vollem Feld: " + scr.msg_label.text)
await shot("25_kampf_voll")
@@ -619,10 +686,10 @@ func test_kampf_grenzen() -> void:
check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei")
# Ohne Mana kein Ziehen
g.human.energy = 0
g.human.drew = true
g.human.draws = 1
scr.refresh()
await frames(1)
check(not scr.draw_enabled, "ohne Mana kein zweites Ziehen")
check(not scr.deck.enabled, "ohne Mana kein zweites Ziehen")
await give_up()
@@ -646,27 +713,27 @@ func test_kampf_elemente_und_verschmelzen() -> void:
cr.hidden = hidden
p.board.append(cr)
return cr
var wolf_cr: Game.Creature = put.call(g.human, "f_wolf")
wolf_cr.health = 4 # übersteht die Konter von Falke und Kobold, damit der Test ihn weiter nutzen kann
var wolf_cr: Game.Creature = put.call(g.human, "f_3_2")
wolf_cr.health = 4 # übersteht die Konter von Falke und Funke, damit der Test ihn weiter nutzen kann
wolf_cr.max_health = 4
put.call(g.human, "f_asche")
put.call(g.human, "w_reiter")
var falke: Game.Creature = put.call(scr.opponent(), "l_falke")
var secret: Game.Creature = put.call(scr.opponent(), "f_kobold", true)
g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()]
put.call(g.human, "f_4_3")
put.call(g.human, "w_3_3")
var falke: Game.Creature = put.call(scr.opponent(), "l_3_1")
var secret: Game.Creature = put.call(scr.opponent(), "f_2_1", true)
g.human.hand = [Cards.by_id("f_2_1").copy(), Cards.by_id("l_1_1").copy()]
g.human.energy = 20
g.human.looks = {"w_reiter": 3}
g.human.looks = {"w_3_3": 3}
g.apply_auras()
scr.refresh()
await frames(2)
check(scr.element_view.text() == "Feuer 2/0 (Doppelschlag) · Wasser 1/0 · Erde 0/0 · Luft 0/0", "Zähler: offene eigene Wesen und ausgespielte Karten, Feuerpaar: " + scr.element_view.text())
check(scr.opp_element_view.text() == "Feuer 0/0 · Wasser 0/0 · Erde 0/0 · Luft 1/0", "die Leiste des Gegners zählt nur seine offenen Wesen: " + scr.opp_element_view.text())
check(scr.element_view.text() == "Feuer 2/0 (Doppelschlag) · Wasser 1/0", "Zähler: nur Elemente mit Wesen oder gespielten Karten, Feuerpaar: " + scr.element_view.text())
check(scr.opp_element_view.text() == "Luft 1/0", "die Liste des Gegners zählt nur seine offenen Wesen: " + scr.opp_element_view.text())
check(g.human.board[0].keywords() == ["Doppelschlag"] and scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "Feuerpaar gibt Doppelschlag; fremdes Verdecktes zeigt den Rücken; noch kein Bonus in Sicht")
# Rechtsklick auf die Handkarte: Menü mit Verdeckt legen, ohne Verschmelzen
await hover(scr.hand_views[0])
await right_click_at(scr.hand_views[0].get_global_rect().get_center())
check(scr.menu_layer.visible and find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and find_button("Abwerfen +1 ◆", scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü mit Verdeckt legen und Abwerfen")
check(find_button("Verschmelzen mit … ◆ 1", scr.menu_layer) == null, "kein Verschmelzen im Rechtsklick-Menü")
check(find_button("Verschmelzen mit …", scr.menu_layer) == null, "kein Verschmelzen im Rechtsklick-Menü")
await shot("23_kartenmenue")
await right_click()
check(not scr.menu_layer.visible, "Rechtsklick schließt das Menü")
@@ -685,16 +752,16 @@ func test_kampf_elemente_und_verschmelzen() -> void:
while scr.own_views[3].busy:
await frames(1)
await click(scr.own_views[3])
check(mine_hidden.hidden and scr.msg_label.text == "Feuerkobold kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text)
check(mine_hidden.hidden and scr.msg_label.text == "Lodernder Funke kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text)
mine_hidden.ready = true
await click(scr.own_views[3])
check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3/1 (Doppelschlag)"), "aufgedeckt: drei offene Feuer, eine ausgespielt: " + scr.element_view.text())
check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "unter 10 Mana kein Bonus, aufgedeckt greift sofort an")
g.human.played["Feuer"] = 10
check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "unter fünf Karten kein Bonus, aufgedeckt greift sofort an")
g.human.played["Feuer"] = 5
g.apply_auras()
scr.refresh()
await frames(1)
check(scr.element_view.text().begins_with("Feuer 3/10 (Doppelschlag) (+1)") and wolf.power() == 4 and falke.power() == 3, "10 Mana Feuer: +1 Angriff für die eigenen Feuerwesen: " + scr.element_view.text())
check(scr.element_view.text().begins_with("Feuer 3/5 (Doppelschlag) (+1/+1)") and wolf.power() == 4 and falke.power() == 3, "fünf Feuerkarten: +1/+1 für die eigenen Feuerwesen: " + scr.element_view.text())
check(scr.hand_views[0].bonus == 0, "die Luftkarte auf der Hand zeigt keinen Bonus")
await shot("23b_vier_feuer")
# Angriffsziel: Elementvorteil, verdeckte ohne Anzeige, Held gesperrt
@@ -714,220 +781,26 @@ func test_kampf_elemente_und_verschmelzen() -> void:
mana = g.human.energy
var before: int = g.human.board.size()
await drag_fuse(scr, scr.own_views[1], scr.own_views[2])
check(g.human.board.size() == before - 1 and g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt")
check(g.human.board.size() == before - 1 and g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 3, "zu Dampf verschmolzen, 3 Mana bezahlt (halbe Manasumme)")
var dampf: Game.Creature = g.human.board.filter(func(c): return c.name == "Dampf")[0]
check(scr.tiles[dampf.uid].rarity() == "Ultra Rare" and dampf.card.look == 3, "die Fusion trägt die höhere Stufe (Wellenreiter war Ultra Rare)")
check(scr.tiles[dampf.uid].rarity() == "Ultra Rare" and dampf.card.look == 3, "die Fusion trägt die höhere Stufe (der Reiter war Ultra Rare)")
await shot("24_verschmelzen")
# Verschmelzen auf der Hand per Ziehen
g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()]
g.human.hand = [Cards.by_id("f_2_1").copy(), Cards.by_id("f_2_1").copy(), Cards.by_id("l_1_1").copy()]
scr.refresh()
await frames(1)
await drag_fuse(scr, scr.hand_views[0], scr.hand_views[1])
check(g.human.hand.size() == 2 and g.human.hand[0].name == "Inferno" and g.human.hand[0].attack == 3 and g.human.hand[0].health == 2, "zwei Kobolde auf der Hand zu Inferno 3/2")
check(g.human.hand.size() == 2 and g.human.hand[0].name == "Inferno" and g.human.hand[0].attack == 3 and g.human.hand[0].health == 2, "zwei Funken auf der Hand zu Inferno 3/2")
# Gamepad-Menü (Y) bietet Verdeckt legen und Verschmelzen mit …
scr.open_menu(g.human.hand[0], Rect2(scr.hand_views[0].position, scr.hand_views[0].size), true)
check(find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and g.human.hand[0].cost == 2, "Gamepad-Menü: Verdeckt legen, Inferno kostet 2")
await click(find_button("Verschmelzen mit … ◆ 2", scr.menu_layer))
await click(find_button("Verschmelzen mit …", scr.menu_layer))
check(scr.fuse_first == g.human.hand[0], "Partner wählen")
await click(scr.hand_views[1])
check(g.human.hand.size() == 1 and g.human.hand[0].name == "Blitz", "Inferno und Windgeist zu Blitz")
await give_up()
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")
if FileAccess.file_exists("user://bot_quit"):
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://bot_quit"))
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 < 600:
await frames(1)
wait += 1
if main.net.names.size() < 2:
print("HINWEIS: Bot kam nicht, zweiter Versuch")
OS.kill(pid)
pid = OS.create_process(OS.get_executable_path(), args)
while main.net.names.size() < 2 and wait < 1500:
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 < 2:
if b.game.current() == b.perspective:
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
if b.game.winner != "":
print("DEBUG Netz: Sieger=", b.game.winner, " Verlauf=", b.game.log.slice(maxi(0, b.game.log.size() - 12)))
check(b.game.turn >= 2 and b.game.winner == "", "Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
check(b.game.log.any(func(l): return l.begins_with("Bot kauft") or l.begins_with("Bot spielt")), "Bot hat gehandelt")
await shot("24_netzwerk_kampf")
# Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen
var quit_file := "user://bot_quit"
var f := FileAccess.open(quit_file, FileAccess.WRITE)
f.store_string("x")
f.close()
var w3 := 0
while main.net.waiting.is_empty() and w3 < 600:
await frames(1)
w3 += 1
check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten")
var turn_before: int = b.game.turn
for i in 2:
if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end)
var w4 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w4 < 300:
await frames(1)
w4 += 1
check(b.game.turn >= turn_before + 1 and b.game.log.any(func(l): return "übersprungen" in l), "Züge des fehlenden Spielers werden übersprungen")
DirAccess.remove_absolute(ProjectSettings.globalize_path(quit_file))
await frames(5)
var pid2 := OS.create_process(OS.get_executable_path(), args)
var w5 := 0
while not main.net.waiting.is_empty() and w5 < 900:
await frames(1)
w5 += 1
check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück")
var turn2: int = b.game.turn
for i in 2:
if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end)
var w6 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w6 < 900:
await frames(1)
w6 += 1
check(b.game.turn >= turn2 + 1, "Partie läuft nach der Rückkehr weiter")
OS.kill(pid2)
await leave_battle()
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
func _bot_args(extra: Array) -> Array:
return ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--"] + extra
func _bot_quit(on: bool) -> void:
var path := "user://bot_quit"
if on:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("x")
f.close()
elif FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## Verbindet sich mit dem Bot-Host (mehrere Versuche, bis er lauscht).
func _join_bot_host(watch: bool) -> void:
await click(find_button("Netzwerkspiel"))
var lobby = screen()
lobby.ip_box.text = "127.0.0.1"
for attempt in 8:
await click(find_button("Zuschauen" if watch else "Beitreten"))
var w := 0
while not main.net.connected and w < 180:
await frames(1)
w += 1
if main.net.connected:
return
main.net.leave()
await frames(30)
## Der Host (ein Bot) verschwindet: der einzige Mitspieler übernimmt und spielt zu Ende.
func test_host_wechsel() -> void:
_bot_quit(false)
var pid := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
check(pid > 0, "Bot-Host gestartet")
await frames(30)
await _join_bot_host(false)
var w := 0
while not (screen() is BattleScreen) and w < 900:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.is_client(), "als Mitspieler beim Bot-Host im Kampf")
w = 0
while (b.game == null or main.net.backup.is_empty()) and w < 600:
await frames(1)
w += 1
check(b.game != null and main.net.i_am_successor and not main.net.backup.is_empty(), "Nachfolger kennt den vollen Spielstand")
main.net.rejoin_seconds = 3.0
_bot_quit(true)
w = 0
while not main.net.is_host and w < 900:
await frames(1)
w += 1
check(main.net.is_host and b.game == main.net.game and screen() == b and b.game.log.any(func(l): return "übernimmt als Host" in l),
"Host-Wechsel: Mitspieler führt das Spiel weiter")
check(main.net.waiting.has("BotHost"), "Platz des alten Hosts bleibt zunächst frei")
await shot("34_host_wechsel")
# Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus
b.ai_delay = 0.0
if b.game.current() == b.perspective:
await click(b.b_end)
w = 0
while b.game.winner == "" and w < 900:
await frames(1)
w += 1
check(b.game.log.any(func(l): return "übersprungen" in l), "Zug des fehlenden Hosts übersprungen")
check(b.game.winner != "" and b.game.winner_player == b.perspective and b.game.log.any(func(l): return "kehrt nicht zurück" in l),
"nach der Frist scheidet der alte Host aus, Partie gewonnen (Sieger '%s', Zug %d)" % [b.game.winner, b.game.turn])
await leave_battle()
_bot_quit(false)
check(screen() is MenuScreen and not main.net.connected, "nach dem Host-Wechsel zurück im Menü")
## Zuschauer: sieht die Partie zweier Bots ohne Hände und ohne eigene Züge.
func test_zuschauer() -> void:
_bot_quit(false)
var pid_h := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
await frames(30)
var pid_c := OS.create_process(OS.get_executable_path(), _bot_args(["--ip=127.0.0.1", "--name=Bot"]))
check(pid_h > 0 and pid_c > 0, "zwei Bots gestartet")
await _join_bot_host(true)
var w := 0
while not (screen() is BattleScreen) and w < 1200:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.spectating(), "als Zuschauer im Kampf")
w = 0
while (b.game == null or b.game.turn < 2) and w < 900:
await frames(1)
w += 1
check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.b_end.visible,
"Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0))
await shot("35_zuschauer")
check(not b.b_end.visible and not b.b_undo.visible, "Zuschauer hat keine Zugknöpfe")
if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein
await leave_battle()
else:
var w2 := 0
while not (screen() is MenuScreen) and w2 < 300:
await frames(1)
w2 += 1
check(screen() is MenuScreen and not main.net.connected, "Zuschauer zurück im Menü")
_bot_quit(true)
await frames(60)
_bot_quit(false)
func test_kampagne() -> void:
await click(find_button("Kampagne"))
var scr = screen()
@@ -978,7 +851,7 @@ func test_kampf(shot_turn: int) -> void:
scr.smart_ai = false
g.opponent.life = 1
for i in 3:
var drache := Game.Creature.new(Cards.by_id("f_drache").copy(), g.human, 9100 + i)
var drache := Game.Creature.new(Cards.by_id("f_5_5").copy(), g.human, 9100 + i)
drache.ready = true
g.human.board.append(drache)
scr.refresh()
@@ -1015,13 +888,13 @@ func test_kampf_abbrechen() -> void:
check(g.opponent != null and g.human.max_energy == Game.START_MANA, "Duell läuft")
# Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit
g.human.energy = 10
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
var wolf := Game.Creature.new(Cards.by_id("f_3_2"), g.human, 9000)
wolf.ready = true
g.human.board.append(wolf)
g.human.hand.append(Cards.by_id("w_reiter"))
g.human.looks["w_reiter"] = Cards.RARITIES.find("Super Rare")
g.human.hand.append(Cards.by_id("w_3_3"))
g.human.looks["w_3_3"] = Cards.RARITIES.find("Super Rare")
scr.refresh()
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_3_3")[0]
check(reiter_view.rarity == "Super Rare", "Handkarte trägt die eigene Stufe")
await hover(scr.element_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(reiter_view)
@@ -1037,13 +910,14 @@ func test_kampf_abbrechen() -> void:
print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(),
" dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control())
check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
await click(scr.b_log)
await key(KEY_ESCAPE)
await click(find_button("Verlauf", scr.options))
check(scr.log_overlay.visible and scr.any_overlay(), "Verlauf öffnet als Überlagerung")
await key(KEY_ESCAPE)
check(not scr.log_overlay.visible, "Esc schließt den Verlauf")
# Runden laufen lassen, damit die Gegner ihre Züge machen
for round in 4:
await click(scr.b_end)
await key(KEY_SPACE)
var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1)

View File

@@ -3,7 +3,7 @@ Dinger,Dinger
Ein Kartenspiel: Duell um das Feld,A card game: duel for the field
Einzelspieler,Single player
Kampagne,Campaign
Netzwerkspiel,Network game
Deck,Deck
Booster öffnen,Open boosters
Einstellungen,Settings
Beenden,Quit
@@ -81,7 +81,7 @@ Starten,Start
Sammlung,Collection
Verschmelzen,Fuse
Nur eigene Wesen,Only your own creatures
Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d
Sammlung: %d von %d Karten · Kampagne: %d/%d,Collection: %d of %d cards · Campaign: %d/%d
%s ist jetzt %s,%s is now %s
Alle Kapitel abgeschlossen,All chapters completed
Elemente,Elements
@@ -141,7 +141,7 @@ Muss zuerst angegriffen werden,Must be attacked first
Greift sofort an,Attacks right away
Heilt sich am Zugende ganz,Heals fully at the end of the turn
Wesen greifen sofort an,Creatures attack right away
Verschmelzen mit … ◆ %d,Fuse with … ◆ %d
Verschmelzen mit …,Fuse with …
Ziel wählen,Pick the partner
Erst die Wesen mit Spott,Taunting creatures first
%s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn
@@ -153,3 +153,17 @@ stirbt,dies
Abwerfen +%d ◆,Discard +%d ◆
Überspringen,Skip
Zug %d,Turn %d
Stapel: %d Karten (%d bis %d),Deck: %d cards (%d to %d)
Herstellen (%d Staub),Craft (%d dust)
Nicht in der Sammlung,Not in the collection
%s hergestellt,%s crafted
Neu!,New!
Auffüllen,Fill up
Leeren,Clear
Dein Stapel ist leer,Your deck is empty
Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters.,Click adds a card to the deck or removes it. Fill up takes the most expensive cards of the filter.
Wähle dein Startdeck,Choose your starting deck
"25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest.","25 cards of the element plus five of each other one. Boosters and the workshop bring the rest later."
%s wählen,Choose %s
Optionen,Options
Hauptmenü,Main menu
1 keys en
3 Ein Kartenspiel: Duell um das Feld A card game: duel for the field
4 Einzelspieler Single player
5 Kampagne Campaign
6 Netzwerkspiel Deck Network game Deck
7 Booster öffnen Open boosters
8 Einstellungen Settings
9 Beenden Quit
81 Sammlung Collection
82 Verschmelzen Fuse
83 Nur eigene Wesen Only your own creatures
84 Aufgewertet: %d von %d Karten · Kampagne: %d/%d Sammlung: %d von %d Karten · Kampagne: %d/%d Upgraded: %d of %d cards · Campaign: %d/%d Collection: %d of %d cards · Campaign: %d/%d
85 %s ist jetzt %s %s is now %s
86 Alle Kapitel abgeschlossen All chapters completed
87 Elemente Elements
141 Greift sofort an Attacks right away
142 Heilt sich am Zugende ganz Heals fully at the end of the turn
143 Wesen greifen sofort an Creatures attack right away
144 Verschmelzen mit … ◆ %d Verschmelzen mit … Fuse with … ◆ %d Fuse with …
145 Ziel wählen Pick the partner
146 Erst die Wesen mit Spott Taunting creatures first
147 %s kann erst im nächsten Zug aufgedeckt werden %s can be revealed next turn
153 Abwerfen +%d ◆ Discard +%d ◆
154 Überspringen Skip
155 Zug %d Turn %d
156 Stapel: %d Karten (%d bis %d) Deck: %d cards (%d to %d)
157 Herstellen (%d Staub) Craft (%d dust)
158 Nicht in der Sammlung Not in the collection
159 %s hergestellt %s crafted
160 Neu! New!
161 Auffüllen Fill up
162 Leeren Clear
163 Dein Stapel ist leer Your deck is empty
164 Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters. Click adds a card to the deck or removes it. Fill up takes the most expensive cards of the filter.
165 Wähle dein Startdeck Choose your starting deck
166 25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest. 25 cards of the element plus five of each other one. Boosters and the workshop bring the rest later.
167 %s wählen Choose %s
168 Optionen Options
169 Hauptmenü Main menu

Binary file not shown.