diff --git a/README.md b/README.md index d2cf8e5..a6f9d33 100644 --- a/README.md +++ b/README.md @@ -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 - Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand) - Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch +- Geführte Anleitung mit Hervorhebungen, Rückgängig für Fehlklicks, Überspringen von Gegnerzügen +- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1 +- Wöchentliche Herausforderung mit Regelvarianten, Bestenliste (Serie, schnellster Sieg), Statistik-Export als CSV +- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei) ## 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 @@ -58,5 +64,6 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe - `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) +- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden +- `scripts/challenge.gd` – wöchentliche Herausforderungen - `translations/ui.csv` – englische Oberflächentexte diff --git a/anforderungen.md b/anforderungen.md index 533b399..142939f 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -8,6 +8,7 @@ - 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 +- Wöchentliche Herausforderungen und Bestenliste als Langzeitziel ## Sammeln - Booster öffnen diff --git a/scripts/ui/achievements_screen.gd b/scripts/ui/achievements_screen.gd index 1ab7406..2ca1f1d 100644 --- a/scripts/ui/achievements_screen.gd +++ b/scripts/ui/achievements_screen.gd @@ -2,27 +2,30 @@ class_name AchievementsScreen extends Control var app +var export_label: Label func _ready() -> void: var p: Profile = app.profile - var col := Style.column(self, 900, 20, 5) - Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true) + var col := Style.column(self, 900, 12, 4) + Style.clabel(col, "Erfolge", 32, Style.GOLD, "center", true) var st: Dictionary = p.stats Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [ - st.games, st.wins, st.boosters, p.campaign_stage, Campaign.STAGES.size()], 16, Style.MUTED) + st.games, st.wins, st.boosters, p.campaign_stage, Campaign.total()], 16, Style.MUTED) var task := p.daily_task() var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \ "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward] Style.clabel(col, daily_text, 16, Style.BLUE) - Style.spacer(col, 8) + Style.clabel(col, "Serie: %d (beste %d) · Schnellster Sieg: %s" % [p.records.streak, p.records.best_streak, + ("%d Züge" % p.records.fastest_win) if p.records.fastest_win > 0 else "–"], 15, Style.MUTED) + Style.spacer(col, 4) for a in Profile.ACHIEVEMENTS: var done: bool = a.id in p.achievements var panel := PanelContainer.new() panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6)) col.add_child(panel) var r := Style.row(panel, 12) - r.custom_minimum_size = Vector2(0, 42) + r.custom_minimum_size = Vector2(0, 36) Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40) Style.clabel(r, a.name, 18, Style.TEXT if done else Style.MUTED, "left", true, 250) Style.clabel(r, a.text, 15, Style.MUTED, "left", false, 380) @@ -34,10 +37,18 @@ func _ready() -> void: for row in top: parts.append("%s %d/%d" % [row.card.name, row.wins, row.games]) Style.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED) - Style.spacer(col, 6) - var br := Style.row(col) + export_label = Style.clabel(col, "", 13, Style.GREEN) + Style.spacer(col, 2) + var br := Style.row(col, 16) br.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) + Style.cbutton(br, "Statistik exportieren", Vector2(220, 40), export_stats) + Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new())) + + +func export_stats() -> void: + var path := "user://statistik.csv" + var err: String = app.profile.export_stats(path) + export_label.text = ("Gespeichert: " + ProjectSettings.globalize_path(path)) if err == "" else err func _input(event: InputEvent) -> void: diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 55a51f5..575ba2c 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -26,6 +26,11 @@ var preview_timer := 0.0 var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik) var mulligan_selected: Array = [] 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) var tiles := {} @@ -70,6 +75,22 @@ var tutorial_panel: Panel var tutorial_text: RichTextLabel var b_tut_next: Button var wait_label: Label +var b_skip: Button +var b_undo: Button +var highlight_layer: Control + + +## 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 := [ ["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 +static func weekly(ch: Dictionary) -> BattleScreen: + var b := BattleScreen.new(int(ch.n_opp), -1) + b.challenge = ch + return b + + func is_hotseat() -> bool: return game != null and game.humans.size() > 1 and online_seat < 0 @@ -146,14 +173,24 @@ func _ready() -> void: var opps := Campaign.opponents(0, r) opps[0].perks = [] 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: 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: - game = Game.new_battle(app.profile.deck_cards(), n_opp) + game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules()) _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: n_opp = game.players.size() - 1 if is_online(): @@ -207,6 +244,11 @@ func _build_static() -> void: status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24)) msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true) b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c")) + b_skip = Style.button(self, "Gegnerzüge überspringen", Rect2(1005, 527, 210, 48), func(): skip_ai = true) + b_skip.add_theme_font_size_override("font_size", 14) + b_skip.visible = false + b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo) + b_undo.visible = false wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") wait_label.visible = false hero_layer = _layer() @@ -214,6 +256,7 @@ func _build_static() -> void: trap_layer = _layer() hand_layer = _layer() preview_holder = _layer() + highlight_layer = _layer() zoom_layer = _layer() fx_layer = _layer() # Mulligan @@ -380,6 +423,11 @@ func _input(event: InputEvent) -> void: KEY_SPACE: if my_turn() and not any_overlay(): end_turn() + elif b_skip.visible: + skip_ai = true + KEY_U: + if b_undo.visible: + undo() KEY_ENTER, KEY_KP_ENTER: if handover.visible: take_over() @@ -414,6 +462,12 @@ func _process(delta: float) -> void: preview_timer -= delta if preview_timer <= 0.0: show_preview(null, false) + if undo_t > 0.0: + undo_t -= delta + if undo_t <= 0.0: + clear_undo() + if is_online() and net.is_host: + net.tick() if game.winner != "": if not ended: ended = true @@ -431,19 +485,24 @@ func _process(delta: float) -> void: return if is_client(): return - ai_timer -= delta + if skip_ai: + ai_timer = 0.0 + else: + 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) + if game.current().is_human: + skip_ai = false 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) + reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge) if tutorial: app.profile.tutorial_done = true app.save() @@ -480,15 +539,52 @@ func play(card: Cards.Card, target) -> void: if is_client(): net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null}) return + var before: Game = game.clone() if card.is_creature() and not is_online() else null + var log_len := game.log.size() var err := game.play_card(perspective, card, target) if err != "": - say(err) + say(tr(err)) + return + # Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte, Kombos, Fallen) + if before != null and game.events.size() == 1 and game.events[0].type == "play": + undo_state = before + undo_log_len = log_len + undo_t = 6.0 + else: + clear_undo() + + +func clear_undo() -> void: + undo_state = null + undo_t = 0.0 + b_undo.visible = false + + +## Nimmt das letzte Ausspielen eines Wesens zurück (nur solange nichts Verdecktes offenbart wurde). +func undo() -> void: + if undo_state == null: + return + var idx := game.players.find(perspective) + var restored := undo_state + restored.quiet = false + restored.rng = game.rng + restored.log = game.log.slice(0, undo_log_len) + restored.say("Rückgängig.") + game = restored + perspective = game.players[idx] + if is_online() and net.is_host: + net.game = game + clear_undo() + cancel() + build_hero_boxes() + after_action() func end_turn() -> void: if not my_turn(): return cancel() + clear_undo() zoom_player = null if is_client(): net.send_action({"type": "end"}) @@ -552,7 +648,7 @@ func on_click(kind: String, obj) -> void: if obj in g.valid_targets(perspective, card.target_type()): play(card, obj) else: - say("Abgebrochen") + say(tr("Abgebrochen")) zoom_player = null after_action() return @@ -560,12 +656,13 @@ func on_click(kind: String, obj) -> void: var cr = selected selected = null if kind == "enemy" or kind == "hero": + clear_undo() if is_client(): net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)}) else: var err := g.attack(cr, obj) if err != "": - say(err) + say(tr(err)) zoom_player = null after_action() return @@ -573,18 +670,18 @@ func on_click(kind: String, obj) -> void: "hand": var ok := g.can_play(perspective, obj) if not ok[0]: - say(ok[1]) + say(tr(ok[1])) elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty(): pending = obj - say("Wähle ein Ziel") + say(tr("Wähle ein Ziel")) else: play(obj, null) "own": if obj.can_attack(): selected = obj - say("Wähle ein Angriffsziel") + say(tr("Wähle ein Angriffsziel")) else: - say("%s kann nicht angreifen" % obj.name) + say(tr("%s kann nicht angreifen") % obj.name) after_action() @@ -739,6 +836,51 @@ func tutorial_next() -> void: 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: while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]): tutorial_step += 1 @@ -876,8 +1018,12 @@ func refresh() -> void: life_label.text = "♥ %d" % maxi(h.life, 0) energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] deck_label.text = "Deck %d" % h.deck.size() - status_label.text = "Zug %d · %s" % [g.turn, "Du bist dran" if mine else g.current().name + " ist dran …"] + status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name]) 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 start := maxi(0, g.log.size() - 60) for i in range(start, g.log.size()): @@ -891,6 +1037,7 @@ func refresh() -> void: log_box.text = "\n".join(lines) if tutorial: _tutorial_refresh() + _tutorial_highlights(mine) Style.clear(zoom_layer) if zoom_player != null and zoom_player.alive(): diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd index 5c406d7..3bd40e3 100644 --- a/scripts/ui/campaign_screen.gd +++ b/scripts/ui/campaign_screen.gd @@ -2,52 +2,88 @@ class_name CampaignScreen extends Control var app +var chapter := 1 +var perk_box: OptionButton func _ready() -> void: var p: Profile = app.profile - var col := Style.column(self, 1000, 24, 6) - Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true) - Style.clabel(col, "Zehn Magier warten. Jede Stufe bringt mehr Gegner an den Tisch, alle nach denselben Regeln.", 16, Style.MUTED) - Style.spacer(col, 6) - for i in Campaign.STAGES.size(): - var st: Dictionary = Campaign.STAGES[i] + chapter = 2 if p.campaign_stage >= Campaign.STAGES.size() else 1 + build() + + +func build() -> void: + Style.clear(self) + var p: Profile = app.profile + var col := Style.column(self, 1000, 16, 5) + Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true) + var tabs := Style.row(col, 10) + tabs.alignment = BoxContainer.ALIGNMENT_CENTER + 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 next := i == p.campaign_stage var panel := PanelContainer.new() panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6, Style.GOLD if next else null, 2 if next else 0)) col.add_child(panel) - var r := Style.row(panel, 12) + var r := Style.row(panel, 10) r.custom_minimum_size = Vector2(0, 50) - Style.clabel(r, str(i + 1), 20, Style.GOLD if next else Style.MUTED, "center", true, 40) - Style.clabel(r, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300) + Style.clabel(r, str(i + 1), 18, Style.GOLD if next else Style.MUTED, "center", true, 36) + Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 290) var dots := Control.new() - dots.custom_minimum_size = Vector2(60, 0) + dots.custom_minimum_size = Vector2(50, 0) dots.mouse_filter = Control.MOUSE_FILTER_IGNORE var elems: Array = st.elements dots.draw.connect(func(): - for k in elems.size(): - dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]])) + for m in elems.size(): + dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]])) r.add_child(dots) - var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2) - Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300) + var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) + var table: String = "Duell" if extra == 0 else "%d am Tisch" % (extra + 2) + var perks_text: String = ", ".join(st.perks.map(func(pk): return Game.PERKS[pk])) + Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, perks_text], 12, Style.MUTED, "left", false, 360) if done: - Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110) - Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i)) + Style.clabel(r, "abgeschlossen", 13, Style.GREEN, "right", false, 100) + Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i)) elif next: - Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) - Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c")) + Style.clabel(r, "", 13, Style.GREEN, "right", false, 100) + Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c")) else: - Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) - var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass) + Style.clabel(r, "", 13, Style.GREEN, "right", false, 100) + var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass) b.disabled = true - if p.campaign_stage >= Campaign.STAGES.size(): - Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true) - Style.spacer(col, 4) + if p.campaign_stage >= Campaign.total(): + Style.clabel(col, "Beide Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true) var br := Style.row(col) br.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) + Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new())) + + +func set_chapter(c: int) -> void: + chapter = c + build() func _start(stage: int) -> void: diff --git a/scripts/ui/challenge_screen.gd b/scripts/ui/challenge_screen.gd new file mode 100644 index 0000000..d632a53 --- /dev/null +++ b/scripts/ui/challenge_screen.gd @@ -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()) diff --git a/scripts/ui/challenge_screen.gd.uid b/scripts/ui/challenge_screen.gd.uid new file mode 100644 index 0000000..d00a978 --- /dev/null +++ b/scripts/ui/challenge_screen.gd.uid @@ -0,0 +1 @@ +uid://cxheynenqemse diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd index 4616291..a297adf 100644 --- a/scripts/ui/menu_screen.gd +++ b/scripts/ui/menu_screen.gd @@ -7,6 +7,7 @@ var b_campaign: Button var b_hotseat: Button var b_online: Button var b_tutorial: Button +var b_challenge: Button var coins_label: Label var coll_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_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c")) b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c")) + b_challenge = Style.cbutton(grid, "Herausforderung", sz, func(): app.show_screen(ChallengeScreen.new()), Color("285a3c")) Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new())) Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new())) Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new())) @@ -61,20 +63,22 @@ func refresh() -> void: b_campaign.disabled = not ok[0] b_tutorial.disabled = not ok[0] b_online.disabled = not ok[0] + b_challenge.disabled = not ok[0] + b_challenge.text = tr("Herausforderung") + (" ✓" if p.challenge_week == Challenge.week() else "") var total := 0 for cid in p.collection: total += p.collection[cid] - coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust] - coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [ - p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()] + coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust] + coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [ + p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()] var elems := Game.deck_elements(p.deck_cards()) - deck_label.text = "Aktives Deck: %s (%s)" % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else "leer"] + deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else tr("leer")] var task := p.daily_task() if p.daily.done: - daily_label.text = "Tagesaufgabe erledigt: %s ✓" % task.text + daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text else: - daily_label.text = "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward] - warn_label.text = "" if ok[0] else "Aktives Deck nicht spielbar: " + ok[1] + daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward] + warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(ok[1]) if ok[0] and not p.tutorial_done: warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos." warn_label.add_theme_color_override("font_color", Style.GOLD) diff --git a/tests/test_runner.gd b/tests/test_runner.gd index ee4944f..33acbc3 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -1105,7 +1105,7 @@ func test_neue_fallen() -> void: g.play_card(o, C("f_wolf")) check(o.board.is_empty(), "Aschefalle tötet den 3/2 Wolf beim Ausspielen") # Sandfalle friert das ausgespielte Wesen ein, Spiegelfalle beschwört einen Windgeist - s = setup(["e_sandfalle", "l_spiegel"], ["e_troll"]) + s = setup(["e_sandfalle", "l_spiegel"], ["e_troll", "w_reiter"]) g = s[0] h = s[1] o = s[2][0] @@ -1113,8 +1113,9 @@ func test_neue_fallen() -> void: g.play_card(h, C("l_spiegel")) g.end_turn() g.play_card(o, C("e_troll")) - check(o.board.size() == 1 and o.board[0].frozen, "Sandfalle friert ein") - check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist") + check(o.board.size() == 1 and o.board[0].frozen and h.traps.size() == 1, "Sandfalle friert ein, eine Falle je Auslöser") + g.play_card(o, C("w_reiter")) # Wasser neben Erde: Schlamm heilt nur, 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") # Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt s = setup(["w_nebelfalle", "e_dornen"]) diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 48ddb3e..d02d03d 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -154,8 +154,8 @@ func _ready() -> void: 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]: + test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_herausforderung, + test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]: await ensure_menu() await t.call() for suffix in ["", ".bak", ".corrupt"]: @@ -358,6 +358,17 @@ func test_gegnerauswahl() -> void: await give_up() +## Kampf verlassen: nach Spielende über den Ergebnisdialog, sonst per Aufgeben. +func leave_battle() -> void: + var scr = screen() + if scr is BattleScreen and scr.game != null and scr.game.winner != "": + await frames(2) + await click(find_button("Zurück zum Menü")) + check(screen() is MenuScreen, "Ergebnisdialog führt ins Menü") + else: + await give_up() + + func give_up() -> void: var scr = screen() await clear_mulligan(scr) @@ -390,6 +401,11 @@ func test_einstellungen() -> void: func test_erfolge() -> void: await click(find_button("Erfolge")) check(screen() is AchievementsScreen, "Erfolge geöffnet") + var csv := "user://statistik.csv" + if FileAccess.file_exists(csv): + DirAccess.remove_absolute(ProjectSettings.globalize_path(csv)) + await click(find_button("Statistik exportieren")) + check(FileAccess.file_exists(csv) and "Gespeichert" in screen().export_label.text, "Statistik exportiert") await shot("14_erfolge") await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") @@ -427,6 +443,26 @@ func test_werkstatt() -> void: 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: await click(find_button("Lokal zu mehreren")) var scr = screen() @@ -458,7 +494,7 @@ func test_hotseat() -> void: await clear_mulligan(b) check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch") await shot("19_hotseat_kampf") - await give_up() + await leave_battle() func test_sprache() -> void: @@ -469,10 +505,12 @@ func test_sprache() -> void: await frames(2) check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch") check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen") + check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Energie") == "Not enough energy", "dynamische Texte übersetzt") check(find_button("Back") != null, "Knöpfe übersetzt") await shot("26_englisch") await click(find_button("Back")) check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch") + check(screen().coins_label.text.begins_with("Coins:"), "zusammengesetzte Texte übersetzt") await click(find_button("Settings")) var scr2 = screen() scr2.language.select(0) @@ -506,14 +544,36 @@ func test_anleitung() -> void: if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1: idx = i break + check(idx < 0 or scr.highlight_layer.get_child_count() >= 1, "spielbare Wesen hervorgehoben") if idx >= 0: + var played_id: String = g.human.hand[idx].id await key(KEY_1 + idx) check(g.human.board.size() == 1, "Wesen per Taste gespielt") check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen") + check(scr.highlight_layer.get_child_count() == 1 and scr.highlight_layer.get_child(0).get_rect().intersects(scr.b_end.get_rect()), "Zug-beenden-Knopf hervorgehoben") + # Rückgängig: Wesen zurück auf die Hand + check(scr.b_undo.visible, "Rückgängig angeboten") + var energy: int = g.human.energy + await click(scr.b_undo) + g = scr.game + check(g.human.board.is_empty() and g.human.hand.any(func(c): return c.id == played_id) and g.human.energy == energy + 1, "Rückgängig stellt Hand und Energie her") + check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht") + await key(KEY_1 + idx) + check(g.human.board.size() == 1, "erneut gespielt") await shot("22_anleitung") + scr.ai_delay = 0.75 await key(KEY_SPACE) check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") - await wait_for_human(scr) + await frames(2) + check(scr.b_skip.visible and not scr.b_end.visible, "Überspringen-Knopf während des Gegnerzugs") + await click(scr.b_skip) + var w := 0 + while g.current() != g.human and g.winner == "" and w < 120: + await frames(1) + w += 1 + check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w) + scr.ai_delay = 0.0 + await clear_mulligan(scr) check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug") var step_before: int = scr.tutorial_step await click(scr.b_tut_next) @@ -532,6 +592,8 @@ func test_netzwerk() -> void: check(scr is LobbyScreen, "Lobby geöffnet") await click(find_button("Spiel eröffnen")) check(main.net.is_host and main.net.connected, "Spiel eröffnet") + if FileAccess.file_exists("user://bot_quit"): + DirAccess.remove_absolute(ProjectSettings.globalize_path("user://bot_quit")) var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"] var pid := OS.create_process(OS.get_executable_path(), args) check(pid > 0, "Bot-Prozess gestartet") @@ -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.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt") await shot("24_netzwerk_kampf") - OS.kill(pid) + # Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen + var quit_file := "user://bot_quit" + var f := FileAccess.open(quit_file, FileAccess.WRITE) + f.store_string("x") + f.close() var w3 := 0 - while b.game.winner == "" and w3 < 1500: + while main.net.waiting.is_empty() and w3 < 600: await frames(1) w3 += 1 - if b.game.winner != "": - check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe") - await frames(2) - check(b.overlay.visible, "Spielende angezeigt") - await click(find_button("Zurück zum Menü")) - else: - print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen") - await give_up() + check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten") + var turn_before: int = b.game.turn + for i in 2: + await clear_mulligan(b) + if b.game.current() == b.perspective and b.game.winner == "": + await click(b.b_end) + var w4 := 0 + while b.game.current() != b.perspective and b.game.winner == "" and w4 < 300: + await frames(1) + w4 += 1 + check(b.game.turn >= turn_before + 1 and b.game.log.any(func(l): return "übersprungen" in l), "Züge des fehlenden Spielers werden übersprungen") + DirAccess.remove_absolute(ProjectSettings.globalize_path(quit_file)) + await frames(5) + var pid2 := OS.create_process(OS.get_executable_path(), args) + var w5 := 0 + while not main.net.waiting.is_empty() and w5 < 900: + await frames(1) + w5 += 1 + check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück") + var turn2: int = b.game.turn + for i in 2: + await clear_mulligan(b) + if b.game.current() == b.perspective and b.game.winner == "": + await click(b.b_end) + var w6 := 0 + while b.game.current() != b.perspective and b.game.winner == "" and w6 < 900: + await frames(1) + w6 += 1 + check(b.game.turn >= turn2 + 1, "Partie läuft nach der Rückkehr weiter") + OS.kill(pid2) + await leave_battle() check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü") @@ -581,12 +670,31 @@ func test_kampagne() -> void: check(scr is CampaignScreen, "Kampagne geöffnet") await shot("15_kampagne") check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen") + check(find_button("Kapitel 2").disabled and scr.perk_box == null, "Kapitel 2 und Merkmal noch gesperrt") await click(find_button("Kämpfen")) var battle = screen() check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner") + check(battle.game.opponents[0].perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv") await shot("16_kampagne_kampf") await give_up() + # Kapitel 1 als abgeschlossen setzen: Kapitel 2 und Merkmalwahl + main.profile.campaign_stage = Campaign.STAGES.size() + await click(find_button("Kampagne")) + var scr2 = screen() + check(scr2.chapter == 2 and scr2.perk_box != null, "Kapitel 2 offen, Merkmal wählbar") + scr2.perk_box.select(1) + scr2.perk_box.item_selected.emit(1) + check(main.profile.perk == Game.PERKS.keys()[0], "Merkmal gewählt") + await shot("27_kampagne_kapitel2") + await click(find_button("Kämpfen")) + var b2 = screen() + check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponents[0].perks.size() == 2, "Kapitel-2-Boss mit zwei Merkmalen") + check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf") + await give_up() + main.profile.campaign_stage = 0 + main.profile.perk = "" + main.save() func test_kampf(n_opp: int, shot_turn: int) -> void: diff --git a/translations/ui.csv b/translations/ui.csv index 87d4165..1ac8fc9 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -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. Fallen,Traps Du bist dran,Your turn +Herausforderung,Challenge +Antreten,Enter +Statistik exportieren,Export statistics +Gegnerzüge überspringen,Skip AI turns +Rückgängig,Undo +Kapitel 1,Chapter 1 +Kapitel 2,Chapter 2 +"Münzen: %d · Staub: %d","Coins: %d · Dust: %d" +"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d" +Aktives Deck: %s (%s),Active deck: %s (%s) +leer,empty +Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓ +Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins +"Aktives Deck nicht spielbar: ","Active deck not playable: " +Zug %d · Du bist dran,Turn %d · Your turn +Zug %d · %s ist dran …,Turn %d · %s is playing … +Wähle ein Ziel,Choose a target +Wähle ein Angriffsziel,Choose an attack target +Abgebrochen,Cancelled +%s kann nicht angreifen,%s cannot attack +Nicht dein Zug,Not your turn +Nicht genug Energie,Not enough energy +Dein Feld ist voll,Your board is full +Kein gültiges Ziel,No valid target +Ungültiges Ziel,Invalid target +Höchstens 2 Fallen,At most 2 traps +Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first +Sieg!,Victory! +Niederlage,Defeat ++%d Münzen,+%d coins +"Deck braucht genau %d Karten (%d)","Deck needs exactly %d cards (%d)" +Höchstens %dx dieselbe Karte,At most %dx the same card +Karte nicht oft genug in der Sammlung,Not enough copies in your collection +Höchstens %d Elemente im Deck,At most %d elements in a deck diff --git a/translations/ui.en.translation b/translations/ui.en.translation index 39c379a..c2a188d 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ