Dinger: Kartenspiel in Godot 4.7
Elemente, Kombos, Fallen, KI mit Vorausschau, Kampagne, Hotseat und Netzwerk, Werkstatt, Erfolge, Tagesaufgaben, Tests und Exporte. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
This commit is contained in:
35
.github/workflows/tests.yml
vendored
Normal file
35
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
headless:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Godot laden
|
||||
run: |
|
||||
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
- name: Tests
|
||||
run: ./tests/run_headless.sh ./godot
|
||||
|
||||
fenster:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Godot und Xvfb laden
|
||||
run: |
|
||||
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
|
||||
- name: Import
|
||||
run: ./godot --headless --path . --import || true
|
||||
- name: Fenster-Test (virtueller Bildschirm)
|
||||
run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
|
||||
- name: Ergebnis
|
||||
run: grep -q " 0 fehlgeschlagen" fenster.log
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.godot/
|
||||
build/
|
||||
python/.venv/
|
||||
python/**/__pycache__/
|
||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Dinger
|
||||
|
||||
Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln.
|
||||
|
||||
## Start
|
||||
|
||||
Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal:
|
||||
|
||||
```
|
||||
godot --path .
|
||||
```
|
||||
|
||||
Der Spielstand liegt unter `user://spielstand.json`
|
||||
(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`).
|
||||
|
||||
## Spielsysteme
|
||||
|
||||
- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen
|
||||
- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus
|
||||
- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht)
|
||||
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern, Erfolge, Tagesaufgabe
|
||||
- Lokal zu mehreren: bis zu 4 Menschen am selben Rechner plus KI
|
||||
- Booster, fünf Deck-Slots, Werkstatt (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen)
|
||||
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
|
||||
- Anleitung (geführte erste Partie), Mulligan im ersten Zug, Tastatur (Leertaste, 1 bis 9, Esc, Enter)
|
||||
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten
|
||||
- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand)
|
||||
- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`).
|
||||
|
||||
```
|
||||
godot --headless --path . --import # einmalig: Ressourcen importieren
|
||||
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
|
||||
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
|
||||
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
|
||||
godot --headless --path . -s tests/dump_cards.gd # Kartenpool aus Code nach JSON schreiben (nur bei Bedarf)
|
||||
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
|
||||
```
|
||||
|
||||
## Export
|
||||
|
||||
Export-Vorlagen für Godot 4.7 installieren (Editor: Editor → Export-Vorlagen verwalten), dann:
|
||||
|
||||
```
|
||||
godot --headless --path . --export-release Linux build/dinger-linux.x86_64
|
||||
godot --headless --path . --export-release Windows build/dinger-windows.exe
|
||||
```
|
||||
|
||||
## Aufbau
|
||||
|
||||
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
|
||||
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Gegner, Hotseat
|
||||
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
|
||||
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen
|
||||
- `scripts/campaign.gd` – Kampagnenstufen
|
||||
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton
|
||||
- `scenes/main.tscn` – Startszene
|
||||
- `scripts/net.gd` – Netzwerkspiel (ENet)
|
||||
- `translations/ui.csv` – englische Oberflächentexte
|
||||
24
anforderungen.md
Normal file
24
anforderungen.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Dinger
|
||||
|
||||
## Grundidee
|
||||
- Kartenspiel wie zum Beispiel Magic
|
||||
- PC-Spiel
|
||||
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
|
||||
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
|
||||
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt
|
||||
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich
|
||||
- Geführte erste Partie als Einstieg
|
||||
|
||||
## Sammeln
|
||||
- Booster öffnen
|
||||
- Karten haben Seltenheiten
|
||||
- Aus der Sammlung werden Decks gebaut (mehrere Slots)
|
||||
- Überschüssige Karten lassen sich zerlegen und neue herstellen
|
||||
|
||||
## Elemente
|
||||
- Karten gehören zu Elementen
|
||||
- Elemente sind kombinierbar, daraus entstehen viele Varianten
|
||||
- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer)
|
||||
- Jedes Element und jede Kombo hat eine eigene Spielweise
|
||||
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus
|
||||
- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug
|
||||
BIN
assets/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
assets/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
36
assets/fonts/DejaVuSans-Bold.ttf.import
Normal file
36
assets/fonts/DejaVuSans-Bold.ttf.import
Normal file
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://jqogbchdskrc"
|
||||
path="res://.godot/imported/DejaVuSans-Bold.ttf-28339d4fde761e3a49fa62b16c3f5900.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/fonts/DejaVuSans-Bold.ttf"
|
||||
dest_files=["res://.godot/imported/DejaVuSans-Bold.ttf-28339d4fde761e3a49fa62b16c3f5900.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
BIN
assets/fonts/DejaVuSans.ttf
Normal file
BIN
assets/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
36
assets/fonts/DejaVuSans.ttf.import
Normal file
36
assets/fonts/DejaVuSans.ttf.import
Normal file
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://l7leg081apqp"
|
||||
path="res://.godot/imported/DejaVuSans.ttf-0dcd47e426510e02f045e926ff1bfdf0.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/fonts/DejaVuSans.ttf"
|
||||
dest_files=["res://.godot/imported/DejaVuSans.ttf-0dcd47e426510e02f045e926ff1bfdf0.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
2002
data/karten.json
Normal file
2002
data/karten.json
Normal file
File diff suppressed because it is too large
Load Diff
73
export_presets.cfg
Normal file
73
export_presets.cfg
Normal file
@@ -0,0 +1,73 @@
|
||||
[preset.0]
|
||||
|
||||
name="Linux"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="*.json"
|
||||
exclude_filter="python/*"
|
||||
export_path="build/dinger-linux.x86_64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
seed=0
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.0.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Windows"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="*.json"
|
||||
exclude_filter="python/*"
|
||||
export_path="build/dinger-windows.exe"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
seed=0
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
binary_format/architecture="x86_64"
|
||||
application/modify_resources=true
|
||||
application/icon=""
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version=""
|
||||
application/product_version=""
|
||||
application/company_name="Dinger"
|
||||
application/product_name="Dinger"
|
||||
application/file_description="Dinger Kartenspiel"
|
||||
application/copyright=""
|
||||
application/trademarks=""
|
||||
application/export_angle=0
|
||||
application/export_d3d12=0
|
||||
application/d3d12_agility_sdk_multiarch=true
|
||||
28
project.godot
Normal file
28
project.godot
Normal file
@@ -0,0 +1,28 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Dinger"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=800
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="keep"
|
||||
|
||||
[internationalization]
|
||||
|
||||
locale/translations=PackedStringArray("res://translations/ui.en.translation")
|
||||
locale/fallback="de"
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
12
scenes/main.tscn
Normal file
12
scenes/main.tscn
Normal file
@@ -0,0 +1,12 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/main.gd" id="1"]
|
||||
|
||||
[node name="Main" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1")
|
||||
269
scripts/ai.gd
Normal file
269
scripts/ai.gd
Normal file
@@ -0,0 +1,269 @@
|
||||
class_name AI
|
||||
## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des
|
||||
## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante
|
||||
## (choose_action_greedy) dient als Vergleich.
|
||||
|
||||
|
||||
# --- Bewertung -------------------------------------------------------------------
|
||||
|
||||
static func creature_value(c: Game.Creature) -> float:
|
||||
var v := float(c.power() + c.health)
|
||||
if c.taunt():
|
||||
v += 2.0
|
||||
if c.shield:
|
||||
v += 1.5
|
||||
if c.attacks_per_turn() == 2:
|
||||
v += c.power()
|
||||
if c.frozen:
|
||||
v -= c.power() * 0.5
|
||||
return v
|
||||
|
||||
|
||||
static func board_value(p: Game.Player) -> float:
|
||||
var v := 0.0
|
||||
for c in p.board:
|
||||
v += creature_value(c)
|
||||
return v
|
||||
|
||||
|
||||
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv,
|
||||
## die der Gegner negativ. Ein besiegter Gegner ist viel wert.
|
||||
static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
if not p.alive():
|
||||
return -10000.0
|
||||
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
|
||||
var alive_enemies := 0
|
||||
for e in g.players:
|
||||
if e == p:
|
||||
continue
|
||||
if not e.alive():
|
||||
score += 40.0
|
||||
continue
|
||||
alive_enemies += 1
|
||||
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
|
||||
if e.life <= 6:
|
||||
score += 3.0 # fast besiegt: dranbleiben
|
||||
if alive_enemies == 0:
|
||||
return 10000.0
|
||||
return score
|
||||
|
||||
|
||||
# --- Vorausschau ---------------------------------------------------------------
|
||||
|
||||
static func candidate_actions(game: Game, p: Game.Player) -> Array:
|
||||
var out := []
|
||||
for card in p.hand:
|
||||
if not game.can_play(p, card)[0]:
|
||||
continue
|
||||
var tt: String = card.target_type()
|
||||
if tt == "":
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
else:
|
||||
var valid := game.valid_targets(p, tt)
|
||||
if valid.is_empty():
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
for t in valid:
|
||||
out.append({"type": "play", "card": card, "target": t})
|
||||
for cr in p.board:
|
||||
if not cr.can_attack():
|
||||
continue
|
||||
for e in game.enemies_of(p):
|
||||
for t in e.board + [e]:
|
||||
if game.can_attack(cr, t)[0]:
|
||||
out.append({"type": "attack", "creature": cr, "target": t})
|
||||
return out
|
||||
|
||||
|
||||
## Führt eine Aktion des Originals auf der Kopie aus.
|
||||
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var target = null
|
||||
if action.get("target") != null:
|
||||
target = map_target(g2, game, action.target)
|
||||
if action.type == "play":
|
||||
g2.play_card(p2, action.card, target)
|
||||
else:
|
||||
g2.attack(g2.find_creature(action.creature.uid), target)
|
||||
|
||||
|
||||
static func map_target(g2: Game, game: Game, t):
|
||||
if t is Game.Creature:
|
||||
return g2.find_creature(t.uid)
|
||||
return g2.players[game.players.find(t)]
|
||||
|
||||
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
|
||||
|
||||
|
||||
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
|
||||
static func mulligan_cards(p: Game.Player) -> Array:
|
||||
return p.hand.filter(func(c): return c.cost >= 5)
|
||||
|
||||
|
||||
## Beste Aktion nach einer Vorausschau von zwei Schritten: jede Aktion wird auf einer
|
||||
## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen).
|
||||
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||
return {"type": "end"}
|
||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
||||
var best := {"type": "end"}
|
||||
var best_score := evaluate(game, p) + 0.01
|
||||
for action in candidate_actions(game, p):
|
||||
var g2 := game.clone_for(p)
|
||||
apply_on_clone(g2, game, action)
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var score := evaluate(g2, p2)
|
||||
if g2.winner == "":
|
||||
var count := 0
|
||||
for f in candidate_actions(g2, p2):
|
||||
if count >= FOLLOW_UPS:
|
||||
break
|
||||
count += 1
|
||||
var g3 := g2.clone_for(p2)
|
||||
apply_on_clone(g3, g2, f)
|
||||
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = action
|
||||
return best
|
||||
|
||||
|
||||
# --- Gierige Vergleichs-KI -------------------------------------------------------
|
||||
|
||||
static func _kills(attacker, defender) -> bool:
|
||||
return not defender.shield and defender.health <= attacker.power()
|
||||
|
||||
|
||||
static func _value(c) -> int:
|
||||
return c.power() + c.health + (2 if c.taunt() else 0)
|
||||
|
||||
|
||||
static func _best(arr: Array, key: Callable):
|
||||
var best = null
|
||||
var best_v := 0
|
||||
for x in arr:
|
||||
var v: int = key.call(x)
|
||||
if best == null or v > best_v:
|
||||
best = x
|
||||
best_v = v
|
||||
return best
|
||||
|
||||
|
||||
static func _threat_score(h) -> int:
|
||||
var board_attack := 0
|
||||
for c in h.board:
|
||||
board_attack += c.power()
|
||||
return h.life - board_attack
|
||||
|
||||
|
||||
static func _weakest(heroes: Array):
|
||||
var best = null
|
||||
for h in heroes:
|
||||
if best == null or _threat_score(h) < _threat_score(best):
|
||||
best = h
|
||||
return best
|
||||
|
||||
|
||||
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
|
||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||
return {"type": "end"}
|
||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
||||
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
|
||||
if not playable.is_empty():
|
||||
Game.shuffle(playable, game.rng)
|
||||
var card = playable[0]
|
||||
for c in playable:
|
||||
if c.cost > card.cost:
|
||||
card = c
|
||||
return {"type": "play", "card": card, "target": pick_target(game, p, card)}
|
||||
for cr in p.board:
|
||||
if cr.can_attack():
|
||||
var t = pick_attack_target(game, p, cr)
|
||||
if t != null:
|
||||
return {"type": "attack", "creature": cr, "target": t}
|
||||
return {"type": "end"}
|
||||
|
||||
|
||||
static func pick_target(game: Game, p: Game.Player, card: Cards.Card):
|
||||
var tt := card.target_type()
|
||||
if tt == "":
|
||||
return null
|
||||
var valid := game.valid_targets(p, tt)
|
||||
if valid.is_empty():
|
||||
return null
|
||||
var fx: Cards.Effect = null
|
||||
for f in card.effects:
|
||||
if f.target == tt:
|
||||
fx = f
|
||||
break
|
||||
var creatures := valid.filter(func(t): return t is Game.Creature)
|
||||
var heroes := valid.filter(func(t): return t is Game.Player)
|
||||
match fx.kind:
|
||||
"damage":
|
||||
var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount)
|
||||
if not killable.is_empty():
|
||||
return _best(killable, Callable(AI, "_value"))
|
||||
if not heroes.is_empty():
|
||||
return _weakest(heroes)
|
||||
return _best(creatures, Callable(AI, "_value"))
|
||||
"freeze", "bounce":
|
||||
return _best(creatures, func(c): return c.card.cost * 100 + c.power())
|
||||
"buff", "shield":
|
||||
return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power())
|
||||
return valid[0]
|
||||
|
||||
|
||||
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
|
||||
var enemies := game.enemies_of(p)
|
||||
var total := 0
|
||||
for c in p.board:
|
||||
if c.can_attack():
|
||||
total += c.power() * c.attacks_left
|
||||
var options := []
|
||||
for e in enemies:
|
||||
var taunts: Array = e.board.filter(func(c): return c.taunt())
|
||||
if not taunts.is_empty():
|
||||
options.append_array(taunts)
|
||||
else:
|
||||
if total >= e.life:
|
||||
return e
|
||||
options.append_array(e.board)
|
||||
options.append(e)
|
||||
var creatures := options.filter(func(t): return t is Game.Creature)
|
||||
var heroes := options.filter(func(t): return t is Game.Player)
|
||||
var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr))
|
||||
if not good.is_empty():
|
||||
return _best(good, Callable(AI, "_value"))
|
||||
var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr))
|
||||
if not trade.is_empty():
|
||||
return _best(trade, Callable(AI, "_value"))
|
||||
if not heroes.is_empty():
|
||||
return _weakest(heroes)
|
||||
if not creatures.is_empty():
|
||||
return _best(creatures, func(c): return 1000 - c.health)
|
||||
return null
|
||||
|
||||
|
||||
# --- Ausführung -------------------------------------------------------------------
|
||||
|
||||
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
|
||||
match action.type:
|
||||
"play":
|
||||
game.play_card(p, action.card, action.target)
|
||||
"attack":
|
||||
game.attack(action.creature, action.target)
|
||||
"mulligan":
|
||||
game.mulligan(p, action.cards)
|
||||
_:
|
||||
game.end_turn()
|
||||
|
||||
|
||||
## Spielt den ganzen Zug (für Simulationen und Tests). greedy: gierige KI statt Vorausschau.
|
||||
static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: int = 100) -> void:
|
||||
for i in limit:
|
||||
var action := choose_action_greedy(game, p) if greedy else choose_action(game, p)
|
||||
apply_action(game, p, action)
|
||||
if action.type == "end" or game.winner != "":
|
||||
return
|
||||
1
scripts/ai.gd.uid
Normal file
1
scripts/ai.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://pewo135bho86
|
||||
59
scripts/campaign.gd
Normal file
59
scripts/campaign.gd
Normal file
@@ -0,0 +1,59 @@
|
||||
class_name Campaign
|
||||
## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch.
|
||||
|
||||
const STAGES := [
|
||||
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "feuer_angriff"},
|
||||
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "leben_35"},
|
||||
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "waechter_leben"},
|
||||
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "flink_angriff"},
|
||||
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "zauber_schaden"},
|
||||
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "extra_karten"},
|
||||
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "resonanz_doppelt"},
|
||||
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "schild_wesen"},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "bias": 2.5, "reward": 300, "perk": "alle_flink"},
|
||||
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "bias": 3.0, "reward": 500, "perk": "start_energie"},
|
||||
]
|
||||
|
||||
|
||||
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück.
|
||||
const STAGES2 := [
|
||||
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
|
||||
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["leben_35", "extra_karten"]},
|
||||
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
|
||||
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
|
||||
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]},
|
||||
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["extra_karten", "flink_angriff"]},
|
||||
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]},
|
||||
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "bias": 3.5, "reward": 500, "perks": ["alle_flink", "start_energie"]},
|
||||
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "bias": 4.0, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]},
|
||||
]
|
||||
|
||||
|
||||
static func total() -> int:
|
||||
return STAGES.size() + STAGES2.size()
|
||||
|
||||
|
||||
static func chapter(stage: int) -> int:
|
||||
return 1 if stage < STAGES.size() else 2
|
||||
|
||||
|
||||
## Stufe mit einheitlichem Feld "perks".
|
||||
static func stage(i: int) -> Dictionary:
|
||||
if i < STAGES.size():
|
||||
var st: Dictionary = STAGES[i].duplicate()
|
||||
st["perks"] = [st.perk]
|
||||
return st
|
||||
return STAGES2[i - STAGES.size()]
|
||||
|
||||
|
||||
static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array:
|
||||
var st := stage(stage_index)
|
||||
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
|
||||
var boss := Game.Player.new(st.name, Game.build_opponent_deck(r, st.elements, st.bias), false, life, st.title)
|
||||
boss.perks = Array(st.perks)
|
||||
var out := [boss]
|
||||
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
|
||||
for k in extra:
|
||||
out.append(Game.random_opponent(r, k + 1))
|
||||
return out
|
||||
1
scripts/campaign.gd.uid
Normal file
1
scripts/campaign.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmpb6g16h42ps
|
||||
292
scripts/cards.gd
Normal file
292
scripts/cards.gd
Normal file
@@ -0,0 +1,292 @@
|
||||
class_name Cards
|
||||
## Elemente, Kombos und der Kartenpool.
|
||||
|
||||
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
|
||||
const ELEMENT_COLORS := {
|
||||
"Feuer": Color("be3c23"),
|
||||
"Wasser": Color("2d64be"),
|
||||
"Erde": Color("73552d"),
|
||||
"Luft": Color("78aac8"),
|
||||
}
|
||||
# Jede Paarung ist eine Kombo, auch gleiche Elemente.
|
||||
const COMBOS := {
|
||||
"Feuer+Feuer": "Inferno",
|
||||
"Wasser+Wasser": "Tiefsee",
|
||||
"Erde+Erde": "Berg",
|
||||
"Luft+Luft": "Orkan",
|
||||
"Feuer+Wasser": "Dampf",
|
||||
"Feuer+Erde": "Lava",
|
||||
"Feuer+Luft": "Blitz",
|
||||
"Wasser+Erde": "Schlamm",
|
||||
"Wasser+Luft": "Sturm",
|
||||
"Erde+Luft": "Sand",
|
||||
# Dreifach-Kombos: die mythische Stufe
|
||||
"Feuer+Feuer+Feuer": "Sonne",
|
||||
"Wasser+Wasser+Wasser": "Ozean",
|
||||
"Erde+Erde+Erde": "Gebirge",
|
||||
"Luft+Luft+Luft": "Himmel",
|
||||
}
|
||||
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
|
||||
const FIELD_COMBO_TEXT := {
|
||||
"Dampf": "Friere das stärkste gegnerische Wesen ein",
|
||||
"Lava": "1 Schaden an alle gegnerischen Wesen",
|
||||
"Blitz": "2 Schaden an den schwächsten Gegner",
|
||||
"Schlamm": "Heile dich um 3",
|
||||
"Sturm": "Ziehe 1 Karte",
|
||||
"Sand": "Das neue Wesen erhält Schild",
|
||||
}
|
||||
const RESONANCE_TEXT := "Resonanz: Ein Wesen hat +1 Angriff je anderem eigenen Wesen, das ein Element mit ihm teilt (höchstens +4)."
|
||||
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
|
||||
const RARITY_COLORS := {
|
||||
"gewöhnlich": Color("aaaaaa"),
|
||||
"selten": Color("4682e6"),
|
||||
"episch": Color("aa46dc"),
|
||||
"legendär": Color("f0aa28"),
|
||||
"mythisch": Color("ff5a8c"),
|
||||
}
|
||||
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"]
|
||||
const KEYWORD_HELP := {
|
||||
"Wächter": "Muss zuerst angegriffen werden.",
|
||||
"Flink": "Kann sofort angreifen.",
|
||||
"Schild": "Blockt den nächsten Schaden.",
|
||||
"Doppelschlag": "Greift zweimal pro Zug an.",
|
||||
}
|
||||
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
|
||||
const DATA_PATH := "res://data/karten.json"
|
||||
|
||||
|
||||
static func combo_key(elements: Array) -> String:
|
||||
var sorted := elements.duplicate()
|
||||
sorted.sort_custom(func(a, b): return ELEMENTS.find(a) < ELEMENTS.find(b))
|
||||
return "+".join(sorted)
|
||||
|
||||
|
||||
static func combo_name(elements: Array) -> String:
|
||||
if elements.size() == 1:
|
||||
return elements[0]
|
||||
return COMBOS[combo_key(elements)]
|
||||
|
||||
|
||||
static func field_combo_effect(name: String) -> Effect:
|
||||
match name:
|
||||
"Dampf":
|
||||
return Effect.new("freeze", "strongest_enemy_creature")
|
||||
"Lava":
|
||||
return Effect.new("damage", "all_enemy_creatures", 1)
|
||||
"Blitz":
|
||||
return Effect.new("damage", "weakest_enemy_hero", 2)
|
||||
"Schlamm":
|
||||
return Effect.new("heal", "self_hero", 3)
|
||||
"Sturm":
|
||||
return Effect.new("draw", "none", 1)
|
||||
"Sand":
|
||||
return Effect.new("shield", "self_creature")
|
||||
return null
|
||||
|
||||
|
||||
const TRIGGER_TEXT := {
|
||||
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift",
|
||||
"creature_played": "Wenn ein Gegner ein Wesen ausspielt",
|
||||
}
|
||||
|
||||
|
||||
class Effect extends RefCounted:
|
||||
var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel
|
||||
var target: String # siehe CHOSEN_TARGETS und Game.resolve()
|
||||
var amount: int
|
||||
var amount2: int
|
||||
var card_id: String # für summon
|
||||
|
||||
func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void:
|
||||
kind = k
|
||||
target = t
|
||||
amount = a
|
||||
amount2 = a2
|
||||
card_id = cid
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var d := {"kind": kind, "target": target}
|
||||
if amount != 0:
|
||||
d["amount"] = amount
|
||||
if amount2 != 0:
|
||||
d["amount2"] = amount2
|
||||
if card_id != "":
|
||||
d["card_id"] = card_id
|
||||
return d
|
||||
|
||||
static func from_dict(d: Dictionary) -> Effect:
|
||||
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
|
||||
|
||||
func text() -> String:
|
||||
var n := str(amount)
|
||||
if target == "trigger_source":
|
||||
match kind:
|
||||
"damage":
|
||||
return n + " Schaden an den Angreifer"
|
||||
"freeze":
|
||||
return "Der Angreifer wird eingefroren"
|
||||
"bounce":
|
||||
return "Es geht zurück auf die Hand"
|
||||
match kind:
|
||||
"cancel":
|
||||
return "Der Angriff verpufft"
|
||||
"summon":
|
||||
return "Beschwöre einen " + Cards.by_id(card_id).name
|
||||
"damage":
|
||||
match target:
|
||||
"enemy_creature":
|
||||
return n + " Schaden an ein gegnerisches Wesen"
|
||||
"enemy_any":
|
||||
return n + " Schaden an ein Ziel"
|
||||
"all_enemy_creatures":
|
||||
return n + " Schaden an alle gegnerischen Wesen"
|
||||
"all_enemy_heroes":
|
||||
return n + " Schaden an alle Gegner"
|
||||
"all_enemies":
|
||||
return n + " Schaden an alle Gegner und ihre Wesen"
|
||||
"heal":
|
||||
return "Heile dich um " + n
|
||||
"draw":
|
||||
return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten"
|
||||
"freeze":
|
||||
if target == "enemy_creature":
|
||||
return "Friere ein gegnerisches Wesen ein"
|
||||
return "Friere alle gegnerischen Wesen ein"
|
||||
"bounce":
|
||||
if target == "enemy_creature":
|
||||
return "Ein gegnerisches Wesen zurück auf die Hand"
|
||||
return "Alle gegnerischen Wesen zurück auf die Hand"
|
||||
"buff":
|
||||
var b := "+%d/+%d" % [amount, amount2]
|
||||
if target == "friendly_creature":
|
||||
return "Ein befreundetes Wesen erhält " + b
|
||||
return "Alle befreundeten Wesen erhalten " + b
|
||||
"shield":
|
||||
if target == "friendly_creature":
|
||||
return "Ein befreundetes Wesen erhält Schild"
|
||||
if target == "self_creature":
|
||||
return "Erhält Schild"
|
||||
return "Alle befreundeten Wesen erhalten Schild"
|
||||
return ""
|
||||
|
||||
|
||||
class Card extends RefCounted:
|
||||
var id: String
|
||||
var name: String
|
||||
var elements: Array
|
||||
var cost: int
|
||||
var rarity: String
|
||||
var kind: String # "Wesen", "Zauber" oder "Falle"
|
||||
var attack: int = 0
|
||||
var health: int = 0
|
||||
var keywords: Array = []
|
||||
var effects: Array = []
|
||||
var trigger: String = "" # nur Fallen: attacked, creature_played
|
||||
|
||||
func is_creature() -> bool:
|
||||
return kind == "Wesen"
|
||||
|
||||
func is_trap() -> bool:
|
||||
return kind == "Falle"
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
|
||||
if is_creature():
|
||||
d["attack"] = attack
|
||||
d["health"] = health
|
||||
if not keywords.is_empty():
|
||||
d["keywords"] = keywords
|
||||
if not effects.is_empty():
|
||||
d["effects"] = effects.map(func(e): return e.to_dict())
|
||||
if trigger != "":
|
||||
d["trigger"] = trigger
|
||||
return d
|
||||
|
||||
static func from_dict(d: Dictionary) -> Card:
|
||||
var c := Card.new()
|
||||
c.id = d.id
|
||||
c.name = d.name
|
||||
c.elements = Array(d.elements)
|
||||
c.cost = int(d.cost)
|
||||
c.rarity = d.rarity
|
||||
c.kind = d.kind
|
||||
c.attack = int(d.get("attack", 0))
|
||||
c.health = int(d.get("health", 0))
|
||||
c.keywords = Array(d.get("keywords", []))
|
||||
for e in d.get("effects", []):
|
||||
c.effects.append(Effect.from_dict(e))
|
||||
c.trigger = str(d.get("trigger", ""))
|
||||
return c
|
||||
|
||||
func is_combo() -> bool:
|
||||
return elements.size() >= 2
|
||||
|
||||
func combo() -> String:
|
||||
return Cards.combo_name(elements)
|
||||
|
||||
func target_type() -> String:
|
||||
for fx in effects:
|
||||
if fx.target in Cards.CHOSEN_TARGETS:
|
||||
return fx.target
|
||||
return ""
|
||||
|
||||
func text() -> String:
|
||||
var parts: Array[String] = []
|
||||
if not keywords.is_empty():
|
||||
var kws: Array[String] = []
|
||||
for k in Cards.KEYWORDS:
|
||||
if k in keywords:
|
||||
kws.append(k)
|
||||
parts.append(", ".join(kws) + ".")
|
||||
if not effects.is_empty():
|
||||
var fx: Array[String] = []
|
||||
for f in effects:
|
||||
fx.append(f.text())
|
||||
var s := ", ".join(fx) + "."
|
||||
if is_trap():
|
||||
if trigger == "creature_played":
|
||||
s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird")
|
||||
parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s])
|
||||
else:
|
||||
parts.append(("Beim Ausspielen: " + s) if is_creature() else s)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
static var _all: Array = []
|
||||
static var _by_id: Dictionary = {}
|
||||
|
||||
|
||||
static func all() -> Array:
|
||||
if _all.is_empty():
|
||||
_build()
|
||||
return _all
|
||||
|
||||
|
||||
static func by_id(id: String) -> Card:
|
||||
if _all.is_empty():
|
||||
_build()
|
||||
return _by_id[id]
|
||||
|
||||
|
||||
static func has_id(id) -> bool:
|
||||
if _all.is_empty():
|
||||
_build()
|
||||
return _by_id.has(id)
|
||||
|
||||
|
||||
static func of_rarity(r: String) -> Array:
|
||||
return all().filter(func(c): return c.rarity == r)
|
||||
|
||||
|
||||
## Lädt den Kartenpool aus data/karten.json.
|
||||
static func _build() -> void:
|
||||
var json := JSON.new()
|
||||
var ok := json.parse(FileAccess.get_file_as_string(DATA_PATH))
|
||||
assert(ok == OK and json.data is Dictionary and json.data.has("karten"), "data/karten.json fehlt oder ist ungültig")
|
||||
_all = []
|
||||
_by_id = {}
|
||||
for d in json.data.karten:
|
||||
var c := Card.from_dict(d)
|
||||
assert(not _by_id.has(c.id), "Karten-ID doppelt: " + c.id)
|
||||
_by_id[c.id] = c
|
||||
_all.append(c)
|
||||
1
scripts/cards.gd.uid
Normal file
1
scripts/cards.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dmhun2kwnifg5
|
||||
33
scripts/challenge.gd
Normal file
33
scripts/challenge.gd
Normal file
@@ -0,0 +1,33 @@
|
||||
class_name Challenge
|
||||
## Wöchentliche Herausforderung: feste Regelvariante, einmal pro Woche belohnt.
|
||||
|
||||
const LIST := [
|
||||
{"id": "eile", "name": "Eile", "text": "Alle beginnen mit 3 Energie und 20 Leben, drei Gegner.", "n_opp": 3,
|
||||
"rules": {"everyone_perk": "start_energie", "start_life": 20}, "reward": 200},
|
||||
{"id": "resonanz", "name": "Resonanzsturm", "text": "Resonanz zählt für alle doppelt, zwei Gegner.", "n_opp": 2,
|
||||
"rules": {"everyone_perk": "resonanz_doppelt"}, "reward": 180},
|
||||
{"id": "flink", "name": "Blitzkrieg", "text": "Alle Wesen sind flink, vier Gegner.", "n_opp": 4,
|
||||
"rules": {"everyone_perk": "alle_flink"}, "reward": 250},
|
||||
{"id": "schild", "name": "Panzerschlacht", "text": "Alle Wesen kommen mit Schild, Duell mit 40 Leben.", "n_opp": 1,
|
||||
"rules": {"everyone_perk": "schild_wesen", "start_life": 40}, "reward": 150},
|
||||
{"id": "karten", "name": "Kartenflut", "text": "Jeder zieht 2 Karten pro Zug, fünf Gegner am Tisch.", "n_opp": 5,
|
||||
"rules": {"everyone_perk": "extra_karten"}, "reward": 300},
|
||||
{"id": "mono", "name": "Reine Lehre", "text": "Nur Decks mit einem Element sind erlaubt, drei Gegner.", "n_opp": 3,
|
||||
"rules": {"mono_only": true}, "reward": 220},
|
||||
]
|
||||
|
||||
|
||||
static func week() -> int:
|
||||
var t := Profile.today()
|
||||
return int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 604800)
|
||||
|
||||
|
||||
static func current() -> Dictionary:
|
||||
return LIST[week() % LIST.size()]
|
||||
|
||||
|
||||
## Prüft, ob das Deck zur Herausforderung passt. Gibt "" oder eine Fehlermeldung zurück.
|
||||
static func deck_allowed(ch: Dictionary, deck: Array) -> String:
|
||||
if ch.rules.get("mono_only", false) and Game.deck_elements(deck).size() != 1:
|
||||
return "Diese Woche sind nur Decks mit einem Element erlaubt"
|
||||
return ""
|
||||
1
scripts/challenge.gd.uid
Normal file
1
scripts/challenge.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://n5h6q6kfoo34
|
||||
854
scripts/game.gd
Normal file
854
scripts/game.gd
Normal file
@@ -0,0 +1,854 @@
|
||||
class_name Game
|
||||
extends RefCounted
|
||||
## Spielregeln: Spieler, Wesen, Züge, Angriffe, Effekte, Feldkombos, Resonanz.
|
||||
|
||||
const MAX_BOARD := 5
|
||||
const MAX_HAND := 8
|
||||
const MAX_ENERGY := 10
|
||||
const START_HAND := 4
|
||||
# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben.
|
||||
const START_LIFE := 30
|
||||
const MAX_OPPONENTS := 5
|
||||
|
||||
const RESONANCE_MAX := 4
|
||||
const MAX_TRAPS := 2
|
||||
|
||||
# Merkmale der Kampagnen-Bosse (Player.perks).
|
||||
const PERKS := {
|
||||
"feuer_angriff": "Feuerwesen haben +1 Angriff",
|
||||
"flink_angriff": "Flinke Wesen haben +1 Angriff",
|
||||
"waechter_leben": "Wächter kommen mit +2 Leben",
|
||||
"extra_karten": "Zieht 2 Karten pro Zug",
|
||||
"leben_35": "Beginnt mit 35 Leben",
|
||||
"zauber_schaden": "Schadenszauber machen +1 Schaden",
|
||||
"schild_wesen": "Wesen kommen mit Schild",
|
||||
"alle_flink": "Alle Wesen sind flink",
|
||||
"start_energie": "Beginnt mit 3 Energie",
|
||||
"resonanz_doppelt": "Resonanz zählt doppelt",
|
||||
}
|
||||
|
||||
## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch).
|
||||
static var test_seed := 0
|
||||
|
||||
|
||||
static func make_rng() -> RandomNumberGenerator:
|
||||
var r := RandomNumberGenerator.new()
|
||||
if test_seed != 0:
|
||||
r.seed = test_seed
|
||||
test_seed += 1
|
||||
else:
|
||||
r.randomize()
|
||||
return r
|
||||
|
||||
const DECK_SIZE := 20
|
||||
const MAX_COPIES := 3
|
||||
const MAX_DECK_ELEMENTS := 2
|
||||
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
|
||||
|
||||
|
||||
class Creature extends RefCounted:
|
||||
var card: Cards.Card
|
||||
var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen
|
||||
var name: String
|
||||
var uid: int
|
||||
var attack: int
|
||||
var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt
|
||||
var health: int
|
||||
var max_health: int
|
||||
var shield: bool
|
||||
var frozen := false
|
||||
var ready: bool
|
||||
var attacks_left: int
|
||||
|
||||
func _init(c: Cards.Card, o, id: int) -> void:
|
||||
card = c
|
||||
owner_ref = weakref(o)
|
||||
name = c.name
|
||||
uid = id
|
||||
attack = c.attack
|
||||
health = c.health
|
||||
max_health = c.health
|
||||
shield = "Schild" in c.keywords
|
||||
ready = "Flink" in c.keywords
|
||||
attacks_left = attacks_per_turn() if ready else 0
|
||||
|
||||
func owner():
|
||||
return owner_ref.get_ref()
|
||||
|
||||
## Angriffswert inklusive Resonanz und Boss-Merkmalen.
|
||||
func power() -> int:
|
||||
var v := attack + resonance
|
||||
var o = owner()
|
||||
if o != null:
|
||||
if "feuer_angriff" in o.perks and "Feuer" in card.elements:
|
||||
v += 1
|
||||
if "flink_angriff" in o.perks and "Flink" in card.keywords:
|
||||
v += 1
|
||||
return v
|
||||
|
||||
func attacks_per_turn() -> int:
|
||||
return 2 if "Doppelschlag" in card.keywords else 1
|
||||
|
||||
func taunt() -> bool:
|
||||
return "Wächter" in card.keywords
|
||||
|
||||
func can_attack() -> bool:
|
||||
return ready and attacks_left > 0 and not frozen and power() > 0
|
||||
|
||||
func clone(new_owner) -> Creature:
|
||||
var c := Creature.new(card, new_owner, uid)
|
||||
c.attack = attack
|
||||
c.resonance = resonance
|
||||
c.health = health
|
||||
c.max_health = max_health
|
||||
c.shield = shield
|
||||
c.frozen = frozen
|
||||
c.ready = ready
|
||||
c.attacks_left = attacks_left
|
||||
return c
|
||||
|
||||
|
||||
class Player extends RefCounted:
|
||||
var name: String
|
||||
var title: String
|
||||
var is_human: bool
|
||||
var life: int
|
||||
var max_life: int
|
||||
var deck: Array = []
|
||||
var hand: Array = []
|
||||
var board: Array = []
|
||||
var energy := 0
|
||||
var max_energy := 0
|
||||
var fatigue := 0
|
||||
var start_bonus := 0 # einmalige Extra-Energie im ersten Zug (Ausgleich fürs Nachziehen)
|
||||
var traps: Array = [] # verdeckt liegende Fallenkarten
|
||||
var order := 0 # Platz in der Zugreihenfolge
|
||||
var perks: Array = [] # Boss-Merkmale (siehe PERKS)
|
||||
var turns := 0 # begonnene eigene Züge
|
||||
var mulligan_done := false
|
||||
|
||||
func _init(n: String, d: Array, h: bool, l: int, t: String = "") -> void:
|
||||
name = n
|
||||
title = t
|
||||
is_human = h
|
||||
life = l
|
||||
max_life = l
|
||||
deck = d.duplicate()
|
||||
|
||||
func alive() -> bool:
|
||||
return life > 0
|
||||
|
||||
func clone() -> Player:
|
||||
var p := Player.new(name, deck, is_human, life, title)
|
||||
p.max_life = max_life
|
||||
p.hand = hand.duplicate()
|
||||
p.energy = energy
|
||||
p.max_energy = max_energy
|
||||
p.fatigue = fatigue
|
||||
p.start_bonus = start_bonus
|
||||
p.traps = traps.duplicate()
|
||||
p.order = order
|
||||
p.perks = perks.duplicate()
|
||||
p.turns = turns
|
||||
p.mulligan_done = mulligan_done
|
||||
for c in board:
|
||||
p.board.append(c.clone(p))
|
||||
return p
|
||||
|
||||
|
||||
var human: Player # der (erste) menschliche Spieler
|
||||
var humans: Array = [] # alle menschlichen Spieler (Hotseat: mehrere)
|
||||
var opponents: Array = [] # alle außer dem ersten Menschen
|
||||
var players: Array = [] # in Zugreihenfolge
|
||||
var winner_player: Player = null
|
||||
var rng: RandomNumberGenerator
|
||||
var turn := 0
|
||||
var current_idx := 0
|
||||
var log: Array[String] = []
|
||||
var events: Array = [] # strukturierte Ereignisse für Animationen
|
||||
var winner := "" # "", "human" oder "opponents"
|
||||
var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse
|
||||
var next_uid := 1
|
||||
|
||||
|
||||
func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void:
|
||||
human = h
|
||||
opponents = opps
|
||||
players = [h] + opps
|
||||
humans = players.filter(func(p): return p.is_human)
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
rng = r
|
||||
|
||||
|
||||
func current() -> Player:
|
||||
return players[current_idx]
|
||||
|
||||
|
||||
func say(t: String) -> void:
|
||||
if not quiet:
|
||||
log.append(t)
|
||||
|
||||
|
||||
func emit(ev: Dictionary) -> void:
|
||||
if not quiet:
|
||||
events.append(ev)
|
||||
|
||||
|
||||
func ref_of(target) -> Dictionary:
|
||||
if target is Creature:
|
||||
return {"kind": "creature", "uid": target.uid}
|
||||
return {"kind": "player", "index": players.find(target)}
|
||||
|
||||
|
||||
## Jeder gegen jeden: alle anderen lebenden Spieler sind Gegner.
|
||||
func enemies_of(p: Player) -> Array:
|
||||
return players.filter(func(o): return o != p and o.alive())
|
||||
|
||||
|
||||
func find_creature(uid: int) -> Creature:
|
||||
for p in players:
|
||||
for c in p.board:
|
||||
if c.uid == uid:
|
||||
return c
|
||||
return null
|
||||
|
||||
|
||||
## Unabhängige Kopie des Spielzustands (für die KI-Vorausschau).
|
||||
func clone() -> Game:
|
||||
var copies := []
|
||||
for p in players:
|
||||
copies.append(p.clone())
|
||||
var idx := players.find(human)
|
||||
var g := Game.new(copies[idx], copies.slice(0, idx) + copies.slice(idx + 1), RandomNumberGenerator.new())
|
||||
g.players = copies # gleiche Zugreihenfolge wie das Original
|
||||
g.turn = turn
|
||||
g.current_idx = current_idx
|
||||
g.winner = winner
|
||||
g.next_uid = next_uid
|
||||
g.quiet = true
|
||||
return g
|
||||
|
||||
|
||||
## Kopie aus Sicht von p: fremde Fallen sind unbekannt und fehlen (die KI schummelt nicht).
|
||||
func clone_for(p: Player) -> Game:
|
||||
var g := clone()
|
||||
var p2: Player = g.players[players.find(p)]
|
||||
for q in g.players:
|
||||
if q != p2:
|
||||
q.traps = []
|
||||
return g
|
||||
|
||||
|
||||
# --- Zugablauf ---------------------------------------------------------------
|
||||
|
||||
func start(fixed_order: bool = false) -> void:
|
||||
if not fixed_order:
|
||||
shuffle(players, rng) # zufällige Zugreihenfolge
|
||||
for i in players.size():
|
||||
var p: Player = players[i]
|
||||
p.order = i
|
||||
if "start_energie" in p.perks:
|
||||
p.max_energy = 2
|
||||
shuffle(p.deck, rng)
|
||||
# Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug
|
||||
# einmalig 1 Energie extra (Ausgleich für den Startvorteil).
|
||||
draw(p, START_HAND + (0 if i == 0 else 1))
|
||||
p.start_bonus = 0 if i == 0 else 1
|
||||
turn = 1
|
||||
current_idx = 0
|
||||
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
|
||||
for p in players:
|
||||
for perk in p.perks:
|
||||
say("%s: %s." % [p.name, PERKS[perk]])
|
||||
begin_turn(current())
|
||||
|
||||
|
||||
func draw(p: Player, n: int = 1) -> void:
|
||||
for i in n:
|
||||
if p.deck.is_empty():
|
||||
p.fatigue += 1
|
||||
say("%s hat keine Karten mehr und erleidet %d Schaden." % [p.name, p.fatigue])
|
||||
damage(p, p.fatigue)
|
||||
continue
|
||||
var card = p.deck.pop_back()
|
||||
if p.hand.size() >= MAX_HAND:
|
||||
say("%s: Hand voll, %s verbrennt." % [p.name, card.name])
|
||||
else:
|
||||
p.hand.append(card)
|
||||
check_end()
|
||||
|
||||
|
||||
func begin_turn(p: Player) -> void:
|
||||
p.max_energy = mini(MAX_ENERGY, p.max_energy + 1)
|
||||
p.energy = p.max_energy
|
||||
if p.start_bonus > 0:
|
||||
p.energy += p.start_bonus
|
||||
say("%s erhält %d Energie extra für den späteren Start." % [p.name, p.start_bonus])
|
||||
p.start_bonus = 0
|
||||
for c in p.board:
|
||||
c.ready = true
|
||||
if c.frozen:
|
||||
c.frozen = false
|
||||
c.attacks_left = 0
|
||||
say("%s taut auf und setzt aus." % c.name)
|
||||
else:
|
||||
c.attacks_left = c.attacks_per_turn()
|
||||
p.turns += 1
|
||||
say("--- Zug %d: %s ---" % [turn, p.name])
|
||||
emit({"type": "turn", "player": players.find(p)})
|
||||
draw(p, 2 if "extra_karten" in p.perks else 1)
|
||||
|
||||
|
||||
## Tauscht einmalig im ersten eigenen Zug Karten der Hand: zurück ins Deck, mischen, nachziehen.
|
||||
func mulligan(p: Player, cards: Array) -> String:
|
||||
if p.mulligan_done:
|
||||
return "Schon getauscht"
|
||||
if p != current() or p.turns != 1:
|
||||
return "Nur im ersten eigenen Zug"
|
||||
p.mulligan_done = true
|
||||
var n := 0
|
||||
for c in cards:
|
||||
if c in p.hand:
|
||||
p.hand.erase(c)
|
||||
p.deck.append(c)
|
||||
n += 1
|
||||
if n > 0:
|
||||
shuffle(p.deck, rng)
|
||||
draw(p, n)
|
||||
say("%s tauscht %d Karten." % [p.name, n] if n > 0 else "%s behält die Hand." % p.name)
|
||||
emit({"type": "mulligan", "player": players.find(p), "count": n})
|
||||
return ""
|
||||
|
||||
|
||||
func end_turn() -> void:
|
||||
if winner != "":
|
||||
return
|
||||
var n := players.size()
|
||||
for i in range(1, n + 1):
|
||||
var idx := (current_idx + i) % n
|
||||
if players[idx].alive():
|
||||
if idx <= current_idx:
|
||||
turn += 1
|
||||
current_idx = idx
|
||||
break
|
||||
begin_turn(current())
|
||||
|
||||
|
||||
# --- Karten spielen ----------------------------------------------------------
|
||||
|
||||
func can_play(p: Player, card: Cards.Card) -> Array:
|
||||
if winner != "" or p != current():
|
||||
return [false, "Nicht dein Zug"]
|
||||
if p.energy < card.cost:
|
||||
return [false, "Nicht genug Energie"]
|
||||
if card.is_creature() and p.board.size() >= MAX_BOARD:
|
||||
return [false, "Dein Feld ist voll"]
|
||||
if card.is_trap() and p.traps.size() >= MAX_TRAPS:
|
||||
return [false, "Höchstens %d Fallen" % MAX_TRAPS]
|
||||
var tt := card.target_type()
|
||||
if tt != "" and not card.is_creature() and valid_targets(p, tt).is_empty():
|
||||
return [false, "Kein gültiges Ziel"]
|
||||
return [true, ""]
|
||||
|
||||
|
||||
func valid_targets(p: Player, target_type: String) -> Array:
|
||||
var enemies := enemies_of(p)
|
||||
var creatures := []
|
||||
for e in enemies:
|
||||
creatures.append_array(e.board)
|
||||
match target_type:
|
||||
"enemy_creature":
|
||||
return creatures
|
||||
"enemy_any":
|
||||
return creatures + enemies
|
||||
"friendly_creature":
|
||||
return p.board.duplicate()
|
||||
return []
|
||||
|
||||
|
||||
## Spielt eine Karte. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func play_card(p: Player, card: Cards.Card, target = null) -> String:
|
||||
var ok := can_play(p, card)
|
||||
if not ok[0]:
|
||||
return ok[1]
|
||||
var tt := card.target_type()
|
||||
if tt != "":
|
||||
var valid := valid_targets(p, tt)
|
||||
if not valid.is_empty() and not (target in valid):
|
||||
return "Ungültiges Ziel"
|
||||
if valid.is_empty():
|
||||
target = null
|
||||
p.hand.erase(card)
|
||||
p.energy -= card.cost
|
||||
if card.is_trap():
|
||||
p.traps.append(card)
|
||||
say("%s legt eine Falle." % p.name)
|
||||
emit({"type": "trap_set", "player": players.find(p)})
|
||||
check_end()
|
||||
return ""
|
||||
say("%s spielt %s." % [p.name, card.name])
|
||||
var cr: Creature = null
|
||||
var bonus := 0
|
||||
if card.is_creature():
|
||||
cr = _enter(p, card)
|
||||
else:
|
||||
emit({"type": "spell", "player": players.find(p), "card": card})
|
||||
if "zauber_schaden" in p.perks:
|
||||
bonus = 1
|
||||
for fx in card.effects:
|
||||
resolve(p, fx, target, cr, bonus)
|
||||
if cr != null and cr in p.board:
|
||||
for e in enemies_of(p):
|
||||
if not (cr in p.board):
|
||||
break
|
||||
trigger_trap(e, "creature_played", cr)
|
||||
if cr != null and cr in p.board:
|
||||
trigger_field_combos(p, cr)
|
||||
update_resonance()
|
||||
cleanup()
|
||||
check_end()
|
||||
return ""
|
||||
|
||||
|
||||
## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem
|
||||
## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus.
|
||||
func trigger_field_combos(p: Player, cr: Creature) -> void:
|
||||
for other in p.board:
|
||||
if other == cr:
|
||||
continue
|
||||
for e1 in cr.card.elements:
|
||||
for e2 in other.card.elements:
|
||||
if e1 == e2:
|
||||
continue
|
||||
var name := Cards.combo_name([e1, e2])
|
||||
say("Feldkombo %s: %s." % [name, Cards.FIELD_COMBO_TEXT[name]])
|
||||
emit({"type": "combo", "name": name, "player": players.find(p), "uid": cr.uid})
|
||||
resolve(p, Cards.field_combo_effect(name), null, cr)
|
||||
return
|
||||
|
||||
|
||||
## Löst die erste passende Falle von owner aus. Gibt true zurück, wenn der
|
||||
## auslösende Angriff abgebrochen wird.
|
||||
func trigger_trap(owner: Player, trigger: String, source: Creature) -> bool:
|
||||
for trap in owner.traps:
|
||||
if trap.trigger != trigger:
|
||||
continue
|
||||
owner.traps.erase(trap)
|
||||
say("Falle von %s: %s!" % [owner.name, trap.name])
|
||||
emit({"type": "trap", "player": players.find(owner), "card": trap, "source": ref_of(source)})
|
||||
var cancel := false
|
||||
for fx in trap.effects:
|
||||
if fx.kind == "cancel":
|
||||
cancel = true
|
||||
else:
|
||||
resolve(owner, fx, null, source)
|
||||
cleanup()
|
||||
return cancel
|
||||
return false
|
||||
|
||||
|
||||
## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt.
|
||||
func _enter(p: Player, card: Cards.Card) -> Creature:
|
||||
var cr := Creature.new(card, p, next_uid)
|
||||
next_uid += 1
|
||||
if "waechter_leben" in p.perks and cr.taunt():
|
||||
cr.health += 2
|
||||
cr.max_health += 2
|
||||
if "schild_wesen" in p.perks:
|
||||
cr.shield = true
|
||||
if "alle_flink" in p.perks:
|
||||
cr.ready = true
|
||||
cr.attacks_left = cr.attacks_per_turn()
|
||||
p.board.append(cr)
|
||||
emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card})
|
||||
return cr
|
||||
|
||||
|
||||
## Setzt ein Wesen aufs Feld (Fallen, Effekte).
|
||||
func summon(p: Player, card: Cards.Card) -> void:
|
||||
if p.board.size() >= MAX_BOARD:
|
||||
say("%s: kein Platz für %s." % [p.name, card.name])
|
||||
return
|
||||
_enter(p, card)
|
||||
say("%s beschwört %s." % [p.name, card.name])
|
||||
update_resonance()
|
||||
|
||||
|
||||
## Resonanz: +1 Angriff je anderem eigenen Wesen, das ein Element teilt (höchstens +4).
|
||||
func update_resonance() -> void:
|
||||
for p in players:
|
||||
for c in p.board:
|
||||
var bonus := 0
|
||||
for o in p.board:
|
||||
if o != c and o.card.elements.any(func(e): return e in c.card.elements):
|
||||
bonus += 1
|
||||
c.resonance = mini(bonus, RESONANCE_MAX) * (2 if "resonanz_doppelt" in p.perks else 1)
|
||||
|
||||
|
||||
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void:
|
||||
var enemies := enemies_of(p)
|
||||
var enemy_creatures := []
|
||||
for e in enemies:
|
||||
enemy_creatures.append_array(e.board)
|
||||
var targets := []
|
||||
if fx.target in Cards.CHOSEN_TARGETS:
|
||||
targets = [target] if target != null else []
|
||||
elif fx.target == "all_enemy_creatures":
|
||||
targets = enemy_creatures
|
||||
elif fx.target == "all_enemy_heroes":
|
||||
targets = enemies.duplicate()
|
||||
elif fx.target == "all_enemies":
|
||||
targets = enemy_creatures + enemies
|
||||
elif fx.target == "all_friendly_creatures":
|
||||
targets = p.board.duplicate()
|
||||
elif fx.target == "self_hero":
|
||||
targets = [p]
|
||||
elif fx.target == "self_creature" or fx.target == "trigger_source":
|
||||
targets = [source] if source != null else []
|
||||
elif fx.target == "strongest_enemy_creature":
|
||||
var best: Creature = null
|
||||
for c in enemy_creatures:
|
||||
if best == null or c.power() > best.power():
|
||||
best = c
|
||||
targets = [best] if best != null else []
|
||||
elif fx.target == "weakest_enemy_hero":
|
||||
var weakest: Player = null
|
||||
for e in enemies:
|
||||
if weakest == null or e.life < weakest.life:
|
||||
weakest = e
|
||||
targets = [weakest] if weakest != null else []
|
||||
else:
|
||||
targets = [null]
|
||||
for t in targets:
|
||||
match fx.kind:
|
||||
"damage":
|
||||
damage(t, fx.amount + bonus)
|
||||
"heal":
|
||||
t.life = mini(t.max_life, t.life + fx.amount)
|
||||
say("%s heilt sich um %d." % [t.name, fx.amount])
|
||||
emit({"type": "heal", "target": ref_of(t), "amount": fx.amount})
|
||||
"draw":
|
||||
draw(p, fx.amount)
|
||||
"freeze":
|
||||
t.frozen = true
|
||||
t.attacks_left = 0
|
||||
say("%s ist eingefroren." % t.name)
|
||||
emit({"type": "freeze", "target": ref_of(t)})
|
||||
"bounce":
|
||||
var o = t.owner()
|
||||
o.board.erase(t)
|
||||
emit({"type": "bounce", "target": ref_of(t)})
|
||||
if o.hand.size() < MAX_HAND:
|
||||
o.hand.append(t.card)
|
||||
say("%s kehrt auf die Hand zurück." % t.name)
|
||||
else:
|
||||
say("%s wird weggeweht (Hand voll)." % t.name)
|
||||
"buff":
|
||||
t.attack += fx.amount
|
||||
t.health += fx.amount2
|
||||
t.max_health += fx.amount2
|
||||
say("%s erhält +%d/+%d." % [t.name, fx.amount, fx.amount2])
|
||||
emit({"type": "buff", "target": ref_of(t), "text": "+%d/+%d" % [fx.amount, fx.amount2]})
|
||||
"shield":
|
||||
t.shield = true
|
||||
say("%s erhält Schild." % t.name)
|
||||
emit({"type": "buff", "target": ref_of(t), "text": "Schild"})
|
||||
"summon":
|
||||
summon(p, Cards.by_id(fx.card_id))
|
||||
|
||||
|
||||
# --- Kampf -------------------------------------------------------------------
|
||||
|
||||
func can_attack(cr: Creature, target) -> Array:
|
||||
if winner != "" or cr.owner() != current():
|
||||
return [false, "Nicht dein Zug"]
|
||||
if not cr.can_attack():
|
||||
return [false, "%s kann nicht angreifen" % cr.name]
|
||||
var tgt_owner = target.owner() if target is Creature else target
|
||||
if not (tgt_owner in enemies_of(cr.owner())):
|
||||
return [false, "Kein gültiges Ziel"]
|
||||
var has_taunt := false
|
||||
for c in tgt_owner.board:
|
||||
if c.taunt():
|
||||
has_taunt = true
|
||||
if has_taunt and not (target is Creature and target.taunt()):
|
||||
return [false, "Ein Wächter muss zuerst angegriffen werden"]
|
||||
return [true, ""]
|
||||
|
||||
|
||||
## Greift an. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func attack(cr: Creature, target) -> String:
|
||||
var ok := can_attack(cr, target)
|
||||
if not ok[0]:
|
||||
return ok[1]
|
||||
cr.attacks_left -= 1
|
||||
say("%s greift %s an." % [cr.name, target.name])
|
||||
emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)})
|
||||
var tgt_owner: Player = target.owner() if target is Creature else target
|
||||
var cancelled := trigger_trap(tgt_owner, "attacked", cr)
|
||||
if cancelled or not (cr in cr.owner().board) or (target is Creature and not (target in tgt_owner.board)):
|
||||
check_end()
|
||||
return ""
|
||||
damage(target, cr.power())
|
||||
if target is Creature:
|
||||
damage(cr, target.power())
|
||||
cleanup()
|
||||
check_end()
|
||||
return ""
|
||||
|
||||
|
||||
func damage(target, amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
if target is Creature:
|
||||
if target.shield:
|
||||
target.shield = false
|
||||
say("Schild von %s blockt den Schaden." % target.name)
|
||||
emit({"type": "blocked", "target": ref_of(target)})
|
||||
return
|
||||
target.health -= amount
|
||||
else:
|
||||
target.life -= amount
|
||||
say("%s erleidet %d Schaden." % [target.name, amount])
|
||||
emit({"type": "damage", "target": ref_of(target), "amount": amount})
|
||||
|
||||
|
||||
func cleanup() -> void:
|
||||
var died := false
|
||||
for p in players:
|
||||
for c in p.board.duplicate():
|
||||
if c.health <= 0:
|
||||
p.board.erase(c)
|
||||
say("%s stirbt." % c.name)
|
||||
emit({"type": "death", "target": ref_of(c)})
|
||||
died = true
|
||||
if died:
|
||||
update_resonance()
|
||||
|
||||
|
||||
## Ende: nur noch ein Spieler übrig, oder kein Mensch mehr am Tisch.
|
||||
func check_end() -> void:
|
||||
if winner != "":
|
||||
return
|
||||
var alive := players.filter(func(p): return p.alive())
|
||||
var humans_alive := alive.filter(func(p): return p.is_human)
|
||||
if humans_alive.is_empty():
|
||||
winner = "opponents"
|
||||
winner_player = alive[0] if alive.size() == 1 else null
|
||||
say("Du wurdest besiegt." if humans.size() == 1 else "Alle Spieler sind besiegt, die KI gewinnt.")
|
||||
elif alive.size() == 1:
|
||||
winner = "human"
|
||||
winner_player = alive[0]
|
||||
say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name)
|
||||
|
||||
|
||||
# --- Zustand für das Netzwerk -------------------------------------------------
|
||||
|
||||
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
|
||||
func snapshot(viewer: int) -> Dictionary:
|
||||
var ps := []
|
||||
for i in players.size():
|
||||
var p: Player = players[i]
|
||||
var mine := i == viewer
|
||||
var board := []
|
||||
for c in p.board:
|
||||
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
|
||||
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
|
||||
"ready": c.ready, "attacks_left": c.attacks_left})
|
||||
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
|
||||
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
|
||||
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
|
||||
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
|
||||
"deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
|
||||
"board": board})
|
||||
var evs := []
|
||||
for ev in events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = e.card.id
|
||||
evs.append(e)
|
||||
return {"players": ps, "human": players.find(human), "turn": turn, "current_idx": current_idx,
|
||||
"winner": winner, "winner_player": players.find(winner_player) if winner_player != null else -1,
|
||||
"next_uid": next_uid, "log": log.slice(maxi(0, log.size() - 80)), "events": evs}
|
||||
|
||||
|
||||
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
|
||||
static func from_snapshot(d: Dictionary) -> Game:
|
||||
var placeholder: Cards.Card = Cards.all()[0]
|
||||
var list := []
|
||||
for pd in d.players:
|
||||
var p := Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
|
||||
p.max_life = int(pd.max_life)
|
||||
p.energy = int(pd.energy)
|
||||
p.max_energy = int(pd.max_energy)
|
||||
p.fatigue = int(pd.fatigue)
|
||||
p.start_bonus = int(pd.start_bonus)
|
||||
p.order = int(pd.order)
|
||||
p.perks = Array(pd.perks)
|
||||
p.turns = int(pd.turns)
|
||||
p.mulligan_done = bool(pd.mulligan_done)
|
||||
if pd.hand is Array:
|
||||
for cid in pd.hand:
|
||||
p.hand.append(Cards.by_id(cid))
|
||||
else:
|
||||
for i in int(pd.hand):
|
||||
p.hand.append(placeholder)
|
||||
for i in int(pd.deck):
|
||||
p.deck.append(placeholder)
|
||||
if pd.traps is Array:
|
||||
for cid in pd.traps:
|
||||
p.traps.append(Cards.by_id(cid))
|
||||
else:
|
||||
for i in int(pd.traps):
|
||||
p.traps.append(placeholder)
|
||||
for cd in pd.board:
|
||||
var c := Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
|
||||
c.attack = int(cd.attack)
|
||||
c.resonance = int(cd.resonance)
|
||||
c.health = int(cd.health)
|
||||
c.max_health = int(cd.max_health)
|
||||
c.shield = bool(cd.shield)
|
||||
c.frozen = bool(cd.frozen)
|
||||
c.ready = bool(cd.ready)
|
||||
c.attacks_left = int(cd.attacks_left)
|
||||
p.board.append(c)
|
||||
list.append(p)
|
||||
var hi := int(d.human)
|
||||
var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new())
|
||||
g.players = list
|
||||
g.turn = int(d.turn)
|
||||
g.current_idx = int(d.current_idx)
|
||||
g.winner = d.winner
|
||||
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
|
||||
g.next_uid = int(d.next_uid)
|
||||
for line in d.log:
|
||||
g.log.append(str(line))
|
||||
for ev in d.events:
|
||||
var e: Dictionary = ev.duplicate()
|
||||
if e.has("card"):
|
||||
e["card"] = Cards.by_id(e.card)
|
||||
for key in ["player", "uid", "count", "amount"]:
|
||||
if e.has(key):
|
||||
e[key] = int(e[key])
|
||||
for key in ["target", "attacker", "source"]:
|
||||
if e.has(key):
|
||||
var r: Dictionary = e[key]
|
||||
if r.has("uid"):
|
||||
r["uid"] = int(r.uid)
|
||||
if r.has("index"):
|
||||
r["index"] = int(r.index)
|
||||
g.events.append(e)
|
||||
return g
|
||||
|
||||
|
||||
# --- Deckregeln und Gegner ---------------------------------------------------
|
||||
|
||||
static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
|
||||
for i in range(arr.size() - 1, 0, -1):
|
||||
var j := r.randi_range(0, i)
|
||||
var tmp = arr[i]
|
||||
arr[i] = arr[j]
|
||||
arr[j] = tmp
|
||||
|
||||
|
||||
static func deck_elements(cards: Array) -> Array:
|
||||
var out := []
|
||||
for c in cards:
|
||||
for e in c.elements:
|
||||
if not (e in out):
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
## Prüft ein Deck. owned: Dictionary id -> Anzahl (null = keine Besitzprüfung).
|
||||
static func validate_deck(cards: Array, owned = null) -> Array:
|
||||
if cards.size() != DECK_SIZE:
|
||||
return [false, "Deck braucht genau %d Karten (%d)" % [DECK_SIZE, cards.size()]]
|
||||
var counts := {}
|
||||
for c in cards:
|
||||
counts[c.id] = counts.get(c.id, 0) + 1
|
||||
for cid in counts:
|
||||
if counts[cid] > MAX_COPIES:
|
||||
return [false, "Höchstens %dx dieselbe Karte" % MAX_COPIES]
|
||||
if owned != null and owned.get(cid, 0) < counts[cid]:
|
||||
return [false, "Karte nicht oft genug in der Sammlung"]
|
||||
if deck_elements(cards).size() > MAX_DECK_ELEMENTS:
|
||||
return [false, "Höchstens %d Elemente im Deck" % MAX_DECK_ELEMENTS]
|
||||
return [true, ""]
|
||||
|
||||
|
||||
## Gegnerdeck aus den Elementen. bias > 1 bevorzugt seltene Karten (Kampagne).
|
||||
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
|
||||
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements))
|
||||
var weights := PackedFloat32Array()
|
||||
for c in pool:
|
||||
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity]
|
||||
weights.append(w / bias if c.rarity == "gewöhnlich" else w * bias)
|
||||
var deck := []
|
||||
var counts := {}
|
||||
while deck.size() < DECK_SIZE:
|
||||
var c = pool[r.rand_weighted(weights)]
|
||||
if counts.get(c.id, 0) < MAX_COPIES:
|
||||
counts[c.id] = counts.get(c.id, 0) + 1
|
||||
deck.append(c)
|
||||
return deck
|
||||
|
||||
|
||||
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE) -> Player:
|
||||
var elements: Array
|
||||
if r.randf() < 0.4:
|
||||
elements = [Cards.ELEMENTS[r.randi_range(0, Cards.ELEMENTS.size() - 1)]]
|
||||
else:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
shuffle(pool, r)
|
||||
elements = [pool[0], pool[1]]
|
||||
return Player.new("Gegner %d" % index, build_opponent_deck(r, elements), false, life,
|
||||
Cards.combo_name(elements) + "-Magier")
|
||||
|
||||
|
||||
## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int}
|
||||
static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
|
||||
assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner")
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
var opps := []
|
||||
for i in n_opp:
|
||||
opps.append(random_opponent(r, i + 1))
|
||||
return new_battle_with(deck, opps, r, false, rules)
|
||||
|
||||
|
||||
static func new_battle_with(deck: Array, opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
var human := Player.new("Du", deck, true, START_LIFE)
|
||||
human.perks = Array(rules.get("human_perks", []))
|
||||
var g := Game.new(human, opps, r)
|
||||
var everyone: String = rules.get("everyone_perk", "")
|
||||
for p in g.players:
|
||||
if everyone != "" and not (everyone in p.perks):
|
||||
p.perks.append(everyone)
|
||||
if rules.has("start_life"):
|
||||
p.life = int(rules.start_life)
|
||||
p.max_life = p.life
|
||||
elif "leben_35" in p.perks and p.life == START_LIFE:
|
||||
p.life = 35
|
||||
p.max_life = 35
|
||||
g.start(fixed_order)
|
||||
return g
|
||||
|
||||
|
||||
## Hotseat: mehrere Menschen am selben Rechner, dazu KI-Gegner.
|
||||
static func new_hotseat(human_decks: Array, n_ai: int, r: RandomNumberGenerator = null) -> Game:
|
||||
assert(human_decks.size() >= 1 and human_decks.size() + n_ai <= MAX_OPPONENTS + 1, "höchstens 6 am Tisch")
|
||||
if r == null:
|
||||
r = make_rng()
|
||||
var first := Player.new("Spieler 1", human_decks[0], true, START_LIFE)
|
||||
var others := []
|
||||
for i in range(1, human_decks.size()):
|
||||
others.append(Player.new("Spieler %d" % (i + 1), human_decks[i], true, START_LIFE))
|
||||
for i in n_ai:
|
||||
others.append(random_opponent(r, i + 1))
|
||||
var g := Game.new(first, others, r)
|
||||
g.start()
|
||||
return g
|
||||
1
scripts/game.gd.uid
Normal file
1
scripts/game.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2f2wrhtkb85h
|
||||
279
scripts/net.gd
Normal file
279
scripts/net.gd
Normal file
@@ -0,0 +1,279 @@
|
||||
class_name Net
|
||||
extends Node
|
||||
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
|
||||
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
|
||||
|
||||
signal lobby_changed
|
||||
signal started(seat: int)
|
||||
signal snapshot_received(snap: Dictionary)
|
||||
signal action_applied
|
||||
signal ended(reason: String)
|
||||
|
||||
const PORT := 24455
|
||||
|
||||
var peer: ENetMultiplayerPeer
|
||||
var is_host := false
|
||||
var connected := false
|
||||
var my_name := "Spieler"
|
||||
var my_deck: Array = [] # Karten-IDs
|
||||
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
|
||||
var decks := {} # peer_id -> Karten-IDs (Host)
|
||||
var seats := {} # peer_id -> Spielerindex (Host)
|
||||
var my_seat := -1
|
||||
var game: Game # Host
|
||||
var status := ""
|
||||
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
|
||||
const REJOIN_SECONDS := 60.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connected)
|
||||
multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen"))
|
||||
multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet"))
|
||||
|
||||
|
||||
func host(port: int = PORT) -> Error:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, Game.MAX_OPPONENTS)
|
||||
if err != OK:
|
||||
status = "Kann nicht hosten (Port %d belegt?)" % port
|
||||
return err
|
||||
multiplayer.multiplayer_peer = peer
|
||||
is_host = true
|
||||
connected = true
|
||||
names = {1: my_name}
|
||||
decks = {1: my_deck}
|
||||
status = "Warte auf Mitspieler …"
|
||||
lobby_changed.emit()
|
||||
return OK
|
||||
|
||||
|
||||
func join(ip: String, port: int = PORT) -> Error:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(ip, port)
|
||||
if err != OK:
|
||||
status = "Adresse ungültig"
|
||||
return err
|
||||
multiplayer.multiplayer_peer = peer
|
||||
is_host = false
|
||||
status = "Verbinde mit %s …" % ip
|
||||
return OK
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
if multiplayer.multiplayer_peer != null:
|
||||
multiplayer.multiplayer_peer.close()
|
||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||
peer = null
|
||||
is_host = false
|
||||
connected = false
|
||||
names = {}
|
||||
decks = {}
|
||||
seats = {}
|
||||
waiting = {}
|
||||
my_seat = -1
|
||||
game = null
|
||||
status = ""
|
||||
|
||||
|
||||
func local_addresses() -> Array:
|
||||
return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127."))
|
||||
|
||||
|
||||
func _fail(reason: String) -> void:
|
||||
status = reason
|
||||
ended.emit(reason)
|
||||
leave()
|
||||
|
||||
|
||||
func _on_connected() -> void:
|
||||
connected = true
|
||||
status = "Verbunden"
|
||||
register.rpc_id(1, my_name, my_deck)
|
||||
|
||||
|
||||
func _on_peer_connected(_id: int) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _on_peer_disconnected(id: int) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
var pname: String = names.get(id, "")
|
||||
names.erase(id)
|
||||
decks.erase(id)
|
||||
if game != null and seats.has(id):
|
||||
var p: Game.Player = game.players[seats[id]]
|
||||
if game.winner == "":
|
||||
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
|
||||
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)])
|
||||
seats.erase(id)
|
||||
action_applied.emit()
|
||||
lobby_update.rpc(names)
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus.
|
||||
func tick() -> void:
|
||||
if not is_host or game == null or game.winner != "":
|
||||
return
|
||||
var now := Time.get_unix_time_from_system()
|
||||
for pname in waiting.keys():
|
||||
var w: Dictionary = waiting[pname]
|
||||
var p: Game.Player = game.players[w.seat]
|
||||
if now - float(w.since) > REJOIN_SECONDS:
|
||||
waiting.erase(pname)
|
||||
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
|
||||
game.damage(p, 999)
|
||||
game.check_end()
|
||||
action_applied.emit()
|
||||
elif game.current() == p:
|
||||
p.mulligan_done = true
|
||||
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
|
||||
game.end_turn()
|
||||
action_applied.emit()
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func register(name: String, deck_ids: Array) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
var id := multiplayer.get_remote_sender_id()
|
||||
if game != null:
|
||||
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
|
||||
if waiting.has(name):
|
||||
seats[id] = waiting[name].seat
|
||||
names[id] = name
|
||||
waiting.erase(name)
|
||||
game.say("%s ist zurück." % name)
|
||||
start_game.rpc_id(id, seats[id])
|
||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
||||
action_applied.emit()
|
||||
else:
|
||||
status = "%s wollte beitreten, das Spiel läuft schon" % name
|
||||
return
|
||||
names[id] = name
|
||||
decks[id] = deck_ids
|
||||
lobby_update.rpc(names)
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func lobby_update(n: Dictionary) -> void:
|
||||
names = n
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten.
|
||||
func start_battle(n_ai: int) -> void:
|
||||
var ids: Array = names.keys()
|
||||
ids.sort()
|
||||
var r := Game.make_rng()
|
||||
var players := []
|
||||
seats = {}
|
||||
for id in ids:
|
||||
var deck_ids: Array = decks.get(id, [])
|
||||
var deck := []
|
||||
for cid in deck_ids:
|
||||
if Cards.has_id(cid):
|
||||
deck.append(Cards.by_id(cid))
|
||||
if Game.validate_deck(deck)[0] == false:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, r)
|
||||
deck = Game.build_opponent_deck(r, [pool[0], pool[1]])
|
||||
players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE))
|
||||
for i in n_ai:
|
||||
players.append(Game.random_opponent(r, i + 1))
|
||||
game = Game.new(players[0], players.slice(1), r)
|
||||
game.start()
|
||||
for id in ids:
|
||||
seats[id] = game.players.find(players[ids.find(id)])
|
||||
my_seat = seats[1]
|
||||
for id in ids:
|
||||
if id != 1:
|
||||
start_game.rpc_id(id, seats[id])
|
||||
started.emit(my_seat)
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func start_game(seat: int) -> void:
|
||||
my_seat = seat
|
||||
started.emit(seat)
|
||||
|
||||
|
||||
## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht).
|
||||
func broadcast() -> void:
|
||||
if not is_host or game == null:
|
||||
return
|
||||
for id in seats:
|
||||
if id != 1:
|
||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func receive_snapshot(snap: Dictionary) -> void:
|
||||
snapshot_received.emit(snap)
|
||||
|
||||
|
||||
## Mitspieler: Aktion an den Host schicken.
|
||||
func send_action(action: Dictionary) -> void:
|
||||
if is_host:
|
||||
_apply(1, action)
|
||||
else:
|
||||
request_action.rpc_id(1, action)
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func request_action(action: Dictionary) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
_apply(multiplayer.get_remote_sender_id(), action)
|
||||
|
||||
|
||||
func _apply(id: int, action: Dictionary) -> void:
|
||||
if game == null or not seats.has(id):
|
||||
return
|
||||
var p: Game.Player = game.players[seats[id]]
|
||||
if game.current() != p or game.winner != "":
|
||||
return
|
||||
match str(action.get("type", "")):
|
||||
"play":
|
||||
var idx := int(action.get("hand", -1))
|
||||
if idx < 0 or idx >= p.hand.size():
|
||||
return
|
||||
game.play_card(p, p.hand[idx], resolve_ref(action.get("target")))
|
||||
"attack":
|
||||
var cr := game.find_creature(int(action.get("uid", -1)))
|
||||
var t = resolve_ref(action.get("target"))
|
||||
if cr != null and t != null:
|
||||
game.attack(cr, t)
|
||||
"mulligan":
|
||||
var cards := []
|
||||
for i in action.get("hand", []):
|
||||
if int(i) >= 0 and int(i) < p.hand.size():
|
||||
cards.append(p.hand[int(i)])
|
||||
game.mulligan(p, cards)
|
||||
"end":
|
||||
game.end_turn()
|
||||
_:
|
||||
return
|
||||
action_applied.emit()
|
||||
|
||||
|
||||
func resolve_ref(ref):
|
||||
if ref == null or not (ref is Dictionary):
|
||||
return null
|
||||
if ref.get("kind") == "creature":
|
||||
return game.find_creature(int(ref.get("uid", -1)))
|
||||
var i := int(ref.get("index", -1))
|
||||
if i >= 0 and i < game.players.size():
|
||||
return game.players[i]
|
||||
return null
|
||||
|
||||
|
||||
static func ref_dict(target) -> Dictionary:
|
||||
if target is Game.Creature:
|
||||
return {"kind": "creature", "uid": target.uid}
|
||||
return {"kind": "player", "index": target.order}
|
||||
1
scripts/net.gd.uid
Normal file
1
scripts/net.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://drhh23lblvq8o
|
||||
519
scripts/profile.gd
Normal file
519
scripts/profile.gd
Normal file
@@ -0,0 +1,519 @@
|
||||
class_name Profile
|
||||
extends RefCounted
|
||||
## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Statistik, Erfolge, Tagesaufgabe, Einstellungen.
|
||||
|
||||
const SAVE_VERSION := 5
|
||||
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
|
||||
const SAVE_PATH := "user://spielstand.json"
|
||||
const BOOSTER_COST := 100
|
||||
const BOOSTER_SIZE := 5
|
||||
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
|
||||
const START_COINS := 150
|
||||
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
|
||||
const REWARD_LOSS := 50
|
||||
const DECK_SLOTS := 5
|
||||
# Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub.
|
||||
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
|
||||
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
|
||||
|
||||
const ACHIEVEMENTS := [
|
||||
{"id": "erster_sieg", "name": "Erster Sieg", "text": "Gewinne einen Kampf.", "reward": 50},
|
||||
{"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50},
|
||||
{"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100},
|
||||
{"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200},
|
||||
{"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100},
|
||||
{"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500},
|
||||
{"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100},
|
||||
{"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100},
|
||||
{"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100},
|
||||
{"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300},
|
||||
{"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500},
|
||||
{"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150},
|
||||
{"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
|
||||
]
|
||||
|
||||
const DAILY_TASKS := [
|
||||
{"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80},
|
||||
{"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60},
|
||||
{"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40},
|
||||
{"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50},
|
||||
{"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50},
|
||||
{"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60},
|
||||
{"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50},
|
||||
{"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120},
|
||||
{"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90},
|
||||
]
|
||||
|
||||
static var today_override := "" # für Tests
|
||||
|
||||
var coins := 0
|
||||
var dust := 0
|
||||
var collection := {} # id -> Anzahl
|
||||
var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids}
|
||||
var active_deck := 0
|
||||
var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
|
||||
var achievements: Array = []
|
||||
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
||||
var daily := {"date": "", "task": "", "progress": 0, "done": false}
|
||||
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de"}
|
||||
var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe)
|
||||
var card_stats := {} # id -> {"played", "games", "wins"}
|
||||
var booster_since_mythic := 0
|
||||
var tutorial_done := false
|
||||
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
||||
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0}
|
||||
var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung
|
||||
|
||||
## Das aktive Deck (Kartenliste des gewählten Slots).
|
||||
var deck: Array:
|
||||
get:
|
||||
return decks[active_deck].cards
|
||||
set(value):
|
||||
decks[active_deck].cards = value
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
for i in DECK_SLOTS:
|
||||
decks.append({"name": "Deck %d" % (i + 1), "cards": []})
|
||||
|
||||
|
||||
static func starter() -> Profile:
|
||||
var p := Profile.new()
|
||||
p.coins = START_COINS
|
||||
for c in Cards.of_rarity("gewöhnlich"):
|
||||
if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"):
|
||||
p.collection[c.id] = 2
|
||||
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
|
||||
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
|
||||
for cid in starter_ids:
|
||||
p.deck.append(cid)
|
||||
p.deck.append(cid)
|
||||
return p
|
||||
|
||||
|
||||
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
|
||||
static func load_or_new(path: String = SAVE_PATH) -> Profile:
|
||||
for candidate in [path, path + ".bak"]:
|
||||
if FileAccess.file_exists(candidate):
|
||||
var json := JSON.new()
|
||||
if json.parse(FileAccess.get_file_as_string(candidate)) == OK and json.data is Dictionary:
|
||||
return from_dict(json.data)
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".corrupt")
|
||||
var p := starter()
|
||||
p.save(path)
|
||||
return p
|
||||
|
||||
|
||||
## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration).
|
||||
static func from_dict(d: Dictionary) -> Profile:
|
||||
var p := Profile.new()
|
||||
var version := int(d.get("version", 1))
|
||||
p.coins = int(d.get("coins", 0))
|
||||
p.dust = int(d.get("dust", 0))
|
||||
var coll = d.get("collection", {})
|
||||
for cid in coll:
|
||||
if Cards.has_id(cid):
|
||||
p.collection[cid] = int(coll[cid])
|
||||
if version >= 3:
|
||||
var list = d.get("decks", [])
|
||||
for i in mini(list.size(), DECK_SLOTS):
|
||||
p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1)))
|
||||
p.decks[i].cards = []
|
||||
for cid in list[i].get("cards", []):
|
||||
if Cards.has_id(cid):
|
||||
p.decks[i].cards.append(cid)
|
||||
p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1)
|
||||
else:
|
||||
for cid in d.get("deck", []):
|
||||
if Cards.has_id(cid):
|
||||
p.decks[0].cards.append(cid)
|
||||
if version >= 2:
|
||||
var st = d.get("stats", {})
|
||||
p.stats.games = int(st.get("games", 0))
|
||||
p.stats.wins = int(st.get("wins", 0))
|
||||
p.stats.boosters = int(st.get("boosters", 0))
|
||||
for k in st.get("wins_by_opp", {}):
|
||||
p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k])
|
||||
for a in d.get("achievements", []):
|
||||
p.achievements.append(str(a))
|
||||
p.campaign_stage = int(d.get("campaign_stage", 0))
|
||||
var dl = d.get("daily", {})
|
||||
p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")),
|
||||
"progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))}
|
||||
var se = d.get("settings", {})
|
||||
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
|
||||
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
|
||||
"language": str(se.get("language", "de"))}
|
||||
if version >= 4:
|
||||
for cid in d.get("card_stats", {}):
|
||||
if Cards.has_id(cid):
|
||||
var st = d.card_stats[cid]
|
||||
p.card_stats[cid] = {"played": int(st.get("played", 0)), "games": int(st.get("games", 0)), "wins": int(st.get("wins", 0))}
|
||||
p.booster_since_mythic = int(d.get("booster_since_mythic", 0))
|
||||
p.tutorial_done = bool(d.get("tutorial_done", false))
|
||||
if version >= 5:
|
||||
p.perk = str(d.get("perk", ""))
|
||||
if not Game.PERKS.has(p.perk):
|
||||
p.perk = ""
|
||||
var rc = d.get("records", {})
|
||||
p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)),
|
||||
"fastest_win": int(rc.get("fastest_win", 0))}
|
||||
p.challenge_week = int(d.get("challenge_week", -1))
|
||||
return p
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
|
||||
"decks": decks, "active_deck": active_deck,
|
||||
"stats": stats, "achievements": achievements, "campaign_stage": campaign_stage,
|
||||
"daily": daily, "settings": settings, "card_stats": card_stats,
|
||||
"booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done,
|
||||
"perk": perk, "records": records, "challenge_week": challenge_week}
|
||||
|
||||
|
||||
## Speichert und sichert den vorherigen Stand als .bak.
|
||||
func save(path: String = SAVE_PATH) -> void:
|
||||
var abs := ProjectSettings.globalize_path(path)
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.copy_absolute(abs, abs + ".bak")
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(to_dict(), "\t"))
|
||||
f.close()
|
||||
|
||||
|
||||
func take_notices() -> Array[String]:
|
||||
var out := notices.duplicate()
|
||||
notices.clear()
|
||||
return out
|
||||
|
||||
|
||||
# --- Decks und Sammlung -----------------------------------------------------------
|
||||
|
||||
func deck_cards(slot: int = -1) -> Array:
|
||||
var ids: Array = decks[slot if slot >= 0 else active_deck].cards
|
||||
var out := []
|
||||
for cid in ids:
|
||||
out.append(Cards.by_id(cid))
|
||||
return out
|
||||
|
||||
|
||||
func deck_valid(slot: int = -1) -> Array:
|
||||
return Game.validate_deck(deck_cards(slot), collection)
|
||||
|
||||
|
||||
func deck_name(slot: int = -1) -> String:
|
||||
return decks[slot if slot >= 0 else active_deck].name
|
||||
|
||||
|
||||
func rename_deck(slot: int, name: String) -> String:
|
||||
var n := name.strip_edges()
|
||||
if n.is_empty() or n.length() > 20:
|
||||
return "Name muss 1 bis 20 Zeichen haben"
|
||||
decks[slot].name = n
|
||||
return ""
|
||||
|
||||
|
||||
## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve.
|
||||
## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt.
|
||||
func autofill_deck(slot: int) -> String:
|
||||
var cards: Array = decks[slot].cards
|
||||
var elems := Game.deck_elements(deck_cards(slot))
|
||||
if elems.is_empty():
|
||||
var count := {}
|
||||
for c in owned_cards():
|
||||
for e in c.elements:
|
||||
count[e] = count.get(e, 0) + 1
|
||||
var ranked: Array = count.keys()
|
||||
ranked.sort_custom(func(a, b): return count[a] > count[b])
|
||||
elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS)
|
||||
var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems))
|
||||
pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
|
||||
var counts := {}
|
||||
for cid in cards:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
var progressed := true
|
||||
while cards.size() < Game.DECK_SIZE and progressed:
|
||||
progressed = false
|
||||
for cost in range(1, 11):
|
||||
if cards.size() >= Game.DECK_SIZE:
|
||||
break
|
||||
for c in pool:
|
||||
if c.cost != cost:
|
||||
continue
|
||||
var have: int = counts.get(c.id, 0)
|
||||
if have < mini(collection.get(c.id, 0), Game.MAX_COPIES):
|
||||
cards.append(c.id)
|
||||
counts[c.id] = have + 1
|
||||
progressed = true
|
||||
break
|
||||
var ok := Game.validate_deck(deck_cards(slot), collection)
|
||||
return "" if ok[0] else ok[1]
|
||||
|
||||
|
||||
## Bestenliste: Karten mit den meisten Siegen (mindestens eine Partie).
|
||||
func top_cards(n: int) -> Array:
|
||||
var rows := []
|
||||
for cid in card_stats:
|
||||
var st: Dictionary = card_stats[cid]
|
||||
if st.games > 0:
|
||||
rows.append({"card": Cards.by_id(cid), "games": st.games, "wins": st.wins, "played": st.played,
|
||||
"rate": float(st.wins) / st.games})
|
||||
rows.sort_custom(func(a, b): return a.wins > b.wins or (a.wins == b.wins and a.rate > b.rate))
|
||||
return rows.slice(0, n)
|
||||
|
||||
|
||||
func owned_cards() -> Array:
|
||||
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
|
||||
|
||||
|
||||
func distinct_owned() -> int:
|
||||
var n := 0
|
||||
for cid in collection:
|
||||
if collection[cid] > 0:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# --- Werkstatt --------------------------------------------------------------------
|
||||
|
||||
## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func dismantle(cid: String, n: int = 1) -> String:
|
||||
var owned: int = collection.get(cid, 0)
|
||||
if owned < n:
|
||||
return "Nicht genug Kopien"
|
||||
var needed := 0
|
||||
for d in decks:
|
||||
needed = maxi(needed, d.cards.count(cid))
|
||||
if owned - n < needed:
|
||||
return "Wird in einem Deck gebraucht"
|
||||
collection[cid] = owned - n
|
||||
dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n
|
||||
return ""
|
||||
|
||||
|
||||
## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func craft(cid: String) -> String:
|
||||
var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity]
|
||||
if dust < cost:
|
||||
return "Nicht genug Staub (%d nötig)" % cost
|
||||
dust -= cost
|
||||
collection[cid] = collection.get(cid, 0) + 1
|
||||
daily_event("craft")
|
||||
check_achievements()
|
||||
return ""
|
||||
|
||||
|
||||
## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück.
|
||||
func dismantle_extras() -> int:
|
||||
var before := dust
|
||||
for cid in collection.keys():
|
||||
var extra: int = collection[cid] - Game.MAX_COPIES
|
||||
if extra > 0:
|
||||
dismantle(cid, extra)
|
||||
return dust - before
|
||||
|
||||
|
||||
# --- Booster ----------------------------------------------------------------------
|
||||
|
||||
static func roll_booster(r: RandomNumberGenerator) -> Array:
|
||||
var rarities := RARITY_WEIGHTS.keys()
|
||||
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
|
||||
var cards := []
|
||||
for i in BOOSTER_SIZE:
|
||||
var rarity: String = rarities[r.rand_weighted(weights)]
|
||||
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
|
||||
and cards.all(func(c): return c.rarity == "gewöhnlich"):
|
||||
rarity = "selten" # mindestens eine seltene Karte pro Booster
|
||||
var pool := Cards.of_rarity(rarity)
|
||||
cards.append(pool[r.randi_range(0, pool.size() - 1)])
|
||||
return cards
|
||||
|
||||
|
||||
## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
|
||||
func open_booster(r: RandomNumberGenerator = null) -> Array:
|
||||
if coins < BOOSTER_COST:
|
||||
return []
|
||||
if r == null:
|
||||
r = Game.make_rng()
|
||||
coins -= BOOSTER_COST
|
||||
var cards := roll_booster(r)
|
||||
booster_since_mythic += 1
|
||||
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
|
||||
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
|
||||
var pool := Cards.of_rarity("mythisch")
|
||||
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
|
||||
has_mythic = true
|
||||
if has_mythic:
|
||||
booster_since_mythic = 0
|
||||
for c in cards:
|
||||
collection[c.id] = collection.get(c.id, 0) + 1
|
||||
stats.boosters += 1
|
||||
daily_event("booster")
|
||||
check_achievements()
|
||||
return cards
|
||||
|
||||
|
||||
# --- Kampfergebnis, Erfolge, Tagesaufgabe -------------------------------------------
|
||||
|
||||
## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück.
|
||||
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
|
||||
## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche.
|
||||
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
|
||||
turns: int = 0, challenge: Dictionary = {}) -> int:
|
||||
stats.games += 1
|
||||
if won:
|
||||
records.streak += 1
|
||||
records.best_streak = maxi(records.best_streak, records.streak)
|
||||
if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win):
|
||||
records.fastest_win = turns
|
||||
else:
|
||||
records.streak = 0
|
||||
for cid in played_ids:
|
||||
_stat(cid).played += 1
|
||||
var seen := {}
|
||||
for c in deck_used:
|
||||
if not seen.has(c.id):
|
||||
seen[c.id] = true
|
||||
_stat(c.id).games += 1
|
||||
if won:
|
||||
_stat(c.id).wins += 1
|
||||
var reward := REWARD_LOSS
|
||||
if won:
|
||||
stats.wins += 1
|
||||
var key := str(n_opp)
|
||||
stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1
|
||||
reward = REWARD_WIN[n_opp]
|
||||
if stage >= 0 and stage == campaign_stage:
|
||||
campaign_stage += 1
|
||||
reward = Campaign.stage(stage).reward
|
||||
notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name)
|
||||
if not challenge.is_empty() and challenge_week != Challenge.week():
|
||||
challenge_week = Challenge.week()
|
||||
reward = challenge.reward
|
||||
notices.append("Herausforderung bestanden: %s (+%d Münzen)" % [challenge.name, challenge.reward])
|
||||
coins += reward
|
||||
daily_event("game")
|
||||
if won:
|
||||
daily_event("win")
|
||||
if n_opp >= 3:
|
||||
daily_event("win_vs3")
|
||||
for e in Game.deck_elements(deck_used):
|
||||
daily_event("win_element:" + e)
|
||||
check_achievements({"won": won, "n_opp": n_opp, "deck": deck_used})
|
||||
return reward
|
||||
|
||||
|
||||
func _stat(cid: String) -> Dictionary:
|
||||
if not card_stats.has(cid):
|
||||
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
|
||||
return card_stats[cid]
|
||||
|
||||
|
||||
func check_achievements(ctx: Dictionary = {}) -> Array:
|
||||
var unlocked := []
|
||||
for a in ACHIEVEMENTS:
|
||||
if a.id in achievements:
|
||||
continue
|
||||
if _achieved(a.id, ctx):
|
||||
achievements.append(a.id)
|
||||
coins += a.reward
|
||||
unlocked.append(a)
|
||||
notices.append("Erfolg: %s (+%d Münzen)" % [a.name, a.reward])
|
||||
return unlocked
|
||||
|
||||
|
||||
func _achieved(id: String, ctx: Dictionary) -> bool:
|
||||
var won: bool = ctx.get("won", false)
|
||||
var n: int = ctx.get("n_opp", 0)
|
||||
var deck_used: Array = ctx.get("deck", [])
|
||||
match id:
|
||||
"erster_sieg":
|
||||
return stats.wins >= 1
|
||||
"duellant":
|
||||
return won and n == 1
|
||||
"tischherr":
|
||||
return won and n >= 3
|
||||
"chaosmeister":
|
||||
return won and n == 5
|
||||
"sammler":
|
||||
return distinct_owned() >= 40
|
||||
"vollstaendig":
|
||||
return distinct_owned() >= Cards.all().size()
|
||||
"boosterfreund":
|
||||
return stats.boosters >= 10
|
||||
"elementarist":
|
||||
return won and Game.deck_elements(deck_used).size() == 1
|
||||
"kombokoenig":
|
||||
return won and deck_used.filter(func(c): return c.is_combo()).size() >= 6
|
||||
"kampagnenheld":
|
||||
return campaign_stage >= Campaign.STAGES.size()
|
||||
"elementmeister":
|
||||
return campaign_stage >= Campaign.total()
|
||||
"serie":
|
||||
return records.best_streak >= 5
|
||||
"wochenheld":
|
||||
return challenge_week >= 0
|
||||
return false
|
||||
|
||||
|
||||
func perk_unlocked() -> bool:
|
||||
return campaign_stage >= Campaign.STAGES.size()
|
||||
|
||||
|
||||
## Schreibt Statistik als CSV (Kartenwerte und Gesamtzahlen), z. B. für Spieltests.
|
||||
func export_stats(path: String) -> String:
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return "Datei nicht schreibbar"
|
||||
f.store_line("karte;seltenheit;gespielt;partien;siege")
|
||||
for cid in card_stats:
|
||||
var st: Dictionary = card_stats[cid]
|
||||
var c := Cards.by_id(cid)
|
||||
f.store_line("%s;%s;%d;%d;%d" % [c.name, c.rarity, st.played, st.games, st.wins])
|
||||
f.store_line("")
|
||||
f.store_line("kaempfe;%d" % stats.games)
|
||||
f.store_line("siege;%d" % stats.wins)
|
||||
f.store_line("booster;%d" % stats.boosters)
|
||||
f.store_line("beste_serie;%d" % records.best_streak)
|
||||
f.store_line("schnellster_sieg;%d" % records.fastest_win)
|
||||
f.close()
|
||||
return ""
|
||||
|
||||
|
||||
static func today() -> String:
|
||||
return today_override if today_override != "" else Time.get_date_string_from_system()
|
||||
|
||||
|
||||
## Wählt die Tagesaufgabe neu, wenn ein neuer Tag begonnen hat.
|
||||
func refresh_daily() -> void:
|
||||
var t := today()
|
||||
if daily.date != t:
|
||||
var day_number := int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 86400)
|
||||
var task: Dictionary = DAILY_TASKS[day_number % DAILY_TASKS.size()]
|
||||
daily = {"date": t, "task": task.id, "progress": 0, "done": false}
|
||||
|
||||
|
||||
func daily_task() -> Dictionary:
|
||||
refresh_daily()
|
||||
for t in DAILY_TASKS:
|
||||
if t.id == daily.task:
|
||||
return t
|
||||
return DAILY_TASKS[0]
|
||||
|
||||
|
||||
## Zählt ein Ereignis (siehe DAILY_TASKS) für die Tagesaufgabe. Gibt die Belohnung zurück.
|
||||
func daily_event(kind: String, n: int = 1) -> int:
|
||||
var task := daily_task()
|
||||
if daily.done or task.kind != kind:
|
||||
return 0
|
||||
daily.progress += n
|
||||
if daily.progress >= task.goal:
|
||||
daily.done = true
|
||||
coins += task.reward
|
||||
notices.append("Tagesaufgabe erfüllt: %s (+%d Münzen)" % [task.text, task.reward])
|
||||
return task.reward
|
||||
return 0
|
||||
1
scripts/profile.gd.uid
Normal file
1
scripts/profile.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bg5qbfgyjxsyq
|
||||
45
scripts/ui/achievements_screen.gd
Normal file
45
scripts/ui/achievements_screen.gd
Normal file
@@ -0,0 +1,45 @@
|
||||
class_name AchievementsScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var p: Profile = app.profile
|
||||
var col := Style.column(self, 900, 20, 5)
|
||||
Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true)
|
||||
var st: Dictionary = p.stats
|
||||
Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [
|
||||
st.games, st.wins, st.boosters, p.campaign_stage, Campaign.STAGES.size()], 16, Style.MUTED)
|
||||
var task := p.daily_task()
|
||||
var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \
|
||||
"Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
|
||||
Style.clabel(col, daily_text, 16, Style.BLUE)
|
||||
Style.spacer(col, 8)
|
||||
for a in Profile.ACHIEVEMENTS:
|
||||
var done: bool = a.id in p.achievements
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6))
|
||||
col.add_child(panel)
|
||||
var r := Style.row(panel, 12)
|
||||
r.custom_minimum_size = Vector2(0, 42)
|
||||
Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40)
|
||||
Style.clabel(r, a.name, 18, Style.TEXT if done else Style.MUTED, "left", true, 250)
|
||||
Style.clabel(r, a.text, 15, Style.MUTED, "left", false, 380)
|
||||
Style.clabel(r, "+%d" % a.reward, 16, Style.GOLD, "right", true, 90)
|
||||
var top := p.top_cards(5)
|
||||
if not top.is_empty():
|
||||
Style.spacer(col, 4)
|
||||
var parts: Array[String] = []
|
||||
for row in top:
|
||||
parts.append("%s %d/%d" % [row.card.name, row.wins, row.games])
|
||||
Style.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED)
|
||||
Style.spacer(col, 6)
|
||||
var br := Style.row(col)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
1
scripts/ui/achievements_screen.gd.uid
Normal file
1
scripts/ui/achievements_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cw4rtcukefdl8
|
||||
159
scripts/ui/battle_fx.gd
Normal file
159
scripts/ui/battle_fx.gd
Normal file
@@ -0,0 +1,159 @@
|
||||
class_name BattleFx
|
||||
extends RefCounted
|
||||
## Animationen und Töne des Kampfes aus den Spielereignissen.
|
||||
|
||||
var screen # BattleScreen
|
||||
var enabled := true
|
||||
|
||||
|
||||
func _init(s) -> void:
|
||||
screen = s
|
||||
|
||||
|
||||
func sfx(kind: String) -> void:
|
||||
screen.app.sfx.play(kind)
|
||||
|
||||
|
||||
## Spielt die Ereignisse einer Aktion ab. centers: vorher gemerkte Positionen (für gestorbene Wesen).
|
||||
func play_events(evs: Array, centers: Dictionary) -> void:
|
||||
var g: Game = screen.game
|
||||
var delay := 0.0
|
||||
for ev in evs:
|
||||
match ev.type:
|
||||
"attack":
|
||||
sfx("attack")
|
||||
var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
|
||||
lunge(atk, center_of(ev.target, centers))
|
||||
flash_target(ev.target)
|
||||
delay = 0.12
|
||||
"damage":
|
||||
sfx("damage")
|
||||
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay)
|
||||
"heal":
|
||||
sfx("heal")
|
||||
popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay)
|
||||
"buff":
|
||||
popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay)
|
||||
"blocked":
|
||||
popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay)
|
||||
"freeze":
|
||||
popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay)
|
||||
"combo":
|
||||
sfx("combo")
|
||||
var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid})
|
||||
if where.x < 0:
|
||||
where = screen.target_center({"kind": "player", "index": ev.player})
|
||||
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
|
||||
"play":
|
||||
sfx("play")
|
||||
"spell":
|
||||
sfx("play")
|
||||
var caster: Game.Player = g.players[ev.player]
|
||||
popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
if caster != screen.perspective:
|
||||
screen.show_preview(ev.card, true)
|
||||
screen.preview_timer = 1.6
|
||||
"trap_set":
|
||||
sfx("click")
|
||||
popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
"trap":
|
||||
sfx("combo")
|
||||
popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0)
|
||||
screen.show_preview(ev.card, true)
|
||||
screen.preview_timer = 1.8
|
||||
"mulligan":
|
||||
if ev.count > 0:
|
||||
popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
|
||||
|
||||
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
||||
var c: Vector2 = centers.get(screen.ref_key(ref), Vector2(-1, -1))
|
||||
if c.x < 0:
|
||||
c = screen.target_center(ref)
|
||||
return c
|
||||
|
||||
|
||||
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
||||
if not enabled or at.x < 0:
|
||||
return
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 22)
|
||||
l.add_theme_font_override("font", Style.FONT_BOLD)
|
||||
l.add_theme_color_override("font_color", color)
|
||||
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
l.add_theme_constant_override("outline_size", 6)
|
||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
screen.fx_layer.add_child(l)
|
||||
l.reset_size()
|
||||
l.position = at - l.size / 2
|
||||
l.modulate.a = 0.0
|
||||
var tw: Tween = screen.create_tween()
|
||||
tw.tween_interval(delay)
|
||||
tw.tween_property(l, "modulate:a", 1.0, 0.08)
|
||||
tw.tween_property(l, "position:y", l.position.y - 44, 0.8).set_ease(Tween.EASE_OUT)
|
||||
tw.parallel().tween_property(l, "modulate:a", 0.0, 0.5).set_delay(0.35)
|
||||
tw.tween_callback(l.queue_free)
|
||||
|
||||
|
||||
func lunge(tile: CreatureView, target: Vector2) -> void:
|
||||
if tile == null or not enabled or target.x < 0:
|
||||
return
|
||||
tile.busy = true
|
||||
var start := tile.slot
|
||||
var dest := start + (target - (start + tile.size / 2)) * 0.35
|
||||
var tw := tile.create_tween()
|
||||
tw.tween_property(tile, "position", dest, 0.12).set_ease(Tween.EASE_OUT)
|
||||
tw.tween_property(tile, "position", start, 0.15).set_ease(Tween.EASE_IN)
|
||||
tw.tween_callback(func():
|
||||
tile.busy = false
|
||||
tile.position = tile.slot)
|
||||
|
||||
|
||||
func flash_target(ref: Dictionary) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
if ref.kind == "creature":
|
||||
var tile: CreatureView = screen.tiles.get(ref.uid)
|
||||
if tile == null:
|
||||
return
|
||||
tile.flash = true
|
||||
tile.queue_redraw()
|
||||
var tw := tile.create_tween()
|
||||
tw.tween_interval(0.35)
|
||||
tw.tween_callback(func():
|
||||
tile.flash = false
|
||||
tile.queue_redraw())
|
||||
else:
|
||||
screen.flash_until[int(ref.index)] = Time.get_ticks_msec() + 350
|
||||
var tw: Tween = screen.create_tween()
|
||||
tw.tween_interval(0.37)
|
||||
tw.tween_callback(screen.refresh)
|
||||
|
||||
|
||||
func pop_in(tile: CreatureView) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
tile.scale = Vector2(0.2, 0.2)
|
||||
tile.modulate.a = 0.0
|
||||
var tw := tile.create_tween().set_parallel(true)
|
||||
tw.tween_property(tile, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||
tw.tween_property(tile, "modulate:a", 1.0, 0.15)
|
||||
|
||||
|
||||
func fade_out(tile: CreatureView) -> void:
|
||||
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
if not enabled:
|
||||
tile.queue_free()
|
||||
return
|
||||
var tw := tile.create_tween().set_parallel(true)
|
||||
tw.tween_property(tile, "modulate:a", 0.0, 0.3)
|
||||
tw.tween_property(tile, "scale", Vector2(0.6, 0.6), 0.3)
|
||||
tw.chain().tween_callback(tile.queue_free)
|
||||
|
||||
|
||||
func move_to(tile: CreatureView, pos: Vector2) -> void:
|
||||
if enabled:
|
||||
screen.create_tween().tween_property(tile, "position", pos, 0.2)
|
||||
else:
|
||||
tile.position = pos
|
||||
1
scripts/ui/battle_fx.gd.uid
Normal file
1
scripts/ui/battle_fx.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://defclm4nv6rkn
|
||||
40
scripts/ui/battle_layout.gd
Normal file
40
scripts/ui/battle_layout.gd
Normal file
@@ -0,0 +1,40 @@
|
||||
class_name BattleLayout
|
||||
## Feste Bildschirmaufteilung des Kampfes (1280x800).
|
||||
|
||||
const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]}
|
||||
const HAND_W := 130
|
||||
const HAND_H := 185
|
||||
const HAND_GAP := 8
|
||||
const BAR := Rect2(335, 415, 610, 68)
|
||||
const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2
|
||||
|
||||
|
||||
static func compact(n_others: int) -> bool:
|
||||
return n_others >= 4
|
||||
|
||||
|
||||
static func hero_box_rect(n_others: int, i: int) -> Rect2:
|
||||
var col_w := 1280 / n_others
|
||||
var margin := 6 if compact(n_others) else 10
|
||||
return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64)
|
||||
|
||||
|
||||
static func own_slot(j: int) -> Rect2:
|
||||
return Rect2(OWN_X + j * 120, 250, 110, 140)
|
||||
|
||||
|
||||
static func other_slot(n_others: int, i: int, j: int) -> Rect2:
|
||||
var col_w := 1280 / n_others
|
||||
var sz: Array = SIZES[n_others]
|
||||
var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2
|
||||
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
|
||||
|
||||
|
||||
static func hand_rect(count: int, i: int) -> Rect2:
|
||||
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
|
||||
var x := (1280 - total) / 2
|
||||
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
|
||||
|
||||
|
||||
static func trap_rect(i: int) -> Rect2:
|
||||
return Rect2(400 + i * 78, 490, 70, 98)
|
||||
1
scripts/ui/battle_layout.gd.uid
Normal file
1
scripts/ui/battle_layout.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://5sjeymvcgfl7
|
||||
945
scripts/ui/battle_screen.gd
Normal file
945
scripts/ui/battle_screen.gd
Normal file
@@ -0,0 +1,945 @@
|
||||
class_name BattleScreen
|
||||
extends Control
|
||||
## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx.
|
||||
## Modi: Einzelspieler, Kampagne, Anleitung, Hotseat (Perspektivwechsel), Netzwerk (Host oder Mitspieler).
|
||||
|
||||
var app
|
||||
var n_opp: int
|
||||
var stage := -1 # Kampagnenstufe oder -1
|
||||
var tutorial := false
|
||||
var hotseat_decks: Array = []
|
||||
var hotseat_ai := 0
|
||||
var online_seat := -1 # Netzwerk: eigener Platz, sonst -1
|
||||
var net: Net
|
||||
var game: Game
|
||||
var perspective: Game.Player # der Mensch, dessen Hand unten liegt
|
||||
var fx: BattleFx
|
||||
var selected = null # Game.Creature
|
||||
var pending = null # Cards.Card, wartet auf Ziel
|
||||
var msg_t := 0.0
|
||||
var ai_delay := 0.75
|
||||
var ai_timer := 0.0
|
||||
var reward := -1
|
||||
var ended := false
|
||||
var zoom_player = null
|
||||
var preview_timer := 0.0
|
||||
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
|
||||
var mulligan_selected: Array = []
|
||||
var tutorial_step := 0
|
||||
|
||||
# Ansichten (auch für Tests)
|
||||
var tiles := {}
|
||||
var hand_views: Array = []
|
||||
var own_views: Array = []
|
||||
var enemy_views: Array = []
|
||||
var trap_views: Array = []
|
||||
var hero_panels := {} # Player -> Panel
|
||||
var hero_labels := {}
|
||||
var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
|
||||
|
||||
var hero_layer: Control
|
||||
var tile_layer: Control
|
||||
var hand_layer: Control
|
||||
var trap_layer: Control
|
||||
var zoom_layer: Control
|
||||
var fx_layer: Control
|
||||
var log_box: RichTextLabel
|
||||
var preview_holder: Control
|
||||
var preview_hint: Label
|
||||
var bar_panel: Panel
|
||||
var name_label: Label
|
||||
var life_label: Label
|
||||
var energy_label: Label
|
||||
var deck_label: Label
|
||||
var status_label: Label
|
||||
var msg_label: Label
|
||||
var b_end: Button
|
||||
var b_giveup: Button
|
||||
var overlay: Control
|
||||
var result_label: Label
|
||||
var reward_label: Label
|
||||
var notice_label: Label
|
||||
var confirm: Control
|
||||
var handover: Control
|
||||
var handover_label: Label
|
||||
var mulligan_box: Control
|
||||
var mulligan_layer: Control
|
||||
var b_keep: Button
|
||||
var b_swap: Button
|
||||
var tutorial_panel: Panel
|
||||
var tutorial_text: RichTextLabel
|
||||
var b_tut_next: Button
|
||||
var wait_label: Label
|
||||
|
||||
const TUTORIAL_STEPS := [
|
||||
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
|
||||
"played_creature"],
|
||||
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
|
||||
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
|
||||
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig +1 Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
|
||||
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an alle gegnerischen Wesen.", "combo"],
|
||||
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
|
||||
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
|
||||
]
|
||||
|
||||
|
||||
func _init(n: int = 2, campaign_stage: int = -1) -> void:
|
||||
n_opp = n
|
||||
stage = campaign_stage
|
||||
|
||||
|
||||
static func hotseat(decks: Array, n_ai: int) -> BattleScreen:
|
||||
var b := BattleScreen.new(0, -1)
|
||||
b.hotseat_decks = decks
|
||||
b.hotseat_ai = n_ai
|
||||
return b
|
||||
|
||||
|
||||
static func online(seat: int) -> BattleScreen:
|
||||
var b := BattleScreen.new(0, -1)
|
||||
b.online_seat = seat
|
||||
return b
|
||||
|
||||
|
||||
static func guided() -> BattleScreen:
|
||||
var b := BattleScreen.new(1, -1)
|
||||
b.tutorial = true
|
||||
return b
|
||||
|
||||
|
||||
func is_hotseat() -> bool:
|
||||
return game != null and game.humans.size() > 1 and online_seat < 0
|
||||
|
||||
|
||||
func is_online() -> bool:
|
||||
return online_seat >= 0
|
||||
|
||||
|
||||
func is_client() -> bool:
|
||||
return is_online() and not net.is_host
|
||||
|
||||
|
||||
func counts_for_profile() -> bool:
|
||||
return not is_hotseat() and not is_online()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var s: Dictionary = app.profile.settings
|
||||
fx = BattleFx.new(self)
|
||||
fx.enabled = s.animations
|
||||
ai_delay = 0.75 / float(s.ai_speed)
|
||||
_build_static()
|
||||
if is_online():
|
||||
net = app.net
|
||||
net.ended.connect(_on_net_ended)
|
||||
if net.is_host:
|
||||
game = net.game
|
||||
net.action_applied.connect(after_action)
|
||||
else:
|
||||
net.snapshot_received.connect(_on_snapshot)
|
||||
wait_label.visible = true
|
||||
return # der erste Zustand kommt vom Host
|
||||
elif not hotseat_decks.is_empty():
|
||||
game = Game.new_hotseat(hotseat_decks, hotseat_ai)
|
||||
elif tutorial:
|
||||
var r := Game.make_rng()
|
||||
var opps := Campaign.opponents(0, r)
|
||||
opps[0].perks = []
|
||||
game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true)
|
||||
elif stage >= 0:
|
||||
var r := Game.make_rng()
|
||||
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r)
|
||||
else:
|
||||
game = Game.new_battle(app.profile.deck_cards(), n_opp)
|
||||
_begin()
|
||||
|
||||
|
||||
func _begin() -> void:
|
||||
n_opp = game.players.size() - 1
|
||||
if is_online():
|
||||
perspective = game.players[online_seat]
|
||||
else:
|
||||
game.events.clear()
|
||||
perspective = game.current() if game.current().is_human else game.humans[0]
|
||||
build_hero_boxes()
|
||||
if is_online() and net.is_host:
|
||||
net.broadcast()
|
||||
game.events.clear()
|
||||
refresh()
|
||||
|
||||
|
||||
func _build_static() -> void:
|
||||
Style.panel(self, Rect2(10, 205, 320, 340))
|
||||
Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20))
|
||||
log_box = RichTextLabel.new()
|
||||
log_box.position = Vector2(18, 230)
|
||||
log_box.size = Vector2(304, 308)
|
||||
log_box.bbcode_enabled = true
|
||||
log_box.scroll_following = true
|
||||
log_box.add_theme_font_size_override("normal_font_size", 13)
|
||||
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(log_box)
|
||||
if tutorial:
|
||||
tutorial_panel = Style.panel(self, Rect2(10, 205, 320, 150), Style.PANEL, 8, Style.GOLD, 2)
|
||||
tutorial_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
Style.label(self, "Anleitung", 14, Style.GOLD, Rect2(20, 209, 200, 20), "left", true)
|
||||
tutorial_text = RichTextLabel.new()
|
||||
tutorial_text.position = Vector2(20, 230)
|
||||
tutorial_text.size = Vector2(300, 90)
|
||||
tutorial_text.bbcode_enabled = false
|
||||
tutorial_text.scroll_active = false
|
||||
tutorial_text.add_theme_font_size_override("normal_font_size", 13)
|
||||
tutorial_text.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(tutorial_text)
|
||||
b_tut_next = Style.button(self, "Weiter", Rect2(230, 318, 90, 30), tutorial_next)
|
||||
b_tut_next.add_theme_font_size_override("font_size", 14)
|
||||
log_box.position = Vector2(18, 365)
|
||||
log_box.size = Vector2(304, 173)
|
||||
b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true)
|
||||
Style.panel(self, Rect2(950, 205, 320, 380))
|
||||
preview_hint = Style.label(self, "Karte überfahren für Details", 14, Style.MUTED, Rect2(950, 345, 320, 24), "center")
|
||||
Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10)
|
||||
bar_panel = Style.panel(self, BattleLayout.BAR)
|
||||
name_label = Style.label(self, "Du", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true)
|
||||
life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true)
|
||||
energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true)
|
||||
deck_label = Style.label(self, "", 16, Style.MUTED, Rect2(780, 424, 150, 24), "right")
|
||||
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24))
|
||||
msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true)
|
||||
b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c"))
|
||||
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
|
||||
wait_label.visible = false
|
||||
hero_layer = _layer()
|
||||
tile_layer = _layer()
|
||||
trap_layer = _layer()
|
||||
hand_layer = _layer()
|
||||
preview_holder = _layer()
|
||||
zoom_layer = _layer()
|
||||
fx_layer = _layer()
|
||||
# Mulligan
|
||||
mulligan_box = _overlay()
|
||||
Style.panel(mulligan_box, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
|
||||
Style.label(mulligan_box, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
|
||||
Style.label(mulligan_box, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
|
||||
mulligan_layer = Control.new()
|
||||
mulligan_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
mulligan_box.add_child(mulligan_layer)
|
||||
b_keep = Style.button(mulligan_box, "Behalten", Rect2(430, 515, 200, 44), func(): do_mulligan(false), Color("285a3c"))
|
||||
b_swap = Style.button(mulligan_box, "Tauschen", Rect2(650, 515, 200, 44), func(): do_mulligan(true))
|
||||
# Spielende
|
||||
overlay = _overlay()
|
||||
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
|
||||
reward_label = Style.label(overlay, "", 26, Style.GOLD, Rect2(0, 335, 1280, 40), "center")
|
||||
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center")
|
||||
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
|
||||
# Aufgeben bestätigen
|
||||
confirm = _overlay()
|
||||
Style.panel(confirm, Rect2(390, 300, 500, 180), Style.PANEL, 10, Style.MUTED, 2)
|
||||
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(390, 320, 500, 40), "center", true)
|
||||
Style.label(confirm, "Der Kampf endet ohne Münzen.", 16, Style.MUTED, Rect2(390, 365, 500, 24), "center")
|
||||
Style.button(confirm, "Ja, aufgeben", Rect2(420, 410, 200, 46), leave, Color("5a2828"))
|
||||
Style.button(confirm, "Weiterspielen", Rect2(660, 410, 200, 46), func(): confirm.visible = false)
|
||||
# Übergabe zwischen Menschen (Hotseat)
|
||||
handover = _overlay()
|
||||
Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2)
|
||||
handover_label = Style.label(handover, "", 30, Style.TEXT, Rect2(340, 310, 600, 44), "center", true)
|
||||
Style.label(handover, "Maus weitergeben, dann weiter. Die Hand des Vorgängers ist verdeckt.", 16, Style.MUTED, Rect2(340, 362, 600, 24), "center")
|
||||
Style.button(handover, "Weiter", Rect2(540, 420, 200, 50), take_over, Color("285a3c"))
|
||||
|
||||
|
||||
func _layer() -> Control:
|
||||
var c := Control.new()
|
||||
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func _overlay() -> Control:
|
||||
var o := Control.new()
|
||||
o.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
o.visible = false
|
||||
var dark := ColorRect.new()
|
||||
dark.color = Color(0, 0, 0, 0.67)
|
||||
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
dark.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
o.add_child(dark)
|
||||
add_child(o)
|
||||
return o
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
if is_online():
|
||||
net.leave()
|
||||
if tutorial and not app.profile.tutorial_done:
|
||||
app.profile.tutorial_done = true
|
||||
app.save()
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
# --- Layout-Helfer ------------------------------------------------------------------
|
||||
|
||||
func others() -> Array:
|
||||
var out := []
|
||||
var n := game.players.size()
|
||||
var start := game.players.find(perspective)
|
||||
for i in range(1, n):
|
||||
out.append(game.players[(start + i) % n])
|
||||
return out
|
||||
|
||||
|
||||
func compact() -> bool:
|
||||
return BattleLayout.compact(others().size())
|
||||
|
||||
|
||||
func slot_rect(p: Game.Player, j: int) -> Rect2:
|
||||
if p == perspective:
|
||||
return BattleLayout.own_slot(j)
|
||||
var os := others()
|
||||
return BattleLayout.other_slot(os.size(), os.find(p), j)
|
||||
|
||||
|
||||
func ref_key(ref: Dictionary) -> String:
|
||||
return "c%d" % ref.uid if ref.kind == "creature" else "p%d" % ref.index
|
||||
|
||||
|
||||
func target_center(ref: Dictionary) -> Vector2:
|
||||
if ref.kind == "creature":
|
||||
if tiles.has(ref.uid):
|
||||
var t: CreatureView = tiles[ref.uid]
|
||||
return t.slot + t.size / 2
|
||||
return Vector2(-1, -1)
|
||||
var p: Game.Player = game.players[ref.index]
|
||||
if p == perspective:
|
||||
return BattleLayout.BAR.position + BattleLayout.BAR.size / 2
|
||||
if hero_panels.has(p):
|
||||
var panel: Panel = hero_panels[p]
|
||||
return panel.position + panel.size / 2
|
||||
return Vector2(-1, -1)
|
||||
|
||||
|
||||
func build_hero_boxes() -> void:
|
||||
Style.clear(hero_layer)
|
||||
hero_panels = {}
|
||||
hero_labels = {}
|
||||
var os := others()
|
||||
var c := compact()
|
||||
for i in os.size():
|
||||
var o: Game.Player = os[i]
|
||||
var box := BattleLayout.hero_box_rect(os.size(), i)
|
||||
var panel := Style.panel(hero_layer, box)
|
||||
panel.gui_input.connect(func(ev):
|
||||
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
|
||||
on_click("hero", o))
|
||||
if not o.perks.is_empty():
|
||||
panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k]))
|
||||
hero_panels[o] = panel
|
||||
var labels := {}
|
||||
var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name
|
||||
if not o.perks.is_empty():
|
||||
title += " ★"
|
||||
labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT,
|
||||
Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (44 if c else 20), 26), "left", true)
|
||||
labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED,
|
||||
Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true)
|
||||
labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED,
|
||||
Rect2(box.position.x + 90, box.position.y + 38, box.size.x - 100, 20), "right")
|
||||
hero_labels[o] = labels
|
||||
if c:
|
||||
var b := Style.button(hero_layer, "Lupe", Rect2(box.end.x - 44, box.position.y + 5, 38, 22), func():
|
||||
zoom_player = o
|
||||
refresh())
|
||||
b.add_theme_font_size_override("font_size", 11)
|
||||
|
||||
|
||||
# --- Ablauf und Eingabe ------------------------------------------------------------------
|
||||
|
||||
func my_turn() -> bool:
|
||||
return game != null and game.winner == "" and game.current() == perspective
|
||||
|
||||
|
||||
func any_overlay() -> bool:
|
||||
return confirm.visible or handover.visible or mulligan_box.visible or overlay.visible
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if game == null:
|
||||
return
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
cancel_all()
|
||||
return
|
||||
if not (event is InputEventKey and event.pressed and not event.echo):
|
||||
return
|
||||
match event.keycode:
|
||||
KEY_ESCAPE:
|
||||
if confirm.visible:
|
||||
confirm.visible = false
|
||||
elif selected != null or pending != null or zoom_player != null:
|
||||
cancel_all()
|
||||
elif not any_overlay():
|
||||
confirm.visible = true
|
||||
KEY_SPACE:
|
||||
if my_turn() and not any_overlay():
|
||||
end_turn()
|
||||
KEY_ENTER, KEY_KP_ENTER:
|
||||
if handover.visible:
|
||||
take_over()
|
||||
elif mulligan_box.visible:
|
||||
do_mulligan(false)
|
||||
elif overlay.visible:
|
||||
leave()
|
||||
elif tutorial and b_tut_next.visible:
|
||||
tutorial_next()
|
||||
_:
|
||||
if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay():
|
||||
var idx: int = event.keycode - KEY_1
|
||||
if idx < perspective.hand.size():
|
||||
on_click("hand", perspective.hand[idx])
|
||||
|
||||
|
||||
func cancel_all() -> void:
|
||||
if confirm.visible:
|
||||
confirm.visible = false
|
||||
cancel()
|
||||
zoom_player = null
|
||||
refresh()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if game == null:
|
||||
return
|
||||
msg_t = maxf(0.0, msg_t - delta)
|
||||
if msg_t <= 0.0 and msg_label.text != "":
|
||||
msg_label.text = ""
|
||||
if preview_timer > 0.0:
|
||||
preview_timer -= delta
|
||||
if preview_timer <= 0.0:
|
||||
show_preview(null, false)
|
||||
if game.winner != "":
|
||||
if not ended:
|
||||
ended = true
|
||||
_finish()
|
||||
return
|
||||
var cur := game.current()
|
||||
if cur.is_human:
|
||||
if cur != perspective and is_hotseat() and not handover.visible:
|
||||
handover_label.text = "%s ist dran" % cur.name
|
||||
handover.visible = true
|
||||
hand_layer.visible = false
|
||||
trap_layer.visible = false
|
||||
elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan_box.visible and not handover.visible:
|
||||
show_mulligan()
|
||||
return
|
||||
if is_client():
|
||||
return
|
||||
ai_timer -= delta
|
||||
if ai_timer > 0:
|
||||
return
|
||||
var action := AI.choose_action(game, cur)
|
||||
AI.apply_action(game, cur, action)
|
||||
ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0)
|
||||
after_action()
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
var won := game.winner == "human"
|
||||
if counts_for_profile():
|
||||
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids)
|
||||
if tutorial:
|
||||
app.profile.tutorial_done = true
|
||||
app.save()
|
||||
app.sfx.play("win" if won else "lose")
|
||||
else:
|
||||
var wp := game.winner_player
|
||||
var mine: bool = wp != null and wp == perspective
|
||||
app.sfx.play("win" if mine else "lose")
|
||||
refresh()
|
||||
|
||||
|
||||
func take_over() -> void:
|
||||
handover.visible = false
|
||||
hand_layer.visible = true
|
||||
trap_layer.visible = true
|
||||
perspective = game.current()
|
||||
cancel()
|
||||
zoom_player = null
|
||||
build_hero_boxes()
|
||||
refresh()
|
||||
|
||||
|
||||
func say(m: String) -> void:
|
||||
msg_t = 2.5
|
||||
msg_label.text = m
|
||||
|
||||
|
||||
func cancel() -> void:
|
||||
selected = null
|
||||
pending = null
|
||||
|
||||
|
||||
func play(card: Cards.Card, target) -> void:
|
||||
if is_client():
|
||||
net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null})
|
||||
return
|
||||
var err := game.play_card(perspective, card, target)
|
||||
if err != "":
|
||||
say(err)
|
||||
|
||||
|
||||
func end_turn() -> void:
|
||||
if not my_turn():
|
||||
return
|
||||
cancel()
|
||||
zoom_player = null
|
||||
if is_client():
|
||||
net.send_action({"type": "end"})
|
||||
return
|
||||
game.end_turn()
|
||||
ai_timer = ai_delay
|
||||
after_action()
|
||||
|
||||
|
||||
func show_mulligan() -> void:
|
||||
mulligan_selected = []
|
||||
mulligan_box.visible = true
|
||||
Style.clear(mulligan_layer)
|
||||
var hand: Array = perspective.hand
|
||||
for i in hand.size():
|
||||
var r := BattleLayout.hand_rect(hand.size(), i)
|
||||
var v := CardView.new(hand[i], r.size)
|
||||
v.position = Vector2(r.position.x, 322)
|
||||
var idx := i
|
||||
v.clicked.connect(func(_view): toggle_mulligan(idx))
|
||||
mulligan_layer.add_child(v)
|
||||
_mulligan_borders()
|
||||
|
||||
|
||||
func toggle_mulligan(idx: int) -> void:
|
||||
if idx in mulligan_selected:
|
||||
mulligan_selected.erase(idx)
|
||||
else:
|
||||
mulligan_selected.append(idx)
|
||||
_mulligan_borders()
|
||||
|
||||
|
||||
func _mulligan_borders() -> void:
|
||||
var views := mulligan_layer.get_children()
|
||||
for i in views.size():
|
||||
views[i].border = Style.YELLOW if i in mulligan_selected else null
|
||||
views[i].queue_redraw()
|
||||
b_swap.text = "Tauschen (%d)" % mulligan_selected.size() if not mulligan_selected.is_empty() else "Tauschen"
|
||||
|
||||
|
||||
func do_mulligan(swap: bool) -> void:
|
||||
mulligan_box.visible = false
|
||||
var idxs: Array = mulligan_selected.duplicate() if swap else []
|
||||
if is_client():
|
||||
net.send_action({"type": "mulligan", "hand": idxs})
|
||||
return
|
||||
var cards := []
|
||||
for i in idxs:
|
||||
cards.append(perspective.hand[i])
|
||||
game.mulligan(perspective, cards)
|
||||
after_action()
|
||||
|
||||
|
||||
func on_click(kind: String, obj) -> void:
|
||||
var g := game
|
||||
if not my_turn() or any_overlay():
|
||||
return
|
||||
if pending != null:
|
||||
var card = pending
|
||||
pending = null
|
||||
if obj in g.valid_targets(perspective, card.target_type()):
|
||||
play(card, obj)
|
||||
else:
|
||||
say("Abgebrochen")
|
||||
zoom_player = null
|
||||
after_action()
|
||||
return
|
||||
if selected != null:
|
||||
var cr = selected
|
||||
selected = null
|
||||
if kind == "enemy" or kind == "hero":
|
||||
if is_client():
|
||||
net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)})
|
||||
else:
|
||||
var err := g.attack(cr, obj)
|
||||
if err != "":
|
||||
say(err)
|
||||
zoom_player = null
|
||||
after_action()
|
||||
return
|
||||
match kind:
|
||||
"hand":
|
||||
var ok := g.can_play(perspective, obj)
|
||||
if not ok[0]:
|
||||
say(ok[1])
|
||||
elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty():
|
||||
pending = obj
|
||||
say("Wähle ein Ziel")
|
||||
else:
|
||||
play(obj, null)
|
||||
"own":
|
||||
if obj.can_attack():
|
||||
selected = obj
|
||||
say("Wähle ein Angriffsziel")
|
||||
else:
|
||||
say("%s kann nicht angreifen" % obj.name)
|
||||
after_action()
|
||||
|
||||
|
||||
func on_tile_click(cr: Game.Creature) -> void:
|
||||
var o = cr.owner()
|
||||
if o == null:
|
||||
return
|
||||
if o == perspective:
|
||||
on_click("own", cr)
|
||||
elif o.alive():
|
||||
on_click("enemy", cr)
|
||||
|
||||
|
||||
func show_preview(card, entered: bool) -> void:
|
||||
Style.clear(preview_holder)
|
||||
if entered and card != null:
|
||||
var v := CardView.new(card, Vector2(210, 294))
|
||||
v.position = Vector2(1005, 215)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
preview_hint.visible = not (entered and card != null)
|
||||
|
||||
|
||||
## Vorschau einer Handkarte mit Hinweisen auf Resonanz und Feldkombos mit dem eigenen Feld.
|
||||
func show_hand_preview(card: Cards.Card, entered: bool) -> void:
|
||||
Style.clear(preview_holder)
|
||||
preview_hint.visible = not entered
|
||||
if not entered:
|
||||
return
|
||||
var hints: Array[String] = combo_hints(card)
|
||||
var v := CardView.new(card, Vector2(190, 266) if not hints.is_empty() else Vector2(210, 294))
|
||||
v.position = Vector2(1015, 212) if not hints.is_empty() else Vector2(1005, 215)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
if not hints.is_empty():
|
||||
var box := RichTextLabel.new()
|
||||
box.position = Vector2(958, 480)
|
||||
box.size = Vector2(304, 46)
|
||||
box.bbcode_enabled = true
|
||||
box.scroll_active = false
|
||||
box.fit_content = false
|
||||
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
box.add_theme_font_size_override("normal_font_size", 11)
|
||||
box.add_theme_color_override("default_color", Style.GOLD)
|
||||
box.text = "[center]" + "\n".join(hints.slice(0, 2)) + "[/center]"
|
||||
preview_holder.add_child(box)
|
||||
|
||||
|
||||
func combo_hints(card: Cards.Card) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
if not card.is_creature():
|
||||
return out
|
||||
var partners: Array[String] = []
|
||||
var combos := {}
|
||||
for other in perspective.board:
|
||||
if other.card.elements.any(func(e): return e in card.elements):
|
||||
partners.append(other.name)
|
||||
for e1 in card.elements:
|
||||
for e2 in other.card.elements:
|
||||
if e1 != e2:
|
||||
var name := Cards.combo_name([e1, e2])
|
||||
if not combos.has(name):
|
||||
combos[name] = other.name
|
||||
if not partners.is_empty():
|
||||
out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)])
|
||||
for name in combos:
|
||||
out.append("Feldkombo %s mit %s: %s" % [name, combos[name], Cards.FIELD_COMBO_TEXT[name]])
|
||||
return out
|
||||
|
||||
|
||||
# --- Netzwerk -------------------------------------------------------------------------------
|
||||
|
||||
func _on_snapshot(snap: Dictionary) -> void:
|
||||
var first := game == null
|
||||
game = Game.from_snapshot(snap)
|
||||
wait_label.visible = false
|
||||
if first:
|
||||
_begin()
|
||||
return
|
||||
perspective = game.players[online_seat]
|
||||
build_hero_boxes()
|
||||
after_action()
|
||||
|
||||
|
||||
func _on_net_ended(reason: String) -> void:
|
||||
if game == null or game.winner == "":
|
||||
say(reason)
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
if is_inside_tree():
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
# --- Ereignisse ----------------------------------------------------------------------------
|
||||
|
||||
## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen.
|
||||
func after_action() -> void:
|
||||
if is_online() and net.is_host:
|
||||
net.broadcast()
|
||||
var evs := game.events.duplicate()
|
||||
game.events.clear()
|
||||
if counts_for_profile():
|
||||
_note_events(evs)
|
||||
var centers := {}
|
||||
for ev in evs:
|
||||
for key in ["target", "attacker", "source"]:
|
||||
if ev.has(key):
|
||||
centers[ref_key(ev[key])] = target_center(ev[key])
|
||||
refresh()
|
||||
fx.play_events(evs, centers)
|
||||
|
||||
|
||||
func _note_events(evs: Array) -> void:
|
||||
var hi := game.players.find(game.human)
|
||||
for ev in evs:
|
||||
if ev.get("player", -1) != hi:
|
||||
continue
|
||||
match ev.type:
|
||||
"play", "spell":
|
||||
played_ids.append(ev.card.id)
|
||||
if ev.type == "play":
|
||||
app.profile.daily_event("creature_played")
|
||||
"trap_set":
|
||||
app.profile.daily_event("trap_set")
|
||||
"combo":
|
||||
app.profile.daily_event("combo")
|
||||
|
||||
|
||||
# --- Anleitung ------------------------------------------------------------------------------
|
||||
|
||||
func tutorial_condition(kind: String) -> bool:
|
||||
var h := game.human
|
||||
match kind:
|
||||
"played_creature":
|
||||
return game.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
|
||||
"turn_passed":
|
||||
return h.turns >= 2
|
||||
"attacked":
|
||||
return game.log.any(func(l): return l.ends_with("greift Fenn an.") or (" greift " in l and l.begins_with(h.board[0].name) if not h.board.is_empty() else false))
|
||||
"resonance":
|
||||
return h.board.any(func(c): return c.resonance > 0)
|
||||
"combo":
|
||||
return game.log.any(func(l): return l.begins_with("Feldkombo"))
|
||||
"spell":
|
||||
return game.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
|
||||
return false
|
||||
|
||||
|
||||
func tutorial_next() -> void:
|
||||
if tutorial_step < TUTORIAL_STEPS.size() - 1:
|
||||
tutorial_step += 1
|
||||
app.sfx.play("click")
|
||||
refresh()
|
||||
|
||||
|
||||
func _tutorial_refresh() -> void:
|
||||
while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]):
|
||||
tutorial_step += 1
|
||||
tutorial_text.text = "%d/%d %s" % [tutorial_step + 1, TUTORIAL_STEPS.size(), TUTORIAL_STEPS[tutorial_step][0]]
|
||||
b_tut_next.visible = tutorial_step < TUTORIAL_STEPS.size() - 1
|
||||
|
||||
|
||||
# --- Ansicht angleichen --------------------------------------------------------------------
|
||||
|
||||
func refresh() -> void:
|
||||
if game == null:
|
||||
return
|
||||
var g := game
|
||||
var h := perspective
|
||||
var now := Time.get_ticks_msec()
|
||||
var mine := my_turn()
|
||||
var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else []
|
||||
hand_views = []
|
||||
own_views = []
|
||||
enemy_views = []
|
||||
trap_views = []
|
||||
|
||||
for o in others():
|
||||
var panel: Panel = hero_panels[o]
|
||||
var bcol = null
|
||||
var bw := 0
|
||||
if g.current() == o:
|
||||
bcol = Style.BLUE
|
||||
bw = 3
|
||||
if o in valid or (selected != null and o.alive() and g.can_attack(selected, o)[0]):
|
||||
bcol = Style.ORANGE
|
||||
bw = 3
|
||||
if int(flash_until.get(g.players.find(o), 0)) > now:
|
||||
bcol = Style.RED
|
||||
bw = 4
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if o.alive() else Style.darker(Style.PANEL, 0.6), 8, bcol, bw))
|
||||
panel.mouse_filter = Control.MOUSE_FILTER_STOP if o.alive() else Control.MOUSE_FILTER_IGNORE
|
||||
var labels: Dictionary = hero_labels[o]
|
||||
labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
|
||||
if o.alive():
|
||||
labels.life.text = "♥ %d" % maxi(o.life, 0)
|
||||
var traps_txt := (" F %d" if compact() else " Fallen %d") % o.traps.size() if not o.traps.is_empty() else ""
|
||||
labels.stats.text = ("H %d D %d%s ◆ %d" if compact() else "Hand %d Deck %d%s ◆ %d") % [o.hand.size(), o.deck.size(), traps_txt, o.max_energy]
|
||||
else:
|
||||
labels.life.text = "besiegt"
|
||||
labels.life.add_theme_color_override("font_color", Style.MUTED)
|
||||
labels.stats.text = ""
|
||||
|
||||
var seen := {}
|
||||
for p in g.players:
|
||||
for j in p.board.size():
|
||||
var cr: Game.Creature = p.board[j]
|
||||
var rect := slot_rect(p, j)
|
||||
seen[cr.uid] = true
|
||||
var tile: CreatureView
|
||||
if tiles.has(cr.uid):
|
||||
tile = tiles[cr.uid]
|
||||
tile.creature = cr
|
||||
if tile.size != rect.size:
|
||||
tile.size = rect.size
|
||||
tile.custom_minimum_size = rect.size
|
||||
tile.pivot_offset = rect.size / 2
|
||||
else:
|
||||
tile = CreatureView.new(cr, rect.size)
|
||||
tile.position = rect.position
|
||||
tile.slot = rect.position
|
||||
tiles[cr.uid] = tile
|
||||
tile_layer.add_child(tile)
|
||||
tile.hover.connect(show_preview)
|
||||
var uid := cr.uid
|
||||
tile.clicked.connect(func(_v):
|
||||
var c := game.find_creature(uid)
|
||||
if c != null:
|
||||
on_tile_click(c))
|
||||
fx.pop_in(tile)
|
||||
tile.slot = rect.position
|
||||
if not tile.busy and tile.position != rect.position:
|
||||
fx.move_to(tile, rect.position)
|
||||
var b = null
|
||||
var w := 2
|
||||
if cr == selected:
|
||||
b = Style.YELLOW
|
||||
w = 4
|
||||
elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
|
||||
b = Style.ORANGE
|
||||
w = 3
|
||||
elif p == h and mine and cr.can_attack():
|
||||
b = Style.GREEN
|
||||
w = 3
|
||||
tile.border = b
|
||||
tile.width = w
|
||||
tile.queue_redraw()
|
||||
if p == h:
|
||||
own_views.append(tile)
|
||||
else:
|
||||
enemy_views.append(tile)
|
||||
for uid in tiles.keys():
|
||||
if not seen.has(uid):
|
||||
var t: CreatureView = tiles[uid]
|
||||
tiles.erase(uid)
|
||||
fx.fade_out(t)
|
||||
|
||||
Style.clear(trap_layer)
|
||||
if not h.traps.is_empty():
|
||||
Style.label(trap_layer, "Fallen", 13, Style.MUTED, Rect2(335, 492, 60, 20))
|
||||
for i in h.traps.size():
|
||||
var tv := CardView.new(h.traps[i], BattleLayout.trap_rect(i).size)
|
||||
tv.position = BattleLayout.trap_rect(i).position
|
||||
tv.hover.connect(show_preview)
|
||||
trap_layer.add_child(tv)
|
||||
trap_views.append(tv)
|
||||
|
||||
Style.clear(hand_layer)
|
||||
for i in h.hand.size():
|
||||
var card: Cards.Card = h.hand[i]
|
||||
var playable: bool = mine and g.can_play(h, card)[0]
|
||||
var r := BattleLayout.hand_rect(h.hand.size(), i)
|
||||
var v := CardView.new(card, r.size)
|
||||
v.position = r.position
|
||||
if card == pending:
|
||||
v.border = Style.YELLOW
|
||||
elif playable:
|
||||
v.border = Style.GREEN
|
||||
v.dim = not playable
|
||||
v.hover.connect(show_hand_preview)
|
||||
v.clicked.connect(func(_view): on_click("hand", card))
|
||||
hand_layer.add_child(v)
|
||||
hand_views.append(v)
|
||||
|
||||
var bar_border = Style.BLUE if mine else null
|
||||
if int(flash_until.get(g.players.find(h), 0)) > now:
|
||||
bar_border = Style.RED
|
||||
bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
|
||||
name_label.text = h.name
|
||||
life_label.text = "♥ %d" % maxi(h.life, 0)
|
||||
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy]
|
||||
deck_label.text = "Deck %d" % h.deck.size()
|
||||
status_label.text = "Zug %d · %s" % [g.turn, "Du bist dran" if mine else g.current().name + " ist dran …"]
|
||||
b_end.disabled = not mine
|
||||
var lines: Array[String] = []
|
||||
var start := maxi(0, g.log.size() - 60)
|
||||
for i in range(start, g.log.size()):
|
||||
var line := g.log[i]
|
||||
if line.begins_with("---"):
|
||||
lines.append("[color=#6eaaf0]%s[/color]" % line)
|
||||
elif line.begins_with("Feldkombo") or line.begins_with("Falle von"):
|
||||
lines.append("[color=#f5c850]%s[/color]" % line)
|
||||
else:
|
||||
lines.append(line)
|
||||
log_box.text = "\n".join(lines)
|
||||
if tutorial:
|
||||
_tutorial_refresh()
|
||||
|
||||
Style.clear(zoom_layer)
|
||||
if zoom_player != null and zoom_player.alive():
|
||||
build_zoom(valid)
|
||||
|
||||
if g.winner != "":
|
||||
var wp := g.winner_player
|
||||
if counts_for_profile():
|
||||
var won := g.winner == "human"
|
||||
result_label.text = "Sieg!" if won else "Niederlage"
|
||||
result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED)
|
||||
reward_label.text = "+%d Münzen" % maxi(reward, 0)
|
||||
var notes: Array[String] = app.profile.take_notices()
|
||||
if not notes.is_empty():
|
||||
notice_label.text = "\n".join(notes)
|
||||
else:
|
||||
var mine_win: bool = wp != null and wp == perspective
|
||||
result_label.text = ("%s gewinnt!" % wp.name) if wp != null and wp.is_human else "Die KI gewinnt"
|
||||
result_label.add_theme_color_override("font_color", Style.GREEN if mine_win else Style.RED)
|
||||
reward_label.text = "Gemeinsame Partie, keine Münzen"
|
||||
overlay.visible = true
|
||||
confirm.visible = false
|
||||
handover.visible = false
|
||||
mulligan_box.visible = false
|
||||
|
||||
|
||||
func build_zoom(valid: Array) -> void:
|
||||
var o: Game.Player = zoom_player
|
||||
var g := game
|
||||
var box := Rect2(190, 190, 900, 300)
|
||||
var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2)
|
||||
back.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
Style.label(zoom_layer, "%s · %s ♥ %d Hand %d Deck %d Fallen %d ◆ %d" % [o.name, o.title, o.life, o.hand.size(), o.deck.size(), o.traps.size(), o.max_energy],
|
||||
18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true)
|
||||
for j in o.board.size():
|
||||
var cr: Game.Creature = o.board[j]
|
||||
var b = null
|
||||
if cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
|
||||
b = Style.ORANGE
|
||||
var v := CreatureView.new(cr, Vector2(110, 140), b, 3 if b != null else 2)
|
||||
v.position = Vector2(box.position.x + 20 + j * 122, box.position.y + 56)
|
||||
v.hover.connect(show_preview)
|
||||
v.clicked.connect(func(_x): on_tile_click(cr))
|
||||
zoom_layer.add_child(v)
|
||||
if o.board.is_empty():
|
||||
Style.label(zoom_layer, "Keine Wesen auf dem Feld", 16, Style.MUTED, Rect2(box.position.x + 20, box.position.y + 110, 600, 30))
|
||||
var hero_ok: bool = (o in valid) or (selected != null and g.can_attack(selected, o)[0])
|
||||
var bh := Style.button(zoom_layer, "Held als Ziel", Rect2(box.position.x + 20, box.end.y - 60, 180, 44), func(): on_click("hero", o), Color("5a3c28"))
|
||||
bh.disabled = not hero_ok
|
||||
Style.button(zoom_layer, "Schließen", Rect2(box.end.x - 180, box.end.y - 60, 160, 44), func():
|
||||
zoom_player = null
|
||||
refresh())
|
||||
1
scripts/ui/battle_screen.gd.uid
Normal file
1
scripts/ui/battle_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmf82cuhs7h1a
|
||||
122
scripts/ui/booster_screen.gd
Normal file
122
scripts/ui/booster_screen.gd
Normal file
@@ -0,0 +1,122 @@
|
||||
class_name BoosterScreen
|
||||
extends Control
|
||||
|
||||
const CARD_W := 200
|
||||
const CARD_H := 280
|
||||
|
||||
var app
|
||||
var cards: Array = []
|
||||
var revealed := {}
|
||||
var new_ids := {}
|
||||
var coins_label: Label
|
||||
var msg_label: Label
|
||||
var b_buy: Button
|
||||
var b_all: Button
|
||||
var slots: Control
|
||||
|
||||
|
||||
class CardBack extends Control:
|
||||
signal clicked
|
||||
|
||||
func _init(sz: Vector2) -> void:
|
||||
size = sz
|
||||
custom_minimum_size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit()
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect)
|
||||
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
|
||||
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
|
||||
|
||||
|
||||
func slot_rect(i: int) -> Rect2:
|
||||
var gap := 20
|
||||
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2
|
||||
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
|
||||
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
|
||||
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
|
||||
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
|
||||
slots = Control.new()
|
||||
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(slots)
|
||||
msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center")
|
||||
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628"))
|
||||
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all)
|
||||
Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new()))
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func buy() -> void:
|
||||
var p: Profile = app.profile
|
||||
var before: Dictionary = p.collection.duplicate()
|
||||
var got: Array = p.open_booster()
|
||||
if got.is_empty():
|
||||
msg_label.text = "Nicht genug Münzen"
|
||||
msg_label.add_theme_color_override("font_color", Style.RED)
|
||||
return
|
||||
app.save()
|
||||
app.sfx.play("play")
|
||||
cards = got
|
||||
revealed = {}
|
||||
new_ids = {}
|
||||
for c in cards:
|
||||
if before.get(c.id, 0) == 0:
|
||||
new_ids[c.id] = true
|
||||
var notes: Array[String] = p.take_notices()
|
||||
msg_label.text = " · ".join(notes)
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN)
|
||||
refresh()
|
||||
|
||||
|
||||
func reveal(i: int) -> void:
|
||||
revealed[i] = true
|
||||
app.sfx.play("click")
|
||||
refresh()
|
||||
|
||||
|
||||
func reveal_all() -> void:
|
||||
for i in 5:
|
||||
revealed[i] = true
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
coins_label.text = "Münzen: %d" % p.coins
|
||||
b_buy.disabled = p.coins < Profile.BOOSTER_COST
|
||||
b_all.disabled = cards.is_empty() or revealed.size() >= 5
|
||||
Style.clear(slots)
|
||||
for i in 5:
|
||||
var r := slot_rect(i)
|
||||
if cards.is_empty():
|
||||
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
|
||||
elif revealed.has(i):
|
||||
var c: Cards.Card = cards[i]
|
||||
var v := CardView.new(c, r.size)
|
||||
v.position = r.position
|
||||
slots.add_child(v)
|
||||
Style.label(slots, c.rarity, 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
|
||||
if new_ids.has(c.id):
|
||||
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
|
||||
else:
|
||||
Style.label(slots, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
|
||||
else:
|
||||
var back := CardBack.new(r.size)
|
||||
back.position = r.position
|
||||
back.clicked.connect(reveal.bind(i))
|
||||
slots.add_child(back)
|
||||
1
scripts/ui/booster_screen.gd.uid
Normal file
1
scripts/ui/booster_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d3irimwei0s4d
|
||||
59
scripts/ui/campaign_screen.gd
Normal file
59
scripts/ui/campaign_screen.gd
Normal file
@@ -0,0 +1,59 @@
|
||||
class_name CampaignScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var p: Profile = app.profile
|
||||
var col := Style.column(self, 1000, 24, 6)
|
||||
Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Zehn Magier warten. Jede Stufe bringt mehr Gegner an den Tisch, alle nach denselben Regeln.", 16, Style.MUTED)
|
||||
Style.spacer(col, 6)
|
||||
for i in Campaign.STAGES.size():
|
||||
var st: Dictionary = Campaign.STAGES[i]
|
||||
var done := i < p.campaign_stage
|
||||
var next := i == p.campaign_stage
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6,
|
||||
Style.GOLD if next else null, 2 if next else 0))
|
||||
col.add_child(panel)
|
||||
var r := Style.row(panel, 12)
|
||||
r.custom_minimum_size = Vector2(0, 50)
|
||||
Style.clabel(r, str(i + 1), 20, Style.GOLD if next else Style.MUTED, "center", true, 40)
|
||||
Style.clabel(r, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300)
|
||||
var dots := Control.new()
|
||||
dots.custom_minimum_size = Vector2(60, 0)
|
||||
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var elems: Array = st.elements
|
||||
dots.draw.connect(func():
|
||||
for k in elems.size():
|
||||
dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]]))
|
||||
r.add_child(dots)
|
||||
var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2)
|
||||
Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300)
|
||||
if done:
|
||||
Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110)
|
||||
Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i))
|
||||
elif next:
|
||||
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
|
||||
Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c"))
|
||||
else:
|
||||
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
|
||||
var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass)
|
||||
b.disabled = true
|
||||
if p.campaign_stage >= Campaign.STAGES.size():
|
||||
Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true)
|
||||
Style.spacer(col, 4)
|
||||
var br := Style.row(col)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func _start(stage: int) -> void:
|
||||
app.show_screen(BattleScreen.new(0, stage))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
1
scripts/ui/campaign_screen.gd.uid
Normal file
1
scripts/ui/campaign_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bk50mk881o2hr
|
||||
50
scripts/ui/card_grid.gd
Normal file
50
scripts/ui/card_grid.gd
Normal file
@@ -0,0 +1,50 @@
|
||||
class_name CardGrid
|
||||
extends ScrollContainer
|
||||
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
|
||||
|
||||
signal card_clicked(card: Cards.Card)
|
||||
signal card_hover(card: Cards.Card, entered: bool)
|
||||
|
||||
const COLS := 7
|
||||
const CW := 100
|
||||
const CH := 140
|
||||
const GAP := 12
|
||||
|
||||
var grid: GridContainer
|
||||
|
||||
|
||||
func _init(rect: Rect2) -> void:
|
||||
position = rect.position
|
||||
size = rect.size
|
||||
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
grid = GridContainer.new()
|
||||
grid.columns = COLS
|
||||
grid.add_theme_constant_override("h_separation", GAP)
|
||||
grid.add_theme_constant_override("v_separation", GAP)
|
||||
add_child(grid)
|
||||
|
||||
|
||||
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool
|
||||
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
|
||||
Style.clear(grid)
|
||||
for c in cards:
|
||||
var cell := Control.new()
|
||||
cell.custom_minimum_size = Vector2(CW, CH + 16)
|
||||
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var v := CardView.new(c, Vector2(CW, CH))
|
||||
v.dim = dim_fn.call(c)
|
||||
v.clicked.connect(func(_view): card_clicked.emit(c))
|
||||
v.hover.connect(func(card, entered): card_hover.emit(card, entered))
|
||||
cell.add_child(v)
|
||||
var text: String = label_fn.call(c)
|
||||
Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")
|
||||
grid.add_child(cell)
|
||||
|
||||
|
||||
func views() -> Array:
|
||||
var out := []
|
||||
for cell in grid.get_children():
|
||||
for v in cell.get_children():
|
||||
if v is CardView:
|
||||
out.append(v)
|
||||
return out
|
||||
1
scripts/ui/card_grid.gd.uid
Normal file
1
scripts/ui/card_grid.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bil3i64hn7rfd
|
||||
65
scripts/ui/card_view.gd
Normal file
65
scripts/ui/card_view.gd
Normal file
@@ -0,0 +1,65 @@
|
||||
class_name CardView
|
||||
extends Control
|
||||
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau).
|
||||
|
||||
signal clicked(view: CardView)
|
||||
signal hover(card: Cards.Card, entered: bool)
|
||||
|
||||
var card: Cards.Card
|
||||
var border = null # Color oder null (dann Seltenheitsfarbe)
|
||||
var dim := false
|
||||
|
||||
|
||||
func _init(c: Cards.Card, sz: Vector2) -> void:
|
||||
card = c
|
||||
custom_minimum_size = sz
|
||||
size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func(): hover.emit(card, true))
|
||||
mouse_exited.connect(func(): hover.emit(card, false))
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit(self)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
var big := size.x >= 180
|
||||
var mid := size.x >= 115
|
||||
var f_name := 18 if big else (14 if mid else 12)
|
||||
var f_text := 14 if big else (11 if mid else 10)
|
||||
var f_stat := 24 if big else (18 if mid else 15)
|
||||
Style.fill_elements(self, rect, card.elements, 8)
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity]
|
||||
Style.border(self, rect, bcol, 3 if big else 2, 8)
|
||||
# Kosten
|
||||
var r := 16 if big else 12
|
||||
var cc := Vector2(r + 4, r + 4)
|
||||
draw_circle(cc, r, Color("14141c"))
|
||||
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
|
||||
# Name und Kombo
|
||||
var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12)
|
||||
Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true)
|
||||
if mid:
|
||||
var sub: String = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind
|
||||
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
|
||||
# Bild
|
||||
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0)
|
||||
var band_bottom: float = size.y * 0.42 - 4
|
||||
if band_bottom - band_top > 14:
|
||||
var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
|
||||
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
|
||||
# Textfeld
|
||||
var box := Rect2(6, size.y * 0.42, size.x - 12, size.y * 0.42)
|
||||
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
|
||||
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(f_text)),
|
||||
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, f_text, 6 if big else 4, Style.TEXT)
|
||||
# Werte
|
||||
if card.is_creature():
|
||||
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
|
||||
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
|
||||
if dim:
|
||||
draw_rect(rect, Color(0, 0, 0, 0.47))
|
||||
1
scripts/ui/card_view.gd.uid
Normal file
1
scripts/ui/card_view.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://oom648upeavd
|
||||
92
scripts/ui/creature_view.gd
Normal file
92
scripts/ui/creature_view.gd
Normal file
@@ -0,0 +1,92 @@
|
||||
class_name CreatureView
|
||||
extends Control
|
||||
## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen).
|
||||
|
||||
signal clicked(view: CreatureView)
|
||||
signal hover(card: Cards.Card, entered: bool)
|
||||
|
||||
var creature: Game.Creature
|
||||
var border = null
|
||||
var width := 2
|
||||
var flash := false # kurz rot: wurde gerade angegriffen
|
||||
var slot: Vector2 # Zielposition im Raster
|
||||
var busy := false # wird gerade animiert (kein Neupositionieren)
|
||||
|
||||
|
||||
func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
|
||||
creature = cr
|
||||
border = b
|
||||
width = w
|
||||
custom_minimum_size = sz
|
||||
size = sz
|
||||
pivot_offset = sz / 2
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func(): hover.emit(creature.card, true))
|
||||
mouse_exited.connect(func(): hover.emit(creature.card, false))
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit(self)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
var small := size.x < 100
|
||||
var mini := size.x < 60
|
||||
Style.fill_elements(self, rect, creature.card.elements, 6)
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity]
|
||||
if flash:
|
||||
bcol = Style.RED
|
||||
Style.border(self, rect, bcol, 4 if flash else width, 6)
|
||||
if mini:
|
||||
_draw_mini()
|
||||
return
|
||||
var fs := 11 if small else 13
|
||||
var nm := Style.fit(creature.name, true, fs, size.x - 6)
|
||||
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
|
||||
var badges: Array[String] = []
|
||||
if creature.taunt():
|
||||
badges.append("Wächter")
|
||||
if creature.shield:
|
||||
badges.append("Schild")
|
||||
if creature.attacks_per_turn() == 2:
|
||||
badges.append("x2")
|
||||
if creature.frozen:
|
||||
badges.append("Eis")
|
||||
if creature.resonance > 0:
|
||||
badges.append("Resonanz")
|
||||
var y := 18.0 if small else 22.0
|
||||
for b in badges:
|
||||
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
|
||||
y += 12 if small else 15
|
||||
var f := 16 if small else 20
|
||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
||||
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true)
|
||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
||||
Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
|
||||
if creature.frozen:
|
||||
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
|
||||
|
||||
|
||||
## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe.
|
||||
func _draw_mini() -> void:
|
||||
var letters: Array[String] = []
|
||||
if creature.taunt():
|
||||
letters.append("W")
|
||||
if creature.shield:
|
||||
letters.append("S")
|
||||
if creature.attacks_per_turn() == 2:
|
||||
letters.append("2")
|
||||
if creature.frozen:
|
||||
letters.append("E")
|
||||
if creature.resonance > 0:
|
||||
letters.append("R")
|
||||
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
|
||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
||||
Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true)
|
||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
||||
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true)
|
||||
if creature.frozen:
|
||||
draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35))
|
||||
1
scripts/ui/creature_view.gd.uid
Normal file
1
scripts/ui/creature_view.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bcnj0sdt2jbjb
|
||||
351
scripts/ui/deck_screen.gd
Normal file
351
scripts/ui/deck_screen.gd
Normal file
@@ -0,0 +1,351 @@
|
||||
class_name DeckScreen
|
||||
extends Control
|
||||
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten.
|
||||
|
||||
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"]
|
||||
|
||||
var app
|
||||
var slot := 0
|
||||
var deck: Array = []
|
||||
var filter := ""
|
||||
var search := ""
|
||||
var sort_mode := 0
|
||||
var show_missing := false
|
||||
var msg_label: Label
|
||||
var grid: CardGrid
|
||||
var deck_list: Control
|
||||
var deck_label: Label
|
||||
var elems_label: Label
|
||||
var valid_label: Label
|
||||
var active_label: Label
|
||||
var curve: Control
|
||||
var preview: CardView
|
||||
var search_box: LineEdit
|
||||
var sort_box: OptionButton
|
||||
var missing_box: CheckButton
|
||||
var slot_buttons: Array = []
|
||||
var b_activate: Button
|
||||
var b_autofill: Button
|
||||
var name_box: LineEdit
|
||||
|
||||
|
||||
class DeckRow extends Control:
|
||||
signal clicked(card)
|
||||
signal hover(card, entered: bool)
|
||||
var card: Cards.Card
|
||||
var count: int
|
||||
var lit := false
|
||||
|
||||
func _init(c: Cards.Card, n: int, sz: Vector2) -> void:
|
||||
card = c
|
||||
count = n
|
||||
size = sz
|
||||
custom_minimum_size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func():
|
||||
lit = true
|
||||
queue_redraw()
|
||||
hover.emit(card, true))
|
||||
mouse_exited.connect(func():
|
||||
lit = false
|
||||
queue_redraw()
|
||||
hover.emit(card, false))
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit(card)
|
||||
|
||||
func _draw() -> void:
|
||||
if lit:
|
||||
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
|
||||
for k in card.elements.size():
|
||||
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
|
||||
Style.text(self, "%dx %s" % [count, card.name], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
|
||||
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
|
||||
|
||||
|
||||
class EnergyCurve extends Control:
|
||||
var cards: Array = []
|
||||
|
||||
func _draw() -> void:
|
||||
Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0))
|
||||
var counts := {}
|
||||
for c in cards:
|
||||
var k: int = mini(c.cost, 8)
|
||||
counts[k] = counts.get(k, 0) + 1
|
||||
for i in range(1, 9):
|
||||
var n: int = counts.get(i, 0)
|
||||
var x := (i - 1) * 48
|
||||
draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE)
|
||||
if n > 0:
|
||||
Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom")
|
||||
Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
slot = app.profile.active_deck
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
||||
Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.",
|
||||
14, Style.MUTED, Rect2(300, 18, 700, 24))
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||
search_box = LineEdit.new()
|
||||
search_box.position = Vector2(400, 55)
|
||||
search_box.size = Vector2(160, 34)
|
||||
search_box.placeholder_text = "Suche"
|
||||
search_box.text_changed.connect(func(t: String):
|
||||
search = t
|
||||
refresh())
|
||||
add_child(search_box)
|
||||
sort_box = OptionButton.new()
|
||||
sort_box.position = Vector2(570, 55)
|
||||
sort_box.size = Vector2(130, 34)
|
||||
for s in SORTS:
|
||||
sort_box.add_item(s)
|
||||
sort_box.item_selected.connect(func(i: int):
|
||||
sort_mode = i
|
||||
refresh())
|
||||
add_child(sort_box)
|
||||
missing_box = CheckButton.new()
|
||||
missing_box.position = Vector2(710, 58)
|
||||
missing_box.text = "Fehlende"
|
||||
missing_box.toggled.connect(func(on: bool):
|
||||
show_missing = on
|
||||
refresh())
|
||||
add_child(missing_box)
|
||||
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
|
||||
grid.card_clicked.connect(add)
|
||||
grid.card_hover.connect(show_preview)
|
||||
add_child(grid)
|
||||
# Rechte Seite: Slots, Deckliste, Kurve
|
||||
Style.panel(self, Rect2(845, 50, 420, 680))
|
||||
for i in Profile.DECK_SLOTS:
|
||||
slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i)))
|
||||
b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate)
|
||||
name_box = LineEdit.new()
|
||||
name_box.position = Vector2(860, 98)
|
||||
name_box.size = Vector2(200, 30)
|
||||
name_box.max_length = 20
|
||||
name_box.text_submitted.connect(func(t: String): rename(t))
|
||||
name_box.focus_exited.connect(func(): rename(name_box.text))
|
||||
add_child(name_box)
|
||||
deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true)
|
||||
active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right")
|
||||
b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill)
|
||||
b_autofill.add_theme_font_size_override("font_size", 13)
|
||||
elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24))
|
||||
valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22))
|
||||
deck_list = Control.new()
|
||||
deck_list.position = Vector2(860, 182)
|
||||
deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(deck_list)
|
||||
curve = EnergyCurve.new()
|
||||
curve.position = Vector2(860, 604)
|
||||
curve.size = Vector2(400, 125)
|
||||
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(curve)
|
||||
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
|
||||
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c"))
|
||||
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck)
|
||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
|
||||
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview.visible = false
|
||||
add_child(preview)
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus():
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if preview.visible:
|
||||
var pos := get_local_mouse_position() + Vector2(16, 16)
|
||||
pos.x = clampf(pos.x, 0, 1280 - preview.size.x)
|
||||
pos.y = clampf(pos.y, 0, 800 - preview.size.y)
|
||||
preview.position = pos
|
||||
|
||||
|
||||
func set_filter(e: String) -> void:
|
||||
filter = e
|
||||
refresh()
|
||||
|
||||
|
||||
func select_slot(i: int) -> void:
|
||||
slot = i
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
say("", true)
|
||||
refresh()
|
||||
|
||||
|
||||
func rename(t: String) -> void:
|
||||
if t.strip_edges() == app.profile.deck_name(slot):
|
||||
return
|
||||
var err: String = app.profile.rename_deck(slot, t)
|
||||
if err == "":
|
||||
app.save()
|
||||
say("Umbenannt in %s" % app.profile.deck_name(slot), true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func autofill() -> void:
|
||||
app.profile.decks[slot].cards = deck.duplicate()
|
||||
var err: String = app.profile.autofill_deck(slot)
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
if err == "":
|
||||
app.save()
|
||||
say("Aufgefüllt und gespeichert", true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func activate() -> void:
|
||||
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
|
||||
if not ok[0]:
|
||||
say("Erst ein spielbares Deck speichern", false)
|
||||
return
|
||||
app.profile.decks[slot].cards = deck.duplicate()
|
||||
app.profile.active_deck = slot
|
||||
app.save()
|
||||
say("%s ist jetzt das aktive Deck" % app.profile.deck_name(slot), true)
|
||||
refresh()
|
||||
|
||||
|
||||
## Karten der Sammlung (oder alle, wenn Fehlende gezeigt werden), gefiltert und sortiert.
|
||||
func cards() -> Array:
|
||||
var out: Array = Cards.all().duplicate() if show_missing else app.profile.owned_cards()
|
||||
if filter != "":
|
||||
out = out.filter(func(c): return filter in c.elements)
|
||||
if search != "":
|
||||
var q := search.to_lower()
|
||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
||||
match sort_mode:
|
||||
0:
|
||||
out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
||||
1:
|
||||
out.sort_custom(func(a, b): return a.name < b.name)
|
||||
3:
|
||||
out.sort_custom(func(a, b):
|
||||
var ra := Cards.RARITIES.find(a.rarity)
|
||||
var rb := Cards.RARITIES.find(b.rarity)
|
||||
return ra > rb or (ra == rb and a.cost < b.cost))
|
||||
return out
|
||||
|
||||
|
||||
func say(m: String, ok: bool) -> void:
|
||||
msg_label.text = m
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
||||
|
||||
|
||||
func add(card: Cards.Card) -> void:
|
||||
var owned: int = app.profile.collection.get(card.id, 0)
|
||||
var have := deck.count(card.id)
|
||||
if owned == 0:
|
||||
say("%s fehlt in deiner Sammlung (Werkstatt)" % card.name, false)
|
||||
elif deck.size() >= Game.DECK_SIZE:
|
||||
say("Das Deck ist voll (%d)" % Game.DECK_SIZE, false)
|
||||
elif have >= mini(owned, Game.MAX_COPIES):
|
||||
say("Nicht mehr Kopien möglich (max. %d)" % mini(owned, Game.MAX_COPIES), false)
|
||||
else:
|
||||
var elems := Game.deck_elements(deck_cards())
|
||||
for e in card.elements:
|
||||
if not (e in elems):
|
||||
elems.append(e)
|
||||
if elems.size() > Game.MAX_DECK_ELEMENTS:
|
||||
say("Höchstens %d Elemente im Deck" % Game.MAX_DECK_ELEMENTS, false)
|
||||
else:
|
||||
deck.append(card.id)
|
||||
app.sfx.play("click")
|
||||
say("", true)
|
||||
refresh()
|
||||
|
||||
|
||||
func remove(card: Cards.Card) -> void:
|
||||
deck.erase(card.id)
|
||||
say("", true)
|
||||
refresh()
|
||||
|
||||
|
||||
func clear_deck() -> void:
|
||||
deck = []
|
||||
refresh()
|
||||
|
||||
|
||||
func save_deck() -> void:
|
||||
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
|
||||
if ok[0]:
|
||||
app.profile.decks[slot].cards = deck.duplicate()
|
||||
app.save()
|
||||
say("%s gespeichert" % app.profile.deck_name(slot), true)
|
||||
else:
|
||||
say(ok[1], false)
|
||||
refresh()
|
||||
|
||||
|
||||
func deck_cards() -> Array:
|
||||
var out := []
|
||||
for cid in deck:
|
||||
out.append(Cards.by_id(cid))
|
||||
return out
|
||||
|
||||
|
||||
func show_preview(card: Cards.Card, entered: bool) -> void:
|
||||
if entered:
|
||||
preview.card = card
|
||||
preview.visible = true
|
||||
preview.queue_redraw()
|
||||
elif preview.card == card:
|
||||
preview.visible = false
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
grid.fill(cards(),
|
||||
func(c):
|
||||
var owned: int = p.collection.get(c.id, 0)
|
||||
return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned],
|
||||
func(c):
|
||||
var owned: int = p.collection.get(c.id, 0)
|
||||
return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES))
|
||||
for i in slot_buttons.size():
|
||||
var b: Button = slot_buttons[i]
|
||||
var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED)
|
||||
b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2))
|
||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2))
|
||||
active_label.text = "aktiv" if slot == p.active_deck else ""
|
||||
# Deckliste
|
||||
Style.clear(deck_list)
|
||||
var counts := {}
|
||||
for cid in deck:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid))
|
||||
listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
||||
for i in listed.size():
|
||||
var c: Cards.Card = listed[i]
|
||||
var row := DeckRow.new(c, counts[c.id], Vector2(400, 20))
|
||||
row.position = Vector2(0, i * 21)
|
||||
row.clicked.connect(remove)
|
||||
row.hover.connect(show_preview)
|
||||
deck_list.add_child(row)
|
||||
var dc := deck_cards()
|
||||
if not name_box.has_focus():
|
||||
name_box.text = p.deck_name(slot)
|
||||
deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE]
|
||||
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
|
||||
var elems := Game.deck_elements(dc)
|
||||
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
|
||||
elems_label.text = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–")
|
||||
var ok: Array = Game.validate_deck(dc, p.collection)
|
||||
valid_label.text = "spielbar" if ok[0] else ok[1]
|
||||
valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE)
|
||||
curve.cards = dc
|
||||
curve.queue_redraw()
|
||||
1
scripts/ui/deck_screen.gd.uid
Normal file
1
scripts/ui/deck_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://yg8pbj56mib5
|
||||
111
scripts/ui/hotseat_screen.gd
Normal file
111
scripts/ui/hotseat_screen.gd
Normal file
@@ -0,0 +1,111 @@
|
||||
class_name HotseatScreen
|
||||
extends Control
|
||||
## Lokal zu mehreren am selben Rechner: Spielerzahl, KI-Zahl und Decks wählen.
|
||||
|
||||
var app
|
||||
var n_humans := 2
|
||||
var n_ai := 0
|
||||
var deck_choice: Array = [] # je Mensch: Slot-Index oder -1 für Zufallsdeck
|
||||
var human_buttons: Array = []
|
||||
var ai_buttons: Array = []
|
||||
var deck_rows: VBoxContainer
|
||||
var b_start: Button
|
||||
var hint: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 760, 40, 10)
|
||||
Style.clabel(col, "Lokal zu mehreren", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Alle spielen am selben Rechner und reichen die Maus weiter. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED)
|
||||
Style.spacer(col, 10)
|
||||
Style.clabel(col, "Spieler", 20, Style.TEXT, "center", true)
|
||||
var hr := Style.row(col, 10)
|
||||
hr.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
for n in range(2, 5):
|
||||
human_buttons.append(Style.cbutton(hr, "%d Spieler" % n, Vector2(150, 44), func(): set_humans(n)))
|
||||
Style.clabel(col, "KI-Gegner", 20, Style.TEXT, "center", true)
|
||||
var ar := Style.row(col, 10)
|
||||
ar.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
for n in range(0, 5):
|
||||
ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 44), func(): set_ai(n)))
|
||||
Style.spacer(col, 6)
|
||||
Style.clabel(col, "Decks", 20, Style.TEXT, "center", true)
|
||||
deck_rows = VBoxContainer.new()
|
||||
deck_rows.add_theme_constant_override("separation", 6)
|
||||
col.add_child(deck_rows)
|
||||
Style.spacer(col, 6)
|
||||
hint = Style.clabel(col, "", 15, Style.ORANGE)
|
||||
var br := Style.row(col, 20)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
b_start = Style.cbutton(br, "Start", Vector2(200, 48), start, Color("285a3c"))
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 48), func(): app.show_screen(MenuScreen.new()))
|
||||
for i in 4:
|
||||
deck_choice.append(-1)
|
||||
var p: Profile = app.profile
|
||||
if p.deck_valid()[0]:
|
||||
deck_choice[0] = p.active_deck
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func set_humans(n: int) -> void:
|
||||
n_humans = n
|
||||
n_ai = mini(n_ai, Game.MAX_OPPONENTS + 1 - n_humans)
|
||||
refresh()
|
||||
|
||||
|
||||
func set_ai(n: int) -> void:
|
||||
n_ai = n
|
||||
refresh()
|
||||
|
||||
|
||||
func valid_slots() -> Array:
|
||||
var out := []
|
||||
for i in Profile.DECK_SLOTS:
|
||||
if app.profile.deck_valid(i)[0]:
|
||||
out.append(i)
|
||||
return out
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
for i in human_buttons.size():
|
||||
human_buttons[i].disabled = (i + 2) == n_humans
|
||||
for i in ai_buttons.size():
|
||||
ai_buttons[i].disabled = i == n_ai or n_humans + i > Game.MAX_OPPONENTS + 1
|
||||
Style.clear(deck_rows)
|
||||
var slots := valid_slots()
|
||||
for h in n_humans:
|
||||
var r := Style.row(deck_rows, 12)
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.clabel(r, "Spieler %d" % (h + 1), 16, Style.TEXT, "right", true, 140)
|
||||
var ob := OptionButton.new()
|
||||
ob.custom_minimum_size = Vector2(340, 36)
|
||||
ob.add_item("Zufallsdeck", -1)
|
||||
for slot in slots:
|
||||
var elems := Game.deck_elements(p.deck_cards(slot))
|
||||
ob.add_item("%s (%s)" % [p.deck_name(slot), ", ".join(elems)], slot)
|
||||
ob.select(ob.get_item_index(deck_choice[h]) if deck_choice[h] >= 0 and slots.has(deck_choice[h]) else 0)
|
||||
var idx := h
|
||||
ob.item_selected.connect(func(i: int): deck_choice[idx] = ob.get_item_id(i))
|
||||
r.add_child(ob)
|
||||
hint.text = "%d am Tisch" % (n_humans + n_ai)
|
||||
|
||||
|
||||
func start() -> void:
|
||||
var p: Profile = app.profile
|
||||
var r := Game.make_rng()
|
||||
var decks := []
|
||||
for h in n_humans:
|
||||
var slot: int = deck_choice[h]
|
||||
if slot >= 0 and p.deck_valid(slot)[0]:
|
||||
decks.append(p.deck_cards(slot))
|
||||
else:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, r)
|
||||
decks.append(Game.build_opponent_deck(r, [pool[0], pool[1]]))
|
||||
app.show_screen(BattleScreen.hotseat(decks, n_ai))
|
||||
1
scripts/ui/hotseat_screen.gd.uid
Normal file
1
scripts/ui/hotseat_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://crlne4y0uiygl
|
||||
112
scripts/ui/lobby_screen.gd
Normal file
112
scripts/ui/lobby_screen.gd
Normal file
@@ -0,0 +1,112 @@
|
||||
class_name LobbyScreen
|
||||
extends Control
|
||||
## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten.
|
||||
|
||||
var app
|
||||
var net: Net
|
||||
var status_label: Label
|
||||
var list_label: Label
|
||||
var ip_box: LineEdit
|
||||
var b_host: Button
|
||||
var b_join: Button
|
||||
var b_start: Button
|
||||
var ai_buttons: Array = []
|
||||
var n_ai := 0
|
||||
var addr_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
net = app.net
|
||||
net.my_name = "Spieler"
|
||||
net.my_deck = app.profile.deck.duplicate()
|
||||
var col := Style.column(self, 760, 40, 10)
|
||||
Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Im selben Netz: einer eröffnet, die anderen treten mit seiner Adresse bei. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED)
|
||||
Style.spacer(col, 10)
|
||||
var hr := Style.row(col, 12)
|
||||
hr.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c"))
|
||||
ip_box = LineEdit.new()
|
||||
ip_box.custom_minimum_size = Vector2(220, 46)
|
||||
ip_box.placeholder_text = "Adresse des Hosts"
|
||||
ip_box.text = "127.0.0.1"
|
||||
hr.add_child(ip_box)
|
||||
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
|
||||
addr_label = Style.clabel(col, "", 14, Style.MUTED)
|
||||
status_label = Style.clabel(col, "", 16, Style.BLUE)
|
||||
Style.spacer(col, 6)
|
||||
Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true)
|
||||
list_label = Style.clabel(col, "", 16, Style.TEXT)
|
||||
Style.spacer(col, 6)
|
||||
Style.clabel(col, "KI-Gegner (nur Host)", 16, Style.MUTED)
|
||||
var ar := Style.row(col, 10)
|
||||
ar.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
for n in range(0, 5):
|
||||
ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 40), func(): _set_ai(n)))
|
||||
Style.spacer(col, 10)
|
||||
var br := Style.row(col, 20)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 48), _back)
|
||||
net.lobby_changed.connect(refresh)
|
||||
net.started.connect(_started)
|
||||
net.ended.connect(func(_reason): refresh())
|
||||
refresh()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if net.lobby_changed.is_connected(refresh):
|
||||
net.lobby_changed.disconnect(refresh)
|
||||
if net.started.is_connected(_started):
|
||||
net.started.disconnect(_started)
|
||||
|
||||
|
||||
func _host() -> void:
|
||||
net.host()
|
||||
refresh()
|
||||
|
||||
|
||||
func _join() -> void:
|
||||
net.join(ip_box.text.strip_edges())
|
||||
refresh()
|
||||
|
||||
|
||||
func _set_ai(n: int) -> void:
|
||||
n_ai = n
|
||||
refresh()
|
||||
|
||||
|
||||
func _start() -> void:
|
||||
net.start_battle(n_ai)
|
||||
|
||||
|
||||
func _started(seat: int) -> void:
|
||||
app.show_screen(BattleScreen.online(seat))
|
||||
|
||||
|
||||
func _back() -> void:
|
||||
net.leave()
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
status_label.text = net.status
|
||||
var lines: Array[String] = []
|
||||
var ids: Array = net.names.keys()
|
||||
ids.sort()
|
||||
for id in ids:
|
||||
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
|
||||
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
|
||||
b_host.disabled = net.connected
|
||||
b_join.disabled = net.connected
|
||||
ip_box.editable = not net.connected
|
||||
var humans := ids.size()
|
||||
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)
|
||||
for i in ai_buttons.size():
|
||||
ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1
|
||||
addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else ""
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus():
|
||||
_back()
|
||||
1
scripts/ui/lobby_screen.gd.uid
Normal file
1
scripts/ui/lobby_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bhnnxjh6rsxnf
|
||||
52
scripts/ui/main.gd
Normal file
52
scripts/ui/main.gd
Normal file
@@ -0,0 +1,52 @@
|
||||
extends Control
|
||||
## Einstieg: hält den Spielstand und wechselt zwischen den Bildschirmen.
|
||||
|
||||
var profile: Profile
|
||||
var screen: Control
|
||||
var save_path: String = Profile.SAVE_PATH
|
||||
var sfx: Sfx
|
||||
var net: Net
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
theme = Style.make_theme()
|
||||
var bg := Style.Backdrop.new()
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(bg)
|
||||
sfx = Sfx.new()
|
||||
add_child(sfx)
|
||||
# Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt,
|
||||
# der bei Host und Mitspielern gleich sein muss.
|
||||
net = Net.new()
|
||||
net.name = "Net"
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
profile = Profile.load_or_new(save_path)
|
||||
profile.refresh_daily()
|
||||
sfx.set_volume(profile.settings.volume)
|
||||
sfx.set_music_volume(profile.settings.music)
|
||||
set_language(profile.settings.language)
|
||||
show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func set_language(code: String) -> void:
|
||||
TranslationServer.set_locale(code)
|
||||
|
||||
|
||||
func show_screen(s: Control) -> void:
|
||||
if screen != null:
|
||||
screen.queue_free()
|
||||
screen = s
|
||||
s.app = self
|
||||
s.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(s)
|
||||
|
||||
|
||||
func save() -> void:
|
||||
profile.save(save_path)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if net != null and net.is_inside_tree():
|
||||
net.leave()
|
||||
net.queue_free()
|
||||
1
scripts/ui/main.gd.uid
Normal file
1
scripts/ui/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bkltqeiif4e0j
|
||||
84
scripts/ui/menu_screen.gd
Normal file
84
scripts/ui/menu_screen.gd
Normal file
@@ -0,0 +1,84 @@
|
||||
class_name MenuScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
var b_single: Button
|
||||
var b_campaign: Button
|
||||
var b_hotseat: Button
|
||||
var b_online: Button
|
||||
var b_tutorial: Button
|
||||
var coins_label: Label
|
||||
var coll_label: Label
|
||||
var deck_label: Label
|
||||
var daily_label: Label
|
||||
var warn_label: Label
|
||||
var notice_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 640, 24, 5)
|
||||
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED)
|
||||
Style.spacer(col, 8)
|
||||
coins_label = Style.clabel(col, "", 20, Style.GOLD)
|
||||
coll_label = Style.clabel(col, "", 16, Style.MUTED)
|
||||
deck_label = Style.clabel(col, "", 16, Style.MUTED)
|
||||
daily_label = Style.clabel(col, "", 16, Style.BLUE)
|
||||
Style.spacer(col, 8)
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 2
|
||||
grid.add_theme_constant_override("h_separation", 12)
|
||||
grid.add_theme_constant_override("v_separation", 8)
|
||||
var center := CenterContainer.new()
|
||||
center.add_child(grid)
|
||||
col.add_child(center)
|
||||
var sz := Vector2(270, 44)
|
||||
var p0: Profile = app.profile
|
||||
b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()),
|
||||
Color("5a4628") if not p0.tutorial_done else null)
|
||||
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
|
||||
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
|
||||
b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c"))
|
||||
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
|
||||
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
|
||||
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
|
||||
Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new()))
|
||||
Style.cbutton(grid, "Erfolge", sz, func(): app.show_screen(AchievementsScreen.new()))
|
||||
Style.cbutton(grid, "Regeln", sz, func(): app.show_screen(RulesScreen.new()))
|
||||
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
|
||||
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
|
||||
Style.spacer(col, 10)
|
||||
warn_label = Style.clabel(col, "", 16, Style.RED)
|
||||
notice_label = Style.clabel(col, "", 16, Style.GREEN)
|
||||
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
var ok: Array = p.deck_valid()
|
||||
b_single.disabled = not ok[0]
|
||||
b_campaign.disabled = not ok[0]
|
||||
b_tutorial.disabled = not ok[0]
|
||||
b_online.disabled = not ok[0]
|
||||
var total := 0
|
||||
for cid in p.collection:
|
||||
total += p.collection[cid]
|
||||
coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust]
|
||||
coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [
|
||||
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()]
|
||||
var elems := Game.deck_elements(p.deck_cards())
|
||||
deck_label.text = "Aktives Deck: %s (%s)" % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else "leer"]
|
||||
var task := p.daily_task()
|
||||
if p.daily.done:
|
||||
daily_label.text = "Tagesaufgabe erledigt: %s ✓" % task.text
|
||||
else:
|
||||
daily_label.text = "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
|
||||
warn_label.text = "" if ok[0] else "Aktives Deck nicht spielbar: " + ok[1]
|
||||
if ok[0] and not p.tutorial_done:
|
||||
warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos."
|
||||
warn_label.add_theme_color_override("font_color", Style.GOLD)
|
||||
var notes := p.take_notices()
|
||||
notice_label.text = " · ".join(notes)
|
||||
if not notes.is_empty():
|
||||
app.save()
|
||||
1
scripts/ui/menu_screen.gd.uid
Normal file
1
scripts/ui/menu_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3ib6kcyl21f
|
||||
29
scripts/ui/opponent_select_screen.gd
Normal file
29
scripts/ui/opponent_select_screen.gd
Normal file
@@ -0,0 +1,29 @@
|
||||
class_name OpponentSelectScreen
|
||||
extends Control
|
||||
## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird.
|
||||
|
||||
var app
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 900, 50, 14)
|
||||
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Wie viele Gegner? Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben." % Game.START_LIFE, 18, Style.MUTED)
|
||||
Style.clabel(col, "Du gewinnst, wenn alle anderen besiegt sind. Mehr Gegner, mehr Chaos, mehr Münzen.", 16, Style.MUTED)
|
||||
Style.spacer(col, 10)
|
||||
for i in range(1, Game.MAX_OPPONENTS + 1):
|
||||
var n := i
|
||||
var r := Style.row(col, 20)
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(r, "%d Gegner" % n, Vector2(200, 56), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c"))
|
||||
var info := ("Duell" if n == 1 else "%d Spieler am Tisch" % (n + 1)) + " · Sieg: %d Münzen" % Profile.REWARD_WIN[n]
|
||||
Style.clabel(r, info, 16, Style.MUTED, "left", false, 420)
|
||||
Style.spacer(col, 20)
|
||||
var br := Style.row(col)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
1
scripts/ui/opponent_select_screen.gd.uid
Normal file
1
scripts/ui/opponent_select_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cjpjji3gxmxvf
|
||||
25
scripts/ui/rules_screen.gd
Normal file
25
scripts/ui/rules_screen.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
class_name RulesScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Style.label(self, "Regeln", 36, Style.GOLD, Rect2(0, 18, 1280, 50), "center", true)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.position = Vector2(60, 78)
|
||||
scroll.size = Vector2(1160, 640)
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
var text := Label.new()
|
||||
text.text = Style.rules_text()
|
||||
text.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
text.custom_minimum_size = Vector2(1140, 0)
|
||||
text.add_theme_font_size_override("font_size", 15)
|
||||
scroll.add_child(text)
|
||||
Style.button(self, "Zurück", Rect2(560, 735, 160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
1
scripts/ui/rules_screen.gd.uid
Normal file
1
scripts/ui/rules_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bkemrytc2pjem
|
||||
121
scripts/ui/settings_screen.gd
Normal file
121
scripts/ui/settings_screen.gd
Normal file
@@ -0,0 +1,121 @@
|
||||
class_name SettingsScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
var volume: HSlider
|
||||
var music: HSlider
|
||||
var speed: HSlider
|
||||
var language: OptionButton
|
||||
var music_label: Label
|
||||
var anim: CheckButton
|
||||
var volume_label: Label
|
||||
var speed_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var s: Dictionary = app.profile.settings
|
||||
var col := Style.column(self, 700, 50, 24)
|
||||
Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true)
|
||||
Style.spacer(col, 30)
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 3
|
||||
grid.add_theme_constant_override("h_separation", 24)
|
||||
grid.add_theme_constant_override("v_separation", 30)
|
||||
col.add_child(grid)
|
||||
Style.clabel(grid, "Lautstärke", 20, Style.TEXT, "left", false, 200)
|
||||
volume = HSlider.new()
|
||||
volume.custom_minimum_size = Vector2(280, 24)
|
||||
volume.min_value = 0
|
||||
volume.max_value = 1
|
||||
volume.step = 0.05
|
||||
volume.value = s.volume
|
||||
volume.value_changed.connect(_on_volume)
|
||||
grid.add_child(volume)
|
||||
volume_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "Musik", 20, Style.TEXT, "left", false, 200)
|
||||
music = HSlider.new()
|
||||
music.custom_minimum_size = Vector2(280, 24)
|
||||
music.min_value = 0
|
||||
music.max_value = 1
|
||||
music.step = 0.05
|
||||
music.value = s.music
|
||||
music.value_changed.connect(_on_music)
|
||||
grid.add_child(music)
|
||||
music_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "KI-Tempo", 20, Style.TEXT, "left", false, 200)
|
||||
speed = HSlider.new()
|
||||
speed.custom_minimum_size = Vector2(280, 24)
|
||||
speed.min_value = 0.5
|
||||
speed.max_value = 3.0
|
||||
speed.step = 0.25
|
||||
speed.value = s.ai_speed
|
||||
speed.value_changed.connect(_on_speed)
|
||||
grid.add_child(speed)
|
||||
speed_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "Animationen", 20, Style.TEXT, "left", false, 200)
|
||||
anim = CheckButton.new()
|
||||
anim.button_pressed = s.animations
|
||||
anim.toggled.connect(_on_anim)
|
||||
grid.add_child(anim)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200)
|
||||
language = OptionButton.new()
|
||||
language.custom_minimum_size = Vector2(280, 36)
|
||||
language.add_item("Deutsch", 0)
|
||||
language.add_item("English (Oberfläche)", 1)
|
||||
language.select(1 if s.language == "en" else 0)
|
||||
language.item_selected.connect(_on_language)
|
||||
grid.add_child(language)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.spacer(col, 20)
|
||||
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
|
||||
Style.spacer(col, 60)
|
||||
var r := Style.row(col)
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
_labels()
|
||||
|
||||
|
||||
func _labels() -> void:
|
||||
volume_label.text = "%d %%" % int(round(volume.value * 100))
|
||||
music_label.text = "%d %%" % int(round(music.value * 100))
|
||||
speed_label.text = "%.2fx" % speed.value
|
||||
|
||||
|
||||
func _on_music(v: float) -> void:
|
||||
app.profile.settings.music = v
|
||||
app.sfx.set_music_volume(v)
|
||||
app.save()
|
||||
_labels()
|
||||
|
||||
|
||||
func _on_language(i: int) -> void:
|
||||
var code := "en" if i == 1 else "de"
|
||||
app.profile.settings.language = code
|
||||
app.save()
|
||||
app.set_language(code)
|
||||
app.show_screen(SettingsScreen.new())
|
||||
|
||||
|
||||
func _on_volume(v: float) -> void:
|
||||
app.profile.settings.volume = v
|
||||
app.sfx.set_volume(v)
|
||||
app.sfx.play("click")
|
||||
app.save()
|
||||
_labels()
|
||||
|
||||
|
||||
func _on_speed(v: float) -> void:
|
||||
app.profile.settings.ai_speed = v
|
||||
app.save()
|
||||
_labels()
|
||||
|
||||
|
||||
func _on_anim(on: bool) -> void:
|
||||
app.profile.settings.animations = on
|
||||
app.save()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
1
scripts/ui/settings_screen.gd.uid
Normal file
1
scripts/ui/settings_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://i4f1frwa2cmm
|
||||
107
scripts/ui/sfx.gd
Normal file
107
scripts/ui/sfx.gd
Normal file
@@ -0,0 +1,107 @@
|
||||
class_name Sfx
|
||||
extends Node
|
||||
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
|
||||
|
||||
static var inst: Sfx
|
||||
|
||||
var streams := {}
|
||||
var players: Array[AudioStreamPlayer] = []
|
||||
var volume := 0.8
|
||||
var music_player: AudioStreamPlayer
|
||||
var music_volume := 0.5
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
inst = self
|
||||
streams["click"] = _tone(880, 0.05, "sine")
|
||||
streams["play"] = _tone(440, 0.14, "sine", 660)
|
||||
streams["attack"] = _tone(220, 0.16, "square", 90)
|
||||
streams["damage"] = _tone(140, 0.12, "square")
|
||||
streams["heal"] = _tone(523, 0.22, "sine", 784)
|
||||
streams["combo"] = _tone(660, 0.2, "sine", 1100)
|
||||
streams["win"] = _tone(400, 0.6, "sine", 1200)
|
||||
streams["lose"] = _tone(320, 0.7, "square", 90)
|
||||
for i in 4:
|
||||
var p := AudioStreamPlayer.new()
|
||||
add_child(p)
|
||||
players.append(p)
|
||||
music_player = AudioStreamPlayer.new()
|
||||
music_player.stream = _music()
|
||||
add_child(music_player)
|
||||
set_music_volume(music_volume)
|
||||
|
||||
|
||||
## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt.
|
||||
func _music() -> AudioStreamWAV:
|
||||
var rate := 22050
|
||||
var chords := [[220.0, 261.63, 329.63], [174.61, 220.0, 261.63], [130.81, 164.81, 196.0], [196.0, 246.94, 293.66]]
|
||||
var note_len := 0.25
|
||||
var pattern := [0, 1, 2, 1, 0, 2, 1, 2]
|
||||
var total := chords.size() * pattern.size() * note_len
|
||||
var n := int(total * rate)
|
||||
var data := PackedByteArray()
|
||||
data.resize(n * 2)
|
||||
for i in n:
|
||||
var t := float(i) / rate
|
||||
var step := int(t / note_len)
|
||||
var chord: Array = chords[(step / pattern.size()) % chords.size()]
|
||||
var f: float = chord[pattern[step % pattern.size()]]
|
||||
var lt := t - step * note_len
|
||||
var env := minf(1.0, lt * 30.0) * maxf(0.0, 1.0 - lt / note_len)
|
||||
var v := sin(TAU * f * t) * 0.6 + sin(TAU * f * 2.0 * t) * 0.15 + sin(TAU * chord[0] * 0.5 * t) * 0.25
|
||||
data.encode_s16(i * 2, int(clampf(v * env * 0.25, -1.0, 1.0) * 32767))
|
||||
var wav := AudioStreamWAV.new()
|
||||
wav.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
wav.mix_rate = rate
|
||||
wav.stereo = false
|
||||
wav.data = data
|
||||
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
wav.loop_begin = 0
|
||||
wav.loop_end = n
|
||||
return wav
|
||||
|
||||
|
||||
func set_music_volume(v: float) -> void:
|
||||
music_volume = clampf(v, 0.0, 1.0)
|
||||
if music_volume <= 0.0:
|
||||
music_player.stop()
|
||||
else:
|
||||
music_player.volume_db = linear_to_db(music_volume * 0.6)
|
||||
if not music_player.playing:
|
||||
music_player.play()
|
||||
|
||||
|
||||
func set_volume(v: float) -> void:
|
||||
volume = clampf(v, 0.0, 1.0)
|
||||
|
||||
|
||||
func play(kind: String) -> void:
|
||||
if volume <= 0.0 or not streams.has(kind):
|
||||
return
|
||||
for p in players:
|
||||
if not p.playing:
|
||||
p.stream = streams[kind]
|
||||
p.volume_db = linear_to_db(volume)
|
||||
p.play()
|
||||
return
|
||||
|
||||
|
||||
func _tone(freq: float, dur: float, shape: String, freq_end: float = -1.0) -> AudioStreamWAV:
|
||||
var rate := 22050
|
||||
var n := int(dur * rate)
|
||||
var data := PackedByteArray()
|
||||
data.resize(n * 2)
|
||||
for i in n:
|
||||
var t := float(i) / rate
|
||||
var f := freq if freq_end < 0 else lerpf(freq, freq_end, t / dur)
|
||||
var env := 1.0 - t / dur
|
||||
var v := sin(TAU * f * t)
|
||||
if shape == "square":
|
||||
v = 1.0 if v >= 0 else -1.0
|
||||
data.encode_s16(i * 2, int(clampf(v * env * 0.4, -1.0, 1.0) * 32767))
|
||||
var wav := AudioStreamWAV.new()
|
||||
wav.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
wav.mix_rate = rate
|
||||
wav.stereo = false
|
||||
wav.data = data
|
||||
return wav
|
||||
1
scripts/ui/sfx.gd.uid
Normal file
1
scripts/ui/sfx.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bprwiyidhn7ek
|
||||
365
scripts/ui/style.gd
Normal file
365
scripts/ui/style.gd
Normal file
@@ -0,0 +1,365 @@
|
||||
class_name Style
|
||||
## Farben, Schriften, Theme und Zeichenhilfen.
|
||||
|
||||
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
|
||||
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
|
||||
|
||||
const BG := Color("1a1c24")
|
||||
const PANEL := Color("282b36")
|
||||
const PANEL_LIGHT := Color("3a3e4c")
|
||||
const TEXT := Color("ececf0")
|
||||
const MUTED := Color("9698a5")
|
||||
const GOLD := Color("f5c850")
|
||||
const GREEN := Color("5ad282")
|
||||
const RED := Color("e65555")
|
||||
const BLUE := Color("6eaaf0")
|
||||
const YELLOW := Color("ffe65a")
|
||||
const ORANGE := Color("ff963c")
|
||||
|
||||
|
||||
static func darker(c: Color, f: float = 0.65) -> Color:
|
||||
return Color(c.r * f, c.g * f, c.b * f, c.a)
|
||||
|
||||
|
||||
static func lighter(c: Color, d: float = 0.1) -> Color:
|
||||
return Color(minf(1, c.r + d), minf(1, c.g + d), minf(1, c.b + d), c.a)
|
||||
|
||||
|
||||
static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0) -> StyleBoxFlat:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = bg
|
||||
sb.set_corner_radius_all(radius)
|
||||
if border_col != null:
|
||||
sb.border_color = border_col
|
||||
sb.set_border_width_all(border_w)
|
||||
return sb
|
||||
|
||||
|
||||
static func make_theme() -> Theme:
|
||||
var t := Theme.new()
|
||||
t.default_font = FONT
|
||||
t.default_font_size = 16
|
||||
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
|
||||
t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2))
|
||||
t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2))
|
||||
t.set_stylebox("disabled", "Button", box(darker(PANEL, 0.9), 8, PANEL, 2))
|
||||
t.set_stylebox("focus", "Button", StyleBoxEmpty.new())
|
||||
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
|
||||
t.set_color(name, "Button", TEXT)
|
||||
t.set_color("font_disabled_color", "Button", MUTED)
|
||||
t.set_font_size("font_size", "Button", 18)
|
||||
t.set_color("font_color", "Label", TEXT)
|
||||
t.set_stylebox("panel", "Panel", box(PANEL, 8))
|
||||
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
|
||||
t.set_stylebox("focus", "RichTextLabel", StyleBoxEmpty.new())
|
||||
t.set_stylebox("panel", "ScrollContainer", StyleBoxEmpty.new())
|
||||
t.set_stylebox("normal", "LineEdit", box(PANEL_LIGHT, 6, MUTED, 1))
|
||||
t.set_stylebox("focus", "LineEdit", box(PANEL_LIGHT, 6, BLUE, 2))
|
||||
t.set_color("font_color", "LineEdit", TEXT)
|
||||
t.set_color("font_placeholder_color", "LineEdit", MUTED)
|
||||
t.set_stylebox("normal", "OptionButton", box(PANEL_LIGHT, 6, MUTED, 1))
|
||||
t.set_stylebox("hover", "OptionButton", box(lighter(PANEL_LIGHT), 6, MUTED, 1))
|
||||
t.set_stylebox("pressed", "OptionButton", box(darker(PANEL_LIGHT), 6, MUTED, 1))
|
||||
t.set_stylebox("focus", "OptionButton", StyleBoxEmpty.new())
|
||||
t.set_color("font_color", "OptionButton", TEXT)
|
||||
t.set_color("font_color", "CheckButton", TEXT)
|
||||
t.set_stylebox("focus", "CheckButton", StyleBoxEmpty.new())
|
||||
return t
|
||||
|
||||
|
||||
# --- Knoten-Hilfen -------------------------------------------------------------
|
||||
|
||||
static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8, border_col = null, border_w: int = 0) -> Panel:
|
||||
var p := Panel.new()
|
||||
p.position = rect.position
|
||||
p.size = rect.size
|
||||
p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w))
|
||||
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
parent.add_child(p)
|
||||
return p
|
||||
|
||||
|
||||
static func label(parent: Node, text: String, size: int, color: Color, rect: Rect2, align: String = "left", bold: bool = false) -> Label:
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.position = rect.position
|
||||
l.size = rect.size
|
||||
l.add_theme_font_size_override("font_size", size)
|
||||
l.add_theme_color_override("font_color", color)
|
||||
if bold:
|
||||
l.add_theme_font_override("font", FONT_BOLD)
|
||||
match align:
|
||||
"center":
|
||||
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
"right":
|
||||
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
parent.add_child(l)
|
||||
return l
|
||||
|
||||
|
||||
static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable, color = null) -> Button:
|
||||
var b := Button.new()
|
||||
b.text = text
|
||||
b.position = rect.position
|
||||
b.size = rect.size
|
||||
b.pressed.connect(on_pressed)
|
||||
if color != null:
|
||||
b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2))
|
||||
b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2))
|
||||
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
|
||||
parent.add_child(b)
|
||||
return b
|
||||
|
||||
|
||||
## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
|
||||
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
|
||||
var v := VBoxContainer.new()
|
||||
v.anchor_left = 0.5
|
||||
v.anchor_right = 0.5
|
||||
v.anchor_top = 0
|
||||
v.anchor_bottom = 0
|
||||
v.offset_left = -width / 2
|
||||
v.offset_right = width / 2
|
||||
v.offset_top = top
|
||||
v.add_theme_constant_override("separation", sep)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
parent.add_child(v)
|
||||
return v
|
||||
|
||||
|
||||
static func row(parent: Node, sep: int = 12) -> HBoxContainer:
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", sep)
|
||||
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
parent.add_child(h)
|
||||
return h
|
||||
|
||||
|
||||
## Label in einem Container.
|
||||
static func clabel(parent: Node, text: String, size: int, color: Color, align: String = "center", bold: bool = false, min_w: float = 0) -> Label:
|
||||
var l := label(parent, text, size, color, Rect2(0, 0, 0, 0), align, bold)
|
||||
l.size_flags_horizontal = Control.SIZE_EXPAND_FILL if min_w == 0 else Control.SIZE_FILL
|
||||
if min_w > 0:
|
||||
l.custom_minimum_size = Vector2(min_w, 0)
|
||||
return l
|
||||
|
||||
|
||||
## Knopf in einem Container.
|
||||
static func cbutton(parent: Node, text: String, size: Vector2, on_pressed: Callable, color = null) -> Button:
|
||||
var b := button(parent, text, Rect2(Vector2.ZERO, size), on_pressed, color)
|
||||
b.custom_minimum_size = size
|
||||
return b
|
||||
|
||||
|
||||
static func spacer(parent: Node, h: float) -> Control:
|
||||
var c := Control.new()
|
||||
c.custom_minimum_size = Vector2(0, h)
|
||||
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
parent.add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
static func clear(node: Node) -> void:
|
||||
for c in node.get_children():
|
||||
node.remove_child(c)
|
||||
c.queue_free()
|
||||
|
||||
|
||||
# --- Zeichenhilfen für _draw() ---------------------------------------------------
|
||||
|
||||
static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
|
||||
var font: Font = FONT_BOLD if bold else FONT
|
||||
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||
var x := pos.x
|
||||
var y := pos.y
|
||||
match anchor:
|
||||
"center":
|
||||
x -= dim.x / 2
|
||||
y -= dim.y / 2
|
||||
"midtop":
|
||||
x -= dim.x / 2
|
||||
"topright":
|
||||
x -= dim.x
|
||||
"midleft":
|
||||
y -= dim.y / 2
|
||||
"midright":
|
||||
x -= dim.x
|
||||
y -= dim.y / 2
|
||||
"bottomleft":
|
||||
y -= dim.y
|
||||
"bottomright":
|
||||
x -= dim.x
|
||||
y -= dim.y
|
||||
"midbottom":
|
||||
x -= dim.x / 2
|
||||
y -= dim.y
|
||||
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
|
||||
|
||||
|
||||
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
|
||||
var font: Font = FONT_BOLD if bold else FONT
|
||||
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
|
||||
s = s.substr(0, s.length() - 2) + "…"
|
||||
return s
|
||||
|
||||
|
||||
static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius: int) -> void:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.draw_center = false
|
||||
sb.border_color = color
|
||||
sb.set_border_width_all(width)
|
||||
sb.set_corner_radius_all(radius)
|
||||
ci.draw_style_box(sb, rect)
|
||||
|
||||
|
||||
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
|
||||
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
|
||||
if elements.size() == 1:
|
||||
ci.draw_style_box(box(a, radius), rect)
|
||||
elif elements[0] == elements[1]:
|
||||
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
|
||||
ci.draw_style_box(box(a, radius), rect)
|
||||
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3))
|
||||
else:
|
||||
var b: Color = Cards.ELEMENT_COLORS[elements[1]]
|
||||
var half := floorf(rect.size.x / 2)
|
||||
var left := StyleBoxFlat.new()
|
||||
left.bg_color = a
|
||||
left.corner_radius_top_left = radius
|
||||
left.corner_radius_bottom_left = radius
|
||||
var right := StyleBoxFlat.new()
|
||||
right.bg_color = b
|
||||
right.corner_radius_top_right = radius
|
||||
right.corner_radius_bottom_right = radius
|
||||
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
|
||||
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
|
||||
|
||||
|
||||
static func rules_text() -> String:
|
||||
var lines := [
|
||||
"Ziel: Besiege alle anderen. Du verlierst, wenn dein Leben auf 0 fällt.",
|
||||
"Du spielst gegen 1 bis 5 Magier, jeder gegen jeden, alle nach denselben Regeln:",
|
||||
"%d Leben, pro Zug 1 Energie mehr (höchstens 10) und 1 Karte. Die Zugreihenfolge wird ausgelost." % Game.START_LIFE,
|
||||
"Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug 1 Energie extra.",
|
||||
"Fallen liegen verdeckt (höchstens 2) und lösen aus, wenn dich jemand angreift oder ein Wesen ausspielt.",
|
||||
"",
|
||||
"Karten kosten Energie. Wesen kommen aufs Feld (max. 5) und greifen ab dem nächsten Zug an.",
|
||||
"Zauber wirken sofort. Ein Wesen greift ein gegnerisches Wesen oder direkt den Gegner an;",
|
||||
"beide Seiten verursachen ihren Angriffswert als Schaden.",
|
||||
"",
|
||||
"Elemente: Feuer (Schaden), Wasser (Kontrolle), Erde (Zähigkeit), Luft (Tempo).",
|
||||
"Kombos verbinden zwei Elemente, auch das gleiche doppelt (Feuer+Feuer = Inferno).",
|
||||
"Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.",
|
||||
Cards.RESONANCE_TEXT,
|
||||
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
|
||||
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an alle gegnerischen Wesen, Blitz 2 Schaden",
|
||||
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.",
|
||||
"",
|
||||
"Schlüsselwörter:",
|
||||
]
|
||||
for k in Cards.KEYWORDS:
|
||||
lines.append(" %s: %s" % [k, Cards.KEYWORD_HELP[k]])
|
||||
lines.append("")
|
||||
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES])
|
||||
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.")
|
||||
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.")
|
||||
lines.append("")
|
||||
lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.")
|
||||
lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.")
|
||||
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
|
||||
lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.")
|
||||
lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
|
||||
|
||||
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
|
||||
var variant := absi(hash(card.id)) % 3
|
||||
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
|
||||
col.a = 0.95
|
||||
if card.is_trap():
|
||||
# Falle: Kreuz im Kreis
|
||||
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
|
||||
var d := size * 0.28
|
||||
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
|
||||
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
|
||||
return
|
||||
if card.kind == "Zauber":
|
||||
_star(ci, center, size * 0.55, Color(col, 0.35))
|
||||
var elems: Array = card.elements
|
||||
if elems.size() >= 2 and elems[0] != elems[1]:
|
||||
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
|
||||
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
|
||||
col2.a = 0.95
|
||||
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
|
||||
else:
|
||||
_element_glyph(ci, elems[0], center, size, variant, col)
|
||||
if elems.size() >= 2:
|
||||
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
|
||||
if elems.size() >= 3:
|
||||
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
|
||||
|
||||
|
||||
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
|
||||
var pts := PackedVector2Array()
|
||||
for i in 8:
|
||||
var a := TAU * i / 8 - PI / 2
|
||||
var rr := r if i % 2 == 0 else r * 0.45
|
||||
pts.append(c + Vector2(cos(a), sin(a)) * rr)
|
||||
ci.draw_colored_polygon(pts, col)
|
||||
|
||||
|
||||
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
|
||||
var h := size / 2
|
||||
match element:
|
||||
"Feuer":
|
||||
var n := 1 + variant
|
||||
for i in n:
|
||||
var off := Vector2((i - (n - 1) / 2.0) * size * 0.32, 0)
|
||||
var s := 1.0 if i == n / 2 else 0.7
|
||||
_flame(ci, c + off, h * s, col)
|
||||
"Wasser":
|
||||
for i in 2 + variant % 2:
|
||||
var y := c.y + (i - 0.5) * size * 0.3
|
||||
for k in 3:
|
||||
var x := c.x - size * 0.4 + k * size * 0.4
|
||||
ci.draw_arc(Vector2(x, y), size * 0.2, PI, TAU, 12, col, maxf(2, size * 0.09))
|
||||
"Erde":
|
||||
var n := 1 + variant
|
||||
for i in n:
|
||||
var off := (i - (n - 1) / 2.0) * size * 0.4
|
||||
var s := 1.0 if i == n / 2 else 0.65
|
||||
var pts := PackedVector2Array([c + Vector2(off - h * s, h * 0.7), c + Vector2(off, -h * s), c + Vector2(off + h * s, h * 0.7)])
|
||||
ci.draw_colored_polygon(pts, col)
|
||||
ci.draw_line(c + Vector2(-h, h * 0.7), c + Vector2(h, h * 0.7), col, maxf(2, size * 0.08))
|
||||
"Luft":
|
||||
for i in 2 + variant:
|
||||
var r := h * (1.0 - i * 0.28)
|
||||
ci.draw_arc(c + Vector2(i * size * 0.06, 0), r, PI * 0.2 + i * 0.4, PI * 1.5 + i * 0.4, 20, col, maxf(2, size * 0.09))
|
||||
|
||||
|
||||
static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
|
||||
var w := h * 0.75
|
||||
var pts := PackedVector2Array([
|
||||
c + Vector2(0, -h), c + Vector2(w * 0.55, -h * 0.25), c + Vector2(w * 0.4, h * 0.45),
|
||||
c + Vector2(0, h), c + Vector2(-w * 0.4, h * 0.45), c + Vector2(-w * 0.55, -h * 0.25)])
|
||||
ci.draw_colored_polygon(pts, col)
|
||||
var inner := PackedVector2Array([
|
||||
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
|
||||
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75))
|
||||
|
||||
|
||||
## Dezenter Hintergrund: Farbverlauf und schräge Linien.
|
||||
class Backdrop extends Control:
|
||||
func _draw() -> void:
|
||||
var sz := size
|
||||
draw_rect(Rect2(Vector2.ZERO, sz), Style.BG)
|
||||
var c := Vector2(sz.x * 0.5, sz.y * 0.35)
|
||||
for i in range(8, 0, -1):
|
||||
var r := sz.x * 0.09 * i
|
||||
draw_circle(c, r, Color(0.16, 0.18, 0.26, 0.05))
|
||||
for k in range(-20, 40):
|
||||
var x := k * 80.0
|
||||
draw_line(Vector2(x, 0), Vector2(x + sz.y * 0.6, sz.y), Color(1, 1, 1, 0.012), 1.0)
|
||||
1
scripts/ui/style.gd.uid
Normal file
1
scripts/ui/style.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://diaenhpjc2b3i
|
||||
151
scripts/ui/workshop_screen.gd
Normal file
151
scripts/ui/workshop_screen.gd
Normal file
@@ -0,0 +1,151 @@
|
||||
class_name WorkshopScreen
|
||||
extends Control
|
||||
## Werkstatt: Karten zerlegen (Staub) und herstellen.
|
||||
|
||||
var app
|
||||
var filter := ""
|
||||
var search := ""
|
||||
var selected: Cards.Card = null
|
||||
var grid: CardGrid
|
||||
var search_box: LineEdit
|
||||
var dust_label: Label
|
||||
var msg_label: Label
|
||||
var preview_holder: Control
|
||||
var info_label: Label
|
||||
var b_dismantle: Button
|
||||
var b_craft: Button
|
||||
var b_extras: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Style.label(self, "Werkstatt", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
||||
Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES,
|
||||
14, Style.MUTED, Rect2(240, 18, 600, 24))
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||
search_box = LineEdit.new()
|
||||
search_box.position = Vector2(400, 55)
|
||||
search_box.size = Vector2(200, 34)
|
||||
search_box.placeholder_text = "Suche"
|
||||
search_box.text_changed.connect(func(t: String):
|
||||
search = t
|
||||
refresh())
|
||||
add_child(search_box)
|
||||
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
|
||||
grid.card_clicked.connect(select)
|
||||
add_child(grid)
|
||||
Style.panel(self, Rect2(845, 50, 420, 680))
|
||||
dust_label = Style.label(self, "", 22, Style.BLUE, Rect2(860, 58, 390, 30), "left", true)
|
||||
preview_holder = Control.new()
|
||||
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(preview_holder)
|
||||
info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center")
|
||||
b_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28"))
|
||||
b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c"))
|
||||
b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras)
|
||||
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "center")
|
||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus():
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func set_filter(e: String) -> void:
|
||||
filter = e
|
||||
refresh()
|
||||
|
||||
|
||||
func cards() -> Array:
|
||||
var out: Array = Cards.all().duplicate()
|
||||
if filter != "":
|
||||
out = out.filter(func(c): return filter in c.elements)
|
||||
if search != "":
|
||||
var q := search.to_lower()
|
||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
||||
return out
|
||||
|
||||
|
||||
func select(card: Cards.Card) -> void:
|
||||
selected = card
|
||||
msg_label.text = ""
|
||||
refresh()
|
||||
|
||||
|
||||
func say(m: String, ok: bool) -> void:
|
||||
msg_label.text = m
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
||||
|
||||
|
||||
func dismantle() -> void:
|
||||
if selected == null:
|
||||
return
|
||||
var err: String = app.profile.dismantle(selected.id)
|
||||
if err == "":
|
||||
app.sfx.play("click")
|
||||
app.save()
|
||||
say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func craft() -> void:
|
||||
if selected == null:
|
||||
return
|
||||
var err: String = app.profile.craft(selected.id)
|
||||
if err == "":
|
||||
app.sfx.play("play")
|
||||
app.save()
|
||||
var notes: Array[String] = app.profile.take_notices()
|
||||
say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func dismantle_extras() -> void:
|
||||
var got: int = app.profile.dismantle_extras()
|
||||
if got > 0:
|
||||
app.save()
|
||||
say("Überschuss zerlegt: +%d Staub" % got, true)
|
||||
else:
|
||||
say("Kein Überschuss vorhanden", false)
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
dust_label.text = "Staub: %d" % p.dust
|
||||
grid.fill(cards(),
|
||||
func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt",
|
||||
func(c): return p.collection.get(c.id, 0) == 0)
|
||||
Style.clear(preview_holder)
|
||||
if selected == null:
|
||||
info_label.text = "Keine Karte gewählt"
|
||||
b_dismantle.text = "Zerlegen"
|
||||
b_craft.text = "Herstellen"
|
||||
b_dismantle.disabled = true
|
||||
b_craft.disabled = true
|
||||
else:
|
||||
var v := CardView.new(selected, Vector2(200, 280))
|
||||
v.position = Vector2(955, 100)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
var owned: int = p.collection.get(selected.id, 0)
|
||||
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
|
||||
info_label.text = "%s · %s\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
|
||||
selected.combo(), selected.rarity, owned, st.played, st.games, st.wins]
|
||||
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
|
||||
b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity]
|
||||
b_dismantle.disabled = owned == 0
|
||||
b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity]
|
||||
var extras := 0
|
||||
for cid in p.collection:
|
||||
extras += maxi(0, p.collection[cid] - Game.MAX_COPIES)
|
||||
b_extras.text = "Überschuss zerlegen (%d Karten)" % extras
|
||||
b_extras.disabled = extras == 0
|
||||
1
scripts/ui/workshop_screen.gd.uid
Normal file
1
scripts/ui/workshop_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cf42h6bsh8lvg
|
||||
11
tests/dump_cards.gd
Normal file
11
tests/dump_cards.gd
Normal file
@@ -0,0 +1,11 @@
|
||||
extends SceneTree
|
||||
## Schreibt den Kartenpool als JSON: godot --headless --path . -s tests/dump_cards.gd
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var list := Cards.all().map(func(c): return c.to_dict())
|
||||
var f := FileAccess.open("res://data/karten.json", FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify({"karten": list}, "\t", false))
|
||||
f.close()
|
||||
print("%d Karten nach data/karten.json geschrieben" % list.size())
|
||||
quit()
|
||||
1
tests/dump_cards.gd.uid
Normal file
1
tests/dump_cards.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bdywlyx7noq6
|
||||
35
tests/matchup.gd
Normal file
35
tests/matchup.gd
Normal file
@@ -0,0 +1,35 @@
|
||||
extends SceneTree
|
||||
## Misst Deckarchetypen im Duell (beide gierig): wer beginnt, Mono gegen Paar.
|
||||
|
||||
func deck(r: RandomNumberGenerator, mono: bool) -> Array:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, r)
|
||||
return Game.build_opponent_deck(r, [pool[0]] if mono else [pool[0], pool[1]])
|
||||
|
||||
|
||||
func duel(r: RandomNumberGenerator, first_mono: bool, second_mono: bool) -> bool:
|
||||
var second := Game.Player.new("Gegner 1", deck(r, second_mono), false, Game.START_LIFE, "x")
|
||||
var g := Game.new_battle_with(deck(r, first_mono), [second], r)
|
||||
var guard := 0
|
||||
while g.winner == "" and guard < 300:
|
||||
AI.take_turn(g, g.current(), true)
|
||||
guard += 1
|
||||
return g.winner == "human"
|
||||
|
||||
|
||||
func run(label: String, a_mono: bool, b_mono: bool, games: int, r: RandomNumberGenerator) -> void:
|
||||
var wins := 0
|
||||
for i in games:
|
||||
if duel(r, a_mono, b_mono):
|
||||
wins += 1
|
||||
print("%-28s Startspieler gewinnt %3d%%" % [label, wins * 100 / games])
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 5
|
||||
run("Paar gegen Paar", false, false, 200, r)
|
||||
run("Mono gegen Mono", true, true, 200, r)
|
||||
run("Paar (startet) gegen Mono", false, true, 200, r)
|
||||
run("Mono (startet) gegen Paar", true, false, 200, r)
|
||||
quit()
|
||||
1
tests/matchup.gd.uid
Normal file
1
tests/matchup.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bu5hjcfuinhy8
|
||||
65
tests/net_bot.gd
Normal file
65
tests/net_bot.gd
Normal file
@@ -0,0 +1,65 @@
|
||||
extends Node
|
||||
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge.
|
||||
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
|
||||
|
||||
var net: Net
|
||||
var seat := -1
|
||||
var game: Game
|
||||
var t := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var ip := "127.0.0.1"
|
||||
var bot_name := "Bot"
|
||||
for a in OS.get_cmdline_user_args():
|
||||
if a.begins_with("--ip="):
|
||||
ip = a.substr(5)
|
||||
if a.begins_with("--name="):
|
||||
bot_name = a.substr(7)
|
||||
net = Net.new()
|
||||
net.name = "Net"
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
net.my_name = bot_name
|
||||
net.my_deck = Profile.starter().deck.duplicate()
|
||||
net.started.connect(func(s): seat = s)
|
||||
net.snapshot_received.connect(_on_snapshot)
|
||||
net.ended.connect(func(_r): get_tree().quit())
|
||||
await get_tree().process_frame
|
||||
net.join(ip)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
t += delta
|
||||
if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
|
||||
net.leave()
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _on_snapshot(snap: Dictionary) -> void:
|
||||
game = Game.from_snapshot(snap)
|
||||
if game.winner != "":
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
get_tree().quit()
|
||||
return
|
||||
if game.current_idx != seat:
|
||||
return
|
||||
var me: Game.Player = game.players[seat]
|
||||
if not me.mulligan_done and me.turns == 1:
|
||||
net.send_action({"type": "mulligan", "hand": []})
|
||||
return
|
||||
for i in me.hand.size():
|
||||
var card: Cards.Card = me.hand[i]
|
||||
if game.can_play(me, card)[0] and card.target_type() == "":
|
||||
net.send_action({"type": "play", "hand": i, "target": null})
|
||||
return
|
||||
for cr in me.board:
|
||||
if cr.can_attack():
|
||||
for e in game.enemies_of(me):
|
||||
if game.can_attack(cr, e)[0]:
|
||||
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}})
|
||||
return
|
||||
for c in e.board:
|
||||
if game.can_attack(cr, c)[0]:
|
||||
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
|
||||
return
|
||||
net.send_action({"type": "end"})
|
||||
1
tests/net_bot.gd.uid
Normal file
1
tests/net_bot.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dcbqt8ybdkhrb
|
||||
6
tests/net_bot.tscn
Normal file
6
tests/net_bot.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/net_bot.gd" id="1"]
|
||||
|
||||
[node name="NetBot" type="Node"]
|
||||
script = ExtResource("1")
|
||||
8
tests/run_headless.sh
Executable file
8
tests/run_headless.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
|
||||
set -euo pipefail
|
||||
GODOT="${1:-godot}"
|
||||
cd "$(dirname "$0")/.."
|
||||
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true
|
||||
"$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true
|
||||
grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log
|
||||
43
tests/simulate.gd
Normal file
43
tests/simulate.gd
Normal file
@@ -0,0 +1,43 @@
|
||||
extends SceneTree
|
||||
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
|
||||
## ist etwa 1/(Gegner+1) Siege zu erwarten:
|
||||
## godot --headless --path . -s tests/simulate.gd
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var games := 100
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 0
|
||||
var starter := Profile.starter().deck_cards()
|
||||
var random_deck := func(rr: RandomNumberGenerator) -> Array:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, rr)
|
||||
return Game.build_opponent_deck(rr, [pool[0], pool[1]])
|
||||
var t0 := Time.get_ticks_msec()
|
||||
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
|
||||
for n in range(1, Game.MAX_OPPONENTS + 1):
|
||||
_run("Zufallsdeck", random_deck, n, games, r, true, true)
|
||||
print("Du mit Vorausschau, Gegner gierig:")
|
||||
for n in [1, 2, 3]:
|
||||
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true)
|
||||
print("Alle mit Vorausschau:")
|
||||
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false)
|
||||
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false)
|
||||
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
|
||||
quit()
|
||||
|
||||
|
||||
func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator,
|
||||
human_greedy: bool, opp_greedy: bool) -> void:
|
||||
var wins := 0
|
||||
var turns := 0
|
||||
for i in games:
|
||||
var g := Game.new_battle(deck_fn.call(r), n_opp, r)
|
||||
while g.winner == "":
|
||||
var p := g.current()
|
||||
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
|
||||
assert(g.turn < 300, "Spiel endet nicht")
|
||||
if g.winner == "human":
|
||||
wins += 1
|
||||
turns += g.turn
|
||||
print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games])
|
||||
1
tests/simulate.gd.uid
Normal file
1
tests/simulate.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bnbiq4krauuyq
|
||||
1198
tests/test_runner.gd
Normal file
1198
tests/test_runner.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
tests/test_runner.gd.uid
Normal file
1
tests/test_runner.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cgjm40u037dnl
|
||||
674
tests/ui_test.gd
Normal file
674
tests/ui_test.gd
Normal file
@@ -0,0 +1,674 @@
|
||||
extends Node
|
||||
## Oberflächentest mit echtem Fenster und Screenshots:
|
||||
## DINGER_SHOTS=/pfad/zum/ordner godot --path . tests/ui_test.tscn
|
||||
|
||||
const SAVE := "user://test_ui_spielstand.json"
|
||||
|
||||
var main: Control
|
||||
var shots := ""
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func check(cond: bool, msg: String) -> void:
|
||||
if cond:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
printerr("FEHLER: " + msg)
|
||||
|
||||
|
||||
func frames(n: int = 2) -> void:
|
||||
for i in n:
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func shot(name: String) -> void:
|
||||
if shots == "":
|
||||
return
|
||||
await RenderingServer.frame_post_draw
|
||||
get_viewport().get_texture().get_image().save_png(shots.path_join(name + ".png"))
|
||||
|
||||
|
||||
func click_at(pos: Vector2) -> void:
|
||||
var down := InputEventMouseButton.new()
|
||||
down.position = pos
|
||||
down.global_position = pos
|
||||
down.button_index = MOUSE_BUTTON_LEFT
|
||||
down.pressed = true
|
||||
get_viewport().push_input(down)
|
||||
var up: InputEventMouseButton = down.duplicate()
|
||||
up.pressed = false
|
||||
get_viewport().push_input(up)
|
||||
await frames(2)
|
||||
|
||||
|
||||
func click(node: Control) -> void:
|
||||
check(node != null, "Klickziel vorhanden")
|
||||
if node != null:
|
||||
await click_at(node.get_global_rect().get_center())
|
||||
|
||||
|
||||
func right_click() -> void:
|
||||
var down := InputEventMouseButton.new()
|
||||
down.position = Vector2(5, 5)
|
||||
down.global_position = down.position
|
||||
down.button_index = MOUSE_BUTTON_RIGHT
|
||||
down.pressed = true
|
||||
get_viewport().push_input(down)
|
||||
var up: InputEventMouseButton = down.duplicate()
|
||||
up.pressed = false
|
||||
get_viewport().push_input(up)
|
||||
await frames(2)
|
||||
|
||||
|
||||
func key(code: int) -> void:
|
||||
var down := InputEventKey.new()
|
||||
down.keycode = code
|
||||
down.pressed = true
|
||||
get_viewport().push_input(down)
|
||||
var up := InputEventKey.new()
|
||||
up.keycode = code
|
||||
up.pressed = false
|
||||
get_viewport().push_input(up)
|
||||
await frames(2)
|
||||
|
||||
|
||||
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
|
||||
func clear_mulligan(scr) -> void:
|
||||
if scr.mulligan_box.visible:
|
||||
await click(scr.b_keep)
|
||||
|
||||
|
||||
func hover(node: Control) -> void:
|
||||
var ev := InputEventMouseMotion.new()
|
||||
ev.position = node.get_global_rect().get_center()
|
||||
ev.global_position = ev.position
|
||||
get_viewport().push_input(ev)
|
||||
await frames(2)
|
||||
|
||||
|
||||
func screen():
|
||||
return main.screen
|
||||
|
||||
|
||||
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist.
|
||||
func ensure_menu() -> void:
|
||||
if main.net.connected:
|
||||
main.net.leave()
|
||||
if TranslationServer.get_locale().begins_with("en"):
|
||||
main.set_language("de")
|
||||
if not (screen() is MenuScreen):
|
||||
main.show_screen(MenuScreen.new())
|
||||
await frames(2)
|
||||
|
||||
|
||||
func find_button(text: String, root: Node = null) -> Button:
|
||||
if root == null:
|
||||
root = screen()
|
||||
for c in root.get_children():
|
||||
if c is Button and (c.text == text or c.tr(c.text) == text) and c.is_visible_in_tree():
|
||||
return c
|
||||
var found := find_button(text, c)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func card_views(root: Node) -> Array:
|
||||
var out := []
|
||||
for c in root.get_children():
|
||||
if c is CardView:
|
||||
out.append(c)
|
||||
out.append_array(card_views(c))
|
||||
return out
|
||||
|
||||
|
||||
## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz).
|
||||
func settle(scr) -> void:
|
||||
for i in 40:
|
||||
if not scr.tiles.values().any(func(t): return t.busy):
|
||||
return
|
||||
await frames(1)
|
||||
|
||||
|
||||
func wait_for_human(scr) -> void:
|
||||
var g: Game = scr.game
|
||||
var wait := 0
|
||||
while g.current() != g.human and g.winner == "" and wait < 600:
|
||||
await frames(1)
|
||||
wait += 1
|
||||
await frames(1)
|
||||
await clear_mulligan(scr)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Game.test_seed = 4242 # wiederholbare Partien und Booster
|
||||
shots = OS.get_environment("DINGER_SHOTS")
|
||||
var abs := ProjectSettings.globalize_path(SAVE)
|
||||
for suffix in ["", ".bak", ".corrupt"]:
|
||||
if FileAccess.file_exists(SAVE + suffix):
|
||||
DirAccess.remove_absolute(abs + suffix)
|
||||
main = load("res://scenes/main.tscn").instantiate()
|
||||
main.save_path = SAVE
|
||||
add_child(main)
|
||||
await frames(3)
|
||||
for t in [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
|
||||
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_hotseat,
|
||||
test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]:
|
||||
await ensure_menu()
|
||||
await t.call()
|
||||
for suffix in ["", ".bak", ".corrupt"]:
|
||||
if FileAccess.file_exists(SAVE + suffix):
|
||||
DirAccess.remove_absolute(abs + suffix)
|
||||
print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed])
|
||||
get_tree().quit(1 if failed > 0 else 0)
|
||||
|
||||
|
||||
func test_menu_und_regeln() -> void:
|
||||
check(screen() is MenuScreen, "Menü sichtbar")
|
||||
await shot("01_menu")
|
||||
await click(find_button("Regeln"))
|
||||
check(screen() is RulesScreen, "Regeln geöffnet")
|
||||
await shot("02_regeln")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_booster() -> void:
|
||||
await click(find_button("Booster öffnen"))
|
||||
var scr = screen()
|
||||
check(scr is BoosterScreen, "Boosterbildschirm")
|
||||
var coins: int = main.profile.coins
|
||||
var ach_before: int = main.profile.achievements.size()
|
||||
await click(scr.b_buy)
|
||||
var bonus := 0
|
||||
for a in Profile.ACHIEVEMENTS:
|
||||
if a.id in main.profile.achievements.slice(ach_before):
|
||||
bonus += a.reward
|
||||
check(main.profile.coins == coins - Profile.BOOSTER_COST + bonus, "Booster bezahlt (plus Erfolgsmünzen %d)" % bonus)
|
||||
check(scr.cards.size() == 5 and scr.revealed.is_empty(), "5 verdeckte Karten")
|
||||
await shot("03_booster_zu")
|
||||
var backs: Array = scr.slots.get_children().filter(func(c): return c is BoosterScreen.CardBack)
|
||||
await click(backs[0])
|
||||
check(scr.revealed.size() == 1, "eine Karte aufgedeckt")
|
||||
await click(scr.b_all)
|
||||
check(scr.revealed.size() == 5, "alle aufgedeckt")
|
||||
await shot("04_booster_offen")
|
||||
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
|
||||
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_deckbau() -> void:
|
||||
await click(find_button("Deck bauen"))
|
||||
var scr = screen()
|
||||
check(scr is DeckScreen, "Deckbildschirm")
|
||||
check(scr.deck.size() == 20, "Deck geladen")
|
||||
await shot("05_deck")
|
||||
await click(find_button("Leeren"))
|
||||
check(scr.deck.is_empty(), "geleert")
|
||||
var first_id: String = card_views(scr.grid)[0].card.id
|
||||
var allowed: int = mini(main.profile.collection[first_id], Game.MAX_COPIES)
|
||||
await click(card_views(scr.grid)[0])
|
||||
check(scr.deck == [first_id], "Karte hinzugefügt")
|
||||
for i in allowed:
|
||||
await click(card_views(scr.grid)[0])
|
||||
check(scr.deck.count(first_id) == allowed and "Kopien" in scr.msg_label.text, "nur besessene Kopien (%d)" % allowed)
|
||||
await click(find_button("Speichern"))
|
||||
check(main.profile.deck.size() == 20 and scr.msg_label.text != "Deck gespeichert", "zu kleines Deck nicht gespeichert")
|
||||
await click(find_button("Wasser"))
|
||||
await click(card_views(scr.grid)[0])
|
||||
await click(find_button("Erde"))
|
||||
await click(card_views(scr.grid)[0])
|
||||
check("Elemente" in scr.msg_label.text, "drittes Element abgelehnt")
|
||||
await click(find_button("Alle"))
|
||||
# Suche, Sortierung, fehlende Karten
|
||||
var all_count := card_views(scr.grid).size()
|
||||
scr.search_box.text = "Wächter"
|
||||
scr.search_box.text_changed.emit("Wächter")
|
||||
await frames(1)
|
||||
var found := card_views(scr.grid)
|
||||
check(found.size() > 0 and found.size() < all_count, "Suche filtert")
|
||||
check(found.all(func(v): return "Wächter" in v.card.text() or "Wächter" in v.card.name), "Suche trifft Kartentext")
|
||||
scr.search_box.text = ""
|
||||
scr.search_box.text_changed.emit("")
|
||||
scr.sort_box.select(1)
|
||||
scr.sort_box.item_selected.emit(1)
|
||||
await frames(1)
|
||||
var names: Array = card_views(scr.grid).map(func(v): return v.card.name)
|
||||
var sorted_names := names.duplicate()
|
||||
sorted_names.sort()
|
||||
check(names == sorted_names, "Sortierung nach Name")
|
||||
scr.missing_box.button_pressed = true
|
||||
await frames(1)
|
||||
check(card_views(scr.grid).size() == Cards.all().size(), "Fehlende Karten eingeblendet")
|
||||
var missing = card_views(scr.grid).filter(func(v): return main.profile.collection.get(v.card.id, 0) == 0)
|
||||
check(not missing.is_empty(), "es gibt fehlende Karten")
|
||||
await click(missing[0])
|
||||
check("fehlt" in scr.msg_label.text, "fehlende Karte nicht ins Deck")
|
||||
scr.missing_box.button_pressed = false
|
||||
scr.sort_box.select(0)
|
||||
scr.sort_box.item_selected.emit(0)
|
||||
await frames(1)
|
||||
var n: int = scr.deck.size()
|
||||
await click(scr.deck_list.get_children()[0])
|
||||
check(scr.deck.size() == n - 1, "Zeile entfernt Karte")
|
||||
await hover(card_views(scr.grid)[0])
|
||||
check(scr.preview.visible, "Vorschau bei Hover")
|
||||
await shot("06_deck_bearbeitet")
|
||||
# Slots: Deck 2 aus Deck 1 kopieren, speichern, aktiv setzen
|
||||
await click(scr.slot_buttons[1])
|
||||
if scr.slot != 1 or not scr.deck.is_empty():
|
||||
print("DEBUG Slot: slot=", scr.slot, " deck=", scr.deck.size(), " rect=", scr.slot_buttons[1].get_global_rect(),
|
||||
" hovered=", get_viewport().gui_get_hovered_control(), " decks1=", main.profile.decks[1].cards.size())
|
||||
check(scr.slot == 1 and scr.deck.is_empty(), "Slot 2 leer")
|
||||
scr.deck = main.profile.decks[0].cards.duplicate()
|
||||
scr.refresh()
|
||||
await click(find_button("Speichern"))
|
||||
check(main.profile.decks[1].cards.size() == 20 and "gespeichert" in scr.msg_label.text, "Slot 2 gespeichert")
|
||||
await click(find_button("Aktiv setzen"))
|
||||
check(main.profile.active_deck == 1 and main.profile.deck_name() == "Deck 2", "Slot 2 aktiv")
|
||||
check(Profile.load_or_new(SAVE).active_deck == 1, "aktiver Slot gespeichert")
|
||||
scr.name_box.text = "Testdeck"
|
||||
scr.name_box.text_submitted.emit("Testdeck")
|
||||
check(main.profile.deck_name(1) == "Testdeck" and Profile.load_or_new(SAVE).deck_name(1) == "Testdeck", "Deck umbenannt")
|
||||
await click(scr.slot_buttons[2])
|
||||
check(scr.slot == 2 and scr.deck.is_empty(), "Slot 3 leer")
|
||||
await click(find_button("Auto-Ausfüllen"))
|
||||
check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar")
|
||||
await shot("25_deck_slots")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen")
|
||||
|
||||
|
||||
func target_view(scr, valid: Array) -> Control:
|
||||
for v in scr.enemy_views:
|
||||
if v.creature in valid:
|
||||
return v
|
||||
for v in scr.own_views:
|
||||
if v.creature in valid:
|
||||
return v
|
||||
for p in scr.hero_panels:
|
||||
if p in valid:
|
||||
return scr.hero_panels[p]
|
||||
return null
|
||||
|
||||
|
||||
func human_turn(scr) -> void:
|
||||
var g: Game = scr.game
|
||||
await clear_mulligan(scr)
|
||||
for i in 12:
|
||||
var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0])
|
||||
if playable.is_empty():
|
||||
break
|
||||
var card: Cards.Card = playable[0].card
|
||||
var energy: int = g.human.energy
|
||||
await click(playable[0])
|
||||
if scr.pending != null:
|
||||
await click(target_view(scr, g.valid_targets(g.human, card.target_type())))
|
||||
check(g.human.energy == energy - card.cost, "Karte gespielt: " + card.name)
|
||||
for i in 12:
|
||||
var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack())
|
||||
if ready.is_empty() or g.winner != "":
|
||||
break
|
||||
var cr: Game.Creature = ready[0].creature
|
||||
await settle(scr)
|
||||
await hover(ready[0])
|
||||
var under = get_viewport().gui_get_hovered_control()
|
||||
await click(ready[0])
|
||||
if scr.selected != cr:
|
||||
print("DEBUG Auswahl fehlgeschlagen: rect=", ready[0].get_global_rect(), " scale=", ready[0].scale,
|
||||
" busy=", ready[0].busy, " unter Maus=", under, " Meldung='", scr.msg_label.text,
|
||||
"' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected)
|
||||
check(scr.selected == cr, "Wesen gewählt")
|
||||
var targets := []
|
||||
for o in g.opponents:
|
||||
if o.alive() and g.can_attack(cr, o)[0]:
|
||||
targets.append(o)
|
||||
for c in o.board:
|
||||
if g.can_attack(cr, c)[0]:
|
||||
targets.append(c)
|
||||
await settle(scr)
|
||||
await click(target_view(scr, targets))
|
||||
check(scr.selected == null and cr.attacks_left < cr.attacks_per_turn(), "Angriff ausgeführt")
|
||||
if g.winner == "":
|
||||
await click(scr.b_end)
|
||||
|
||||
|
||||
func start_battle(n_opp: int) -> void:
|
||||
await click(find_button("Einzelspieler"))
|
||||
check(screen() is OpponentSelectScreen, "Gegnerauswahl")
|
||||
await click(find_button("%d Gegner" % n_opp))
|
||||
|
||||
|
||||
func test_gegnerauswahl() -> void:
|
||||
await click(find_button("Einzelspieler"))
|
||||
check(screen() is OpponentSelectScreen, "Gegnerauswahl geöffnet")
|
||||
await shot("07_gegnerauswahl")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
for n in [1, 5]:
|
||||
await start_battle(n)
|
||||
var scr = screen()
|
||||
check(scr is BattleScreen and scr.game.opponents.size() == n, "Kampf gegen %d gestartet" % n)
|
||||
check(scr.game.players[0].max_energy == 1 and scr.game.opponents[0].life == Game.START_LIFE, "gleiche Regeln gegen %d" % n)
|
||||
await shot("11_kampf_%d" % n)
|
||||
await give_up()
|
||||
|
||||
|
||||
func give_up() -> void:
|
||||
var scr = screen()
|
||||
await clear_mulligan(scr)
|
||||
await click(find_button("Aufgeben"))
|
||||
check(scr.confirm.visible, "Aufgeben fragt nach")
|
||||
await click(find_button("Weiterspielen"))
|
||||
check(not scr.confirm.visible and screen() == scr, "Weiterspielen bleibt im Kampf")
|
||||
await click(find_button("Aufgeben"))
|
||||
await click(find_button("Ja, aufgeben"))
|
||||
check(screen() is MenuScreen, "Aufgeben führt ins Menü")
|
||||
|
||||
|
||||
func test_einstellungen() -> void:
|
||||
await click(find_button("Einstellungen"))
|
||||
var scr = screen()
|
||||
check(scr is SettingsScreen, "Einstellungen geöffnet")
|
||||
scr.volume.value = 0.3
|
||||
scr.speed.value = 2.0
|
||||
scr.anim.button_pressed = false
|
||||
await frames(1)
|
||||
check(is_equal_approx(main.profile.settings.volume, 0.3) and is_equal_approx(main.profile.settings.ai_speed, 2.0), "Regler gespeichert")
|
||||
check(main.profile.settings.animations == false, "Animationen abschaltbar")
|
||||
check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte")
|
||||
scr.anim.button_pressed = true
|
||||
await shot("13_einstellungen")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_erfolge() -> void:
|
||||
await click(find_button("Erfolge"))
|
||||
check(screen() is AchievementsScreen, "Erfolge geöffnet")
|
||||
await shot("14_erfolge")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_werkstatt() -> void:
|
||||
await click(find_button("Werkstatt"))
|
||||
var scr = screen()
|
||||
check(scr is WorkshopScreen, "Werkstatt geöffnet")
|
||||
var p: Profile = main.profile
|
||||
p.dust = 0
|
||||
var in_decks := {}
|
||||
for d in p.decks:
|
||||
for cid in d.cards:
|
||||
in_decks[cid] = true
|
||||
var free = scr.grid.views().filter(func(v): return p.collection.get(v.card.id, 0) > 0 and not in_decks.has(v.card.id))
|
||||
check(not free.is_empty(), "eine freie Karte zum Zerlegen")
|
||||
var card: Cards.Card = free[0].card
|
||||
await click(free[0])
|
||||
check(scr.selected == card, "Karte gewählt")
|
||||
var before: int = p.collection[card.id]
|
||||
await click(scr.b_dismantle)
|
||||
check(p.collection[card.id] == before - 1 and p.dust == Profile.DUST_DISMANTLE[card.rarity], "zerlegt, Staub gutgeschrieben")
|
||||
p.dust = 5000
|
||||
scr.search_box.text = "Phönix"
|
||||
scr.search_box.text_changed.emit("Phönix")
|
||||
await frames(1)
|
||||
var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0]
|
||||
await click(phoenix)
|
||||
await click(scr.b_craft)
|
||||
check(p.collection.get("ff_phoenix", 0) == 1 and p.dust == 5000 - Profile.DUST_CRAFT["legendär"], "Phönix hergestellt")
|
||||
check(Profile.load_or_new(SAVE).collection.get("ff_phoenix", 0) == 1, "Werkstatt gespeichert")
|
||||
await shot("20_werkstatt")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_hotseat() -> void:
|
||||
await click(find_button("Lokal zu mehreren"))
|
||||
var scr = screen()
|
||||
check(scr is HotseatScreen, "Hotseat-Auswahl")
|
||||
await click(find_button("3 Spieler"))
|
||||
await click(find_button("1 KI"))
|
||||
check(scr.n_humans == 3 and scr.n_ai == 1, "3 Menschen, 1 KI")
|
||||
await shot("17_hotseat")
|
||||
await click(find_button("Start"))
|
||||
var b = screen()
|
||||
check(b is BattleScreen and b.is_hotseat() and b.game.players.size() == 4, "Hotseat-Kampf gestartet")
|
||||
b.ai_delay = 0.0
|
||||
var guard := 0
|
||||
while not b.handover.visible and b.game.winner == "" and guard < 400:
|
||||
if b.game.current() == b.perspective:
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective:
|
||||
await click(b.b_end)
|
||||
await frames(1)
|
||||
guard += 1
|
||||
check(b.handover.visible, "Übergabe erscheint")
|
||||
var cur: Game.Player = b.game.current()
|
||||
check(cur.is_human and cur != b.perspective, "anderer Mensch ist dran")
|
||||
await shot("18_hotseat_uebergabe")
|
||||
await click(find_button("Weiter"))
|
||||
check(b.perspective == cur and not b.handover.visible, "Perspektive gewechselt")
|
||||
check(b.hand_views.size() == cur.hand.size() and b.name_label.text == cur.name, "Hand und Name des neuen Spielers")
|
||||
check(b.mulligan_box.visible, "neuer Spieler bekommt seine Mulligan-Maske")
|
||||
await clear_mulligan(b)
|
||||
check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch")
|
||||
await shot("19_hotseat_kampf")
|
||||
await give_up()
|
||||
|
||||
|
||||
func test_sprache() -> void:
|
||||
await click(find_button("Einstellungen"))
|
||||
var scr = screen()
|
||||
scr.language.select(1)
|
||||
scr.language.item_selected.emit(1)
|
||||
await frames(2)
|
||||
check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch")
|
||||
check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen")
|
||||
check(find_button("Back") != null, "Knöpfe übersetzt")
|
||||
await shot("26_englisch")
|
||||
await click(find_button("Back"))
|
||||
check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch")
|
||||
await click(find_button("Settings"))
|
||||
var scr2 = screen()
|
||||
scr2.language.select(0)
|
||||
scr2.language.item_selected.emit(0)
|
||||
await frames(2)
|
||||
check(find_button("Zurück") != null, "zurück auf Deutsch")
|
||||
await click(find_button("Zurück"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_anleitung() -> void:
|
||||
check(not main.profile.tutorial_done, "Anleitung noch offen")
|
||||
await click(find_button("Anleitung"))
|
||||
var scr = screen()
|
||||
check(scr is BattleScreen and scr.tutorial, "Anleitung gestartet")
|
||||
scr.ai_delay = 0.0
|
||||
var g: Game = scr.game
|
||||
check(g.players[0] == g.human and g.opponents[0].name == "Fenn" and g.opponents[0].perks.is_empty(), "Fenn ohne Merkmal, Mensch beginnt")
|
||||
await frames(2)
|
||||
check(scr.mulligan_box.visible, "Mulligan-Maske im ersten Zug")
|
||||
var views = scr.mulligan_layer.get_children()
|
||||
await click(views[0])
|
||||
check(scr.mulligan_selected == [0], "Karte zum Tausch gewählt")
|
||||
await shot("22_anleitung_mulligan")
|
||||
await click(scr.b_swap)
|
||||
check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht")
|
||||
check(g.log.any(func(l): return "tauscht 1 Karten" in l), "Tausch im Verlauf")
|
||||
check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar")
|
||||
var idx := -1
|
||||
for i in g.human.hand.size():
|
||||
if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1:
|
||||
idx = i
|
||||
break
|
||||
if idx >= 0:
|
||||
await key(KEY_1 + idx)
|
||||
check(g.human.board.size() == 1, "Wesen per Taste gespielt")
|
||||
check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen")
|
||||
await shot("22_anleitung")
|
||||
await key(KEY_SPACE)
|
||||
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
|
||||
await wait_for_human(scr)
|
||||
check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug")
|
||||
var step_before: int = scr.tutorial_step
|
||||
await click(scr.b_tut_next)
|
||||
check(scr.tutorial_step == step_before + 1, "Weiter-Knopf")
|
||||
await key(KEY_ESCAPE)
|
||||
check(scr.confirm.visible, "Esc öffnet Aufgeben")
|
||||
await key(KEY_ESCAPE)
|
||||
check(not scr.confirm.visible, "Esc schließt den Dialog")
|
||||
await give_up()
|
||||
check(main.profile.tutorial_done, "Anleitung als erledigt gespeichert")
|
||||
|
||||
|
||||
func test_netzwerk() -> void:
|
||||
await click(find_button("Netzwerkspiel"))
|
||||
var scr = screen()
|
||||
check(scr is LobbyScreen, "Lobby geöffnet")
|
||||
await click(find_button("Spiel eröffnen"))
|
||||
check(main.net.is_host and main.net.connected, "Spiel eröffnet")
|
||||
var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"]
|
||||
var pid := OS.create_process(OS.get_executable_path(), args)
|
||||
check(pid > 0, "Bot-Prozess gestartet")
|
||||
var wait := 0
|
||||
while main.net.names.size() < 2 and wait < 900:
|
||||
await frames(1)
|
||||
wait += 1
|
||||
check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path())
|
||||
await shot("23_lobby")
|
||||
await click(find_button("Start"))
|
||||
var b = screen()
|
||||
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
|
||||
b.ai_delay = 0.0
|
||||
var rounds := 0
|
||||
while b.game != null and b.game.winner == "" and rounds < 4:
|
||||
if b.game.current() == b.perspective:
|
||||
await clear_mulligan(b)
|
||||
if b.game.current() == b.perspective:
|
||||
await click(b.b_end)
|
||||
var w2 := 0
|
||||
while b.game.current() != b.perspective and b.game.winner == "" and w2 < 900:
|
||||
await frames(1)
|
||||
w2 += 1
|
||||
rounds += 1
|
||||
check(b.game.turn >= 3 and b.game.winner == "", "mehrere Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
|
||||
check(b.game.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt")
|
||||
await shot("24_netzwerk_kampf")
|
||||
OS.kill(pid)
|
||||
var w3 := 0
|
||||
while b.game.winner == "" and w3 < 1500:
|
||||
await frames(1)
|
||||
w3 += 1
|
||||
if b.game.winner != "":
|
||||
check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe")
|
||||
await frames(2)
|
||||
check(b.overlay.visible, "Spielende angezeigt")
|
||||
await click(find_button("Zurück zum Menü"))
|
||||
else:
|
||||
print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen")
|
||||
await give_up()
|
||||
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
|
||||
|
||||
|
||||
func test_kampagne() -> void:
|
||||
await click(find_button("Kampagne"))
|
||||
var scr = screen()
|
||||
check(scr is CampaignScreen, "Kampagne geöffnet")
|
||||
await shot("15_kampagne")
|
||||
check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen")
|
||||
await click(find_button("Kämpfen"))
|
||||
var battle = screen()
|
||||
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
|
||||
check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner")
|
||||
await shot("16_kampagne_kampf")
|
||||
await give_up()
|
||||
|
||||
|
||||
func test_kampf(n_opp: int, shot_turn: int) -> void:
|
||||
await start_battle(n_opp)
|
||||
var scr = screen()
|
||||
check(scr is BattleScreen, "Kampfbildschirm")
|
||||
scr.ai_delay = 0.0
|
||||
var g: Game = scr.game
|
||||
check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost")
|
||||
await wait_for_human(scr)
|
||||
check(g.current() == g.human and g.human.mulligan_done, "Spieler ist dran, Mulligan erledigt")
|
||||
check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar")
|
||||
await shot("07_kampf_start")
|
||||
var coins: int = main.profile.coins
|
||||
var guard := 0
|
||||
while g.winner == "" and guard < 60:
|
||||
guard += 1
|
||||
var turn_before := g.turn
|
||||
await human_turn(scr)
|
||||
if g.winner != "":
|
||||
break
|
||||
check(g.current() != g.human or g.turn > turn_before, "Zug beendet")
|
||||
var wait := 0
|
||||
while g.current() != g.human and g.winner == "" and wait < 600:
|
||||
await frames(1)
|
||||
wait += 1
|
||||
if guard == shot_turn:
|
||||
await shot("08_kampf_zug%d" % (shot_turn + 1))
|
||||
check(g.winner != "", "Spiel beendet")
|
||||
await frames(3)
|
||||
await shot("09_kampf_ende")
|
||||
check(scr.reward > 0 and main.profile.coins >= coins + scr.reward, "Belohnung gutgeschrieben")
|
||||
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Belohnung gespeichert")
|
||||
await click(find_button("Zurück zum Menü"))
|
||||
check(screen() is MenuScreen, "zurück im Menü")
|
||||
|
||||
|
||||
func test_kampf_abbrechen() -> void:
|
||||
await start_battle(5)
|
||||
var scr = screen()
|
||||
scr.ai_delay = 0.0
|
||||
var g: Game = scr.game
|
||||
await wait_for_human(scr)
|
||||
check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner")
|
||||
# Falle legen und Kombo-Hinweis prüfen
|
||||
g.human.hand.append(Cards.by_id("f_falle"))
|
||||
g.human.energy = 10
|
||||
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
|
||||
g.human.board.append(wolf)
|
||||
g.human.hand.append(Cards.by_id("w_reiter"))
|
||||
scr.refresh()
|
||||
var trap_view = scr.hand_views.filter(func(v): return v.card.id == "f_falle")[0]
|
||||
await click(trap_view)
|
||||
check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar")
|
||||
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
|
||||
await hover(reiter_view)
|
||||
var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel)
|
||||
check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau")
|
||||
await shot("21_kampf_falle_hinweis")
|
||||
var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
|
||||
if not spells.is_empty():
|
||||
var card: Cards.Card = spells[0].card
|
||||
await click(spells[0])
|
||||
check(scr.pending == card, "Ziel wird erwartet")
|
||||
await right_click()
|
||||
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab")
|
||||
await hover(scr.hand_views[0])
|
||||
check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
|
||||
await click(find_button("Lupe"))
|
||||
check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte")
|
||||
await shot("12_lupe")
|
||||
await click(find_button("Schließen"))
|
||||
check(scr.zoom_player == null, "Lupe geschlossen")
|
||||
# Runden laufen lassen, damit die Gegner ihre Züge machen
|
||||
for round in 4:
|
||||
await click(scr.b_end)
|
||||
var wait := 0
|
||||
while g.current() != g.human and g.winner == "" and wait < 600:
|
||||
await frames(1)
|
||||
wait += 1
|
||||
var ai_attacked: bool = g.log.any(func(l): return l.begins_with("Gegner") and " spielt " in l) \
|
||||
and g.log.any(func(l): return " greift " in l)
|
||||
check(ai_attacked, "Gegner spielen Karten und greifen an")
|
||||
check(scr.tiles.size() == g.players.reduce(func(acc, p): return acc + p.board.size(), 0), "eine Kachel je Wesen")
|
||||
await shot("10_kampf_fuenf")
|
||||
1
tests/ui_test.gd.uid
Normal file
1
tests/ui_test.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ci5hglrkyevqi
|
||||
6
tests/ui_test.tscn
Normal file
6
tests/ui_test.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/ui_test.gd" id="1"]
|
||||
|
||||
[node name="UITest" type="Node"]
|
||||
script = ExtResource("1")
|
||||
59
translations/ui.csv
Normal file
59
translations/ui.csv
Normal file
@@ -0,0 +1,59 @@
|
||||
keys,en
|
||||
Dinger,Dinger
|
||||
Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all
|
||||
Einzelspieler,Single player
|
||||
Kampagne,Campaign
|
||||
Lokal zu mehreren,Local multiplayer
|
||||
Netzwerkspiel,Network game
|
||||
Booster öffnen,Open boosters
|
||||
Deck bauen,Build deck
|
||||
Werkstatt,Workshop
|
||||
Erfolge,Achievements
|
||||
Regeln,Rules
|
||||
Einstellungen,Settings
|
||||
Anleitung,Tutorial
|
||||
Beenden,Quit
|
||||
Zurück,Back
|
||||
Weiter,Continue
|
||||
Start,Start
|
||||
Speichern,Save
|
||||
Leeren,Clear
|
||||
Suche,Search
|
||||
Fehlende,Missing
|
||||
Alle,All
|
||||
Aktiv setzen,Set active
|
||||
Auto-Ausfüllen,Auto-fill
|
||||
Umbenennen,Rename
|
||||
Zug beenden,End turn
|
||||
Aufgeben,Surrender
|
||||
Weiterspielen,Keep playing
|
||||
"Ja, aufgeben","Yes, surrender"
|
||||
Wirklich aufgeben?,Really surrender?
|
||||
Der Kampf endet ohne Münzen.,The battle ends without coins.
|
||||
Zurück zum Menü,Back to menu
|
||||
Lupe,Zoom
|
||||
Schließen,Close
|
||||
Held als Ziel,Target hero
|
||||
Verlauf,Log
|
||||
Karte überfahren für Details,Hover a card for details
|
||||
Lautstärke,Volume
|
||||
Musik,Music
|
||||
KI-Tempo,AI speed
|
||||
Animationen,Animations
|
||||
Sprache,Language
|
||||
Booster,Boosters
|
||||
Booster kaufen (100 Münzen),Buy booster (100 coins)
|
||||
Alle aufdecken,Reveal all
|
||||
Zerlegen,Dismantle
|
||||
Herstellen,Craft
|
||||
Kämpfen,Fight
|
||||
Wiederholen,Replay
|
||||
Gesperrt,Locked
|
||||
Spiel eröffnen,Host game
|
||||
Beitreten,Join
|
||||
Behalten,Keep
|
||||
Tauschen,Swap
|
||||
Hand tauschen?,Swap cards?
|
||||
Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
|
||||
Fallen,Traps
|
||||
Du bist dran,Your turn
|
||||
|
19
translations/ui.csv.import
Normal file
19
translations/ui.csv.import
Normal file
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="csv_translation"
|
||||
type="Translation"
|
||||
uid="uid://bcfsui5t8peik"
|
||||
|
||||
[deps]
|
||||
|
||||
files=["res://translations/ui.en.translation"]
|
||||
|
||||
source_file="res://translations/ui.csv"
|
||||
dest_files=["res://translations/ui.en.translation"]
|
||||
|
||||
[params]
|
||||
|
||||
compress=1
|
||||
delimiter=0
|
||||
unescape_keys=false
|
||||
unescape_translations=true
|
||||
BIN
translations/ui.en.translation
Normal file
BIN
translations/ui.en.translation
Normal file
Binary file not shown.
Reference in New Issue
Block a user