Anleitung mit Hervorhebungen, Rückgängig, Kapitel 2, Herausforderungen, Fallen, Wiederverbinden, CI

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
This commit is contained in:
Team3
2026-09-16 13:27:41 +02:00
parent 536a523f50
commit 46c3fae233
12 changed files with 462 additions and 70 deletions

View File

@@ -26,10 +26,16 @@ Der Spielstand liegt unter `user://spielstand.json`
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten - 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) - 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 - Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch
- Geführte Anleitung mit Hervorhebungen, Rückgängig für Fehlklicks, Überspringen von Gegnerzügen
- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1
- Wöchentliche Herausforderung mit Regelvarianten, Bestenliste (Serie, schnellster Sieg), Statistik-Export als CSV
- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei)
## Tests ## Tests
`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`). `tests/run_headless.sh` führt Import und Headless-Prüfungen aus. `.github/workflows/tests.yml` läuft beides in CI:
die Headless-Prüfungen direkt, den Fenster-Test unter Xvfb (lokal ohne Xvfb nicht geprüft).
Statistiken aus Spieltests lassen sich unter Erfolge als CSV exportieren.
``` ```
godot --headless --path . --import # einmalig: Ressourcen importieren godot --headless --path . --import # einmalig: Ressourcen importieren
@@ -58,5 +64,6 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
- `scripts/campaign.gd` – Kampagnenstufen - `scripts/campaign.gd` – Kampagnenstufen
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton - `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton
- `scenes/main.tscn` – Startszene - `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) - `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
- `scripts/challenge.gd` – wöchentliche Herausforderungen
- `translations/ui.csv` – englische Oberflächentexte - `translations/ui.csv` – englische Oberflächentexte

View File

@@ -8,6 +8,7 @@
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt - Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich - Lokal zu mehreren am selben Rechner und im lokalen Netz möglich
- Geführte erste Partie als Einstieg - Geführte erste Partie als Einstieg
- Wöchentliche Herausforderungen und Bestenliste als Langzeitziel
## Sammeln ## Sammeln
- Booster öffnen - Booster öffnen

View File

@@ -2,27 +2,30 @@ class_name AchievementsScreen
extends Control extends Control
var app var app
var export_label: Label
func _ready() -> void: func _ready() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var col := Style.column(self, 900, 20, 5) var col := Style.column(self, 900, 12, 4)
Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true) Style.clabel(col, "Erfolge", 32, Style.GOLD, "center", true)
var st: Dictionary = p.stats var st: Dictionary = p.stats
Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [ Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [
st.games, st.wins, st.boosters, p.campaign_stage, Campaign.STAGES.size()], 16, Style.MUTED) st.games, st.wins, st.boosters, p.campaign_stage, Campaign.total()], 16, Style.MUTED)
var task := p.daily_task() var task := p.daily_task()
var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \ 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] "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.clabel(col, daily_text, 16, Style.BLUE)
Style.spacer(col, 8) Style.clabel(col, "Serie: %d (beste %d) · Schnellster Sieg: %s" % [p.records.streak, p.records.best_streak,
("%d Züge" % p.records.fastest_win) if p.records.fastest_win > 0 else "–"], 15, Style.MUTED)
Style.spacer(col, 4)
for a in Profile.ACHIEVEMENTS: for a in Profile.ACHIEVEMENTS:
var done: bool = a.id in p.achievements var done: bool = a.id in p.achievements
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6)) panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6))
col.add_child(panel) col.add_child(panel)
var r := Style.row(panel, 12) var r := Style.row(panel, 12)
r.custom_minimum_size = Vector2(0, 42) r.custom_minimum_size = Vector2(0, 36)
Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40) Style.clabel(r, "✓" 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.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, a.text, 15, Style.MUTED, "left", false, 380)
@@ -34,10 +37,18 @@ func _ready() -> void:
for row in top: for row in top:
parts.append("%s %d/%d" % [row.card.name, row.wins, row.games]) 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.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED)
Style.spacer(col, 6) export_label = Style.clabel(col, "", 13, Style.GREEN)
var br := Style.row(col) Style.spacer(col, 2)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) Style.cbutton(br, "Statistik exportieren", Vector2(220, 40), export_stats)
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
func export_stats() -> void:
var path := "user://statistik.csv"
var err: String = app.profile.export_stats(path)
export_label.text = ("Gespeichert: " + ProjectSettings.globalize_path(path)) if err == "" else err
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:

View File

@@ -26,6 +26,11 @@ var preview_timer := 0.0
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik) var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
var mulligan_selected: Array = [] var mulligan_selected: Array = []
var tutorial_step := 0 var tutorial_step := 0
var challenge := {} # wöchentliche Herausforderung oder leer
var skip_ai := false # Gegnerzüge im Schnelldurchlauf
var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion
var undo_log_len := 0
var undo_t := 0.0
# Ansichten (auch für Tests) # Ansichten (auch für Tests)
var tiles := {} var tiles := {}
@@ -70,6 +75,22 @@ var tutorial_panel: Panel
var tutorial_text: RichTextLabel var tutorial_text: RichTextLabel
var b_tut_next: Button var b_tut_next: Button
var wait_label: Label var wait_label: Label
var b_skip: Button
var b_undo: Button
var highlight_layer: Control
## Pulsierender Rahmen für die Anleitung.
class Highlight extends Control:
var t := 0.0
func _process(delta: float) -> void:
t += delta
queue_redraw()
func _draw() -> void:
var a := 0.55 + 0.45 * sin(t * 6.0)
Style.border(self, Rect2(Vector2.ZERO, size), Color(Style.GOLD, a), 4, 8)
const TUTORIAL_STEPS := [ 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.", ["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
@@ -107,6 +128,12 @@ static func guided() -> BattleScreen:
return b return b
static func weekly(ch: Dictionary) -> BattleScreen:
var b := BattleScreen.new(int(ch.n_opp), -1)
b.challenge = ch
return b
func is_hotseat() -> bool: func is_hotseat() -> bool:
return game != null and game.humans.size() > 1 and online_seat < 0 return game != null and game.humans.size() > 1 and online_seat < 0
@@ -146,14 +173,24 @@ func _ready() -> void:
var opps := Campaign.opponents(0, r) var opps := Campaign.opponents(0, r)
opps[0].perks = [] opps[0].perks = []
game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true) game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true)
elif not challenge.is_empty():
game = Game.new_battle(app.profile.deck_cards(), n_opp, null, challenge.rules)
elif stage >= 0: elif stage >= 0:
var r := Game.make_rng() var r := Game.make_rng()
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r) game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r, false, own_rules())
else: else:
game = Game.new_battle(app.profile.deck_cards(), n_opp) game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules())
_begin() _begin()
## Eigenes Merkmal des Spielers (nach Kapitel 1 wählbar).
func own_rules() -> Dictionary:
var p: Profile = app.profile
if p.perk != "" and p.perk_unlocked():
return {"human_perks": [p.perk]}
return {}
func _begin() -> void: func _begin() -> void:
n_opp = game.players.size() - 1 n_opp = game.players.size() - 1
if is_online(): if is_online():
@@ -207,6 +244,11 @@ func _build_static() -> void:
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24)) 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) msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true)
b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c")) b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c"))
b_skip = Style.button(self, "Gegnerzüge überspringen", Rect2(1005, 527, 210, 48), func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 14)
b_skip.visible = false
b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo)
b_undo.visible = false
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false wait_label.visible = false
hero_layer = _layer() hero_layer = _layer()
@@ -214,6 +256,7 @@ func _build_static() -> void:
trap_layer = _layer() trap_layer = _layer()
hand_layer = _layer() hand_layer = _layer()
preview_holder = _layer() preview_holder = _layer()
highlight_layer = _layer()
zoom_layer = _layer() zoom_layer = _layer()
fx_layer = _layer() fx_layer = _layer()
# Mulligan # Mulligan
@@ -380,6 +423,11 @@ func _input(event: InputEvent) -> void:
KEY_SPACE: KEY_SPACE:
if my_turn() and not any_overlay(): if my_turn() and not any_overlay():
end_turn() end_turn()
elif b_skip.visible:
skip_ai = true
KEY_U:
if b_undo.visible:
undo()
KEY_ENTER, KEY_KP_ENTER: KEY_ENTER, KEY_KP_ENTER:
if handover.visible: if handover.visible:
take_over() take_over()
@@ -414,6 +462,12 @@ func _process(delta: float) -> void:
preview_timer -= delta preview_timer -= delta
if preview_timer <= 0.0: if preview_timer <= 0.0:
show_preview(null, false) show_preview(null, false)
if undo_t > 0.0:
undo_t -= delta
if undo_t <= 0.0:
clear_undo()
if is_online() and net.is_host:
net.tick()
if game.winner != "": if game.winner != "":
if not ended: if not ended:
ended = true ended = true
@@ -431,19 +485,24 @@ func _process(delta: float) -> void:
return return
if is_client(): if is_client():
return return
if skip_ai:
ai_timer = 0.0
else:
ai_timer -= delta ai_timer -= delta
if ai_timer > 0: if ai_timer > 0:
return return
var action := AI.choose_action(game, cur) var action := AI.choose_action(game, cur)
AI.apply_action(game, cur, action) AI.apply_action(game, cur, action)
ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0) ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0)
if game.current().is_human:
skip_ai = false
after_action() after_action()
func _finish() -> void: func _finish() -> void:
var won := game.winner == "human" var won := game.winner == "human"
if counts_for_profile(): if counts_for_profile():
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids) reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge)
if tutorial: if tutorial:
app.profile.tutorial_done = true app.profile.tutorial_done = true
app.save() app.save()
@@ -480,15 +539,52 @@ func play(card: Cards.Card, target) -> void:
if is_client(): if is_client():
net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null}) net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null})
return return
var before: Game = game.clone() if card.is_creature() and not is_online() else null
var log_len := game.log.size()
var err := game.play_card(perspective, card, target) var err := game.play_card(perspective, card, target)
if err != "": if err != "":
say(err) say(tr(err))
return
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte, Kombos, Fallen)
if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before
undo_log_len = log_len
undo_t = 6.0
else:
clear_undo()
func clear_undo() -> void:
undo_state = null
undo_t = 0.0
b_undo.visible = false
## Nimmt das letzte Ausspielen eines Wesens zurück (nur solange nichts Verdecktes offenbart wurde).
func undo() -> void:
if undo_state == null:
return
var idx := game.players.find(perspective)
var restored := undo_state
restored.quiet = false
restored.rng = game.rng
restored.log = game.log.slice(0, undo_log_len)
restored.say("Rückgängig.")
game = restored
perspective = game.players[idx]
if is_online() and net.is_host:
net.game = game
clear_undo()
cancel()
build_hero_boxes()
after_action()
func end_turn() -> void: func end_turn() -> void:
if not my_turn(): if not my_turn():
return return
cancel() cancel()
clear_undo()
zoom_player = null zoom_player = null
if is_client(): if is_client():
net.send_action({"type": "end"}) net.send_action({"type": "end"})
@@ -552,7 +648,7 @@ func on_click(kind: String, obj) -> void:
if obj in g.valid_targets(perspective, card.target_type()): if obj in g.valid_targets(perspective, card.target_type()):
play(card, obj) play(card, obj)
else: else:
say("Abgebrochen") say(tr("Abgebrochen"))
zoom_player = null zoom_player = null
after_action() after_action()
return return
@@ -560,12 +656,13 @@ func on_click(kind: String, obj) -> void:
var cr = selected var cr = selected
selected = null selected = null
if kind == "enemy" or kind == "hero": if kind == "enemy" or kind == "hero":
clear_undo()
if is_client(): if is_client():
net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)}) net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)})
else: else:
var err := g.attack(cr, obj) var err := g.attack(cr, obj)
if err != "": if err != "":
say(err) say(tr(err))
zoom_player = null zoom_player = null
after_action() after_action()
return return
@@ -573,18 +670,18 @@ func on_click(kind: String, obj) -> void:
"hand": "hand":
var ok := g.can_play(perspective, obj) var ok := g.can_play(perspective, obj)
if not ok[0]: if not ok[0]:
say(ok[1]) say(tr(ok[1]))
elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty(): elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty():
pending = obj pending = obj
say("Wähle ein Ziel") say(tr("Wähle ein Ziel"))
else: else:
play(obj, null) play(obj, null)
"own": "own":
if obj.can_attack(): if obj.can_attack():
selected = obj selected = obj
say("Wähle ein Angriffsziel") say(tr("Wähle ein Angriffsziel"))
else: else:
say("%s kann nicht angreifen" % obj.name) say(tr("%s kann nicht angreifen") % obj.name)
after_action() after_action()
@@ -739,6 +836,51 @@ func tutorial_next() -> void:
refresh() refresh()
## Hebt hervor, was der aktuelle Schritt verlangt.
func _tutorial_highlights(mine: bool) -> void:
Style.clear(highlight_layer)
if not mine or any_overlay():
return
var g := game
var h := perspective
var rects: Array[Rect2] = []
match TUTORIAL_STEPS[tutorial_step][1]:
"played_creature":
for v in hand_views:
if v.card.is_creature() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"turn_passed":
rects.append(b_end.get_rect())
"attacked":
for v in own_views:
if v.creature.can_attack():
rects.append(Rect2(v.slot, v.size))
if not own_views.is_empty() and own_views.any(func(v): return v.creature.can_attack()):
for o in others():
if o.alive() and hero_panels.has(o):
rects.append(hero_panels[o].get_rect())
"resonance":
for v in hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return e in c.elements)):
rects.append(Rect2(v.position, v.size))
"combo":
for v in hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return not (e in c.elements))):
rects.append(Rect2(v.position, v.size))
"spell":
for v in hand_views:
if v.card.kind == "Zauber" and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
for r in rects:
var hl := Highlight.new()
hl.position = r.position - Vector2(4, 4)
hl.size = r.size + Vector2(8, 8)
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
highlight_layer.add_child(hl)
func _tutorial_refresh() -> void: func _tutorial_refresh() -> void:
while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]): while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]):
tutorial_step += 1 tutorial_step += 1
@@ -876,8 +1018,12 @@ func refresh() -> void:
life_label.text = "♥ %d" % maxi(h.life, 0) life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy]
deck_label.text = "Deck %d" % h.deck.size() deck_label.text = "Deck %d" % h.deck.size()
status_label.text = "Zug %d · %s" % [g.turn, "Du bist dran" if mine else g.current().name + " ist dran …"] status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name])
b_end.disabled = not mine b_end.disabled = not mine
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
b_skip.visible = ai_running
b_end.visible = not ai_running
b_undo.visible = undo_state != null and mine
var lines: Array[String] = [] var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60) var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()): for i in range(start, g.log.size()):
@@ -891,6 +1037,7 @@ func refresh() -> void:
log_box.text = "\n".join(lines) log_box.text = "\n".join(lines)
if tutorial: if tutorial:
_tutorial_refresh() _tutorial_refresh()
_tutorial_highlights(mine)
Style.clear(zoom_layer) Style.clear(zoom_layer)
if zoom_player != null and zoom_player.alive(): if zoom_player != null and zoom_player.alive():

View File

@@ -2,52 +2,88 @@ class_name CampaignScreen
extends Control extends Control
var app var app
var chapter := 1
var perk_box: OptionButton
func _ready() -> void: func _ready() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var col := Style.column(self, 1000, 24, 6) chapter = 2 if p.campaign_stage >= Campaign.STAGES.size() else 1
Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true) build()
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(): func build() -> void:
var st: Dictionary = Campaign.STAGES[i] Style.clear(self)
var p: Profile = app.profile
var col := Style.column(self, 1000, 16, 5)
Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true)
var tabs := Style.row(col, 10)
tabs.alignment = BoxContainer.ALIGNMENT_CENTER
var b1 := Style.cbutton(tabs, "Kapitel 1", Vector2(160, 36), func(): set_chapter(1), Color("285a3c") if chapter == 1 else null)
var b2 := Style.cbutton(tabs, "Kapitel 2", Vector2(160, 36), func(): set_chapter(2), Color("285a3c") if chapter == 2 else null)
b2.disabled = p.campaign_stage < Campaign.STAGES.size()
if p.perk_unlocked():
var pr := Style.row(tabs, 8)
Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110)
perk_box = OptionButton.new()
perk_box.custom_minimum_size = Vector2(300, 36)
perk_box.add_item("keins")
var keys: Array = Game.PERKS.keys()
for k in keys:
perk_box.add_item(Game.PERKS[k])
perk_box.select(keys.find(p.perk) + 1 if p.perk != "" else 0)
perk_box.item_selected.connect(func(i: int):
p.perk = "" if i == 0 else keys[i - 1]
app.save())
pr.add_child(perk_box)
Style.clabel(col, "Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★)." if chapter == 1
else "Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.", 14, Style.MUTED)
var offset := 0 if chapter == 1 else Campaign.STAGES.size()
for k in Campaign.STAGES.size():
var i := offset + k
var st := Campaign.stage(i)
var done := i < p.campaign_stage var done := i < p.campaign_stage
var next := i == p.campaign_stage var next := i == p.campaign_stage
var panel := PanelContainer.new() 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, 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)) Style.GOLD if next else null, 2 if next else 0))
col.add_child(panel) col.add_child(panel)
var r := Style.row(panel, 12) var r := Style.row(panel, 10)
r.custom_minimum_size = Vector2(0, 50) 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, str(i + 1), 18, Style.GOLD if next else Style.MUTED, "center", true, 36)
Style.clabel(r, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300) Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 290)
var dots := Control.new() var dots := Control.new()
dots.custom_minimum_size = Vector2(60, 0) dots.custom_minimum_size = Vector2(50, 0)
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
var elems: Array = st.elements var elems: Array = st.elements
dots.draw.connect(func(): dots.draw.connect(func():
for k in elems.size(): for m in elems.size():
dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]])) dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]]))
r.add_child(dots) r.add_child(dots)
var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2) var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300) var table: String = "Duell" if extra == 0 else "%d am Tisch" % (extra + 2)
var perks_text: String = ", ".join(st.perks.map(func(pk): return Game.PERKS[pk]))
Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, perks_text], 12, Style.MUTED, "left", false, 360)
if done: if done:
Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110) Style.clabel(r, "abgeschlossen", 13, Style.GREEN, "right", false, 100)
Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i)) Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i))
elif next: elif next:
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) Style.clabel(r, "", 13, Style.GREEN, "right", false, 100)
Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c")) Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c"))
else: else:
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) Style.clabel(r, "", 13, Style.GREEN, "right", false, 100)
var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass) var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
b.disabled = true b.disabled = true
if p.campaign_stage >= Campaign.STAGES.size(): if p.campaign_stage >= Campaign.total():
Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true) Style.clabel(col, "Beide Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true)
Style.spacer(col, 4)
var br := Style.row(col) var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
func set_chapter(c: int) -> void:
chapter = c
build()
func _start(stage: int) -> void: func _start(stage: int) -> void:

View File

@@ -0,0 +1,42 @@
class_name ChallengeScreen
extends Control
## Wöchentliche Herausforderung: feste Regelvariante mit einmaliger Belohnung.
var app
var msg: Label
func _ready() -> void:
var p: Profile = app.profile
var ch := Challenge.current()
var done: bool = p.challenge_week == Challenge.week()
var col := Style.column(self, 760, 60, 14)
Style.clabel(col, "Herausforderung der Woche", 36, Style.GOLD, "center", true)
Style.clabel(col, "Jede Woche eine andere Regel. Die Belohnung gibt es einmal pro Woche, danach die normale.", 16, Style.MUTED)
Style.spacer(col, 20)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 10, Style.GREEN if done else Style.GOLD, 2))
col.add_child(panel)
var inner := VBoxContainer.new()
inner.add_theme_constant_override("separation", 10)
panel.add_child(inner)
Style.spacer(inner, 6)
Style.clabel(inner, ch.name, 30, Style.TEXT, "center", true)
Style.clabel(inner, ch.text, 18, Style.MUTED)
Style.clabel(inner, "Belohnung: %d Münzen%s" % [ch.reward, " · diese Woche schon bestanden ✓" if done else ""], 16, Style.GOLD)
Style.spacer(inner, 6)
Style.spacer(col, 10)
msg = Style.clabel(col, "", 16, Style.ORANGE)
var deck_err := Challenge.deck_allowed(ch, p.deck_cards())
if deck_err != "":
msg.text = deck_err + " (aktives Deck: %s)" % p.deck_name()
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
var b := Style.cbutton(br, "Antreten", Vector2(200, 50), func(): app.show_screen(BattleScreen.weekly(ch)), Color("285a3c"))
b.disabled = deck_err != "" or not p.deck_valid()[0]
Style.cbutton(br, "Zurück", Vector2(160, 50), func(): app.show_screen(MenuScreen.new()))
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())

View File

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

View File

@@ -7,6 +7,7 @@ var b_campaign: Button
var b_hotseat: Button var b_hotseat: Button
var b_online: Button var b_online: Button
var b_tutorial: Button var b_tutorial: Button
var b_challenge: Button
var coins_label: Label var coins_label: Label
var coll_label: Label var coll_label: Label
var deck_label: Label var deck_label: Label
@@ -40,6 +41,7 @@ func _ready() -> void:
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.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_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c"))
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c")) b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
b_challenge = Style.cbutton(grid, "Herausforderung", sz, func(): app.show_screen(ChallengeScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new())) Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.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, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new()))
@@ -61,20 +63,22 @@ func refresh() -> void:
b_campaign.disabled = not ok[0] b_campaign.disabled = not ok[0]
b_tutorial.disabled = not ok[0] b_tutorial.disabled = not ok[0]
b_online.disabled = not ok[0] b_online.disabled = not ok[0]
b_challenge.disabled = not ok[0]
b_challenge.text = tr("Herausforderung") + (" ✓" if p.challenge_week == Challenge.week() else "")
var total := 0 var total := 0
for cid in p.collection: for cid in p.collection:
total += p.collection[cid] total += p.collection[cid]
coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust] coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [ coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()] p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()]
var elems := Game.deck_elements(p.deck_cards()) var elems := Game.deck_elements(p.deck_cards())
deck_label.text = "Aktives Deck: %s (%s)" % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else "leer"] deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else tr("leer")]
var task := p.daily_task() var task := p.daily_task()
if p.daily.done: if p.daily.done:
daily_label.text = "Tagesaufgabe erledigt: %s ✓" % task.text daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text
else: else:
daily_label.text = "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward] daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward]
warn_label.text = "" if ok[0] else "Aktives Deck nicht spielbar: " + ok[1] warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(ok[1])
if ok[0] and not p.tutorial_done: if ok[0] and not p.tutorial_done:
warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos." warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos."
warn_label.add_theme_color_override("font_color", Style.GOLD) warn_label.add_theme_color_override("font_color", Style.GOLD)

View File

@@ -1105,7 +1105,7 @@ func test_neue_fallen() -> void:
g.play_card(o, C("f_wolf")) g.play_card(o, C("f_wolf"))
check(o.board.is_empty(), "Aschefalle tötet den 3/2 Wolf beim Ausspielen") check(o.board.is_empty(), "Aschefalle tötet den 3/2 Wolf beim Ausspielen")
# Sandfalle friert das ausgespielte Wesen ein, Spiegelfalle beschwört einen Windgeist # Sandfalle friert das ausgespielte Wesen ein, Spiegelfalle beschwört einen Windgeist
s = setup(["e_sandfalle", "l_spiegel"], ["e_troll"]) s = setup(["e_sandfalle", "l_spiegel"], ["e_troll", "w_reiter"])
g = s[0] g = s[0]
h = s[1] h = s[1]
o = s[2][0] o = s[2][0]
@@ -1113,8 +1113,9 @@ func test_neue_fallen() -> void:
g.play_card(h, C("l_spiegel")) g.play_card(h, C("l_spiegel"))
g.end_turn() g.end_turn()
g.play_card(o, C("e_troll")) g.play_card(o, C("e_troll"))
check(o.board.size() == 1 and o.board[0].frozen, "Sandfalle friert ein") check(o.board.size() == 1 and o.board[0].frozen and h.traps.size() == 1, "Sandfalle friert ein, eine Falle je Auslöser")
check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist") g.play_card(o, C("w_reiter")) # Wasser neben Erde: Schlamm heilt nur, der Windgeist überlebt
check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist beim zweiten Wesen")
check(h.traps.is_empty(), "beide Fallen verbraucht") check(h.traps.is_empty(), "beide Fallen verbraucht")
# Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt # Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt
s = setup(["w_nebelfalle", "e_dornen"]) s = setup(["w_nebelfalle", "e_dornen"])

View File

@@ -154,8 +154,8 @@ func _ready() -> void:
add_child(main) add_child(main)
await frames(3) await frames(3)
for t in [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, 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_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_herausforderung,
test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]: test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]:
await ensure_menu() await ensure_menu()
await t.call() await t.call()
for suffix in ["", ".bak", ".corrupt"]: for suffix in ["", ".bak", ".corrupt"]:
@@ -358,6 +358,17 @@ func test_gegnerauswahl() -> void:
await give_up() await give_up()
## Kampf verlassen: nach Spielende über den Ergebnisdialog, sonst per Aufgeben.
func leave_battle() -> void:
var scr = screen()
if scr is BattleScreen and scr.game != null and scr.game.winner != "":
await frames(2)
await click(find_button("Zurück zum Menü"))
check(screen() is MenuScreen, "Ergebnisdialog führt ins Menü")
else:
await give_up()
func give_up() -> void: func give_up() -> void:
var scr = screen() var scr = screen()
await clear_mulligan(scr) await clear_mulligan(scr)
@@ -390,6 +401,11 @@ func test_einstellungen() -> void:
func test_erfolge() -> void: func test_erfolge() -> void:
await click(find_button("Erfolge")) await click(find_button("Erfolge"))
check(screen() is AchievementsScreen, "Erfolge geöffnet") check(screen() is AchievementsScreen, "Erfolge geöffnet")
var csv := "user://statistik.csv"
if FileAccess.file_exists(csv):
DirAccess.remove_absolute(ProjectSettings.globalize_path(csv))
await click(find_button("Statistik exportieren"))
check(FileAccess.file_exists(csv) and "Gespeichert" in screen().export_label.text, "Statistik exportiert")
await shot("14_erfolge") await shot("14_erfolge")
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
@@ -427,6 +443,26 @@ func test_werkstatt() -> void:
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
func test_herausforderung() -> void:
await click(find_button("Herausforderung"))
var scr = screen()
check(scr is ChallengeScreen, "Herausforderung geöffnet")
var ch := Challenge.current()
var allowed := Challenge.deck_allowed(ch, main.profile.deck_cards()) == ""
check(find_button("Antreten").disabled == not allowed, "Antreten je nach Deck")
await shot("28_herausforderung")
if allowed:
await click(find_button("Antreten"))
var b = screen()
check(b is BattleScreen and b.challenge == ch and b.game.opponents.size() == ch.n_opp, "Herausforderungskampf")
var everyone: String = ch.rules.get("everyone_perk", "")
check(everyone == "" or b.game.players.all(func(p): return everyone in p.perks), "Regel gilt für alle")
await give_up()
else:
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_hotseat() -> void: func test_hotseat() -> void:
await click(find_button("Lokal zu mehreren")) await click(find_button("Lokal zu mehreren"))
var scr = screen() var scr = screen()
@@ -458,7 +494,7 @@ func test_hotseat() -> void:
await clear_mulligan(b) await clear_mulligan(b)
check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch") check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch")
await shot("19_hotseat_kampf") await shot("19_hotseat_kampf")
await give_up() await leave_battle()
func test_sprache() -> void: func test_sprache() -> void:
@@ -469,10 +505,12 @@ func test_sprache() -> void:
await frames(2) await frames(2)
check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch") check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch")
check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen") check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen")
check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Energie") == "Not enough energy", "dynamische Texte übersetzt")
check(find_button("Back") != null, "Knöpfe übersetzt") check(find_button("Back") != null, "Knöpfe übersetzt")
await shot("26_englisch") await shot("26_englisch")
await click(find_button("Back")) await click(find_button("Back"))
check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch") check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch")
check(screen().coins_label.text.begins_with("Coins:"), "zusammengesetzte Texte übersetzt")
await click(find_button("Settings")) await click(find_button("Settings"))
var scr2 = screen() var scr2 = screen()
scr2.language.select(0) scr2.language.select(0)
@@ -506,14 +544,36 @@ func test_anleitung() -> void:
if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1: if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1:
idx = i idx = i
break break
check(idx < 0 or scr.highlight_layer.get_child_count() >= 1, "spielbare Wesen hervorgehoben")
if idx >= 0: if idx >= 0:
var played_id: String = g.human.hand[idx].id
await key(KEY_1 + idx) await key(KEY_1 + idx)
check(g.human.board.size() == 1, "Wesen per Taste gespielt") check(g.human.board.size() == 1, "Wesen per Taste gespielt")
check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen") check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen")
check(scr.highlight_layer.get_child_count() == 1 and scr.highlight_layer.get_child(0).get_rect().intersects(scr.b_end.get_rect()), "Zug-beenden-Knopf hervorgehoben")
# Rückgängig: Wesen zurück auf die Hand
check(scr.b_undo.visible, "Rückgängig angeboten")
var energy: int = g.human.energy
await click(scr.b_undo)
g = scr.game
check(g.human.board.is_empty() and g.human.hand.any(func(c): return c.id == played_id) and g.human.energy == energy + 1, "Rückgängig stellt Hand und Energie her")
check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht")
await key(KEY_1 + idx)
check(g.human.board.size() == 1, "erneut gespielt")
await shot("22_anleitung") await shot("22_anleitung")
scr.ai_delay = 0.75
await key(KEY_SPACE) await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
await wait_for_human(scr) await frames(2)
check(scr.b_skip.visible and not scr.b_end.visible, "Überspringen-Knopf während des Gegnerzugs")
await click(scr.b_skip)
var w := 0
while g.current() != g.human and g.winner == "" and w < 120:
await frames(1)
w += 1
check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w)
scr.ai_delay = 0.0
await clear_mulligan(scr)
check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug") check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug")
var step_before: int = scr.tutorial_step var step_before: int = scr.tutorial_step
await click(scr.b_tut_next) await click(scr.b_tut_next)
@@ -532,6 +592,8 @@ func test_netzwerk() -> void:
check(scr is LobbyScreen, "Lobby geöffnet") check(scr is LobbyScreen, "Lobby geöffnet")
await click(find_button("Spiel eröffnen")) await click(find_button("Spiel eröffnen"))
check(main.net.is_host and main.net.connected, "Spiel eröffnet") check(main.net.is_host and main.net.connected, "Spiel eröffnet")
if FileAccess.file_exists("user://bot_quit"):
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://bot_quit"))
var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"] var 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) var pid := OS.create_process(OS.get_executable_path(), args)
check(pid > 0, "Bot-Prozess gestartet") check(pid > 0, "Bot-Prozess gestartet")
@@ -559,19 +621,46 @@ func test_netzwerk() -> void:
check(b.game.turn >= 3 and b.game.winner == "", "mehrere Runden gespielt, Partie läuft (Zug %d)" % b.game.turn) 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") 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") await shot("24_netzwerk_kampf")
OS.kill(pid) # Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen
var quit_file := "user://bot_quit"
var f := FileAccess.open(quit_file, FileAccess.WRITE)
f.store_string("x")
f.close()
var w3 := 0 var w3 := 0
while b.game.winner == "" and w3 < 1500: while main.net.waiting.is_empty() and w3 < 600:
await frames(1) await frames(1)
w3 += 1 w3 += 1
if b.game.winner != "": check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten")
check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe") var turn_before: int = b.game.turn
await frames(2) for i in 2:
check(b.overlay.visible, "Spielende angezeigt") await clear_mulligan(b)
await click(find_button("Zurück zum Menü")) if b.game.current() == b.perspective and b.game.winner == "":
else: await click(b.b_end)
print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen") var w4 := 0
await give_up() while b.game.current() != b.perspective and b.game.winner == "" and w4 < 300:
await frames(1)
w4 += 1
check(b.game.turn >= turn_before + 1 and b.game.log.any(func(l): return "übersprungen" in l), "Züge des fehlenden Spielers werden übersprungen")
DirAccess.remove_absolute(ProjectSettings.globalize_path(quit_file))
await frames(5)
var pid2 := OS.create_process(OS.get_executable_path(), args)
var w5 := 0
while not main.net.waiting.is_empty() and w5 < 900:
await frames(1)
w5 += 1
check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück")
var turn2: int = b.game.turn
for i in 2:
await clear_mulligan(b)
if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end)
var w6 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w6 < 900:
await frames(1)
w6 += 1
check(b.game.turn >= turn2 + 1, "Partie läuft nach der Rückkehr weiter")
OS.kill(pid2)
await leave_battle()
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü") check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
@@ -581,12 +670,31 @@ func test_kampagne() -> void:
check(scr is CampaignScreen, "Kampagne geöffnet") check(scr is CampaignScreen, "Kampagne geöffnet")
await shot("15_kampagne") await shot("15_kampagne")
check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen") check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen")
check(find_button("Kapitel 2").disabled and scr.perk_box == null, "Kapitel 2 und Merkmal noch gesperrt")
await click(find_button("Kämpfen")) await click(find_button("Kämpfen"))
var battle = screen() var battle = screen()
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner") check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner")
check(battle.game.opponents[0].perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv")
await shot("16_kampagne_kampf") await shot("16_kampagne_kampf")
await give_up() await give_up()
# Kapitel 1 als abgeschlossen setzen: Kapitel 2 und Merkmalwahl
main.profile.campaign_stage = Campaign.STAGES.size()
await click(find_button("Kampagne"))
var scr2 = screen()
check(scr2.chapter == 2 and scr2.perk_box != null, "Kapitel 2 offen, Merkmal wählbar")
scr2.perk_box.select(1)
scr2.perk_box.item_selected.emit(1)
check(main.profile.perk == Game.PERKS.keys()[0], "Merkmal gewählt")
await shot("27_kampagne_kapitel2")
await click(find_button("Kämpfen"))
var b2 = screen()
check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponents[0].perks.size() == 2, "Kapitel-2-Boss mit zwei Merkmalen")
check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf")
await give_up()
main.profile.campaign_stage = 0
main.profile.perk = ""
main.save()
func test_kampf(n_opp: int, shot_turn: int) -> void: func test_kampf(n_opp: int, shot_turn: int) -> void:

View File

@@ -57,3 +57,37 @@ 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. Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
Fallen,Traps Fallen,Traps
Du bist dran,Your turn Du bist dran,Your turn
Herausforderung,Challenge
Antreten,Enter
Statistik exportieren,Export statistics
Gegnerzüge überspringen,Skip AI turns
Rückgängig,Undo
Kapitel 1,Chapter 1
Kapitel 2,Chapter 2
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d"
Aktives Deck: %s (%s),Active deck: %s (%s)
leer,empty
Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓
Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins
"Aktives Deck nicht spielbar: ","Active deck not playable: "
Zug %d · Du bist dran,Turn %d · Your turn
Zug %d · %s ist dran …,Turn %d · %s is playing …
Wähle ein Ziel,Choose a target
Wähle ein Angriffsziel,Choose an attack target
Abgebrochen,Cancelled
%s kann nicht angreifen,%s cannot attack
Nicht dein Zug,Not your turn
Nicht genug Energie,Not enough energy
Dein Feld ist voll,Your board is full
Kein gültiges Ziel,No valid target
Ungültiges Ziel,Invalid target
Höchstens 2 Fallen,At most 2 traps
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
Sieg!,Victory!
Niederlage,Defeat
+%d Münzen,+%d coins
"Deck braucht genau %d Karten (%d)","Deck needs exactly %d cards (%d)"
Höchstens %dx dieselbe Karte,At most %dx the same card
Karte nicht oft genug in der Sammlung,Not enough copies in your collection
Höchstens %d Elemente im Deck,At most %d elements in a deck
1 keys en
57 Klicke Karten an die du zurücklegen willst. Einmal pro Partie. Click the cards you want to put back. Once per game.
58 Fallen Traps
59 Du bist dran Your turn
60 Herausforderung Challenge
61 Antreten Enter
62 Statistik exportieren Export statistics
63 Gegnerzüge überspringen Skip AI turns
64 Rückgängig Undo
65 Kapitel 1 Chapter 1
66 Kapitel 2 Chapter 2
67 Münzen: %d · Staub: %d Coins: %d · Dust: %d
68 Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d Collection: %d of %d cards (%d total) · Campaign: %d/%d
69 Aktives Deck: %s (%s) Active deck: %s (%s)
70 leer empty
71 Tagesaufgabe erledigt: %s ✓ Daily task done: %s ✓
72 Tagesaufgabe: %s (%d/%d) · +%d Münzen Daily task: %s (%d/%d) · +%d coins
73 Aktives Deck nicht spielbar: Active deck not playable:
74 Zug %d · Du bist dran Turn %d · Your turn
75 Zug %d · %s ist dran … Turn %d · %s is playing …
76 Wähle ein Ziel Choose a target
77 Wähle ein Angriffsziel Choose an attack target
78 Abgebrochen Cancelled
79 %s kann nicht angreifen %s cannot attack
80 Nicht dein Zug Not your turn
81 Nicht genug Energie Not enough energy
82 Dein Feld ist voll Your board is full
83 Kein gültiges Ziel No valid target
84 Ungültiges Ziel Invalid target
85 Höchstens 2 Fallen At most 2 traps
86 Ein Wächter muss zuerst angegriffen werden A guardian must be attacked first
87 Sieg! Victory!
88 Niederlage Defeat
89 +%d Münzen +%d coins
90 Deck braucht genau %d Karten (%d) Deck needs exactly %d cards (%d)
91 Höchstens %dx dieselbe Karte At most %dx the same card
92 Karte nicht oft genug in der Sammlung Not enough copies in your collection
93 Höchstens %d Elemente im Deck At most %d elements in a deck

Binary file not shown.