diff --git a/README.md b/README.md index f4aaf4f..aef7a10 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ Der Spielstand liegt unter `user://spielstand.json` - Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach - Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen - Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor -- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben +- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm +- Anleitung Teil 2 (Mulligan, Fallen, Feldkombo Dampf), Gamepad-Steuerung, große Schrift, Profil zurücksetzen +- Gegnervorschau mit höchstens drei Neuwürfen, Schwierigkeitstipp nach Spielverlauf (deutliche Siege oder drei Niederlagen) +- KI mit Zeitbudget je Entscheidung (150 ms), Warnung bei ungespeichertem Deck ## Tests @@ -70,7 +73,7 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) - `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen - `scripts/campaign.gd` – Kampagnenstufen -- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton +- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte, Anleitung, Mulligan, Gamepad), Erfolge, Einstellungen, Regeln, Ton - `scenes/main.tscn` – Startszene - `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden - `scripts/challenge.gd` – wöchentliche Herausforderungen diff --git a/data/karten.json b/data/karten.json index 059f6ee..3b48e1b 100644 --- a/data/karten.json +++ b/data/karten.json @@ -936,6 +936,29 @@ } ] }, + { + "id": "wl_gischt", + "name": "Gischtläufer", + "elements": [ + "Wasser", + "Luft" + ], + "cost": 2, + "rarity": "selten", + "kind": "Wesen", + "attack": 2, + "health": 2, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, { "id": "wl_koenigin", "name": "Sturmkönigin", diff --git a/scripts/ai.gd b/scripts/ai.gd index c51726f..69b8b0c 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -95,6 +95,7 @@ static func map_target(g2: Game, game: Game, t): const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe +static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug ## Mulligan der KI: teure Karten im ersten Zug zurücklegen. @@ -111,7 +112,12 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: return {"type": "mulligan", "cards": mulligan_cards(p)} var best := {"type": "end"} var best_score := evaluate(game, p) + 0.01 + var t0 := Time.get_ticks_msec() + var examined := 0 for action in candidate_actions(game, p): + if examined > 0 and Time.get_ticks_msec() - t0 > time_budget_ms: + break + examined += 1 var g2 := game.clone_for(p) apply_on_clone(g2, game, action) var p2: Game.Player = g2.players[game.current_idx] @@ -119,7 +125,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: if g2.winner == "": var count := 0 for f in candidate_actions(g2, p2): - if count >= FOLLOW_UPS: + if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms: break count += 1 var g3 := g2.clone_for(p2) diff --git a/scripts/deckgen.gd b/scripts/deckgen.gd index 676de01..d4e50e4 100644 --- a/scripts/deckgen.gd +++ b/scripts/deckgen.gd @@ -7,9 +7,9 @@ const RESONANCE_STEPS := [0, 1, 2, 3, 3] # Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, tests/strength_probe.gd). const ARCHETYPE_STRENGTH := { - "Feuer": 36, "Wasser": 30, "Erde": 63, "Luft": 50, - "Feuer+Wasser": 46, "Feuer+Erde": 70, "Feuer+Luft": 40, - "Wasser+Erde": 46, "Wasser+Luft": 20, "Erde+Luft": 46, + "Feuer": 40, "Wasser": 56, "Erde": 46, "Luft": 63, + "Feuer+Wasser": 40, "Feuer+Erde": 83, "Feuer+Luft": 40, + "Wasser+Erde": 53, "Wasser+Luft": 23, "Erde+Luft": 60, } diff --git a/scripts/profile.gd b/scripts/profile.gd index f6ac31b..f766f7f 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -64,13 +64,14 @@ var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}} var achievements: Array = [] var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen var daily := {"date": "", "task": "", "progress": 0, "done": false} -var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal"} +var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false} var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe) var card_stats := {} # id -> {"played", "games", "wins"} var booster_since_mythic := 0 var tutorial_done := false var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) -var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0} +var tutorial2_done := false +var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []} var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung ## Das aktive Deck (Kartenliste des gewählten Slots). @@ -158,7 +159,8 @@ static func from_dict(d: Dictionary) -> Profile: var se = d.get("settings", {}) p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)), "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), - "language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal"))} + "language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")), + "big_font": bool(se.get("big_font", false))} if not DIFFICULTIES.has(p.settings.difficulty): p.settings.difficulty = "normal" if version >= 4: @@ -174,8 +176,10 @@ static func from_dict(d: Dictionary) -> Profile: p.perk = "" var rc = d.get("records", {}) p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)), - "fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0))} + "fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0)), + "margins": Array(rc.get("margins", [])).map(func(m): return int(m))} p.challenge_week = int(d.get("challenge_week", -1)) + p.tutorial2_done = bool(d.get("tutorial2_done", false)) return p @@ -185,7 +189,7 @@ func to_dict() -> Dictionary: "stats": stats, "achievements": achievements, "campaign_stage": campaign_stage, "daily": daily, "settings": settings, "card_stats": card_stats, "booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done, - "perk": perk, "records": records, "challenge_week": challenge_week} + "perk": perk, "records": records, "challenge_week": challenge_week, "tutorial2_done": tutorial2_done} ## Speichert und sichert den vorherigen Stand als .bak. @@ -376,8 +380,11 @@ func open_booster(r: RandomNumberGenerator = null) -> Array: ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. ## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche. func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [], - turns: int = 0, challenge: Dictionary = {}, reward_factor: float = 1.0) -> int: + turns: int = 0, challenge: Dictionary = {}, reward_factor: float = 1.0, remaining_life: int = -1) -> int: stats.games += 1 + records.margins.append(maxi(remaining_life, 0) if won else 0) + while records.margins.size() > 5: + records.margins.pop_front() if won: records.streak += 1 records.loss_streak = 0 @@ -483,12 +490,18 @@ func difficulty() -> Dictionary: return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal) -## Vorschlag: nach drei Siegen in Folge eine Stufe rauf, nach drei Niederlagen runter. "" = kein Vorschlag. +## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf +## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag. func suggest_difficulty() -> String: var order := ["leicht", "normal", "schwer"] var i := order.find(str(settings.get("difficulty", "normal"))) - if records.streak >= 3 and i < order.size() - 1: - return order[i + 1] + if i < order.size() - 1 and records.streak >= 3: + var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3)) + var avg := 0.0 + for m in last: + avg += float(m) / last.size() + if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0): + return order[i + 1] if records.loss_streak >= 3 and i > 0: return order[i - 1] return "" diff --git a/scripts/ui/battle_gamepad.gd b/scripts/ui/battle_gamepad.gd new file mode 100644 index 0000000..ce54eef --- /dev/null +++ b/scripts/ui/battle_gamepad.gd @@ -0,0 +1,139 @@ +class_name BattleGamepad +extends RefCounted +## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele, +## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig. + +var screen # BattleScreen +var active := false +var zone := "hand" +var index := 0 +var targets: Array = [] + + +func _init(s) -> void: + screen = s + + +func button(button: int) -> void: + active = true + if screen.game == null: + return + match button: + JOY_BUTTON_START: + if screen.my_turn() and not screen.any_overlay(): + screen.end_turn() + JOY_BUTTON_B: + if screen.confirm.visible: + screen.confirm.visible = false + screen.cancel_all() + zone = "hand" + JOY_BUTTON_Y: + if screen.b_skip.visible: + screen.skip_ai = true + JOY_BUTTON_X: + if screen.b_undo.visible: + screen.undo() + JOY_BUTTON_A: + confirm() + JOY_BUTTON_DPAD_LEFT: + move(-1) + JOY_BUTTON_DPAD_RIGHT: + move(1) + JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN: + if zone != "target": + zone = "own" if zone == "hand" else "hand" + index = 0 + screen.refresh() + + +func items() -> Array: + match zone: + "hand": + return screen.hand_views + "own": + return screen.own_views.filter(func(v): return v.creature.can_attack()) + "target": + return targets + return [] + + +func move(dir: int) -> void: + var n := items().size() + if n > 0: + index = posmod(index + dir, n) + + +func confirm() -> void: + if screen.handover.visible: + screen.take_over() + return + if screen.mulligan.visible: + screen.do_mulligan([]) + return + if screen.overlay.visible: + screen.leave() + return + if screen.confirm.visible: + return + var items := items() + if items.is_empty(): + return + index = clampi(index, 0, items.size() - 1) + var item = items[index] + match zone: + "hand": + screen.on_click("hand", item.card) + if screen.pending != null: + targets = [] + var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type()) + for v in screen.enemy_views: + if v.creature in valid: + targets.append(v) + for v in screen.own_views: + if v.creature in valid: + targets.append(v) + for o in screen.others(): + if o in valid: + targets.append(screen.hero_panels[o]) + zone = "target" + index = 0 + "own": + screen.on_click("own", item.creature) + if screen.selected != null: + targets = [] + for v in screen.enemy_views: + if screen.game.can_attack(screen.selected, v.creature)[0]: + targets.append(v) + for o in screen.others(): + if o.alive() and screen.game.can_attack(screen.selected, o)[0]: + targets.append(screen.hero_panels[o]) + zone = "target" + index = 0 + "target": + if item is CreatureView: + screen.on_tile_click(item.creature) + else: + for o in screen.hero_panels: + if screen.hero_panels[o] == item: + screen.on_click("hero", o) + zone = "hand" + index = 0 + + +func draw_cursor() -> void: + Style.clear(screen.cursor_layer) + if not active or screen.game == null or not screen.my_turn(): + return + var items := items() + if items.is_empty(): + return + index = clampi(index, 0, items.size() - 1) + var item = items[index] + var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size) + var hl := BattleScreen.Highlight.new() + hl.position = r.position - Vector2(6, 6) + hl.size = r.size + Vector2(12, 12) + hl.mouse_filter = Control.MOUSE_FILTER_IGNORE + screen.cursor_layer.add_child(hl) + + diff --git a/scripts/ui/battle_gamepad.gd.uid b/scripts/ui/battle_gamepad.gd.uid new file mode 100644 index 0000000..a2db7e3 --- /dev/null +++ b/scripts/ui/battle_gamepad.gd.uid @@ -0,0 +1 @@ +uid://8wth8k7f7uvk diff --git a/scripts/ui/battle_mulligan.gd b/scripts/ui/battle_mulligan.gd new file mode 100644 index 0000000..dc9e79a --- /dev/null +++ b/scripts/ui/battle_mulligan.gd @@ -0,0 +1,63 @@ +class_name BattleMulligan +extends Control +## Mulligan-Maske: Karten anklicken, behalten oder tauschen. + +signal decided(indexes: Array) + +var layer: Control +var b_keep: Button +var b_swap: Button +var selected: Array = [] + + +func _init() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + visible = false + var dark := ColorRect.new() + dark.color = Color(0, 0, 0, 0.67) + dark.set_anchors_preset(Control.PRESET_FULL_RECT) + dark.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(dark) + Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2) + Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true) + Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center") + layer = Control.new() + layer.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(layer) + b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c")) + b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true)) + + +func open(hand: Array) -> void: + selected = [] + visible = true + Style.clear(layer) + for i in hand.size(): + var r := BattleLayout.hand_rect(hand.size(), i) + var v := CardView.new(hand[i], r.size) + v.position = Vector2(r.position.x, 322) + var idx := i + v.clicked.connect(func(_view): toggle(idx)) + layer.add_child(v) + _borders() + + +func toggle(idx: int) -> void: + if idx in selected: + selected.erase(idx) + else: + selected.append(idx) + _borders() + + +func _borders() -> void: + var views := layer.get_children() + for i in views.size(): + views[i].border = Style.YELLOW if i in selected else null + views[i].queue_redraw() + b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen" + + +func finish(swap: bool) -> void: + visible = false + decided.emit(selected.duplicate() if swap else []) diff --git a/scripts/ui/battle_mulligan.gd.uid b/scripts/ui/battle_mulligan.gd.uid new file mode 100644 index 0000000..52274a1 --- /dev/null +++ b/scripts/ui/battle_mulligan.gd.uid @@ -0,0 +1 @@ +uid://gwdm38e83cbj diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 9d0e9d3..4a45ba4 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -24,8 +24,18 @@ var ended := false var zoom_player = null var preview_timer := 0.0 var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik) -var mulligan_selected: Array = [] -var tutorial_step := 0 +var guide: BattleTutorial +var tutorial_part := 1 +var tutorial_step: int: + get: + return guide.step if guide != null else 0 +var pad: BattleGamepad +var gp_zone: String: + get: + return pad.zone +var gp_index: int: + get: + return pad.index var challenge := {} # wöchentliche Herausforderung oder leer var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner var skip_ai := false # Gegnerzüge im Schnelldurchlauf @@ -69,10 +79,23 @@ 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 mulligan: BattleMulligan +var mulligan_box: Control: + get: + return mulligan +var mulligan_layer: Control: + get: + return mulligan.layer +var b_keep: Button: + get: + return mulligan.b_keep +var b_swap: Button: + get: + return mulligan.b_swap +var mulligan_selected: Array: + get: + return mulligan.selected +var cursor_layer: Control var tutorial_panel: Panel var tutorial_text: RichTextLabel var b_tut_next: Button @@ -94,17 +117,6 @@ class Highlight extends Control: 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.", - "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 @@ -124,9 +136,10 @@ static func online(seat: int) -> BattleScreen: return b -static func guided() -> BattleScreen: +static func guided(part: int = 1) -> BattleScreen: var b := BattleScreen.new(1, -1) b.tutorial = true + b.tutorial_part = part return b @@ -162,6 +175,7 @@ func _ready() -> void: var s: Dictionary = app.profile.settings fx = BattleFx.new(self) fx.enabled = s.animations + pad = BattleGamepad.new(self) ai_delay = 0.75 / float(s.ai_speed) _build_static() if is_online(): @@ -177,10 +191,12 @@ func _ready() -> void: elif not hotseat_decks.is_empty(): game = Game.new_hotseat(hotseat_decks, hotseat_ai) elif tutorial: + guide = BattleTutorial.new(self, tutorial_part) var r := Game.make_rng() - var opps := Campaign.opponents(0, r, Game.deck_rating(app.profile.deck_cards())) + var opps := Campaign.opponents(guide.stage_index(), r, Game.deck_rating(app.profile.deck_cards())) opps[0].perks = [] game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true) + guide.inject_cards(game.human.hand) elif not challenge.is_empty(): var rules: Dictionary = challenge.rules.duplicate() rules["target_rating"] = Game.deck_rating(app.profile.deck_cards()) @@ -273,16 +289,11 @@ func _build_static() -> void: highlight_layer = _layer() zoom_layer = _layer() fx_layer = _layer() + cursor_layer = _layer() # Mulligan - mulligan_box = _overlay() - Style.panel(mulligan_box, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2) - Style.label(mulligan_box, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true) - Style.label(mulligan_box, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center") - mulligan_layer = Control.new() - mulligan_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE - mulligan_box.add_child(mulligan_layer) - b_keep = Style.button(mulligan_box, "Behalten", Rect2(430, 515, 200, 44), func(): do_mulligan(false), Color("285a3c")) - b_swap = Style.button(mulligan_box, "Tauschen", Rect2(650, 515, 200, 44), func(): do_mulligan(true)) + mulligan = BattleMulligan.new() + mulligan.decided.connect(do_mulligan) + add_child(mulligan) # Spielende overlay = _overlay() result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) @@ -327,8 +338,11 @@ func _overlay() -> Control: func leave() -> void: if is_online(): net.leave() - if tutorial and not app.profile.tutorial_done: - app.profile.tutorial_done = true + if tutorial: + if tutorial_part == 1: + app.profile.tutorial_done = true + else: + app.profile.tutorial2_done = true app.save() app.show_screen(MenuScreen.new()) @@ -415,7 +429,7 @@ func my_turn() -> bool: func any_overlay() -> bool: - return confirm.visible or handover.visible or mulligan_box.visible or overlay.visible + return confirm.visible or handover.visible or mulligan.visible or overlay.visible func _input(event: InputEvent) -> void: @@ -424,6 +438,9 @@ func _input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: cancel_all() return + if event is InputEventJoypadButton and event.pressed: + pad.button(event.button_index) + return if not (event is InputEventKey and event.pressed and not event.echo): return match event.keycode: @@ -445,8 +462,8 @@ func _input(event: InputEvent) -> void: KEY_ENTER, KEY_KP_ENTER: if handover.visible: take_over() - elif mulligan_box.visible: - do_mulligan(false) + elif mulligan.visible: + do_mulligan([]) elif overlay.visible: leave() elif tutorial and b_tut_next.visible: @@ -494,7 +511,7 @@ func _process(delta: float) -> void: handover.visible = true hand_layer.visible = false trap_layer.visible = false - elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan_box.visible and not handover.visible: + elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible: show_mulligan() return if is_client(): @@ -517,9 +534,12 @@ func _finish() -> void: var won := game.winner == "human" if counts_for_profile(): var factor: float = float(app.profile.difficulty().reward) if stage < 0 and challenge.is_empty() and not tutorial else 1.0 - reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge, factor) + reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge, factor, game.human.life) if tutorial: - app.profile.tutorial_done = true + if tutorial_part == 1: + app.profile.tutorial_done = true + else: + app.profile.tutorial2_done = true app.save() app.sfx.play("win" if won else "lose") else: @@ -610,39 +630,11 @@ func end_turn() -> void: func show_mulligan() -> void: - mulligan_selected = [] - mulligan_box.visible = true - Style.clear(mulligan_layer) - var hand: Array = perspective.hand - for i in hand.size(): - var r := BattleLayout.hand_rect(hand.size(), i) - var v := CardView.new(hand[i], r.size) - v.position = Vector2(r.position.x, 322) - var idx := i - v.clicked.connect(func(_view): toggle_mulligan(idx)) - mulligan_layer.add_child(v) - _mulligan_borders() + mulligan.open(perspective.hand) -func toggle_mulligan(idx: int) -> void: - if idx in mulligan_selected: - mulligan_selected.erase(idx) - else: - mulligan_selected.append(idx) - _mulligan_borders() - - -func _mulligan_borders() -> void: - var views := mulligan_layer.get_children() - for i in views.size(): - views[i].border = Style.YELLOW if i in mulligan_selected else null - views[i].queue_redraw() - b_swap.text = "Tauschen (%d)" % mulligan_selected.size() if not mulligan_selected.is_empty() else "Tauschen" - - -func do_mulligan(swap: bool) -> void: - mulligan_box.visible = false - var idxs: Array = mulligan_selected.duplicate() if swap else [] +func do_mulligan(idxs: Array) -> void: + mulligan.visible = false if is_client(): net.send_action({"type": "mulligan", "hand": idxs}) return @@ -826,69 +818,21 @@ func _note_events(evs: Array) -> void: # --- Anleitung ------------------------------------------------------------------------------ -func tutorial_condition(kind: String) -> bool: - var h := game.human - match kind: - "played_creature": - return game.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0 - "turn_passed": - return h.turns >= 2 - "attacked": - return game.log.any(func(l): return l.ends_with("greift Fenn an.") or (" greift " in l and l.begins_with(h.board[0].name) if not h.board.is_empty() else false)) - "resonance": - return h.board.any(func(c): return c.resonance > 0) - "combo": - return game.log.any(func(l): return l.begins_with("Feldkombo")) - "spell": - return game.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.") - return false - - func tutorial_next() -> void: - if tutorial_step < TUTORIAL_STEPS.size() - 1: - tutorial_step += 1 + if guide != null and guide.next(): app.sfx.play("click") refresh() -## Hebt hervor, was der aktuelle Schritt verlangt. +func _tutorial_refresh() -> void: + guide.advance() + tutorial_text.text = guide.text() + b_tut_next.visible = not guide.finished() + + 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: + for r in guide.highlight_rects(mine): var hl := Highlight.new() hl.position = r.position - Vector2(4, 4) hl.size = r.size + Vector2(8, 8) @@ -896,13 +840,6 @@ func _tutorial_highlights(mine: bool) -> void: 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 - 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: @@ -1053,6 +990,7 @@ func refresh() -> void: if tutorial: _tutorial_refresh() _tutorial_highlights(mine) + pad.draw_cursor() Style.clear(zoom_layer) if zoom_player != null and zoom_player.alive(): @@ -1076,7 +1014,7 @@ func refresh() -> void: overlay.visible = true confirm.visible = false handover.visible = false - mulligan_box.visible = false + mulligan.visible = false func build_zoom(valid: Array) -> void: diff --git a/scripts/ui/battle_tutorial.gd b/scripts/ui/battle_tutorial.gd new file mode 100644 index 0000000..36fb326 --- /dev/null +++ b/scripts/ui/battle_tutorial.gd @@ -0,0 +1,144 @@ +class_name BattleTutorial +extends RefCounted +## Geführte Partien: Schritte, Bedingungen und Hervorhebungen (ausgelagert aus dem Kampfbildschirm). + +const STEPS1 := [ + ["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.", + "played_creature"], + ["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"], + ["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"], + ["Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"], + ["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an 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!", ""], +] +const STEPS2 := [ + ["Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.", "mulligan"], + ["Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.", "trap_set"], + ["Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.", "trap_fired"], + ["Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.", "combo_dampf"], + ["Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!", ""], +] + +var screen # BattleScreen +var part := 1 +var step := 0 + + +func _init(s, p: int = 1) -> void: + screen = s + part = p + + +func steps() -> Array: + return STEPS1 if part == 1 else STEPS2 + + +func stage_index() -> int: + return 0 if part == 1 else 1 + + +## Teil 2 bekommt die Lehrkarten auf die Hand. +func inject_cards(hand: Array) -> void: + if part == 2: + hand.append(Cards.by_id("w_falle")) + hand.append(Cards.by_id("w_reiter")) + + +func condition(kind: String) -> bool: + var g: Game = screen.game + var h: Game.Player = g.human + match kind: + "played_creature": + return g.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0 + "turn_passed": + return h.turns >= 2 + "attacked": + return g.log.any(func(l): return l.begins_with(h.board[0].name + " greift") if not h.board.is_empty() else false) \ + or g.log.any(func(l): return l.ends_with("greift Fenn an.")) + "resonance": + return h.board.any(func(c): return c.resonance > 0) + "combo": + return g.log.any(func(l): return l.begins_with("Feldkombo")) + "spell": + return g.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.") + "mulligan": + return h.mulligan_done + "trap_set": + return g.log.any(func(l): return l == "Du legt eine Falle.") + "trap_fired": + return g.log.any(func(l): return l.begins_with("Falle von Du")) + "combo_dampf": + return g.log.any(func(l): return l.begins_with("Feldkombo Dampf")) + return false + + +func advance() -> void: + while step < steps().size() - 1 and condition(steps()[step][1]): + step += 1 + + +func next() -> bool: + if step < steps().size() - 1: + step += 1 + return true + return false + + +func text() -> String: + return "%d/%d %s" % [step + 1, steps().size(), steps()[step][0]] + + +func finished() -> bool: + return step >= steps().size() - 1 + + +## Rechtecke, die der aktuelle Schritt hervorhebt. +func highlight_rects(mine: bool) -> Array[Rect2]: + var rects: Array[Rect2] = [] + if not mine or screen.any_overlay(): + return rects + var g: Game = screen.game + var h: Game.Player = screen.perspective + match steps()[step][1]: + "played_creature": + for v in screen.hand_views: + if v.card.is_creature() and g.can_play(h, v.card)[0]: + rects.append(Rect2(v.position, v.size)) + "turn_passed", "trap_fired": + rects.append(screen.b_end.get_rect()) + "attacked": + for v in screen.own_views: + if v.creature.can_attack(): + rects.append(Rect2(v.slot, v.size)) + if screen.own_views.any(func(v): return v.creature.can_attack()): + for o in screen.others(): + if o.alive() and screen.hero_panels.has(o): + rects.append(screen.hero_panels[o].get_rect()) + "resonance": + for v in screen.hand_views: + var c: Cards.Card = v.card + if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return e in c.elements)): + rects.append(Rect2(v.position, v.size)) + "combo": + for v in screen.hand_views: + var c: Cards.Card = v.card + if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return not (e in c.elements))): + rects.append(Rect2(v.position, v.size)) + "spell": + for v in screen.hand_views: + if v.card.kind == "Zauber" and g.can_play(h, v.card)[0]: + rects.append(Rect2(v.position, v.size)) + "trap_set": + for v in screen.hand_views: + if v.card.is_trap() and g.can_play(h, v.card)[0]: + rects.append(Rect2(v.position, v.size)) + "combo_dampf": + var has_fire: bool = h.board.any(func(b): return "Feuer" in b.card.elements) + for v in screen.hand_views: + var c: Cards.Card = v.card + if not c.is_creature() or not g.can_play(h, c)[0]: + continue + if (has_fire and "Wasser" in c.elements) or (not has_fire and "Feuer" in c.elements): + rects.append(Rect2(v.position, v.size)) + return rects diff --git a/scripts/ui/battle_tutorial.gd.uid b/scripts/ui/battle_tutorial.gd.uid new file mode 100644 index 0000000..8d5a126 --- /dev/null +++ b/scripts/ui/battle_tutorial.gd.uid @@ -0,0 +1 @@ +uid://cg78afyod2873 diff --git a/scripts/ui/deck_screen.gd b/scripts/ui/deck_screen.gd index b2c010a..fc52c2d 100644 --- a/scripts/ui/deck_screen.gd +++ b/scripts/ui/deck_screen.gd @@ -27,6 +27,7 @@ var slot_buttons: Array = [] var b_activate: Button var b_autofill: Button var name_box: LineEdit +var confirm: Control class DeckRow extends Control: @@ -151,7 +152,21 @@ func _ready() -> void: msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24)) Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c")) Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck) - Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new())) + Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), leave) + confirm = Control.new() + confirm.set_anchors_preset(Control.PRESET_FULL_RECT) + confirm.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 + confirm.add_child(dark) + Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.GOLD, 2) + Style.label(confirm, "Ungespeicherte Änderungen", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true) + Style.label(confirm, "Das Deck wurde geändert, aber nicht gespeichert.", 16, Style.MUTED, Rect2(340, 360, 600, 24), "center") + Style.button(confirm, "Verwerfen", Rect2(380, 420, 240, 46), func(): app.show_screen(MenuScreen.new()), Color("5a2828")) + Style.button(confirm, "Weiter bearbeiten", Rect2(660, 420, 240, 46), func(): confirm.visible = false) + add_child(confirm) preview = CardView.new(Cards.all()[0], Vector2(200, 280)) preview.mouse_filter = Control.MOUSE_FILTER_IGNORE preview.visible = false @@ -159,9 +174,23 @@ func _ready() -> void: refresh() +func dirty() -> bool: + return deck != app.profile.decks[slot].cards + + +func leave() -> void: + if dirty(): + confirm.visible = true + else: + app.show_screen(MenuScreen.new()) + + func _input(event: InputEvent) -> void: if event is 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()) + if confirm.visible: + confirm.visible = false + else: + leave() func _process(_delta: float) -> void: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 09ffe48..4a0f454 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -23,10 +23,24 @@ func _ready() -> void: get_tree().root.add_child.call_deferred(net) profile = Profile.load_or_new(save_path) profile.refresh_daily() + apply_settings() + show_screen(MenuScreen.new()) + + +func apply_settings() -> void: sfx.set_volume(profile.settings.volume) sfx.set_music_volume(profile.settings.music) set_language(profile.settings.language) - show_screen(MenuScreen.new()) + Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0 + theme = Style.make_theme() + + +## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen. +func reset_profile() -> void: + profile = Profile.starter() + profile.refresh_daily() + save() + apply_settings() func set_language(code: String) -> void: diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd index a297adf..100d9a0 100644 --- a/scripts/ui/menu_screen.gd +++ b/scripts/ui/menu_screen.gd @@ -35,8 +35,10 @@ func _ready() -> void: col.add_child(center) var sz := Vector2(270, 44) var p0: Profile = app.profile - b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()), - Color("5a4628") if not p0.tutorial_done else null) + var part2: bool = p0.tutorial_done and not p0.tutorial2_done + b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz, + func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)), + Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null) b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c")) b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c")) b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c")) @@ -82,6 +84,9 @@ func refresh() -> void: if ok[0] and not p.tutorial_done: warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos." warn_label.add_theme_color_override("font_color", Style.GOLD) + elif ok[0] and not p.tutorial2_done: + warn_label.text = "Anleitung 2 zeigt Mulligan, Fallen und die Feldkombo Dampf." + warn_label.add_theme_color_override("font_color", Style.GOLD) var notes := p.take_notices() notice_label.text = " · ".join(notes) if not notes.is_empty(): diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index 2835244..e4069ce 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -9,6 +9,8 @@ var diff_buttons := {} var previews := {} # n -> Array von Game.Player var preview_labels := {} var b_tip: Button +var b_reroll: Button +var rerolls_left := 3 func _ready() -> void: @@ -38,7 +40,7 @@ func _ready() -> void: Style.spacer(col, 4) var br := Style.row(col, 16) br.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(br, "Neu würfeln", Vector2(150, 40), func(): refresh(true)) + b_reroll = Style.cbutton(br, "Neu würfeln", Vector2(190, 40), reroll) Style.cbutton(br, "Zurück", Vector2(150, 40), func(): app.show_screen(MenuScreen.new())) refresh(true) @@ -55,6 +57,14 @@ func take_tip() -> void: set_difficulty(tip) +## Höchstens dreimal neu würfeln, sonst ließen sich schwache Gegner erwürfeln. +func reroll() -> void: + if rerolls_left <= 0: + return + rerolls_left -= 1 + refresh(true) + + func start(n: int) -> void: app.show_screen(BattleScreen.against(previews[n])) @@ -82,6 +92,8 @@ func refresh(reroll: bool = false) -> void: for i in n: opps.append(Game.random_opponent(r, i + 1, Game.START_LIFE, p.target_rating(), str(d.tier))) previews[n] = opps + b_reroll.text = tr("Neu würfeln (%d)") % rerolls_left + b_reroll.disabled = rerolls_left <= 0 for n in previews: var parts: Array[String] = [] for o in previews[n]: diff --git a/scripts/ui/settings_screen.gd b/scripts/ui/settings_screen.gd index 05556d7..55d50a8 100644 --- a/scripts/ui/settings_screen.gd +++ b/scripts/ui/settings_screen.gd @@ -7,6 +7,8 @@ var music: HSlider var speed: HSlider var language: OptionButton var music_label: Label +var big_font: CheckButton +var confirm: Control var anim: CheckButton var volume_label: Label var speed_label: Label @@ -58,6 +60,12 @@ func _ready() -> void: anim.toggled.connect(_on_anim) grid.add_child(anim) Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) + Style.clabel(grid, "Große Schrift", 20, Style.TEXT, "left", false, 200) + big_font = CheckButton.new() + big_font.button_pressed = bool(s.get("big_font", false)) + big_font.toggled.connect(_on_big_font) + grid.add_child(big_font) + Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200) language = OptionButton.new() language.custom_minimum_size = Vector2(280, 36) @@ -69,13 +77,40 @@ func _ready() -> void: Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) Style.spacer(col, 20) Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED) - Style.spacer(col, 60) - var r := Style.row(col) + Style.spacer(col, 30) + var r := Style.row(col, 16) r.alignment = BoxContainer.ALIGNMENT_CENTER + Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828")) Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) + confirm = Control.new() + confirm.set_anchors_preset(Control.PRESET_FULL_RECT) + confirm.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 + confirm.add_child(dark) + Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.RED, 2) + Style.label(confirm, "Profil wirklich zurücksetzen?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true) + Style.label(confirm, "Sammlung, Decks, Münzen und Fortschritt gehen verloren.", 16, Style.MUTED, Rect2(340, 360, 600, 24), "center") + Style.button(confirm, "Ja, zurücksetzen", Rect2(380, 420, 240, 46), _reset, Color("5a2828")) + Style.button(confirm, "Abbrechen", Rect2(660, 420, 240, 46), func(): confirm.visible = false) + add_child(confirm) _labels() +func _reset() -> void: + app.reset_profile() + app.show_screen(MenuScreen.new()) + + +func _on_big_font(on: bool) -> void: + app.profile.settings.big_font = on + app.save() + app.apply_settings() + app.show_screen(SettingsScreen.new()) + + func _labels() -> void: volume_label.text = "%d %%" % int(round(volume.value * 100)) music_label.text = "%d %%" % int(round(music.value * 100)) diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index a556beb..b6c2e39 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -2,6 +2,11 @@ class_name Style ## Farben, Schriften, Theme und Zeichenhilfen. const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf") +static var font_scale := 1.0 # Einstellung „Große Schrift“ + + +static func fs(size: int) -> int: + return int(round(size * font_scale)) const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf") const BG := Color("1a1c24") @@ -38,7 +43,7 @@ static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0 static func make_theme() -> Theme: var t := Theme.new() t.default_font = FONT - t.default_font_size = 16 + t.default_font_size = fs(16) t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2)) t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2)) t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2)) @@ -47,7 +52,7 @@ static func make_theme() -> Theme: for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]: t.set_color(name, "Button", TEXT) t.set_color("font_disabled_color", "Button", MUTED) - t.set_font_size("font_size", "Button", 18) + t.set_font_size("font_size", "Button", fs(18)) t.set_color("font_color", "Label", TEXT) t.set_stylebox("panel", "Panel", box(PANEL, 8)) t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new()) @@ -84,7 +89,7 @@ static func label(parent: Node, text: String, size: int, color: Color, rect: Rec l.text = text l.position = rect.position l.size = rect.size - l.add_theme_font_size_override("font_size", size) + l.add_theme_font_size_override("font_size", fs(size)) l.add_theme_color_override("font_color", color) if bold: l.add_theme_font_override("font", FONT_BOLD) @@ -168,7 +173,8 @@ static func clear(node: Node) -> void: # --- Zeichenhilfen für _draw() --------------------------------------------------- -static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void: +static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void: + var size := fs(size_in) var font: Font = FONT_BOLD if bold else FONT var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size) var x := pos.x diff --git a/tests/test_runner.gd b/tests/test_runner.gd index bc7e8ee..6e3db75 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -110,12 +110,13 @@ func _initialize() -> void: test_starter_gegen_passende_gegner() test_kurve_statistik_vorschlag_verbesserung() test_wasser_antworten() + test_ki_zeitbudget() print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) quit(1 if failed > 0 else 0) func test_kartenpool() -> void: - check(Cards.all().size() == 109, "109 Karten") + check(Cards.all().size() == 110, "110 Karten") for key in Cards.COMBOS: var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key) check(found, "Kombo vorhanden: " + key) @@ -1282,8 +1283,18 @@ func test_kurve_statistik_vorschlag_verbesserung() -> void: var mono := starter check(p.suggest_difficulty() == "", "kein Vorschlag am Anfang") for i in 3: - p.record_battle(true, 1, mono) - check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei Siege: Schwer vorschlagen") + p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 4) + check(p.suggest_difficulty() == "", "drei knappe Siege: noch kein Vorschlag") + p.records.streak = 0 + p.records.margins = [] + for i in 3: + p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 20) + check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei deutliche Siege: Schwer vorschlagen") + p.records.streak = 0 + p.records.margins = [] + for i in 5: + p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 2) + check(p.suggest_difficulty() == "schwer" and p.records.margins.size() == 5, "fünf Siege egal wie knapp") p.settings.difficulty = "schwer" p.take_notices() for i in 3: @@ -1319,3 +1330,27 @@ func test_wasser_antworten() -> void: g.play_card(h, C("w_strudel"), t) check(o.board.is_empty() and C("e_troll") in o.hand and h.hand.size() == hand, "Strudel: zurück auf die Hand, eine Karte gezogen") check(C("w_ebbe").rarity == "gewöhnlich" and Profile.starter().collection.has("w_ebbe"), "Ebbe in der Startsammlung") + + +func test_ki_zeitbudget() -> void: + var r := RandomNumberGenerator.new() + r.seed = 12 + var g := Game.new_battle(Profile.starter().deck_cards(), 5, r) + var h: Game.Player = g.players[0] + h.mulligan_done = true + h.energy = 10 + h.max_energy = 10 + for i in 8: + h.hand.append(Cards.by_id("f_funke")) + for p in g.players: + for i in 3: + put(p, "e_troll") + AI.time_budget_ms = 1 + var t0 := Time.get_ticks_msec() + var act := AI.choose_action(g, h) + var ms := Time.get_ticks_msec() - t0 + AI.time_budget_ms = 150 + check(act.type in ["play", "attack", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms) + check(Cards.by_id("wl_gischt").keywords == ["Flink"] and Cards.by_id("wl_gischt").combo() == "Sturm", "Gischtläufer für Sturm") + var v := Profile.from_dict(JSON.parse_string(JSON.stringify(Profile.starter().to_dict()))) + check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern") diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 0a18d76..a9e5aa0 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -74,6 +74,14 @@ func key(code: int) -> void: await frames(2) +func pad(button: int) -> void: + var ev := InputEventJoypadButton.new() + ev.button_index = button + ev.pressed = true + get_viewport().push_input(ev) + await frames(2) + + ## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist. func clear_mulligan(scr) -> void: if scr.mulligan_box.visible: @@ -155,9 +163,10 @@ func _ready() -> void: await frames(3) var fast := OS.get_environment("DINGER_FAST") != "" var suite := [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, - test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_herausforderung] + test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung] if not fast: suite += [test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen] + suite.append(test_profil_zuruecksetzen) for t in suite: await ensure_menu() await t.call() @@ -290,7 +299,14 @@ func test_deckbau() -> void: await click(find_button("Auto-Ausfüllen")) check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar") await shot("25_deck_slots") + await click(scr.deck_list.get_children()[0]) # ungespeicherte Änderung: eine Karte entfernt + check(scr.dirty(), "Deck geändert") await click(find_button("Zurück")) + check(scr.confirm.visible and screen() == scr, "Warnung bei ungespeicherten Änderungen") + await click(find_button("Weiter bearbeiten")) + check(not scr.confirm.visible, "weiter bearbeiten") + await click(find_button("Zurück")) + await click(find_button("Verwerfen")) check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen") @@ -360,6 +376,9 @@ func test_gegnerauswahl() -> void: check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet") check("Deckstärke" in sel.rating_label.text, "Deckstärke angezeigt") check(sel.preview_labels[3].text.count("(") == 3 and sel.previews[3].size() == 3, "Vorschau zeigt drei Gegner mit Stärke") + for i in 3: + await click(sel.b_reroll) + check(sel.rerolls_left == 0 and sel.b_reroll.disabled, "höchstens dreimal neu würfeln") await click(find_button("Schwer")) check(main.profile.settings.difficulty == "schwer" and Profile.load_or_new(SAVE).settings.difficulty == "schwer", "Schwer gewählt und gespeichert") var previewed: Array = sel.previews[1].map(func(o): return o.title) @@ -382,11 +401,13 @@ func test_gegnerauswahl() -> void: await click(find_button("Einzelspieler")) await click(find_button("Normal")) main.profile.records.streak = 3 + main.profile.records.margins = [20, 22, 18] screen().refresh() - check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei Siegen") + check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei deutlichen Siegen") await click(find_button("Übernehmen")) check(main.profile.settings.difficulty == "schwer", "Vorschlag übernommen") main.profile.records.streak = 0 + main.profile.records.margins = [] await click(find_button("Normal")) await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") @@ -434,7 +455,15 @@ func test_einstellungen() -> void: check(main.profile.settings.animations == false, "Animationen abschaltbar") check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte") scr.anim.button_pressed = true + scr.big_font.button_pressed = true + await frames(2) + check(main.profile.settings.big_font and Style.font_scale > 1.0 and screen() is SettingsScreen, "große Schrift aktiv") + screen().big_font.button_pressed = false + await frames(2) + check(is_equal_approx(Style.font_scale, 1.0), "normale Schrift") + var scr_b = screen() await shot("13_einstellungen") + scr = scr_b await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") @@ -484,6 +513,45 @@ func test_werkstatt() -> void: check(screen() is MenuScreen, "zurück im Menü") +func test_anleitung2() -> void: + check(main.profile.tutorial_done and not main.profile.tutorial2_done, "Teil 1 erledigt, Teil 2 offen") + check(find_button("Anleitung 2: Fallen") != null, "Menü bietet Teil 2 an") + await click(find_button("Anleitung 2: Fallen")) + var scr = screen() + check(scr is BattleScreen and scr.tutorial and scr.tutorial_part == 2, "Anleitung 2 gestartet") + scr.ai_delay = 0.0 + var g: Game = scr.game + check(g.opponents[0].name == "Mara" and g.human.hand.any(func(c): return c.id == "w_falle") and g.human.hand.any(func(c): return c.id == "w_reiter"), "Mara, Frostfalle und Wellenreiter auf der Hand") + await frames(2) + check(scr.mulligan_box.visible and scr.tutorial_step == 0, "Schritt 1: Mulligan-Maske") + await click(scr.b_keep) + check(scr.tutorial_step == 1 and "Frostfalle" in scr.tutorial_text.text, "Schritt 2: Falle legen") + await shot("29_anleitung2") + # Zug 1 hat nur 1 Energie: Zug beenden, dann Falle legen + await key(KEY_SPACE) + await wait_for_human(scr) + check(scr.highlight_layer.get_child_count() >= 1, "Falle hervorgehoben") + var idx: int = g.human.hand.map(func(c): return c.id).find("w_falle") + check(idx >= 0 and idx < 9, "Falle auf der Hand") + await key(KEY_1 + idx) + check(g.human.traps.size() == 1 and scr.tutorial_step == 2, "Falle gelegt, Schritt 3") + await give_up() + check(main.profile.tutorial2_done and find_button("Anleitung") != null, "Teil 2 erledigt") + + +func test_profil_zuruecksetzen() -> void: + await click(find_button("Einstellungen")) + await click(find_button("Profil zurücksetzen")) + var scr = screen() + check(scr.confirm.visible, "Rückfrage") + await click(find_button("Abbrechen")) + check(not scr.confirm.visible, "abgebrochen") + await click(find_button("Profil zurücksetzen")) + await click(find_button("Ja, zurücksetzen")) + check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and not main.profile.tutorial_done, "Profil zurückgesetzt") + check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert") + + func test_herausforderung() -> void: await click(find_button("Herausforderung")) var scr = screen() @@ -602,6 +670,20 @@ func test_anleitung() -> void: await key(KEY_1 + idx) check(g.human.board.size() == 1, "erneut gespielt") await shot("22_anleitung") + # Gamepad: Zeiger bewegen, Karte spielen + await pad(JOY_BUTTON_DPAD_RIGHT) + check(scr.gp_zone == "hand" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweiter Handkarte") + var pad_idx := -1 + for i in g.human.hand.size(): + if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]: + pad_idx = i + break + if pad_idx >= 0: + while scr.gp_index != pad_idx: + await pad(JOY_BUTTON_DPAD_RIGHT) + var board_before: int = g.human.board.size() + await pad(JOY_BUTTON_A) + check(g.human.board.size() == board_before + 1, "Gamepad A spielt das Wesen") scr.ai_delay = 0.75 await key(KEY_SPACE) check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") @@ -639,9 +721,16 @@ func test_netzwerk() -> void: var pid := OS.create_process(OS.get_executable_path(), args) check(pid > 0, "Bot-Prozess gestartet") var wait := 0 - while main.net.names.size() < 2 and wait < 900: + while main.net.names.size() < 2 and wait < 600: await frames(1) wait += 1 + if main.net.names.size() < 2: + print("HINWEIS: Bot kam nicht, zweiter Versuch") + OS.kill(pid) + pid = OS.create_process(OS.get_executable_path(), args) + while main.net.names.size() < 2 and wait < 1500: + await frames(1) + wait += 1 check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path()) await shot("23_lobby") await click(find_button("Start")) diff --git a/translations/ui.csv b/translations/ui.csv index cb35698..8c9d183 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -107,3 +107,10 @@ Tipp: %s,Tip: %s Übernehmen,Apply Neu würfeln,Reroll Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s) +Anleitung 2: Fallen,Tutorial 2: Traps +Neu würfeln (%d),Reroll (%d) +Profil zurücksetzen,Reset profile +Große Schrift,Large text +Verwerfen,Discard +Weiter bearbeiten,Keep editing +Ungespeicherte Änderungen,Unsaved changes diff --git a/translations/ui.en.translation b/translations/ui.en.translation index d847f76..8e15741 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ