Compare commits
10 Commits
536a523f50
...
7ed58ed69a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed58ed69a | ||
|
|
4c6bf96eeb | ||
|
|
b6d5916a8d | ||
|
|
e1e6211ef8 | ||
|
|
77d1b318d6 | ||
|
|
f68ac07550 | ||
|
|
4246d7e970 | ||
|
|
0dbc98b456 | ||
|
|
7ae6017f12 | ||
|
|
46c3fae233 |
54
.github/workflows/tests.yml
vendored
54
.github/workflows/tests.yml
vendored
@@ -1,6 +1,11 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
headless:
|
||||
@@ -13,10 +18,11 @@ jobs:
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
- name: Tests
|
||||
- name: Statische Prüfung und Tests
|
||||
run: ./tests/run_headless.sh ./godot
|
||||
|
||||
fenster:
|
||||
fenster-schnell:
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -29,7 +35,47 @@ jobs:
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
|
||||
- name: Import
|
||||
run: ./godot --headless --path . --import || true
|
||||
- name: Fenster-Test (virtueller Bildschirm)
|
||||
- name: Fenster-Test (schnell)
|
||||
run: DINGER_FAST=1 xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
|
||||
- name: Ergebnis
|
||||
run: grep -q " 0 fehlgeschlagen" fenster.log
|
||||
|
||||
fenster-voll:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Godot und Xvfb laden
|
||||
run: |
|
||||
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
|
||||
- name: Import
|
||||
run: ./godot --headless --path . --import || true
|
||||
- name: Fenster-Test (voll, mit Netzwerk und Hotseat)
|
||||
run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
|
||||
- name: Ergebnis
|
||||
run: grep -q " 0 fehlgeschlagen" fenster.log
|
||||
|
||||
balance:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Godot laden
|
||||
run: |
|
||||
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
- name: 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
|
||||
|
||||
45
README.md
45
README.md
@@ -26,18 +26,50 @@ Der Spielstand liegt unter `user://spielstand.json`
|
||||
- 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
|
||||
- Geführte Anleitung mit Hervorhebungen, Rückgängig für Fehlklicks, Überspringen von Gegnerzügen
|
||||
- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1
|
||||
- Wöchentliche Herausforderung mit Regelvarianten, Bestenliste (Serie, schnellster Sieg), Statistik-Export als CSV
|
||||
- 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
|
||||
- Anleitung Teil 2 (Mulligan, Fallen, Feldkombo Dampf), Gamepad-Steuerung, große Schrift, Profil zurücksetzen
|
||||
- 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)
|
||||
- Kartentext passt sich der Schriftgröße an, Gamepad auch in Menüs (Fokusrahmen), Anleitung 2 erzwingt Maras Angriff in die Falle
|
||||
- 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
|
||||
- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) werden laufend gespeichert und lassen sich im Menü fortsetzen
|
||||
- Gamepad im Deckbau, in der Werkstatt 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 Werkstatt
|
||||
- Übersetzungsprüfung: jeder tr()-Text und jeder Anleitungsschritt braucht einen englischen Eintrag
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`).
|
||||
`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).
|
||||
`DINGER_FAST=1` lässt den Fenster-Test ohne Hotseat, Netzwerk und lange Kämpfe laufen (etwa ein Drittel der Zeit).
|
||||
Statistiken aus Spieltests lassen sich unter Erfolge als CSV exportieren.
|
||||
|
||||
```
|
||||
godot --headless --path . --import # einmalig: Ressourcen importieren
|
||||
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
|
||||
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
|
||||
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
|
||||
godot --headless --path . -s tests/dump_cards.gd # Kartenpool aus Code nach JSON schreiben (nur bei Bedarf)
|
||||
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_host_wechsel,test_zuschauer godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
|
||||
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 --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
|
||||
```
|
||||
|
||||
## Export
|
||||
@@ -52,11 +84,14 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
|
||||
## Aufbau
|
||||
|
||||
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
|
||||
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Gegner, Hotseat
|
||||
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Hotseat
|
||||
- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen
|
||||
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
|
||||
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
|
||||
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen
|
||||
- `scripts/campaign.gd` – Kampagnenstufen
|
||||
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton
|
||||
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte, Anleitung, Mulligan, Gamepad), Erfolge, Einstellungen, Regeln, Ton
|
||||
- `scenes/main.tscn` – Startszene
|
||||
- `scripts/net.gd` – Netzwerkspiel (ENet)
|
||||
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
|
||||
- `scripts/challenge.gd` – wöchentliche Herausforderungen
|
||||
- `translations/ui.csv` – englische Oberflächentexte
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
|
||||
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
|
||||
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt
|
||||
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich
|
||||
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich, auch als Zuschauer
|
||||
- Ein unterbrochener Kampf lässt sich später fortsetzen
|
||||
- Geführte erste Partie als Einstieg
|
||||
- Wöchentliche Herausforderungen und Bestenliste als Langzeitziel
|
||||
|
||||
## Sammeln
|
||||
- Booster öffnen
|
||||
- Karten haben Seltenheiten
|
||||
- Aus der Sammlung werden Decks gebaut (mehrere Slots)
|
||||
- Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt
|
||||
- Überschüssige Karten lassen sich zerlegen und neue herstellen
|
||||
|
||||
## Elemente
|
||||
|
||||
12
data/archetypen.json
Normal file
12
data/archetypen.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
468
data/karten.json
468
data/karten.json
File diff suppressed because it is too large
Load Diff
164
scripts/ai.gd
164
scripts/ai.gd
@@ -1,7 +1,9 @@
|
||||
class_name AI
|
||||
## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des
|
||||
## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante
|
||||
## (choose_action_greedy) dient als Vergleich.
|
||||
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels
|
||||
## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um
|
||||
## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient
|
||||
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und
|
||||
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
|
||||
|
||||
|
||||
# --- Bewertung -------------------------------------------------------------------
|
||||
@@ -15,7 +17,9 @@ static func creature_value(c: Game.Creature) -> float:
|
||||
if c.attacks_per_turn() == 2:
|
||||
v += c.power()
|
||||
if c.frozen:
|
||||
v -= c.power() * 0.5
|
||||
v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht
|
||||
if not c.ready:
|
||||
v -= c.power() * 0.25
|
||||
return v
|
||||
|
||||
|
||||
@@ -26,13 +30,16 @@ 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. Ein besiegter Gegner ist viel wert.
|
||||
## 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.
|
||||
static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
if not p.alive():
|
||||
return -10000.0
|
||||
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
|
||||
var alive_enemies := 0
|
||||
var threat := 0.0
|
||||
var leader := 0.0
|
||||
for e in g.players:
|
||||
if e == p:
|
||||
continue
|
||||
@@ -40,37 +47,115 @@ static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
score += 40.0
|
||||
continue
|
||||
alive_enemies += 1
|
||||
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
|
||||
var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
|
||||
score -= ev
|
||||
leader = maxf(leader, ev)
|
||||
if e.life <= 6:
|
||||
score += 3.0 # fast besiegt: dranbleiben
|
||||
for c in e.board:
|
||||
if not c.frozen:
|
||||
threat += c.power() * c.attacks_per_turn()
|
||||
if alive_enemies == 0:
|
||||
return 10000.0
|
||||
score -= leader * 0.25
|
||||
var guarded := false
|
||||
for c in p.board:
|
||||
if c.taunt():
|
||||
guarded = true
|
||||
break
|
||||
if guarded:
|
||||
score -= threat * 0.1
|
||||
elif threat >= p.life:
|
||||
score -= 25.0
|
||||
else:
|
||||
score -= threat * 0.3
|
||||
return score
|
||||
|
||||
|
||||
# --- Vorausschau ---------------------------------------------------------------
|
||||
|
||||
static func candidate_actions(game: Game, p: Game.Player) -> Array:
|
||||
const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen
|
||||
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
|
||||
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
|
||||
|
||||
|
||||
## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren).
|
||||
static func _target_rank(kind: String, amount: int, t) -> float:
|
||||
if t is Game.Player:
|
||||
return 50.0 - t.life if kind == "damage" else 0.0
|
||||
match kind:
|
||||
"damage":
|
||||
return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t))
|
||||
"freeze", "bounce":
|
||||
return t.card.cost * 10.0 + t.power()
|
||||
"buff", "shield":
|
||||
return (100.0 if t.can_attack() else 0.0) + t.power()
|
||||
return float(_value(t))
|
||||
|
||||
|
||||
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest.
|
||||
static func _attack_rank(cr: Game.Creature, t) -> float:
|
||||
if t is Game.Player:
|
||||
return 50.0 - t.life
|
||||
if _kills(cr, t):
|
||||
return (100.0 if not _kills(t, cr) else 60.0) + _value(t)
|
||||
return -float(t.health)
|
||||
|
||||
|
||||
static func _top(list: Array, rank: Callable, n: int) -> Array:
|
||||
if list.size() <= n:
|
||||
return list
|
||||
var ranked := []
|
||||
for t in list:
|
||||
ranked.append([rank.call(t), t])
|
||||
ranked.sort_custom(func(a, b): return a[0] > b[0])
|
||||
return ranked.slice(0, n).map(func(pair): return pair[1])
|
||||
|
||||
|
||||
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer.
|
||||
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
|
||||
var out := []
|
||||
var valid_cache := {}
|
||||
for card in p.hand:
|
||||
if not game.can_play(p, card)[0]:
|
||||
continue
|
||||
var tt: String = card.target_type()
|
||||
if tt == "":
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
else:
|
||||
var valid := game.valid_targets(p, tt)
|
||||
if valid.is_empty():
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
for t in valid:
|
||||
out.append({"type": "play", "card": card, "target": t})
|
||||
continue
|
||||
if not valid_cache.has(tt):
|
||||
valid_cache[tt] = game.valid_targets(p, tt)
|
||||
var valid: Array = valid_cache[tt]
|
||||
if valid.is_empty():
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
continue
|
||||
if prune:
|
||||
var fx: Cards.Effect = null
|
||||
for f in card.effects:
|
||||
if f.target == tt:
|
||||
fx = f
|
||||
break
|
||||
var kind: String = fx.kind if fx != null else ""
|
||||
var amount: int = fx.amount if fx != null else 0
|
||||
var key := "%s/%s/%d" % [tt, kind, amount]
|
||||
if not valid_cache.has(key):
|
||||
valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS)
|
||||
valid = valid_cache[key]
|
||||
for t in valid:
|
||||
out.append({"type": "play", "card": card, "target": t})
|
||||
for cr in p.board:
|
||||
if not cr.can_attack():
|
||||
continue
|
||||
var targets := []
|
||||
for e in game.enemies_of(p):
|
||||
for t in e.board + [e]:
|
||||
if game.can_attack(cr, t)[0]:
|
||||
out.append({"type": "attack", "creature": cr, "target": t})
|
||||
targets.append(t)
|
||||
if prune:
|
||||
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
|
||||
for t in targets:
|
||||
out.append({"type": "attack", "creature": cr, "target": t})
|
||||
return out
|
||||
|
||||
|
||||
@@ -92,7 +177,14 @@ static func map_target(g2: Game, game: Game, t):
|
||||
return g2.players[game.players.find(t)]
|
||||
|
||||
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
|
||||
## Überträgt eine auf der Kopie gewählte Aktion zurück auf das Original.
|
||||
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.get("target") != null:
|
||||
out["target"] = map_target(game, copy, action.target)
|
||||
return out
|
||||
|
||||
|
||||
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
|
||||
@@ -100,32 +192,48 @@ static func mulligan_cards(p: Game.Player) -> Array:
|
||||
return p.hand.filter(func(c): return c.cost >= 5)
|
||||
|
||||
|
||||
## Beste Aktion nach einer Vorausschau von zwei Schritten: jede Aktion wird auf einer
|
||||
## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen).
|
||||
## 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 best := {"type": "end"}
|
||||
var best_score := evaluate(game, p) + 0.01
|
||||
for action in candidate_actions(game, 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)
|
||||
apply_on_clone(g2, game, action)
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var score := evaluate(g2, p2)
|
||||
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)
|
||||
var best := {"type": "end"}
|
||||
var best_score := base
|
||||
for i in mini(firsts.size(), DEEPEN):
|
||||
var f: Dictionary = firsts[i]
|
||||
var g2: Game = f.game
|
||||
var score: float = f.score
|
||||
if g2.winner == "":
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var count := 0
|
||||
for f in candidate_actions(g2, p2):
|
||||
if count >= FOLLOW_UPS:
|
||||
for nxt in candidate_actions(g2, p2, true):
|
||||
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
break
|
||||
count += 1
|
||||
var g3 := g2.clone_for(p2)
|
||||
apply_on_clone(g3, g2, f)
|
||||
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001)
|
||||
apply_on_clone(g3, g2, nxt)
|
||||
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = action
|
||||
best = f.action
|
||||
# Erstschritte ohne Vertiefung zählen mit ihrer Einzelbewertung
|
||||
for i in range(DEEPEN, firsts.size()):
|
||||
if firsts[i].score > best_score:
|
||||
best_score = firsts[i].score
|
||||
best = firsts[i].action
|
||||
return best
|
||||
|
||||
|
||||
|
||||
@@ -1,41 +1,69 @@
|
||||
class_name Campaign
|
||||
## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch.
|
||||
## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch.
|
||||
|
||||
const STAGES := [
|
||||
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "feuer_angriff"},
|
||||
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "leben_35"},
|
||||
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "waechter_leben"},
|
||||
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "flink_angriff"},
|
||||
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "zauber_schaden"},
|
||||
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "extra_karten"},
|
||||
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "resonanz_doppelt"},
|
||||
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "schild_wesen"},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "bias": 2.5, "reward": 300, "perk": "alle_flink"},
|
||||
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "bias": 3.0, "reward": 500, "perk": "start_energie"},
|
||||
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"},
|
||||
{"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": "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"},
|
||||
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 500, "perk": "start_energie"},
|
||||
]
|
||||
|
||||
|
||||
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück.
|
||||
const STAGES2 := [
|
||||
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
|
||||
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["leben_35", "extra_karten"]},
|
||||
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
|
||||
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
|
||||
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]},
|
||||
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["extra_karten", "flink_angriff"]},
|
||||
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]},
|
||||
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "bias": 3.5, "reward": 500, "perks": ["alle_flink", "start_energie"]},
|
||||
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "bias": 4.0, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]},
|
||||
{"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": "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": "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"]},
|
||||
]
|
||||
|
||||
|
||||
## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks.
|
||||
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": "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"]},
|
||||
]
|
||||
|
||||
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() + STAGES2.size()
|
||||
return STAGES.size() * CHAPTERS.size()
|
||||
|
||||
|
||||
static func chapter(stage: int) -> int:
|
||||
return 1 if stage < STAGES.size() else 2
|
||||
return stage / STAGES.size() + 1
|
||||
|
||||
|
||||
## Stufe mit einheitlichem Feld "perks".
|
||||
@@ -44,16 +72,18 @@ static func stage(i: int) -> Dictionary:
|
||||
var st: Dictionary = STAGES[i].duplicate()
|
||||
st["perks"] = [st.perk]
|
||||
return st
|
||||
return STAGES2[i - STAGES.size()]
|
||||
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
|
||||
|
||||
|
||||
static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array:
|
||||
## 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:
|
||||
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(r, st.elements, st.bias), false, life, st.title)
|
||||
var boss := Game.Player.new(st.name, Game.build_opponent_deck_rated(r, st.elements, target), false, life, st.title)
|
||||
boss.perks = Array(st.perks)
|
||||
var out := [boss]
|
||||
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
|
||||
for k in extra:
|
||||
out.append(Game.random_opponent(r, k + 1))
|
||||
out.append(Game.random_opponent(r, k + 1, Game.START_LIFE, target))
|
||||
return out
|
||||
|
||||
@@ -29,13 +29,13 @@ const COMBOS := {
|
||||
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
|
||||
const FIELD_COMBO_TEXT := {
|
||||
"Dampf": "Friere das stärkste gegnerische Wesen ein",
|
||||
"Lava": "1 Schaden an alle gegnerischen Wesen",
|
||||
"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",
|
||||
"Sand": "Das neue Wesen erhält Schild",
|
||||
"Sand": "Das neue Wesen erhält +1/+1",
|
||||
}
|
||||
const RESONANCE_TEXT := "Resonanz: Ein Wesen hat +1 Angriff je anderem eigenen Wesen, das ein Element mit ihm teilt (höchstens +4)."
|
||||
const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern."
|
||||
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
|
||||
const RARITY_COLORS := {
|
||||
"gewöhnlich": Color("aaaaaa"),
|
||||
@@ -52,6 +52,8 @@ const KEYWORD_HELP := {
|
||||
"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"
|
||||
|
||||
|
||||
@@ -72,7 +74,7 @@ static func field_combo_effect(name: String) -> Effect:
|
||||
"Dampf":
|
||||
return Effect.new("freeze", "strongest_enemy_creature")
|
||||
"Lava":
|
||||
return Effect.new("damage", "all_enemy_creatures", 1)
|
||||
return Effect.new("damage", "strongest_enemy_creature", 1)
|
||||
"Blitz":
|
||||
return Effect.new("damage", "weakest_enemy_hero", 2)
|
||||
"Schlamm":
|
||||
@@ -80,7 +82,7 @@ static func field_combo_effect(name: String) -> Effect:
|
||||
"Sturm":
|
||||
return Effect.new("draw", "none", 1)
|
||||
"Sand":
|
||||
return Effect.new("shield", "self_creature")
|
||||
return Effect.new("buff", "self_creature", 1, 1)
|
||||
return null
|
||||
|
||||
|
||||
@@ -117,62 +119,73 @@ class Effect extends RefCounted:
|
||||
static func from_dict(d: Dictionary) -> Effect:
|
||||
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
|
||||
|
||||
func text() -> String:
|
||||
## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl).
|
||||
func text(trigger: String = "") -> String:
|
||||
var n := str(amount)
|
||||
if target == "trigger_source":
|
||||
var played := trigger == "creature_played"
|
||||
match kind:
|
||||
"damage":
|
||||
return n + " Schaden an den Angreifer"
|
||||
if played:
|
||||
return tr("%s Schaden an dieses Wesen") % n
|
||||
return tr("%s Schaden an den Angreifer") % n
|
||||
"freeze":
|
||||
return "Der Angreifer wird eingefroren"
|
||||
if played:
|
||||
return tr("Es wird eingefroren")
|
||||
return tr("Der Angreifer wird eingefroren")
|
||||
"bounce":
|
||||
return "Es geht zurück auf die Hand"
|
||||
return tr("Es geht zurück auf die Hand")
|
||||
match kind:
|
||||
"cancel":
|
||||
return "Der Angriff verpufft"
|
||||
return tr("Der Angriff verpufft")
|
||||
"summon":
|
||||
return "Beschwöre einen " + Cards.by_id(card_id).name
|
||||
return tr("Beschwöre einen %s") % Cards.by_id(card_id).title()
|
||||
"damage":
|
||||
match target:
|
||||
"enemy_creature":
|
||||
return n + " Schaden an ein gegnerisches Wesen"
|
||||
return tr("%s Schaden an ein gegnerisches Wesen") % n
|
||||
"enemy_any":
|
||||
return n + " Schaden an ein Ziel"
|
||||
return tr("%s Schaden an ein Ziel") % n
|
||||
"all_enemy_creatures":
|
||||
return n + " Schaden an alle gegnerischen Wesen"
|
||||
return tr("%s Schaden an alle gegnerischen Wesen") % n
|
||||
"all_enemy_heroes":
|
||||
return n + " Schaden an alle Gegner"
|
||||
return tr("%s Schaden an alle Gegner") % n
|
||||
"all_enemies":
|
||||
return n + " Schaden an alle Gegner und ihre Wesen"
|
||||
return tr("%s Schaden an alle Gegner und ihre Wesen") % n
|
||||
"heal":
|
||||
return "Heile dich um " + n
|
||||
return tr("Heile dich um %s") % n
|
||||
"draw":
|
||||
return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten"
|
||||
if amount == 1:
|
||||
return tr("Ziehe 1 Karte")
|
||||
return tr("Ziehe %s Karten") % n
|
||||
"freeze":
|
||||
if target == "enemy_creature":
|
||||
return "Friere ein gegnerisches Wesen ein"
|
||||
return "Friere alle gegnerischen Wesen ein"
|
||||
return tr("Friere ein gegnerisches Wesen ein")
|
||||
if target == "strongest_enemy_creature":
|
||||
return tr("Friere das stärkste gegnerische Wesen ein")
|
||||
return tr("Friere alle gegnerischen Wesen ein")
|
||||
"bounce":
|
||||
if target == "enemy_creature":
|
||||
return "Ein gegnerisches Wesen zurück auf die Hand"
|
||||
return "Alle gegnerischen Wesen zurück auf die Hand"
|
||||
return tr("Ein gegnerisches Wesen zurück auf die Hand")
|
||||
return tr("Alle gegnerischen Wesen zurück auf die Hand")
|
||||
"buff":
|
||||
var b := "+%d/+%d" % [amount, amount2]
|
||||
if target == "friendly_creature":
|
||||
return "Ein befreundetes Wesen erhält " + b
|
||||
return "Alle befreundeten Wesen erhalten " + b
|
||||
return tr("Ein befreundetes Wesen erhält %s") % b
|
||||
return tr("Alle befreundeten Wesen erhalten %s") % b
|
||||
"shield":
|
||||
if target == "friendly_creature":
|
||||
return "Ein befreundetes Wesen erhält Schild"
|
||||
return tr("Ein befreundetes Wesen erhält Schild")
|
||||
if target == "self_creature":
|
||||
return "Erhält Schild"
|
||||
return "Alle befreundeten Wesen erhalten Schild"
|
||||
return tr("Erhält Schild")
|
||||
return tr("Alle befreundeten Wesen erhalten Schild")
|
||||
return ""
|
||||
|
||||
|
||||
class Card extends RefCounted:
|
||||
var id: String
|
||||
var name: String
|
||||
var name: String # deutscher Name (auch im Verlauf und in Tests)
|
||||
var name_en := "" # englischer Name
|
||||
var elements: Array
|
||||
var cost: int
|
||||
var rarity: String
|
||||
@@ -190,7 +203,7 @@ class Card extends RefCounted:
|
||||
return kind == "Falle"
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
|
||||
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
|
||||
@@ -206,6 +219,7 @@ class Card extends RefCounted:
|
||||
var c := Card.new()
|
||||
c.id = d.id
|
||||
c.name = d.name
|
||||
c.name_en = str(d.get("name_en", ""))
|
||||
c.elements = Array(d.elements)
|
||||
c.cost = int(d.cost)
|
||||
c.rarity = d.rarity
|
||||
@@ -218,6 +232,12 @@ class Card extends RefCounted:
|
||||
c.trigger = str(d.get("trigger", ""))
|
||||
return c
|
||||
|
||||
## Anzeigename in der aktuellen Sprache.
|
||||
func title() -> String:
|
||||
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
|
||||
return name_en
|
||||
return name
|
||||
|
||||
func is_combo() -> bool:
|
||||
return elements.size() >= 2
|
||||
|
||||
@@ -236,19 +256,19 @@ class Card extends RefCounted:
|
||||
var kws: Array[String] = []
|
||||
for k in Cards.KEYWORDS:
|
||||
if k in keywords:
|
||||
kws.append(k)
|
||||
kws.append(tr(k))
|
||||
parts.append(", ".join(kws) + ".")
|
||||
if not effects.is_empty():
|
||||
var fx: Array[String] = []
|
||||
for f in effects:
|
||||
fx.append(f.text())
|
||||
fx.append(f.text(trigger))
|
||||
var s := ", ".join(fx) + "."
|
||||
if is_trap():
|
||||
if trigger == "creature_played":
|
||||
s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird")
|
||||
parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s])
|
||||
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(("Beim Ausspielen: " + s) if is_creature() else s)
|
||||
parts.append(s)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -274,6 +294,10 @@ 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)
|
||||
|
||||
|
||||
142
scripts/deckgen.gd
Normal file
142
scripts/deckgen.gd
Normal file
@@ -0,0 +1,142 @@
|
||||
class_name DeckGen
|
||||
## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln).
|
||||
|
||||
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}
|
||||
|
||||
|
||||
|
||||
const ARCHETYPE_PATH := "res://data/archetypen.json"
|
||||
static var _strength: Dictionary = {}
|
||||
|
||||
|
||||
## 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)
|
||||
1
scripts/deckgen.gd.uid
Normal file
1
scripts/deckgen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ev8t5ar278vl
|
||||
154
scripts/game.gd
154
scripts/game.gd
@@ -43,7 +43,6 @@ static func make_rng() -> RandomNumberGenerator:
|
||||
const DECK_SIZE := 20
|
||||
const MAX_COPIES := 3
|
||||
const MAX_DECK_ELEMENTS := 2
|
||||
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
|
||||
|
||||
|
||||
class Creature extends RefCounted:
|
||||
@@ -316,7 +315,7 @@ func mulligan(p: Player, cards: Array) -> String:
|
||||
if n > 0:
|
||||
shuffle(p.deck, rng)
|
||||
draw(p, n)
|
||||
say("%s tauscht %d Karten." % [p.name, n] if n > 0 else "%s behält die Hand." % p.name)
|
||||
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 ""
|
||||
|
||||
@@ -475,7 +474,12 @@ func summon(p: Player, card: Cards.Card) -> void:
|
||||
update_resonance()
|
||||
|
||||
|
||||
## Resonanz: +1 Angriff je anderem eigenen Wesen, das ein Element teilt (höchstens +4).
|
||||
## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3.
|
||||
static func resonance_for(partners: int) -> int:
|
||||
return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)]
|
||||
|
||||
|
||||
## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for).
|
||||
func update_resonance() -> void:
|
||||
for p in players:
|
||||
for c in p.board:
|
||||
@@ -483,7 +487,7 @@ func update_resonance() -> void:
|
||||
for o in p.board:
|
||||
if o != c and o.card.elements.any(func(e): return e in c.card.elements):
|
||||
bonus += 1
|
||||
c.resonance = mini(bonus, RESONANCE_MAX) * (2 if "resonanz_doppelt" in p.perks else 1)
|
||||
c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1)
|
||||
|
||||
|
||||
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void:
|
||||
@@ -643,103 +647,14 @@ func check_end() -> void:
|
||||
say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name)
|
||||
|
||||
|
||||
# --- Zustand für das Netzwerk -------------------------------------------------
|
||||
# --- Zustand für das Netzwerk (siehe NetState) ------------------------------------
|
||||
|
||||
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
|
||||
func snapshot(viewer: int) -> Dictionary:
|
||||
var ps := []
|
||||
for i in players.size():
|
||||
var p: Player = players[i]
|
||||
var mine := i == viewer
|
||||
var board := []
|
||||
for c in p.board:
|
||||
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
|
||||
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
|
||||
"ready": c.ready, "attacks_left": c.attacks_left})
|
||||
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
|
||||
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
|
||||
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
|
||||
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
|
||||
"deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
|
||||
"board": board})
|
||||
var evs := []
|
||||
for ev in events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = e.card.id
|
||||
evs.append(e)
|
||||
return {"players": ps, "human": players.find(human), "turn": turn, "current_idx": current_idx,
|
||||
"winner": winner, "winner_player": players.find(winner_player) if winner_player != null else -1,
|
||||
"next_uid": next_uid, "log": log.slice(maxi(0, log.size() - 80)), "events": evs}
|
||||
return NetState.snapshot(self, viewer)
|
||||
|
||||
|
||||
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
|
||||
static func from_snapshot(d: Dictionary) -> Game:
|
||||
var placeholder: Cards.Card = Cards.all()[0]
|
||||
var list := []
|
||||
for pd in d.players:
|
||||
var p := Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
|
||||
p.max_life = int(pd.max_life)
|
||||
p.energy = int(pd.energy)
|
||||
p.max_energy = int(pd.max_energy)
|
||||
p.fatigue = int(pd.fatigue)
|
||||
p.start_bonus = int(pd.start_bonus)
|
||||
p.order = int(pd.order)
|
||||
p.perks = Array(pd.perks)
|
||||
p.turns = int(pd.turns)
|
||||
p.mulligan_done = bool(pd.mulligan_done)
|
||||
if pd.hand is Array:
|
||||
for cid in pd.hand:
|
||||
p.hand.append(Cards.by_id(cid))
|
||||
else:
|
||||
for i in int(pd.hand):
|
||||
p.hand.append(placeholder)
|
||||
for i in int(pd.deck):
|
||||
p.deck.append(placeholder)
|
||||
if pd.traps is Array:
|
||||
for cid in pd.traps:
|
||||
p.traps.append(Cards.by_id(cid))
|
||||
else:
|
||||
for i in int(pd.traps):
|
||||
p.traps.append(placeholder)
|
||||
for cd in pd.board:
|
||||
var c := Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
|
||||
c.attack = int(cd.attack)
|
||||
c.resonance = int(cd.resonance)
|
||||
c.health = int(cd.health)
|
||||
c.max_health = int(cd.max_health)
|
||||
c.shield = bool(cd.shield)
|
||||
c.frozen = bool(cd.frozen)
|
||||
c.ready = bool(cd.ready)
|
||||
c.attacks_left = int(cd.attacks_left)
|
||||
p.board.append(c)
|
||||
list.append(p)
|
||||
var hi := int(d.human)
|
||||
var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new())
|
||||
g.players = list
|
||||
g.turn = int(d.turn)
|
||||
g.current_idx = int(d.current_idx)
|
||||
g.winner = d.winner
|
||||
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
|
||||
g.next_uid = int(d.next_uid)
|
||||
for line in d.log:
|
||||
g.log.append(str(line))
|
||||
for ev in d.events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = Cards.by_id(e.card)
|
||||
for key in ["player", "uid", "count", "amount"]:
|
||||
if e.has(key):
|
||||
e[key] = int(e[key])
|
||||
for key in ["target", "attacker", "source"]:
|
||||
if e.has(key):
|
||||
var r: Dictionary = e[key]
|
||||
if r.has("uid"):
|
||||
r["uid"] = int(r.uid)
|
||||
if r.has("index"):
|
||||
r["index"] = int(r.index)
|
||||
g.events.append(e)
|
||||
return g
|
||||
return NetState.from_snapshot(d)
|
||||
|
||||
|
||||
# --- Deckregeln und Gegner ---------------------------------------------------
|
||||
@@ -778,43 +693,38 @@ static func validate_deck(cards: Array, owned = null) -> Array:
|
||||
return [true, ""]
|
||||
|
||||
|
||||
## Gegnerdeck aus den Elementen. bias > 1 bevorzugt seltene Karten (Kampagne).
|
||||
## 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:
|
||||
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements))
|
||||
var weights := PackedFloat32Array()
|
||||
for c in pool:
|
||||
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity]
|
||||
weights.append(w / bias if c.rarity == "gewöhnlich" else w * bias)
|
||||
var deck := []
|
||||
var counts := {}
|
||||
while deck.size() < DECK_SIZE:
|
||||
var c = pool[r.rand_weighted(weights)]
|
||||
if counts.get(c.id, 0) < MAX_COPIES:
|
||||
counts[c.id] = counts.get(c.id, 0) + 1
|
||||
deck.append(c)
|
||||
return deck
|
||||
return DeckGen.build_opponent_deck(r, elements, bias)
|
||||
|
||||
|
||||
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE) -> Player:
|
||||
var elements: Array
|
||||
if r.randf() < 0.4:
|
||||
elements = [Cards.ELEMENTS[r.randi_range(0, Cards.ELEMENTS.size() - 1)]]
|
||||
else:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
shuffle(pool, r)
|
||||
elements = [pool[0], pool[1]]
|
||||
return Player.new("Gegner %d" % index, build_opponent_deck(r, elements), false, life,
|
||||
Cards.combo_name(elements) + "-Magier")
|
||||
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)
|
||||
|
||||
|
||||
## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int}
|
||||
static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
|
||||
assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner")
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
var target: float = float(rules.get("target_rating", 0.0))
|
||||
var tier: String = str(rules.get("tier", ""))
|
||||
var opps := []
|
||||
for i in n_opp:
|
||||
opps.append(random_opponent(r, i + 1))
|
||||
opps.append(random_opponent(r, i + 1, START_LIFE, target, tier))
|
||||
return new_battle_with(deck, opps, r, false, rules)
|
||||
|
||||
|
||||
|
||||
171
scripts/net.gd
171
scripts/net.gd
@@ -8,6 +8,8 @@ signal started(seat: int)
|
||||
signal snapshot_received(snap: Dictionary)
|
||||
signal action_applied
|
||||
signal ended(reason: String)
|
||||
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
|
||||
signal status_changed
|
||||
|
||||
const PORT := 24455
|
||||
|
||||
@@ -23,15 +25,25 @@ var my_seat := -1
|
||||
var game: Game # Host
|
||||
var status := ""
|
||||
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
|
||||
const REJOIN_SECONDS := 60.0
|
||||
var rejoin_seconds := 60.0
|
||||
var spectator := false # eigene Rolle: nur zuschauen
|
||||
var spectators := {} # Host: peer_id -> Name
|
||||
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
|
||||
var i_am_successor := false
|
||||
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
|
||||
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
|
||||
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
|
||||
var reconnecting := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connected)
|
||||
multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen"))
|
||||
multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet"))
|
||||
multiplayer.connection_failed.connect(func():
|
||||
if not reconnecting:
|
||||
_fail("Verbindung fehlgeschlagen"))
|
||||
multiplayer.server_disconnected.connect(_on_server_lost)
|
||||
|
||||
|
||||
func host(port: int = PORT) -> Error:
|
||||
@@ -45,6 +57,7 @@ func host(port: int = PORT) -> Error:
|
||||
connected = true
|
||||
names = {1: my_name}
|
||||
decks = {1: my_deck}
|
||||
spectators = {}
|
||||
status = "Warte auf Mitspieler …"
|
||||
lobby_changed.emit()
|
||||
return OK
|
||||
@@ -76,6 +89,13 @@ func leave() -> void:
|
||||
my_seat = -1
|
||||
game = null
|
||||
status = ""
|
||||
spectator = false
|
||||
spectators = {}
|
||||
successor_ip = ""
|
||||
i_am_successor = false
|
||||
backup = {}
|
||||
backup_names = {}
|
||||
last_winner = ""
|
||||
|
||||
|
||||
func local_addresses() -> Array:
|
||||
@@ -91,7 +111,114 @@ func _fail(reason: String) -> void:
|
||||
func _on_connected() -> void:
|
||||
connected = true
|
||||
status = "Verbunden"
|
||||
register.rpc_id(1, my_name, my_deck)
|
||||
register.rpc_id(1, my_name, my_deck, spectator)
|
||||
|
||||
|
||||
# --- Host-Wechsel ---------------------------------------------------------------
|
||||
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
|
||||
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
|
||||
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
|
||||
|
||||
func _successor_id() -> int:
|
||||
var ids: Array = seats.keys().filter(func(id): return id != 1)
|
||||
ids.sort()
|
||||
return ids[0] if not ids.is_empty() else -1
|
||||
|
||||
|
||||
func _update_successor() -> void:
|
||||
if not is_host or game == null:
|
||||
return
|
||||
var succ := _successor_id()
|
||||
var ip := ""
|
||||
if succ > 0 and peer != null and peer.get_peer(succ) != null:
|
||||
ip = peer.get_peer(succ).get_remote_address()
|
||||
for id in seats:
|
||||
if id != 1:
|
||||
set_successor.rpc_id(id, ip, id == succ)
|
||||
|
||||
|
||||
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
|
||||
func _seat_names() -> Dictionary:
|
||||
var out := {}
|
||||
for id in seats:
|
||||
out[seats[id]] = names.get(id, "")
|
||||
for pname in waiting:
|
||||
out[waiting[pname].seat] = pname
|
||||
return out
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func set_successor(ip: String, me: bool) -> void:
|
||||
successor_ip = ip
|
||||
i_am_successor = me
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
|
||||
backup = snap
|
||||
backup_names = seat_names
|
||||
|
||||
|
||||
func _on_server_lost() -> void:
|
||||
if is_host:
|
||||
return
|
||||
if last_winner != "":
|
||||
_fail("Host hat das Spiel beendet")
|
||||
elif i_am_successor and not backup.is_empty():
|
||||
_become_host()
|
||||
elif successor_ip != "":
|
||||
status = "Host weg, verbinde mit dem Nachfolger …"
|
||||
status_changed.emit()
|
||||
_reconnect(successor_ip)
|
||||
else:
|
||||
_fail("Host hat das Spiel beendet")
|
||||
|
||||
|
||||
func _become_host() -> void:
|
||||
var snap := backup
|
||||
var seat_names := backup_names
|
||||
var seat := my_seat
|
||||
if multiplayer.multiplayer_peer != null:
|
||||
multiplayer.multiplayer_peer.close()
|
||||
var err := FAILED
|
||||
for attempt in 5:
|
||||
err = host()
|
||||
if err == OK:
|
||||
break
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
if err != OK:
|
||||
_fail("Host weg, Übernahme fehlgeschlagen")
|
||||
return
|
||||
game = Game.from_snapshot(snap)
|
||||
seats = {1: seat}
|
||||
my_seat = seat
|
||||
waiting = {}
|
||||
for s in seat_names:
|
||||
if int(s) != seat:
|
||||
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
|
||||
game.say("%s übernimmt als Host." % my_name)
|
||||
status = "Du bist jetzt Host, die anderen verbinden neu …"
|
||||
successor_ip = ""
|
||||
i_am_successor = false
|
||||
backup = {}
|
||||
migrated.emit()
|
||||
|
||||
|
||||
func _reconnect(ip: String) -> void:
|
||||
reconnecting = true
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
for attempt in 5:
|
||||
if join(ip) == OK:
|
||||
var t := 0.0
|
||||
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
|
||||
await get_tree().process_frame
|
||||
t += get_process_delta_time()
|
||||
if connected:
|
||||
reconnecting = false
|
||||
return
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
reconnecting = false
|
||||
_fail("Nachfolge-Host nicht erreichbar")
|
||||
|
||||
|
||||
func _on_peer_connected(_id: int) -> void:
|
||||
@@ -104,14 +231,16 @@ func _on_peer_disconnected(id: int) -> void:
|
||||
var pname: String = names.get(id, "")
|
||||
names.erase(id)
|
||||
decks.erase(id)
|
||||
spectators.erase(id)
|
||||
if game != null and seats.has(id):
|
||||
var p: Game.Player = game.players[seats[id]]
|
||||
if game.winner == "":
|
||||
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
|
||||
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)])
|
||||
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
|
||||
seats.erase(id)
|
||||
_update_successor()
|
||||
action_applied.emit()
|
||||
lobby_update.rpc(names)
|
||||
lobby_update.rpc(names, spectators)
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
@@ -123,7 +252,7 @@ func tick() -> void:
|
||||
for pname in waiting.keys():
|
||||
var w: Dictionary = waiting[pname]
|
||||
var p: Game.Player = game.players[w.seat]
|
||||
if now - float(w.since) > REJOIN_SECONDS:
|
||||
if now - float(w.since) > rejoin_seconds:
|
||||
waiting.erase(pname)
|
||||
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
|
||||
game.damage(p, 999)
|
||||
@@ -137,10 +266,18 @@ func tick() -> void:
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func register(name: String, deck_ids: Array) -> void:
|
||||
func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
var id := multiplayer.get_remote_sender_id()
|
||||
if as_spectator:
|
||||
spectators[id] = name
|
||||
lobby_update.rpc(names, spectators)
|
||||
lobby_changed.emit()
|
||||
if game != null:
|
||||
start_game.rpc_id(id, -1)
|
||||
receive_snapshot.rpc_id(id, game.snapshot(-2))
|
||||
return
|
||||
if game != null:
|
||||
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
|
||||
if waiting.has(name):
|
||||
@@ -150,19 +287,21 @@ func register(name: String, deck_ids: Array) -> void:
|
||||
game.say("%s ist zurück." % name)
|
||||
start_game.rpc_id(id, seats[id])
|
||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
||||
_update_successor()
|
||||
action_applied.emit()
|
||||
else:
|
||||
status = "%s wollte beitreten, das Spiel läuft schon" % name
|
||||
return
|
||||
names[id] = name
|
||||
decks[id] = deck_ids
|
||||
lobby_update.rpc(names)
|
||||
lobby_update.rpc(names, spectators)
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func lobby_update(n: Dictionary) -> void:
|
||||
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
|
||||
names = n
|
||||
spectators = sp
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
@@ -194,6 +333,9 @@ func start_battle(n_ai: int) -> void:
|
||||
for id in ids:
|
||||
if id != 1:
|
||||
start_game.rpc_id(id, seats[id])
|
||||
for id in spectators:
|
||||
start_game.rpc_id(id, -1)
|
||||
_update_successor()
|
||||
started.emit(my_seat)
|
||||
|
||||
|
||||
@@ -203,17 +345,24 @@ func start_game(seat: int) -> void:
|
||||
started.emit(seat)
|
||||
|
||||
|
||||
## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht).
|
||||
## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
|
||||
## der Nachfolger zusätzlich den vollen Spielstand).
|
||||
func broadcast() -> void:
|
||||
if not is_host or game == null:
|
||||
return
|
||||
for id in seats:
|
||||
if id != 1:
|
||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
||||
for id in spectators:
|
||||
receive_snapshot.rpc_id(id, game.snapshot(-2))
|
||||
var succ := _successor_id()
|
||||
if succ > 0:
|
||||
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func receive_snapshot(snap: Dictionary) -> void:
|
||||
last_winner = str(snap.get("winner", ""))
|
||||
snapshot_received.emit(snap)
|
||||
|
||||
|
||||
|
||||
105
scripts/netstate.gd
Normal file
105
scripts/netstate.gd
Normal file
@@ -0,0 +1,105 @@
|
||||
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.
|
||||
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
|
||||
static func snapshot(g: Game, viewer: int) -> Dictionary:
|
||||
var ps := []
|
||||
for i in g.players.size():
|
||||
var p: Game.Player = g.players[i]
|
||||
var mine := i == viewer or viewer == -1
|
||||
var board := []
|
||||
for c in p.board:
|
||||
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
|
||||
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
|
||||
"ready": c.ready, "attacks_left": c.attacks_left})
|
||||
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
|
||||
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
|
||||
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
|
||||
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
|
||||
"deck": p.deck.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(),
|
||||
"board": board})
|
||||
var evs := []
|
||||
for ev in g.events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = e.card.id
|
||||
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}
|
||||
|
||||
|
||||
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
|
||||
static func from_snapshot(d: Dictionary) -> Game:
|
||||
var placeholder: Cards.Card = Cards.all()[0]
|
||||
var list := []
|
||||
for pd in d.players:
|
||||
var p := Game.Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
|
||||
p.max_life = int(pd.max_life)
|
||||
p.energy = int(pd.energy)
|
||||
p.max_energy = int(pd.max_energy)
|
||||
p.fatigue = int(pd.fatigue)
|
||||
p.start_bonus = int(pd.start_bonus)
|
||||
p.order = int(pd.order)
|
||||
p.perks = Array(pd.perks)
|
||||
p.turns = int(pd.turns)
|
||||
p.mulligan_done = bool(pd.mulligan_done)
|
||||
if pd.hand is Array:
|
||||
for cid in pd.hand:
|
||||
p.hand.append(Cards.by_id(cid))
|
||||
else:
|
||||
for i in int(pd.hand):
|
||||
p.hand.append(placeholder)
|
||||
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 cd in pd.board:
|
||||
var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
|
||||
c.attack = int(cd.attack)
|
||||
c.resonance = int(cd.resonance)
|
||||
c.health = int(cd.health)
|
||||
c.max_health = int(cd.max_health)
|
||||
c.shield = bool(cd.shield)
|
||||
c.frozen = bool(cd.frozen)
|
||||
c.ready = bool(cd.ready)
|
||||
c.attacks_left = int(cd.attacks_left)
|
||||
p.board.append(c)
|
||||
list.append(p)
|
||||
var hi := int(d.human)
|
||||
var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new())
|
||||
g.players = list
|
||||
g.turn = int(d.turn)
|
||||
g.current_idx = int(d.current_idx)
|
||||
g.winner = d.winner
|
||||
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
|
||||
g.next_uid = int(d.next_uid)
|
||||
for line in d.log:
|
||||
g.log.append(str(line))
|
||||
for ev in d.events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = Cards.by_id(e.card)
|
||||
for key in ["player", "uid", "count", "amount"]:
|
||||
if e.has(key):
|
||||
e[key] = int(e[key])
|
||||
for key in ["target", "attacker", "source"]:
|
||||
if e.has(key):
|
||||
var r: Dictionary = e[key]
|
||||
if r.has("uid"):
|
||||
r["uid"] = int(r.uid)
|
||||
if r.has("index"):
|
||||
r["index"] = int(r.index)
|
||||
g.events.append(e)
|
||||
return g
|
||||
1
scripts/netstate.gd.uid
Normal file
1
scripts/netstate.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c3ao1mpgbrter
|
||||
@@ -6,12 +6,20 @@ const SAVE_VERSION := 5
|
||||
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
|
||||
const SAVE_PATH := "user://spielstand.json"
|
||||
const BOOSTER_COST := 100
|
||||
const 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.
|
||||
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"},
|
||||
}
|
||||
# Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub.
|
||||
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
|
||||
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
|
||||
@@ -21,13 +29,14 @@ const ACHIEVEMENTS := [
|
||||
{"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50},
|
||||
{"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100},
|
||||
{"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200},
|
||||
{"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100},
|
||||
{"id": "sammler", "name": "Sammler", "text": "Besitze 50 verschiedene Karten.", "reward": 100},
|
||||
{"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500},
|
||||
{"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100},
|
||||
{"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100},
|
||||
{"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100},
|
||||
{"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300},
|
||||
{"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500},
|
||||
{"id": "archimagier", "name": "Archimagier", "text": "Schließe Kapitel 3 der Kampagne ab.", "reward": 800},
|
||||
{"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150},
|
||||
{"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
|
||||
]
|
||||
@@ -58,13 +67,14 @@ var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
|
||||
var achievements: Array = []
|
||||
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
||||
var daily := {"date": "", "task": "", "progress": 0, "done": false}
|
||||
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de"}
|
||||
var 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 (Erfolge, Tagesaufgabe)
|
||||
var card_stats := {} # id -> {"played", "games", "wins"}
|
||||
var booster_since_mythic := 0
|
||||
var tutorial_done := false
|
||||
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
||||
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0}
|
||||
var tutorial2_done := false
|
||||
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []}
|
||||
var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung
|
||||
|
||||
## Das aktive Deck (Kartenliste des gewählten Slots).
|
||||
@@ -86,11 +96,16 @@ static func starter() -> Profile:
|
||||
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
|
||||
|
||||
|
||||
@@ -147,7 +162,10 @@ static func from_dict(d: Dictionary) -> Profile:
|
||||
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"))}
|
||||
"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):
|
||||
@@ -161,8 +179,10 @@ static func from_dict(d: Dictionary) -> Profile:
|
||||
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))}
|
||||
"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.challenge_week = int(d.get("challenge_week", -1))
|
||||
p.tutorial2_done = bool(d.get("tutorial2_done", false))
|
||||
return p
|
||||
|
||||
|
||||
@@ -172,7 +192,7 @@ func to_dict() -> Dictionary:
|
||||
"stats": stats, "achievements": achievements, "campaign_stage": campaign_stage,
|
||||
"daily": daily, "settings": settings, "card_stats": card_stats,
|
||||
"booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done,
|
||||
"perk": perk, "records": records, "challenge_week": challenge_week}
|
||||
"perk": perk, "records": records, "challenge_week": challenge_week, "tutorial2_done": tutorial2_done}
|
||||
|
||||
|
||||
## Speichert und sichert den vorherigen Stand als .bak.
|
||||
@@ -319,7 +339,15 @@ func dismantle_extras() -> int:
|
||||
|
||||
# --- Booster ----------------------------------------------------------------------
|
||||
|
||||
static func roll_booster(r: RandomNumberGenerator) -> Array:
|
||||
## 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:
|
||||
var rarities := RARITY_WEIGHTS.keys()
|
||||
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
|
||||
var cards := []
|
||||
@@ -328,23 +356,49 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
|
||||
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
|
||||
and cards.all(func(c): return c.rarity == "gewöhnlich"):
|
||||
rarity = "selten" # mindestens eine seltene Karte pro Booster
|
||||
var pool := Cards.of_rarity(rarity)
|
||||
var pool := booster_pool(rarity, element)
|
||||
cards.append(pool[r.randi_range(0, pool.size() - 1)])
|
||||
return cards
|
||||
|
||||
|
||||
## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
|
||||
func open_booster(r: RandomNumberGenerator = null) -> Array:
|
||||
if coins < BOOSTER_COST:
|
||||
## 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 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])
|
||||
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:
|
||||
return []
|
||||
if r == null:
|
||||
r = Game.make_rng()
|
||||
coins -= BOOSTER_COST
|
||||
var cards := roll_booster(r)
|
||||
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 := Cards.of_rarity("mythisch")
|
||||
var pool := booster_pool("mythisch", element)
|
||||
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
|
||||
has_mythic = true
|
||||
if has_mythic:
|
||||
@@ -363,15 +417,24 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
|
||||
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
|
||||
## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche.
|
||||
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
|
||||
turns: int = 0, challenge: Dictionary = {}) -> int:
|
||||
turns: int = 0, challenge: Dictionary = {}, 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 and challenge.is_empty():
|
||||
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 := {}
|
||||
@@ -386,7 +449,7 @@ func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, pla
|
||||
stats.wins += 1
|
||||
var key := str(n_opp)
|
||||
stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1
|
||||
reward = REWARD_WIN[n_opp]
|
||||
reward = int(round(REWARD_WIN[n_opp] * reward_factor))
|
||||
if stage >= 0 and stage == campaign_stage:
|
||||
campaign_stage += 1
|
||||
reward = Campaign.stage(stage).reward
|
||||
@@ -440,7 +503,7 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
|
||||
"chaosmeister":
|
||||
return won and n == 5
|
||||
"sammler":
|
||||
return distinct_owned() >= 40
|
||||
return distinct_owned() >= 50
|
||||
"vollstaendig":
|
||||
return distinct_owned() >= Cards.all().size()
|
||||
"boosterfreund":
|
||||
@@ -452,6 +515,8 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
|
||||
"kampagnenheld":
|
||||
return campaign_stage >= Campaign.STAGES.size()
|
||||
"elementmeister":
|
||||
return campaign_stage >= 2 * Campaign.STAGES.size()
|
||||
"archimagier":
|
||||
return campaign_stage >= Campaign.total()
|
||||
"serie":
|
||||
return records.best_streak >= 5
|
||||
@@ -460,6 +525,83 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -2,27 +2,30 @@ class_name AchievementsScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
var export_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var p: Profile = app.profile
|
||||
var col := Style.column(self, 900, 20, 5)
|
||||
Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true)
|
||||
var col := Style.column(self, 900, 12, 4)
|
||||
Style.clabel(col, "Erfolge", 32, Style.GOLD, "center", true)
|
||||
var st: Dictionary = p.stats
|
||||
Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [
|
||||
st.games, st.wins, st.boosters, p.campaign_stage, Campaign.STAGES.size()], 16, Style.MUTED)
|
||||
st.games, st.wins, st.boosters, p.campaign_stage, Campaign.total()], 16, Style.MUTED)
|
||||
var task := p.daily_task()
|
||||
var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \
|
||||
"Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
|
||||
Style.clabel(col, daily_text, 16, Style.BLUE)
|
||||
Style.spacer(col, 8)
|
||||
Style.clabel(col, "Serie: %d (beste %d) · Schnellster Sieg: %s" % [p.records.streak, p.records.best_streak,
|
||||
("%d Züge" % p.records.fastest_win) if p.records.fastest_win > 0 else "–"], 15, Style.MUTED)
|
||||
Style.spacer(col, 4)
|
||||
for a in Profile.ACHIEVEMENTS:
|
||||
var done: bool = a.id in p.achievements
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6))
|
||||
col.add_child(panel)
|
||||
var r := Style.row(panel, 12)
|
||||
r.custom_minimum_size = Vector2(0, 42)
|
||||
r.custom_minimum_size = Vector2(0, 36)
|
||||
Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40)
|
||||
Style.clabel(r, a.name, 18, Style.TEXT if done else Style.MUTED, "left", true, 250)
|
||||
Style.clabel(r, a.text, 15, Style.MUTED, "left", false, 380)
|
||||
@@ -34,10 +37,18 @@ func _ready() -> void:
|
||||
for row in top:
|
||||
parts.append("%s %d/%d" % [row.card.name, row.wins, row.games])
|
||||
Style.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED)
|
||||
Style.spacer(col, 6)
|
||||
var br := Style.row(col)
|
||||
export_label = Style.clabel(col, "", 13, Style.GREEN)
|
||||
Style.spacer(col, 2)
|
||||
var br := Style.row(col, 16)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
Style.cbutton(br, "Statistik exportieren", Vector2(220, 40), export_stats)
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func export_stats() -> void:
|
||||
var path := "user://statistik.csv"
|
||||
var err: String = app.profile.export_stats(path)
|
||||
export_label.text = ("Gespeichert: " + ProjectSettings.globalize_path(path)) if err == "" else err
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
|
||||
@@ -63,7 +63,7 @@ func play_events(evs: Array, centers: Dictionary) -> void:
|
||||
screen.preview_timer = 1.8
|
||||
"mulligan":
|
||||
if ev.count > 0:
|
||||
popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
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)
|
||||
|
||||
|
||||
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
||||
|
||||
139
scripts/ui/battle_gamepad.gd
Normal file
139
scripts/ui/battle_gamepad.gd
Normal file
@@ -0,0 +1,139 @@
|
||||
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.
|
||||
|
||||
var screen # BattleScreen
|
||||
var active := false
|
||||
var zone := "hand"
|
||||
var index := 0
|
||||
var targets: Array = []
|
||||
|
||||
|
||||
func _init(s) -> void:
|
||||
screen = s
|
||||
|
||||
|
||||
func button(button: int) -> void:
|
||||
active = true
|
||||
if screen.game == null:
|
||||
return
|
||||
match button:
|
||||
JOY_BUTTON_START:
|
||||
if screen.my_turn() and not screen.any_overlay():
|
||||
screen.end_turn()
|
||||
JOY_BUTTON_B:
|
||||
if screen.confirm.visible:
|
||||
screen.confirm.visible = false
|
||||
screen.cancel_all()
|
||||
zone = "hand"
|
||||
JOY_BUTTON_Y:
|
||||
if screen.b_skip.visible:
|
||||
screen.skip_ai = true
|
||||
JOY_BUTTON_X:
|
||||
if screen.b_undo.visible:
|
||||
screen.undo()
|
||||
JOY_BUTTON_A:
|
||||
confirm()
|
||||
JOY_BUTTON_DPAD_LEFT:
|
||||
move(-1)
|
||||
JOY_BUTTON_DPAD_RIGHT:
|
||||
move(1)
|
||||
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
|
||||
if zone != "target":
|
||||
zone = "own" if zone == "hand" else "hand"
|
||||
index = 0
|
||||
screen.refresh()
|
||||
|
||||
|
||||
func items() -> Array:
|
||||
match zone:
|
||||
"hand":
|
||||
return screen.hand_views
|
||||
"own":
|
||||
return screen.own_views.filter(func(v): return v.creature.can_attack())
|
||||
"target":
|
||||
return targets
|
||||
return []
|
||||
|
||||
|
||||
func move(dir: int) -> void:
|
||||
var n := items().size()
|
||||
if n > 0:
|
||||
index = posmod(index + dir, n)
|
||||
|
||||
|
||||
func confirm() -> void:
|
||||
if screen.handover.visible:
|
||||
screen.take_over()
|
||||
return
|
||||
if screen.mulligan.visible:
|
||||
screen.do_mulligan([])
|
||||
return
|
||||
if screen.overlay.visible:
|
||||
screen.leave()
|
||||
return
|
||||
if screen.confirm.visible:
|
||||
return
|
||||
var items := items()
|
||||
if items.is_empty():
|
||||
return
|
||||
index = clampi(index, 0, items.size() - 1)
|
||||
var item = items[index]
|
||||
match zone:
|
||||
"hand":
|
||||
screen.on_click("hand", item.card)
|
||||
if screen.pending != null:
|
||||
targets = []
|
||||
var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type())
|
||||
for v in screen.enemy_views:
|
||||
if v.creature in valid:
|
||||
targets.append(v)
|
||||
for v in screen.own_views:
|
||||
if v.creature in valid:
|
||||
targets.append(v)
|
||||
for o in screen.others():
|
||||
if o in valid:
|
||||
targets.append(screen.hero_panels[o])
|
||||
zone = "target"
|
||||
index = 0
|
||||
"own":
|
||||
screen.on_click("own", item.creature)
|
||||
if screen.selected != null:
|
||||
targets = []
|
||||
for v in screen.enemy_views:
|
||||
if screen.game.can_attack(screen.selected, v.creature)[0]:
|
||||
targets.append(v)
|
||||
for o in screen.others():
|
||||
if o.alive() and screen.game.can_attack(screen.selected, o)[0]:
|
||||
targets.append(screen.hero_panels[o])
|
||||
zone = "target"
|
||||
index = 0
|
||||
"target":
|
||||
if item is CreatureView:
|
||||
screen.on_tile_click(item.creature)
|
||||
else:
|
||||
for o in screen.hero_panels:
|
||||
if screen.hero_panels[o] == item:
|
||||
screen.on_click("hero", o)
|
||||
zone = "hand"
|
||||
index = 0
|
||||
|
||||
|
||||
func draw_cursor() -> void:
|
||||
Style.clear(screen.cursor_layer)
|
||||
if not active or screen.game == null or not screen.my_turn():
|
||||
return
|
||||
var items := items()
|
||||
if items.is_empty():
|
||||
return
|
||||
index = clampi(index, 0, items.size() - 1)
|
||||
var item = items[index]
|
||||
var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size)
|
||||
var hl := BattleScreen.Highlight.new()
|
||||
hl.position = r.position - Vector2(6, 6)
|
||||
hl.size = r.size + Vector2(12, 12)
|
||||
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
screen.cursor_layer.add_child(hl)
|
||||
|
||||
|
||||
1
scripts/ui/battle_gamepad.gd.uid
Normal file
1
scripts/ui/battle_gamepad.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://8wth8k7f7uvk
|
||||
63
scripts/ui/battle_mulligan.gd
Normal file
63
scripts/ui/battle_mulligan.gd
Normal file
@@ -0,0 +1,63 @@
|
||||
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
scripts/ui/battle_mulligan.gd.uid
Normal file
1
scripts/ui/battle_mulligan.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://gwdm38e83cbj
|
||||
@@ -9,7 +9,8 @@ var stage := -1 # Kampagnenstufe oder -1
|
||||
var tutorial := false
|
||||
var hotseat_decks: Array = []
|
||||
var hotseat_ai := 0
|
||||
var online_seat := -1 # Netzwerk: eigener Platz, sonst -1
|
||||
var net_play := false # Netzwerkspiel (auch als Zuschauer)
|
||||
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
|
||||
var net: Net
|
||||
var game: Game
|
||||
var perspective: Game.Player # der Mensch, dessen Hand unten liegt
|
||||
@@ -19,13 +20,34 @@ var pending = null # Cards.Card, wartet auf Ziel
|
||||
var msg_t := 0.0
|
||||
var ai_delay := 0.75
|
||||
var ai_timer := 0.0
|
||||
var ai_task := -1 # WorkerThreadPool-Aufgabe der laufenden KI-Berechnung
|
||||
var ai_copy: Game # Spielkopie, auf der die KI rechnet
|
||||
var ai_box := {} # Ergebnis der Berechnung (vom Thread befüllt)
|
||||
var reward := -1
|
||||
var ended := false
|
||||
var zoom_player = null
|
||||
var preview_timer := 0.0
|
||||
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
|
||||
var mulligan_selected: Array = []
|
||||
var tutorial_step := 0
|
||||
var guide: BattleTutorial
|
||||
var tutorial_part := 1
|
||||
var tutorial_step: int:
|
||||
get:
|
||||
return guide.step if guide != null else 0
|
||||
var pad: BattleGamepad
|
||||
var gp_zone: String:
|
||||
get:
|
||||
return pad.zone
|
||||
var gp_index: int:
|
||||
get:
|
||||
return pad.index
|
||||
var challenge := {} # wöchentliche Herausforderung oder leer
|
||||
var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner
|
||||
var skip_ai := false # Gegnerzüge im Schnelldurchlauf
|
||||
var smart_ai := true # Vorausschau-KI (Leicht: einfache KI)
|
||||
var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion
|
||||
var saved_game: Game = null # fortgesetzter Kampf (siehe resume)
|
||||
var undo_log_len := 0
|
||||
var undo_t := 0.0
|
||||
|
||||
# Ansichten (auch für Tests)
|
||||
var tiles := {}
|
||||
@@ -60,27 +82,46 @@ var result_label: Label
|
||||
var reward_label: Label
|
||||
var notice_label: Label
|
||||
var confirm: Control
|
||||
var b_save_leave: Button
|
||||
var handover: Control
|
||||
var handover_label: Label
|
||||
var mulligan_box: Control
|
||||
var mulligan_layer: Control
|
||||
var b_keep: Button
|
||||
var b_swap: 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 tutorial_panel: Panel
|
||||
var tutorial_text: RichTextLabel
|
||||
var b_tut_next: Button
|
||||
var wait_label: Label
|
||||
var b_skip: Button
|
||||
var b_undo: Button
|
||||
var highlight_layer: Control
|
||||
|
||||
const TUTORIAL_STEPS := [
|
||||
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
|
||||
"played_creature"],
|
||||
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
|
||||
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
|
||||
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig +1 Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
|
||||
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an alle gegnerischen Wesen.", "combo"],
|
||||
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
|
||||
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
|
||||
]
|
||||
|
||||
## Pulsierender Rahmen für die Anleitung.
|
||||
class Highlight extends Control:
|
||||
var t := 0.0
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
t += delta
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
var a := 0.55 + 0.45 * sin(t * 6.0)
|
||||
Style.border(self, Rect2(Vector2.ZERO, size), Color(Style.GOLD, a), 4, 8)
|
||||
|
||||
|
||||
func _init(n: int = 2, campaign_stage: int = -1) -> void:
|
||||
@@ -97,22 +138,50 @@ static func hotseat(decks: Array, n_ai: int) -> BattleScreen:
|
||||
|
||||
static func online(seat: int) -> BattleScreen:
|
||||
var b := BattleScreen.new(0, -1)
|
||||
b.net_play = true
|
||||
b.online_seat = seat
|
||||
return b
|
||||
|
||||
|
||||
static func guided() -> BattleScreen:
|
||||
static func guided(part: int = 1) -> BattleScreen:
|
||||
var b := BattleScreen.new(1, -1)
|
||||
b.tutorial = true
|
||||
b.tutorial_part = part
|
||||
return b
|
||||
|
||||
|
||||
static func against(opps: Array) -> BattleScreen:
|
||||
var b := BattleScreen.new(opps.size(), -1)
|
||||
b.preset_opponents = opps
|
||||
return b
|
||||
|
||||
|
||||
## 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.challenge = data.get("challenge", {})
|
||||
s.played_ids = Array(data.get("played_ids", []))
|
||||
s.saved_game = Game.from_snapshot(data.game)
|
||||
return s
|
||||
|
||||
|
||||
static func weekly(ch: Dictionary) -> BattleScreen:
|
||||
var b := BattleScreen.new(int(ch.n_opp), -1)
|
||||
b.challenge = ch
|
||||
return b
|
||||
|
||||
|
||||
func is_hotseat() -> bool:
|
||||
return game != null and game.humans.size() > 1 and online_seat < 0
|
||||
return game != null and game.humans.size() > 1 and not net_play
|
||||
|
||||
|
||||
func is_online() -> bool:
|
||||
return online_seat >= 0
|
||||
return net_play
|
||||
|
||||
|
||||
## Zuschauer im Netzwerk: kein eigener Platz, keine Hand.
|
||||
func spectating() -> bool:
|
||||
return is_online() and online_seat < 0
|
||||
|
||||
|
||||
func is_client() -> bool:
|
||||
@@ -127,11 +196,14 @@ func _ready() -> void:
|
||||
var s: Dictionary = app.profile.settings
|
||||
fx = BattleFx.new(self)
|
||||
fx.enabled = s.animations
|
||||
pad = BattleGamepad.new(self)
|
||||
ai_delay = 0.75 / float(s.ai_speed)
|
||||
_build_static()
|
||||
if is_online():
|
||||
net = app.net
|
||||
net.ended.connect(_on_net_ended)
|
||||
net.migrated.connect(_on_migrated)
|
||||
net.status_changed.connect(_on_net_status)
|
||||
if net.is_host:
|
||||
game = net.game
|
||||
net.action_applied.connect(after_action)
|
||||
@@ -139,25 +211,45 @@ func _ready() -> void:
|
||||
net.snapshot_received.connect(_on_snapshot)
|
||||
wait_label.visible = true
|
||||
return # der erste Zustand kommt vom Host
|
||||
elif saved_game != null:
|
||||
game = saved_game
|
||||
elif not hotseat_decks.is_empty():
|
||||
game = Game.new_hotseat(hotseat_decks, hotseat_ai)
|
||||
elif tutorial:
|
||||
guide = BattleTutorial.new(self, tutorial_part)
|
||||
var r := Game.make_rng()
|
||||
var opps := Campaign.opponents(0, r)
|
||||
var opps := Campaign.opponents(guide.stage_index(), r, Game.deck_rating(app.profile.deck_cards()))
|
||||
opps[0].perks = []
|
||||
game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true)
|
||||
guide.inject_cards(game)
|
||||
elif not challenge.is_empty():
|
||||
var rules: Dictionary = challenge.rules.duplicate()
|
||||
rules["target_rating"] = Game.deck_rating(app.profile.deck_cards())
|
||||
game = Game.new_battle(app.profile.deck_cards(), n_opp, null, rules)
|
||||
elif stage >= 0:
|
||||
var r := Game.make_rng()
|
||||
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r)
|
||||
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules())
|
||||
elif not preset_opponents.is_empty():
|
||||
game = Game.new_battle_with(app.profile.deck_cards(), preset_opponents, null, false, own_rules())
|
||||
else:
|
||||
game = Game.new_battle(app.profile.deck_cards(), n_opp)
|
||||
game = Game.new_battle(app.profile.deck_cards(), n_opp, null, 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.
|
||||
func own_rules() -> Dictionary:
|
||||
var p: Profile = app.profile
|
||||
var rules := {"target_rating": p.target_rating(), "tier": str(p.difficulty().tier)}
|
||||
if p.perk != "" and p.perk_unlocked():
|
||||
rules["human_perks"] = [p.perk]
|
||||
return rules
|
||||
|
||||
|
||||
func _begin() -> void:
|
||||
n_opp = game.players.size() - 1
|
||||
if is_online():
|
||||
perspective = game.players[online_seat]
|
||||
perspective = game.players[maxi(online_seat, 0)]
|
||||
else:
|
||||
game.events.clear()
|
||||
perspective = game.current() if game.current().is_human else game.humans[0]
|
||||
@@ -165,6 +257,8 @@ func _begin() -> void:
|
||||
if is_online() and net.is_host:
|
||||
net.broadcast()
|
||||
game.events.clear()
|
||||
b_save_leave.visible = resumable()
|
||||
save_battle()
|
||||
refresh()
|
||||
|
||||
|
||||
@@ -207,6 +301,11 @@ func _build_static() -> void:
|
||||
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"))
|
||||
b_skip = Style.button(self, "Gegnerzüge überspringen", Rect2(1005, 527, 210, 48), func(): skip_ai = true)
|
||||
b_skip.add_theme_font_size_override("font_size", 14)
|
||||
b_skip.visible = false
|
||||
b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo)
|
||||
b_undo.visible = false
|
||||
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()
|
||||
@@ -214,18 +313,14 @@ func _build_static() -> void:
|
||||
trap_layer = _layer()
|
||||
hand_layer = _layer()
|
||||
preview_holder = _layer()
|
||||
highlight_layer = _layer()
|
||||
zoom_layer = _layer()
|
||||
fx_layer = _layer()
|
||||
cursor_layer = _layer()
|
||||
# Mulligan
|
||||
mulligan_box = _overlay()
|
||||
Style.panel(mulligan_box, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
|
||||
Style.label(mulligan_box, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
|
||||
Style.label(mulligan_box, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
|
||||
mulligan_layer = Control.new()
|
||||
mulligan_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
mulligan_box.add_child(mulligan_layer)
|
||||
b_keep = Style.button(mulligan_box, "Behalten", Rect2(430, 515, 200, 44), func(): do_mulligan(false), Color("285a3c"))
|
||||
b_swap = Style.button(mulligan_box, "Tauschen", Rect2(650, 515, 200, 44), func(): do_mulligan(true))
|
||||
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)
|
||||
@@ -233,12 +328,14 @@ func _build_static() -> void:
|
||||
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center")
|
||||
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
|
||||
# Aufgeben bestätigen
|
||||
confirm = _overlay()
|
||||
Style.panel(confirm, Rect2(390, 300, 500, 180), Style.PANEL, 10, Style.MUTED, 2)
|
||||
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(390, 320, 500, 40), "center", true)
|
||||
Style.label(confirm, "Der Kampf endet ohne Münzen.", 16, Style.MUTED, Rect2(390, 365, 500, 24), "center")
|
||||
Style.button(confirm, "Ja, aufgeben", Rect2(420, 410, 200, 46), leave, Color("5a2828"))
|
||||
Style.button(confirm, "Weiterspielen", Rect2(660, 410, 200, 46), func(): confirm.visible = false)
|
||||
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)
|
||||
Style.button(confirm, "Weiterspielen", Rect2(745, 420, 180, 46), func(): confirm.visible = false)
|
||||
# Übergabe zwischen Menschen (Hotseat)
|
||||
handover = _overlay()
|
||||
Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2)
|
||||
@@ -255,23 +352,39 @@ func _layer() -> Control:
|
||||
|
||||
|
||||
func _overlay() -> Control:
|
||||
var o := Control.new()
|
||||
o.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
o.visible = false
|
||||
var dark := ColorRect.new()
|
||||
dark.color = Color(0, 0, 0, 0.67)
|
||||
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
dark.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
o.add_child(dark)
|
||||
add_child(o)
|
||||
return o
|
||||
return Style.overlay(self)
|
||||
|
||||
|
||||
## Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) lassen sich unterbrechen und fortsetzen.
|
||||
func resumable() -> bool:
|
||||
return counts_for_profile() and not tutorial
|
||||
|
||||
|
||||
func save_battle() -> void:
|
||||
if not resumable() or game == null or game.winner != "":
|
||||
return
|
||||
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "challenge": challenge, "played_ids": played_ids, "game": game.snapshot(-1)}
|
||||
var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE)
|
||||
if f != null:
|
||||
f.store_string(JSON.stringify(d))
|
||||
f.close()
|
||||
|
||||
|
||||
func save_and_leave() -> void:
|
||||
save_battle()
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
if is_online():
|
||||
net.leave()
|
||||
if tutorial and not app.profile.tutorial_done:
|
||||
app.profile.tutorial_done = true
|
||||
if resumable() and game != null and game.winner == "":
|
||||
app.clear_battle_save() # aufgegeben
|
||||
if tutorial:
|
||||
if tutorial_part == 1:
|
||||
app.profile.tutorial_done = true
|
||||
else:
|
||||
app.profile.tutorial2_done = true
|
||||
app.save()
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
@@ -338,7 +451,9 @@ func build_hero_boxes() -> void:
|
||||
if not o.perks.is_empty():
|
||||
title += " ★"
|
||||
labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT,
|
||||
Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (44 if c else 20), 26), "left", true)
|
||||
Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (58 if c else 20), 26), "left", true)
|
||||
labels.name.clip_text = true
|
||||
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED,
|
||||
Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true)
|
||||
labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED,
|
||||
@@ -354,11 +469,13 @@ func build_hero_boxes() -> void:
|
||||
# --- Ablauf und Eingabe ------------------------------------------------------------------
|
||||
|
||||
func my_turn() -> bool:
|
||||
if spectating():
|
||||
return false
|
||||
return game != null and game.winner == "" and game.current() == perspective
|
||||
|
||||
|
||||
func any_overlay() -> bool:
|
||||
return confirm.visible or handover.visible or mulligan_box.visible or overlay.visible
|
||||
return confirm.visible or handover.visible or mulligan.visible or overlay.visible
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
@@ -367,6 +484,9 @@ func _input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
cancel_all()
|
||||
return
|
||||
if event is InputEventJoypadButton and event.pressed:
|
||||
pad.button(event.button_index)
|
||||
return
|
||||
if not (event is InputEventKey and event.pressed and not event.echo):
|
||||
return
|
||||
match event.keycode:
|
||||
@@ -380,11 +500,16 @@ func _input(event: InputEvent) -> void:
|
||||
KEY_SPACE:
|
||||
if my_turn() and not any_overlay():
|
||||
end_turn()
|
||||
elif b_skip.visible:
|
||||
skip_ai = true
|
||||
KEY_U:
|
||||
if b_undo.visible:
|
||||
undo()
|
||||
KEY_ENTER, KEY_KP_ENTER:
|
||||
if handover.visible:
|
||||
take_over()
|
||||
elif mulligan_box.visible:
|
||||
do_mulligan(false)
|
||||
elif mulligan.visible:
|
||||
do_mulligan([])
|
||||
elif overlay.visible:
|
||||
leave()
|
||||
elif tutorial and b_tut_next.visible:
|
||||
@@ -414,6 +539,12 @@ func _process(delta: float) -> void:
|
||||
preview_timer -= delta
|
||||
if preview_timer <= 0.0:
|
||||
show_preview(null, false)
|
||||
if undo_t > 0.0:
|
||||
undo_t -= delta
|
||||
if undo_t <= 0.0:
|
||||
clear_undo()
|
||||
if is_online() and net.is_host:
|
||||
net.tick()
|
||||
if game.winner != "":
|
||||
if not ended:
|
||||
ended = true
|
||||
@@ -426,28 +557,65 @@ func _process(delta: float) -> void:
|
||||
handover.visible = true
|
||||
hand_layer.visible = false
|
||||
trap_layer.visible = false
|
||||
elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan_box.visible and not handover.visible:
|
||||
elif cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible:
|
||||
show_mulligan()
|
||||
return
|
||||
if is_client():
|
||||
return
|
||||
ai_timer -= delta
|
||||
if ai_timer > 0:
|
||||
return
|
||||
var action := AI.choose_action(game, cur)
|
||||
if skip_ai:
|
||||
ai_timer = 0.0
|
||||
else:
|
||||
ai_timer -= delta
|
||||
var action: Dictionary
|
||||
if smart_ai:
|
||||
# Die Vorausschau rechnet im Hintergrund auf einer Kopie, schon während der Wartezeit,
|
||||
# damit Animationen nicht stocken.
|
||||
if ai_task < 0:
|
||||
_start_ai(cur)
|
||||
if ai_timer > 0 or not WorkerThreadPool.is_task_completed(ai_task):
|
||||
return
|
||||
WorkerThreadPool.wait_for_task_completion(ai_task)
|
||||
ai_task = -1
|
||||
action = AI.map_action(game, ai_copy, ai_box.action)
|
||||
ai_copy = null
|
||||
else:
|
||||
if ai_timer > 0:
|
||||
return
|
||||
action = AI.choose_action_greedy(game, cur)
|
||||
AI.apply_action(game, cur, action)
|
||||
ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0)
|
||||
if game.current().is_human:
|
||||
skip_ai = false
|
||||
after_action()
|
||||
|
||||
|
||||
func _start_ai(cur: Game.Player) -> void:
|
||||
ai_copy = game.clone_for(cur)
|
||||
var copy := ai_copy
|
||||
var idx := game.current_idx
|
||||
var box := ai_box
|
||||
ai_task = WorkerThreadPool.add_task(func(): box["action"] = AI.choose_action(copy, copy.players[idx]))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if ai_task >= 0:
|
||||
WorkerThreadPool.wait_for_task_completion(ai_task)
|
||||
ai_task = -1
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
var won := game.winner == "human"
|
||||
if counts_for_profile():
|
||||
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids)
|
||||
var factor: float = float(app.profile.difficulty().reward) if stage < 0 and challenge.is_empty() and not tutorial else 1.0
|
||||
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge, factor, game.human.life)
|
||||
if tutorial:
|
||||
app.profile.tutorial_done = true
|
||||
if tutorial_part == 1:
|
||||
app.profile.tutorial_done = true
|
||||
else:
|
||||
app.profile.tutorial2_done = true
|
||||
app.save()
|
||||
app.sfx.play("win" if won else "lose")
|
||||
app.clear_battle_save()
|
||||
else:
|
||||
var wp := game.winner_player
|
||||
var mine: bool = wp != null and wp == perspective
|
||||
@@ -480,15 +648,52 @@ 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 log_len := game.log.size()
|
||||
var err := game.play_card(perspective, card, target)
|
||||
if err != "":
|
||||
say(err)
|
||||
say(tr(err))
|
||||
return
|
||||
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte, Kombos, Fallen)
|
||||
if before != null and game.events.size() == 1 and game.events[0].type == "play":
|
||||
undo_state = before
|
||||
undo_log_len = log_len
|
||||
undo_t = 6.0
|
||||
else:
|
||||
clear_undo()
|
||||
|
||||
|
||||
func clear_undo() -> void:
|
||||
undo_state = null
|
||||
undo_t = 0.0
|
||||
b_undo.visible = false
|
||||
|
||||
|
||||
## Nimmt das letzte Ausspielen eines Wesens zurück (nur solange nichts Verdecktes offenbart wurde).
|
||||
func undo() -> void:
|
||||
if undo_state == null:
|
||||
return
|
||||
var idx := game.players.find(perspective)
|
||||
var restored := undo_state
|
||||
restored.quiet = false
|
||||
restored.rng = game.rng
|
||||
restored.log = game.log.slice(0, undo_log_len)
|
||||
restored.say("Rückgängig.")
|
||||
game = restored
|
||||
perspective = game.players[idx]
|
||||
if is_online() and net.is_host:
|
||||
net.game = game
|
||||
clear_undo()
|
||||
cancel()
|
||||
build_hero_boxes()
|
||||
after_action()
|
||||
|
||||
|
||||
func end_turn() -> void:
|
||||
if not my_turn():
|
||||
return
|
||||
cancel()
|
||||
clear_undo()
|
||||
zoom_player = null
|
||||
if is_client():
|
||||
net.send_action({"type": "end"})
|
||||
@@ -499,39 +704,11 @@ func end_turn() -> void:
|
||||
|
||||
|
||||
func show_mulligan() -> void:
|
||||
mulligan_selected = []
|
||||
mulligan_box.visible = true
|
||||
Style.clear(mulligan_layer)
|
||||
var hand: Array = perspective.hand
|
||||
for i in hand.size():
|
||||
var r := BattleLayout.hand_rect(hand.size(), i)
|
||||
var v := CardView.new(hand[i], r.size)
|
||||
v.position = Vector2(r.position.x, 322)
|
||||
var idx := i
|
||||
v.clicked.connect(func(_view): toggle_mulligan(idx))
|
||||
mulligan_layer.add_child(v)
|
||||
_mulligan_borders()
|
||||
mulligan.open(perspective.hand)
|
||||
|
||||
|
||||
func toggle_mulligan(idx: int) -> void:
|
||||
if idx in mulligan_selected:
|
||||
mulligan_selected.erase(idx)
|
||||
else:
|
||||
mulligan_selected.append(idx)
|
||||
_mulligan_borders()
|
||||
|
||||
|
||||
func _mulligan_borders() -> void:
|
||||
var views := mulligan_layer.get_children()
|
||||
for i in views.size():
|
||||
views[i].border = Style.YELLOW if i in mulligan_selected else null
|
||||
views[i].queue_redraw()
|
||||
b_swap.text = "Tauschen (%d)" % mulligan_selected.size() if not mulligan_selected.is_empty() else "Tauschen"
|
||||
|
||||
|
||||
func do_mulligan(swap: bool) -> void:
|
||||
mulligan_box.visible = false
|
||||
var idxs: Array = mulligan_selected.duplicate() if swap else []
|
||||
func do_mulligan(idxs: Array) -> void:
|
||||
mulligan.visible = false
|
||||
if is_client():
|
||||
net.send_action({"type": "mulligan", "hand": idxs})
|
||||
return
|
||||
@@ -552,7 +729,7 @@ func on_click(kind: String, obj) -> void:
|
||||
if obj in g.valid_targets(perspective, card.target_type()):
|
||||
play(card, obj)
|
||||
else:
|
||||
say("Abgebrochen")
|
||||
say(tr("Abgebrochen"))
|
||||
zoom_player = null
|
||||
after_action()
|
||||
return
|
||||
@@ -560,12 +737,13 @@ func on_click(kind: String, obj) -> void:
|
||||
var cr = selected
|
||||
selected = null
|
||||
if kind == "enemy" or kind == "hero":
|
||||
clear_undo()
|
||||
if is_client():
|
||||
net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)})
|
||||
else:
|
||||
var err := g.attack(cr, obj)
|
||||
if err != "":
|
||||
say(err)
|
||||
say(tr(err))
|
||||
zoom_player = null
|
||||
after_action()
|
||||
return
|
||||
@@ -573,18 +751,18 @@ func on_click(kind: String, obj) -> void:
|
||||
"hand":
|
||||
var ok := g.can_play(perspective, obj)
|
||||
if not ok[0]:
|
||||
say(ok[1])
|
||||
say(tr(ok[1]))
|
||||
elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty():
|
||||
pending = obj
|
||||
say("Wähle ein Ziel")
|
||||
say(tr("Wähle ein Ziel"))
|
||||
else:
|
||||
play(obj, null)
|
||||
"own":
|
||||
if obj.can_attack():
|
||||
selected = obj
|
||||
say("Wähle ein Angriffsziel")
|
||||
say(tr("Wähle ein Angriffsziel"))
|
||||
else:
|
||||
say("%s kann nicht angreifen" % obj.name)
|
||||
say(tr("%s kann nicht angreifen") % obj.card.title())
|
||||
after_action()
|
||||
|
||||
|
||||
@@ -641,13 +819,13 @@ func combo_hints(card: Cards.Card) -> Array[String]:
|
||||
var combos := {}
|
||||
for other in perspective.board:
|
||||
if other.card.elements.any(func(e): return e in card.elements):
|
||||
partners.append(other.name)
|
||||
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.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:
|
||||
@@ -664,11 +842,28 @@ func _on_snapshot(snap: Dictionary) -> void:
|
||||
if first:
|
||||
_begin()
|
||||
return
|
||||
perspective = game.players[online_seat]
|
||||
perspective = game.players[maxi(online_seat, 0)]
|
||||
build_hero_boxes()
|
||||
after_action()
|
||||
|
||||
|
||||
## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter.
|
||||
func _on_migrated() -> void:
|
||||
game = net.game
|
||||
if not net.action_applied.is_connected(after_action):
|
||||
net.action_applied.connect(after_action)
|
||||
wait_label.visible = false
|
||||
perspective = game.players[online_seat]
|
||||
build_hero_boxes()
|
||||
say("Du bist jetzt Host.")
|
||||
after_action()
|
||||
|
||||
|
||||
func _on_net_status() -> void:
|
||||
wait_label.text = net.status
|
||||
wait_label.visible = true
|
||||
|
||||
|
||||
func _on_net_ended(reason: String) -> void:
|
||||
if game == null or game.winner == "":
|
||||
say(reason)
|
||||
@@ -694,6 +889,7 @@ func after_action() -> void:
|
||||
centers[ref_key(ev[key])] = target_center(ev[key])
|
||||
refresh()
|
||||
fx.play_events(evs, centers)
|
||||
save_battle()
|
||||
|
||||
|
||||
func _note_events(evs: Array) -> void:
|
||||
@@ -714,36 +910,26 @@ func _note_events(evs: Array) -> void:
|
||||
|
||||
# --- Anleitung ------------------------------------------------------------------------------
|
||||
|
||||
func tutorial_condition(kind: String) -> bool:
|
||||
var h := game.human
|
||||
match kind:
|
||||
"played_creature":
|
||||
return game.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
|
||||
"turn_passed":
|
||||
return h.turns >= 2
|
||||
"attacked":
|
||||
return game.log.any(func(l): return l.ends_with("greift Fenn an.") or (" greift " in l and l.begins_with(h.board[0].name) if not h.board.is_empty() else false))
|
||||
"resonance":
|
||||
return h.board.any(func(c): return c.resonance > 0)
|
||||
"combo":
|
||||
return game.log.any(func(l): return l.begins_with("Feldkombo"))
|
||||
"spell":
|
||||
return game.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
|
||||
return false
|
||||
|
||||
|
||||
func tutorial_next() -> void:
|
||||
if tutorial_step < TUTORIAL_STEPS.size() - 1:
|
||||
tutorial_step += 1
|
||||
if guide != null and guide.next():
|
||||
app.sfx.play("click")
|
||||
refresh()
|
||||
|
||||
|
||||
func _tutorial_refresh() -> void:
|
||||
while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]):
|
||||
tutorial_step += 1
|
||||
tutorial_text.text = "%d/%d %s" % [tutorial_step + 1, TUTORIAL_STEPS.size(), TUTORIAL_STEPS[tutorial_step][0]]
|
||||
b_tut_next.visible = tutorial_step < TUTORIAL_STEPS.size() - 1
|
||||
guide.advance()
|
||||
tutorial_text.text = guide.text()
|
||||
b_tut_next.visible = not guide.finished()
|
||||
|
||||
|
||||
func _tutorial_highlights(mine: bool) -> void:
|
||||
Style.clear(highlight_layer)
|
||||
for r in guide.highlight_rects(mine):
|
||||
var hl := Highlight.new()
|
||||
hl.position = r.position - Vector2(4, 4)
|
||||
hl.size = r.size + Vector2(8, 8)
|
||||
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
highlight_layer.add_child(hl)
|
||||
|
||||
|
||||
# --- Ansicht angleichen --------------------------------------------------------------------
|
||||
@@ -852,21 +1038,24 @@ func refresh() -> void:
|
||||
trap_views.append(tv)
|
||||
|
||||
Style.clear(hand_layer)
|
||||
for i in h.hand.size():
|
||||
var card: Cards.Card = h.hand[i]
|
||||
var playable: bool = mine and g.can_play(h, card)[0]
|
||||
var r := BattleLayout.hand_rect(h.hand.size(), i)
|
||||
var v := CardView.new(card, r.size)
|
||||
v.position = r.position
|
||||
if card == pending:
|
||||
v.border = Style.YELLOW
|
||||
elif playable:
|
||||
v.border = Style.GREEN
|
||||
v.dim = not playable
|
||||
v.hover.connect(show_hand_preview)
|
||||
v.clicked.connect(func(_view): on_click("hand", card))
|
||||
hand_layer.add_child(v)
|
||||
hand_views.append(v)
|
||||
if spectating():
|
||||
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center")
|
||||
else:
|
||||
for i in h.hand.size():
|
||||
var card: Cards.Card = h.hand[i]
|
||||
var playable: bool = mine and g.can_play(h, card)[0]
|
||||
var r := BattleLayout.hand_rect(h.hand.size(), i)
|
||||
var v := CardView.new(card, r.size)
|
||||
v.position = r.position
|
||||
if card == pending:
|
||||
v.border = Style.YELLOW
|
||||
elif playable:
|
||||
v.border = Style.GREEN
|
||||
v.dim = not playable
|
||||
v.hover.connect(show_hand_preview)
|
||||
v.clicked.connect(func(_view): on_click("hand", card))
|
||||
hand_layer.add_child(v)
|
||||
hand_views.append(v)
|
||||
|
||||
var bar_border = Style.BLUE if mine else null
|
||||
if int(flash_until.get(g.players.find(h), 0)) > now:
|
||||
@@ -876,8 +1065,14 @@ func refresh() -> void:
|
||||
life_label.text = "♥ %d" % maxi(h.life, 0)
|
||||
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy]
|
||||
deck_label.text = "Deck %d" % h.deck.size()
|
||||
status_label.text = "Zug %d · %s" % [g.turn, "Du bist dran" if mine else g.current().name + " ist dran …"]
|
||||
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
|
||||
b_end.disabled = not mine
|
||||
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
|
||||
b_skip.visible = ai_running
|
||||
b_end.visible = not ai_running and not spectating()
|
||||
b_undo.visible = undo_state != null and mine
|
||||
var lines: Array[String] = []
|
||||
var start := maxi(0, g.log.size() - 60)
|
||||
for i in range(start, g.log.size()):
|
||||
@@ -891,6 +1086,8 @@ func refresh() -> void:
|
||||
log_box.text = "\n".join(lines)
|
||||
if tutorial:
|
||||
_tutorial_refresh()
|
||||
_tutorial_highlights(mine)
|
||||
pad.draw_cursor()
|
||||
|
||||
Style.clear(zoom_layer)
|
||||
if zoom_player != null and zoom_player.alive():
|
||||
@@ -914,7 +1111,7 @@ func refresh() -> void:
|
||||
overlay.visible = true
|
||||
confirm.visible = false
|
||||
handover.visible = false
|
||||
mulligan_box.visible = false
|
||||
mulligan.visible = false
|
||||
|
||||
|
||||
func build_zoom(valid: Array) -> void:
|
||||
|
||||
149
scripts/ui/battle_tutorial.gd
Normal file
149
scripts/ui/battle_tutorial.gd
Normal file
@@ -0,0 +1,149 @@
|
||||
class_name BattleTutorial
|
||||
extends RefCounted
|
||||
## Geführte Partien: Schritte, Bedingungen und Hervorhebungen (ausgelagert aus dem Kampfbildschirm).
|
||||
|
||||
const STEPS1 := [
|
||||
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
|
||||
"played_creature"],
|
||||
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
|
||||
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
|
||||
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
|
||||
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen.", "combo"],
|
||||
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
|
||||
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
|
||||
]
|
||||
const STEPS2 := [
|
||||
["Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.", "mulligan"],
|
||||
["Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.", "trap_set"],
|
||||
["Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.", "trap_fired"],
|
||||
["Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.", "combo_dampf"],
|
||||
["Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!", ""],
|
||||
]
|
||||
|
||||
var screen # BattleScreen
|
||||
var part := 1
|
||||
var step := 0
|
||||
|
||||
|
||||
func _init(s, p: int = 1) -> void:
|
||||
screen = s
|
||||
part = p
|
||||
|
||||
|
||||
func steps() -> Array:
|
||||
return STEPS1 if part == 1 else STEPS2
|
||||
|
||||
|
||||
func stage_index() -> int:
|
||||
return 0 if part == 1 else 1
|
||||
|
||||
|
||||
## Teil 1: ein Wesen für 1 Energie auf der Hand, damit der erste Schritt immer möglich ist.
|
||||
## Teil 2: Lehrkarten auf die eigene Hand; Mara bekommt Wesen, damit sie sicher in die Falle läuft.
|
||||
func inject_cards(g: Game) -> void:
|
||||
if part == 1 and not g.human.hand.any(func(c): return c.is_creature() and c.cost == 1):
|
||||
g.human.hand.append(Cards.by_id("f_kobold"))
|
||||
if part == 2:
|
||||
g.human.hand.append(Cards.by_id("w_falle"))
|
||||
g.human.hand.append(Cards.by_id("w_reiter"))
|
||||
for i in 2:
|
||||
g.opponents[0].hand.append(Cards.by_id("w_reiter"))
|
||||
|
||||
|
||||
func condition(kind: String) -> bool:
|
||||
var g: Game = screen.game
|
||||
var h: Game.Player = g.human
|
||||
match kind:
|
||||
"played_creature":
|
||||
return g.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
|
||||
"turn_passed":
|
||||
return h.turns >= 2
|
||||
"attacked":
|
||||
return g.log.any(func(l): return l.begins_with(h.board[0].name + " greift") if not h.board.is_empty() else false) \
|
||||
or g.log.any(func(l): return l.ends_with("greift Fenn an."))
|
||||
"resonance":
|
||||
return h.board.any(func(c): return c.resonance > 0)
|
||||
"combo":
|
||||
return g.log.any(func(l): return l.begins_with("Feldkombo"))
|
||||
"spell":
|
||||
return g.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
|
||||
"mulligan":
|
||||
return h.mulligan_done
|
||||
"trap_set":
|
||||
return g.log.any(func(l): return l == "Du legt eine Falle.")
|
||||
"trap_fired":
|
||||
return g.log.any(func(l): return l.begins_with("Falle von Du"))
|
||||
"combo_dampf":
|
||||
return g.log.any(func(l): return l.begins_with("Feldkombo Dampf"))
|
||||
return false
|
||||
|
||||
|
||||
func advance() -> void:
|
||||
while step < steps().size() - 1 and condition(steps()[step][1]):
|
||||
step += 1
|
||||
|
||||
|
||||
func next() -> bool:
|
||||
if step < steps().size() - 1:
|
||||
step += 1
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func text() -> String:
|
||||
return "%d/%d %s" % [step + 1, steps().size(), screen.tr(steps()[step][0])]
|
||||
|
||||
|
||||
func finished() -> bool:
|
||||
return step >= steps().size() - 1
|
||||
|
||||
|
||||
## Rechtecke, die der aktuelle Schritt hervorhebt.
|
||||
func highlight_rects(mine: bool) -> Array[Rect2]:
|
||||
var rects: Array[Rect2] = []
|
||||
if not mine or screen.any_overlay():
|
||||
return rects
|
||||
var g: Game = screen.game
|
||||
var h: Game.Player = screen.perspective
|
||||
match steps()[step][1]:
|
||||
"played_creature":
|
||||
for v in screen.hand_views:
|
||||
if v.card.is_creature() and g.can_play(h, v.card)[0]:
|
||||
rects.append(Rect2(v.position, v.size))
|
||||
"turn_passed", "trap_fired":
|
||||
rects.append(screen.b_end.get_rect())
|
||||
"attacked":
|
||||
for v in screen.own_views:
|
||||
if v.creature.can_attack():
|
||||
rects.append(Rect2(v.slot, v.size))
|
||||
if screen.own_views.any(func(v): return v.creature.can_attack()):
|
||||
for o in screen.others():
|
||||
if o.alive() and screen.hero_panels.has(o):
|
||||
rects.append(screen.hero_panels[o].get_rect())
|
||||
"resonance":
|
||||
for v in screen.hand_views:
|
||||
var c: Cards.Card = v.card
|
||||
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return e in c.elements)):
|
||||
rects.append(Rect2(v.position, v.size))
|
||||
"combo":
|
||||
for v in screen.hand_views:
|
||||
var c: Cards.Card = v.card
|
||||
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return not (e in c.elements))):
|
||||
rects.append(Rect2(v.position, v.size))
|
||||
"spell":
|
||||
for v in screen.hand_views:
|
||||
if v.card.kind == "Zauber" and g.can_play(h, v.card)[0]:
|
||||
rects.append(Rect2(v.position, v.size))
|
||||
"trap_set":
|
||||
for v in screen.hand_views:
|
||||
if v.card.is_trap() and g.can_play(h, v.card)[0]:
|
||||
rects.append(Rect2(v.position, v.size))
|
||||
"combo_dampf":
|
||||
var has_fire: bool = h.board.any(func(b): return "Feuer" in b.card.elements)
|
||||
for v in screen.hand_views:
|
||||
var c: Cards.Card = v.card
|
||||
if not c.is_creature() or not g.can_play(h, c)[0]:
|
||||
continue
|
||||
if (has_fire and "Wasser" in c.elements) or (not has_fire and "Feuer" in c.elements):
|
||||
rects.append(Rect2(v.position, v.size))
|
||||
return rects
|
||||
1
scripts/ui/battle_tutorial.gd.uid
Normal file
1
scripts/ui/battle_tutorial.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cg78afyod2873
|
||||
@@ -12,7 +12,10 @@ 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:
|
||||
@@ -22,15 +25,21 @@ class CardBack extends Control:
|
||||
size = sz
|
||||
custom_minimum_size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
focus_entered.connect(queue_redraw)
|
||||
focus_exited.connect(queue_redraw)
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit()
|
||||
elif has_focus() and event.is_action_pressed("ui_accept"):
|
||||
accept_event()
|
||||
clicked.emit()
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect)
|
||||
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
|
||||
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
|
||||
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
|
||||
|
||||
@@ -46,13 +55,20 @@ func _ready() -> void:
|
||||
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, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center")
|
||||
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628"))
|
||||
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all)
|
||||
Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new()))
|
||||
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()
|
||||
|
||||
|
||||
@@ -61,10 +77,10 @@ func _input(event: InputEvent) -> void:
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func buy() -> void:
|
||||
func buy(element: String = "") -> void:
|
||||
var p: Profile = app.profile
|
||||
var before: Dictionary = p.collection.duplicate()
|
||||
var got: Array = p.open_booster()
|
||||
var got: Array = p.open_booster(null, element)
|
||||
if got.is_empty():
|
||||
msg_label.text = "Nicht genug Münzen"
|
||||
msg_label.add_theme_color_override("font_color", Style.RED)
|
||||
@@ -95,11 +111,39 @@ func reveal_all() -> void:
|
||||
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()]
|
||||
Style.clear(slots)
|
||||
for i in 5:
|
||||
var r := slot_rect(i)
|
||||
@@ -110,7 +154,7 @@ func refresh() -> void:
|
||||
var v := CardView.new(c, r.size)
|
||||
v.position = r.position
|
||||
slots.add_child(v)
|
||||
Style.label(slots, c.rarity, 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
|
||||
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)
|
||||
else:
|
||||
|
||||
@@ -2,52 +2,91 @@ class_name CampaignScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
var chapter := 1
|
||||
var perk_box: OptionButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var p: Profile = app.profile
|
||||
var col := Style.column(self, 1000, 24, 6)
|
||||
Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Zehn Magier warten. Jede Stufe bringt mehr Gegner an den Tisch, alle nach denselben Regeln.", 16, Style.MUTED)
|
||||
Style.spacer(col, 6)
|
||||
for i in Campaign.STAGES.size():
|
||||
var st: Dictionary = Campaign.STAGES[i]
|
||||
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
|
||||
build()
|
||||
|
||||
|
||||
func build() -> void:
|
||||
Style.clear(self)
|
||||
var p: Profile = app.profile
|
||||
var col := Style.column(self, 1000, 16, 5)
|
||||
Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true)
|
||||
var tabs := Style.row(col, 10)
|
||||
tabs.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
for c in Campaign.CHAPTERS.size():
|
||||
var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null)
|
||||
b.disabled = p.campaign_stage < c * Campaign.STAGES.size()
|
||||
if p.perk_unlocked():
|
||||
var pr := Style.row(tabs, 8)
|
||||
Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110)
|
||||
perk_box = OptionButton.new()
|
||||
perk_box.custom_minimum_size = Vector2(300, 36)
|
||||
perk_box.add_item("keins")
|
||||
var keys: Array = Game.PERKS.keys()
|
||||
for k in keys:
|
||||
perk_box.add_item(Game.PERKS[k])
|
||||
perk_box.select(keys.find(p.perk) + 1 if p.perk != "" else 0)
|
||||
perk_box.item_selected.connect(func(i: int):
|
||||
p.perk = "" if i == 0 else keys[i - 1]
|
||||
app.save())
|
||||
pr.add_child(perk_box)
|
||||
var intro := ["Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★).",
|
||||
"Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.",
|
||||
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks."]
|
||||
Style.clabel(col, intro[chapter - 1], 14, Style.MUTED)
|
||||
var offset := (chapter - 1) * Campaign.STAGES.size()
|
||||
for k in Campaign.STAGES.size():
|
||||
var i := offset + k
|
||||
var st := Campaign.stage(i)
|
||||
var done := i < p.campaign_stage
|
||||
var next := i == p.campaign_stage
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6,
|
||||
Style.GOLD if next else null, 2 if next else 0))
|
||||
col.add_child(panel)
|
||||
var r := Style.row(panel, 12)
|
||||
var r := Style.row(panel, 10)
|
||||
r.custom_minimum_size = Vector2(0, 50)
|
||||
Style.clabel(r, str(i + 1), 20, Style.GOLD if next else Style.MUTED, "center", true, 40)
|
||||
Style.clabel(r, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300)
|
||||
Style.clabel(r, str(i + 1), 18, Style.GOLD if next else Style.MUTED, "center", true, 36)
|
||||
Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 290)
|
||||
var dots := Control.new()
|
||||
dots.custom_minimum_size = Vector2(60, 0)
|
||||
dots.custom_minimum_size = Vector2(50, 0)
|
||||
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var elems: Array = st.elements
|
||||
dots.draw.connect(func():
|
||||
for k in elems.size():
|
||||
dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]]))
|
||||
for m in elems.size():
|
||||
dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]]))
|
||||
r.add_child(dots)
|
||||
var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2)
|
||||
Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300)
|
||||
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
|
||||
var table: String = "Duell" if extra == 0 else "%d am Tisch" % (extra + 2)
|
||||
var perks_text: String = ", ".join(st.perks.map(func(pk): return Game.PERKS[pk]))
|
||||
var target := Campaign.target_rating(i, p.target_rating())
|
||||
Style.clabel(r, "%s · Stärke %.1f · %d Münzen\n★ %s" % [table, target, st.reward, perks_text], 12, Style.MUTED, "left", false, 360)
|
||||
if done:
|
||||
Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110)
|
||||
Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i))
|
||||
Style.clabel(r, "abgeschlossen", 13, Style.GREEN, "right", false, 100)
|
||||
Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i))
|
||||
elif next:
|
||||
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
|
||||
Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c"))
|
||||
Style.clabel(r, "", 13, Style.GREEN, "right", false, 100)
|
||||
Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c"))
|
||||
else:
|
||||
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
|
||||
var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass)
|
||||
Style.clabel(r, "", 13, Style.GREEN, "right", false, 100)
|
||||
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
|
||||
b.disabled = true
|
||||
if p.campaign_stage >= Campaign.STAGES.size():
|
||||
Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true)
|
||||
Style.spacer(col, 4)
|
||||
if p.campaign_stage >= Campaign.total():
|
||||
Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true)
|
||||
var br := Style.row(col)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func set_chapter(c: int) -> void:
|
||||
chapter = c
|
||||
build()
|
||||
|
||||
|
||||
func _start(stage: int) -> void:
|
||||
|
||||
@@ -17,6 +17,7 @@ func _init(rect: Rect2) -> void:
|
||||
position = rect.position
|
||||
size = rect.size
|
||||
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
follow_focus = true # Gamepad: der fokussierten Karte hinterherscrollen
|
||||
grid = GridContainer.new()
|
||||
grid.columns = COLS
|
||||
grid.add_theme_constant_override("h_separation", GAP)
|
||||
@@ -31,7 +32,7 @@ func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
|
||||
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))
|
||||
var v := CardView.new(c, Vector2(CW, CH), true)
|
||||
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))
|
||||
|
||||
@@ -10,19 +10,31 @@ var border = null # Color oder null (dann Seltenheitsfarbe)
|
||||
var dim := false
|
||||
|
||||
|
||||
func _init(c: Cards.Card, sz: Vector2) -> void:
|
||||
## 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:
|
||||
card = c
|
||||
custom_minimum_size = sz
|
||||
size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func(): hover.emit(card, true))
|
||||
mouse_exited.connect(func(): hover.emit(card, false))
|
||||
if focusable:
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
focus_entered.connect(func():
|
||||
hover.emit(card, true)
|
||||
queue_redraw())
|
||||
focus_exited.connect(func():
|
||||
hover.emit(card, false)
|
||||
queue_redraw())
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit(self)
|
||||
elif has_focus() and event.is_action_pressed("ui_accept"):
|
||||
accept_event()
|
||||
clicked.emit(self)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -41,25 +53,34 @@ func _draw() -> void:
|
||||
draw_circle(cc, r, Color("14141c"))
|
||||
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
|
||||
# Name und Kombo
|
||||
var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12)
|
||||
Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true)
|
||||
var name_left := 2 * r + 10
|
||||
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 = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind
|
||||
var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind)
|
||||
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 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 = size.y * 0.42 - 4
|
||||
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)
|
||||
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
|
||||
# Textfeld
|
||||
var box := Rect2(6, size.y * 0.42, size.x - 12, size.y * 0.42)
|
||||
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
|
||||
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(f_text)),
|
||||
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, f_text, 6 if big else 4, Style.TEXT)
|
||||
var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen
|
||||
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)
|
||||
if dim:
|
||||
draw_rect(rect, Color(0, 0, 0, 0.47))
|
||||
if has_focus():
|
||||
Style.border(self, rect, Style.GOLD, 4, 8)
|
||||
|
||||
42
scripts/ui/challenge_screen.gd
Normal file
42
scripts/ui/challenge_screen.gd
Normal file
@@ -0,0 +1,42 @@
|
||||
class_name ChallengeScreen
|
||||
extends Control
|
||||
## Wöchentliche Herausforderung: feste Regelvariante mit einmaliger Belohnung.
|
||||
|
||||
var app
|
||||
var msg: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var p: Profile = app.profile
|
||||
var ch := Challenge.current()
|
||||
var done: bool = p.challenge_week == Challenge.week()
|
||||
var col := Style.column(self, 760, 60, 14)
|
||||
Style.clabel(col, "Herausforderung der Woche", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Jede Woche eine andere Regel. Die Belohnung gibt es einmal pro Woche, danach die normale.", 16, Style.MUTED)
|
||||
Style.spacer(col, 20)
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 10, Style.GREEN if done else Style.GOLD, 2))
|
||||
col.add_child(panel)
|
||||
var inner := VBoxContainer.new()
|
||||
inner.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(inner)
|
||||
Style.spacer(inner, 6)
|
||||
Style.clabel(inner, ch.name, 30, Style.TEXT, "center", true)
|
||||
Style.clabel(inner, ch.text, 18, Style.MUTED)
|
||||
Style.clabel(inner, "Belohnung: %d Münzen%s" % [ch.reward, " · diese Woche schon bestanden ✓" if done else ""], 16, Style.GOLD)
|
||||
Style.spacer(inner, 6)
|
||||
Style.spacer(col, 10)
|
||||
msg = Style.clabel(col, "", 16, Style.ORANGE)
|
||||
var deck_err := Challenge.deck_allowed(ch, p.deck_cards())
|
||||
if deck_err != "":
|
||||
msg.text = deck_err + " (aktives Deck: %s)" % p.deck_name()
|
||||
var br := Style.row(col, 20)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
var b := Style.cbutton(br, "Antreten", Vector2(200, 50), func(): app.show_screen(BattleScreen.weekly(ch)), Color("285a3c"))
|
||||
b.disabled = deck_err != "" or not p.deck_valid()[0]
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 50), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
1
scripts/ui/challenge_screen.gd.uid
Normal file
1
scripts/ui/challenge_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cxheynenqemse
|
||||
@@ -44,7 +44,7 @@ func _draw() -> void:
|
||||
_draw_mini()
|
||||
return
|
||||
var fs := 11 if small else 13
|
||||
var nm := Style.fit(creature.name, true, fs, size.x - 6)
|
||||
var nm := Style.fit(creature.card.title(), true, fs, size.x - 6)
|
||||
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
|
||||
var badges: Array[String] = []
|
||||
if creature.taunt():
|
||||
|
||||
@@ -27,6 +27,9 @@ 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:
|
||||
@@ -42,26 +45,30 @@ class DeckRow extends Control:
|
||||
size = sz
|
||||
custom_minimum_size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func():
|
||||
lit = true
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
var light := func(on: bool):
|
||||
lit = on
|
||||
queue_redraw()
|
||||
hover.emit(card, true))
|
||||
mouse_exited.connect(func():
|
||||
lit = false
|
||||
queue_redraw()
|
||||
hover.emit(card, false))
|
||||
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.name], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
|
||||
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)
|
||||
|
||||
|
||||
@@ -92,10 +99,11 @@ func _ready() -> void:
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||
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(400, 55)
|
||||
search_box.size = Vector2(160, 34)
|
||||
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
|
||||
@@ -149,9 +157,25 @@ func _ready() -> void:
|
||||
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(curve)
|
||||
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
|
||||
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c"))
|
||||
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck)
|
||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
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
|
||||
@@ -159,11 +183,27 @@ func _ready() -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus():
|
||||
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)
|
||||
@@ -227,7 +267,7 @@ func cards() -> Array:
|
||||
out = out.filter(func(c): return filter in c.elements)
|
||||
if search != "":
|
||||
var q := search.to_lower()
|
||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
||||
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))
|
||||
@@ -291,6 +331,34 @@ func save_deck() -> void:
|
||||
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:
|
||||
@@ -340,10 +408,13 @@ func refresh() -> void:
|
||||
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 = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–")
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ var list_label: Label
|
||||
var ip_box: LineEdit
|
||||
var b_host: Button
|
||||
var b_join: Button
|
||||
var b_watch: Button
|
||||
var b_start: Button
|
||||
var ai_buttons: Array = []
|
||||
var n_ai := 0
|
||||
@@ -32,6 +33,7 @@ func _ready() -> void:
|
||||
ip_box.text = "127.0.0.1"
|
||||
hr.add_child(ip_box)
|
||||
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
|
||||
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
|
||||
addr_label = Style.clabel(col, "", 14, Style.MUTED)
|
||||
status_label = Style.clabel(col, "", 16, Style.BLUE)
|
||||
Style.spacer(col, 6)
|
||||
@@ -71,6 +73,13 @@ func _join() -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
|
||||
func _watch() -> void:
|
||||
net.spectator = true
|
||||
net.join(ip_box.text.strip_edges())
|
||||
refresh()
|
||||
|
||||
|
||||
func _set_ai(n: int) -> void:
|
||||
n_ai = n
|
||||
refresh()
|
||||
@@ -96,9 +105,12 @@ func refresh() -> void:
|
||||
ids.sort()
|
||||
for id in ids:
|
||||
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
|
||||
for id in net.spectators:
|
||||
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
|
||||
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
|
||||
b_host.disabled = net.connected
|
||||
b_join.disabled = net.connected
|
||||
b_watch.disabled = net.connected
|
||||
ip_box.editable = not net.connected
|
||||
var humans := ids.size()
|
||||
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)
|
||||
|
||||
@@ -9,6 +9,7 @@ var net: Net
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_map_gamepad()
|
||||
theme = Style.make_theme()
|
||||
var bg := Style.Backdrop.new()
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
@@ -23,10 +24,25 @@ func _ready() -> void:
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
profile = Profile.load_or_new(save_path)
|
||||
profile.refresh_daily()
|
||||
apply_settings()
|
||||
show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func apply_settings() -> void:
|
||||
sfx.set_volume(profile.settings.volume)
|
||||
sfx.set_music_volume(profile.settings.music)
|
||||
set_language(profile.settings.language)
|
||||
show_screen(MenuScreen.new())
|
||||
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
|
||||
theme = Style.make_theme()
|
||||
|
||||
|
||||
## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen.
|
||||
func reset_profile() -> void:
|
||||
profile = Profile.starter()
|
||||
profile.refresh_daily()
|
||||
clear_battle_save()
|
||||
save()
|
||||
apply_settings()
|
||||
|
||||
|
||||
func set_language(code: String) -> void:
|
||||
@@ -40,12 +56,69 @@ func show_screen(s: Control) -> void:
|
||||
s.app = self
|
||||
s.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(s)
|
||||
if pad_mode:
|
||||
focus_first_button.call_deferred()
|
||||
|
||||
|
||||
var pad_mode := false # zuletzt Gamepad benutzt: Fokusrahmen sichtbar, Steuerkreuz wechselt Knöpfe
|
||||
|
||||
|
||||
## Gamepad in Menüs: erster Tastendruck setzt den Fokus, Maus nimmt ihn wieder weg.
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventJoypadButton or (event is InputEventJoypadMotion and absf(event.axis_value) > 0.5):
|
||||
if not pad_mode:
|
||||
pad_mode = true
|
||||
if get_viewport().gui_get_focus_owner() == null:
|
||||
focus_first_button()
|
||||
elif event is InputEventMouseMotion and pad_mode:
|
||||
pad_mode = false
|
||||
get_viewport().gui_release_focus()
|
||||
|
||||
|
||||
## Gamepad-Tasten für die Menünavigation: A bestätigt, B bricht ab (falls nicht schon zugeordnet).
|
||||
func _map_gamepad() -> void:
|
||||
for pair in [["ui_accept", JOY_BUTTON_A], ["ui_cancel", JOY_BUTTON_B]]:
|
||||
var action: String = pair[0]
|
||||
if not InputMap.has_action(action):
|
||||
InputMap.add_action(action)
|
||||
if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton):
|
||||
var ev := InputEventJoypadButton.new()
|
||||
ev.button_index = pair[1]
|
||||
InputMap.action_add_event(action, ev)
|
||||
|
||||
|
||||
func focus_first_button() -> void:
|
||||
if screen == null or screen is BattleScreen:
|
||||
return
|
||||
var b := _first_button(screen)
|
||||
if b != null:
|
||||
b.grab_focus()
|
||||
|
||||
|
||||
func _first_button(node: Node) -> Button:
|
||||
for c in node.get_children():
|
||||
if c is Button and not c.disabled and c.is_visible_in_tree():
|
||||
return c
|
||||
var found := _first_button(c)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func save() -> void:
|
||||
profile.save(save_path)
|
||||
|
||||
|
||||
## Datei für einen unterbrochenen Kampf (neben dem Spielstand).
|
||||
func battle_save_path() -> String:
|
||||
return save_path.get_basename() + "_kampf.json"
|
||||
|
||||
|
||||
func clear_battle_save() -> void:
|
||||
if FileAccess.file_exists(battle_save_path()):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(battle_save_path()))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if net != null and net.is_inside_tree():
|
||||
net.leave()
|
||||
|
||||
@@ -7,6 +7,8 @@ var b_campaign: Button
|
||||
var b_hotseat: Button
|
||||
var b_online: Button
|
||||
var b_tutorial: Button
|
||||
var b_challenge: Button
|
||||
var b_resume: Button
|
||||
var coins_label: Label
|
||||
var coll_label: Label
|
||||
var deck_label: Label
|
||||
@@ -34,12 +36,17 @@ func _ready() -> void:
|
||||
col.add_child(center)
|
||||
var sz := Vector2(270, 44)
|
||||
var p0: Profile = app.profile
|
||||
b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()),
|
||||
Color("5a4628") if not p0.tutorial_done else null)
|
||||
var part2: bool = p0.tutorial_done and not p0.tutorial2_done
|
||||
if FileAccess.file_exists(app.battle_save_path()):
|
||||
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628"))
|
||||
b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz,
|
||||
func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)),
|
||||
Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null)
|
||||
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
|
||||
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
|
||||
b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c"))
|
||||
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
|
||||
b_challenge = Style.cbutton(grid, "Herausforderung", sz, func(): app.show_screen(ChallengeScreen.new()), Color("285a3c"))
|
||||
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
|
||||
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
|
||||
Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new()))
|
||||
@@ -54,6 +61,17 @@ func _ready() -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
## Unterbrochenen Kampf laden; eine unlesbare Datei wird verworfen.
|
||||
func _resume() -> void:
|
||||
var json := JSON.new()
|
||||
var text := FileAccess.get_file_as_string(app.battle_save_path())
|
||||
if json.parse(text) != OK or not (json.data is Dictionary) or not json.data.has("game"):
|
||||
app.clear_battle_save()
|
||||
app.show_screen(MenuScreen.new())
|
||||
return
|
||||
app.show_screen(BattleScreen.resume(json.data))
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
var ok: Array = p.deck_valid()
|
||||
@@ -61,23 +79,28 @@ func refresh() -> void:
|
||||
b_campaign.disabled = not ok[0]
|
||||
b_tutorial.disabled = not ok[0]
|
||||
b_online.disabled = not ok[0]
|
||||
b_challenge.disabled = not ok[0]
|
||||
b_challenge.text = tr("Herausforderung") + (" ✓" if p.challenge_week == Challenge.week() else "")
|
||||
var total := 0
|
||||
for cid in p.collection:
|
||||
total += p.collection[cid]
|
||||
coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust]
|
||||
coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [
|
||||
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()]
|
||||
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 = "Aktives Deck: %s (%s)" % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else "leer"]
|
||||
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 = "Tagesaufgabe erledigt: %s ✓" % task.text
|
||||
daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text
|
||||
else:
|
||||
daily_label.text = "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
|
||||
warn_label.text = "" if ok[0] else "Aktives Deck nicht spielbar: " + ok[1]
|
||||
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])
|
||||
if ok[0] and not p.tutorial_done:
|
||||
warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos."
|
||||
warn_label.add_theme_color_override("font_color", Style.GOLD)
|
||||
elif ok[0] and not p.tutorial2_done:
|
||||
warn_label.text = "Anleitung 2 zeigt Mulligan, Fallen und die Feldkombo Dampf."
|
||||
warn_label.add_theme_color_override("font_color", Style.GOLD)
|
||||
var notes := p.take_notices()
|
||||
notice_label.text = " · ".join(notes)
|
||||
if not notes.is_empty():
|
||||
|
||||
@@ -1,27 +1,107 @@
|
||||
class_name OpponentSelectScreen
|
||||
extends Control
|
||||
## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird.
|
||||
## Einzelspieler: Schwierigkeit, Gegnerzahl und Vorschau der Gegner (Element, Stärke).
|
||||
|
||||
var app
|
||||
var rating_label: Label
|
||||
var tip_label: Label
|
||||
var diff_buttons := {}
|
||||
var previews := {} # n -> Array von Game.Player
|
||||
var preview_labels := {}
|
||||
var b_tip: Button
|
||||
var b_reroll: Button
|
||||
var rerolls_left := 3
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 900, 50, 14)
|
||||
var col := Style.column(self, 1000, 30, 10)
|
||||
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Wie viele Gegner? Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben." % Game.START_LIFE, 18, Style.MUTED)
|
||||
Style.clabel(col, "Du gewinnst, wenn alle anderen besiegt sind. Mehr Gegner, mehr Chaos, mehr Münzen.", 16, Style.MUTED)
|
||||
Style.spacer(col, 10)
|
||||
Style.clabel(col, "Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben. Du gewinnst, wenn alle anderen besiegt sind." % Game.START_LIFE, 15, Style.MUTED)
|
||||
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"]:
|
||||
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, "", 14, Style.MUTED)
|
||||
var tr_row := Style.row(col, 10)
|
||||
tr_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
tip_label = Style.clabel(tr_row, "", 14, Style.GOLD, "right", false, 520)
|
||||
b_tip = Style.cbutton(tr_row, "Übernehmen", Vector2(140, 32), take_tip)
|
||||
Style.spacer(col, 2)
|
||||
for i in range(1, Game.MAX_OPPONENTS + 1):
|
||||
var n := i
|
||||
var r := Style.row(col, 20)
|
||||
var r := Style.row(col, 16)
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(r, "%d Gegner" % n, Vector2(200, 56), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c"))
|
||||
var info := ("Duell" if n == 1 else "%d Spieler am Tisch" % (n + 1)) + " · Sieg: %d Münzen" % Profile.REWARD_WIN[n]
|
||||
Style.clabel(r, info, 16, Style.MUTED, "left", false, 420)
|
||||
Style.spacer(col, 20)
|
||||
var br := Style.row(col)
|
||||
Style.cbutton(r, "%d Gegner" % n, Vector2(150, 46), func(): start(n), Color("285a3c"))
|
||||
var info := (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
|
||||
Style.clabel(r, info, 13, Style.MUTED, "left", false, 150)
|
||||
preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600)
|
||||
preview_labels[n].autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
preview_labels[n].custom_minimum_size = Vector2(600, 0)
|
||||
preview_labels[n].size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
Style.spacer(col, 4)
|
||||
var br := Style.row(col, 16)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
b_reroll = Style.cbutton(br, "Neu würfeln", Vector2(190, 40), reroll)
|
||||
Style.cbutton(br, "Zurück", Vector2(150, 40), func(): app.show_screen(MenuScreen.new()))
|
||||
refresh(true)
|
||||
|
||||
|
||||
func set_difficulty(key: String) -> void:
|
||||
app.profile.settings.difficulty = key
|
||||
app.save()
|
||||
refresh(true)
|
||||
|
||||
|
||||
func take_tip() -> void:
|
||||
var tip: String = app.profile.suggest_difficulty()
|
||||
if tip != "":
|
||||
set_difficulty(tip)
|
||||
|
||||
|
||||
## Höchstens dreimal neu würfeln, sonst ließen sich schwache Gegner erwürfeln.
|
||||
func reroll() -> void:
|
||||
if rerolls_left <= 0:
|
||||
return
|
||||
rerolls_left -= 1
|
||||
refresh(true)
|
||||
|
||||
|
||||
func start(n: int) -> void:
|
||||
app.show_screen(BattleScreen.against(previews[n]))
|
||||
|
||||
|
||||
## Baut die Gegner für jede Anzahl vor und zeigt Element und Stärke; der Kampf nutzt genau diese.
|
||||
func refresh(reroll: bool = false) -> void:
|
||||
var p: Profile = app.profile
|
||||
var d := p.difficulty()
|
||||
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))
|
||||
var arche: String = tr("schwache Archetypen") if d.tier == "leicht" else (tr("starke Archetypen") if d.tier == "schwer" else tr("alle Archetypen"))
|
||||
rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s") % [
|
||||
p.deck_rating(), p.target_rating(), arche, d.reward, tr("Vorausschau") if d.smart else tr("einfach")]
|
||||
var tip := p.suggest_difficulty()
|
||||
tip_label.text = (tr("Tipp: %s") % ((tr("%d Siege in Folge, versuche %s") % [p.records.streak, tr(Profile.DIFFICULTIES[tip].name)]) if p.records.streak >= 3
|
||||
else (tr("%d Niederlagen in Folge, versuche %s") % [p.records.loss_streak, tr(Profile.DIFFICULTIES[tip].name)]))) if tip != "" else ""
|
||||
b_tip.visible = tip != ""
|
||||
if reroll:
|
||||
var r := Game.make_rng()
|
||||
for n in range(1, Game.MAX_OPPONENTS + 1):
|
||||
var opps := []
|
||||
for i in n:
|
||||
opps.append(Game.random_opponent(r, i + 1, Game.START_LIFE, p.target_rating(), str(d.tier)))
|
||||
previews[n] = opps
|
||||
b_reroll.text = tr("Neu würfeln (%d)") % rerolls_left
|
||||
b_reroll.disabled = rerolls_left <= 0
|
||||
for n in previews:
|
||||
var parts: Array[String] = []
|
||||
for o in previews[n]:
|
||||
parts.append("%s (%.1f)" % [o.title, Game.deck_rating(o.deck)])
|
||||
preview_labels[n].text = ", ".join(parts)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
|
||||
@@ -7,6 +7,8 @@ var music: HSlider
|
||||
var speed: HSlider
|
||||
var language: OptionButton
|
||||
var music_label: Label
|
||||
var big_font: CheckButton
|
||||
var confirm: Control
|
||||
var anim: CheckButton
|
||||
var volume_label: Label
|
||||
var speed_label: Label
|
||||
@@ -58,24 +60,45 @@ func _ready() -> void:
|
||||
anim.toggled.connect(_on_anim)
|
||||
grid.add_child(anim)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "Große Schrift", 20, Style.TEXT, "left", false, 200)
|
||||
big_font = CheckButton.new()
|
||||
big_font.button_pressed = bool(s.get("big_font", false))
|
||||
big_font.toggled.connect(_on_big_font)
|
||||
grid.add_child(big_font)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200)
|
||||
language = OptionButton.new()
|
||||
language.custom_minimum_size = Vector2(280, 36)
|
||||
language.add_item("Deutsch", 0)
|
||||
language.add_item("English (Oberfläche)", 1)
|
||||
language.add_item("English", 1)
|
||||
language.select(1 if s.language == "en" else 0)
|
||||
language.item_selected.connect(_on_language)
|
||||
grid.add_child(language)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.spacer(col, 20)
|
||||
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
|
||||
Style.spacer(col, 60)
|
||||
var r := Style.row(col)
|
||||
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.",
|
||||
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
|
||||
_labels()
|
||||
|
||||
|
||||
func _reset() -> void:
|
||||
app.reset_profile()
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func _on_big_font(on: bool) -> void:
|
||||
app.profile.settings.big_font = on
|
||||
app.save()
|
||||
app.apply_settings()
|
||||
app.show_screen(SettingsScreen.new())
|
||||
|
||||
|
||||
func _labels() -> void:
|
||||
volume_label.text = "%d %%" % int(round(volume.value * 100))
|
||||
music_label.text = "%d %%" % int(round(music.value * 100))
|
||||
|
||||
@@ -9,6 +9,8 @@ var players: Array[AudioStreamPlayer] = []
|
||||
var volume := 0.8
|
||||
var music_player: AudioStreamPlayer
|
||||
var music_volume := 0.5
|
||||
var music_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
|
||||
var music_box := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -26,9 +28,23 @@ func _ready() -> void:
|
||||
add_child(p)
|
||||
players.append(p)
|
||||
music_player = AudioStreamPlayer.new()
|
||||
music_player.stream = _music()
|
||||
add_child(music_player)
|
||||
set_music_volume(music_volume)
|
||||
var box := music_box
|
||||
music_task = WorkerThreadPool.add_task(func(): box["wav"] = _music())
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if music_task >= 0 and WorkerThreadPool.is_task_completed(music_task):
|
||||
WorkerThreadPool.wait_for_task_completion(music_task)
|
||||
music_task = -1
|
||||
music_player.stream = music_box.wav
|
||||
set_music_volume(music_volume)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if music_task >= 0:
|
||||
WorkerThreadPool.wait_for_task_completion(music_task)
|
||||
music_task = -1
|
||||
|
||||
|
||||
## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt.
|
||||
@@ -67,7 +83,7 @@ func set_music_volume(v: float) -> void:
|
||||
music_player.stop()
|
||||
else:
|
||||
music_player.volume_db = linear_to_db(music_volume * 0.6)
|
||||
if not music_player.playing:
|
||||
if not music_player.playing and music_player.stream != null:
|
||||
music_player.play()
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ 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")
|
||||
|
||||
const BG := Color("1a1c24")
|
||||
@@ -38,16 +43,16 @@ static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0
|
||||
static func make_theme() -> Theme:
|
||||
var t := Theme.new()
|
||||
t.default_font = FONT
|
||||
t.default_font_size = 16
|
||||
t.default_font_size = fs(16)
|
||||
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
|
||||
t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2))
|
||||
t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2))
|
||||
t.set_stylebox("disabled", "Button", box(darker(PANEL, 0.9), 8, PANEL, 2))
|
||||
t.set_stylebox("focus", "Button", StyleBoxEmpty.new())
|
||||
t.set_stylebox("focus", "Button", box(Color(0, 0, 0, 0), 8, GOLD, 3))
|
||||
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
|
||||
t.set_color(name, "Button", TEXT)
|
||||
t.set_color("font_disabled_color", "Button", MUTED)
|
||||
t.set_font_size("font_size", "Button", 18)
|
||||
t.set_font_size("font_size", "Button", fs(18))
|
||||
t.set_color("font_color", "Label", TEXT)
|
||||
t.set_stylebox("panel", "Panel", box(PANEL, 8))
|
||||
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
|
||||
@@ -84,7 +89,7 @@ static func label(parent: Node, text: String, size: int, color: Color, rect: Rec
|
||||
l.text = text
|
||||
l.position = rect.position
|
||||
l.size = rect.size
|
||||
l.add_theme_font_size_override("font_size", size)
|
||||
l.add_theme_font_size_override("font_size", fs(size))
|
||||
l.add_theme_color_override("font_color", color)
|
||||
if bold:
|
||||
l.add_theme_font_override("font", FONT_BOLD)
|
||||
@@ -160,6 +165,31 @@ static func spacer(parent: Node, h: float) -> Control:
|
||||
return c
|
||||
|
||||
|
||||
## Unsichtbare Vollbild-Ebene mit abgedunkeltem Hintergrund, die Klicks schluckt.
|
||||
static func overlay(parent: Node) -> Control:
|
||||
var o := Control.new()
|
||||
o.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
o.visible = false
|
||||
var dark := ColorRect.new()
|
||||
dark.color = Color(0, 0, 0, 0.67)
|
||||
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
dark.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
o.add_child(dark)
|
||||
parent.add_child(o)
|
||||
return o
|
||||
|
||||
|
||||
## Bestätigungsdialog (unsichtbar): Titel, Hinweis, Ja-Knopf mit Aktion und ein Abbrechen-Knopf.
|
||||
static func confirm(parent: Node, title: String, text: String, yes: String, on_yes: Callable, no: String = "Abbrechen", color: Color = MUTED) -> Control:
|
||||
var o := overlay(parent)
|
||||
panel(o, Rect2(340, 290, 600, 200), PANEL, 10, color, 2)
|
||||
label(o, title, 26, TEXT, Rect2(340, 312, 600, 40), "center", true)
|
||||
label(o, text, 16, MUTED, Rect2(340, 360, 600, 24), "center")
|
||||
button(o, yes, Rect2(380, 420, 240, 46), on_yes, Color("5a2828"))
|
||||
button(o, no, Rect2(660, 420, 240, 46), func(): o.visible = false)
|
||||
return o
|
||||
|
||||
|
||||
static func clear(node: Node) -> void:
|
||||
for c in node.get_children():
|
||||
node.remove_child(c)
|
||||
@@ -168,7 +198,8 @@ static func clear(node: Node) -> void:
|
||||
|
||||
# --- Zeichenhilfen für _draw() ---------------------------------------------------
|
||||
|
||||
static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
|
||||
static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
|
||||
var size := fs(size_in)
|
||||
var font: Font = FONT_BOLD if bold else FONT
|
||||
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||
var x := pos.x
|
||||
@@ -253,8 +284,8 @@ static func rules_text() -> String:
|
||||
"Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.",
|
||||
Cards.RESONANCE_TEXT,
|
||||
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
|
||||
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an alle gegnerischen Wesen, Blitz 2 Schaden",
|
||||
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.",
|
||||
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an das stärkste gegnerische Wesen, Blitz 2 Schaden",
|
||||
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen +1/+1.",
|
||||
"",
|
||||
"Schlüsselwörter:",
|
||||
]
|
||||
@@ -264,10 +295,16 @@ static func rules_text() -> String:
|
||||
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES])
|
||||
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.")
|
||||
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.")
|
||||
lines.append("Elementbooster: 5 Karten eines Elements (und seiner Kombos) für %d Münzen." % Profile.ELEMENT_BOOSTER_COST)
|
||||
lines.append("Deck-Code: im Deckbau unter „Code“ kopieren und mit anderen teilen.")
|
||||
lines.append("Ein unterbrochener Einzelspieler-Kampf lässt sich im Menü fortsetzen.")
|
||||
lines.append("")
|
||||
lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.")
|
||||
lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.")
|
||||
lines.append("Netzwerkspiel: Fällt der Host aus, übernimmt ein Mitspieler; Zuschauer sehen die Partie ohne Hände.")
|
||||
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
|
||||
lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine")
|
||||
lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.")
|
||||
lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.")
|
||||
lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -24,9 +24,10 @@ func _ready() -> void:
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||
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(400, 55)
|
||||
search_box.position = Vector2(424, 55)
|
||||
search_box.size = Vector2(200, 34)
|
||||
search_box.placeholder_text = "Suche"
|
||||
search_box.text_changed.connect(func(t: String):
|
||||
@@ -51,7 +52,7 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus():
|
||||
if event.is_action_pressed("ui_cancel") and not search_box.has_focus():
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
@@ -66,7 +67,7 @@ func cards() -> Array:
|
||||
out = out.filter(func(c): return filter in c.elements)
|
||||
if search != "":
|
||||
var q := search.to_lower()
|
||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
||||
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())
|
||||
return out
|
||||
|
||||
|
||||
@@ -88,7 +89,7 @@ func dismantle() -> void:
|
||||
if err == "":
|
||||
app.sfx.play("click")
|
||||
app.save()
|
||||
say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true)
|
||||
say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[selected.rarity]], true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
@@ -102,7 +103,7 @@ func craft() -> void:
|
||||
app.sfx.play("play")
|
||||
app.save()
|
||||
var notes: Array[String] = app.profile.take_notices()
|
||||
say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
|
||||
say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
@@ -138,8 +139,8 @@ func refresh() -> void:
|
||||
preview_holder.add_child(v)
|
||||
var owned: int = p.collection.get(selected.id, 0)
|
||||
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
|
||||
info_label.text = "%s · %s\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
|
||||
selected.combo(), selected.rarity, owned, st.played, st.games, st.wins]
|
||||
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
|
||||
|
||||
49
tests/frame_probe.gd
Normal file
49
tests/frame_probe.gd
Normal file
@@ -0,0 +1,49 @@
|
||||
extends Node
|
||||
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen (5 Gegner).
|
||||
|
||||
var main: Node
|
||||
var max_dt := 0.0
|
||||
var frames_n := 0
|
||||
var measuring := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Game.test_seed = 4242
|
||||
main = load("res://scenes/main.tscn").instantiate()
|
||||
main.save_path = "user://_probe.json"
|
||||
add_child(main)
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var scr: BattleScreen = BattleScreen.new(5)
|
||||
main.show_screen(scr)
|
||||
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)
|
||||
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()
|
||||
var actions := 0
|
||||
var last_turn := scr.game.turn
|
||||
while Time.get_ticks_msec() - start < 12000 and scr.game.winner == "":
|
||||
if scr.game.current().is_human and scr.game.current() == h:
|
||||
scr.game.end_turn()
|
||||
scr.after_action()
|
||||
await get_tree().process_frame
|
||||
measuring = false
|
||||
print("längste Bildzeit in 12 s KI-Spiel: %.0f ms (%d Bilder, Zug %d)" % [max_dt * 1000.0, frames_n, scr.game.turn])
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://_probe.json"))
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if measuring:
|
||||
frames_n += 1
|
||||
max_dt = maxf(max_dt, delta)
|
||||
1
tests/frame_probe.gd.uid
Normal file
1
tests/frame_probe.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://botawgwpha3ai
|
||||
6
tests/frame_probe.tscn
Normal file
6
tests/frame_probe.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/frame_probe.gd" id="1"]
|
||||
|
||||
[node name="FrameProbe" type="Node"]
|
||||
script = ExtResource("1")
|
||||
29
tests/lint.gd
Normal file
29
tests/lint.gd
Normal file
@@ -0,0 +1,29 @@
|
||||
extends SceneTree
|
||||
## Statische Prüfung: lädt jedes Skript; Parse- oder Kompilierfehler lassen den Lauf scheitern.
|
||||
## godot --headless --path . -s tests/lint.gd
|
||||
|
||||
|
||||
func scan(dir: String, out: Array) -> void:
|
||||
var d := DirAccess.open(dir)
|
||||
if d == null:
|
||||
return
|
||||
for sub in d.get_directories():
|
||||
if not sub.begins_with("."):
|
||||
scan(dir.path_join(sub), out)
|
||||
for f in d.get_files():
|
||||
if f.ends_with(".gd"):
|
||||
out.append(dir.path_join(f))
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var files := []
|
||||
for dir in ["res://scripts", "res://tests"]:
|
||||
scan(dir, files)
|
||||
var bad := 0
|
||||
for path in files:
|
||||
var script = load(path)
|
||||
if script == null or not (script is GDScript):
|
||||
printerr("FEHLER: %s lädt nicht" % path)
|
||||
bad += 1
|
||||
print("%d Skripte geprüft, %d fehlerhaft" % [files.size(), bad])
|
||||
quit(1 if bad > 0 else 0)
|
||||
1
tests/lint.gd.uid
Normal file
1
tests/lint.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c65qyffqln67m
|
||||
@@ -1,11 +1,14 @@
|
||||
extends Node
|
||||
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge.
|
||||
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er
|
||||
## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI).
|
||||
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
|
||||
## godot --path . tests/net_bot.tscn -- --host --name=BotHost
|
||||
|
||||
var net: Net
|
||||
var seat := -1
|
||||
var game: Game
|
||||
var t := 0.0
|
||||
var hosting := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -16,20 +19,37 @@ func _ready() -> void:
|
||||
ip = a.substr(5)
|
||||
if a.begins_with("--name="):
|
||||
bot_name = a.substr(7)
|
||||
if a == "--host":
|
||||
hosting = true
|
||||
net = Net.new()
|
||||
net.name = "Net"
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
net.my_name = bot_name
|
||||
net.my_deck = Profile.starter().deck.duplicate()
|
||||
net.started.connect(func(s): seat = s)
|
||||
net.started.connect(func(s):
|
||||
seat = s
|
||||
if hosting:
|
||||
net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut
|
||||
_act_host.call_deferred())
|
||||
net.snapshot_received.connect(_on_snapshot)
|
||||
net.ended.connect(func(_r): get_tree().quit())
|
||||
await get_tree().process_frame
|
||||
net.join(ip)
|
||||
if hosting:
|
||||
net.host()
|
||||
net.lobby_changed.connect(func():
|
||||
if net.game == null and net.names.size() >= 2:
|
||||
net.start_battle(0))
|
||||
net.action_applied.connect(func():
|
||||
net.broadcast()
|
||||
_act_host.call_deferred())
|
||||
else:
|
||||
net.join(ip)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
t += delta
|
||||
if hosting and net.game != null:
|
||||
net.tick()
|
||||
if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
|
||||
net.leave()
|
||||
get_tree().quit()
|
||||
@@ -43,7 +63,23 @@ func _on_snapshot(snap: Dictionary) -> void:
|
||||
return
|
||||
if game.current_idx != seat:
|
||||
return
|
||||
var me: Game.Player = game.players[seat]
|
||||
_decide(game.players[seat])
|
||||
|
||||
|
||||
func _act_host() -> void:
|
||||
if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat:
|
||||
return
|
||||
game = net.game
|
||||
_decide(game.players[seat])
|
||||
|
||||
|
||||
## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares 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
|
||||
|
||||
50
tests/perf_probe.gd
Normal file
50
tests/perf_probe.gd
Normal file
@@ -0,0 +1,50 @@
|
||||
extends SceneTree
|
||||
## Laufzeitmessung: Start, KI-Entscheidungen, Deckerzeugung, Vorschau, Musik.
|
||||
|
||||
|
||||
func ms(f: Callable, n: int = 1) -> float:
|
||||
var t0 := Time.get_ticks_usec()
|
||||
for i in n:
|
||||
f.call()
|
||||
return (Time.get_ticks_usec() - t0) / 1000.0 / n
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var t := ms(func(): Cards.all())
|
||||
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("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, "")))
|
||||
for n in [1, 3, 5]:
|
||||
var g := Game.new_battle(starter, n, r, {"target_rating": 1.5})
|
||||
# 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
|
||||
c.ready = true
|
||||
c.attacks_left = 1
|
||||
p.board.append(c)
|
||||
var h: Game.Player = g.players[0]
|
||||
h.energy = 10
|
||||
h.max_energy = 10
|
||||
for k in 6:
|
||||
h.hand.append(Cards.by_id("f_funke"))
|
||||
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("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))
|
||||
quit()
|
||||
1
tests/perf_probe.gd.uid
Normal file
1
tests/perf_probe.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://xrm3o6vloxh0
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
|
||||
# Führt Import, statische Prüfung und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
|
||||
set -euo pipefail
|
||||
GODOT="${1:-godot}"
|
||||
cd "$(dirname "$0")/.."
|
||||
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true
|
||||
"$GODOT" --headless --path . -s tests/lint.gd 2>&1 | grep -E "FEHLER|Skripte" || true
|
||||
"$GODOT" --headless --path . -s tests/lint.gd > /dev/null 2>&1
|
||||
"$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true
|
||||
grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log
|
||||
|
||||
41
tests/strength_probe.gd
Normal file
41
tests/strength_probe.gd
Normal file
@@ -0,0 +1,41 @@
|
||||
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
tests/strength_probe.gd.uid
Normal file
1
tests/strength_probe.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d4m21t7od3kdm
|
||||
@@ -55,7 +55,11 @@ func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature:
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg
|
||||
test_kartenpool()
|
||||
test_kampf_speichern()
|
||||
test_elementbooster_und_deckcode()
|
||||
test_englische_karten()
|
||||
test_kombos()
|
||||
test_wesen_spielen()
|
||||
test_flink()
|
||||
@@ -106,12 +110,18 @@ func _initialize() -> void:
|
||||
test_neue_fallen()
|
||||
test_kapitel_zwei_und_merkmalwahl()
|
||||
test_herausforderung_und_bestenliste()
|
||||
test_deckstaerke_und_schwierigkeit()
|
||||
test_starter_gegen_passende_gegner()
|
||||
test_kurve_statistik_vorschlag_verbesserung()
|
||||
test_wasser_antworten()
|
||||
test_ki_zeitbudget()
|
||||
test_uebersetzung_vollstaendig()
|
||||
print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed])
|
||||
quit(1 if failed > 0 else 0)
|
||||
|
||||
|
||||
func test_kartenpool() -> void:
|
||||
check(Cards.all().size() == 106, "106 Karten")
|
||||
check(Cards.all().size() == 118, "118 Karten")
|
||||
for key in Cards.COMBOS:
|
||||
var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key)
|
||||
check(found, "Kombo vorhanden: " + key)
|
||||
@@ -456,7 +466,7 @@ func test_spielstand() -> void:
|
||||
p.coins = 999
|
||||
p.save(path)
|
||||
var p2 := Profile.load_or_new(path)
|
||||
check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 39, "geladen")
|
||||
check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == Cards.of_rarity("gewöhnlich").size() + 2, "geladen")
|
||||
for suffix in ["", ".bak"]:
|
||||
if FileAccess.file_exists(path + suffix):
|
||||
DirAccess.remove_absolute(abs + suffix)
|
||||
@@ -508,7 +518,7 @@ func test_resonanz() -> void:
|
||||
put(h, "f_kobold")
|
||||
put(h, "f_kobold")
|
||||
g.update_resonance()
|
||||
check(h.board[0].power() == 7, "Resonanz höchstens +4")
|
||||
check(h.board[0].power() == 6 and Game.resonance_for(1) == 1 and Game.resonance_for(3) == 3 and Game.resonance_for(4) == 3, "Resonanz höchstens +3")
|
||||
for i in [1, 3, 4, 5, 6]:
|
||||
h.board[i].health = 0
|
||||
g.cleanup()
|
||||
@@ -527,15 +537,16 @@ func test_feldkombos() -> void:
|
||||
g.play_card(h, C("w_reiter"))
|
||||
check(strong.frozen and not weak.frozen, "Dampf friert das stärkste Wesen")
|
||||
check(g.log.any(func(l): return l.begins_with("Feldkombo Dampf")), "Feldkombo im Verlauf")
|
||||
# Lava: 1 Schaden an alle gegnerischen Wesen
|
||||
# Lava: 1 Schaden an das stärkste gegnerische Wesen
|
||||
s = setup(["e_golem"])
|
||||
g = s[0]
|
||||
h = s[1]
|
||||
o = s[2][0]
|
||||
put(h, "f_wolf")
|
||||
var k := put(o, "f_kobold")
|
||||
var big := put(o, "e_troll")
|
||||
g.play_card(h, C("e_golem"))
|
||||
check(not (k in o.board), "Lava tötet den 2/1 Kobold")
|
||||
check(k in o.board and big.health == 5, "Lava trifft nur das stärkste Wesen")
|
||||
# Blitz: 2 Schaden an den schwächsten Gegner
|
||||
s = setup(["l_wind"], [], 2)
|
||||
g = s[0]
|
||||
@@ -544,7 +555,7 @@ func test_feldkombos() -> void:
|
||||
s[2][1].life = 5
|
||||
g.play_card(h, C("l_wind"))
|
||||
check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner")
|
||||
# Schlamm heilt, Sturm zieht, Sand gibt Schild
|
||||
# Schlamm heilt, Sturm zieht, Sand gibt +1/+1
|
||||
s = setup(["e_wall", "l_wind", "w_reiter"])
|
||||
g = s[0]
|
||||
h = s[1]
|
||||
@@ -555,13 +566,13 @@ func test_feldkombos() -> void:
|
||||
var hand_before := h.hand.size()
|
||||
g.play_card(h, C("l_wind"))
|
||||
check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)")
|
||||
check(not h.board[2].shield, "nur eine Feldkombo je Ausspielen")
|
||||
check(h.board[2].attack == 1, "nur eine Feldkombo je Ausspielen")
|
||||
s = setup(["l_wind"])
|
||||
g = s[0]
|
||||
h = s[1]
|
||||
put(h, "e_wall")
|
||||
g.play_card(h, C("l_wind"))
|
||||
check(h.board[1].shield, "Sand: neues Wesen hat Schild")
|
||||
check(h.board[1].attack == 2 and h.board[1].health == 2, "Sand: neues Wesen erhält +1/+1")
|
||||
# Gleiche Elemente lösen keine Feldkombo aus
|
||||
s = setup(["f_kobold"])
|
||||
g = s[0]
|
||||
@@ -729,7 +740,7 @@ func test_fallen() -> void:
|
||||
g.end_turn()
|
||||
var a := put(o, "e_troll")
|
||||
check(g.attack(a, h) == "", "Angriff in die Feuerfalle")
|
||||
check(h.traps.is_empty() and a.health == 3 and h.life == 26, "Feuerfalle: 3 Schaden, Angriff geht durch")
|
||||
check(h.traps.is_empty() and a.health == 3 and h.life == 27, "Feuerfalle: 3 Schaden, Angriff geht durch")
|
||||
# Frostfalle bricht den Angriff ab
|
||||
s = setup(["w_falle"])
|
||||
g = s[0]
|
||||
@@ -1082,7 +1093,7 @@ func test_deck_umbenennen_und_auffuellen() -> void:
|
||||
|
||||
|
||||
func test_neue_fallen() -> void:
|
||||
check(Cards.all().filter(func(c): return c.is_trap()).size() == 10, "zehn Fallen")
|
||||
check(Cards.all().filter(func(c): return c.is_trap()).size() == 12, "zwölf Fallen")
|
||||
check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser")
|
||||
check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text")
|
||||
# Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft
|
||||
@@ -1105,7 +1116,7 @@ func test_neue_fallen() -> void:
|
||||
g.play_card(o, C("f_wolf"))
|
||||
check(o.board.is_empty(), "Aschefalle tötet den 3/2 Wolf beim Ausspielen")
|
||||
# Sandfalle friert das ausgespielte Wesen ein, Spiegelfalle beschwört einen Windgeist
|
||||
s = setup(["e_sandfalle", "l_spiegel"], ["e_troll"])
|
||||
s = setup(["e_sandfalle", "l_spiegel"], ["e_troll", "w_reiter"])
|
||||
g = s[0]
|
||||
h = s[1]
|
||||
o = s[2][0]
|
||||
@@ -1113,8 +1124,9 @@ func test_neue_fallen() -> void:
|
||||
g.play_card(h, C("l_spiegel"))
|
||||
g.end_turn()
|
||||
g.play_card(o, C("e_troll"))
|
||||
check(o.board.size() == 1 and o.board[0].frozen, "Sandfalle friert ein")
|
||||
check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist")
|
||||
check(o.board.size() == 1 and o.board[0].frozen and h.traps.size() == 1, "Sandfalle friert ein, eine Falle je Auslöser")
|
||||
g.play_card(o, C("w_reiter")) # Wasser neben Erde: Schlamm heilt nur
|
||||
check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist beim zweiten Wesen")
|
||||
check(h.traps.is_empty(), "beide Fallen verbraucht")
|
||||
# Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt
|
||||
s = setup(["w_nebelfalle", "e_dornen"])
|
||||
@@ -1136,7 +1148,11 @@ func test_neue_fallen() -> void:
|
||||
|
||||
|
||||
func test_kapitel_zwei_und_merkmalwahl() -> void:
|
||||
check(Campaign.total() == 20 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2, "zwanzig Stufen in zwei Kapiteln")
|
||||
check(Campaign.total() == 30 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(29) == 3, "dreißig Stufen in drei Kapiteln")
|
||||
check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx" and Campaign.target_rating(29, 1.0) == 4.4, "Kapitel 3 mit drei Merkmalen und höherer Zielstärke")
|
||||
for st in Campaign.STAGES3:
|
||||
for pk in st.perks:
|
||||
check(Game.PERKS.has(pk), "Merkmal %s bekannt" % pk)
|
||||
for i in Campaign.total():
|
||||
var st := Campaign.stage(i)
|
||||
check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1))
|
||||
@@ -1196,3 +1212,268 @@ func test_herausforderung_und_bestenliste() -> void:
|
||||
check(text.begins_with("karte;seltenheit;gespielt;partien;siege") and "beste_serie;5" in text, "CSV-Inhalt")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
Profile.today_override = ""
|
||||
|
||||
|
||||
func test_deckstaerke_und_schwierigkeit() -> void:
|
||||
check(is_equal_approx(Cards.card_value(C("f_kobold")), 1.0) and is_equal_approx(Cards.card_value(C("ff_phoenix")), 4.6), "Kartenwert aus Seltenheit, Schlüsselwörtern, Effekten")
|
||||
var starter := Profile.starter().deck_cards()
|
||||
var rs := Game.deck_rating(starter)
|
||||
check(rs > 1.2 and rs < 1.5, "Starter mit zwei seltenen Karten bei etwa 1,3 (%.2f)" % rs)
|
||||
check(Profile.starter().deck_valid()[0] and starter.any(func(c): return c.id == "f_drache") and starter.any(func(c): return c.id == "e_baum"), "Feuerdrache und Uralter Baum im Starterdeck")
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 5
|
||||
for target in [1.2, 1.6, 2.2, 2.6]:
|
||||
for elems in [["Feuer"], ["Wasser", "Luft"]]:
|
||||
var d := Game.build_opponent_deck_rated(r, elems, target)
|
||||
check(Game.validate_deck(d)[0] and absf(Game.deck_rating(d) - target) <= 0.25, "Zielstärke %.1f für %s erreicht (%.2f)" % [target, "+".join(elems), Game.deck_rating(d)])
|
||||
var strong := Game.build_opponent_deck_rated(r, ["Feuer", "Luft"], 3.0)
|
||||
check(Game.deck_rating(strong) >= 2.7, "Zielstärke 3,0 mit zwei Elementen nahezu erreichbar (%.2f)" % Game.deck_rating(strong))
|
||||
var g := Game.new_battle(starter, 2, r, {"target_rating": 1.3})
|
||||
for o in g.opponents:
|
||||
check(absf(Game.deck_rating(o.deck + o.hand) - 1.3) <= 0.3, "Gegner auf Zielstärke (%.2f)" % Game.deck_rating(o.deck + o.hand))
|
||||
check(is_equal_approx(Campaign.target_rating(0, 1.3), 1.3) and is_equal_approx(Campaign.target_rating(0, 2.5), 2.5) and is_equal_approx(Campaign.target_rating(9, 1.3), 2.6), "Kampagne: Stufe oder eigene Stärke, was höher ist")
|
||||
var opps := Campaign.opponents(2, r, 1.3)
|
||||
check(opps.size() == 2 and absf(Game.deck_rating(opps[0].deck) - 1.5) <= 0.25 and absf(Game.deck_rating(opps[1].deck) - 1.5) <= 0.3, "Borin und Gefolge auf 1,5")
|
||||
var p := Profile.starter()
|
||||
check(p.difficulty().name == "Normal" and is_equal_approx(p.target_rating(), rs), "Normal: Zielstärke gleich eigener Stärke")
|
||||
p.settings.difficulty = "schwer"
|
||||
check(is_equal_approx(p.target_rating(), rs * 1.2) and p.difficulty().smart, "Schwer: 1,2-fach mit Vorausschau-KI")
|
||||
p.settings.difficulty = "leicht"
|
||||
check(not p.difficulty().smart and is_equal_approx(p.target_rating(), rs * 0.9), "Leicht: einfache KI, 0,9-fach")
|
||||
var reward := p.record_battle(true, 1, starter, -1, [], 5, {}, p.difficulty().reward)
|
||||
check(reward == int(round(Profile.REWARD_WIN[1] * 0.75)), "Leicht: weniger Münzen")
|
||||
var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict())))
|
||||
check(v.settings.difficulty == "leicht", "Schwierigkeit gespeichert")
|
||||
v.settings.difficulty = "unsinn"
|
||||
check(v.difficulty().name == "Normal", "unbekannte Schwierigkeit fällt auf Normal zurück")
|
||||
|
||||
|
||||
func test_starter_gegen_passende_gegner() -> void:
|
||||
check(Game.archetypes("leicht").size() == 5 and Game.archetypes("schwer").size() == 5 and Game.archetypes().size() == 10, "Archetypen geteilt")
|
||||
var table: Dictionary = DeckGen.strength()
|
||||
var keys: Array = table.keys()
|
||||
keys.sort_custom(func(a, b): return table[a] < table[b])
|
||||
check(keys[0] in Game.archetypes("leicht") and keys[-1] in Game.archetypes("schwer"), "schwächster Archetyp in Leicht, stärkster in Schwer")
|
||||
check(Game.archetypes("leicht").all(func(k): return table[k] <= table[Game.archetypes("schwer")[0]]), "Hälften nach Stärke getrennt")
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 6
|
||||
var starter := Profile.starter().deck_cards()
|
||||
var rs := Game.deck_rating(starter)
|
||||
var results := {}
|
||||
for key in ["leicht", "schwer"]:
|
||||
var d: Dictionary = Profile.DIFFICULTIES[key]
|
||||
var wins := 0
|
||||
var games := 24
|
||||
for i in games:
|
||||
var g := Game.new_battle(starter, 1, r, {"target_rating": rs * float(d.factor), "tier": d.tier})
|
||||
var arch := Cards.combo_key(Game.deck_elements(g.opponents[0].deck + g.opponents[0].hand))
|
||||
check(arch in Game.archetypes(d.tier), "Archetyp passt zur Stufe (%s)" % arch)
|
||||
var guard := 0
|
||||
while g.winner == "" and guard < 300:
|
||||
var p := g.current()
|
||||
AI.take_turn(g, p, (not d.smart) and not p.is_human)
|
||||
guard += 1
|
||||
if g.winner == "human":
|
||||
wins += 1
|
||||
results[key] = wins
|
||||
check(results.leicht > results.schwer, "Leicht ist leichter als Schwer (%d zu %d von 24)" % [results.leicht, results.schwer])
|
||||
check(results.leicht >= 16, "Leicht: Starter gewinnt meistens (%d von 24)" % results.leicht)
|
||||
|
||||
|
||||
func test_kurve_statistik_vorschlag_verbesserung() -> void:
|
||||
var starter := Profile.starter().deck_cards()
|
||||
check(is_zero_approx(DeckGen.curve_penalty(starter)), "Starterkurve ohne Abzug")
|
||||
var teuer := []
|
||||
for i in 20:
|
||||
teuer.append(Cards.by_id("f_zunge"))
|
||||
check(DeckGen.curve_penalty(teuer) >= 0.4 - 0.001 and Game.deck_rating(teuer) < 1.0, "teures Deck: Kurvenabzug")
|
||||
var stats := {"f_kobold": {"played": 30, "games": 20, "wins": 16}}
|
||||
check(DeckGen.card_value(Cards.by_id("f_kobold"), stats) > Cards.card_value(Cards.by_id("f_kobold")), "gute Statistik hebt den Wert")
|
||||
check(Game.deck_rating(starter, stats) > Game.deck_rating(starter), "Deckstärke mit Statistik")
|
||||
var few := {"f_kobold": {"played": 3, "games": 3, "wins": 3}}
|
||||
check(is_equal_approx(DeckGen.card_value(Cards.by_id("f_kobold"), few), 1.0), "zu wenig Partien: Basiswert")
|
||||
# Vorschlag nach Serien
|
||||
var p := Profile.starter()
|
||||
var mono := starter
|
||||
check(p.suggest_difficulty() == "", "kein Vorschlag am Anfang")
|
||||
for i in 3:
|
||||
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 4)
|
||||
check(p.suggest_difficulty() == "", "drei knappe Siege: noch kein Vorschlag")
|
||||
p.records.streak = 0
|
||||
p.records.margins = []
|
||||
for i in 3:
|
||||
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 20)
|
||||
check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei deutliche Siege: Schwer vorschlagen")
|
||||
p.records.streak = 0
|
||||
p.records.margins = []
|
||||
for i in 5:
|
||||
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 2)
|
||||
check(p.suggest_difficulty() == "schwer" and p.records.margins.size() == 5, "fünf Siege egal wie knapp")
|
||||
p.settings.difficulty = "schwer"
|
||||
p.take_notices()
|
||||
for i in 3:
|
||||
p.record_battle(false, 1, mono)
|
||||
check(p.records.loss_streak == 3 and p.suggest_difficulty() == "normal", "drei Niederlagen: Normal vorschlagen")
|
||||
p.settings.difficulty = "leicht"
|
||||
check(p.suggest_difficulty() == "", "auf Leicht kein Abstieg")
|
||||
# Deck verbessern
|
||||
var q := Profile.starter()
|
||||
check(q.improve_deck_with("ff_phoenix", true) != "", "nicht besessen")
|
||||
q.collection["ff_phoenix"] = 1
|
||||
check(q.improve_deck_with("ff_phoenix", true) == "", "Phönix passt (Feuer)")
|
||||
q.collection["ww_leviathan"] = 1
|
||||
check(q.improve_deck_with("ww_leviathan", true) != "", "Leviathan passt nicht (drittes Element)")
|
||||
check(q.deck_suggestions(["ff_phoenix", "ww_leviathan", "f_kobold"]) == ["ff_phoenix"], "Vorschläge gefiltert")
|
||||
var before := q.deck.duplicate()
|
||||
check(q.improve_deck_with("ff_phoenix") == "" and q.deck.count("ff_phoenix") == 1 and q.deck.size() == 20 and q.deck_valid()[0], "Phönix eingetauscht")
|
||||
check(Game.deck_rating(q.deck_cards()) > Game.deck_rating(before.map(func(id): return Cards.by_id(id))), "Deck stärker")
|
||||
check(q.improve_deck_with("ff_phoenix", true) != "", "zweite Kopie nicht möglich")
|
||||
|
||||
|
||||
func test_wasser_antworten() -> void:
|
||||
var s := setup(["w_ebbe", "w_tiefenstoss", "w_strudel"])
|
||||
var g: Game = s[0]
|
||||
var h: Game.Player = s[1]
|
||||
var o: Game.Player = s[2][0]
|
||||
var t := put(o, "e_troll")
|
||||
g.play_card(h, C("w_ebbe"), t)
|
||||
check(t.health == 5 and t.frozen, "Ebbe: 1 Schaden und Eis")
|
||||
g.play_card(h, C("w_tiefenstoss"), t)
|
||||
check(t.health == 2, "Tiefenstoß: 3 Schaden")
|
||||
var hand := h.hand.size()
|
||||
g.play_card(h, C("w_strudel"), t)
|
||||
check(o.board.is_empty() and C("e_troll") in o.hand and h.hand.size() == hand, "Strudel: zurück auf die Hand, eine Karte gezogen")
|
||||
check(C("w_ebbe").rarity == "gewöhnlich" and Profile.starter().collection.has("w_ebbe"), "Ebbe in der Startsammlung")
|
||||
|
||||
|
||||
func test_ki_zeitbudget() -> void:
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 12
|
||||
var g := Game.new_battle(Profile.starter().deck_cards(), 5, r)
|
||||
var h: Game.Player = g.players[0]
|
||||
h.mulligan_done = true
|
||||
h.energy = 10
|
||||
h.max_energy = 10
|
||||
for i in 8:
|
||||
h.hand.append(Cards.by_id("f_funke"))
|
||||
for p in g.players:
|
||||
for i in 3:
|
||||
put(p, "e_troll")
|
||||
AI.time_budget_ms = 1
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var act := AI.choose_action(g, h)
|
||||
var ms := Time.get_ticks_msec() - t0
|
||||
AI.time_budget_ms = 150
|
||||
check(act.type in ["play", "attack", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms)
|
||||
check(Cards.by_id("wl_gischt").keywords == ["Flink"] and Cards.by_id("wl_gischt").combo() == "Sturm", "Gischtläufer für Sturm")
|
||||
var v := Profile.from_dict(JSON.parse_string(JSON.stringify(Profile.starter().to_dict())))
|
||||
check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern")
|
||||
|
||||
|
||||
## Elementbooster enthalten nur Karten des Elements; Deck-Codes sind umkehrbar und robust.
|
||||
## Englisch: jede Karte hat einen englischen Namen, Kartentexte und Schlüsselwörter werden übersetzt.
|
||||
func test_englische_karten() -> void:
|
||||
check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen")
|
||||
var de: String = Cards.by_id("f_funke").text()
|
||||
TranslationServer.set_locale("en")
|
||||
var funke := Cards.by_id("f_funke")
|
||||
var falle := Cards.by_id("w_falle")
|
||||
var wall := Cards.by_id("e_wall")
|
||||
check(funke.title() == "Spark" and funke.text() == "2 damage to a target.", "Funke auf Englisch: %s / %s" % [funke.title(), funke.text()])
|
||||
check(falle.text().begins_with("Trap. When an enemy creature attacks") and "The attack fizzles" in falle.text(), "Fallentext auf Englisch: %s" % falle.text())
|
||||
check(wall.text() == "Guardian." and Cards.by_id("l_spiegel").text().ends_with("Summon a Wind Spirit."), "Schlüsselwort und Beschwörung auf Englisch")
|
||||
TranslationServer.set_locale("de")
|
||||
check(funke.title() == "Funke" and funke.text() == de, "zurück auf Deutsch")
|
||||
|
||||
|
||||
func test_elementbooster_und_deckcode() -> void:
|
||||
var p := Profile.starter()
|
||||
p.coins = 500
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 5
|
||||
var got: Array = p.open_booster(r, "Wasser")
|
||||
check(got.size() == 5 and got.all(func(c): return "Wasser" in c.elements) and p.coins == 500 - Profile.ELEMENT_BOOSTER_COST, "Wasser-Booster: fünf Wasserkarten, %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
|
||||
p.coins = 100
|
||||
check(p.open_booster(r, "Feuer").is_empty() and p.coins == 100, "Elementbooster braucht %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
|
||||
for e in Cards.ELEMENTS:
|
||||
for rar in Profile.RARITY_WEIGHTS:
|
||||
check(not Profile.booster_pool(rar, e).is_empty(), "%s hat %s Karten" % [e, rar])
|
||||
var deck: Array = Profile.starter().deck
|
||||
var code := Profile.deck_code(deck)
|
||||
check(code.begins_with(Profile.CODE_PREFIX) and Profile.parse_deck_code(code).size() == 20, "Deck-Code hin und zurück")
|
||||
var back: Array = Profile.parse_deck_code(code)
|
||||
back.sort()
|
||||
var orig: Array = deck.duplicate()
|
||||
orig.sort()
|
||||
check(back == orig, "Deck-Code enthält genau die Karten")
|
||||
check(Profile.parse_deck_code("kaputt").is_empty() and Profile.parse_deck_code("DINGER1:x_nix*2").is_empty()
|
||||
and Profile.parse_deck_code("DINGER1:f_funke*9").size() == Game.MAX_COPIES, "ungültige Codes leer, Kopien gedeckelt")
|
||||
check(Cards.by_id("w_flutfalle").is_trap() and Cards.by_id("fl_sturmfalke").combo() == "Blitz" and Cards.by_id("el_sandsturm").is_combo(), "neue Karten im Pool")
|
||||
|
||||
|
||||
## Voller Spielstand (viewer -1) stellt Hände, Decks und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes.
|
||||
func test_kampf_speichern() -> void:
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 7
|
||||
var g := Game.new_battle(Profile.starter().deck_cards(), 2, r)
|
||||
g.start()
|
||||
AI.take_turn(g, g.current())
|
||||
var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1)))
|
||||
var g2 := Game.from_snapshot(d)
|
||||
var same := g2.players.size() == g.players.size()
|
||||
for i in g.players.size():
|
||||
var a: Game.Player = g.players[i]
|
||||
var b: Game.Player = g2.players[i]
|
||||
var ids := func(cs: Array): return cs.map(func(c): return c.id)
|
||||
same = same and ids.call(a.hand) == ids.call(b.hand) and ids.call(a.deck) == ids.call(b.deck) \
|
||||
and a.life == b.life and a.board.size() == b.board.size() and a.is_human == b.is_human
|
||||
check(same and g2.current_idx == g.current_idx and g2.turn == g.turn and g2.human.is_human, "voller Spielstand: Hände, Decks und Bretter erhalten")
|
||||
var guard := 0
|
||||
while g2.winner == "" and guard < 300:
|
||||
AI.take_turn(g2, g2.current())
|
||||
guard += 1
|
||||
check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar")
|
||||
var s2: Dictionary = g.snapshot(-2)
|
||||
check(s2.players.all(func(p): return not (p.hand is Array) and not (p.traps is Array) and not (p.deck is Array)), "Zuschauer-Sicht ohne Hände, Decks und Fallen")
|
||||
|
||||
|
||||
## Jeder tr("...")-Text in der Oberfläche und jeder Anleitungsschritt hat einen englischen Eintrag.
|
||||
func test_uebersetzung_vollstaendig() -> void:
|
||||
var keys := {}
|
||||
var csv := FileAccess.get_file_as_string("res://translations/ui.csv")
|
||||
for line in csv.split("\n"):
|
||||
if line.is_empty() or line.begins_with("keys,"):
|
||||
continue
|
||||
var key := line
|
||||
if line.begins_with("\""):
|
||||
key = line.substr(1, line.find("\",", 1) - 1)
|
||||
else:
|
||||
key = line.substr(0, line.find(","))
|
||||
keys[key] = true
|
||||
var missing := []
|
||||
for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + ["Wesen", "Zauber", "Falle"] + Cards.TRIGGER_TEXT.values():
|
||||
if not keys.has(k):
|
||||
missing.append(k)
|
||||
var d := DirAccess.open("res://scripts/ui")
|
||||
d.list_dir_begin()
|
||||
var name := d.get_next()
|
||||
while name != "":
|
||||
if name.ends_with(".gd"):
|
||||
var src := FileAccess.get_file_as_string("res://scripts/ui/" + name)
|
||||
var rx := RegEx.new()
|
||||
rx.compile("tr\\(\"([^\"]+)\"\\)")
|
||||
for m in rx.search_all(src):
|
||||
var k: String = m.get_string(1)
|
||||
if not keys.has(k) and not (k in missing):
|
||||
missing.append(k)
|
||||
name = d.get_next()
|
||||
var rx_cards := RegEx.new()
|
||||
rx_cards.compile("tr\\(\"([^\"]+)\"\\)")
|
||||
for m in rx_cards.search_all(FileAccess.get_file_as_string("res://scripts/cards.gd")):
|
||||
if not keys.has(m.get_string(1)) and not (m.get_string(1) in missing):
|
||||
missing.append(m.get_string(1))
|
||||
for stepset in [BattleTutorial.STEPS1, BattleTutorial.STEPS2]:
|
||||
for st in stepset:
|
||||
if not keys.has(st[0]):
|
||||
missing.append(st[0])
|
||||
check(missing.is_empty(), "fehlende Übersetzungen: %s" % [missing])
|
||||
|
||||
535
tests/ui_test.gd
535
tests/ui_test.gd
@@ -74,6 +74,14 @@ func key(code: int) -> void:
|
||||
await frames(2)
|
||||
|
||||
|
||||
func pad(button: int) -> void:
|
||||
var ev := InputEventJoypadButton.new()
|
||||
ev.button_index = button
|
||||
ev.pressed = true
|
||||
get_viewport().push_input(ev)
|
||||
await frames(2)
|
||||
|
||||
|
||||
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
|
||||
func clear_mulligan(scr) -> void:
|
||||
if scr.mulligan_box.visible:
|
||||
@@ -149,13 +157,23 @@ func _ready() -> void:
|
||||
for suffix in ["", ".bak", ".corrupt"]:
|
||||
if FileAccess.file_exists(SAVE + suffix):
|
||||
DirAccess.remove_absolute(abs + suffix)
|
||||
if FileAccess.file_exists(SAVE.get_basename() + "_kampf.json"):
|
||||
DirAccess.remove_absolute(abs.get_basename() + "_kampf.json")
|
||||
main = load("res://scenes/main.tscn").instantiate()
|
||||
main.save_path = SAVE
|
||||
add_child(main)
|
||||
await frames(3)
|
||||
for t in [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
|
||||
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_hotseat,
|
||||
test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]:
|
||||
var fast := OS.get_environment("DINGER_FAST") != ""
|
||||
var suite := [test_menu_und_regeln, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
|
||||
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung,
|
||||
test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster]
|
||||
if not fast:
|
||||
suite += [test_hotseat, test_netzwerk, test_host_wechsel, test_zuschauer, test_kampf.bind(2, 3), test_kampf_abbrechen]
|
||||
suite.append(test_profil_zuruecksetzen)
|
||||
var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer
|
||||
if only != "":
|
||||
suite = suite.filter(func(t: Callable): return t.get_method() in only.split(","))
|
||||
for t in suite:
|
||||
await ensure_menu()
|
||||
await t.call()
|
||||
for suffix in ["", ".bak", ".corrupt"]:
|
||||
@@ -196,6 +214,13 @@ 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")
|
||||
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ü")
|
||||
@@ -206,6 +231,7 @@ func test_deckbau() -> void:
|
||||
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")
|
||||
@@ -279,7 +305,14 @@ func test_deckbau() -> void:
|
||||
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")
|
||||
|
||||
|
||||
@@ -345,8 +378,43 @@ func start_battle(n_opp: int) -> void:
|
||||
|
||||
func test_gegnerauswahl() -> void:
|
||||
await click(find_button("Einzelspieler"))
|
||||
check(screen() is OpponentSelectScreen, "Gegnerauswahl geöffnet")
|
||||
var sel = screen()
|
||||
check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet")
|
||||
check("Deckstärke" in sel.rating_label.text, "Deckstärke angezeigt")
|
||||
check(sel.preview_labels[3].text.count("(") == 3 and sel.previews[3].size() == 3, "Vorschau zeigt drei Gegner mit Stärke")
|
||||
for i in 3:
|
||||
await click(sel.b_reroll)
|
||||
check(sel.rerolls_left == 0 and sel.b_reroll.disabled, "höchstens dreimal neu würfeln")
|
||||
await click(find_button("Schwer"))
|
||||
check(main.profile.settings.difficulty == "schwer" and Profile.load_or_new(SAVE).settings.difficulty == "schwer", "Schwer gewählt und gespeichert")
|
||||
var previewed: Array = sel.previews[1].map(func(o): return o.title)
|
||||
await shot("07_gegnerauswahl")
|
||||
await click(find_button("1 Gegner"))
|
||||
var hard = screen()
|
||||
check(hard.game.opponents.map(func(o): return o.title) == previewed, "Kampf nutzt die vorgeschauten Gegner")
|
||||
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])
|
||||
await give_up()
|
||||
await click(find_button("Einzelspieler"))
|
||||
await click(find_button("Leicht"))
|
||||
await click(find_button("1 Gegner"))
|
||||
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")
|
||||
await give_up()
|
||||
await click(find_button("Einzelspieler"))
|
||||
await click(find_button("Normal"))
|
||||
main.profile.records.streak = 3
|
||||
main.profile.records.margins = [20, 22, 18]
|
||||
screen().refresh()
|
||||
check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei deutlichen Siegen")
|
||||
await click(find_button("Übernehmen"))
|
||||
check(main.profile.settings.difficulty == "schwer", "Vorschlag übernommen")
|
||||
main.profile.records.streak = 0
|
||||
main.profile.records.margins = []
|
||||
await click(find_button("Normal"))
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
for n in [1, 5]:
|
||||
@@ -358,6 +426,17 @@ func test_gegnerauswahl() -> void:
|
||||
await give_up()
|
||||
|
||||
|
||||
## Kampf verlassen: nach Spielende über den Ergebnisdialog, sonst per Aufgeben.
|
||||
func leave_battle() -> void:
|
||||
var scr = screen()
|
||||
if scr is BattleScreen and scr.game != null and scr.game.winner != "":
|
||||
await frames(2)
|
||||
await click(find_button("Zurück zum Menü"))
|
||||
check(screen() is MenuScreen, "Ergebnisdialog führt ins Menü")
|
||||
else:
|
||||
await give_up()
|
||||
|
||||
|
||||
func give_up() -> void:
|
||||
var scr = screen()
|
||||
await clear_mulligan(scr)
|
||||
@@ -370,6 +449,97 @@ 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)
|
||||
var scr = screen()
|
||||
scr.ai_delay = 0.0
|
||||
await wait_for_human(scr)
|
||||
var kampf: String = main.battle_save_path()
|
||||
check(FileAccess.file_exists(kampf), "Kampf wird laufend gespeichert")
|
||||
await click(scr.b_end)
|
||||
await wait_for_human(scr)
|
||||
var turn: int = scr.game.turn
|
||||
var life: int = scr.game.human.life
|
||||
var hand: int = scr.game.human.hand.size()
|
||||
await click(find_button("Aufgeben"))
|
||||
await click(find_button("Speichern und zurück"))
|
||||
check(screen() is MenuScreen and find_button("Kampf fortsetzen") != null, "Speichern führt ins Menü mit Fortsetzen-Knopf")
|
||||
await click(find_button("Kampf fortsetzen"))
|
||||
var scr2 = screen()
|
||||
check(scr2 is BattleScreen and scr2.game.turn == turn and scr2.game.human.life == life and scr2.game.human.hand.size() == hand
|
||||
and scr2.game.current() == scr2.game.human, "Kampf fortgesetzt mit gleichem Zug, Leben und Hand")
|
||||
scr2.ai_delay = 0.0
|
||||
await shot("30_kampf_fortgesetzt")
|
||||
await give_up()
|
||||
check(not FileAccess.file_exists(kampf) and find_button("Kampf fortsetzen") == null, "Aufgeben löscht den gespeicherten Kampf")
|
||||
|
||||
|
||||
func test_einstellungen() -> void:
|
||||
await click(find_button("Einstellungen"))
|
||||
var scr = screen()
|
||||
@@ -382,7 +552,15 @@ func test_einstellungen() -> void:
|
||||
check(main.profile.settings.animations == false, "Animationen abschaltbar")
|
||||
check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte")
|
||||
scr.anim.button_pressed = true
|
||||
scr.big_font.button_pressed = true
|
||||
await frames(2)
|
||||
check(main.profile.settings.big_font and Style.font_scale > 1.0 and screen() is SettingsScreen, "große Schrift aktiv")
|
||||
screen().big_font.button_pressed = false
|
||||
await frames(2)
|
||||
check(is_equal_approx(Style.font_scale, 1.0), "normale Schrift")
|
||||
var scr_b = screen()
|
||||
await shot("13_einstellungen")
|
||||
scr = scr_b
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
@@ -390,6 +568,11 @@ func test_einstellungen() -> void:
|
||||
func test_erfolge() -> void:
|
||||
await click(find_button("Erfolge"))
|
||||
check(screen() is AchievementsScreen, "Erfolge geöffnet")
|
||||
var csv := "user://statistik.csv"
|
||||
if FileAccess.file_exists(csv):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(csv))
|
||||
await click(find_button("Statistik exportieren"))
|
||||
check(FileAccess.file_exists(csv) and "Gespeichert" in screen().export_label.text, "Statistik exportiert")
|
||||
await shot("14_erfolge")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
@@ -427,6 +610,103 @@ func test_werkstatt() -> void:
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_gamepad_menue() -> void:
|
||||
check(get_viewport().gui_get_focus_owner() == null, "ohne Gamepad kein Fokus")
|
||||
await pad(JOY_BUTTON_DPAD_DOWN)
|
||||
var f := get_viewport().gui_get_focus_owner()
|
||||
check(f is Button and main.pad_mode, "Gamepad setzt den Fokus auf einen Knopf")
|
||||
var down := InputEventJoypadButton.new()
|
||||
down.button_index = JOY_BUTTON_DPAD_DOWN
|
||||
down.pressed = true
|
||||
Input.parse_input_event(down)
|
||||
await frames(2)
|
||||
var f2 := get_viewport().gui_get_focus_owner()
|
||||
check(f2 is Button and f2 != f, "Steuerkreuz wechselt den Knopf")
|
||||
var target_text: String = f2.text
|
||||
check(InputMap.action_get_events("ui_accept").any(func(e): return e is InputEventJoypadButton), "A ist auf ui_accept gemappt")
|
||||
var accept := InputEventJoypadButton.new()
|
||||
accept.button_index = JOY_BUTTON_A
|
||||
accept.pressed = true
|
||||
get_viewport().push_input(accept)
|
||||
await frames(1)
|
||||
var release := InputEventJoypadButton.new()
|
||||
release.button_index = JOY_BUTTON_A
|
||||
release.pressed = false
|
||||
get_viewport().push_input(release)
|
||||
await frames(3)
|
||||
check(not (screen() is MenuScreen) or target_text == "Beenden", "A öffnet den fokussierten Bildschirm (%s)" % target_text)
|
||||
await hover(main)
|
||||
check(not main.pad_mode and get_viewport().gui_get_focus_owner() == null, "Maus nimmt den Fokus wieder")
|
||||
|
||||
|
||||
func test_anleitung2() -> void:
|
||||
check(main.profile.tutorial_done and not main.profile.tutorial2_done, "Teil 1 erledigt, Teil 2 offen")
|
||||
check(find_button("Anleitung 2: Fallen") != null, "Menü bietet Teil 2 an")
|
||||
await click(find_button("Anleitung 2: Fallen"))
|
||||
var scr = screen()
|
||||
check(scr is BattleScreen and scr.tutorial and scr.tutorial_part == 2, "Anleitung 2 gestartet")
|
||||
scr.ai_delay = 0.0
|
||||
var g: Game = scr.game
|
||||
check(g.opponents[0].name == "Mara" and g.human.hand.any(func(c): return c.id == "w_falle") and g.human.hand.any(func(c): return c.id == "w_reiter"), "Mara, Frostfalle und Wellenreiter auf der Hand")
|
||||
await frames(2)
|
||||
check(scr.mulligan_box.visible and scr.tutorial_step == 0, "Schritt 1: Mulligan-Maske")
|
||||
await click(scr.b_keep)
|
||||
check(scr.tutorial_step == 1 and "Frostfalle" in scr.tutorial_text.text, "Schritt 2: Falle legen")
|
||||
await shot("29_anleitung2")
|
||||
# Zug 1 hat nur 1 Energie: Zug beenden, dann Falle legen
|
||||
await key(KEY_SPACE)
|
||||
await wait_for_human(scr)
|
||||
check(scr.highlight_layer.get_child_count() >= 1, "Falle hervorgehoben")
|
||||
var idx: int = g.human.hand.map(func(c): return c.id).find("w_falle")
|
||||
check(idx >= 0 and idx < 9, "Falle auf der Hand")
|
||||
await key(KEY_1 + idx)
|
||||
check(g.human.traps.size() == 1 and scr.tutorial_step == 2, "Falle gelegt, Schritt 3")
|
||||
# Mara läuft in die Falle: zwei Züge abwarten
|
||||
for i in 2:
|
||||
await clear_mulligan(scr)
|
||||
if g.current() == g.human and g.winner == "":
|
||||
await key(KEY_SPACE)
|
||||
await wait_for_human(scr)
|
||||
if scr.tutorial_step >= 3:
|
||||
break
|
||||
check(scr.tutorial_step >= 3 and g.log.any(func(l): return l.begins_with("Falle von Du")), "Mara löst die Frostfalle aus, Schritt 4")
|
||||
await give_up()
|
||||
check(main.profile.tutorial2_done and find_button("Anleitung") != null, "Teil 2 erledigt")
|
||||
|
||||
|
||||
func test_profil_zuruecksetzen() -> void:
|
||||
await click(find_button("Einstellungen"))
|
||||
await click(find_button("Profil zurücksetzen"))
|
||||
var scr = screen()
|
||||
check(scr.confirm.visible, "Rückfrage")
|
||||
await click(find_button("Abbrechen"))
|
||||
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 not main.profile.tutorial_done, "Profil zurückgesetzt")
|
||||
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
|
||||
|
||||
|
||||
func test_herausforderung() -> void:
|
||||
await click(find_button("Herausforderung"))
|
||||
var scr = screen()
|
||||
check(scr is ChallengeScreen, "Herausforderung geöffnet")
|
||||
var ch := Challenge.current()
|
||||
var allowed := Challenge.deck_allowed(ch, main.profile.deck_cards()) == ""
|
||||
check(find_button("Antreten").disabled == not allowed, "Antreten je nach Deck")
|
||||
await shot("28_herausforderung")
|
||||
if allowed:
|
||||
await click(find_button("Antreten"))
|
||||
var b = screen()
|
||||
check(b is BattleScreen and b.challenge == ch and b.game.opponents.size() == ch.n_opp, "Herausforderungskampf")
|
||||
var everyone: String = ch.rules.get("everyone_perk", "")
|
||||
check(everyone == "" or b.game.players.all(func(p): return everyone in p.perks), "Regel gilt für alle")
|
||||
await give_up()
|
||||
else:
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_hotseat() -> void:
|
||||
await click(find_button("Lokal zu mehreren"))
|
||||
var scr = screen()
|
||||
@@ -458,7 +738,7 @@ func test_hotseat() -> void:
|
||||
await clear_mulligan(b)
|
||||
check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch")
|
||||
await shot("19_hotseat_kampf")
|
||||
await give_up()
|
||||
await leave_battle()
|
||||
|
||||
|
||||
func test_sprache() -> void:
|
||||
@@ -469,10 +749,12 @@ 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(find_button("Back") != null, "Knöpfe übersetzt")
|
||||
await shot("26_englisch")
|
||||
await click(find_button("Back"))
|
||||
check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch")
|
||||
check(screen().coins_label.text.begins_with("Coins:"), "zusammengesetzte Texte übersetzt")
|
||||
await click(find_button("Settings"))
|
||||
var scr2 = screen()
|
||||
scr2.language.select(0)
|
||||
@@ -499,25 +781,61 @@ func test_anleitung() -> void:
|
||||
await shot("22_anleitung_mulligan")
|
||||
await click(scr.b_swap)
|
||||
check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht")
|
||||
check(g.log.any(func(l): return "tauscht 1 Karten" in l), "Tausch im Verlauf")
|
||||
check(g.log.any(func(l): return "tauscht 1 Karte" in l), "Tausch im Verlauf")
|
||||
check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar")
|
||||
var idx := -1
|
||||
for i in g.human.hand.size():
|
||||
if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1:
|
||||
idx = i
|
||||
break
|
||||
check(idx < 0 or scr.highlight_layer.get_child_count() >= 1, "spielbare Wesen hervorgehoben")
|
||||
if idx >= 0:
|
||||
var played_id: String = g.human.hand[idx].id
|
||||
await key(KEY_1 + idx)
|
||||
check(g.human.board.size() == 1, "Wesen per Taste gespielt")
|
||||
check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen")
|
||||
check(scr.highlight_layer.get_child_count() == 1 and scr.highlight_layer.get_child(0).get_rect().intersects(scr.b_end.get_rect()), "Zug-beenden-Knopf hervorgehoben")
|
||||
# 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.is_empty() and g.human.hand.any(func(c): return c.id == played_id) and g.human.energy == energy + 1, "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() == 1, "erneut gespielt")
|
||||
await shot("22_anleitung")
|
||||
# Gamepad: Zeiger bewegen, Karte spielen
|
||||
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_before: int = g.human.board.size()
|
||||
await pad(JOY_BUTTON_A)
|
||||
check(g.human.board.size() == board_before + 1, "Gamepad A spielt das Wesen")
|
||||
scr.ai_delay = 0.75
|
||||
await key(KEY_SPACE)
|
||||
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
|
||||
await wait_for_human(scr)
|
||||
await frames(2)
|
||||
check(scr.b_skip.visible and not scr.b_end.visible, "Überspringen-Knopf während des Gegnerzugs")
|
||||
await click(scr.b_skip)
|
||||
var w := 0
|
||||
while g.current() != g.human and g.winner == "" and w < 120:
|
||||
await frames(1)
|
||||
w += 1
|
||||
check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w)
|
||||
scr.ai_delay = 0.0
|
||||
await clear_mulligan(scr)
|
||||
check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug")
|
||||
var step_before: int = scr.tutorial_step
|
||||
await click(scr.b_tut_next)
|
||||
check(scr.tutorial_step == step_before + 1, "Weiter-Knopf")
|
||||
check(scr.tutorial_step >= step_before + 1, "Weiter-Knopf (Schritt %d)" % step_before)
|
||||
await key(KEY_ESCAPE)
|
||||
check(scr.confirm.visible, "Esc öffnet Aufgeben")
|
||||
await key(KEY_ESCAPE)
|
||||
@@ -532,13 +850,22 @@ func test_netzwerk() -> void:
|
||||
check(scr is LobbyScreen, "Lobby geöffnet")
|
||||
await click(find_button("Spiel eröffnen"))
|
||||
check(main.net.is_host and main.net.connected, "Spiel eröffnet")
|
||||
if FileAccess.file_exists("user://bot_quit"):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://bot_quit"))
|
||||
var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"]
|
||||
var pid := OS.create_process(OS.get_executable_path(), args)
|
||||
check(pid > 0, "Bot-Prozess gestartet")
|
||||
var wait := 0
|
||||
while main.net.names.size() < 2 and wait < 900:
|
||||
while main.net.names.size() < 2 and wait < 600:
|
||||
await frames(1)
|
||||
wait += 1
|
||||
if main.net.names.size() < 2:
|
||||
print("HINWEIS: Bot kam nicht, zweiter Versuch")
|
||||
OS.kill(pid)
|
||||
pid = OS.create_process(OS.get_executable_path(), args)
|
||||
while main.net.names.size() < 2 and wait < 1500:
|
||||
await frames(1)
|
||||
wait += 1
|
||||
check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path())
|
||||
await shot("23_lobby")
|
||||
await click(find_button("Start"))
|
||||
@@ -546,7 +873,7 @@ func test_netzwerk() -> void:
|
||||
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
|
||||
b.ai_delay = 0.0
|
||||
var rounds := 0
|
||||
while b.game != null and b.game.winner == "" and rounds < 4:
|
||||
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:
|
||||
@@ -556,37 +883,196 @@ func test_netzwerk() -> void:
|
||||
await frames(1)
|
||||
w2 += 1
|
||||
rounds += 1
|
||||
check(b.game.turn >= 3 and b.game.winner == "", "mehrere Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
|
||||
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")
|
||||
await shot("24_netzwerk_kampf")
|
||||
OS.kill(pid)
|
||||
# Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen
|
||||
var quit_file := "user://bot_quit"
|
||||
var f := FileAccess.open(quit_file, FileAccess.WRITE)
|
||||
f.store_string("x")
|
||||
f.close()
|
||||
var w3 := 0
|
||||
while b.game.winner == "" and w3 < 1500:
|
||||
while main.net.waiting.is_empty() and w3 < 600:
|
||||
await frames(1)
|
||||
w3 += 1
|
||||
if b.game.winner != "":
|
||||
check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe")
|
||||
await frames(2)
|
||||
check(b.overlay.visible, "Spielende angezeigt")
|
||||
await click(find_button("Zurück zum Menü"))
|
||||
else:
|
||||
print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen")
|
||||
await give_up()
|
||||
check(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
|
||||
while b.game.current() != b.perspective and b.game.winner == "" and w4 < 300:
|
||||
await frames(1)
|
||||
w4 += 1
|
||||
check(b.game.turn >= turn_before + 1 and b.game.log.any(func(l): return "übersprungen" in l), "Züge des fehlenden Spielers werden übersprungen")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(quit_file))
|
||||
await frames(5)
|
||||
var pid2 := OS.create_process(OS.get_executable_path(), args)
|
||||
var w5 := 0
|
||||
while not main.net.waiting.is_empty() and w5 < 900:
|
||||
await frames(1)
|
||||
w5 += 1
|
||||
check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück")
|
||||
var turn2: int = b.game.turn
|
||||
for i in 2:
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective and b.game.winner == "":
|
||||
await click(b.b_end)
|
||||
var w6 := 0
|
||||
while b.game.current() != b.perspective and b.game.winner == "" and w6 < 900:
|
||||
await frames(1)
|
||||
w6 += 1
|
||||
check(b.game.turn >= turn2 + 1, "Partie läuft nach der Rückkehr weiter")
|
||||
OS.kill(pid2)
|
||||
await leave_battle()
|
||||
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
|
||||
|
||||
|
||||
func _bot_args(extra: Array) -> Array:
|
||||
return ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--"] + extra
|
||||
|
||||
|
||||
func _bot_quit(on: bool) -> void:
|
||||
var path := "user://bot_quit"
|
||||
if on:
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string("x")
|
||||
f.close()
|
||||
elif FileAccess.file_exists(path):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
## Verbindet sich mit dem Bot-Host (mehrere Versuche, bis er lauscht).
|
||||
func _join_bot_host(watch: bool) -> void:
|
||||
await click(find_button("Netzwerkspiel"))
|
||||
var lobby = screen()
|
||||
lobby.ip_box.text = "127.0.0.1"
|
||||
for attempt in 8:
|
||||
await click(find_button("Zuschauen" if watch else "Beitreten"))
|
||||
var w := 0
|
||||
while not main.net.connected and w < 180:
|
||||
await frames(1)
|
||||
w += 1
|
||||
if main.net.connected:
|
||||
return
|
||||
main.net.leave()
|
||||
await frames(30)
|
||||
|
||||
|
||||
## Der Host (ein Bot) verschwindet: der einzige Mitspieler übernimmt und spielt zu Ende.
|
||||
func test_host_wechsel() -> void:
|
||||
_bot_quit(false)
|
||||
var pid := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
|
||||
check(pid > 0, "Bot-Host gestartet")
|
||||
await frames(30)
|
||||
await _join_bot_host(false)
|
||||
var w := 0
|
||||
while not (screen() is BattleScreen) and w < 900:
|
||||
await frames(1)
|
||||
w += 1
|
||||
var b = screen()
|
||||
check(b is BattleScreen and b.is_client(), "als Mitspieler beim Bot-Host im Kampf")
|
||||
w = 0
|
||||
while (b.game == null or main.net.backup.is_empty()) and w < 600:
|
||||
await frames(1)
|
||||
w += 1
|
||||
check(b.game != null and main.net.i_am_successor and not main.net.backup.is_empty(), "Nachfolger kennt den vollen Spielstand")
|
||||
main.net.rejoin_seconds = 3.0
|
||||
_bot_quit(true)
|
||||
w = 0
|
||||
while not main.net.is_host and w < 900:
|
||||
await frames(1)
|
||||
w += 1
|
||||
check(main.net.is_host and b.game == main.net.game and screen() == b and b.game.log.any(func(l): return "übernimmt als Host" in l),
|
||||
"Host-Wechsel: Mitspieler führt das Spiel weiter")
|
||||
check(main.net.waiting.has("BotHost"), "Platz des alten Hosts bleibt zunächst frei")
|
||||
await shot("34_host_wechsel")
|
||||
# Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus
|
||||
b.ai_delay = 0.0
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective:
|
||||
await click(b.b_end)
|
||||
w = 0
|
||||
while b.game.winner == "" and w < 900:
|
||||
await frames(1)
|
||||
w += 1
|
||||
check(b.game.log.any(func(l): return "übersprungen" in l), "Zug des fehlenden Hosts übersprungen")
|
||||
check(b.game.winner != "" and b.game.winner_player == b.perspective and b.game.log.any(func(l): return "kehrt nicht zurück" in l),
|
||||
"nach der Frist scheidet der alte Host aus, Partie gewonnen (Sieger '%s', Zug %d)" % [b.game.winner, b.game.turn])
|
||||
await leave_battle()
|
||||
_bot_quit(false)
|
||||
check(screen() is MenuScreen and not main.net.connected, "nach dem Host-Wechsel zurück im Menü")
|
||||
|
||||
|
||||
## Zuschauer: sieht die Partie zweier Bots ohne Hände und ohne eigene Züge.
|
||||
func test_zuschauer() -> void:
|
||||
_bot_quit(false)
|
||||
var pid_h := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
|
||||
await frames(30)
|
||||
var pid_c := OS.create_process(OS.get_executable_path(), _bot_args(["--ip=127.0.0.1", "--name=Bot"]))
|
||||
check(pid_h > 0 and pid_c > 0, "zwei Bots gestartet")
|
||||
await _join_bot_host(true)
|
||||
var w := 0
|
||||
while not (screen() is BattleScreen) and w < 1200:
|
||||
await frames(1)
|
||||
w += 1
|
||||
var b = screen()
|
||||
check(b is BattleScreen and b.spectating(), "als Zuschauer im Kampf")
|
||||
w = 0
|
||||
while (b.game == null or b.game.turn < 2) and w < 900:
|
||||
await frames(1)
|
||||
w += 1
|
||||
check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.mulligan_box.visible 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.handover.visible and not b.is_hotseat(), "Zuschauer bekommt keine Hotseat-Übergabe")
|
||||
if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein
|
||||
await leave_battle()
|
||||
else:
|
||||
var w2 := 0
|
||||
while not (screen() is MenuScreen) and w2 < 300:
|
||||
await frames(1)
|
||||
w2 += 1
|
||||
check(screen() is MenuScreen and not main.net.connected, "Zuschauer zurück im Menü")
|
||||
_bot_quit(true)
|
||||
await frames(60)
|
||||
_bot_quit(false)
|
||||
|
||||
|
||||
func test_kampagne() -> void:
|
||||
await click(find_button("Kampagne"))
|
||||
var scr = screen()
|
||||
check(scr is CampaignScreen, "Kampagne geöffnet")
|
||||
await shot("15_kampagne")
|
||||
check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen")
|
||||
check(find_button("Kapitel 2").disabled and scr.perk_box == null, "Kapitel 2 und Merkmal noch gesperrt")
|
||||
await click(find_button("Kämpfen"))
|
||||
var battle = screen()
|
||||
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
|
||||
check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner")
|
||||
check(battle.game.opponents[0].perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv")
|
||||
await shot("16_kampagne_kampf")
|
||||
await give_up()
|
||||
# Kapitel 1 als abgeschlossen setzen: Kapitel 2 und Merkmalwahl
|
||||
main.profile.campaign_stage = Campaign.STAGES.size()
|
||||
await click(find_button("Kampagne"))
|
||||
var scr2 = screen()
|
||||
check(scr2.chapter == 2 and scr2.perk_box != null, "Kapitel 2 offen, Merkmal wählbar")
|
||||
scr2.perk_box.select(1)
|
||||
scr2.perk_box.item_selected.emit(1)
|
||||
check(main.profile.perk == Game.PERKS.keys()[0], "Merkmal gewählt")
|
||||
await shot("27_kampagne_kapitel2")
|
||||
await click(find_button("Kämpfen"))
|
||||
var b2 = screen()
|
||||
check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponents[0].perks.size() == 2, "Kapitel-2-Boss mit zwei Merkmalen")
|
||||
check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf")
|
||||
await give_up()
|
||||
main.profile.campaign_stage = 0
|
||||
main.profile.perk = ""
|
||||
main.save()
|
||||
|
||||
|
||||
func test_kampf(n_opp: int, shot_turn: int) -> void:
|
||||
@@ -642,8 +1128,12 @@ func test_kampf_abbrechen() -> void:
|
||||
await click(trap_view)
|
||||
check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar")
|
||||
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
|
||||
await hover(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])
|
||||
@@ -654,6 +1144,9 @@ func test_kampf_abbrechen() -> void:
|
||||
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(),
|
||||
" 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"))
|
||||
check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte")
|
||||
|
||||
@@ -57,3 +57,163 @@ 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
|
||||
Herausforderung,Challenge
|
||||
Antreten,Enter
|
||||
Statistik exportieren,Export statistics
|
||||
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
|
||||
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
|
||||
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
|
||||
"Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s","Your deck strength %.1f · Opponent decks %.1f · %s · Coins x%.2f · AI: %s"
|
||||
schwache Archetypen,weaker archetypes
|
||||
starke Archetypen,stronger archetypes
|
||||
alle Archetypen,all archetypes
|
||||
Vorausschau,lookahead
|
||||
einfach,simple
|
||||
Stärke %.1f,Strength %.1f
|
||||
"Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects"
|
||||
Tipp: %s,Tip: %s
|
||||
Übernehmen,Apply
|
||||
Neu würfeln,Reroll
|
||||
Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s)
|
||||
Anleitung 2: Fallen,Tutorial 2: Traps
|
||||
Neu würfeln (%d),Reroll (%d)
|
||||
Profil zurücksetzen,Reset profile
|
||||
Große Schrift,Large text
|
||||
Verwerfen,Discard
|
||||
Weiter bearbeiten,Keep editing
|
||||
Ungespeicherte Änderungen,Unsaved changes
|
||||
"Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",Welcome! Your hand is at the bottom. Every card costs energy (◆); you have 1 on your first turn. Click a creature that costs 1 to play it.
|
||||
Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.,Creatures attack from the next turn on. End your turn with the button on the right or the space bar.
|
||||
Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.,"Green frames mark creatures ready to attack. Click a creature, then Fenn or one of his creatures. Guardians must fall first."
|
||||
Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.,Resonance: creatures sharing an element boost each other's attack. Play a second fire or earth creature.
|
||||
Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen.,Field combo: a creature next to one of yours with a different element triggers an effect. Lava deals 1 damage to the strongest enemy creature.
|
||||
"Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.",Spells resolve immediately. Spark or Fireball can hit Fenn directly. Pick targets by clicking; right-click cancels.
|
||||
Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!,Fenn loses at 0 life. Swap weak opening hands on your first turn (mulligan). Good luck!
|
||||
"Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.",Mulligan: on your first turn you may put cards back once and redraw. Decide in the dialog what to keep.
|
||||
"Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.",Traps lie face down and trigger during enemy turns. Set the Frost Trap once you have 2 energy.
|
||||
"Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.","End your turn and wait. If Mara attacks you or a creature, the attack fizzles and her creature freezes."
|
||||
Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.,Field combo Steam: a water creature next to a fire creature freezes the strongest enemy creature. Play both in sequence.
|
||||
"Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!",Undo (U) takes back a creature you just played as long as nothing was triggered. Good luck against Mara!
|
||||
"%d Siege in Folge, versuche %s","%d wins in a row, try %s"
|
||||
"%d Niederlagen in Folge, versuche %s","%d losses in a row, try %s"
|
||||
Duell,Duel
|
||||
%d am Tisch,%d at the table
|
||||
%d Spieler am Tisch,%d players at the table
|
||||
%d Münzen,%d coins
|
||||
Stärke %.1f · %d Münzen,Strength %.1f · %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
|
||||
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
|
||||
Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand
|
||||
Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands
|
||||
Ein befreundetes Wesen erhält %s,A friendly creature gets %s
|
||||
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
|
||||
legendär,legendary
|
||||
mythisch,mythic
|
||||
Feuer,Fire
|
||||
Wasser,Water
|
||||
Erde,Earth
|
||||
Luft,Air
|
||||
Inferno,Inferno
|
||||
Tiefsee,Deep Sea
|
||||
Berg,Mountain
|
||||
Orkan,Hurricane
|
||||
Dampf,Steam
|
||||
Lava,Lava
|
||||
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"
|
||||
|
||||
|
Binary file not shown.
Reference in New Issue
Block a user