update
This commit is contained in:
9
.github/workflows/tests.yml
vendored
9
.github/workflows/tests.yml
vendored
@@ -72,10 +72,5 @@ jobs:
|
||||
chmod +x godot
|
||||
- name: Import
|
||||
run: ./godot --headless --path . --import || true
|
||||
- name: Archetypen messen
|
||||
run: ./godot --headless --path . -s tests/strength_probe.gd 2>&1 | grep -E "%|geschrieben"
|
||||
- name: Ergebnis sichern
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: archetypen
|
||||
path: data/archetypen.json
|
||||
- name: Balance messen
|
||||
run: ./godot --headless --path . -s tests/simulate.gd 2>&1 | grep -E "Siege|Dauer"
|
||||
|
||||
79
README.md
79
README.md
@@ -4,75 +4,58 @@ Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle
|
||||
|
||||
## Starten
|
||||
|
||||
Projektordner in Godot öffnen und auf „Play" drücken, oder im Terminal:
|
||||
|
||||
```
|
||||
./start.sh # Linux/macOS (start.bat unter Windows); Godot 4.7 muss als `godot` im Pfad liegen
|
||||
godot --path . # wenn das Projekt schon einmal importiert wurde
|
||||
```
|
||||
|
||||
Frisch geklont fehlt das Verzeichnis `.godot` mit Godots Import-Cache. Die Startskripte holen den Import
|
||||
beim ersten Start nach; alternativ das Projekt einmal im Godot-Editor öffnen oder
|
||||
`godot --headless --path . --import` ausführen. Danach genügt `godot --path .`.
|
||||
|
||||
## Start
|
||||
|
||||
Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal:
|
||||
|
||||
```
|
||||
godot --path .
|
||||
```
|
||||
`godot --headless --path . --import` ausführen.
|
||||
|
||||
Der Spielstand liegt unter `user://spielstand.json`
|
||||
(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`).
|
||||
|
||||
## Spielsysteme
|
||||
|
||||
- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen
|
||||
- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus
|
||||
- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht)
|
||||
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern je Kapitel, Tagesaufgabe
|
||||
- Booster, fünf Deck-Slots, Sammlung (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen)
|
||||
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
|
||||
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten
|
||||
- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand)
|
||||
- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch
|
||||
- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1
|
||||
- Bestenliste (Serie, schnellster Sieg) im Hintergrund: sie steuert den Schwierigkeitstipp
|
||||
- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei)
|
||||
- Deckstärke aus Seltenheit, Schlüsselwörtern und Effekten; Gegnerdecks werden auf die eigene Stärke gebaut
|
||||
- Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
|
||||
- Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen
|
||||
- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor
|
||||
- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm
|
||||
- Gegnervorschau mit höchstens drei Neuwürfen, Schwierigkeitstipp nach Spielverlauf (deutliche Siege oder drei Niederlagen)
|
||||
- KI mit Zeitbudget je Entscheidung (150 ms), Warnung bei ungespeichertem Deck
|
||||
- Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie)
|
||||
- KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso
|
||||
- KI-Bewertung mit Bedrohung (gegnerische Angriffskraft ohne eigenen Wächter, tödliche besonders) und Führenden-Bremse; im Duell 62:38 gegen die alte Bewertung
|
||||
- 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos entstehen erst im Spiel (Verschmelzen, Feldkombos)
|
||||
- Kein Deck und keine Starthand: jeder hat einen eigenen Laden mit fünf Angeboten. Kaufen kostet Mana, Ausspielen nichts, neu würfeln kostet 1 Mana
|
||||
- Mana wächst um 1 je Zug (bis 10) und bleibt liegen, wenn du es nicht ausgibst — sparen ist eine echte Entscheidung
|
||||
- Der Laden zeigt nur Karten, die höchstens dein Einkommen plus eins kosten: teure Wesen tauchen erst später auf
|
||||
- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik: sie ändert weder Werte noch Text
|
||||
- Booster (100 Münzen): fünf gewürfelte Stufen, höhere werten die Karte auf, der Rest wird zu Staub. Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück
|
||||
- Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3)
|
||||
- Feldkombos: ein neues Wesen neben einem Wesen mit anderem Element löst einen Effekt aus
|
||||
- Karteneffekte lösen beim Ausspielen aus (Schaden, Einfrieren, Heilen, Zurückwerfen, Verstärken, Schild, Mana); Kartenvorteil ist hier Manavorteil
|
||||
- Elementvorteil im Kreis (Wasser > Feuer > Luft > Erde > Wasser): 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, auch für Karteneffekte gegen Wesen
|
||||
- Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff
|
||||
- Verschmelzen: zwei eigene Wesen werden eins (Elemente und Schlüsselwörter beider, Werte Durchschnitt plus 1, Kosten der Durchschnitt der Karten); drei oder mehr Elemente ergeben eine Chimäre
|
||||
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner; die Hand fasst 8 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)
|
||||
- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne) werden laufend gespeichert und lassen sich im Menü fortsetzen
|
||||
- Gamepad im Deckbau, in der Sammlung und beim Booster: Karten, Deckzeilen und Kartenrücken sind fokussierbar, A wählt, B geht zurück
|
||||
- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`)
|
||||
- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau
|
||||
- Kapitel 3 der Kampagne (drei Merkmale, volle Tische), acht neue Karten (je Elementpaar eine, zwei Fallen), Elementbooster für 120 Münzen
|
||||
- Netzwerk: fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung (er bekommt laufend den vollen Spielstand), die anderen verbinden neu; Zuschauer sehen die Partie ohne Hände
|
||||
- Balance in drei Messrunden (je 200 Partien pro Archetyp): Sand gibt +1/+1 statt Schild, Wasser und Feuer zäher, Lava-Karten leicht schwächer; Spanne jetzt 40–64 % statt 36–78 % (Feuer+Erde bleibt mit 64 % die Spitze)
|
||||
- Große Schrift: Kartennamen neben dem Kostenkreis, Kartentext läuft nicht mehr über, breitere Knöpfe in Booster, Deckbau und Sammlung
|
||||
- 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
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`:
|
||||
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test und Archetypen-Messung nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
|
||||
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
|
||||
`DINGER_FAST=1` lässt den Fenster-Test ohne die langen Kämpfe laufen (etwa ein Drittel der Zeit).
|
||||
|
||||
```
|
||||
godot --headless --path . --import # einmalig nach dem Klonen: Ressourcen importieren (macht start.sh auch)
|
||||
godot --headless --path . --import # einmalig nach dem Klonen: Ressourcen importieren
|
||||
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
|
||||
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
|
||||
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
|
||||
godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH)
|
||||
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
|
||||
DINGER_ONLY=test_deckbau godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
|
||||
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)
|
||||
N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp
|
||||
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik
|
||||
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Ladenangebot, Musik
|
||||
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
|
||||
```
|
||||
|
||||
@@ -88,11 +71,11 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
|
||||
## Aufbau
|
||||
|
||||
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
|
||||
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln
|
||||
- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen
|
||||
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Feldkombos, Resonanz, Feld, Verschmelzen
|
||||
- `scripts/deckgen.gd` – Ladenangebot und Gegner
|
||||
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
|
||||
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
|
||||
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Tagesaufgabe, Einstellungen
|
||||
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, Einstellungen
|
||||
- `scripts/campaign.gd` – Kampagnenstufen
|
||||
- `scenes/main.tscn` – Startszene
|
||||
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
# Dinger
|
||||
# Cards
|
||||
##
|
||||
|
||||
|
||||
## Grundidee
|
||||
- Kartenspiel wie zum Beispiel Magic
|
||||
- PC-Spiel
|
||||
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
|
||||
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
|
||||
- Kampagne gegen benannte Magier und Tagesaufgaben als Fortschritt
|
||||
- Kampagne gegen benannte Magier als Fortschritt
|
||||
- Im lokalen Netz zu mehreren spielbar, auch als Zuschauer
|
||||
- Ein unterbrochener Kampf lässt sich später fortsetzen
|
||||
- Bestenliste als Langzeitziel
|
||||
|
||||
## Sammeln
|
||||
- Booster öffnen
|
||||
- Karten haben Seltenheiten
|
||||
- Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt
|
||||
- Überschüssige Karten lassen sich zerlegen und neue herstellen
|
||||
- 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, und die ist reine Optik (auch im Netzwerkspiel sichtbar)
|
||||
- Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub
|
||||
- Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück
|
||||
- Kein Deckbau und kein Nachziehstapel: fünf Angebote im Laden, Kaufen kostet Mana, Ausspielen nichts
|
||||
- Nicht ausgegebenes Mana bleibt liegen, neu würfeln kostet 1 Mana (wie in TFT)
|
||||
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
|
||||
|
||||
## Elemente
|
||||
- Karten gehören zu Elementen
|
||||
- Elemente sind kombinierbar, daraus entstehen viele Varianten
|
||||
- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer)
|
||||
- Jedes Element und jede Kombo hat eine eigene Spielweise
|
||||
- Jede Karte gehört zu genau einem Element
|
||||
- Kombos entstehen erst im Spiel: beim Verschmelzen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) und als Feldkombo
|
||||
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus
|
||||
- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug
|
||||
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (1 Schaden mehr im Vorteil, 1 weniger im Nachteil; Mehrfachelemente nehmen den für sie besseren Wert)
|
||||
- Das Feld gehört allen am Tisch: vier gespielte Karten eines Elements kippen es, Wesen dieses Elements bekommen +1 Angriff
|
||||
- Zwei eigene Wesen lassen sich für Energie verschmelzen: das neue erbt Elemente und Fähigkeiten beider, seine Werte sind der Durchschnitt plus 1
|
||||
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"Erde": 50,
|
||||
"Erde+Luft": 56,
|
||||
"Feuer": 40,
|
||||
"Feuer+Erde": 64,
|
||||
"Feuer+Luft": 42,
|
||||
"Feuer+Wasser": 57,
|
||||
"Luft": 45,
|
||||
"Wasser": 43,
|
||||
"Wasser+Erde": 50,
|
||||
"Wasser+Luft": 41
|
||||
}
|
||||
1075
data/karten.json
1075
data/karten.json
File diff suppressed because it is too large
Load Diff
@@ -30,13 +30,21 @@ static func board_value(p: Game.Player) -> float:
|
||||
return v
|
||||
|
||||
|
||||
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner
|
||||
## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen).
|
||||
## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
|
||||
## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold.
|
||||
static func hand_value(p: Game.Player) -> float:
|
||||
var v := 0.0
|
||||
for c in p.hand:
|
||||
v += 0.15 * (c.attack + c.health)
|
||||
return v
|
||||
|
||||
|
||||
## Stellung aus Sicht von p: eigenes Leben, Feld, Hand und übriges Mana zählen positiv,
|
||||
## die der Gegner negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden
|
||||
## bremsen). Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
|
||||
static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
if not p.alive():
|
||||
return -10000.0
|
||||
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
|
||||
var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.02
|
||||
var alive_enemies := 0
|
||||
var threat := 0.0
|
||||
var leader := 0.0
|
||||
@@ -117,6 +125,18 @@ static func _top(list: Array, rank: Callable, n: int) -> Array:
|
||||
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
|
||||
var out := []
|
||||
var valid_cache := {}
|
||||
var afford := false
|
||||
var seen := {}
|
||||
for i in p.shop.size():
|
||||
var offer = p.shop[i]
|
||||
if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND:
|
||||
afford = true
|
||||
if not seen.has(offer.id): # dasselbe Angebot zweimal zu prüfen bringt nichts
|
||||
seen[offer.id] = true
|
||||
out.append({"type": "buy", "slot": i, "card": offer})
|
||||
# Neu würfeln lohnt nur, wenn ein Laden da ist und nichts Bezahlbares darin liegt.
|
||||
if not afford and not p.shop.is_empty() and p.energy >= Game.REROLL_COST and p.hand.size() < Game.MAX_HAND:
|
||||
out.append({"type": "reroll"})
|
||||
for card in p.hand:
|
||||
if not game.can_play(p, card)[0]:
|
||||
continue
|
||||
@@ -144,6 +164,14 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -
|
||||
valid = valid_cache[key]
|
||||
for t in valid:
|
||||
out.append({"type": "play", "card": card, "target": t})
|
||||
var pairs := []
|
||||
for i in p.board.size():
|
||||
for j in range(i + 1, p.board.size()):
|
||||
if game.can_fuse(p, p.board[i], p.board[j])[0]:
|
||||
pairs.append({"type": "fuse", "creature": p.board[i], "other": p.board[j]})
|
||||
if prune: # Verschmelzen lohnt vor allem bei schwachen Wesen
|
||||
pairs = _top(pairs, func(a): return -float(_value(a.creature) + _value(a.other)), MAX_TARGETS)
|
||||
out.append_array(pairs)
|
||||
for cr in p.board:
|
||||
if not cr.can_attack():
|
||||
continue
|
||||
@@ -165,10 +193,17 @@ static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
|
||||
var target = null
|
||||
if action.get("target") != null:
|
||||
target = map_target(g2, game, action.target)
|
||||
if action.type == "play":
|
||||
g2.play_card(p2, action.card, target)
|
||||
else:
|
||||
g2.attack(g2.find_creature(action.creature.uid), target)
|
||||
match action.type:
|
||||
"play":
|
||||
g2.play_card(p2, action.card, target)
|
||||
"buy":
|
||||
g2.buy(p2, int(action.slot))
|
||||
"reroll":
|
||||
g2.reroll(p2)
|
||||
"fuse":
|
||||
g2.fuse(p2, g2.find_creature(action.creature.uid), g2.find_creature(action.other.uid))
|
||||
_:
|
||||
g2.attack(g2.find_creature(action.creature.uid), target)
|
||||
|
||||
|
||||
static func map_target(g2: Game, game: Game, t):
|
||||
@@ -182,31 +217,26 @@ static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary
|
||||
var out := action.duplicate()
|
||||
if action.has("creature"):
|
||||
out["creature"] = game.find_creature(action.creature.uid)
|
||||
if action.has("other"):
|
||||
out["other"] = game.find_creature(action.other.uid)
|
||||
if action.get("target") != null:
|
||||
out["target"] = map_target(game, copy, action.target)
|
||||
return out
|
||||
|
||||
|
||||
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
|
||||
static func mulligan_cards(p: Game.Player) -> Array:
|
||||
return p.hand.filter(func(c): return c.cost >= 5)
|
||||
|
||||
|
||||
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
|
||||
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
|
||||
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
|
||||
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||
return {"type": "end"}
|
||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var base := evaluate(game, p) + 0.01
|
||||
var firsts := [] # {action, game, score}
|
||||
for action in candidate_actions(game, p, true):
|
||||
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
break
|
||||
var g2 := game.clone_for(p)
|
||||
var g2 := game.clone()
|
||||
apply_on_clone(g2, game, action)
|
||||
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])})
|
||||
firsts.sort_custom(func(a, b): return a.score > b.score)
|
||||
@@ -223,7 +253,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
||||
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
break
|
||||
count += 1
|
||||
var g3 := g2.clone_for(p2)
|
||||
var g3 := g2.clone()
|
||||
apply_on_clone(g3, g2, nxt)
|
||||
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
|
||||
if score > best_score:
|
||||
@@ -240,7 +270,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
||||
# --- Gierige Vergleichs-KI -------------------------------------------------------
|
||||
|
||||
static func _kills(attacker, defender) -> bool:
|
||||
return not defender.shield and defender.health <= attacker.power()
|
||||
return not defender.shield and defender.health <= attacker.power() + Game.matchup_bonus(attacker, defender)
|
||||
|
||||
|
||||
static func _value(c) -> int:
|
||||
@@ -276,8 +306,15 @@ static func _weakest(heroes: Array):
|
||||
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
|
||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||
return {"type": "end"}
|
||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
||||
# Gierig: erst das teuerste Bezahlbare kaufen, dann ausspielen, dann angreifen.
|
||||
var best := -1
|
||||
for k in p.shop.size():
|
||||
var offer = p.shop[k]
|
||||
if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND:
|
||||
if best < 0 or offer.cost > p.shop[best].cost:
|
||||
best = k
|
||||
if best >= 0:
|
||||
return {"type": "buy", "slot": best}
|
||||
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
|
||||
if not playable.is_empty():
|
||||
Game.shuffle(playable, game.rng)
|
||||
@@ -360,10 +397,14 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void
|
||||
match action.type:
|
||||
"play":
|
||||
game.play_card(p, action.card, action.target)
|
||||
"buy":
|
||||
game.buy(p, int(action.slot))
|
||||
"reroll":
|
||||
game.reroll(p)
|
||||
"attack":
|
||||
game.attack(action.creature, action.target)
|
||||
"mulligan":
|
||||
game.mulligan(p, action.cards)
|
||||
"fuse":
|
||||
game.fuse(p, action.creature, action.other)
|
||||
_:
|
||||
game.end_turn()
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ const STAGES := [
|
||||
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"},
|
||||
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"},
|
||||
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"},
|
||||
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "zauber_schaden"},
|
||||
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_karten"},
|
||||
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "wesen_leben"},
|
||||
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_mana"},
|
||||
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"},
|
||||
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"},
|
||||
@@ -18,46 +18,34 @@ const STAGES := [
|
||||
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück.
|
||||
const STAGES2 := [
|
||||
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
|
||||
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_karten"]},
|
||||
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_mana"]},
|
||||
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
|
||||
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
|
||||
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]},
|
||||
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_karten", "flink_angriff"]},
|
||||
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]},
|
||||
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
|
||||
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_mana", "flink_angriff"]},
|
||||
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]},
|
||||
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 500, "perks": ["alle_flink", "start_energie"]},
|
||||
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 500, "perks": ["alle_flink", "start_energie"]},
|
||||
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]},
|
||||
]
|
||||
|
||||
|
||||
## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks.
|
||||
## Kapitel 3: drei Merkmale und volle Tische.
|
||||
const STAGES3 := [
|
||||
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "zauber_schaden"]},
|
||||
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "schild_wesen"]},
|
||||
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
|
||||
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]},
|
||||
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]},
|
||||
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_karten"]},
|
||||
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]},
|
||||
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]},
|
||||
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]},
|
||||
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
|
||||
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 1000, "perks": ["alle_flink", "start_energie", "zauber_schaden"]},
|
||||
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "leben_35"]},
|
||||
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]},
|
||||
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]},
|
||||
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]},
|
||||
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 4, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]},
|
||||
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
|
||||
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
|
||||
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 4, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]},
|
||||
]
|
||||
|
||||
const CHAPTERS := [STAGES, STAGES2, STAGES3]
|
||||
|
||||
# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks.
|
||||
const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6]
|
||||
const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4]
|
||||
const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4]
|
||||
const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3]
|
||||
|
||||
|
||||
static func target_rating(stage_index: int, player_rating: float) -> float:
|
||||
var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()]
|
||||
return maxf(base, player_rating)
|
||||
|
||||
|
||||
static func total() -> int:
|
||||
return STAGES.size() * CHAPTERS.size()
|
||||
|
||||
@@ -75,15 +63,14 @@ static func stage(i: int) -> Dictionary:
|
||||
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
|
||||
|
||||
|
||||
## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist).
|
||||
static func opponents(stage_index: int, r: RandomNumberGenerator, player_rating: float = 0.0) -> Array:
|
||||
## Gegner einer Stufe: der Boss handelt nur mit seinen Elementen, Begleiter mit allen.
|
||||
static func opponents(stage_index: int) -> Array:
|
||||
var st := stage(stage_index)
|
||||
var target := target_rating(stage_index, player_rating)
|
||||
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
|
||||
var boss := Game.Player.new(st.name, Game.build_opponent_deck_rated(r, st.elements, target), false, life, st.title)
|
||||
var boss := Game.Player.new(st.name, false, life, st.title, st.elements)
|
||||
boss.perks = Array(st.perks)
|
||||
var out := [boss]
|
||||
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
|
||||
for k in extra:
|
||||
out.append(Game.random_opponent(r, k + 1, Game.START_LIFE, target))
|
||||
out.append(DeckGen.random_opponent(k + 1))
|
||||
return out
|
||||
|
||||
160
scripts/cards.gd
160
scripts/cards.gd
@@ -8,7 +8,8 @@ const ELEMENT_COLORS := {
|
||||
"Erde": Color("73552d"),
|
||||
"Luft": Color("78aac8"),
|
||||
}
|
||||
# Jede Paarung ist eine Kombo, auch gleiche Elemente.
|
||||
# Jede Karte hat genau ein Element. Kombos entstehen erst im Spiel: beim Verschmelzen
|
||||
# (der Name des neuen Wesens) und als Feldkombo zwischen benachbarten Elementen.
|
||||
const COMBOS := {
|
||||
"Feuer+Feuer": "Inferno",
|
||||
"Wasser+Wasser": "Tiefsee",
|
||||
@@ -20,11 +21,6 @@ const COMBOS := {
|
||||
"Wasser+Erde": "Schlamm",
|
||||
"Wasser+Luft": "Sturm",
|
||||
"Erde+Luft": "Sand",
|
||||
# Dreifach-Kombos: die mythische Stufe
|
||||
"Feuer+Feuer+Feuer": "Sonne",
|
||||
"Wasser+Wasser+Wasser": "Ozean",
|
||||
"Erde+Erde+Erde": "Gebirge",
|
||||
"Luft+Luft+Luft": "Himmel",
|
||||
}
|
||||
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
|
||||
const FIELD_COMBO_TEXT := {
|
||||
@@ -32,10 +28,21 @@ const FIELD_COMBO_TEXT := {
|
||||
"Lava": "1 Schaden an das stärkste gegnerische Wesen",
|
||||
"Blitz": "2 Schaden an den schwächsten Gegner",
|
||||
"Schlamm": "Heile dich um 3",
|
||||
"Sturm": "Ziehe 1 Karte",
|
||||
"Sturm": "Erhalte 1 Mana",
|
||||
"Sand": "Das neue Wesen erhält +1/+1",
|
||||
}
|
||||
const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern."
|
||||
# Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf.
|
||||
const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"}
|
||||
# Das Feld gehört allen am Tisch: jede gespielte Karte lädt ihre Elemente, bei FIELD_CHARGE kippt es.
|
||||
const FIELD_CHARGE := 4
|
||||
const FIELD_NAMES := {"Feuer": "Vulkan", "Wasser": "Flut", "Erde": "Fels", "Luft": "Wirbel"}
|
||||
const FIELD_TEXT := {
|
||||
"Feuer": "Feuerwesen haben +1 Angriff",
|
||||
"Wasser": "Wasserwesen haben +1 Angriff",
|
||||
"Erde": "Erdwesen haben +1 Angriff",
|
||||
"Luft": "Luftwesen haben +1 Angriff",
|
||||
}
|
||||
# Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich.
|
||||
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
|
||||
const RARITY_COLORS := {
|
||||
"gewöhnlich": Color("aaaaaa"),
|
||||
@@ -45,15 +52,7 @@ const RARITY_COLORS := {
|
||||
"mythisch": Color("ff5a8c"),
|
||||
}
|
||||
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"]
|
||||
const KEYWORD_HELP := {
|
||||
"Wächter": "Muss zuerst angegriffen werden.",
|
||||
"Flink": "Kann sofort angreifen.",
|
||||
"Schild": "Blockt den nächsten Schaden.",
|
||||
"Doppelschlag": "Greift zweimal pro Zug an.",
|
||||
}
|
||||
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
|
||||
# Näherung der Kartenstärke: Seltenheit plus Zuschläge für Schlüsselwörter und Effekte.
|
||||
const RARITY_POINTS := {"gewöhnlich": 1.0, "selten": 2.0, "episch": 3.0, "legendär": 4.0, "mythisch": 5.0}
|
||||
const DATA_PATH := "res://data/karten.json"
|
||||
|
||||
|
||||
@@ -63,10 +62,54 @@ static func combo_key(elements: Array) -> String:
|
||||
return "+".join(sorted)
|
||||
|
||||
|
||||
## Name der Elementkombination eines verschmolzenen Wesens; drei oder vier Elemente sind eine Chimäre.
|
||||
static func combo_name(elements: Array) -> String:
|
||||
if elements.size() == 1:
|
||||
return elements[0]
|
||||
return COMBOS[combo_key(elements)]
|
||||
return COMBOS.get(combo_key(elements), "Chimäre")
|
||||
|
||||
|
||||
## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0.
|
||||
## Mehrfachelemente nehmen den für sie besseren Wert.
|
||||
static func matchup(own: Array, other: Array) -> int:
|
||||
for e in own:
|
||||
if BEATS.get(e, "") in other:
|
||||
return 1
|
||||
for e in other:
|
||||
if BEATS.get(e, "") in own:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
## Mana für eine Verschmelzung: Durchschnitt der Kartenkosten, aufgerundet.
|
||||
static func fusion_cost(a: Card, b: Card) -> int:
|
||||
return ceili((a.cost + b.cost) / 2.0)
|
||||
|
||||
|
||||
## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt).
|
||||
static func fusion_name(elements: Array) -> String:
|
||||
return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]])
|
||||
|
||||
|
||||
## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten,
|
||||
## Werte kommen vom Spiel (Durchschnitt plus 1, siehe Game.fuse).
|
||||
static func fused(a: Card, b: Card, attack: int, health: int) -> Card:
|
||||
var els := []
|
||||
for e in a.elements + b.elements:
|
||||
if not (e in els):
|
||||
els.append(e)
|
||||
els.sort_custom(func(x, y): return ELEMENTS.find(x) < ELEMENTS.find(y))
|
||||
var c := Card.new()
|
||||
c.id = "fus_%s_%s" % [a.id, b.id]
|
||||
c.name = fusion_name(els)
|
||||
c.elements = els
|
||||
c.cost = fusion_cost(a, b)
|
||||
c.attack = attack
|
||||
c.health = health
|
||||
for k in KEYWORDS:
|
||||
if k in a.keywords or k in b.keywords:
|
||||
c.keywords.append(k)
|
||||
return c
|
||||
|
||||
|
||||
static func field_combo_effect(name: String) -> Effect:
|
||||
@@ -80,31 +123,23 @@ static func field_combo_effect(name: String) -> Effect:
|
||||
"Schlamm":
|
||||
return Effect.new("heal", "self_hero", 3)
|
||||
"Sturm":
|
||||
return Effect.new("draw", "none", 1)
|
||||
return Effect.new("mana", "none", 1)
|
||||
"Sand":
|
||||
return Effect.new("buff", "self_creature", 1, 1)
|
||||
return null
|
||||
|
||||
|
||||
const TRIGGER_TEXT := {
|
||||
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift",
|
||||
"creature_played": "Wenn ein Gegner ein Wesen ausspielt",
|
||||
}
|
||||
|
||||
|
||||
class Effect extends RefCounted:
|
||||
var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel
|
||||
var kind: String # damage, heal, mana, freeze, bounce, buff, shield
|
||||
var target: String # siehe CHOSEN_TARGETS und Game.resolve()
|
||||
var amount: int
|
||||
var amount2: int
|
||||
var card_id: String # für summon
|
||||
|
||||
func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void:
|
||||
func _init(k: String, t: String, a: int = 0, a2: int = 0) -> void:
|
||||
kind = k
|
||||
target = t
|
||||
amount = a
|
||||
amount2 = a2
|
||||
card_id = cid
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var d := {"kind": kind, "target": target}
|
||||
@@ -112,34 +147,15 @@ class Effect extends RefCounted:
|
||||
d["amount"] = amount
|
||||
if amount2 != 0:
|
||||
d["amount2"] = amount2
|
||||
if card_id != "":
|
||||
d["card_id"] = card_id
|
||||
return d
|
||||
|
||||
static func from_dict(d: Dictionary) -> Effect:
|
||||
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
|
||||
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)))
|
||||
|
||||
## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl).
|
||||
func text(trigger: String = "") -> String:
|
||||
## Kartentext in der aktuellen Sprache.
|
||||
func text() -> String:
|
||||
var n := str(amount)
|
||||
if target == "trigger_source":
|
||||
var played := trigger == "creature_played"
|
||||
match kind:
|
||||
"damage":
|
||||
if played:
|
||||
return tr("%s Schaden an dieses Wesen") % n
|
||||
return tr("%s Schaden an den Angreifer") % n
|
||||
"freeze":
|
||||
if played:
|
||||
return tr("Es wird eingefroren")
|
||||
return tr("Der Angreifer wird eingefroren")
|
||||
"bounce":
|
||||
return tr("Es geht zurück auf die Hand")
|
||||
match kind:
|
||||
"cancel":
|
||||
return tr("Der Angriff verpufft")
|
||||
"summon":
|
||||
return tr("Beschwöre einen %s") % Cards.by_id(card_id).title()
|
||||
"damage":
|
||||
match target:
|
||||
"enemy_creature":
|
||||
@@ -154,10 +170,10 @@ class Effect extends RefCounted:
|
||||
return tr("%s Schaden an alle Gegner und ihre Wesen") % n
|
||||
"heal":
|
||||
return tr("Heile dich um %s") % n
|
||||
"draw":
|
||||
"mana":
|
||||
if amount == 1:
|
||||
return tr("Ziehe 1 Karte")
|
||||
return tr("Ziehe %s Karten") % n
|
||||
return tr("Erhalte 1 Mana")
|
||||
return tr("Erhalte %s Mana") % n
|
||||
"freeze":
|
||||
if target == "enemy_creature":
|
||||
return tr("Friere ein gegnerisches Wesen ein")
|
||||
@@ -188,31 +204,18 @@ class Card extends RefCounted:
|
||||
var name_en := "" # englischer Name
|
||||
var elements: Array
|
||||
var cost: int
|
||||
var rarity: String
|
||||
var kind: String # "Wesen", "Zauber" oder "Falle"
|
||||
var attack: int = 0
|
||||
var health: int = 0
|
||||
var keywords: Array = []
|
||||
var effects: Array = []
|
||||
var trigger: String = "" # nur Fallen: attacked, creature_played
|
||||
|
||||
func is_creature() -> bool:
|
||||
return kind == "Wesen"
|
||||
|
||||
func is_trap() -> bool:
|
||||
return kind == "Falle"
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
|
||||
if is_creature():
|
||||
d["attack"] = attack
|
||||
d["health"] = health
|
||||
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost,
|
||||
"attack": attack, "health": health}
|
||||
if not keywords.is_empty():
|
||||
d["keywords"] = keywords
|
||||
if not effects.is_empty():
|
||||
d["effects"] = effects.map(func(e): return e.to_dict())
|
||||
if trigger != "":
|
||||
d["trigger"] = trigger
|
||||
return d
|
||||
|
||||
static func from_dict(d: Dictionary) -> Card:
|
||||
@@ -222,14 +225,11 @@ class Card extends RefCounted:
|
||||
c.name_en = str(d.get("name_en", ""))
|
||||
c.elements = Array(d.elements)
|
||||
c.cost = int(d.cost)
|
||||
c.rarity = d.rarity
|
||||
c.kind = d.kind
|
||||
c.attack = int(d.get("attack", 0))
|
||||
c.health = int(d.get("health", 0))
|
||||
c.keywords = Array(d.get("keywords", []))
|
||||
for e in d.get("effects", []):
|
||||
c.effects.append(Effect.from_dict(e))
|
||||
c.trigger = str(d.get("trigger", ""))
|
||||
return c
|
||||
|
||||
## Anzeigename in der aktuellen Sprache.
|
||||
@@ -261,14 +261,8 @@ class Card extends RefCounted:
|
||||
if not effects.is_empty():
|
||||
var fx: Array[String] = []
|
||||
for f in effects:
|
||||
fx.append(f.text(trigger))
|
||||
var s := ", ".join(fx) + "."
|
||||
if is_trap():
|
||||
parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s])
|
||||
elif is_creature():
|
||||
parts.append(tr("Beim Ausspielen: %s") % s)
|
||||
else:
|
||||
parts.append(s)
|
||||
fx.append(f.text())
|
||||
parts.append(tr("Beim Ausspielen: %s") % (", ".join(fx) + "."))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -294,14 +288,6 @@ static func has_id(id) -> bool:
|
||||
return _by_id.has(id)
|
||||
|
||||
|
||||
static func card_value(c: Card) -> float:
|
||||
return RARITY_POINTS[c.rarity] + 0.3 * c.keywords.size() + 0.3 * c.effects.size()
|
||||
|
||||
|
||||
static func of_rarity(r: String) -> Array:
|
||||
return all().filter(func(c): return c.rarity == r)
|
||||
|
||||
|
||||
## Lädt den Kartenpool aus data/karten.json.
|
||||
static func _build() -> void:
|
||||
var json := JSON.new()
|
||||
|
||||
@@ -1,142 +1,35 @@
|
||||
class_name DeckGen
|
||||
## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln).
|
||||
## Ladenangebot und Gegner (ausgelagert aus den Spielregeln).
|
||||
## Es gibt keine Decks: jeder kauft jede Runde aus seinem eigenen Laden.
|
||||
|
||||
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
|
||||
# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner).
|
||||
const RESONANCE_STEPS := [0, 1, 2, 3, 3]
|
||||
|
||||
# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, 60 Partien je Archetyp, tests/strength_probe.gd).
|
||||
const ARCHETYPE_STRENGTH := {"Erde": 50, "Erde+Luft": 56, "Feuer": 40, "Feuer+Erde": 64, "Feuer+Luft": 42, "Feuer+Wasser": 57, "Luft": 45, "Wasser": 43, "Wasser+Erde": 50, "Wasser+Luft": 41}
|
||||
|
||||
## Karten, die im Laden auftauchen dürfen: höchstens Einkommen plus eins teuer,
|
||||
## bei gesetzten Elementen nur aus diesen.
|
||||
static func pool(income: int, elements: Array = []) -> Array:
|
||||
var limit := income + 1
|
||||
return Cards.all().filter(func(c):
|
||||
return c.cost <= limit and (elements.is_empty() or c.elements.all(func(e): return e in elements)))
|
||||
|
||||
|
||||
|
||||
const ARCHETYPE_PATH := "res://data/archetypen.json"
|
||||
static var _strength: Dictionary = {}
|
||||
## Angebot des Ladens: n zufällige Karten (Wiederholungen erlaubt, wie in TFT).
|
||||
## Mindestens eine Karte ist vom Einkommen allein bezahlbar, sonst wäre der Zug tot.
|
||||
static func shop_cards(r: RandomNumberGenerator, income: int, elements: Array = [], n: int = Game.SHOP_SIZE) -> Array:
|
||||
var p := pool(income, elements)
|
||||
if p.is_empty():
|
||||
p = pool(Game.MAX_ENERGY, elements)
|
||||
assert(not p.is_empty(), "kein Angebot für " + str(elements))
|
||||
var out := []
|
||||
for i in n:
|
||||
out.append(p[r.randi_range(0, p.size() - 1)])
|
||||
var cheap := p.filter(func(c): return c.cost <= income)
|
||||
if not cheap.is_empty() and not out.any(func(c): return c.cost <= income):
|
||||
out[r.randi_range(0, out.size() - 1)] = cheap[r.randi_range(0, cheap.size() - 1)]
|
||||
return out
|
||||
|
||||
|
||||
## Gemessene Werte aus data/archetypen.json (vom Messlauf geschrieben), sonst die eingebaute Tabelle.
|
||||
static func strength() -> Dictionary:
|
||||
if _strength.is_empty():
|
||||
_strength = ARCHETYPE_STRENGTH.duplicate()
|
||||
if FileAccess.file_exists(ARCHETYPE_PATH):
|
||||
var json := JSON.new()
|
||||
if json.parse(FileAccess.get_file_as_string(ARCHETYPE_PATH)) == OK and json.data is Dictionary:
|
||||
for k in json.data:
|
||||
if _strength.has(k):
|
||||
_strength[k] = int(json.data[k])
|
||||
return _strength
|
||||
|
||||
|
||||
## Elementkombinationen einer Stufe: "leicht" die schwächere Hälfte, "schwer" die stärkere, sonst alle.
|
||||
static func archetypes(tier: String = "") -> Array:
|
||||
var table := strength()
|
||||
var keys: Array = table.keys()
|
||||
keys.sort_custom(func(a, b): return table[a] < table[b])
|
||||
var half: int = keys.size() / 2
|
||||
match tier:
|
||||
"leicht":
|
||||
return keys.slice(0, half)
|
||||
"schwer":
|
||||
return keys.slice(half)
|
||||
return keys
|
||||
|
||||
|
||||
# Energiekurve der Gegnerdecks: Anzahl Karten je Kosten (6 = sechs und mehr).
|
||||
const CURVE := {1: 3, 2: 5, 3: 4, 4: 3, 5: 2, 6: 3}
|
||||
|
||||
|
||||
|
||||
## Gegnerdeck aus den Elementen entlang der Energiekurve. bias > 1 bevorzugt seltene Karten, bias < 1 gewöhnliche.
|
||||
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
|
||||
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements))
|
||||
var deck := []
|
||||
var counts := {}
|
||||
for bucket in CURVE:
|
||||
for i in CURVE[bucket]:
|
||||
var c = _pick_for_cost(r, pool, counts, bucket, bias)
|
||||
if c != null:
|
||||
counts[c.id] = counts.get(c.id, 0) + 1
|
||||
deck.append(c)
|
||||
while deck.size() < Game.DECK_SIZE:
|
||||
var c = _pick_for_cost(r, pool, counts, r.randi_range(1, 6), bias)
|
||||
if c == null:
|
||||
break
|
||||
counts[c.id] = counts.get(c.id, 0) + 1
|
||||
deck.append(c)
|
||||
return deck
|
||||
|
||||
|
||||
## Karte nahe den gewünschten Kosten, gewichtet nach Seltenheit (bias) und Kostenabstand.
|
||||
## Bei extremer Seltenheitsvorgabe darf die Kurve nachgeben, sonst wäre das Ziel unerreichbar.
|
||||
static func _pick_for_cost(r: RandomNumberGenerator, pool: Array, counts: Dictionary, cost: int, bias: float):
|
||||
var candidates := []
|
||||
var weights := PackedFloat32Array()
|
||||
for c in pool:
|
||||
if counts.get(c.id, 0) >= Game.MAX_COPIES:
|
||||
continue
|
||||
var spread: int = absi(mini(c.cost, 6) - cost)
|
||||
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity]
|
||||
w = w / bias if c.rarity == "gewöhnlich" else w * bias
|
||||
w *= pow(0.3, spread)
|
||||
candidates.append(c)
|
||||
weights.append(w)
|
||||
if candidates.is_empty():
|
||||
return null
|
||||
return candidates[r.rand_weighted(weights)]
|
||||
|
||||
|
||||
## Gegnerdeck auf eine Zielstärke: die Gewichtung wird angepasst, bis das Deck nahe am Ziel liegt.
|
||||
static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array:
|
||||
var bias := 1.0
|
||||
var best := []
|
||||
var best_diff := 99.0
|
||||
for attempt in 24:
|
||||
var deck := build_opponent_deck(r, elements, bias)
|
||||
var diff := deck_rating(deck) - target
|
||||
if absf(diff) < best_diff:
|
||||
best = deck
|
||||
best_diff = absf(diff)
|
||||
if absf(diff) <= tolerance:
|
||||
break
|
||||
bias = clampf(bias * (1.35 if diff < 0 else 1.0 / 1.35), 0.05, 20.0)
|
||||
return best
|
||||
|
||||
|
||||
## Zufälliger Gegner. target > 0: Deck auf diese Stärke gebaut. tier: Archetypen der Schwierigkeit.
|
||||
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = Game.START_LIFE, target: float = 0.0, tier: String = "") -> Game.Player:
|
||||
var options := archetypes(tier)
|
||||
var elements: Array = options[r.randi_range(0, options.size() - 1)].split("+")
|
||||
var deck := build_opponent_deck_rated(r, elements, target) if target > 0.0 else build_opponent_deck(r, elements)
|
||||
return Game.Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier")
|
||||
|
||||
|
||||
## Kartenwert, bei genug Partien mit der Siegquote der Karte gewichtet (Statistik aus Spieltests).
|
||||
static func card_value(c: Cards.Card, stats: Dictionary = {}) -> float:
|
||||
var v := Cards.card_value(c)
|
||||
if stats.has(c.id):
|
||||
var st: Dictionary = stats[c.id]
|
||||
if int(st.games) >= 10:
|
||||
v *= 0.5 + float(st.wins) / float(st.games)
|
||||
return v
|
||||
|
||||
|
||||
## Abzug für schlechte Energiekurven: zu wenig günstige oder zu viele teure Karten.
|
||||
static func curve_penalty(cards: Array) -> float:
|
||||
var cheap := 0
|
||||
var expensive := 0
|
||||
for c in cards:
|
||||
if c.cost <= 2:
|
||||
cheap += 1
|
||||
if c.cost >= 5:
|
||||
expensive += 1
|
||||
return minf(0.05 * maxi(0, 6 - cheap) + 0.05 * maxi(0, expensive - 5), 0.4)
|
||||
|
||||
|
||||
## Deckstärke: Durchschnitt der Kartenwerte minus Kurvenabzug (1 = nur gewöhnliche Karten, bis etwa 3,5).
|
||||
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
|
||||
if cards.is_empty():
|
||||
return 0.0
|
||||
var sum := 0.0
|
||||
for c in cards:
|
||||
sum += card_value(c, stats)
|
||||
return sum / cards.size() - curve_penalty(cards)
|
||||
## Gegner mit eigenem Laden über alle Elemente.
|
||||
static func random_opponent(index: int, life: int = Game.START_LIFE) -> Game.Player:
|
||||
return Game.Player.new("Gegner %d" % index, false, life, "Magier")
|
||||
|
||||
391
scripts/game.gd
391
scripts/game.gd
@@ -4,26 +4,24 @@ extends RefCounted
|
||||
|
||||
const MAX_BOARD := 5
|
||||
const MAX_HAND := 8
|
||||
const MAX_ENERGY := 10
|
||||
const START_HAND := 4
|
||||
const MAX_ENERGY := 10 # Einkommen je Zug, höher steigt es nicht
|
||||
const SHOP_SIZE := 5 # so viele Karten liegen im Laden
|
||||
const REROLL_COST := 1
|
||||
# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben.
|
||||
const START_LIFE := 30
|
||||
const MAX_OPPONENTS := 5
|
||||
|
||||
const RESONANCE_MAX := 4
|
||||
const MAX_TRAPS := 2
|
||||
|
||||
# Merkmale der Kampagnen-Bosse (Player.perks).
|
||||
const PERKS := {
|
||||
"feuer_angriff": "Feuerwesen haben +1 Angriff",
|
||||
"flink_angriff": "Flinke Wesen haben +1 Angriff",
|
||||
"waechter_leben": "Wächter kommen mit +2 Leben",
|
||||
"extra_karten": "Zieht 2 Karten pro Zug",
|
||||
"wesen_leben": "Wesen kommen mit +1 Leben",
|
||||
"extra_mana": "Bekommt jeden Zug 1 Mana mehr",
|
||||
"leben_35": "Beginnt mit 35 Leben",
|
||||
"zauber_schaden": "Schadenszauber machen +1 Schaden",
|
||||
"schild_wesen": "Wesen kommen mit Schild",
|
||||
"alle_flink": "Alle Wesen sind flink",
|
||||
"start_energie": "Beginnt mit 3 Energie",
|
||||
"start_energie": "Beginnt mit 3 Mana",
|
||||
"resonanz_doppelt": "Resonanz zählt doppelt",
|
||||
}
|
||||
|
||||
@@ -40,9 +38,6 @@ static func make_rng() -> RandomNumberGenerator:
|
||||
r.randomize()
|
||||
return r
|
||||
|
||||
const DECK_SIZE := 20
|
||||
const MAX_COPIES := 3
|
||||
const MAX_DECK_ELEMENTS := 2
|
||||
|
||||
|
||||
class Creature extends RefCounted:
|
||||
@@ -52,6 +47,7 @@ class Creature extends RefCounted:
|
||||
var uid: int
|
||||
var attack: int
|
||||
var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt
|
||||
var field_bonus := 0 # +1, wenn das Feld auf dem eigenen Element steht
|
||||
var health: int
|
||||
var max_health: int
|
||||
var shield: bool
|
||||
@@ -74,9 +70,9 @@ class Creature extends RefCounted:
|
||||
func owner():
|
||||
return owner_ref.get_ref()
|
||||
|
||||
## Angriffswert inklusive Resonanz und Boss-Merkmalen.
|
||||
## Angriffswert inklusive Resonanz, Feld und Boss-Merkmalen.
|
||||
func power() -> int:
|
||||
var v := attack + resonance
|
||||
var v := attack + resonance + field_bonus
|
||||
var o = owner()
|
||||
if o != null:
|
||||
if "feuer_angriff" in o.perks and "Feuer" in card.elements:
|
||||
@@ -98,6 +94,7 @@ class Creature extends RefCounted:
|
||||
var c := Creature.new(card, new_owner, uid)
|
||||
c.attack = attack
|
||||
c.resonance = resonance
|
||||
c.field_bonus = field_bonus
|
||||
c.health = health
|
||||
c.max_health = max_health
|
||||
c.shield = shield
|
||||
@@ -113,43 +110,41 @@ class Player extends RefCounted:
|
||||
var is_human: bool
|
||||
var life: int
|
||||
var max_life: int
|
||||
var deck: Array = []
|
||||
var shop: Array = [] # SHOP_SIZE Plätze, gekaufte sind null
|
||||
var hand: Array = []
|
||||
var board: Array = []
|
||||
var energy := 0
|
||||
var max_energy := 0
|
||||
var fatigue := 0
|
||||
var start_bonus := 0 # einmalige Extra-Energie im ersten Zug (Ausgleich fürs Nachziehen)
|
||||
var traps: Array = [] # verdeckt liegende Fallenkarten
|
||||
var energy := 0 # Mana; nicht ausgegebenes bleibt liegen
|
||||
var max_energy := 0 # Einkommen je Zug
|
||||
var start_bonus := 0 # einmaliges Extra-Mana für den späteren Start
|
||||
var order := 0 # Platz in der Zugreihenfolge
|
||||
var perks: Array = [] # Boss-Merkmale (siehe PERKS)
|
||||
var elements: Array = [] # Laden nur aus diesen Elementen (leer = alle)
|
||||
var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile)
|
||||
var turns := 0 # begonnene eigene Züge
|
||||
var mulligan_done := false
|
||||
|
||||
func _init(n: String, d: Array, h: bool, l: int, t: String = "") -> void:
|
||||
func _init(n: String, h: bool, l: int, t: String = "", els: Array = []) -> void:
|
||||
name = n
|
||||
title = t
|
||||
is_human = h
|
||||
life = l
|
||||
max_life = l
|
||||
deck = d.duplicate()
|
||||
elements = els.duplicate()
|
||||
|
||||
func alive() -> bool:
|
||||
return life > 0
|
||||
|
||||
func clone() -> Player:
|
||||
var p := Player.new(name, deck, is_human, life, title)
|
||||
var p := Player.new(name, is_human, life, title, elements)
|
||||
p.max_life = max_life
|
||||
p.shop = shop.duplicate()
|
||||
p.hand = hand.duplicate()
|
||||
p.energy = energy
|
||||
p.max_energy = max_energy
|
||||
p.fatigue = fatigue
|
||||
p.start_bonus = start_bonus
|
||||
p.traps = traps.duplicate()
|
||||
p.order = order
|
||||
p.perks = perks.duplicate()
|
||||
p.looks = looks
|
||||
p.turns = turns
|
||||
p.mulligan_done = mulligan_done
|
||||
for c in board:
|
||||
p.board.append(c.clone(p))
|
||||
return p
|
||||
@@ -168,6 +163,8 @@ var events: Array = [] # strukturierte Ereignisse für Animationen
|
||||
var winner := "" # "", "human" oder "opponents"
|
||||
var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse
|
||||
var next_uid := 1
|
||||
var field_element := "" # gemeinsames Feld aller am Tisch, "" = neutral
|
||||
var field_charge := {} # Element -> gespielte Karten seit dem letzten Wechsel
|
||||
|
||||
|
||||
func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void:
|
||||
@@ -178,6 +175,8 @@ func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void:
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
rng = r
|
||||
for e in Cards.ELEMENTS:
|
||||
field_charge[e] = 0
|
||||
|
||||
|
||||
func current() -> Player:
|
||||
@@ -225,20 +224,12 @@ func clone() -> Game:
|
||||
g.current_idx = current_idx
|
||||
g.winner = winner
|
||||
g.next_uid = next_uid
|
||||
g.field_element = field_element
|
||||
g.field_charge = field_charge.duplicate()
|
||||
g.quiet = true
|
||||
return g
|
||||
|
||||
|
||||
## Kopie aus Sicht von p: fremde Fallen sind unbekannt und fehlen (die KI schummelt nicht).
|
||||
func clone_for(p: Player) -> Game:
|
||||
var g := clone()
|
||||
var p2: Player = g.players[players.find(p)]
|
||||
for q in g.players:
|
||||
if q != p2:
|
||||
q.traps = []
|
||||
return g
|
||||
|
||||
|
||||
# --- Zugablauf ---------------------------------------------------------------
|
||||
|
||||
func start(fixed_order: bool = false) -> void:
|
||||
@@ -247,13 +238,9 @@ func start(fixed_order: bool = false) -> void:
|
||||
for i in players.size():
|
||||
var p: Player = players[i]
|
||||
p.order = i
|
||||
if "start_energie" in p.perks:
|
||||
p.max_energy = 2
|
||||
shuffle(p.deck, rng)
|
||||
# Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug
|
||||
# einmalig 1 Energie extra (Ausgleich für den Startvorteil).
|
||||
draw(p, START_HAND + (0 if i == 0 else 1))
|
||||
# Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra.
|
||||
p.start_bonus = 0 if i == 0 else 1
|
||||
roll_shop(p)
|
||||
turn = 1
|
||||
current_idx = 0
|
||||
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
|
||||
@@ -263,28 +250,54 @@ func start(fixed_order: bool = false) -> void:
|
||||
begin_turn(current())
|
||||
|
||||
|
||||
func draw(p: Player, n: int = 1) -> void:
|
||||
for i in n:
|
||||
if p.deck.is_empty():
|
||||
p.fatigue += 1
|
||||
say("%s hat keine Karten mehr und erleidet %d Schaden." % [p.name, p.fatigue])
|
||||
damage(p, p.fatigue)
|
||||
continue
|
||||
var card = p.deck.pop_back()
|
||||
if p.hand.size() >= MAX_HAND:
|
||||
say("%s: Hand voll, %s verbrennt." % [p.name, card.name])
|
||||
else:
|
||||
p.hand.append(card)
|
||||
check_end()
|
||||
## Füllt den Laden neu: SHOP_SIZE Karten, höchstens so teuer wie das Einkommen plus eins.
|
||||
func roll_shop(p: Player) -> void:
|
||||
p.shop = DeckGen.shop_cards(rng, p.max_energy, p.elements)
|
||||
|
||||
|
||||
## Kauft eine Karte aus dem Laden auf die Hand. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func buy(p: Player, slot: int) -> String:
|
||||
if winner != "" or p != current():
|
||||
return "Nicht dein Zug"
|
||||
if slot < 0 or slot >= p.shop.size() or p.shop[slot] == null:
|
||||
return "Kein Angebot"
|
||||
var card: Cards.Card = p.shop[slot]
|
||||
if p.energy < card.cost:
|
||||
return "Nicht genug Mana"
|
||||
if p.hand.size() >= MAX_HAND:
|
||||
return "Hand voll"
|
||||
p.energy -= card.cost
|
||||
p.shop[slot] = null
|
||||
p.hand.append(card)
|
||||
say("%s kauft %s." % [p.name, card.name])
|
||||
emit({"type": "buy", "player": players.find(p), "card": card})
|
||||
return ""
|
||||
|
||||
|
||||
## Würfelt den Laden neu. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func reroll(p: Player) -> String:
|
||||
if winner != "" or p != current():
|
||||
return "Nicht dein Zug"
|
||||
if p.energy < REROLL_COST:
|
||||
return "Nicht genug Mana"
|
||||
p.energy -= REROLL_COST
|
||||
roll_shop(p)
|
||||
say("%s würfelt den Laden neu." % p.name)
|
||||
emit({"type": "reroll", "player": players.find(p)})
|
||||
return ""
|
||||
|
||||
|
||||
func begin_turn(p: Player) -> void:
|
||||
p.max_energy = mini(MAX_ENERGY, p.max_energy + 1)
|
||||
p.energy = p.max_energy
|
||||
# Nicht ausgegebenes Mana bleibt liegen.
|
||||
p.energy += p.max_energy + (1 if "extra_mana" in p.perks else 0)
|
||||
if p.turns == 0 and "start_energie" in p.perks:
|
||||
p.energy += 2 # einmalige Starthilfe, kein dauerhafter Vorsprung
|
||||
if p.start_bonus > 0:
|
||||
p.energy += p.start_bonus
|
||||
say("%s erhält %d Energie extra für den späteren Start." % [p.name, p.start_bonus])
|
||||
say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus])
|
||||
p.start_bonus = 0
|
||||
roll_shop(p)
|
||||
for c in p.board:
|
||||
c.ready = true
|
||||
if c.frozen:
|
||||
@@ -296,28 +309,6 @@ func begin_turn(p: Player) -> void:
|
||||
p.turns += 1
|
||||
say("--- Zug %d: %s ---" % [turn, p.name])
|
||||
emit({"type": "turn", "player": players.find(p)})
|
||||
draw(p, 2 if "extra_karten" in p.perks else 1)
|
||||
|
||||
|
||||
## Tauscht einmalig im ersten eigenen Zug Karten der Hand: zurück ins Deck, mischen, nachziehen.
|
||||
func mulligan(p: Player, cards: Array) -> String:
|
||||
if p.mulligan_done:
|
||||
return "Schon getauscht"
|
||||
if p != current() or p.turns != 1:
|
||||
return "Nur im ersten eigenen Zug"
|
||||
p.mulligan_done = true
|
||||
var n := 0
|
||||
for c in cards:
|
||||
if c in p.hand:
|
||||
p.hand.erase(c)
|
||||
p.deck.append(c)
|
||||
n += 1
|
||||
if n > 0:
|
||||
shuffle(p.deck, rng)
|
||||
draw(p, n)
|
||||
say(("%s tauscht 1 Karte." % p.name if n == 1 else "%s tauscht %d Karten." % [p.name, n]) if n > 0 else "%s behält die Hand." % p.name)
|
||||
emit({"type": "mulligan", "player": players.find(p), "count": n})
|
||||
return ""
|
||||
|
||||
|
||||
func end_turn() -> void:
|
||||
@@ -336,18 +327,14 @@ func end_turn() -> void:
|
||||
|
||||
# --- Karten spielen ----------------------------------------------------------
|
||||
|
||||
## Ausspielen kostet nichts mehr: bezahlt wird beim Kauf im Laden.
|
||||
func can_play(p: Player, card: Cards.Card) -> Array:
|
||||
if winner != "" or p != current():
|
||||
return [false, "Nicht dein Zug"]
|
||||
if p.energy < card.cost:
|
||||
return [false, "Nicht genug Energie"]
|
||||
if card.is_creature() and p.board.size() >= MAX_BOARD:
|
||||
if not (card in p.hand):
|
||||
return [false, "Nicht auf der Hand"]
|
||||
if p.board.size() >= MAX_BOARD:
|
||||
return [false, "Dein Feld ist voll"]
|
||||
if card.is_trap() and p.traps.size() >= MAX_TRAPS:
|
||||
return [false, "Höchstens %d Fallen" % MAX_TRAPS]
|
||||
var tt := card.target_type()
|
||||
if tt != "" and not card.is_creature() and valid_targets(p, tt).is_empty():
|
||||
return [false, "Kein gültiges Ziel"]
|
||||
return [true, ""]
|
||||
|
||||
|
||||
@@ -379,37 +366,40 @@ func play_card(p: Player, card: Cards.Card, target = null) -> String:
|
||||
if valid.is_empty():
|
||||
target = null
|
||||
p.hand.erase(card)
|
||||
p.energy -= card.cost
|
||||
if card.is_trap():
|
||||
p.traps.append(card)
|
||||
say("%s legt eine Falle." % p.name)
|
||||
emit({"type": "trap_set", "player": players.find(p)})
|
||||
check_end()
|
||||
return ""
|
||||
say("%s spielt %s." % [p.name, card.name])
|
||||
var cr: Creature = null
|
||||
var bonus := 0
|
||||
if card.is_creature():
|
||||
cr = _enter(p, card)
|
||||
else:
|
||||
emit({"type": "spell", "player": players.find(p), "card": card})
|
||||
if "zauber_schaden" in p.perks:
|
||||
bonus = 1
|
||||
var cr := _enter(p, card)
|
||||
for fx in card.effects:
|
||||
resolve(p, fx, target, cr, bonus)
|
||||
if cr != null and cr in p.board:
|
||||
for e in enemies_of(p):
|
||||
if not (cr in p.board):
|
||||
break
|
||||
trigger_trap(e, "creature_played", cr)
|
||||
if cr != null and cr in p.board:
|
||||
resolve(p, fx, target, cr, card.elements)
|
||||
if cr in p.board:
|
||||
trigger_field_combos(p, cr)
|
||||
charge_field(card)
|
||||
update_resonance()
|
||||
cleanup()
|
||||
check_end()
|
||||
return ""
|
||||
|
||||
|
||||
## Jede gespielte Karte lädt ihre Elemente. Wer FIELD_CHARGE erreicht, kippt das
|
||||
## gemeinsame Feld auf sein Element; danach beginnen alle Zähler von vorn.
|
||||
func charge_field(card: Cards.Card) -> void:
|
||||
for e in card.elements:
|
||||
field_charge[e] = int(field_charge.get(e, 0)) + 1
|
||||
var best := ""
|
||||
for e in Cards.ELEMENTS:
|
||||
if e == field_element or int(field_charge.get(e, 0)) < Cards.FIELD_CHARGE:
|
||||
continue
|
||||
if best == "" or int(field_charge[e]) > int(field_charge[best]):
|
||||
best = e
|
||||
if best == "":
|
||||
return
|
||||
field_element = best
|
||||
for e in Cards.ELEMENTS:
|
||||
field_charge[e] = 0
|
||||
say("Das Feld wird zu %s: %s." % [Cards.FIELD_NAMES[best], Cards.FIELD_TEXT[best]])
|
||||
emit({"type": "field", "element": best})
|
||||
update_resonance() # der Feldbonus gilt sofort
|
||||
|
||||
|
||||
## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem
|
||||
## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus.
|
||||
func trigger_field_combos(p: Player, cr: Creature) -> void:
|
||||
@@ -427,26 +417,6 @@ func trigger_field_combos(p: Player, cr: Creature) -> void:
|
||||
return
|
||||
|
||||
|
||||
## Löst die erste passende Falle von owner aus. Gibt true zurück, wenn der
|
||||
## auslösende Angriff abgebrochen wird.
|
||||
func trigger_trap(owner: Player, trigger: String, source: Creature) -> bool:
|
||||
for trap in owner.traps:
|
||||
if trap.trigger != trigger:
|
||||
continue
|
||||
owner.traps.erase(trap)
|
||||
say("Falle von %s: %s!" % [owner.name, trap.name])
|
||||
emit({"type": "trap", "player": players.find(owner), "card": trap, "source": ref_of(source)})
|
||||
var cancel := false
|
||||
for fx in trap.effects:
|
||||
if fx.kind == "cancel":
|
||||
cancel = true
|
||||
else:
|
||||
resolve(owner, fx, null, source)
|
||||
cleanup()
|
||||
return cancel
|
||||
return false
|
||||
|
||||
|
||||
## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt.
|
||||
func _enter(p: Player, card: Cards.Card) -> Creature:
|
||||
var cr := Creature.new(card, p, next_uid)
|
||||
@@ -454,6 +424,9 @@ func _enter(p: Player, card: Cards.Card) -> Creature:
|
||||
if "waechter_leben" in p.perks and cr.taunt():
|
||||
cr.health += 2
|
||||
cr.max_health += 2
|
||||
if "wesen_leben" in p.perks:
|
||||
cr.health += 1
|
||||
cr.max_health += 1
|
||||
if "schild_wesen" in p.perks:
|
||||
cr.shield = true
|
||||
if "alle_flink" in p.perks:
|
||||
@@ -464,14 +437,45 @@ func _enter(p: Player, card: Cards.Card) -> Creature:
|
||||
return cr
|
||||
|
||||
|
||||
## Setzt ein Wesen aufs Feld (Fallen, Effekte).
|
||||
func summon(p: Player, card: Cards.Card) -> void:
|
||||
if p.board.size() >= MAX_BOARD:
|
||||
say("%s: kein Platz für %s." % [p.name, card.name])
|
||||
return
|
||||
_enter(p, card)
|
||||
say("%s beschwört %s." % [p.name, card.name])
|
||||
## Prüft eine Verschmelzung: zwei eigene Wesen, genug Mana.
|
||||
func can_fuse(p: Player, a: Creature, b: Creature) -> Array:
|
||||
if winner != "" or p != current():
|
||||
return [false, "Nicht dein Zug"]
|
||||
if a == null or b == null or a == b:
|
||||
return [false, "Zwei verschiedene Wesen wählen"]
|
||||
if not (a in p.board) or not (b in p.board):
|
||||
return [false, "Nur eigene Wesen"]
|
||||
if p.energy < Cards.fusion_cost(a.card, b.card):
|
||||
return [false, "Nicht genug Mana"]
|
||||
return [true, ""]
|
||||
|
||||
|
||||
## Verschmilzt zwei eigene Wesen zu einem. Es erbt Elemente und Schlüsselwörter beider
|
||||
## Karten, seine Werte sind der abgerundete Durchschnitt plus 1. Kosten: Durchschnitt der
|
||||
## Kartenkosten, aufgerundet. Eine Verschmelzung ist kein Ausspielen: sie löst keine
|
||||
## Feldkombo aus und lädt das Feld nicht.
|
||||
func fuse(p: Player, a: Creature, b: Creature) -> String:
|
||||
var ok := can_fuse(p, a, b)
|
||||
if not ok[0]:
|
||||
return ok[1]
|
||||
p.energy -= Cards.fusion_cost(a.card, b.card)
|
||||
var card := Cards.fused(a.card, b.card, (a.attack + b.attack) / 2 + 1, (a.health + b.health) / 2 + 1)
|
||||
var cr := Creature.new(card, p, next_uid)
|
||||
next_uid += 1
|
||||
cr.shield = cr.shield or a.shield or b.shield
|
||||
cr.frozen = a.frozen or b.frozen
|
||||
cr.ready = a.ready and b.ready
|
||||
cr.attacks_left = 0 if not cr.ready else mini(a.attacks_left, b.attacks_left)
|
||||
var at := p.board.find(a)
|
||||
p.board.erase(a)
|
||||
p.board.erase(b)
|
||||
p.board.insert(mini(at, p.board.size()), cr)
|
||||
say("%s und %s verschmelzen zu %s (%d/%d)." % [a.name, b.name, cr.name, cr.attack, cr.health])
|
||||
emit({"type": "fuse", "player": players.find(p), "uid": cr.uid, "gone": [a.uid, b.uid]})
|
||||
update_resonance()
|
||||
cleanup()
|
||||
check_end()
|
||||
return ""
|
||||
|
||||
|
||||
## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3.
|
||||
@@ -488,9 +492,10 @@ func update_resonance() -> void:
|
||||
if o != c and o.card.elements.any(func(e): return e in c.card.elements):
|
||||
bonus += 1
|
||||
c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1)
|
||||
c.field_bonus = 1 if field_element != "" and field_element in c.card.elements else 0
|
||||
|
||||
|
||||
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void:
|
||||
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, elements: Array = []) -> void:
|
||||
var enemies := enemies_of(p)
|
||||
var enemy_creatures := []
|
||||
for e in enemies:
|
||||
@@ -508,7 +513,7 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus
|
||||
targets = p.board.duplicate()
|
||||
elif fx.target == "self_hero":
|
||||
targets = [p]
|
||||
elif fx.target == "self_creature" or fx.target == "trigger_source":
|
||||
elif fx.target == "self_creature":
|
||||
targets = [source] if source != null else []
|
||||
elif fx.target == "strongest_enemy_creature":
|
||||
var best: Creature = null
|
||||
@@ -527,13 +532,15 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus
|
||||
for t in targets:
|
||||
match fx.kind:
|
||||
"damage":
|
||||
damage(t, fx.amount + bonus)
|
||||
damage(t, fx.amount + (Cards.matchup(elements, t.card.elements) if t is Creature else 0))
|
||||
"heal":
|
||||
t.life = mini(t.max_life, t.life + fx.amount)
|
||||
say("%s heilt sich um %d." % [t.name, fx.amount])
|
||||
emit({"type": "heal", "target": ref_of(t), "amount": fx.amount})
|
||||
"draw":
|
||||
draw(p, fx.amount)
|
||||
"mana":
|
||||
p.energy += fx.amount
|
||||
say("%s erhält %d Mana." % [p.name, fx.amount])
|
||||
emit({"type": "mana", "player": players.find(p), "amount": fx.amount})
|
||||
"freeze":
|
||||
t.frozen = true
|
||||
t.attacks_left = 0
|
||||
@@ -558,8 +565,6 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus
|
||||
t.shield = true
|
||||
say("%s erhält Schild." % t.name)
|
||||
emit({"type": "buff", "target": ref_of(t), "text": "Schild"})
|
||||
"summon":
|
||||
summon(p, Cards.by_id(fx.card_id))
|
||||
|
||||
|
||||
# --- Kampf -------------------------------------------------------------------
|
||||
@@ -589,19 +594,21 @@ func attack(cr: Creature, target) -> String:
|
||||
cr.attacks_left -= 1
|
||||
say("%s greift %s an." % [cr.name, target.name])
|
||||
emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)})
|
||||
var tgt_owner: Player = target.owner() if target is Creature else target
|
||||
var cancelled := trigger_trap(tgt_owner, "attacked", cr)
|
||||
if cancelled or not (cr in cr.owner().board) or (target is Creature and not (target in tgt_owner.board)):
|
||||
check_end()
|
||||
return ""
|
||||
damage(target, cr.power())
|
||||
if target is Creature:
|
||||
damage(cr, target.power())
|
||||
damage(target, cr.power() + matchup_bonus(cr, target))
|
||||
if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht
|
||||
damage(cr, target.power() + matchup_bonus(target, cr))
|
||||
cleanup()
|
||||
check_end()
|
||||
return ""
|
||||
|
||||
|
||||
## Elementvorteil beim Zuschlagen: gegen Helden gibt es keinen.
|
||||
static func matchup_bonus(source: Creature, target) -> int:
|
||||
if not (target is Creature):
|
||||
return 0
|
||||
return Cards.matchup(source.card.elements, target.card.elements)
|
||||
|
||||
|
||||
func damage(target, amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
@@ -622,9 +629,13 @@ func cleanup() -> void:
|
||||
var died := false
|
||||
for p in players:
|
||||
for c in p.board.duplicate():
|
||||
if c.health <= 0:
|
||||
# Wer besiegt ist, nimmt seine Wesen mit vom Tisch.
|
||||
if c.health <= 0 or not p.alive():
|
||||
p.board.erase(c)
|
||||
say("%s stirbt." % c.name)
|
||||
if c.health > 0:
|
||||
say("%s verschwindet mit %s." % [c.name, p.name])
|
||||
else:
|
||||
say("%s stirbt." % c.name)
|
||||
emit({"type": "death", "target": ref_of(c)})
|
||||
died = true
|
||||
if died:
|
||||
@@ -657,7 +668,7 @@ static func from_snapshot(d: Dictionary) -> Game:
|
||||
return NetState.from_snapshot(d)
|
||||
|
||||
|
||||
# --- Deckregeln und Gegner ---------------------------------------------------
|
||||
# --- Gegner und Kampfstart ---------------------------------------------------
|
||||
|
||||
static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
|
||||
for i in range(arr.size() - 1, 0, -1):
|
||||
@@ -667,81 +678,27 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
|
||||
arr[j] = tmp
|
||||
|
||||
|
||||
static func deck_elements(cards: Array) -> Array:
|
||||
var out := []
|
||||
for c in cards:
|
||||
for e in c.elements:
|
||||
if not (e in out):
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
## Prüft ein Deck. owned: Dictionary id -> Anzahl (null = keine Besitzprüfung).
|
||||
static func validate_deck(cards: Array, owned = null) -> Array:
|
||||
if cards.size() != DECK_SIZE:
|
||||
return [false, "Deck braucht genau %d Karten (%d)" % [DECK_SIZE, cards.size()]]
|
||||
var counts := {}
|
||||
for c in cards:
|
||||
counts[c.id] = counts.get(c.id, 0) + 1
|
||||
for cid in counts:
|
||||
if counts[cid] > MAX_COPIES:
|
||||
return [false, "Höchstens %dx dieselbe Karte" % MAX_COPIES]
|
||||
if owned != null and owned.get(cid, 0) < counts[cid]:
|
||||
return [false, "Karte nicht oft genug in der Sammlung"]
|
||||
if deck_elements(cards).size() > MAX_DECK_ELEMENTS:
|
||||
return [false, "Höchstens %d Elemente im Deck" % MAX_DECK_ELEMENTS]
|
||||
return [true, ""]
|
||||
|
||||
|
||||
## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int, "target_rating": float, "tier": String}
|
||||
# --- Deckstärke und Gegner (siehe DeckGen) --------------------------------------
|
||||
|
||||
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
|
||||
return DeckGen.deck_rating(cards, stats)
|
||||
|
||||
|
||||
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
|
||||
return DeckGen.build_opponent_deck(r, elements, bias)
|
||||
|
||||
|
||||
static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array:
|
||||
return DeckGen.build_opponent_deck_rated(r, elements, target, tolerance)
|
||||
|
||||
|
||||
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE, target: float = 0.0, tier: String = "") -> Player:
|
||||
return DeckGen.random_opponent(r, index, life, target, tier)
|
||||
|
||||
|
||||
static func archetypes(tier: String = "") -> Array:
|
||||
return DeckGen.archetypes(tier)
|
||||
|
||||
|
||||
static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
|
||||
## rules: {"human_perks": [...], "opponent_life": int, "looks": {...}}
|
||||
static func new_battle(n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
|
||||
assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner")
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
var target: float = float(rules.get("target_rating", 0.0))
|
||||
var tier: String = str(rules.get("tier", ""))
|
||||
var life: int = int(rules.get("opponent_life", START_LIFE))
|
||||
var opps := []
|
||||
for i in n_opp:
|
||||
opps.append(random_opponent(r, i + 1, START_LIFE, target, tier))
|
||||
return new_battle_with(deck, opps, r, false, rules)
|
||||
opps.append(DeckGen.random_opponent(i + 1, life))
|
||||
return new_battle_with(opps, r, false, rules)
|
||||
|
||||
|
||||
static func new_battle_with(deck: Array, opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
|
||||
static func new_battle_with(opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
var human := Player.new("Du", deck, true, START_LIFE)
|
||||
var human := Player.new("Spieler", true, START_LIFE)
|
||||
human.perks = Array(rules.get("human_perks", []))
|
||||
human.looks = rules.get("looks", {})
|
||||
var g := Game.new(human, opps, r)
|
||||
var everyone: String = rules.get("everyone_perk", "")
|
||||
for p in g.players:
|
||||
if everyone != "" and not (everyone in p.perks):
|
||||
p.perks.append(everyone)
|
||||
if rules.has("start_life"):
|
||||
p.life = int(rules.start_life)
|
||||
p.max_life = p.life
|
||||
elif "leben_35" in p.perks and p.life == START_LIFE:
|
||||
if "leben_35" in p.perks and p.life == START_LIFE:
|
||||
p.life = 35
|
||||
p.max_life = 35
|
||||
g.start(fixed_order)
|
||||
|
||||
@@ -17,9 +17,9 @@ var peer: ENetMultiplayerPeer
|
||||
var is_host := false
|
||||
var connected := false
|
||||
var my_name := "Spieler"
|
||||
var my_deck: Array = [] # Karten-IDs
|
||||
var my_pick := {"looks": {}} # eigene Kartenoptik (Seltenheit), die der Gegner sieht
|
||||
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
|
||||
var decks := {} # peer_id -> Karten-IDs (Host)
|
||||
var picks := {} # peer_id -> {"looks": {...}} (Host)
|
||||
var seats := {} # peer_id -> Spielerindex (Host)
|
||||
var my_seat := -1
|
||||
var game: Game # Host
|
||||
@@ -56,7 +56,7 @@ func host(port: int = PORT) -> Error:
|
||||
is_host = true
|
||||
connected = true
|
||||
names = {1: my_name}
|
||||
decks = {1: my_deck}
|
||||
picks = {1: my_pick}
|
||||
spectators = {}
|
||||
status = "Warte auf Mitspieler …"
|
||||
lobby_changed.emit()
|
||||
@@ -83,7 +83,7 @@ func leave() -> void:
|
||||
is_host = false
|
||||
connected = false
|
||||
names = {}
|
||||
decks = {}
|
||||
picks = {}
|
||||
seats = {}
|
||||
waiting = {}
|
||||
my_seat = -1
|
||||
@@ -111,7 +111,7 @@ func _fail(reason: String) -> void:
|
||||
func _on_connected() -> void:
|
||||
connected = true
|
||||
status = "Verbunden"
|
||||
register.rpc_id(1, my_name, my_deck, spectator)
|
||||
register.rpc_id(1, my_name, my_pick, spectator)
|
||||
|
||||
|
||||
# --- Host-Wechsel ---------------------------------------------------------------
|
||||
@@ -230,7 +230,7 @@ func _on_peer_disconnected(id: int) -> void:
|
||||
return
|
||||
var pname: String = names.get(id, "")
|
||||
names.erase(id)
|
||||
decks.erase(id)
|
||||
picks.erase(id)
|
||||
spectators.erase(id)
|
||||
if game != null and seats.has(id):
|
||||
var p: Game.Player = game.players[seats[id]]
|
||||
@@ -256,17 +256,19 @@ func tick() -> void:
|
||||
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:
|
||||
p.mulligan_done = true
|
||||
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
|
||||
game.end_turn()
|
||||
action_applied.emit()
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void:
|
||||
func register(name: String, pick: Dictionary, as_spectator: bool = false) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
var id := multiplayer.get_remote_sender_id()
|
||||
@@ -293,7 +295,7 @@ func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void
|
||||
status = "%s wollte beitreten, das Spiel läuft schon" % name
|
||||
return
|
||||
names[id] = name
|
||||
decks[id] = deck_ids
|
||||
picks[id] = pick
|
||||
lobby_update.rpc(names, spectators)
|
||||
lobby_changed.emit()
|
||||
|
||||
@@ -313,18 +315,14 @@ func start_battle(n_ai: int) -> void:
|
||||
var players := []
|
||||
seats = {}
|
||||
for id in ids:
|
||||
var deck_ids: Array = decks.get(id, [])
|
||||
var deck := []
|
||||
for cid in deck_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):
|
||||
deck.append(Cards.by_id(cid))
|
||||
if Game.validate_deck(deck)[0] == false:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, r)
|
||||
deck = Game.build_opponent_deck(r, [pool[0], pool[1]])
|
||||
players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE))
|
||||
p.looks[cid] = int(pick.looks[cid])
|
||||
players.append(p)
|
||||
for i in n_ai:
|
||||
players.append(Game.random_opponent(r, i + 1))
|
||||
players.append(DeckGen.random_opponent(i + 1))
|
||||
game = Game.new(players[0], players.slice(1), r)
|
||||
game.start()
|
||||
for id in ids:
|
||||
@@ -398,12 +396,15 @@ func _apply(id: int, action: Dictionary) -> void:
|
||||
var t = resolve_ref(action.get("target"))
|
||||
if cr != null and t != null:
|
||||
game.attack(cr, t)
|
||||
"mulligan":
|
||||
var cards := []
|
||||
for i in action.get("hand", []):
|
||||
if int(i) >= 0 and int(i) < p.hand.size():
|
||||
cards.append(p.hand[int(i)])
|
||||
game.mulligan(p, cards)
|
||||
"buy":
|
||||
game.buy(p, int(action.get("slot", -1)))
|
||||
"reroll":
|
||||
game.reroll(p)
|
||||
"fuse":
|
||||
var a := game.find_creature(int(action.get("uid", -1)))
|
||||
var b := game.find_creature(int(action.get("other", -1)))
|
||||
if a != null and b != null:
|
||||
game.fuse(p, a, b)
|
||||
"end":
|
||||
game.end_turn()
|
||||
_:
|
||||
|
||||
@@ -2,8 +2,17 @@ class_name NetState
|
||||
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
|
||||
|
||||
|
||||
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
|
||||
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände nur als Anzahl, fremde Läden gar nicht.
|
||||
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
|
||||
## Karten reisen als ID; verschmolzene stehen nicht im Pool und gehen als Ganzes mit.
|
||||
static func card_ref(c: Cards.Card):
|
||||
return c.id if Cards.has_id(c.id) 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)
|
||||
|
||||
|
||||
static func snapshot(g: Game, viewer: int) -> Dictionary:
|
||||
var ps := []
|
||||
for i in g.players.size():
|
||||
@@ -11,25 +20,26 @@ static func snapshot(g: Game, viewer: int) -> Dictionary:
|
||||
var mine := i == viewer or viewer == -1
|
||||
var board := []
|
||||
for c in p.board:
|
||||
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
|
||||
board.append({"uid": c.uid, "card": card_ref(c.card),
|
||||
"attack": c.attack, "resonance": c.resonance, "field_bonus": c.field_bonus,
|
||||
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
|
||||
"ready": c.ready, "attacks_left": c.attacks_left})
|
||||
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
|
||||
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
|
||||
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
|
||||
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
|
||||
"deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(),
|
||||
"traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
|
||||
"energy": p.energy, "max_energy": p.max_energy, "start_bonus": p.start_bonus,
|
||||
"order": p.order, "perks": p.perks, "elements": p.elements, "looks": p.looks, "turns": p.turns,
|
||||
"hand": p.hand.map(card_ref) if mine else p.hand.size(),
|
||||
"shop": p.shop.map(func(c): return c.id if c != null else "") if mine else [],
|
||||
"board": board})
|
||||
var evs := []
|
||||
for ev in g.events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = e.card.id
|
||||
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, "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
|
||||
"next_uid": g.next_uid, "field_element": g.field_element, "field_charge": g.field_charge,
|
||||
"log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
|
||||
|
||||
|
||||
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
|
||||
@@ -37,38 +47,30 @@ 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)
|
||||
var p := Game.Player.new(pd.name, pd.is_human, int(pd.life), pd.title, Array(pd.get("elements", [])))
|
||||
p.max_life = int(pd.max_life)
|
||||
p.energy = int(pd.energy)
|
||||
p.max_energy = int(pd.max_energy)
|
||||
p.fatigue = int(pd.fatigue)
|
||||
p.start_bonus = int(pd.start_bonus)
|
||||
p.order = int(pd.order)
|
||||
p.perks = Array(pd.perks)
|
||||
for cid in pd.get("looks", {}):
|
||||
p.looks[cid] = int(pd.looks[cid])
|
||||
p.turns = int(pd.turns)
|
||||
p.mulligan_done = bool(pd.mulligan_done)
|
||||
if pd.hand is Array:
|
||||
for cid in pd.hand:
|
||||
p.hand.append(Cards.by_id(cid))
|
||||
for ref in pd.hand:
|
||||
p.hand.append(card_of(ref))
|
||||
else:
|
||||
for i in int(pd.hand):
|
||||
p.hand.append(placeholder)
|
||||
if pd.deck is Array:
|
||||
for cid in pd.deck:
|
||||
p.deck.append(Cards.by_id(cid))
|
||||
else:
|
||||
for i in int(pd.deck):
|
||||
p.deck.append(placeholder)
|
||||
if pd.traps is Array:
|
||||
for cid in pd.traps:
|
||||
p.traps.append(Cards.by_id(cid))
|
||||
else:
|
||||
for i in int(pd.traps):
|
||||
p.traps.append(placeholder)
|
||||
for cid in pd.get("shop", []):
|
||||
p.shop.append(Cards.by_id(cid) if str(cid) != "" else null)
|
||||
for cd in pd.board:
|
||||
var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
|
||||
var card := card_of(cd.card)
|
||||
var c := Game.Creature.new(card, p, int(cd.uid))
|
||||
c.attack = int(cd.attack)
|
||||
c.resonance = int(cd.resonance)
|
||||
c.field_bonus = int(cd.get("field_bonus", 0))
|
||||
c.health = int(cd.health)
|
||||
c.max_health = int(cd.max_health)
|
||||
c.shield = bool(cd.shield)
|
||||
@@ -85,16 +87,19 @@ static func from_snapshot(d: Dictionary) -> Game:
|
||||
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)
|
||||
g.field_element = str(d.get("field_element", ""))
|
||||
for e in Cards.ELEMENTS:
|
||||
g.field_charge[e] = int(d.get("field_charge", {}).get(e, 0))
|
||||
for line in d.log:
|
||||
g.log.append(str(line))
|
||||
for ev in d.events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = Cards.by_id(e.card)
|
||||
for key in ["player", "uid", "count", "amount"]:
|
||||
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", "source"]:
|
||||
for key in ["target", "attacker"]:
|
||||
if e.has(key):
|
||||
var r: Dictionary = e[key]
|
||||
if r.has("uid"):
|
||||
|
||||
@@ -1,90 +1,39 @@
|
||||
class_name Profile
|
||||
extends RefCounted
|
||||
## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Kartenstatistik (für Deckstärke und Schwierigkeit), Tagesaufgabe, Einstellungen.
|
||||
## 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.
|
||||
|
||||
const SAVE_VERSION := 5
|
||||
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
|
||||
const SAVE_VERSION := 6
|
||||
const SAVE_PATH := "user://spielstand.json"
|
||||
const BOOSTER_COST := 100
|
||||
const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos)
|
||||
const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen
|
||||
const BOOSTER_SIZE := 5
|
||||
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
|
||||
const START_COINS := 150
|
||||
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
|
||||
const REWARD_LOSS := 50
|
||||
const DECK_SLOTS := 5
|
||||
# Schwierigkeit: Faktor auf die Zielstärke der Gegnerdecks, Belohnung und KI-Tiefe.
|
||||
# Schwierigkeit: Leben der Gegner, KI und Münzen.
|
||||
const DIFFICULTIES := {
|
||||
"leicht": {"name": "Leicht", "factor": 0.9, "reward": 0.75, "smart": false, "tier": "leicht"},
|
||||
"normal": {"name": "Normal", "factor": 1.0, "reward": 1.0, "smart": true, "tier": ""},
|
||||
"schwer": {"name": "Schwer", "factor": 1.2, "reward": 1.5, "smart": true, "tier": "schwer"},
|
||||
"leicht": {"name": "Leicht", "life": 25, "reward": 0.75, "smart": false},
|
||||
"normal": {"name": "Normal", "life": 30, "reward": 1.0, "smart": true},
|
||||
"schwer": {"name": "Schwer", "life": 35, "reward": 1.5, "smart": true},
|
||||
}
|
||||
# Sammlung: Zerlegen bringt Staub, Herstellen kostet Staub.
|
||||
# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
|
||||
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
|
||||
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
|
||||
|
||||
const DAILY_TASKS := [
|
||||
{"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80},
|
||||
{"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60},
|
||||
{"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40},
|
||||
{"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50},
|
||||
{"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50},
|
||||
{"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60},
|
||||
{"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50},
|
||||
{"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120},
|
||||
{"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90},
|
||||
]
|
||||
|
||||
static var today_override := "" # für Tests
|
||||
const DUST_UPGRADE := {"selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
|
||||
|
||||
var coins := 0
|
||||
var dust := 0
|
||||
var collection := {} # id -> Anzahl
|
||||
var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids}
|
||||
var active_deck := 0
|
||||
var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
|
||||
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
||||
var daily := {"date": "", "task": "", "progress": 0, "done": false}
|
||||
var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = gewöhnlich
|
||||
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
||||
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
|
||||
var notices: Array[String] = [] # Meldungen für die Oberfläche (Tagesaufgabe, Fortschritt)
|
||||
var card_stats := {} # id -> {"played", "games", "wins"}
|
||||
var booster_since_mythic := 0
|
||||
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
||||
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []}
|
||||
|
||||
## Das aktive Deck (Kartenliste des gewählten Slots).
|
||||
var deck: Array:
|
||||
get:
|
||||
return decks[active_deck].cards
|
||||
set(value):
|
||||
decks[active_deck].cards = value
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
for i in DECK_SLOTS:
|
||||
decks.append({"name": "Deck %d" % (i + 1), "cards": []})
|
||||
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
|
||||
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
||||
|
||||
|
||||
static func starter() -> Profile:
|
||||
var p := Profile.new()
|
||||
p.coins = START_COINS
|
||||
for c in Cards.of_rarity("gewöhnlich"):
|
||||
if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"):
|
||||
p.collection[c.id] = 2
|
||||
# Zwei seltene Karten zum Start, damit es etwas zum Aufbauen gibt
|
||||
p.collection["f_drache"] = 1
|
||||
p.collection["e_baum"] = 1
|
||||
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
|
||||
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
|
||||
for cid in starter_ids:
|
||||
p.deck.append(cid)
|
||||
p.deck.append(cid)
|
||||
p.deck[p.deck.find("f_asche")] = "f_drache"
|
||||
p.deck[p.deck.find("e_troll")] = "e_baum"
|
||||
return p
|
||||
|
||||
|
||||
@@ -102,71 +51,34 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile:
|
||||
return p
|
||||
|
||||
|
||||
## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration).
|
||||
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
|
||||
## Einstellungen, die alte Sammlung (Anzahl je Karte) hat keine Entsprechung mehr.
|
||||
static func from_dict(d: Dictionary) -> Profile:
|
||||
var p := Profile.new()
|
||||
var version := int(d.get("version", 1))
|
||||
p.coins = int(d.get("coins", 0))
|
||||
p.dust = int(d.get("dust", 0))
|
||||
var coll = d.get("collection", {})
|
||||
for cid in coll:
|
||||
if Cards.has_id(cid):
|
||||
p.collection[cid] = int(coll[cid])
|
||||
if version >= 3:
|
||||
var list = d.get("decks", [])
|
||||
for i in mini(list.size(), DECK_SLOTS):
|
||||
p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1)))
|
||||
p.decks[i].cards = []
|
||||
for cid in list[i].get("cards", []):
|
||||
if Cards.has_id(cid):
|
||||
p.decks[i].cards.append(cid)
|
||||
p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1)
|
||||
else:
|
||||
for cid in d.get("deck", []):
|
||||
p.campaign_stage = int(d.get("campaign_stage", 0))
|
||||
if int(d.get("version", 1)) >= SAVE_VERSION:
|
||||
var coll = d.get("collection", {})
|
||||
for cid in coll:
|
||||
if Cards.has_id(cid):
|
||||
p.decks[0].cards.append(cid)
|
||||
if version >= 2:
|
||||
var st = d.get("stats", {})
|
||||
p.stats.games = int(st.get("games", 0))
|
||||
p.stats.wins = int(st.get("wins", 0))
|
||||
p.stats.boosters = int(st.get("boosters", 0))
|
||||
for k in st.get("wins_by_opp", {}):
|
||||
p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k])
|
||||
p.campaign_stage = int(d.get("campaign_stage", 0))
|
||||
var dl = d.get("daily", {})
|
||||
p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")),
|
||||
"progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))}
|
||||
var se = d.get("settings", {})
|
||||
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
|
||||
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
|
||||
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
|
||||
"big_font": bool(se.get("big_font", false))}
|
||||
if not DIFFICULTIES.has(p.settings.difficulty):
|
||||
p.settings.difficulty = "normal"
|
||||
if version >= 4:
|
||||
for cid in d.get("card_stats", {}):
|
||||
if Cards.has_id(cid):
|
||||
var st = d.card_stats[cid]
|
||||
p.card_stats[cid] = {"played": int(st.get("played", 0)), "games": int(st.get("games", 0)), "wins": int(st.get("wins", 0))}
|
||||
p.booster_since_mythic = int(d.get("booster_since_mythic", 0))
|
||||
if version >= 5:
|
||||
p.perk = str(d.get("perk", ""))
|
||||
if not Game.PERKS.has(p.perk):
|
||||
p.perk = ""
|
||||
var rc = d.get("records", {})
|
||||
p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)),
|
||||
"fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0)),
|
||||
"margins": Array(rc.get("margins", [])).map(func(m): return int(m))}
|
||||
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
|
||||
var se = d.get("settings", {})
|
||||
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
|
||||
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
|
||||
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
|
||||
"big_font": bool(se.get("big_font", false))}
|
||||
if not DIFFICULTIES.has(p.settings.difficulty):
|
||||
p.settings.difficulty = "normal"
|
||||
p.perk = str(d.get("perk", ""))
|
||||
if not Game.PERKS.has(p.perk):
|
||||
p.perk = ""
|
||||
return p
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
|
||||
"decks": decks, "active_deck": active_deck,
|
||||
"stats": stats, "campaign_stage": campaign_stage,
|
||||
"daily": daily, "settings": settings, "card_stats": card_stats,
|
||||
"booster_since_mythic": booster_since_mythic,
|
||||
"perk": perk, "records": records}
|
||||
"campaign_stage": campaign_stage, "settings": settings, "perk": perk}
|
||||
|
||||
|
||||
## Speichert und sichert den vorherigen Stand als .bak.
|
||||
@@ -185,363 +97,114 @@ func take_notices() -> Array[String]:
|
||||
return out
|
||||
|
||||
|
||||
# --- Decks und Sammlung -----------------------------------------------------------
|
||||
# --- Sammlung: Seltenheit als Aussehen --------------------------------------------
|
||||
|
||||
func deck_cards(slot: int = -1) -> Array:
|
||||
var ids: Array = decks[slot if slot >= 0 else active_deck].cards
|
||||
var out := []
|
||||
for cid in ids:
|
||||
out.append(Cards.by_id(cid))
|
||||
func level(cid: String) -> int:
|
||||
return int(collection.get(cid, 0))
|
||||
|
||||
|
||||
func rarity(cid: String) -> String:
|
||||
return Cards.RARITIES[level(cid)]
|
||||
|
||||
|
||||
## Karten-ID -> Stufe für den Kampf (nur aufgewertete Karten, siehe Game.Player.looks).
|
||||
func looks() -> Dictionary:
|
||||
var out := {}
|
||||
for cid in collection:
|
||||
if int(collection[cid]) > 0:
|
||||
out[cid] = int(collection[cid])
|
||||
return out
|
||||
|
||||
|
||||
func deck_valid(slot: int = -1) -> Array:
|
||||
return Game.validate_deck(deck_cards(slot), collection)
|
||||
|
||||
|
||||
func deck_name(slot: int = -1) -> String:
|
||||
return decks[slot if slot >= 0 else active_deck].name
|
||||
|
||||
|
||||
func rename_deck(slot: int, name: String) -> String:
|
||||
var n := name.strip_edges()
|
||||
if n.is_empty() or n.length() > 20:
|
||||
return "Name muss 1 bis 20 Zeichen haben"
|
||||
decks[slot].name = n
|
||||
return ""
|
||||
|
||||
|
||||
## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve.
|
||||
## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt.
|
||||
func autofill_deck(slot: int) -> String:
|
||||
var cards: Array = decks[slot].cards
|
||||
var elems := Game.deck_elements(deck_cards(slot))
|
||||
if elems.is_empty():
|
||||
var count := {}
|
||||
for c in owned_cards():
|
||||
for e in c.elements:
|
||||
count[e] = count.get(e, 0) + 1
|
||||
var ranked: Array = count.keys()
|
||||
ranked.sort_custom(func(a, b): return count[a] > count[b])
|
||||
elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS)
|
||||
var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems))
|
||||
pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
|
||||
var counts := {}
|
||||
for cid in cards:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
var progressed := true
|
||||
while cards.size() < Game.DECK_SIZE and progressed:
|
||||
progressed = false
|
||||
for cost in range(1, 11):
|
||||
if cards.size() >= Game.DECK_SIZE:
|
||||
break
|
||||
for c in pool:
|
||||
if c.cost != cost:
|
||||
continue
|
||||
var have: int = counts.get(c.id, 0)
|
||||
if have < mini(collection.get(c.id, 0), Game.MAX_COPIES):
|
||||
cards.append(c.id)
|
||||
counts[c.id] = have + 1
|
||||
progressed = true
|
||||
break
|
||||
var ok := Game.validate_deck(deck_cards(slot), collection)
|
||||
return "" if ok[0] else ok[1]
|
||||
|
||||
|
||||
func owned_cards() -> Array:
|
||||
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
|
||||
|
||||
|
||||
func distinct_owned() -> int:
|
||||
## Anzahl Karten über der gewöhnlichen Stufe.
|
||||
func upgraded_count() -> int:
|
||||
var n := 0
|
||||
for cid in collection:
|
||||
if collection[cid] > 0:
|
||||
if int(collection[cid]) > 0:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# --- Zerlegen und Herstellen ------------------------------------------------------
|
||||
|
||||
## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func dismantle(cid: String, n: int = 1) -> String:
|
||||
var owned: int = collection.get(cid, 0)
|
||||
if owned < n:
|
||||
return "Nicht genug Kopien"
|
||||
var needed := 0
|
||||
for d in decks:
|
||||
needed = maxi(needed, d.cards.count(cid))
|
||||
if owned - n < needed:
|
||||
return "Wird in einem Deck gebraucht"
|
||||
collection[cid] = owned - n
|
||||
dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n
|
||||
return ""
|
||||
|
||||
|
||||
## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func craft(cid: String) -> String:
|
||||
var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity]
|
||||
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func upgrade(cid: String) -> String:
|
||||
var lvl := level(cid)
|
||||
if lvl >= Cards.RARITIES.size() - 1:
|
||||
return "Schon auf der höchsten Stufe"
|
||||
var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]]
|
||||
if dust < cost:
|
||||
return "Nicht genug Staub (%d nötig)" % cost
|
||||
dust -= cost
|
||||
collection[cid] = collection.get(cid, 0) + 1
|
||||
daily_event("craft")
|
||||
collection[cid] = lvl + 1
|
||||
return ""
|
||||
|
||||
|
||||
## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück.
|
||||
func dismantle_extras() -> int:
|
||||
var before := dust
|
||||
for cid in collection.keys():
|
||||
var extra: int = collection[cid] - Game.MAX_COPIES
|
||||
if extra > 0:
|
||||
dismantle(cid, extra)
|
||||
return dust - before
|
||||
## Gibt eine Stufe ab und bekommt Staub dafür. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func dismantle(cid: String) -> String:
|
||||
var lvl := level(cid)
|
||||
if lvl == 0:
|
||||
return "Gewöhnliche Karten lassen sich nicht zerlegen"
|
||||
dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
|
||||
collection[cid] = lvl - 1
|
||||
return ""
|
||||
|
||||
|
||||
# --- Booster ----------------------------------------------------------------------
|
||||
|
||||
## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element.
|
||||
static func booster_pool(rarity: String, element: String) -> Array:
|
||||
var pool := Cards.of_rarity(rarity)
|
||||
if element != "":
|
||||
pool = pool.filter(func(c): return element in c.elements)
|
||||
return pool
|
||||
|
||||
|
||||
static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array:
|
||||
## Fünf Karten mit gewürfelter Seltenheit.
|
||||
static func roll_booster(r: RandomNumberGenerator) -> Array:
|
||||
var rarities := RARITY_WEIGHTS.keys()
|
||||
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
|
||||
var cards := []
|
||||
for i in BOOSTER_SIZE:
|
||||
var rarity: String = rarities[r.rand_weighted(weights)]
|
||||
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
|
||||
and cards.all(func(c): return c.rarity == "gewöhnlich"):
|
||||
rarity = "selten" # mindestens eine seltene Karte pro Booster
|
||||
var pool := booster_pool(rarity, element)
|
||||
cards.append(pool[r.randi_range(0, pool.size() - 1)])
|
||||
return cards
|
||||
|
||||
|
||||
## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar).
|
||||
static func deck_code(card_ids: Array) -> String:
|
||||
var counts := {}
|
||||
for cid in card_ids:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
var keys := counts.keys()
|
||||
keys.sort()
|
||||
return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]]))
|
||||
|
||||
|
||||
## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code.
|
||||
static func parse_deck_code(code: String) -> Array:
|
||||
var s := code.strip_edges()
|
||||
if not s.begins_with(CODE_PREFIX):
|
||||
return []
|
||||
var pool := Cards.all()
|
||||
var out := []
|
||||
for part in s.substr(CODE_PREFIX.length()).split(",", false):
|
||||
var kv: PackedStringArray = part.strip_edges().split("*")
|
||||
if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int():
|
||||
return []
|
||||
for i in clampi(int(kv[1]), 0, Game.MAX_COPIES):
|
||||
out.append(kv[0])
|
||||
for i in BOOSTER_SIZE:
|
||||
var rar: String = rarities[r.rand_weighted(weights)]
|
||||
if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "gewöhnlich"):
|
||||
rar = "selten" # mindestens eine seltene Karte pro Booster
|
||||
out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar})
|
||||
return out
|
||||
|
||||
|
||||
## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
|
||||
func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array:
|
||||
var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST
|
||||
if coins < cost:
|
||||
## Ö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.
|
||||
func open_booster(r: RandomNumberGenerator = null) -> Array:
|
||||
if coins < BOOSTER_COST:
|
||||
return []
|
||||
if r == null:
|
||||
r = Game.make_rng()
|
||||
coins -= cost
|
||||
var cards := roll_booster(r, element)
|
||||
booster_since_mythic += 1
|
||||
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
|
||||
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
|
||||
var pool := booster_pool("mythisch", element)
|
||||
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
|
||||
has_mythic = true
|
||||
if has_mythic:
|
||||
booster_since_mythic = 0
|
||||
for c in cards:
|
||||
collection[c.id] = collection.get(c.id, 0) + 1
|
||||
stats.boosters += 1
|
||||
daily_event("booster")
|
||||
return cards
|
||||
coins -= BOOSTER_COST
|
||||
var out := roll_booster(r)
|
||||
for res in out:
|
||||
var lvl: int = Cards.RARITIES.find(res.rarity)
|
||||
if lvl > level(res.card.id):
|
||||
collection[res.card.id] = lvl
|
||||
res["upgraded"] = true
|
||||
res["dust"] = 0
|
||||
else:
|
||||
res["upgraded"] = false
|
||||
res["dust"] = DUST_DISMANTLE[res.rarity]
|
||||
dust += res.dust
|
||||
return out
|
||||
|
||||
|
||||
# --- Kampfergebnis, Tagesaufgabe -------------------------------------------
|
||||
# --- Kampf und Kampagne -----------------------------------------------------------
|
||||
|
||||
## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück.
|
||||
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
|
||||
## turns: Dauer der Partie für die Bestenliste.
|
||||
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
|
||||
turns: int = 0, reward_factor: float = 1.0, remaining_life: int = -1) -> int:
|
||||
stats.games += 1
|
||||
records.margins.append(maxi(remaining_life, 0) if won else 0)
|
||||
while records.margins.size() > 5:
|
||||
records.margins.pop_front()
|
||||
if won:
|
||||
records.streak += 1
|
||||
records.loss_streak = 0
|
||||
records.best_streak = maxi(records.best_streak, records.streak)
|
||||
if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win):
|
||||
records.fastest_win = turns
|
||||
else:
|
||||
records.streak = 0
|
||||
records.loss_streak += 1
|
||||
var tip := suggest_difficulty()
|
||||
if tip != "" and stage < 0:
|
||||
notices.append("Tipp: %s. Schwierigkeit %s?" % [
|
||||
"%d Siege in Folge" % records.streak if won else "%d Niederlagen in Folge" % records.loss_streak, DIFFICULTIES[tip].name])
|
||||
for cid in played_ids:
|
||||
_stat(cid).played += 1
|
||||
var seen := {}
|
||||
for c in deck_used:
|
||||
if not seen.has(c.id):
|
||||
seen[c.id] = true
|
||||
_stat(c.id).games += 1
|
||||
if won:
|
||||
_stat(c.id).wins += 1
|
||||
func record_battle(won: bool, n_opp: int, stage: int = -1, reward_factor: float = 1.0) -> int:
|
||||
var reward := REWARD_LOSS
|
||||
if won:
|
||||
stats.wins += 1
|
||||
var key := str(n_opp)
|
||||
stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1
|
||||
reward = int(round(REWARD_WIN[n_opp] * reward_factor))
|
||||
if stage >= 0 and stage == campaign_stage:
|
||||
campaign_stage += 1
|
||||
reward = Campaign.stage(stage).reward
|
||||
notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name)
|
||||
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
|
||||
coins += reward
|
||||
daily_event("game")
|
||||
if won:
|
||||
daily_event("win")
|
||||
if n_opp >= 3:
|
||||
daily_event("win_vs3")
|
||||
for e in Game.deck_elements(deck_used):
|
||||
daily_event("win_element:" + e)
|
||||
return reward
|
||||
|
||||
|
||||
func _stat(cid: String) -> Dictionary:
|
||||
if not card_stats.has(cid):
|
||||
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
|
||||
return card_stats[cid]
|
||||
|
||||
|
||||
func difficulty() -> Dictionary:
|
||||
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
|
||||
|
||||
|
||||
## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf
|
||||
## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag.
|
||||
func suggest_difficulty() -> String:
|
||||
var order := ["leicht", "normal", "schwer"]
|
||||
var i := order.find(str(settings.get("difficulty", "normal")))
|
||||
if i < order.size() - 1 and records.streak >= 3:
|
||||
var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3))
|
||||
var avg := 0.0
|
||||
for m in last:
|
||||
avg += float(m) / last.size()
|
||||
if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0):
|
||||
return order[i + 1]
|
||||
if records.loss_streak >= 3 and i > 0:
|
||||
return order[i - 1]
|
||||
return ""
|
||||
|
||||
|
||||
## Eigene Deckstärke, mit Kartenstatistik gewichtet.
|
||||
func deck_rating(cards: Array = []) -> float:
|
||||
var deck_used: Array = cards if not cards.is_empty() else deck_cards()
|
||||
return Game.deck_rating(deck_used, card_stats)
|
||||
|
||||
|
||||
## Zielstärke der Gegnerdecks: eigene Deckstärke mal Schwierigkeitsfaktor.
|
||||
func target_rating(cards: Array = []) -> float:
|
||||
return deck_rating(cards) * float(difficulty().factor)
|
||||
|
||||
|
||||
## Tauscht die schwächste Karte des aktiven Decks gegen cid, wenn das Deck dadurch stärker und gültig bleibt.
|
||||
## dry_run: nur prüfen. Gibt "" zurück oder den Grund, warum nicht.
|
||||
func improve_deck_with(cid: String, dry_run: bool = false) -> String:
|
||||
if collection.get(cid, 0) <= 0:
|
||||
return "Karte nicht in der Sammlung"
|
||||
var card := Cards.by_id(cid)
|
||||
if deck.count(cid) >= mini(collection[cid], Game.MAX_COPIES):
|
||||
return "Schon so oft im Deck wie möglich"
|
||||
var elems := Game.deck_elements(deck_cards())
|
||||
for e in card.elements:
|
||||
if not (e in elems):
|
||||
elems.append(e)
|
||||
if elems.size() > Game.MAX_DECK_ELEMENTS:
|
||||
return "Passt nicht zu den Elementen des Decks"
|
||||
var weakest := ""
|
||||
var weakest_value := Cards.card_value(card)
|
||||
for other in deck:
|
||||
if other == cid:
|
||||
continue
|
||||
var v := Cards.card_value(Cards.by_id(other))
|
||||
if v < weakest_value:
|
||||
weakest_value = v
|
||||
weakest = other
|
||||
if weakest == "":
|
||||
return "Keine schwächere Karte im Deck"
|
||||
if dry_run:
|
||||
return ""
|
||||
var trial: Array = deck.duplicate()
|
||||
trial[trial.find(weakest)] = cid
|
||||
var ok := Game.validate_deck(trial.map(func(id): return Cards.by_id(id)), collection)
|
||||
if not ok[0]:
|
||||
return ok[1]
|
||||
deck = trial
|
||||
return ""
|
||||
|
||||
|
||||
## Karten aus new_ids, die das aktive Deck verbessern würden.
|
||||
func deck_suggestions(new_ids: Array) -> Array:
|
||||
var out := []
|
||||
for cid in new_ids:
|
||||
if not (cid in out) and improve_deck_with(cid, true) == "":
|
||||
out.append(cid)
|
||||
return out
|
||||
|
||||
|
||||
func perk_unlocked() -> bool:
|
||||
return campaign_stage >= Campaign.STAGES.size()
|
||||
|
||||
|
||||
static func today() -> String:
|
||||
return today_override if today_override != "" else Time.get_date_string_from_system()
|
||||
|
||||
|
||||
## Wählt die Tagesaufgabe neu, wenn ein neuer Tag begonnen hat.
|
||||
func refresh_daily() -> void:
|
||||
var t := today()
|
||||
if daily.date != t:
|
||||
var day_number := int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 86400)
|
||||
var task: Dictionary = DAILY_TASKS[day_number % DAILY_TASKS.size()]
|
||||
daily = {"date": t, "task": task.id, "progress": 0, "done": false}
|
||||
|
||||
|
||||
func daily_task() -> Dictionary:
|
||||
refresh_daily()
|
||||
for t in DAILY_TASKS:
|
||||
if t.id == daily.task:
|
||||
return t
|
||||
return DAILY_TASKS[0]
|
||||
|
||||
|
||||
## Zählt ein Ereignis (siehe DAILY_TASKS) für die Tagesaufgabe. Gibt die Belohnung zurück.
|
||||
func daily_event(kind: String, n: int = 1) -> int:
|
||||
var task := daily_task()
|
||||
if daily.done or task.kind != kind:
|
||||
return 0
|
||||
daily.progress += n
|
||||
if daily.progress >= task.goal:
|
||||
daily.done = true
|
||||
coins += task.reward
|
||||
notices.append("Tagesaufgabe erfüllt: %s (+%d Münzen)" % [task.text, task.reward])
|
||||
return task.reward
|
||||
return 0
|
||||
|
||||
@@ -46,24 +46,16 @@ func play_events(evs: Array, centers: Dictionary) -> void:
|
||||
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
|
||||
"play":
|
||||
sfx("play")
|
||||
"spell":
|
||||
sfx("play")
|
||||
var caster: Game.Player = g.players[ev.player]
|
||||
popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
if caster != screen.perspective:
|
||||
screen.show_preview(ev.card, true)
|
||||
screen.preview_timer = 1.6
|
||||
"trap_set":
|
||||
sfx("click")
|
||||
popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
"trap":
|
||||
"fuse":
|
||||
sfx("combo")
|
||||
popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0)
|
||||
screen.show_preview(ev.card, true)
|
||||
screen.preview_timer = 1.8
|
||||
"mulligan":
|
||||
if ev.count > 0:
|
||||
popup(("tauscht 1 Karte" if int(ev.count) == 1 else "tauscht %d Karten" % ev.count), Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
"field":
|
||||
sfx("combo")
|
||||
popup("Feld: %s" % Cards.FIELD_NAMES[ev.element], Cards.ELEMENT_COLORS[ev.element], Vector2(640, 196), 0.0)
|
||||
"buy":
|
||||
sfx("click") # die Karte landet sichtbar auf der Hand, das reicht
|
||||
"reroll":
|
||||
sfx("click")
|
||||
|
||||
|
||||
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
||||
@@ -73,9 +65,24 @@ func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
||||
return c
|
||||
|
||||
|
||||
## Mehrere Meldungen an derselben Stelle rücken nach unten, sonst liegen sie übereinander.
|
||||
func _free_spot(at: Vector2) -> Vector2:
|
||||
for step in 6:
|
||||
var spot := at + Vector2(0, step * 26)
|
||||
var taken := false
|
||||
for l in screen.fx_layer.get_children():
|
||||
if l is Label and l.get_meta("spot", Vector2(-999, -999)).distance_to(spot) < 20:
|
||||
taken = true
|
||||
break
|
||||
if not taken:
|
||||
return spot
|
||||
return at
|
||||
|
||||
|
||||
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
||||
if not enabled or at.x < 0:
|
||||
return
|
||||
at = _free_spot(at)
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 22)
|
||||
@@ -84,6 +91,7 @@ func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
||||
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
l.add_theme_constant_override("outline_size", 6)
|
||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
l.set_meta("spot", at)
|
||||
screen.fx_layer.add_child(l)
|
||||
l.reset_size()
|
||||
l.position = at - l.size / 2
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class_name BattleGamepad
|
||||
extends RefCounted
|
||||
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele,
|
||||
## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig.
|
||||
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Laden, Hand, eigene Wesen
|
||||
## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge,
|
||||
## X macht rückgängig.
|
||||
|
||||
var screen # BattleScreen
|
||||
var active := false
|
||||
var zone := "hand"
|
||||
var zone := "shop"
|
||||
var index := 0
|
||||
var targets: Array = []
|
||||
|
||||
@@ -26,7 +27,7 @@ func button(button: int) -> void:
|
||||
if screen.confirm.visible:
|
||||
screen.confirm.visible = false
|
||||
screen.cancel_all()
|
||||
zone = "hand"
|
||||
zone = "shop"
|
||||
JOY_BUTTON_Y:
|
||||
if screen.b_skip.visible:
|
||||
screen.skip_ai = true
|
||||
@@ -41,16 +42,35 @@ func button(button: int) -> void:
|
||||
move(1)
|
||||
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
|
||||
if zone != "target":
|
||||
zone = "own" if zone == "hand" else "hand"
|
||||
index = 0
|
||||
_next_zone(1 if button == JOY_BUTTON_DPAD_DOWN else -1)
|
||||
screen.refresh()
|
||||
|
||||
|
||||
## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station).
|
||||
## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält.
|
||||
func _next_zone(step: int) -> void:
|
||||
var order := ["shop", "hand", "own"]
|
||||
var at := order.find(zone)
|
||||
index = 0
|
||||
if step == 0 and not items().is_empty():
|
||||
return
|
||||
var dir := -1 if step < 0 else 1
|
||||
for i in order.size():
|
||||
at = posmod(at + dir, order.size())
|
||||
zone = order[at]
|
||||
if not items().is_empty():
|
||||
return
|
||||
|
||||
|
||||
func items() -> Array:
|
||||
match zone:
|
||||
"shop":
|
||||
return screen.shop_views
|
||||
"hand":
|
||||
return screen.hand_views
|
||||
"own":
|
||||
if screen.fusing: # zum Verschmelzen zählt jedes eigene Wesen, nicht nur angriffsbereite
|
||||
return screen.own_views
|
||||
return screen.own_views.filter(func(v): return v.creature.can_attack())
|
||||
"target":
|
||||
return targets
|
||||
@@ -64,9 +84,6 @@ func move(dir: int) -> void:
|
||||
|
||||
|
||||
func confirm() -> void:
|
||||
if screen.mulligan.visible:
|
||||
screen.do_mulligan([])
|
||||
return
|
||||
if screen.overlay.visible:
|
||||
screen.leave()
|
||||
return
|
||||
@@ -78,6 +95,8 @@ func confirm() -> void:
|
||||
index = clampi(index, 0, items.size() - 1)
|
||||
var item = items[index]
|
||||
match zone:
|
||||
"shop":
|
||||
screen.on_click("shop", int(item.get_meta("slot", 0)))
|
||||
"hand":
|
||||
screen.on_click("hand", item.card)
|
||||
if screen.pending != null:
|
||||
@@ -113,8 +132,8 @@ func confirm() -> void:
|
||||
for o in screen.hero_panels:
|
||||
if screen.hero_panels[o] == item:
|
||||
screen.on_click("hero", o)
|
||||
zone = "hand"
|
||||
index = 0
|
||||
zone = "own"
|
||||
_next_zone(0)
|
||||
|
||||
|
||||
## Pulsierender Rahmen als Zeiger.
|
||||
|
||||
@@ -30,11 +30,13 @@ static func other_slot(n_others: int, i: int, j: int) -> Rect2:
|
||||
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
|
||||
|
||||
|
||||
## Laden: fünf Angebote über der Hand.
|
||||
static func shop_rect(i: int) -> Rect2:
|
||||
return Rect2(340 + i * 121, 487, 115, 112)
|
||||
|
||||
|
||||
static func hand_rect(count: int, i: int) -> Rect2:
|
||||
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
|
||||
var x := (1280 - total) / 2
|
||||
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
|
||||
|
||||
|
||||
static func trap_rect(i: int) -> Rect2:
|
||||
return Rect2(400 + i * 78, 490, 70, 98)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
class_name BattleMulligan
|
||||
extends Control
|
||||
## Mulligan-Maske: Karten anklicken, behalten oder tauschen.
|
||||
|
||||
signal decided(indexes: Array)
|
||||
|
||||
var layer: Control
|
||||
var b_keep: Button
|
||||
var b_swap: Button
|
||||
var selected: Array = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
visible = false
|
||||
var dark := ColorRect.new()
|
||||
dark.color = Color(0, 0, 0, 0.67)
|
||||
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
dark.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(dark)
|
||||
Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
|
||||
Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
|
||||
Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
|
||||
layer = Control.new()
|
||||
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(layer)
|
||||
b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c"))
|
||||
b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true))
|
||||
|
||||
|
||||
func open(hand: Array) -> void:
|
||||
selected = []
|
||||
visible = true
|
||||
Style.clear(layer)
|
||||
for i in hand.size():
|
||||
var r := BattleLayout.hand_rect(hand.size(), i)
|
||||
var v := CardView.new(hand[i], r.size)
|
||||
v.position = Vector2(r.position.x, 322)
|
||||
var idx := i
|
||||
v.clicked.connect(func(_view): toggle(idx))
|
||||
layer.add_child(v)
|
||||
_borders()
|
||||
|
||||
|
||||
func toggle(idx: int) -> void:
|
||||
if idx in selected:
|
||||
selected.erase(idx)
|
||||
else:
|
||||
selected.append(idx)
|
||||
_borders()
|
||||
|
||||
|
||||
func _borders() -> void:
|
||||
var views := layer.get_children()
|
||||
for i in views.size():
|
||||
views[i].border = Style.YELLOW if i in selected else null
|
||||
views[i].queue_redraw()
|
||||
b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen"
|
||||
|
||||
|
||||
func finish(swap: bool) -> void:
|
||||
visible = false
|
||||
decided.emit(selected.duplicate() if swap else [])
|
||||
@@ -1 +0,0 @@
|
||||
uid://gwdm38e83cbj
|
||||
@@ -14,6 +14,8 @@ var perspective: Game.Player # der Mensch, dessen Hand unten liegt
|
||||
var fx: BattleFx
|
||||
var selected = null # Game.Creature
|
||||
var pending = null # Cards.Card, wartet auf Ziel
|
||||
var fusing := false # Verschmelzen gewählt: zwei eigene Wesen anklicken
|
||||
var fuse_first = null # Game.Creature, erstes Wesen der Verschmelzung
|
||||
var msg_t := 0.0
|
||||
var ai_delay := 0.75
|
||||
var ai_timer := 0.0
|
||||
@@ -24,7 +26,6 @@ var reward := -1
|
||||
var ended := false
|
||||
var zoom_player = null
|
||||
var preview_timer := 0.0
|
||||
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
|
||||
var pad: BattleGamepad
|
||||
var gp_zone: String:
|
||||
get:
|
||||
@@ -42,9 +43,9 @@ var undo_t := 0.0
|
||||
# Ansichten (auch für Tests)
|
||||
var tiles := {}
|
||||
var hand_views: Array = []
|
||||
var shop_views: Array = []
|
||||
var own_views: Array = []
|
||||
var enemy_views: Array = []
|
||||
var trap_views: Array = []
|
||||
var hero_panels := {} # Player -> Panel
|
||||
var hero_labels := {}
|
||||
var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
|
||||
@@ -52,43 +53,29 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
|
||||
var hero_layer: Control
|
||||
var tile_layer: Control
|
||||
var hand_layer: Control
|
||||
var trap_layer: Control
|
||||
var shop_layer: Control
|
||||
var zoom_layer: Control
|
||||
var fx_layer: Control
|
||||
var log_box: RichTextLabel
|
||||
var preview_holder: Control
|
||||
var preview_hint: Label
|
||||
var bar_panel: Panel
|
||||
var name_label: Label
|
||||
var life_label: Label
|
||||
var energy_label: Label
|
||||
var deck_label: Label
|
||||
var income_label: Label
|
||||
var status_label: Label
|
||||
var msg_label: Label
|
||||
var b_end: Button
|
||||
var b_fuse: Button
|
||||
var b_reroll: Button
|
||||
var b_giveup: Button
|
||||
var field_label: Label
|
||||
var overlay: Control
|
||||
var result_label: Label
|
||||
var reward_label: Label
|
||||
var notice_label: Label
|
||||
var confirm: Control
|
||||
var b_save_leave: Button
|
||||
var mulligan: BattleMulligan
|
||||
var mulligan_box: Control:
|
||||
get:
|
||||
return mulligan
|
||||
var mulligan_layer: Control:
|
||||
get:
|
||||
return mulligan.layer
|
||||
var b_keep: Button:
|
||||
get:
|
||||
return mulligan.b_keep
|
||||
var b_swap: Button:
|
||||
get:
|
||||
return mulligan.b_swap
|
||||
var mulligan_selected: Array:
|
||||
get:
|
||||
return mulligan.selected
|
||||
var cursor_layer: Control
|
||||
var wait_label: Label
|
||||
var b_skip: Button
|
||||
@@ -110,7 +97,6 @@ static func online(seat: int) -> BattleScreen:
|
||||
## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle).
|
||||
static func resume(data: Dictionary) -> BattleScreen:
|
||||
var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1)))
|
||||
s.played_ids = Array(data.get("played_ids", []))
|
||||
s.saved_game = Game.from_snapshot(data.game)
|
||||
return s
|
||||
|
||||
@@ -155,17 +141,19 @@ func _ready() -> void:
|
||||
game = saved_game
|
||||
elif stage >= 0:
|
||||
var r := Game.make_rng()
|
||||
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules())
|
||||
game = Game.new_battle_with(Campaign.opponents(stage), r, false, own_rules())
|
||||
else:
|
||||
game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules())
|
||||
game = Game.new_battle(n_opp, Game.make_rng(), own_rules())
|
||||
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile()
|
||||
_begin()
|
||||
|
||||
|
||||
## Eigenes Merkmal des Spielers (nach Kapitel 1 wählbar) und Zielstärke der Gegner.
|
||||
## Kartenoptik, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
|
||||
func own_rules() -> Dictionary:
|
||||
var p: Profile = app.profile
|
||||
var rules := {"target_rating": p.target_rating(), "tier": str(p.difficulty().tier)}
|
||||
var rules := {"looks": p.looks()}
|
||||
if stage < 0:
|
||||
rules["opponent_life"] = int(p.difficulty().life)
|
||||
if p.perk != "" and p.perk_unlocked():
|
||||
rules["human_perks"] = [p.perk]
|
||||
return rules
|
||||
@@ -188,11 +176,11 @@ func _begin() -> void:
|
||||
|
||||
|
||||
func _build_static() -> void:
|
||||
Style.panel(self, Rect2(10, 205, 320, 340))
|
||||
Style.panel(self, Rect2(10, 205, 320, 290))
|
||||
Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20))
|
||||
log_box = RichTextLabel.new()
|
||||
log_box.position = Vector2(18, 230)
|
||||
log_box.size = Vector2(304, 308)
|
||||
log_box.size = Vector2(304, 258)
|
||||
log_box.bbcode_enabled = true
|
||||
log_box.scroll_following = true
|
||||
log_box.add_theme_font_size_override("normal_font_size", 13)
|
||||
@@ -200,13 +188,12 @@ func _build_static() -> void:
|
||||
add_child(log_box)
|
||||
b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true)
|
||||
Style.panel(self, Rect2(950, 205, 320, 380))
|
||||
preview_hint = Style.label(self, "Karte überfahren für Details", 14, Style.MUTED, Rect2(950, 345, 320, 24), "center")
|
||||
Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10)
|
||||
bar_panel = Style.panel(self, BattleLayout.BAR)
|
||||
name_label = Style.label(self, "Du", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true)
|
||||
name_label = Style.label(self, "", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true)
|
||||
life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true)
|
||||
energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true)
|
||||
deck_label = Style.label(self, "", 16, Style.MUTED, Rect2(780, 424, 150, 24), "right")
|
||||
income_label = Style.label(self, "", 16, Style.MUTED, Rect2(760, 424, 170, 24), "right")
|
||||
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24))
|
||||
msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true)
|
||||
b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c"))
|
||||
@@ -215,20 +202,21 @@ func _build_static() -> void:
|
||||
b_skip.visible = false
|
||||
b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo)
|
||||
b_undo.visible = false
|
||||
field_label = Style.label(self, "", 15, Style.MUTED, Rect2(340, 211, 600, 24), "center")
|
||||
b_fuse = Style.button(self, "Verschmelzen", Rect2(10, 503, 150, 40), toggle_fuse, Color("3c2858"))
|
||||
b_fuse.visible = false
|
||||
b_reroll = Style.button(self, "", Rect2(170, 503, 150, 40), do_reroll, Color("285a5a"))
|
||||
b_reroll.add_theme_font_size_override("font_size", 14)
|
||||
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
|
||||
wait_label.visible = false
|
||||
hero_layer = _layer()
|
||||
tile_layer = _layer()
|
||||
trap_layer = _layer()
|
||||
hand_layer = _layer()
|
||||
shop_layer = _layer()
|
||||
preview_holder = _layer()
|
||||
zoom_layer = _layer()
|
||||
fx_layer = _layer()
|
||||
cursor_layer = _layer()
|
||||
# Mulligan
|
||||
mulligan = BattleMulligan.new()
|
||||
mulligan.decided.connect(do_mulligan)
|
||||
add_child(mulligan)
|
||||
# Spielende
|
||||
overlay = _overlay()
|
||||
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
|
||||
@@ -239,7 +227,6 @@ func _build_static() -> void:
|
||||
confirm = Style.overlay(self)
|
||||
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2)
|
||||
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
|
||||
Style.label(confirm, tr("Aufgeben beendet den Kampf ohne Münzen.") + "\n" + tr("Speichern hebt ihn für später auf."), 15, Style.MUTED, Rect2(340, 356, 600, 48), "center")
|
||||
Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828"))
|
||||
b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c"))
|
||||
b_save_leave.add_theme_font_size_override("font_size", 14)
|
||||
@@ -265,7 +252,7 @@ func resumable() -> bool:
|
||||
func save_battle() -> void:
|
||||
if not resumable() or game == null or game.winner != "":
|
||||
return
|
||||
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "played_ids": played_ids, "game": game.snapshot(-1)}
|
||||
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "game": game.snapshot(-1)}
|
||||
var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE)
|
||||
if f != null:
|
||||
f.store_string(JSON.stringify(d))
|
||||
@@ -371,7 +358,7 @@ func my_turn() -> bool:
|
||||
|
||||
|
||||
func any_overlay() -> bool:
|
||||
return confirm.visible or mulligan.visible or overlay.visible
|
||||
return confirm.visible or overlay.visible
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
@@ -389,7 +376,7 @@ func _input(event: InputEvent) -> void:
|
||||
KEY_ESCAPE:
|
||||
if confirm.visible:
|
||||
confirm.visible = false
|
||||
elif selected != null or pending != null or zoom_player != null:
|
||||
elif selected != null or pending != null or fusing or zoom_player != null:
|
||||
cancel_all()
|
||||
elif not any_overlay():
|
||||
confirm.visible = true
|
||||
@@ -402,9 +389,7 @@ func _input(event: InputEvent) -> void:
|
||||
if b_undo.visible:
|
||||
undo()
|
||||
KEY_ENTER, KEY_KP_ENTER:
|
||||
if mulligan.visible:
|
||||
do_mulligan([])
|
||||
elif overlay.visible:
|
||||
if overlay.visible:
|
||||
leave()
|
||||
_:
|
||||
if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay():
|
||||
@@ -444,8 +429,6 @@ func _process(delta: float) -> void:
|
||||
return
|
||||
var cur := game.current()
|
||||
if cur.is_human:
|
||||
if cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible:
|
||||
show_mulligan()
|
||||
return
|
||||
if is_client():
|
||||
return
|
||||
@@ -477,7 +460,7 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
func _start_ai(cur: Game.Player) -> void:
|
||||
ai_copy = game.clone_for(cur)
|
||||
ai_copy = game.clone()
|
||||
var copy := ai_copy
|
||||
var idx := game.current_idx
|
||||
var box := ai_box
|
||||
@@ -494,7 +477,7 @@ 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, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, factor, game.human.life)
|
||||
reward = app.profile.record_battle(won, n_opp, stage, factor)
|
||||
app.save()
|
||||
app.sfx.play("win" if won else "lose")
|
||||
app.clear_battle_save()
|
||||
@@ -513,19 +496,21 @@ func say(m: String) -> void:
|
||||
func cancel() -> void:
|
||||
selected = null
|
||||
pending = null
|
||||
fusing = false
|
||||
fuse_first = null
|
||||
|
||||
|
||||
func play(card: Cards.Card, target) -> void:
|
||||
if is_client():
|
||||
net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null})
|
||||
return
|
||||
var before: Game = game.clone() if card.is_creature() and not is_online() else null
|
||||
var before: Game = game.clone() if not is_online() else null
|
||||
var log_len := game.log.size()
|
||||
var err := game.play_card(perspective, card, target)
|
||||
if err != "":
|
||||
say(tr(err))
|
||||
return
|
||||
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte, Kombos, Fallen)
|
||||
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte und keine Kombos)
|
||||
if before != null and game.events.size() == 1 and game.events[0].type == "play":
|
||||
undo_state = before
|
||||
undo_log_len = log_len
|
||||
@@ -534,6 +519,52 @@ func play(card: Cards.Card, target) -> void:
|
||||
clear_undo()
|
||||
|
||||
|
||||
## Verschmelzen an- und abwählen (zwei eigene Wesen, siehe Game.fuse).
|
||||
func toggle_fuse() -> void:
|
||||
var on := not fusing
|
||||
cancel()
|
||||
fusing = on
|
||||
say(tr("Zwei eigene Wesen wählen") if on else tr("Abgebrochen"))
|
||||
after_action()
|
||||
|
||||
|
||||
## Kauft ein Angebot aus dem Laden auf die Hand.
|
||||
func do_buy(slot: int) -> void:
|
||||
clear_undo()
|
||||
if is_client():
|
||||
net.send_action({"type": "buy", "slot": slot})
|
||||
return
|
||||
var err := game.buy(perspective, slot)
|
||||
if err != "":
|
||||
say(tr(err))
|
||||
|
||||
|
||||
## Würfelt den Laden neu (kostet Mana).
|
||||
func do_reroll() -> void:
|
||||
if not my_turn() or any_overlay():
|
||||
return
|
||||
clear_undo()
|
||||
if is_client():
|
||||
net.send_action({"type": "reroll"})
|
||||
after_action()
|
||||
return
|
||||
var err := game.reroll(perspective)
|
||||
if err != "":
|
||||
say(tr(err))
|
||||
after_action()
|
||||
|
||||
|
||||
func do_fuse(a, b) -> void:
|
||||
cancel()
|
||||
clear_undo()
|
||||
if is_client():
|
||||
net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid})
|
||||
return
|
||||
var err := game.fuse(perspective, a, b)
|
||||
if err != "":
|
||||
say(tr(err))
|
||||
|
||||
|
||||
func clear_undo() -> void:
|
||||
undo_state = null
|
||||
undo_t = 0.0
|
||||
@@ -574,26 +605,23 @@ func end_turn() -> void:
|
||||
after_action()
|
||||
|
||||
|
||||
func show_mulligan() -> void:
|
||||
mulligan.open(perspective.hand)
|
||||
|
||||
|
||||
func do_mulligan(idxs: Array) -> void:
|
||||
mulligan.visible = false
|
||||
if is_client():
|
||||
net.send_action({"type": "mulligan", "hand": idxs})
|
||||
return
|
||||
var cards := []
|
||||
for i in idxs:
|
||||
cards.append(perspective.hand[i])
|
||||
game.mulligan(perspective, cards)
|
||||
after_action()
|
||||
|
||||
|
||||
func on_click(kind: String, obj) -> void:
|
||||
var g := game
|
||||
if not my_turn() or any_overlay():
|
||||
return
|
||||
if fusing:
|
||||
if kind != "own":
|
||||
cancel()
|
||||
say(tr("Abgebrochen"))
|
||||
elif fuse_first == null:
|
||||
fuse_first = obj
|
||||
say(tr("Zweites Wesen wählen"))
|
||||
elif obj == fuse_first:
|
||||
fuse_first = null
|
||||
else:
|
||||
do_fuse(fuse_first, obj)
|
||||
after_action()
|
||||
return
|
||||
if pending != null:
|
||||
var card = pending
|
||||
pending = null
|
||||
@@ -619,6 +647,8 @@ func on_click(kind: String, obj) -> void:
|
||||
after_action()
|
||||
return
|
||||
match kind:
|
||||
"shop":
|
||||
do_buy(int(obj))
|
||||
"hand":
|
||||
var ok := g.can_play(perspective, obj)
|
||||
if not ok[0]:
|
||||
@@ -647,61 +677,17 @@ func on_tile_click(cr: Game.Creature) -> void:
|
||||
on_click("enemy", cr)
|
||||
|
||||
|
||||
func show_preview(card, entered: bool) -> void:
|
||||
func show_preview(card, entered: bool, owner_looks: Dictionary = {}) -> void:
|
||||
Style.clear(preview_holder)
|
||||
if entered and card != null:
|
||||
var v := CardView.new(card, Vector2(210, 294))
|
||||
var v := CardView.new(card, Vector2(210, 294), false, Cards.RARITIES[int(owner_looks.get(card.id, 0))])
|
||||
v.position = Vector2(1005, 215)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
preview_hint.visible = not (entered and card != null)
|
||||
|
||||
|
||||
## Vorschau einer Handkarte mit Hinweisen auf Resonanz und Feldkombos mit dem eigenen Feld.
|
||||
func show_hand_preview(card: Cards.Card, entered: bool) -> void:
|
||||
Style.clear(preview_holder)
|
||||
preview_hint.visible = not entered
|
||||
if not entered:
|
||||
return
|
||||
var hints: Array[String] = combo_hints(card)
|
||||
var v := CardView.new(card, Vector2(190, 266) if not hints.is_empty() else Vector2(210, 294))
|
||||
v.position = Vector2(1015, 212) if not hints.is_empty() else Vector2(1005, 215)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
if not hints.is_empty():
|
||||
var box := RichTextLabel.new()
|
||||
box.position = Vector2(958, 480)
|
||||
box.size = Vector2(304, 46)
|
||||
box.bbcode_enabled = true
|
||||
box.scroll_active = false
|
||||
box.fit_content = false
|
||||
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
box.add_theme_font_size_override("normal_font_size", 11)
|
||||
box.add_theme_color_override("default_color", Style.GOLD)
|
||||
box.text = "[center]" + "\n".join(hints.slice(0, 2)) + "[/center]"
|
||||
preview_holder.add_child(box)
|
||||
|
||||
|
||||
func combo_hints(card: Cards.Card) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
if not card.is_creature():
|
||||
return out
|
||||
var partners: Array[String] = []
|
||||
var combos := {}
|
||||
for other in perspective.board:
|
||||
if other.card.elements.any(func(e): return e in card.elements):
|
||||
partners.append(other.card.title())
|
||||
for e1 in card.elements:
|
||||
for e2 in other.card.elements:
|
||||
if e1 != e2:
|
||||
var name := Cards.combo_name([e1, e2])
|
||||
if not combos.has(name):
|
||||
combos[name] = other.card.title()
|
||||
if not partners.is_empty():
|
||||
out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)])
|
||||
for name in combos:
|
||||
out.append("Feldkombo %s mit %s: %s" % [name, combos[name], Cards.FIELD_COMBO_TEXT[name]])
|
||||
return out
|
||||
show_preview(card, entered, perspective.looks)
|
||||
|
||||
|
||||
# --- Netzwerk -------------------------------------------------------------------------------
|
||||
@@ -751,8 +737,6 @@ func after_action() -> void:
|
||||
net.broadcast()
|
||||
var evs := game.events.duplicate()
|
||||
game.events.clear()
|
||||
if counts_for_profile():
|
||||
_note_events(evs)
|
||||
var centers := {}
|
||||
for ev in evs:
|
||||
for key in ["target", "attacker", "source"]:
|
||||
@@ -763,22 +747,6 @@ func after_action() -> void:
|
||||
save_battle()
|
||||
|
||||
|
||||
func _note_events(evs: Array) -> void:
|
||||
var hi := game.players.find(game.human)
|
||||
for ev in evs:
|
||||
if ev.get("player", -1) != hi:
|
||||
continue
|
||||
match ev.type:
|
||||
"play", "spell":
|
||||
played_ids.append(ev.card.id)
|
||||
if ev.type == "play":
|
||||
app.profile.daily_event("creature_played")
|
||||
"trap_set":
|
||||
app.profile.daily_event("trap_set")
|
||||
"combo":
|
||||
app.profile.daily_event("combo")
|
||||
|
||||
|
||||
# --- Ansicht angleichen --------------------------------------------------------------------
|
||||
|
||||
func refresh() -> void:
|
||||
@@ -790,9 +758,9 @@ func refresh() -> void:
|
||||
var mine := my_turn()
|
||||
var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else []
|
||||
hand_views = []
|
||||
shop_views = []
|
||||
own_views = []
|
||||
enemy_views = []
|
||||
trap_views = []
|
||||
|
||||
for o in others():
|
||||
var panel: Panel = hero_panels[o]
|
||||
@@ -813,8 +781,8 @@ func refresh() -> void:
|
||||
labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
|
||||
if o.alive():
|
||||
labels.life.text = "♥ %d" % maxi(o.life, 0)
|
||||
var traps_txt := (" F %d" if compact() else " Fallen %d") % o.traps.size() if not o.traps.is_empty() else ""
|
||||
labels.stats.text = ("H %d D %d%s ◆ %d" if compact() else "Hand %d Deck %d%s ◆ %d") % [o.hand.size(), o.deck.size(), traps_txt, o.max_energy]
|
||||
labels.stats.text = ("H %d ◆ %d" % [o.hand.size(), o.energy]) if compact() \
|
||||
else ("Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy])
|
||||
else:
|
||||
labels.life.text = "besiegt"
|
||||
labels.life.add_theme_color_override("font_color", Style.MUTED)
|
||||
@@ -840,7 +808,8 @@ func refresh() -> void:
|
||||
tile.slot = rect.position
|
||||
tiles[cr.uid] = tile
|
||||
tile_layer.add_child(tile)
|
||||
tile.hover.connect(show_preview)
|
||||
var looks: Dictionary = p.looks
|
||||
tile.hover.connect(func(card, entered): show_preview(card, entered, looks))
|
||||
var uid := cr.uid
|
||||
tile.clicked.connect(func(_v):
|
||||
var c := game.find_creature(uid)
|
||||
@@ -852,9 +821,12 @@ func refresh() -> void:
|
||||
fx.move_to(tile, rect.position)
|
||||
var b = null
|
||||
var w := 2
|
||||
if cr == selected:
|
||||
if cr == selected or cr == fuse_first:
|
||||
b = Style.YELLOW
|
||||
w = 4
|
||||
elif fusing and p == h and mine:
|
||||
b = Style.PURPLE
|
||||
w = 3
|
||||
elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
|
||||
b = Style.ORANGE
|
||||
w = 3
|
||||
@@ -874,15 +846,23 @@ func refresh() -> void:
|
||||
tiles.erase(uid)
|
||||
fx.fade_out(t)
|
||||
|
||||
Style.clear(trap_layer)
|
||||
if not h.traps.is_empty():
|
||||
Style.label(trap_layer, "Fallen", 13, Style.MUTED, Rect2(335, 492, 60, 20))
|
||||
for i in h.traps.size():
|
||||
var tv := CardView.new(h.traps[i], BattleLayout.trap_rect(i).size)
|
||||
tv.position = BattleLayout.trap_rect(i).position
|
||||
tv.hover.connect(show_preview)
|
||||
trap_layer.add_child(tv)
|
||||
trap_views.append(tv)
|
||||
Style.clear(shop_layer)
|
||||
if not spectating():
|
||||
for i in h.shop.size():
|
||||
var offer = h.shop[i]
|
||||
var sr := BattleLayout.shop_rect(i)
|
||||
if offer == null:
|
||||
Style.panel(shop_layer, sr, Style.darker(Style.PANEL, 0.8), 8, Style.PANEL_LIGHT, 2)
|
||||
continue
|
||||
var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))])
|
||||
sv.position = sr.position
|
||||
sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND
|
||||
sv.hover.connect(show_hand_preview)
|
||||
var slot := i
|
||||
sv.set_meta("slot", slot) # der Platz im Laden, auch für das Gamepad
|
||||
sv.clicked.connect(func(_view): on_click("shop", slot))
|
||||
shop_layer.add_child(sv)
|
||||
shop_views.append(sv)
|
||||
|
||||
Style.clear(hand_layer)
|
||||
if spectating():
|
||||
@@ -892,7 +872,7 @@ func refresh() -> void:
|
||||
var card: Cards.Card = h.hand[i]
|
||||
var playable: bool = mine and g.can_play(h, card)[0]
|
||||
var r := BattleLayout.hand_rect(h.hand.size(), i)
|
||||
var v := CardView.new(card, r.size)
|
||||
var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))])
|
||||
v.position = r.position
|
||||
if card == pending:
|
||||
v.border = Style.YELLOW
|
||||
@@ -910,8 +890,8 @@ func refresh() -> void:
|
||||
bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
|
||||
name_label.text = h.name
|
||||
life_label.text = "♥ %d" % maxi(h.life, 0)
|
||||
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy]
|
||||
deck_label.text = "Deck %d" % h.deck.size()
|
||||
energy_label.text = "◆ %d" % h.energy
|
||||
income_label.text = tr("+%d je Zug") % h.max_energy
|
||||
status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name])
|
||||
if spectating():
|
||||
status_label.text = tr("Zuschauer") + " · " + status_label.text
|
||||
@@ -920,13 +900,29 @@ func refresh() -> void:
|
||||
b_skip.visible = ai_running
|
||||
b_end.visible = not ai_running and not spectating()
|
||||
b_undo.visible = undo_state != null and mine
|
||||
b_fuse.visible = mine and not spectating() and h.board.size() >= 2
|
||||
b_reroll.visible = mine and not spectating()
|
||||
b_reroll.text = tr("Neu würfeln (%d)") % Game.REROLL_COST
|
||||
b_reroll.disabled = h.energy < Game.REROLL_COST
|
||||
b_fuse.text = tr("Abbrechen") if fusing else tr("Verschmelzen")
|
||||
if g.field_element == "":
|
||||
var parts: Array[String] = []
|
||||
for e in Cards.ELEMENTS:
|
||||
parts.append("%s %d" % [tr(e), int(g.field_charge.get(e, 0))])
|
||||
field_label.text = tr("Feld: neutral") + " · " + " ".join(parts)
|
||||
field_label.add_theme_color_override("font_color", Style.MUTED)
|
||||
else:
|
||||
field_label.text = "%s: %s" % [tr(Cards.FIELD_NAMES[g.field_element]), tr(Cards.FIELD_TEXT[g.field_element])]
|
||||
field_label.add_theme_color_override("font_color", Cards.ELEMENT_COLORS[g.field_element])
|
||||
var lines: Array[String] = []
|
||||
var start := maxi(0, g.log.size() - 60)
|
||||
for i in range(start, g.log.size()):
|
||||
var line := g.log[i]
|
||||
if line.begins_with("---"):
|
||||
lines.append("[color=#6eaaf0]%s[/color]" % line)
|
||||
elif line.begins_with("Feldkombo") or line.begins_with("Falle von"):
|
||||
elif line.begins_with("Das Feld wird"):
|
||||
lines.append("[color=#8c78dc]%s[/color]" % line)
|
||||
elif line.begins_with("Feldkombo"):
|
||||
lines.append("[color=#f5c850]%s[/color]" % line)
|
||||
else:
|
||||
lines.append(line)
|
||||
@@ -954,7 +950,6 @@ func refresh() -> void:
|
||||
reward_label.text = "Gemeinsame Partie, keine Münzen"
|
||||
overlay.visible = true
|
||||
confirm.visible = false
|
||||
mulligan.visible = false
|
||||
|
||||
|
||||
func build_zoom(valid: Array) -> void:
|
||||
@@ -963,7 +958,7 @@ func build_zoom(valid: Array) -> void:
|
||||
var box := Rect2(190, 190, 900, 300)
|
||||
var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2)
|
||||
back.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
Style.label(zoom_layer, "%s · %s ♥ %d Hand %d Deck %d Fallen %d ◆ %d" % [o.name, o.title, o.life, o.hand.size(), o.deck.size(), o.traps.size(), o.max_energy],
|
||||
Style.label(zoom_layer, "%s · %s ♥ %d Hand %d ◆ %d (+%d)" % [o.name, o.title, o.life, o.hand.size(), o.energy, o.max_energy],
|
||||
18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true)
|
||||
for j in o.board.size():
|
||||
var cr: Game.Creature = o.board[j]
|
||||
|
||||
@@ -5,17 +5,13 @@ const CARD_W := 200
|
||||
const CARD_H := 280
|
||||
|
||||
var app
|
||||
var cards: Array = []
|
||||
var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte
|
||||
var revealed := {}
|
||||
var new_ids := {}
|
||||
var coins_label: Label
|
||||
var msg_label: Label
|
||||
var b_buy: Button
|
||||
var b_all: Button
|
||||
var b_improve: Button
|
||||
var suggestion := ""
|
||||
var slots: Control
|
||||
var element_buttons: Array = []
|
||||
|
||||
|
||||
class CardBack extends Control:
|
||||
@@ -46,28 +42,19 @@ class CardBack extends Control:
|
||||
|
||||
func slot_rect(i: int) -> Rect2:
|
||||
var gap := 20
|
||||
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2
|
||||
var x0 := (1280 - Profile.BOOSTER_SIZE * CARD_W - (Profile.BOOSTER_SIZE - 1) * gap) / 2
|
||||
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
|
||||
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
|
||||
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
|
||||
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
|
||||
Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST,
|
||||
14, Style.MUTED, Rect2(0, 586, 1280, 22), "center")
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)))
|
||||
slots = Control.new()
|
||||
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(slots)
|
||||
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center")
|
||||
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628"))
|
||||
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all)
|
||||
b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c"))
|
||||
b_improve.visible = false
|
||||
Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
|
||||
refresh()
|
||||
|
||||
@@ -77,10 +64,9 @@ func _input(event: InputEvent) -> void:
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func buy(element: String = "") -> void:
|
||||
func buy() -> void:
|
||||
var p: Profile = app.profile
|
||||
var before: Dictionary = p.collection.duplicate()
|
||||
var got: Array = p.open_booster(null, element)
|
||||
var got: Array = p.open_booster()
|
||||
if got.is_empty():
|
||||
msg_label.text = "Nicht genug Münzen"
|
||||
msg_label.add_theme_color_override("font_color", Style.RED)
|
||||
@@ -89,13 +75,7 @@ func buy(element: String = "") -> void:
|
||||
app.sfx.play("play")
|
||||
cards = got
|
||||
revealed = {}
|
||||
new_ids = {}
|
||||
for c in cards:
|
||||
if before.get(c.id, 0) == 0:
|
||||
new_ids[c.id] = true
|
||||
var notes: Array[String] = p.take_notices()
|
||||
msg_label.text = " · ".join(notes)
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN)
|
||||
msg_label.text = ""
|
||||
refresh()
|
||||
|
||||
|
||||
@@ -106,59 +86,32 @@ func reveal(i: int) -> void:
|
||||
|
||||
|
||||
func reveal_all() -> void:
|
||||
for i in 5:
|
||||
for i in Profile.BOOSTER_SIZE:
|
||||
revealed[i] = true
|
||||
refresh()
|
||||
|
||||
|
||||
## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste).
|
||||
func improve() -> void:
|
||||
if suggestion == "":
|
||||
return
|
||||
var err: String = app.profile.improve_deck_with(suggestion)
|
||||
if err == "":
|
||||
app.save()
|
||||
msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()]
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN)
|
||||
suggestion = ""
|
||||
else:
|
||||
msg_label.text = err
|
||||
msg_label.add_theme_color_override("font_color", Style.RED)
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
coins_label.text = "Münzen: %d" % p.coins
|
||||
b_buy.disabled = p.coins < Profile.BOOSTER_COST
|
||||
for b in element_buttons:
|
||||
b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST
|
||||
b_all.disabled = cards.is_empty() or revealed.size() >= 5
|
||||
# Deck-Vorschlag, sobald alle Karten offen liegen
|
||||
suggestion = ""
|
||||
if not cards.is_empty() and revealed.size() >= 5:
|
||||
var ids: Array = cards.map(func(c): return c.id)
|
||||
var better: Array = p.deck_suggestions(ids)
|
||||
if not better.is_empty():
|
||||
suggestion = better[0]
|
||||
b_improve.visible = suggestion != ""
|
||||
if suggestion != "":
|
||||
b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()]
|
||||
b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE
|
||||
Style.clear(slots)
|
||||
for i in 5:
|
||||
for i in Profile.BOOSTER_SIZE:
|
||||
var r := slot_rect(i)
|
||||
if cards.is_empty():
|
||||
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
|
||||
elif revealed.has(i):
|
||||
var c: Cards.Card = cards[i]
|
||||
var v := CardView.new(c, r.size)
|
||||
var res: Dictionary = cards[i]
|
||||
var c: Cards.Card = res.card
|
||||
var v := CardView.new(c, r.size, false, res.rarity)
|
||||
v.position = r.position
|
||||
slots.add_child(v)
|
||||
Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
|
||||
if new_ids.has(c.id):
|
||||
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
|
||||
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:
|
||||
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, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
|
||||
Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
|
||||
else:
|
||||
var back := CardBack.new(r.size)
|
||||
back.position = r.position
|
||||
|
||||
@@ -70,7 +70,7 @@ func build() -> void:
|
||||
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
|
||||
b.disabled = true
|
||||
if p.campaign_stage >= Campaign.total():
|
||||
Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true)
|
||||
Style.clabel(col, "Alle Kapitel abgeschlossen", 16, Style.GREEN, "center", true)
|
||||
var br := Style.row(col)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
@@ -3,7 +3,6 @@ extends ScrollContainer
|
||||
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
|
||||
|
||||
signal card_clicked(card: Cards.Card)
|
||||
signal card_hover(card: Cards.Card, entered: bool)
|
||||
|
||||
const COLS := 7
|
||||
const CW := 100
|
||||
@@ -25,17 +24,16 @@ func _init(rect: Rect2) -> void:
|
||||
add_child(grid)
|
||||
|
||||
|
||||
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool
|
||||
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
|
||||
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool; rarity_fn(card) -> String (Optik)
|
||||
func fill(cards: Array, label_fn: Callable, dim_fn: Callable, rarity_fn: Callable = Callable()) -> void:
|
||||
Style.clear(grid)
|
||||
for c in cards:
|
||||
var cell := Control.new()
|
||||
cell.custom_minimum_size = Vector2(CW, CH + 16)
|
||||
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var v := CardView.new(c, Vector2(CW, CH), true)
|
||||
var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c) if rarity_fn.is_valid() else "gewöhnlich")
|
||||
v.dim = dim_fn.call(c)
|
||||
v.clicked.connect(func(_view): card_clicked.emit(c))
|
||||
v.hover.connect(func(card, entered): card_hover.emit(card, entered))
|
||||
cell.add_child(v)
|
||||
var text: String = label_fn.call(c)
|
||||
Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")
|
||||
|
||||
@@ -7,12 +7,14 @@ signal hover(card: Cards.Card, entered: bool)
|
||||
|
||||
var card: Cards.Card
|
||||
var border = null # Color oder null (dann Seltenheitsfarbe)
|
||||
var rarity := "gewöhnlich" # nur Optik: Rahmenfarbe der eigenen Stufe
|
||||
var dim := false
|
||||
|
||||
|
||||
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
|
||||
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false) -> void:
|
||||
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "gewöhnlich") -> void:
|
||||
card = c
|
||||
rarity = rar
|
||||
custom_minimum_size = sz
|
||||
size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
@@ -45,7 +47,7 @@ func _draw() -> void:
|
||||
var f_text := 14 if big else (11 if mid else 10)
|
||||
var f_stat := 24 if big else (18 if mid else 15)
|
||||
Style.fill_elements(self, rect, card.elements, 8)
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity]
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[rarity]
|
||||
Style.border(self, rect, bcol, 3 if big else 2, 8)
|
||||
# Kosten
|
||||
var r := 16 if big else 12
|
||||
@@ -57,17 +59,17 @@ func _draw() -> void:
|
||||
var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6)
|
||||
Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true)
|
||||
if mid:
|
||||
var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind)
|
||||
var sub: String = tr(card.combo()) if card.is_combo() else ""
|
||||
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
|
||||
# Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest
|
||||
var tsize := Style.fs(f_text)
|
||||
var text_w: float = size.x - 20
|
||||
var needed: float = Style.FONT.get_multiline_string_size(card.text(), HORIZONTAL_ALIGNMENT_LEFT, text_w, tsize).y + 8 if card.text() != "" else size.y * 0.42
|
||||
var box_bottom: float = size.y - (28 if big else 22)
|
||||
var box_top: float = maxf(size.y * 0.30, box_bottom - maxf(needed, size.y * 0.42))
|
||||
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) # unter Name und Kombo
|
||||
var box_top: float = maxf(maxf(size.y * 0.30, band_top), box_bottom - maxf(needed, size.y * 0.42))
|
||||
var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top)
|
||||
# Bild
|
||||
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0)
|
||||
var band_bottom: float = box_top - 4
|
||||
if band_bottom - band_top > 14:
|
||||
var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
|
||||
@@ -77,9 +79,8 @@ func _draw() -> void:
|
||||
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)),
|
||||
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
|
||||
# Werte
|
||||
if card.is_creature():
|
||||
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
|
||||
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
|
||||
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
|
||||
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
|
||||
if dim:
|
||||
draw_rect(rect, Color(0, 0, 0, 0.47))
|
||||
if has_focus():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class_name CollectionScreen
|
||||
extends Control
|
||||
## Sammlung: alle Karten mit Besitzstand; ausgewählte Karten lassen sich
|
||||
## zerlegen (Staub) oder herstellen.
|
||||
## Sammlung: alle Karten gehören dir. Gesammelt wird die Seltenheitsstufe — reine Optik.
|
||||
## Aufwerten kostet Staub, eine Stufe abgeben bringt Staub.
|
||||
|
||||
var app
|
||||
var filter := ""
|
||||
@@ -14,14 +14,11 @@ var msg_label: Label
|
||||
var preview_holder: Control
|
||||
var info_label: Label
|
||||
var b_dismantle: Button
|
||||
var b_craft: Button
|
||||
var b_extras: Button
|
||||
var b_upgrade: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Style.label(self, "Sammlung", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
||||
Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES,
|
||||
14, Style.MUTED, Rect2(240, 18, 600, 24))
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
@@ -44,10 +41,9 @@ func _ready() -> void:
|
||||
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(preview_holder)
|
||||
info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center")
|
||||
b_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28"))
|
||||
b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c"))
|
||||
b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras)
|
||||
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "center")
|
||||
b_upgrade = Style.button(self, "", Rect2(860, 480, 390, 44), upgrade, Color("285a3c"))
|
||||
b_dismantle = Style.button(self, "", Rect2(860, 534, 390, 44), dismantle, Color("5a3c28"))
|
||||
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 600, 390, 48), "center")
|
||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
refresh()
|
||||
|
||||
@@ -83,71 +79,57 @@ func say(m: String, ok: bool) -> void:
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
||||
|
||||
|
||||
func upgrade() -> void:
|
||||
if selected == null:
|
||||
return
|
||||
var err: String = app.profile.upgrade(selected.id)
|
||||
if err == "":
|
||||
app.sfx.play("play")
|
||||
app.save()
|
||||
say(tr("%s ist jetzt %s") % [selected.title(), tr(app.profile.rarity(selected.id))], true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func dismantle() -> void:
|
||||
if selected == null:
|
||||
return
|
||||
var lvl: int = app.profile.level(selected.id)
|
||||
var err: String = app.profile.dismantle(selected.id)
|
||||
if err == "":
|
||||
app.sfx.play("click")
|
||||
app.save()
|
||||
say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[selected.rarity]], true)
|
||||
say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]], true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func craft() -> void:
|
||||
if selected == null:
|
||||
return
|
||||
var err: String = app.profile.craft(selected.id)
|
||||
if err == "":
|
||||
app.sfx.play("play")
|
||||
app.save()
|
||||
var notes: Array[String] = app.profile.take_notices()
|
||||
say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func dismantle_extras() -> void:
|
||||
var got: int = app.profile.dismantle_extras()
|
||||
if got > 0:
|
||||
app.save()
|
||||
say("Überschuss zerlegt: +%d Staub" % got, true)
|
||||
else:
|
||||
say("Kein Überschuss vorhanden", false)
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
dust_label.text = "Staub: %d" % p.dust
|
||||
grid.fill(cards(),
|
||||
func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt",
|
||||
func(c): return p.collection.get(c.id, 0) == 0)
|
||||
grid.fill(cards(), func(c): return tr(p.rarity(c.id)), func(_c): return false, func(c): return p.rarity(c.id))
|
||||
Style.clear(preview_holder)
|
||||
if selected == null:
|
||||
info_label.text = "Keine Karte gewählt"
|
||||
b_upgrade.text = "Aufwerten"
|
||||
b_dismantle.text = "Zerlegen"
|
||||
b_craft.text = "Herstellen"
|
||||
b_upgrade.disabled = true
|
||||
b_dismantle.disabled = true
|
||||
b_craft.disabled = true
|
||||
return
|
||||
var v := CardView.new(selected, Vector2(200, 280), false, p.rarity(selected.id))
|
||||
v.position = Vector2(955, 100)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
var lvl := p.level(selected.id)
|
||||
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"
|
||||
b_upgrade.disabled = true
|
||||
else:
|
||||
var v := CardView.new(selected, Vector2(200, 280))
|
||||
v.position = Vector2(955, 100)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
var owned: int = p.collection.get(selected.id, 0)
|
||||
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
|
||||
info_label.text = "%s · %s · Wert %.1f\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
|
||||
selected.combo(), selected.rarity, Cards.card_value(selected), owned, st.played, st.games, st.wins]
|
||||
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
|
||||
b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity]
|
||||
b_dismantle.disabled = owned == 0
|
||||
b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity]
|
||||
var extras := 0
|
||||
for cid in p.collection:
|
||||
extras += maxi(0, p.collection[cid] - Game.MAX_COPIES)
|
||||
b_extras.text = "Überschuss zerlegen (%d Karten)" % extras
|
||||
b_extras.disabled = extras == 0
|
||||
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
|
||||
|
||||
@@ -36,7 +36,9 @@ func _draw() -> void:
|
||||
var small := size.x < 100
|
||||
var mini := size.x < 60
|
||||
Style.fill_elements(self, rect, creature.card.elements, 6)
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity]
|
||||
var owner = creature.owner()
|
||||
var lvl: int = int(owner.looks.get(creature.card.id, 0)) if owner != null else 0
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[Cards.RARITIES[lvl]]
|
||||
if flash:
|
||||
bcol = Style.RED
|
||||
Style.border(self, rect, bcol, 4 if flash else width, 6)
|
||||
@@ -57,12 +59,14 @@ func _draw() -> void:
|
||||
badges.append("Eis")
|
||||
if creature.resonance > 0:
|
||||
badges.append("Resonanz")
|
||||
if creature.field_bonus > 0:
|
||||
badges.append("Feld")
|
||||
var y := 18.0 if small else 22.0
|
||||
for b in badges:
|
||||
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
|
||||
y += 12 if small else 15
|
||||
var f := 16 if small else 20
|
||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
||||
var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW
|
||||
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true)
|
||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
||||
Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
|
||||
@@ -83,8 +87,10 @@ func _draw_mini() -> void:
|
||||
letters.append("E")
|
||||
if creature.resonance > 0:
|
||||
letters.append("R")
|
||||
if creature.field_bonus > 0:
|
||||
letters.append("F")
|
||||
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
|
||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
||||
var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW
|
||||
Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true)
|
||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
||||
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true)
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
class_name DeckScreen
|
||||
extends Control
|
||||
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten.
|
||||
|
||||
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"]
|
||||
|
||||
var app
|
||||
var slot := 0
|
||||
var deck: Array = []
|
||||
var filter := ""
|
||||
var search := ""
|
||||
var sort_mode := 0
|
||||
var show_missing := false
|
||||
var msg_label: Label
|
||||
var grid: CardGrid
|
||||
var deck_list: Control
|
||||
var deck_label: Label
|
||||
var elems_label: Label
|
||||
var valid_label: Label
|
||||
var active_label: Label
|
||||
var curve: Control
|
||||
var preview: CardView
|
||||
var search_box: LineEdit
|
||||
var sort_box: OptionButton
|
||||
var missing_box: CheckButton
|
||||
var slot_buttons: Array = []
|
||||
var b_activate: Button
|
||||
var b_autofill: Button
|
||||
var name_box: LineEdit
|
||||
var confirm: Control
|
||||
var code_dialog: Control
|
||||
var code_box: LineEdit
|
||||
|
||||
|
||||
class DeckRow extends Control:
|
||||
signal clicked(card)
|
||||
signal hover(card, entered: bool)
|
||||
var card: Cards.Card
|
||||
var count: int
|
||||
var lit := false
|
||||
|
||||
func _init(c: Cards.Card, n: int, sz: Vector2) -> void:
|
||||
card = c
|
||||
count = n
|
||||
size = sz
|
||||
custom_minimum_size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
var light := func(on: bool):
|
||||
lit = on
|
||||
queue_redraw()
|
||||
hover.emit(card, on)
|
||||
mouse_entered.connect(light.bind(true))
|
||||
mouse_exited.connect(light.bind(false))
|
||||
focus_entered.connect(light.bind(true))
|
||||
focus_exited.connect(light.bind(false))
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit(card)
|
||||
elif has_focus() and event.is_action_pressed("ui_accept"):
|
||||
accept_event()
|
||||
clicked.emit(card)
|
||||
|
||||
func _draw() -> void:
|
||||
if lit:
|
||||
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
|
||||
for k in card.elements.size():
|
||||
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
|
||||
Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
|
||||
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
|
||||
|
||||
|
||||
class EnergyCurve extends Control:
|
||||
var cards: Array = []
|
||||
|
||||
func _draw() -> void:
|
||||
Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0))
|
||||
var counts := {}
|
||||
for c in cards:
|
||||
var k: int = mini(c.cost, 8)
|
||||
counts[k] = counts.get(k, 0) + 1
|
||||
for i in range(1, 9):
|
||||
var n: int = counts.get(i, 0)
|
||||
var x := (i - 1) * 48
|
||||
draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE)
|
||||
if n > 0:
|
||||
Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom")
|
||||
Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
slot = app.profile.active_deck
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
||||
Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.",
|
||||
14, Style.MUTED, Rect2(300, 18, 700, 24))
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||
fb.add_theme_font_size_override("font_size", 14)
|
||||
search_box = LineEdit.new()
|
||||
search_box.position = Vector2(424, 55)
|
||||
search_box.size = Vector2(136, 34)
|
||||
search_box.placeholder_text = "Suche"
|
||||
search_box.text_changed.connect(func(t: String):
|
||||
search = t
|
||||
refresh())
|
||||
add_child(search_box)
|
||||
sort_box = OptionButton.new()
|
||||
sort_box.position = Vector2(570, 55)
|
||||
sort_box.size = Vector2(130, 34)
|
||||
for s in SORTS:
|
||||
sort_box.add_item(s)
|
||||
sort_box.item_selected.connect(func(i: int):
|
||||
sort_mode = i
|
||||
refresh())
|
||||
add_child(sort_box)
|
||||
missing_box = CheckButton.new()
|
||||
missing_box.position = Vector2(710, 58)
|
||||
missing_box.text = "Fehlende"
|
||||
missing_box.toggled.connect(func(on: bool):
|
||||
show_missing = on
|
||||
refresh())
|
||||
add_child(missing_box)
|
||||
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
|
||||
grid.card_clicked.connect(add)
|
||||
grid.card_hover.connect(show_preview)
|
||||
add_child(grid)
|
||||
# Rechte Seite: Slots, Deckliste, Kurve
|
||||
Style.panel(self, Rect2(845, 50, 420, 680))
|
||||
for i in Profile.DECK_SLOTS:
|
||||
slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i)))
|
||||
b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate)
|
||||
name_box = LineEdit.new()
|
||||
name_box.position = Vector2(860, 98)
|
||||
name_box.size = Vector2(200, 30)
|
||||
name_box.max_length = 20
|
||||
name_box.text_submitted.connect(func(t: String): rename(t))
|
||||
name_box.focus_exited.connect(func(): rename(name_box.text))
|
||||
add_child(name_box)
|
||||
deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true)
|
||||
active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right")
|
||||
b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill)
|
||||
b_autofill.add_theme_font_size_override("font_size", 13)
|
||||
elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24))
|
||||
valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22))
|
||||
deck_list = Control.new()
|
||||
deck_list.position = Vector2(860, 182)
|
||||
deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(deck_list)
|
||||
curve = EnergyCurve.new()
|
||||
curve.position = Vector2(860, 604)
|
||||
curve.size = Vector2(400, 125)
|
||||
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(curve)
|
||||
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
|
||||
Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c"))
|
||||
Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck)
|
||||
Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code)
|
||||
Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave)
|
||||
# Deck-Code zum Teilen: kopieren, einfügen, übernehmen
|
||||
code_dialog = Style.overlay(self)
|
||||
Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2)
|
||||
Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true)
|
||||
Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center")
|
||||
code_box = LineEdit.new()
|
||||
code_box.position = Vector2(270, 368)
|
||||
code_box.size = Vector2(740, 36)
|
||||
code_dialog.add_child(code_box)
|
||||
Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text))
|
||||
Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get())
|
||||
Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c"))
|
||||
Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false)
|
||||
confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.",
|
||||
"Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD)
|
||||
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
|
||||
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview.visible = false
|
||||
add_child(preview)
|
||||
refresh()
|
||||
|
||||
|
||||
func dirty() -> bool:
|
||||
return deck != app.profile.decks[slot].cards
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
if dirty():
|
||||
confirm.visible = true
|
||||
else:
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("ui_cancel") and not search_box.has_focus() and not name_box.has_focus() and not code_box.has_focus():
|
||||
if confirm.visible:
|
||||
confirm.visible = false
|
||||
elif code_dialog.visible:
|
||||
code_dialog.visible = false
|
||||
else:
|
||||
leave()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if preview.visible:
|
||||
var pos := get_local_mouse_position() + Vector2(16, 16)
|
||||
pos.x = clampf(pos.x, 0, 1280 - preview.size.x)
|
||||
pos.y = clampf(pos.y, 0, 800 - preview.size.y)
|
||||
preview.position = pos
|
||||
|
||||
|
||||
func set_filter(e: String) -> void:
|
||||
filter = e
|
||||
refresh()
|
||||
|
||||
|
||||
func select_slot(i: int) -> void:
|
||||
slot = i
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
say("", true)
|
||||
refresh()
|
||||
|
||||
|
||||
func rename(t: String) -> void:
|
||||
if t.strip_edges() == app.profile.deck_name(slot):
|
||||
return
|
||||
var err: String = app.profile.rename_deck(slot, t)
|
||||
if err == "":
|
||||
app.save()
|
||||
say("Umbenannt in %s" % app.profile.deck_name(slot), true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func autofill() -> void:
|
||||
app.profile.decks[slot].cards = deck.duplicate()
|
||||
var err: String = app.profile.autofill_deck(slot)
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
if err == "":
|
||||
app.save()
|
||||
say("Aufgefüllt und gespeichert", true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func activate() -> void:
|
||||
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
|
||||
if not ok[0]:
|
||||
say("Erst ein spielbares Deck speichern", false)
|
||||
return
|
||||
app.profile.decks[slot].cards = deck.duplicate()
|
||||
app.profile.active_deck = slot
|
||||
app.save()
|
||||
say("%s ist jetzt das aktive Deck" % app.profile.deck_name(slot), true)
|
||||
refresh()
|
||||
|
||||
|
||||
## Karten der Sammlung (oder alle, wenn Fehlende gezeigt werden), gefiltert und sortiert.
|
||||
func cards() -> Array:
|
||||
var out: Array = Cards.all().duplicate() if show_missing else app.profile.owned_cards()
|
||||
if filter != "":
|
||||
out = out.filter(func(c): return filter in c.elements)
|
||||
if search != "":
|
||||
var q := search.to_lower()
|
||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
||||
match sort_mode:
|
||||
0:
|
||||
out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
||||
1:
|
||||
out.sort_custom(func(a, b): return a.name < b.name)
|
||||
3:
|
||||
out.sort_custom(func(a, b):
|
||||
var ra := Cards.RARITIES.find(a.rarity)
|
||||
var rb := Cards.RARITIES.find(b.rarity)
|
||||
return ra > rb or (ra == rb and a.cost < b.cost))
|
||||
return out
|
||||
|
||||
|
||||
func say(m: String, ok: bool) -> void:
|
||||
msg_label.text = m
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
||||
|
||||
|
||||
func add(card: Cards.Card) -> void:
|
||||
var owned: int = app.profile.collection.get(card.id, 0)
|
||||
var have := deck.count(card.id)
|
||||
if owned == 0:
|
||||
say("%s fehlt in deiner Sammlung" % card.title(), false)
|
||||
elif deck.size() >= Game.DECK_SIZE:
|
||||
say("Das Deck ist voll (%d)" % Game.DECK_SIZE, false)
|
||||
elif have >= mini(owned, Game.MAX_COPIES):
|
||||
say("Nicht mehr Kopien möglich (max. %d)" % mini(owned, Game.MAX_COPIES), false)
|
||||
else:
|
||||
var elems := Game.deck_elements(deck_cards())
|
||||
for e in card.elements:
|
||||
if not (e in elems):
|
||||
elems.append(e)
|
||||
if elems.size() > Game.MAX_DECK_ELEMENTS:
|
||||
say("Höchstens %d Elemente im Deck" % Game.MAX_DECK_ELEMENTS, false)
|
||||
else:
|
||||
deck.append(card.id)
|
||||
app.sfx.play("click")
|
||||
say("", true)
|
||||
refresh()
|
||||
|
||||
|
||||
func remove(card: Cards.Card) -> void:
|
||||
deck.erase(card.id)
|
||||
say("", true)
|
||||
refresh()
|
||||
|
||||
|
||||
func clear_deck() -> void:
|
||||
deck = []
|
||||
refresh()
|
||||
|
||||
|
||||
func save_deck() -> void:
|
||||
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
|
||||
if ok[0]:
|
||||
app.profile.decks[slot].cards = deck.duplicate()
|
||||
app.save()
|
||||
say("%s gespeichert" % app.profile.deck_name(slot), true)
|
||||
else:
|
||||
say(ok[1], false)
|
||||
refresh()
|
||||
|
||||
|
||||
func open_code() -> void:
|
||||
code_box.text = Profile.deck_code(deck)
|
||||
code_dialog.visible = true
|
||||
|
||||
|
||||
## Übernimmt einen Deck-Code; Karten, die in der Sammlung fehlen, werden gemeldet und weggelassen.
|
||||
func apply_code(code: String) -> void:
|
||||
var ids: Array = Profile.parse_deck_code(code)
|
||||
if ids.is_empty():
|
||||
say("Ungültiger Code", false)
|
||||
return
|
||||
var p: Profile = app.profile
|
||||
var missing := {}
|
||||
deck = []
|
||||
for cid in ids:
|
||||
var owned: int = p.collection.get(cid, 0)
|
||||
if deck.count(cid) < owned and deck.size() < Game.DECK_SIZE:
|
||||
deck.append(cid)
|
||||
else:
|
||||
missing[cid] = true
|
||||
code_dialog.visible = false
|
||||
if missing.is_empty():
|
||||
say("Code übernommen", true)
|
||||
else:
|
||||
say(tr("Code übernommen, es fehlen: ") + ", ".join(missing.keys().map(func(cid): return Cards.by_id(cid).title())), false)
|
||||
refresh()
|
||||
|
||||
|
||||
func deck_cards() -> Array:
|
||||
var out := []
|
||||
for cid in deck:
|
||||
out.append(Cards.by_id(cid))
|
||||
return out
|
||||
|
||||
|
||||
func show_preview(card: Cards.Card, entered: bool) -> void:
|
||||
if entered:
|
||||
preview.card = card
|
||||
preview.visible = true
|
||||
preview.queue_redraw()
|
||||
elif preview.card == card:
|
||||
preview.visible = false
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
grid.fill(cards(),
|
||||
func(c):
|
||||
var owned: int = p.collection.get(c.id, 0)
|
||||
return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned],
|
||||
func(c):
|
||||
var owned: int = p.collection.get(c.id, 0)
|
||||
return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES))
|
||||
for i in slot_buttons.size():
|
||||
var b: Button = slot_buttons[i]
|
||||
var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED)
|
||||
b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2))
|
||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2))
|
||||
active_label.text = "aktiv" if slot == p.active_deck else ""
|
||||
# Deckliste
|
||||
Style.clear(deck_list)
|
||||
var counts := {}
|
||||
for cid in deck:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid))
|
||||
listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
||||
for i in listed.size():
|
||||
var c: Cards.Card = listed[i]
|
||||
var row := DeckRow.new(c, counts[c.id], Vector2(400, 20))
|
||||
row.position = Vector2(0, i * 21)
|
||||
row.clicked.connect(remove)
|
||||
row.hover.connect(show_preview)
|
||||
deck_list.add_child(row)
|
||||
var dc := deck_cards()
|
||||
if not name_box.has_focus():
|
||||
name_box.text = p.deck_name(slot)
|
||||
deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE]
|
||||
deck_label.tooltip_text = tr("Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten")
|
||||
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
|
||||
var elems := Game.deck_elements(dc)
|
||||
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
|
||||
elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–")
|
||||
if not dc.is_empty():
|
||||
elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc)
|
||||
var ok: Array = Game.validate_deck(dc, p.collection)
|
||||
valid_label.text = "spielbar" if ok[0] else ok[1]
|
||||
valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE)
|
||||
curve.cards = dc
|
||||
curve.queue_redraw()
|
||||
@@ -1 +0,0 @@
|
||||
uid://yg8pbj56mib5
|
||||
@@ -19,7 +19,7 @@ var addr_label: Label
|
||||
func _ready() -> void:
|
||||
net = app.net
|
||||
net.my_name = "Spieler"
|
||||
net.my_deck = app.profile.deck.duplicate()
|
||||
net.my_pick = {"looks": app.profile.looks()}
|
||||
var col := Style.column(self, 760, 40, 10)
|
||||
Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true)
|
||||
Style.spacer(col, 10)
|
||||
|
||||
@@ -23,7 +23,6 @@ func _ready() -> void:
|
||||
net.name = "Net"
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
profile = Profile.load_or_new(save_path)
|
||||
profile.refresh_daily()
|
||||
apply_settings()
|
||||
show_screen(MenuScreen.new())
|
||||
|
||||
@@ -36,10 +35,9 @@ func apply_settings() -> void:
|
||||
theme = Style.make_theme()
|
||||
|
||||
|
||||
## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen.
|
||||
## Neues Profil: Sammlung und Fortschritt werden verworfen.
|
||||
func reset_profile() -> void:
|
||||
profile = Profile.starter()
|
||||
profile.refresh_daily()
|
||||
clear_battle_save()
|
||||
save()
|
||||
apply_settings()
|
||||
|
||||
@@ -2,27 +2,19 @@ class_name MenuScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
var b_single: Button
|
||||
var b_campaign: Button
|
||||
var b_online: Button
|
||||
var b_resume: Button
|
||||
var coins_label: Label
|
||||
var coll_label: Label
|
||||
var deck_label: Label
|
||||
var daily_label: Label
|
||||
var warn_label: Label
|
||||
var notice_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 640, 24, 5)
|
||||
var col := Style.column(self, 640, 40, 5)
|
||||
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED)
|
||||
Style.spacer(col, 8)
|
||||
coins_label = Style.clabel(col, "", 20, Style.GOLD)
|
||||
coll_label = Style.clabel(col, "", 16, Style.MUTED)
|
||||
deck_label = Style.clabel(col, "", 16, Style.MUTED)
|
||||
daily_label = Style.clabel(col, "", 16, Style.BLUE)
|
||||
Style.spacer(col, 8)
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 2
|
||||
@@ -34,16 +26,14 @@ func _ready() -> void:
|
||||
var sz := Vector2(270, 44)
|
||||
if FileAccess.file_exists(app.battle_save_path()):
|
||||
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628"))
|
||||
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
|
||||
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
|
||||
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
|
||||
Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
|
||||
Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
|
||||
Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
|
||||
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
|
||||
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
|
||||
Style.cbutton(grid, "Sammlung", sz, func(): app.show_screen(CollectionScreen.new()))
|
||||
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
|
||||
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
|
||||
Style.spacer(col, 10)
|
||||
warn_label = Style.clabel(col, "", 16, Style.RED)
|
||||
notice_label = Style.clabel(col, "", 16, Style.GREEN)
|
||||
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
refresh()
|
||||
@@ -62,24 +52,9 @@ func _resume() -> void:
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
var ok: Array = p.deck_valid()
|
||||
b_single.disabled = not ok[0]
|
||||
b_campaign.disabled = not ok[0]
|
||||
b_online.disabled = not ok[0]
|
||||
var total := 0
|
||||
for cid in p.collection:
|
||||
total += p.collection[cid]
|
||||
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
|
||||
coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [
|
||||
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()]
|
||||
var elems := Game.deck_elements(p.deck_cards())
|
||||
deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")]
|
||||
var task := p.daily_task()
|
||||
if p.daily.done:
|
||||
daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text
|
||||
else:
|
||||
daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward]
|
||||
warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(ok[1])
|
||||
coll_label.text = tr("Aufgewertet: %d von %d Karten · Kampagne: %d/%d") % [
|
||||
p.upgraded_count(), Cards.all().size(), p.campaign_stage, Campaign.total()]
|
||||
var notes := p.take_notices()
|
||||
notice_label.text = " · ".join(notes)
|
||||
if not notes.is_empty():
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
class_name OpponentSelectScreen
|
||||
extends Control
|
||||
## Einzelspieler: Schwierigkeit wählen und die Gegnerzahl schieben. Die Gegnerdecks
|
||||
## werden erst beim Start zufällig gezogen (siehe BattleScreen und DeckGen).
|
||||
## Einzelspieler: Schwierigkeit und Gegnerzahl wählen. Die Karten kommen im Kampf
|
||||
## aus dem Laden (siehe DeckGen.shop_cards).
|
||||
|
||||
var app
|
||||
var rating_label: Label
|
||||
var diff_buttons := {}
|
||||
var count_slider: HSlider
|
||||
var count_label: Label
|
||||
@@ -12,17 +11,16 @@ var info_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 1000, 60, 14)
|
||||
var col := Style.column(self, 1000, 50, 12)
|
||||
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
|
||||
Style.spacer(col, 10)
|
||||
Style.spacer(col, 8)
|
||||
Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
|
||||
var dr := Style.row(col, 10)
|
||||
dr.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
for key in ["leicht", "normal", "schwer"]:
|
||||
for key in Profile.DIFFICULTIES:
|
||||
var k: String = key
|
||||
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
|
||||
rating_label = Style.clabel(col, "", 15, Style.MUTED)
|
||||
Style.spacer(col, 24)
|
||||
Style.spacer(col, 20)
|
||||
count_label = Style.clabel(col, "", 20, Style.TEXT, "center", true)
|
||||
var sr := Style.row(col, 12)
|
||||
sr.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
@@ -39,7 +37,7 @@ func _ready() -> void:
|
||||
sr.add_child(count_slider)
|
||||
Style.clabel(sr, str(Game.MAX_OPPONENTS), 15, Style.MUTED, "left", false, 20)
|
||||
info_label = Style.clabel(col, "", 15, Style.MUTED)
|
||||
Style.spacer(col, 30)
|
||||
Style.spacer(col, 24)
|
||||
var br := Style.row(col, 16)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Starten", Vector2(190, 46), start, Color("285a3c"))
|
||||
@@ -64,16 +62,18 @@ func start() -> void:
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
for key in diff_buttons:
|
||||
var b: Button = diff_buttons[key]
|
||||
var active: bool = key == p.settings.difficulty
|
||||
b.add_theme_stylebox_override("normal", Style.box(Color("285a3c") if active else Style.PANEL_LIGHT, 8, Style.GOLD if active else Style.MUTED, 2))
|
||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Color("285a3c") if active else Style.PANEL_LIGHT), 8, Style.GOLD if active else Style.MUTED, 2))
|
||||
rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f") % [p.deck_rating(), p.target_rating()]
|
||||
_mark(diff_buttons[key], key == p.settings.difficulty)
|
||||
var n := opponents()
|
||||
count_label.text = tr("1 Gegner") if n == 1 else tr("%d Gegner") % n
|
||||
info_label.text = (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
|
||||
|
||||
|
||||
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
|
||||
var base: Color = col if active else Style.PANEL_LIGHT
|
||||
b.add_theme_stylebox_override("normal", Style.box(base, 8, Style.GOLD if active else Style.MUTED, 2))
|
||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
@@ -76,13 +76,12 @@ func _ready() -> void:
|
||||
grid.add_child(language)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.spacer(col, 20)
|
||||
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
|
||||
Style.spacer(col, 30)
|
||||
var r := Style.row(col, 16)
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
|
||||
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Decks, Münzen und Fortschritt gehen verloren.",
|
||||
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
|
||||
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
|
||||
_labels()
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ class_name Sfx
|
||||
extends Node
|
||||
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
|
||||
|
||||
static var inst: Sfx
|
||||
|
||||
var streams := {}
|
||||
var players: Array[AudioStreamPlayer] = []
|
||||
var volume := 0.8
|
||||
@@ -14,7 +12,6 @@ var music_box := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
inst = self
|
||||
streams["click"] = _tone(880, 0.05, "sine")
|
||||
streams["play"] = _tone(440, 0.14, "sine", 660)
|
||||
streams["attack"] = _tone(220, 0.16, "square", 90)
|
||||
|
||||
@@ -2,12 +2,8 @@ class_name Style
|
||||
## Farben, Schriften, Theme und Zeichenhilfen.
|
||||
|
||||
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
|
||||
static var font_scale := 1.0 # Einstellung „Große Schrift“
|
||||
|
||||
|
||||
static func fs(size: int) -> int:
|
||||
return int(round(size * font_scale))
|
||||
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
|
||||
static var font_scale := 1.0 # Einstellung „Große Schrift“
|
||||
|
||||
const BG := Color("1a1c24")
|
||||
const PANEL := Color("282b36")
|
||||
@@ -20,6 +16,12 @@ const RED := Color("e65555")
|
||||
const BLUE := Color("6eaaf0")
|
||||
const YELLOW := Color("ffe65a")
|
||||
const ORANGE := Color("ff963c")
|
||||
const PURPLE := Color("8c78dc")
|
||||
|
||||
|
||||
## Schriftgröße mit der Einstellung „Große Schrift“.
|
||||
static func fs(size: int) -> int:
|
||||
return int(round(size * font_scale))
|
||||
|
||||
|
||||
static func darker(c: Color, f: float = 0.65) -> Color:
|
||||
@@ -244,65 +246,42 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius
|
||||
ci.draw_style_box(sb, rect)
|
||||
|
||||
|
||||
## Hintergrund in Elementfarbe; ein verschmolzenes Wesen zeigt seine Elemente als Streifen.
|
||||
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
|
||||
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
|
||||
if elements.size() == 1:
|
||||
ci.draw_style_box(box(a, radius), rect)
|
||||
elif elements[0] == elements[1]:
|
||||
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
|
||||
ci.draw_style_box(box(a, radius), rect)
|
||||
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3))
|
||||
else:
|
||||
var b: Color = Cards.ELEMENT_COLORS[elements[1]]
|
||||
var half := floorf(rect.size.x / 2)
|
||||
var left := StyleBoxFlat.new()
|
||||
left.bg_color = a
|
||||
left.corner_radius_top_left = radius
|
||||
left.corner_radius_bottom_left = radius
|
||||
var right := StyleBoxFlat.new()
|
||||
right.bg_color = b
|
||||
right.corner_radius_top_right = radius
|
||||
right.corner_radius_bottom_right = radius
|
||||
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
|
||||
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
|
||||
ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect)
|
||||
return
|
||||
var n := elements.size()
|
||||
var w := rect.size.x / n
|
||||
for i in n:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = Cards.ELEMENT_COLORS[elements[i]]
|
||||
if i == 0:
|
||||
sb.corner_radius_top_left = radius
|
||||
sb.corner_radius_bottom_left = radius
|
||||
if i == n - 1:
|
||||
sb.corner_radius_top_right = radius
|
||||
sb.corner_radius_bottom_right = radius
|
||||
ci.draw_style_box(sb, Rect2(rect.position + Vector2(floorf(i * w), 0), Vector2(ceilf(w), rect.size.y)))
|
||||
|
||||
|
||||
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
|
||||
# --- Kartensymbole (prozedural, je Element) --------------------------
|
||||
|
||||
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
|
||||
var variant := absi(hash(card.id)) % 3
|
||||
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
|
||||
col.a = 0.95
|
||||
if card.is_trap():
|
||||
# Falle: Kreuz im Kreis
|
||||
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
|
||||
var d := size * 0.28
|
||||
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
|
||||
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
|
||||
return
|
||||
if card.kind == "Zauber":
|
||||
_star(ci, center, size * 0.55, Color(col, 0.35))
|
||||
var elems: Array = card.elements
|
||||
if elems.size() >= 2 and elems[0] != elems[1]:
|
||||
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
|
||||
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
|
||||
col2.a = 0.95
|
||||
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
|
||||
else:
|
||||
if elems.size() == 1:
|
||||
_element_glyph(ci, elems[0], center, size, variant, col)
|
||||
if elems.size() >= 2:
|
||||
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
|
||||
if elems.size() >= 3:
|
||||
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
|
||||
|
||||
|
||||
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
|
||||
var pts := PackedVector2Array()
|
||||
for i in 8:
|
||||
var a := TAU * i / 8 - PI / 2
|
||||
var rr := r if i % 2 == 0 else r * 0.45
|
||||
pts.append(c + Vector2(cos(a), sin(a)) * rr)
|
||||
ci.draw_colored_polygon(pts, col)
|
||||
return
|
||||
# Verschmolzen: die ersten beiden Elemente nebeneinander, ab dem dritten ein Ring
|
||||
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
|
||||
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
|
||||
col2.a = 0.95
|
||||
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
|
||||
if elems.size() >= 3:
|
||||
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
|
||||
|
||||
|
||||
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
|
||||
|
||||
@@ -19,14 +19,11 @@ func _ready() -> void:
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var h: Game.Player = scr.game.human
|
||||
while h.hand.size() < 8 and not h.deck.is_empty():
|
||||
scr.game.draw(h)
|
||||
scr.game.buy(h, 0)
|
||||
var t0 := Time.get_ticks_usec()
|
||||
for i in 30:
|
||||
scr.refresh()
|
||||
print("refresh() mit 5 Gegnern und 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
|
||||
scr.mulligan.visible = false
|
||||
h.mulligan_done = true
|
||||
scr.ai_delay = 0.2
|
||||
measuring = true
|
||||
var start := Time.get_ticks_msec()
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
extends SceneTree
|
||||
## Misst Deckarchetypen im Duell (beide gierig): wer beginnt, Mono gegen Paar.
|
||||
|
||||
func deck(r: RandomNumberGenerator, mono: bool) -> Array:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, r)
|
||||
return Game.build_opponent_deck(r, [pool[0]] if mono else [pool[0], pool[1]])
|
||||
|
||||
|
||||
func duel(r: RandomNumberGenerator, first_mono: bool, second_mono: bool) -> bool:
|
||||
var second := Game.Player.new("Gegner 1", deck(r, second_mono), false, Game.START_LIFE, "x")
|
||||
var g := Game.new_battle_with(deck(r, first_mono), [second], r)
|
||||
var guard := 0
|
||||
while g.winner == "" and guard < 300:
|
||||
AI.take_turn(g, g.current(), true)
|
||||
guard += 1
|
||||
return g.winner == "human"
|
||||
|
||||
|
||||
func run(label: String, a_mono: bool, b_mono: bool, games: int, r: RandomNumberGenerator) -> void:
|
||||
var wins := 0
|
||||
for i in games:
|
||||
if duel(r, a_mono, b_mono):
|
||||
wins += 1
|
||||
print("%-28s Startspieler gewinnt %3d%%" % [label, wins * 100 / games])
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 5
|
||||
run("Paar gegen Paar", false, false, 200, r)
|
||||
run("Mono gegen Mono", true, true, 200, r)
|
||||
run("Paar (startet) gegen Mono", false, true, 200, r)
|
||||
run("Mono (startet) gegen Paar", true, false, 200, r)
|
||||
quit()
|
||||
@@ -1 +0,0 @@
|
||||
uid://bu5hjcfuinhy8
|
||||
@@ -25,7 +25,7 @@ func _ready() -> void:
|
||||
net.name = "Net"
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
net.my_name = bot_name
|
||||
net.my_deck = Profile.starter().deck.duplicate()
|
||||
net.my_pick = {"elements": ["Feuer", "Erde"], "looks": {}}
|
||||
net.started.connect(func(s):
|
||||
seat = s
|
||||
if hosting:
|
||||
@@ -73,16 +73,18 @@ func _act_host() -> void:
|
||||
_decide(game.players[seat])
|
||||
|
||||
|
||||
## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel,
|
||||
## dann angreifen, sonst Zug beenden.
|
||||
## Einfache Züge mit kurzer Bedenkzeit: erst das billigste bezahlbare Angebot kaufen, dann das
|
||||
## erste spielbare Wesen ohne Ziel, dann angreifen, 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
|
||||
if not me.mulligan_done and me.turns == 1:
|
||||
net.send_action({"type": "mulligan", "hand": []})
|
||||
return
|
||||
for i in me.shop.size():
|
||||
var offer = me.shop[i]
|
||||
if offer != null and me.energy >= offer.cost and me.hand.size() < Game.MAX_HAND:
|
||||
net.send_action({"type": "buy", "slot": i})
|
||||
return
|
||||
for i in me.hand.size():
|
||||
var card: Cards.Card = me.hand[i]
|
||||
if game.can_play(me, card)[0] and card.target_type() == "":
|
||||
|
||||
@@ -14,18 +14,16 @@ func _initialize() -> void:
|
||||
print("Kartenpool laden: %.1f ms" % t)
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 3
|
||||
var starter := Profile.starter().deck_cards()
|
||||
print("Gegnerdeck (Zielstärke): %.1f ms" % ms(func(): DeckGen.build_opponent_deck_rated(r, ["Feuer", "Erde"], 1.5), 10))
|
||||
print("Ladenangebot: %.2f ms" % ms(func(): DeckGen.shop_cards(r, 5), 50))
|
||||
print("Vorschau 1..5 Gegner: %.1f ms" % ms(func():
|
||||
for n in range(1, 6):
|
||||
for i in n:
|
||||
Game.random_opponent(r, i + 1, Game.START_LIFE, 1.3, "")))
|
||||
DeckGen.random_opponent(i + 1)))
|
||||
for n in [1, 3, 5]:
|
||||
var g := Game.new_battle(starter, n, r, {"target_rating": 1.5})
|
||||
var g := Game.new_battle(n, r)
|
||||
# ein volles Brett für alle, damit die Entscheidung teuer wird
|
||||
var uid := 5000
|
||||
for p in g.players:
|
||||
p.mulligan_done = true
|
||||
for k in 4:
|
||||
var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid)
|
||||
uid += 1
|
||||
@@ -36,15 +34,14 @@ func _initialize() -> void:
|
||||
h.energy = 10
|
||||
h.max_energy = 10
|
||||
for k in 6:
|
||||
h.hand.append(Cards.by_id("f_funke"))
|
||||
h.hand.append(Cards.by_id("f_ritter"))
|
||||
AI.time_budget_ms = 100000
|
||||
var t_full := ms(func(): AI.choose_action(g, h))
|
||||
AI.time_budget_ms = 150
|
||||
var t_budget := ms(func(): AI.choose_action(g, h))
|
||||
var cands := AI.candidate_actions(g, h).size()
|
||||
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(starter, 5, r).clone(), 20))
|
||||
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(5, r).clone(), 20))
|
||||
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
|
||||
print("Deckstärke berechnen: %.3f ms" % ms(func(): Game.deck_rating(starter), 100))
|
||||
print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(starter, 5, r).snapshot(0), 10))
|
||||
print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(5, r).snapshot(0), 10))
|
||||
quit()
|
||||
|
||||
@@ -8,31 +8,26 @@ func _initialize() -> void:
|
||||
var games := 100
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 0
|
||||
var starter := Profile.starter().deck_cards()
|
||||
var random_deck := func(rr: RandomNumberGenerator) -> Array:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, rr)
|
||||
return Game.build_opponent_deck(rr, [pool[0], pool[1]])
|
||||
var t0 := Time.get_ticks_msec()
|
||||
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
|
||||
for n in range(1, Game.MAX_OPPONENTS + 1):
|
||||
_run("Zufallsdeck", random_deck, n, games, r, true, true)
|
||||
_run(n, games, r, true, true)
|
||||
print("Du mit Vorausschau, Gegner gierig:")
|
||||
for n in [1, 2, 3]:
|
||||
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true)
|
||||
_run(n, games / 2, r, false, true)
|
||||
print("Alle mit Vorausschau:")
|
||||
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false)
|
||||
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false)
|
||||
_run(1, games / 2, r, false, false)
|
||||
_run(2, games / 4, r, false, false)
|
||||
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
|
||||
quit()
|
||||
|
||||
|
||||
func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator,
|
||||
func _run(n_opp: int, games: int, r: RandomNumberGenerator,
|
||||
human_greedy: bool, opp_greedy: bool) -> void:
|
||||
var wins := 0
|
||||
var turns := 0
|
||||
for i in games:
|
||||
var g := Game.new_battle(deck_fn.call(r), n_opp, r)
|
||||
var g := Game.new_battle(n_opp, r)
|
||||
while g.winner == "":
|
||||
var p := g.current()
|
||||
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
|
||||
@@ -40,4 +35,4 @@ func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNum
|
||||
if g.winner == "human":
|
||||
wins += 1
|
||||
turns += g.turn
|
||||
print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games])
|
||||
print("gegen %d: Siege %3d%% Ø Züge %.1f" % [n_opp, wins * 100 / games, float(turns) / games])
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
extends SceneTree
|
||||
## Archetyp-Stärke: jede Elementkombination gegen zufällige andere, gleiche Zielstärke, beide KI.
|
||||
|
||||
|
||||
func archetypes() -> Array:
|
||||
var out := []
|
||||
for e in Cards.ELEMENTS:
|
||||
out.append([e])
|
||||
for i in Cards.ELEMENTS.size():
|
||||
for j in range(i + 1, Cards.ELEMENTS.size()):
|
||||
out.append([Cards.ELEMENTS[i], Cards.ELEMENTS[j]])
|
||||
return out
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 21
|
||||
var arts := archetypes()
|
||||
var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60
|
||||
var results := {}
|
||||
for a in arts:
|
||||
var wins := 0
|
||||
for i in n:
|
||||
var b: Array = arts[r.randi_range(0, arts.size() - 1)]
|
||||
var deck_a := Game.build_opponent_deck_rated(r, a, 1.6)
|
||||
var deck_b := Game.build_opponent_deck_rated(r, b, 1.6)
|
||||
var opp := Game.Player.new("Gegner 1", deck_b, false, Game.START_LIFE, "x")
|
||||
var g := Game.new_battle_with(deck_a, [opp], r)
|
||||
var guard := 0
|
||||
while g.winner == "" and guard < 300:
|
||||
AI.take_turn(g, g.current())
|
||||
guard += 1
|
||||
if g.winner == "human":
|
||||
wins += 1
|
||||
results["+".join(a)] = wins * 100 / n
|
||||
print("%-14s %3d%%" % ["+".join(a), wins * 100 / n])
|
||||
var f := FileAccess.open(DeckGen.ARCHETYPE_PATH, FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(results, "\t"))
|
||||
f.close()
|
||||
print("nach %s geschrieben" % DeckGen.ARCHETYPE_PATH)
|
||||
quit()
|
||||
@@ -1 +0,0 @@
|
||||
uid://d4m21t7od3kdm
|
||||
1050
tests/test_runner.gd
1050
tests/test_runner.gd
File diff suppressed because it is too large
Load Diff
431
tests/ui_test.gd
431
tests/ui_test.gd
@@ -83,11 +83,6 @@ func pad(button: int) -> void:
|
||||
|
||||
|
||||
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
|
||||
func clear_mulligan(scr) -> void:
|
||||
if scr.mulligan_box.visible:
|
||||
await click(scr.b_keep)
|
||||
|
||||
|
||||
func hover(node: Control) -> void:
|
||||
var ev := InputEventMouseMotion.new()
|
||||
ev.position = node.get_global_rect().get_center()
|
||||
@@ -123,15 +118,6 @@ func find_button(text: String, root: Node = null) -> Button:
|
||||
return null
|
||||
|
||||
|
||||
func card_views(root: Node) -> Array:
|
||||
var out := []
|
||||
for c in root.get_children():
|
||||
if c is CardView:
|
||||
out.append(c)
|
||||
out.append_array(card_views(c))
|
||||
return out
|
||||
|
||||
|
||||
## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz).
|
||||
func settle(scr) -> void:
|
||||
for i in 40:
|
||||
@@ -147,7 +133,6 @@ func wait_for_human(scr) -> void:
|
||||
await frames(1)
|
||||
wait += 1
|
||||
await frames(1)
|
||||
await clear_mulligan(scr)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -164,9 +149,9 @@ func _ready() -> void:
|
||||
add_child(main)
|
||||
await frames(3)
|
||||
var fast := OS.get_environment("DINGER_FAST") != ""
|
||||
var suite := [test_menu, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
|
||||
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung,
|
||||
test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster]
|
||||
var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
|
||||
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen,
|
||||
test_kampf_fortsetzen]
|
||||
if not fast:
|
||||
suite += [test_kampf.bind(2, 3), test_kampf_abbrechen]
|
||||
# Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten.
|
||||
@@ -209,108 +194,16 @@ func test_booster() -> void:
|
||||
check(scr.revealed.size() == 5, "alle aufgedeckt")
|
||||
await shot("04_booster_offen")
|
||||
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
|
||||
var ids: Array = scr.cards.map(func(c): return c.id)
|
||||
var better: Array = main.profile.deck_suggestions(ids)
|
||||
check(scr.b_improve.visible == (not better.is_empty()), "Deck-Vorschlag nur bei passender Karte")
|
||||
if scr.b_improve.visible:
|
||||
var deck_before: Array = main.profile.deck.duplicate()
|
||||
await click(scr.b_improve)
|
||||
check(main.profile.deck != deck_before and main.profile.deck_valid()[0] and "im Deck" in scr.msg_label.text, "Karte ins Deck übernommen")
|
||||
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")
|
||||
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")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_deckbau() -> void:
|
||||
await click(find_button("Deck bauen"))
|
||||
var scr = screen()
|
||||
check(scr is DeckScreen, "Deckbildschirm")
|
||||
check(scr.deck.size() == 20, "Deck geladen")
|
||||
check("Stärke" in scr.elems_label.text, "Deckstärke im Deckbau")
|
||||
await shot("05_deck")
|
||||
await click(find_button("Leeren"))
|
||||
check(scr.deck.is_empty(), "geleert")
|
||||
var first_id: String = card_views(scr.grid)[0].card.id
|
||||
var allowed: int = mini(main.profile.collection[first_id], Game.MAX_COPIES)
|
||||
await click(card_views(scr.grid)[0])
|
||||
check(scr.deck == [first_id], "Karte hinzugefügt")
|
||||
for i in allowed:
|
||||
await click(card_views(scr.grid)[0])
|
||||
check(scr.deck.count(first_id) == allowed and "Kopien" in scr.msg_label.text, "nur besessene Kopien (%d)" % allowed)
|
||||
await click(find_button("Speichern"))
|
||||
check(main.profile.deck.size() == 20 and scr.msg_label.text != "Deck gespeichert", "zu kleines Deck nicht gespeichert")
|
||||
await click(find_button("Wasser"))
|
||||
await click(card_views(scr.grid)[0])
|
||||
await click(find_button("Erde"))
|
||||
await click(card_views(scr.grid)[0])
|
||||
check("Elemente" in scr.msg_label.text, "drittes Element abgelehnt")
|
||||
await click(find_button("Alle"))
|
||||
# Suche, Sortierung, fehlende Karten
|
||||
var all_count := card_views(scr.grid).size()
|
||||
scr.search_box.text = "Wächter"
|
||||
scr.search_box.text_changed.emit("Wächter")
|
||||
await frames(1)
|
||||
var found := card_views(scr.grid)
|
||||
check(found.size() > 0 and found.size() < all_count, "Suche filtert")
|
||||
check(found.all(func(v): return "Wächter" in v.card.text() or "Wächter" in v.card.name), "Suche trifft Kartentext")
|
||||
scr.search_box.text = ""
|
||||
scr.search_box.text_changed.emit("")
|
||||
scr.sort_box.select(1)
|
||||
scr.sort_box.item_selected.emit(1)
|
||||
await frames(1)
|
||||
var names: Array = card_views(scr.grid).map(func(v): return v.card.name)
|
||||
var sorted_names := names.duplicate()
|
||||
sorted_names.sort()
|
||||
check(names == sorted_names, "Sortierung nach Name")
|
||||
scr.missing_box.button_pressed = true
|
||||
await frames(1)
|
||||
check(card_views(scr.grid).size() == Cards.all().size(), "Fehlende Karten eingeblendet")
|
||||
var missing = card_views(scr.grid).filter(func(v): return main.profile.collection.get(v.card.id, 0) == 0)
|
||||
check(not missing.is_empty(), "es gibt fehlende Karten")
|
||||
await click(missing[0])
|
||||
check("fehlt" in scr.msg_label.text, "fehlende Karte nicht ins Deck")
|
||||
scr.missing_box.button_pressed = false
|
||||
scr.sort_box.select(0)
|
||||
scr.sort_box.item_selected.emit(0)
|
||||
await frames(1)
|
||||
var n: int = scr.deck.size()
|
||||
await click(scr.deck_list.get_children()[0])
|
||||
check(scr.deck.size() == n - 1, "Zeile entfernt Karte")
|
||||
await hover(card_views(scr.grid)[0])
|
||||
check(scr.preview.visible, "Vorschau bei Hover")
|
||||
await shot("06_deck_bearbeitet")
|
||||
# Slots: Deck 2 aus Deck 1 kopieren, speichern, aktiv setzen
|
||||
await click(scr.slot_buttons[1])
|
||||
if scr.slot != 1 or not scr.deck.is_empty():
|
||||
print("DEBUG Slot: slot=", scr.slot, " deck=", scr.deck.size(), " rect=", scr.slot_buttons[1].get_global_rect(),
|
||||
" hovered=", get_viewport().gui_get_hovered_control(), " decks1=", main.profile.decks[1].cards.size())
|
||||
check(scr.slot == 1 and scr.deck.is_empty(), "Slot 2 leer")
|
||||
scr.deck = main.profile.decks[0].cards.duplicate()
|
||||
scr.refresh()
|
||||
await click(find_button("Speichern"))
|
||||
check(main.profile.decks[1].cards.size() == 20 and "gespeichert" in scr.msg_label.text, "Slot 2 gespeichert")
|
||||
await click(find_button("Aktiv setzen"))
|
||||
check(main.profile.active_deck == 1 and main.profile.deck_name() == "Deck 2", "Slot 2 aktiv")
|
||||
check(Profile.load_or_new(SAVE).active_deck == 1, "aktiver Slot gespeichert")
|
||||
scr.name_box.text = "Testdeck"
|
||||
scr.name_box.text_submitted.emit("Testdeck")
|
||||
check(main.profile.deck_name(1) == "Testdeck" and Profile.load_or_new(SAVE).deck_name(1) == "Testdeck", "Deck umbenannt")
|
||||
await click(scr.slot_buttons[2])
|
||||
check(scr.slot == 2 and scr.deck.is_empty(), "Slot 3 leer")
|
||||
await click(find_button("Auto-Ausfüllen"))
|
||||
check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar")
|
||||
await shot("25_deck_slots")
|
||||
await click(scr.deck_list.get_children()[0]) # ungespeicherte Änderung: eine Karte entfernt
|
||||
check(scr.dirty(), "Deck geändert")
|
||||
await click(find_button("Zurück"))
|
||||
check(scr.confirm.visible and screen() == scr, "Warnung bei ungespeicherten Änderungen")
|
||||
await click(find_button("Weiter bearbeiten"))
|
||||
check(not scr.confirm.visible, "weiter bearbeiten")
|
||||
await click(find_button("Zurück"))
|
||||
await click(find_button("Verwerfen"))
|
||||
check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen")
|
||||
|
||||
|
||||
func target_view(scr, valid: Array) -> Control:
|
||||
for v in scr.enemy_views:
|
||||
if v.creature in valid:
|
||||
@@ -326,17 +219,26 @@ func target_view(scr, valid: Array) -> Control:
|
||||
|
||||
func human_turn(scr) -> void:
|
||||
var g: Game = scr.game
|
||||
await clear_mulligan(scr)
|
||||
# erst kaufen, solange Mana und Handplatz reichen
|
||||
for i in 12:
|
||||
var offers: Array = scr.shop_views.filter(func(v): return g.human.energy >= v.card.cost and g.human.hand.size() < Game.MAX_HAND)
|
||||
if offers.is_empty():
|
||||
break
|
||||
var offer: Cards.Card = offers[0].card
|
||||
var mana: int = g.human.energy
|
||||
await click(offers[0])
|
||||
check(g.human.energy == mana - offer.cost, "gekauft: " + offer.name)
|
||||
# dann ausspielen, das kostet nichts
|
||||
for i in 12:
|
||||
var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0])
|
||||
if playable.is_empty():
|
||||
break
|
||||
var card: Cards.Card = playable[0].card
|
||||
var energy: int = g.human.energy
|
||||
var board_before: int = g.human.board.size()
|
||||
await click(playable[0])
|
||||
if scr.pending != null:
|
||||
await click(target_view(scr, g.valid_targets(g.human, card.target_type())))
|
||||
check(g.human.energy == energy - card.cost, "Karte gespielt: " + card.name)
|
||||
check(g.human.board.size() > board_before, "Karte gespielt: " + card.name)
|
||||
for i in 12:
|
||||
var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack())
|
||||
if ready.is_empty() or g.winner != "":
|
||||
@@ -377,9 +279,8 @@ func test_gegnerauswahl() -> void:
|
||||
await click(find_button("Einzelspieler"))
|
||||
var sel = screen()
|
||||
check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet")
|
||||
check("Deckstärke" in sel.rating_label.text and "Gegnerdecks" in sel.rating_label.text
|
||||
and not ("Archetypen" in sel.rating_label.text or "KI" in sel.rating_label.text), "unter der Schwierigkeit nur die zwei Stärken")
|
||||
check(find_button("Neu würfeln") == null and find_button("1 Gegner") == null and find_button("Starten") != null, "kein Würfeln und keine Gegnerknöpfe, nur Starten")
|
||||
check(find_button("Feuer") == null and find_button("Luft") == null, "keine Elementwahl mehr")
|
||||
check(sel.count_slider.min_value == 1 and sel.count_slider.max_value == Game.MAX_OPPONENTS and sel.count_slider.step == 1, "Gegnerzahl als Schieberegler von 1 bis %d" % Game.MAX_OPPONENTS)
|
||||
check(sel.opponents() == 1 and sel.count_label.text == "1 Gegner" and "Duell" in sel.info_label.text, "Regler beginnt beim Duell")
|
||||
var rect: Rect2 = sel.count_slider.get_global_rect()
|
||||
@@ -400,17 +301,17 @@ func test_gegnerauswahl() -> void:
|
||||
await click(find_button("Starten"))
|
||||
var hard = screen()
|
||||
check(hard is BattleScreen and hard.game.opponents.size() == 1, "Kampf mit einem Gegner gestartet")
|
||||
var own := Game.deck_rating(main.profile.deck_cards())
|
||||
var opp: Game.Player = hard.game.opponents[0]
|
||||
var opp_rating := Game.deck_rating(opp.deck + opp.hand)
|
||||
check(opp_rating >= own * 1.05 - 0.05 and hard.smart_ai, "Schwer: Gegnerdeck stärker (%.2f zu %.2f), Vorausschau-KI" % [opp_rating, own])
|
||||
check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life)
|
||||
check(hard.game.human.shop.size() == Game.SHOP_SIZE and hard.game.human.hand.is_empty(),
|
||||
"Start mit vollem Laden und leerer Hand")
|
||||
await give_up()
|
||||
await click(find_button("Einzelspieler"))
|
||||
await click(find_button("Leicht"))
|
||||
await click(find_button("Starten"))
|
||||
var easy = screen()
|
||||
opp = easy.game.opponents[0]
|
||||
check(Game.deck_rating(opp.deck + opp.hand) <= own * 1.0 + 0.15 and not easy.smart_ai, "Leicht: schwächeres Deck, einfache KI")
|
||||
check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life)
|
||||
await give_up()
|
||||
await click(find_button("Einzelspieler"))
|
||||
await click(find_button("Normal"))
|
||||
@@ -438,7 +339,6 @@ func leave_battle() -> void:
|
||||
|
||||
func give_up() -> void:
|
||||
var scr = screen()
|
||||
await clear_mulligan(scr)
|
||||
await click(find_button("Aufgeben"))
|
||||
check(scr.confirm.visible, "Aufgeben fragt nach")
|
||||
await click(find_button("Weiterspielen"))
|
||||
@@ -448,71 +348,6 @@ func give_up() -> void:
|
||||
check(screen() is MenuScreen, "Aufgeben führt ins Menü")
|
||||
|
||||
|
||||
## Gamepad im Deckbau: Karten und Deckzeilen sind fokussierbar, A wirkt wie ein Klick, B geht zurück.
|
||||
func test_gamepad_deckbau() -> void:
|
||||
await click(find_button("Deck bauen"))
|
||||
var scr = screen()
|
||||
var before: int = scr.deck.size()
|
||||
await pad(JOY_BUTTON_DPAD_DOWN)
|
||||
check(get_viewport().gui_get_focus_owner() != null and main.pad_mode, "Gamepad setzt Fokus im Deckbau")
|
||||
var row: Control = scr.deck_list.get_child(0)
|
||||
row.grab_focus()
|
||||
await frames(1)
|
||||
check(row.has_focus() and scr.preview.visible, "Deckzeile fokussierbar, Vorschau folgt")
|
||||
await pad(JOY_BUTTON_A)
|
||||
check(scr.deck.size() == before - 1, "A entfernt die fokussierte Karte aus dem Deck")
|
||||
var elems: Array = Game.deck_elements(scr.deck_cards())
|
||||
var target: CardView = null
|
||||
for v in scr.grid.views():
|
||||
if not v.dim and v.card.elements.all(func(e): return e in elems):
|
||||
target = v
|
||||
break
|
||||
target.grab_focus()
|
||||
await frames(1)
|
||||
check(target.has_focus() and scr.preview.visible and scr.preview.card == target.card, "Karte im Raster fokussierbar mit Vorschau")
|
||||
var target_id: String = target.card.id
|
||||
await pad(JOY_BUTTON_A) # das Raster wird danach neu aufgebaut, target ist dann freigegeben
|
||||
check(scr.deck.size() == before and target_id in scr.deck, "A nimmt die fokussierte Karte ins Deck (%d Karten, Meldung '%s')" % [scr.deck.size(), scr.msg_label.text])
|
||||
await shot("31_gamepad_deckbau")
|
||||
await pad(JOY_BUTTON_B)
|
||||
check(scr.confirm.visible, "B fragt bei ungespeicherten Änderungen nach")
|
||||
await click(find_button("Verwerfen"))
|
||||
check(screen() is MenuScreen, "Verwerfen führt ins Menü")
|
||||
await hover(main)
|
||||
|
||||
|
||||
## Deck-Code kopieren, einfügen und übernehmen.
|
||||
func test_deckcode() -> void:
|
||||
await click(find_button("Deck bauen"))
|
||||
var scr = screen()
|
||||
await click(find_button("Code"))
|
||||
check(scr.code_dialog.visible and scr.code_box.text.begins_with(Profile.CODE_PREFIX), "Code-Dialog zeigt den Deck-Code")
|
||||
await click(find_button("In Zwischenablage"))
|
||||
check(DisplayServer.clipboard_get() == scr.code_box.text, "Code in der Zwischenablage")
|
||||
await shot("32_deckcode")
|
||||
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,x_nix*1"
|
||||
await click(find_button("Übernehmen"))
|
||||
check(scr.deck.size() == Game.DECK_SIZE and "Ungültiger Code" in scr.msg_label.text, "ungültiger Code wird abgelehnt")
|
||||
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,fff_sonne*1"
|
||||
await click(find_button("Übernehmen"))
|
||||
check(scr.deck == ["f_funke", "f_funke", "e_wall"] and "Kern der Sonne" in scr.msg_label.text and not scr.code_dialog.visible, "Code übernommen, fehlende Karte gemeldet")
|
||||
await click(find_button("Zurück"))
|
||||
await click(find_button("Verwerfen"))
|
||||
check(screen() is MenuScreen, "Deckbau verlassen")
|
||||
|
||||
|
||||
## Elementbooster liefert nur Karten des Elements.
|
||||
func test_elementbooster() -> void:
|
||||
main.profile.coins = 300
|
||||
await click(find_button("Booster öffnen"))
|
||||
var scr = screen()
|
||||
await click(find_button("Feuer"))
|
||||
check(scr.cards.size() == 5 and scr.cards.all(func(c): return "Feuer" in c.elements) and main.profile.coins == 300 - Profile.ELEMENT_BOOSTER_COST, "Feuer-Booster: fünf Feuerkarten")
|
||||
await click(find_button("Alle aufdecken"))
|
||||
await shot("33_elementbooster")
|
||||
await click(find_button("Zurück"))
|
||||
|
||||
|
||||
## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei.
|
||||
func test_kampf_fortsetzen() -> void:
|
||||
await start_battle(1)
|
||||
@@ -569,28 +404,20 @@ func test_sammlung() -> void:
|
||||
var scr = screen()
|
||||
check(scr is CollectionScreen, "Sammlung geöffnet")
|
||||
var p: Profile = main.profile
|
||||
p.dust = 0
|
||||
var in_decks := {}
|
||||
for d in p.decks:
|
||||
for cid in d.cards:
|
||||
in_decks[cid] = true
|
||||
var free = scr.grid.views().filter(func(v): return p.collection.get(v.card.id, 0) > 0 and not in_decks.has(v.card.id))
|
||||
check(not free.is_empty(), "eine freie Karte zum Zerlegen")
|
||||
var card: Cards.Card = free[0].card
|
||||
await click(free[0])
|
||||
check(scr.selected == card, "Karte gewählt")
|
||||
var before: int = p.collection[card.id]
|
||||
await click(scr.b_dismantle)
|
||||
check(p.collection[card.id] == before - 1 and p.dust == Profile.DUST_DISMANTLE[card.rarity], "zerlegt, Staub gutgeschrieben")
|
||||
check(scr.grid.views().size() == Cards.all().size(), "alle Karten gehören dir")
|
||||
p.dust = 5000
|
||||
scr.search_box.text = "Phönix"
|
||||
scr.search_box.text_changed.emit("Phönix")
|
||||
await frames(1)
|
||||
var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0]
|
||||
await click(phoenix)
|
||||
await click(scr.b_craft)
|
||||
check(p.collection.get("ff_phoenix", 0) == 1 and p.dust == 5000 - Profile.DUST_CRAFT["legendär"], "Phönix hergestellt")
|
||||
check(Profile.load_or_new(SAVE).collection.get("ff_phoenix", 0) == 1, "Sammlung gespeichert")
|
||||
check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "gewöhnlich", "Karte gewählt, noch gewöhnlich")
|
||||
await click(scr.b_upgrade)
|
||||
check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["selten"], "auf selten aufgewertet")
|
||||
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["selten"], "wieder zerlegt")
|
||||
await shot("20_sammlung")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
@@ -634,7 +461,7 @@ 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.size() > 0, "Profil zurückgesetzt")
|
||||
check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.is_empty(), "Profil zurückgesetzt")
|
||||
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
|
||||
|
||||
|
||||
@@ -646,7 +473,7 @@ func test_sprache() -> void:
|
||||
await frames(2)
|
||||
check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch")
|
||||
check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen")
|
||||
check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Energie") == "Not enough energy", "dynamische Texte übersetzt")
|
||||
check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Mana") == "Not enough mana", "dynamische Texte übersetzt")
|
||||
check(find_button("Back") != null, "Knöpfe übersetzt")
|
||||
await shot("26_englisch")
|
||||
await click(find_button("Back"))
|
||||
@@ -670,41 +497,39 @@ func test_kampf_steuerung() -> void:
|
||||
scr.ai_delay = 0.0
|
||||
await wait_for_human(scr)
|
||||
var g: Game = scr.game
|
||||
var idx := -1
|
||||
for i in g.human.hand.size():
|
||||
if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]:
|
||||
idx = i
|
||||
break
|
||||
check(idx >= 0, "spielbares Wesen auf der Hand")
|
||||
if idx >= 0:
|
||||
var played_id: String = g.human.hand[idx].id
|
||||
var board_before: int = g.human.board.size()
|
||||
await key(KEY_1 + idx)
|
||||
check(g.human.board.size() == board_before + 1, "Wesen per Taste gespielt")
|
||||
# Rückgängig: Wesen zurück auf die Hand
|
||||
check(scr.b_undo.visible, "Rückgängig angeboten")
|
||||
var energy: int = g.human.energy
|
||||
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 == played_id) and g.human.energy > energy, "Rückgängig stellt Hand und Energie her")
|
||||
check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht")
|
||||
await key(KEY_1 + idx)
|
||||
check(g.human.board.size() == board_before + 1, "erneut gespielt")
|
||||
g.human.energy = 20 # das Angebot ist zufällig, hier soll nur die Steuerung geprüft werden
|
||||
scr.refresh()
|
||||
check(scr.shop_views.size() == Game.SHOP_SIZE and g.human.hand.is_empty(), "voller Laden, leere Hand")
|
||||
# Kaufen: Mana weg, Karte auf der Hand
|
||||
var offer: Cards.Card = scr.shop_views[0].card
|
||||
var mana: int = g.human.energy
|
||||
await click(scr.shop_views[0])
|
||||
check(g.human.hand.size() == 1 and g.human.hand[0] == offer and g.human.energy == mana - offer.cost, "Angebot gekauft")
|
||||
# Ausspielen kostet nichts
|
||||
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, "Wesen per Taste gespielt, ohne Mana")
|
||||
# Rückgängig: Wesen zurück auf die Hand
|
||||
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 == offer.id), "Rückgängig stellt die Hand 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")
|
||||
# Neu würfeln kostet Mana
|
||||
var shop_before: Array = g.human.shop.duplicate()
|
||||
mana = g.human.energy
|
||||
await click(scr.b_reroll)
|
||||
check(g.human.energy == mana - Game.REROLL_COST and g.human.shop != shop_before, "Laden neu gewürfelt")
|
||||
await shot("22_kampf_steuerung")
|
||||
# Gamepad: Zeiger bewegen, Karte spielen
|
||||
# Gamepad: Zeiger im Laden bewegen, A kauft
|
||||
await pad(JOY_BUTTON_DPAD_RIGHT)
|
||||
check(scr.gp_zone == "hand" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweiter Handkarte")
|
||||
var pad_idx := -1
|
||||
for i in g.human.hand.size():
|
||||
if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]:
|
||||
pad_idx = i
|
||||
break
|
||||
if pad_idx >= 0:
|
||||
while scr.gp_index != pad_idx:
|
||||
await pad(JOY_BUTTON_DPAD_RIGHT)
|
||||
var board_before2: int = g.human.board.size()
|
||||
await pad(JOY_BUTTON_A)
|
||||
check(g.human.board.size() == board_before2 + 1, "Gamepad A spielt das Wesen")
|
||||
check(scr.gp_zone == "shop" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweitem Angebot")
|
||||
var hand_before: int = g.human.hand.size()
|
||||
await pad(JOY_BUTTON_A)
|
||||
check(g.human.hand.size() == hand_before + 1, "Gamepad A kauft das Angebot")
|
||||
scr.ai_delay = 0.75
|
||||
await key(KEY_SPACE)
|
||||
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
|
||||
@@ -717,7 +542,6 @@ func test_kampf_steuerung() -> void:
|
||||
w += 1
|
||||
check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w)
|
||||
scr.ai_delay = 0.0
|
||||
await clear_mulligan(scr)
|
||||
await key(KEY_ESCAPE)
|
||||
check(scr.confirm.visible, "Esc öffnet Aufgeben")
|
||||
await key(KEY_ESCAPE)
|
||||
@@ -725,6 +549,92 @@ func test_kampf_steuerung() -> void:
|
||||
await give_up()
|
||||
|
||||
|
||||
## Grenzen im Kampf: volle Hand, volles Feld, kein Mana.
|
||||
func test_kampf_grenzen() -> void:
|
||||
await start_battle(1)
|
||||
var scr = screen()
|
||||
scr.ai_delay = 0.0
|
||||
await wait_for_human(scr)
|
||||
var g: Game = scr.game
|
||||
g.human.energy = 100
|
||||
scr.refresh()
|
||||
await frames(1)
|
||||
# Hand voll kaufen
|
||||
var guard := 0
|
||||
while g.human.hand.size() < Game.MAX_HAND and guard < 30:
|
||||
guard += 1
|
||||
if scr.shop_views.is_empty():
|
||||
await click(scr.b_reroll)
|
||||
continue
|
||||
await click(scr.shop_views[0])
|
||||
check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND)
|
||||
check(scr.shop_views.all(func(v): return v.dim), "bei voller Hand ist nichts mehr kaufbar")
|
||||
if not scr.shop_views.is_empty():
|
||||
await click(scr.shop_views[0])
|
||||
check(scr.msg_label.text == tr("Hand 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
|
||||
await key(KEY_1)
|
||||
if scr.pending != null: # Karte will ein Ziel
|
||||
var valid: Array = g.valid_targets(g.human, scr.pending.target_type())
|
||||
if valid.is_empty():
|
||||
await right_click()
|
||||
else:
|
||||
await click(target_view(scr, valid))
|
||||
check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD)
|
||||
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")
|
||||
# Verschmelzen macht wieder Platz
|
||||
var before: int = g.human.board.size()
|
||||
await click(scr.b_fuse)
|
||||
await click(scr.own_views[0])
|
||||
await click(scr.own_views[1])
|
||||
check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei")
|
||||
# Ohne Mana kein Neuwürfeln
|
||||
g.human.energy = 0
|
||||
scr.refresh()
|
||||
await frames(1)
|
||||
check(scr.b_reroll.disabled, "ohne Mana kein Neuwürfeln")
|
||||
await give_up()
|
||||
|
||||
|
||||
## Feldanzeige und Verschmelzen im Kampf.
|
||||
func test_kampf_feld_und_verschmelzen() -> void:
|
||||
await start_battle(1)
|
||||
var scr = screen()
|
||||
check(scr is BattleScreen, "Kampf gestartet")
|
||||
scr.ai_delay = 0.0
|
||||
await wait_for_human(scr)
|
||||
var g: Game = scr.game
|
||||
g.human.board.clear()
|
||||
for id in ["f_wolf", "w_reiter"]:
|
||||
var cr := Game.Creature.new(Cards.by_id(id), g.human, g.next_uid)
|
||||
g.next_uid += 1
|
||||
g.human.board.append(cr)
|
||||
g.human.energy = 10
|
||||
for i in Cards.FIELD_CHARGE:
|
||||
g.charge_field(Cards.by_id("f_kobold"))
|
||||
scr.refresh()
|
||||
await frames(2)
|
||||
check(scr.field_label.text.begins_with("Vulkan"), "Feldanzeige nennt das Feld: " + scr.field_label.text)
|
||||
check(g.human.board[0].field_bonus == 1 and g.human.board[0].power() == 4, "das Feuerwesen bekommt +1 Angriff")
|
||||
check(scr.b_fuse.visible and scr.b_fuse.text == "Verschmelzen", "Verschmelzen wird angeboten")
|
||||
await shot("23_feld")
|
||||
await click(scr.b_fuse)
|
||||
check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt")
|
||||
await click(scr.own_views[0])
|
||||
check(scr.fuse_first != null, "erstes Wesen gewählt")
|
||||
await shot("24_verschmelzen")
|
||||
await click(scr.own_views[1])
|
||||
check(g.human.board.size() == 1 and g.human.board[0].name == "Dampf", "die beiden Wesen sind zu Dampf verschmolzen")
|
||||
check(not scr.fusing and g.human.energy == 8, "Modus beendet und Mana bezahlt")
|
||||
check(scr.own_views.size() == 1, "nur noch eine eigene Kachel")
|
||||
await give_up()
|
||||
|
||||
|
||||
func test_netzwerk() -> void:
|
||||
await click(find_button("Netzwerkspiel"))
|
||||
var scr = screen()
|
||||
@@ -756,7 +666,6 @@ func test_netzwerk() -> void:
|
||||
var rounds := 0
|
||||
while b.game != null and b.game.winner == "" and rounds < 2:
|
||||
if b.game.current() == b.perspective:
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective:
|
||||
await click(b.b_end)
|
||||
var w2 := 0
|
||||
@@ -767,7 +676,7 @@ func test_netzwerk() -> void:
|
||||
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 spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt")
|
||||
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"
|
||||
@@ -781,7 +690,6 @@ func test_netzwerk() -> void:
|
||||
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:
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective and b.game.winner == "":
|
||||
await click(b.b_end)
|
||||
var w4 := 0
|
||||
@@ -799,7 +707,6 @@ func test_netzwerk() -> void:
|
||||
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:
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective and b.game.winner == "":
|
||||
await click(b.b_end)
|
||||
var w6 := 0
|
||||
@@ -873,7 +780,6 @@ func test_host_wechsel() -> void:
|
||||
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
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective:
|
||||
await click(b.b_end)
|
||||
w = 0
|
||||
@@ -906,7 +812,7 @@ func test_zuschauer() -> void:
|
||||
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.mulligan_box.visible and not b.b_end.visible,
|
||||
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")
|
||||
@@ -964,7 +870,7 @@ func test_kampf(n_opp: int, shot_turn: int) -> void:
|
||||
var g: Game = scr.game
|
||||
check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost")
|
||||
await wait_for_human(scr)
|
||||
check(g.current() == g.human and g.human.mulligan_done, "Spieler ist dran, Mulligan erledigt")
|
||||
check(g.current() == g.human, "Spieler ist dran")
|
||||
check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar")
|
||||
await shot("07_kampf_start")
|
||||
var coins: int = main.profile.coins
|
||||
@@ -998,35 +904,30 @@ func test_kampf_abbrechen() -> void:
|
||||
var g: Game = scr.game
|
||||
await wait_for_human(scr)
|
||||
check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner")
|
||||
# Falle legen und Kombo-Hinweis prüfen
|
||||
g.human.hand.append(Cards.by_id("f_falle"))
|
||||
# Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit
|
||||
g.human.energy = 10
|
||||
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
|
||||
g.human.board.append(wolf)
|
||||
g.human.hand.append(Cards.by_id("w_reiter"))
|
||||
g.human.looks["w_reiter"] = Cards.RARITIES.find("episch")
|
||||
scr.refresh()
|
||||
var trap_view = scr.hand_views.filter(func(v): return v.card.id == "f_falle")[0]
|
||||
await click(trap_view)
|
||||
check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar")
|
||||
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
|
||||
check(reiter_view.rarity == "episch", "Handkarte trägt die eigene Stufe")
|
||||
await hover(scr.log_box) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
|
||||
await hover(reiter_view)
|
||||
var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel)
|
||||
if hint_boxes.size() != 1:
|
||||
print("DEBUG Hinweis: hints=", scr.combo_hints(Cards.by_id("w_reiter")), " kinder=", scr.preview_holder.get_children().map(func(c): return c.get_class()),
|
||||
" brett=", g.human.board.map(func(c): return c.name), " hovered=", get_viewport().gui_get_hovered_control(), " reiter_pos=", reiter_view.get_global_rect())
|
||||
check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau")
|
||||
await shot("21_kampf_falle_hinweis")
|
||||
var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
|
||||
if not spells.is_empty():
|
||||
var card: Cards.Card = spells[0].card
|
||||
await click(spells[0])
|
||||
var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView)
|
||||
check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls")
|
||||
await shot("21_kampf_vorschau")
|
||||
var targeted: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
|
||||
if not targeted.is_empty():
|
||||
var card: Cards.Card = targeted[0].card
|
||||
await click(targeted[0])
|
||||
check(scr.pending == card, "Ziel wird erwartet")
|
||||
await right_click()
|
||||
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab")
|
||||
await hover(scr.hand_views[0])
|
||||
if scr.preview_holder.get_child_count() == 0:
|
||||
print("DEBUG Hover: mulligan=", scr.mulligan_box.visible, " overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(),
|
||||
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(find_button("Lupe"))
|
||||
|
||||
@@ -5,31 +5,22 @@ Einzelspieler,Single player
|
||||
Kampagne,Campaign
|
||||
Netzwerkspiel,Network game
|
||||
Booster öffnen,Open boosters
|
||||
Deck bauen,Build deck
|
||||
Einstellungen,Settings
|
||||
Beenden,Quit
|
||||
Zurück,Back
|
||||
Start,Start
|
||||
Speichern,Save
|
||||
Leeren,Clear
|
||||
Suche,Search
|
||||
Fehlende,Missing
|
||||
Alle,All
|
||||
Aktiv setzen,Set active
|
||||
Auto-Ausfüllen,Auto-fill
|
||||
Umbenennen,Rename
|
||||
Zug beenden,End turn
|
||||
Aufgeben,Surrender
|
||||
Weiterspielen,Keep playing
|
||||
"Ja, aufgeben","Yes, surrender"
|
||||
Wirklich aufgeben?,Really surrender?
|
||||
Der Kampf endet ohne Münzen.,The battle ends without coins.
|
||||
Zurück zum Menü,Back to menu
|
||||
Lupe,Zoom
|
||||
Schließen,Close
|
||||
Held als Ziel,Target hero
|
||||
Verlauf,Log
|
||||
Karte überfahren für Details,Hover a card for details
|
||||
Lautstärke,Volume
|
||||
Musik,Music
|
||||
KI-Tempo,AI speed
|
||||
@@ -39,30 +30,17 @@ Booster,Boosters
|
||||
Booster kaufen (100 Münzen),Buy booster (100 coins)
|
||||
Alle aufdecken,Reveal all
|
||||
Zerlegen,Dismantle
|
||||
Herstellen,Craft
|
||||
Kämpfen,Fight
|
||||
Wiederholen,Replay
|
||||
Gesperrt,Locked
|
||||
Spiel eröffnen,Host game
|
||||
Beitreten,Join
|
||||
Behalten,Keep
|
||||
Tauschen,Swap
|
||||
Hand tauschen?,Swap cards?
|
||||
Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
|
||||
Fallen,Traps
|
||||
Du bist dran,Your turn
|
||||
Antreten,Enter
|
||||
Gegnerzüge überspringen,Skip AI turns
|
||||
Rückgängig,Undo
|
||||
Kapitel 1,Chapter 1
|
||||
Kapitel 2,Chapter 2
|
||||
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
|
||||
"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d"
|
||||
Aktives Deck: %s (%s),Active deck: %s (%s)
|
||||
leer,empty
|
||||
Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓
|
||||
Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins
|
||||
"Aktives Deck nicht spielbar: ","Active deck not playable: "
|
||||
Zug %d · Du bist dran,Turn %d · Your turn
|
||||
Zug %d · %s ist dran …,Turn %d · %s is playing …
|
||||
Wähle ein Ziel,Choose a target
|
||||
@@ -70,57 +48,35 @@ Wähle ein Angriffsziel,Choose an attack target
|
||||
Abgebrochen,Cancelled
|
||||
%s kann nicht angreifen,%s cannot attack
|
||||
Nicht dein Zug,Not your turn
|
||||
Nicht genug Energie,Not enough energy
|
||||
Nicht auf der Hand,Not in your hand
|
||||
Dein Feld ist voll,Your board is full
|
||||
Kein gültiges Ziel,No valid target
|
||||
Ungültiges Ziel,Invalid target
|
||||
Höchstens 2 Fallen,At most 2 traps
|
||||
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
|
||||
Sieg!,Victory!
|
||||
Niederlage,Defeat
|
||||
+%d Münzen,+%d coins
|
||||
"Deck braucht genau %d Karten (%d)","Deck needs exactly %d cards (%d)"
|
||||
Höchstens %dx dieselbe Karte,At most %dx the same card
|
||||
Karte nicht oft genug in der Sammlung,Not enough copies in your collection
|
||||
Höchstens %d Elemente im Deck,At most %d elements in a deck
|
||||
Schwierigkeit,Difficulty
|
||||
Leicht,Easy
|
||||
Normal,Normal
|
||||
Schwer,Hard
|
||||
Stärke %.1f,Strength %.1f
|
||||
"Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects"
|
||||
Neu würfeln,Reroll
|
||||
Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s)
|
||||
Profil zurücksetzen,Reset profile
|
||||
Große Schrift,Large text
|
||||
Verwerfen,Discard
|
||||
Weiter bearbeiten,Keep editing
|
||||
Ungespeicherte Änderungen,Unsaved changes
|
||||
Duell,Duel
|
||||
%d am Tisch,%d at the table
|
||||
%d Spieler am Tisch,%d players at the table
|
||||
%d Münzen,%d coins
|
||||
Abbrechen,Cancel
|
||||
"Ja, zurücksetzen","Yes, reset"
|
||||
"Speichern und zurück","Save and go back"
|
||||
"Kampf fortsetzen","Resume battle"
|
||||
Aufgeben beendet den Kampf ohne Münzen.,Surrendering ends the battle without coins.
|
||||
Speichern hebt ihn für später auf.,Saving keeps it for later.
|
||||
%s Schaden an den Angreifer,%s damage to the attacker
|
||||
%s Schaden an dieses Wesen,%s damage to that creature
|
||||
Der Angreifer wird eingefroren,The attacker is frozen
|
||||
Es wird eingefroren,It is frozen
|
||||
Es geht zurück auf die Hand,It returns to its owner's hand
|
||||
Der Angriff verpufft,The attack fizzles
|
||||
Beschwöre einen %s,Summon a %s
|
||||
%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature
|
||||
%s Schaden an ein Ziel,%s damage to a target
|
||||
%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures
|
||||
%s Schaden an alle Gegner,%s damage to all opponents
|
||||
%s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures
|
||||
Heile dich um %s,Heal yourself for %s
|
||||
Ziehe 1 Karte,Draw 1 card
|
||||
Ziehe %s Karten,Draw %s cards
|
||||
Erhalte 1 Mana,Gain 1 mana
|
||||
Erhalte %s Mana,Gain %s mana
|
||||
Friere ein gegnerisches Wesen ein,Freeze an enemy creature
|
||||
Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature
|
||||
Friere alle gegnerischen Wesen ein,Freeze all enemy creatures
|
||||
@@ -131,17 +87,11 @@ Alle befreundeten Wesen erhalten %s,All friendly creatures get %s
|
||||
Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield
|
||||
Erhält Schild,Gets Shield
|
||||
Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield
|
||||
Falle. %s: %s,Trap. %s: %s
|
||||
Beim Ausspielen: %s,On play: %s
|
||||
Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift,When an enemy creature attacks you or one of your creatures
|
||||
Wenn ein Gegner ein Wesen ausspielt,When an opponent plays a creature
|
||||
Wächter,Guardian
|
||||
Flink,Swift
|
||||
Schild,Shield
|
||||
Doppelschlag,Double Strike
|
||||
Wesen,Creature
|
||||
Zauber,Spell
|
||||
Falle,Trap
|
||||
gewöhnlich,common
|
||||
selten,rare
|
||||
episch,epic
|
||||
@@ -161,33 +111,49 @@ Blitz,Lightning
|
||||
Schlamm,Mud
|
||||
Sturm,Storm
|
||||
Sand,Sand
|
||||
Sonne,Sun
|
||||
Ozean,Ocean
|
||||
Gebirge,Mountains
|
||||
Himmel,Sky
|
||||
"Code übernommen, es fehlen: ","Code applied, missing: "
|
||||
Elemente: ,Elements:
|
||||
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
|
||||
%s hergestellt,%s crafted
|
||||
%s ist jetzt im Deck %s,%s is now in deck %s
|
||||
Deck-Code,Deck code
|
||||
In Zwischenablage,To clipboard
|
||||
Aus Zwischenablage,From clipboard
|
||||
Code,Code
|
||||
Ungültiger Code,Invalid code
|
||||
Code übernommen,Code applied
|
||||
Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.,Copy to share or paste a code. Cards you do not own are skipped.
|
||||
Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos
|
||||
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks."
|
||||
Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good!
|
||||
Kapitel %d,Chapter %d
|
||||
Zuschauen,Watch
|
||||
Zuschauer,Spectator
|
||||
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
|
||||
Deine Deckstärke %.1f · Gegnerdecks %.1f,Your deck strength %.1f · Opponent decks %.1f
|
||||
Starten,Start
|
||||
1 Gegner,1 opponent
|
||||
%d Gegner,%d opponents
|
||||
Übernehmen,Apply
|
||||
KI-Gegner,AI opponents
|
||||
Sammlung,Collection
|
||||
Verschmelzen,Fuse
|
||||
Zwei eigene Wesen wählen,Pick two of your creatures
|
||||
Zweites Wesen wählen,Pick the second creature
|
||||
Feld: neutral,Field: neutral
|
||||
Zwei verschiedene Wesen wählen,Pick two different creatures
|
||||
Nur eigene Wesen,Only your own creatures
|
||||
Vulkan,Volcano
|
||||
Flut,Flood
|
||||
Fels,Rock
|
||||
Wirbel,Vortex
|
||||
Feuerwesen haben +1 Angriff,Fire creatures get +1 attack
|
||||
Wasserwesen haben +1 Angriff,Water creatures get +1 attack
|
||||
Erdwesen haben +1 Angriff,Earth creatures get +1 attack
|
||||
Luftwesen haben +1 Angriff,Air creatures get +1 attack
|
||||
Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d
|
||||
%s ist jetzt %s,%s is now %s
|
||||
Alle Kapitel abgeschlossen,All chapters completed
|
||||
Elemente,Elements
|
||||
Aufwerten,Upgrade
|
||||
Aufwerten zu %s (%d Staub),Upgrade to %s (%d dust)
|
||||
Höchste Stufe,Top tier
|
||||
Keine Karte gewählt,No card selected
|
||||
Zerlegen (+%d Staub),Dismantle (+%d dust)
|
||||
Nicht genug Staub (%d nötig),Not enough dust (%d needed)
|
||||
Schon auf der höchsten Stufe,Already at the top tier
|
||||
Gewöhnliche Karten lassen sich nicht zerlegen,Common cards cannot be dismantled
|
||||
Aufgewertet,Upgraded
|
||||
+%d Staub,+%d dust
|
||||
+%d je Zug,+%d per turn
|
||||
Neu würfeln (%d),Reroll (%d)
|
||||
Nicht genug Mana,Not enough mana
|
||||
Kein Angebot,No offer
|
||||
Hand voll,Hand full
|
||||
Profil wirklich zurücksetzen?,Really reset your profile?
|
||||
"Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost."
|
||||
Chimäre,Chimera
|
||||
|
||||
|
Binary file not shown.
Reference in New Issue
Block a user