diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd4ab19..33ce9fa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,7 +54,7 @@ jobs: sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1 - name: Import run: ./godot --headless --path . --import || true - - name: Fenster-Test (voll, mit Netzwerk und Hotseat) + - name: Fenster-Test (voll) run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true - name: Ergebnis run: grep -q " 0 fehlgeschlagen" fenster.log diff --git a/README.md b/README.md index a2be3a5..da6b375 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ Der Spielstand liegt unter `user://spielstand.json` - Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus - Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht) - Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern, Erfolge, Tagesaufgabe -- Lokal zu mehreren: bis zu 4 Menschen am selben Rechner plus KI - Booster, fünf Deck-Slots, Werkstatt (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen) - Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`) - Anleitung (geführte erste Partie), Mulligan im ersten Zug, Tastatur (Leertaste, 1 bis 9, Esc, Enter) @@ -66,7 +65,7 @@ Der Spielstand liegt unter `user://spielstand.json` `tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`: Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test und Archetypen-Messung nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft). -`DINGER_FAST=1` lässt den Fenster-Test ohne Hotseat, Netzwerk und lange Kämpfe laufen (etwa ein Drittel der Zeit). +`DINGER_FAST=1` lässt den Fenster-Test ohne die langen Kämpfe laufen (etwa ein Drittel der Zeit). Statistiken aus Spieltests lassen sich unter Erfolge als CSV exportieren. ``` @@ -76,7 +75,8 @@ godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen K godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH) DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots -DINGER_ONLY=test_host_wechsel,test_zuschauer godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests +DINGER_ONLY=test_deckbau godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests +DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse) N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern @@ -94,13 +94,13 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe ## Aufbau - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` -- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Hotseat +- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln - `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen - `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) - `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen - `scripts/campaign.gd` – Kampagnenstufen -- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte, Anleitung, Mulligan, Gamepad), Erfolge, Einstellungen, Regeln, Ton +- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, 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/anforderungen.md b/anforderungen.md index c8c4d68..9252f92 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -6,7 +6,7 @@ - Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln - Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl - Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt -- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich, auch als Zuschauer +- Im lokalen Netz zu mehreren spielbar, auch als Zuschauer - Ein unterbrochener Kampf lässt sich später fortsetzen - Geführte erste Partie als Einstieg - Wöchentliche Herausforderungen und Bestenliste als Langzeitziel diff --git a/scripts/game.gd b/scripts/game.gd index 11050c3..69b607d 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -156,7 +156,7 @@ class Player extends RefCounted: var human: Player # der (erste) menschliche Spieler -var humans: Array = [] # alle menschlichen Spieler (Hotseat: mehrere) +var humans: Array = [] # alle menschlichen Spieler (im Netz mehrere) var opponents: Array = [] # alle außer dem ersten Menschen var players: Array = [] # in Zugreihenfolge var winner_player: Player = null @@ -746,19 +746,3 @@ static func new_battle_with(deck: Array, opps: Array, r: RandomNumberGenerator = p.max_life = 35 g.start(fixed_order) return g - - -## Hotseat: mehrere Menschen am selben Rechner, dazu KI-Gegner. -static func new_hotseat(human_decks: Array, n_ai: int, r: RandomNumberGenerator = null) -> Game: - assert(human_decks.size() >= 1 and human_decks.size() + n_ai <= MAX_OPPONENTS + 1, "höchstens 6 am Tisch") - if r == null: - r = make_rng() - var first := Player.new("Spieler 1", human_decks[0], true, START_LIFE) - var others := [] - for i in range(1, human_decks.size()): - others.append(Player.new("Spieler %d" % (i + 1), human_decks[i], true, START_LIFE)) - for i in n_ai: - others.append(random_opponent(r, i + 1)) - var g := Game.new(first, others, r) - g.start() - return g diff --git a/scripts/ui/battle_gamepad.gd b/scripts/ui/battle_gamepad.gd index ce54eef..3ab9f58 100644 --- a/scripts/ui/battle_gamepad.gd +++ b/scripts/ui/battle_gamepad.gd @@ -64,9 +64,6 @@ func move(dir: int) -> void: func confirm() -> void: - if screen.handover.visible: - screen.take_over() - return if screen.mulligan.visible: screen.do_mulligan([]) return diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 7ec7404..36a8621 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -1,14 +1,12 @@ class_name BattleScreen extends Control ## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx. -## Modi: Einzelspieler, Kampagne, Anleitung, Hotseat (Perspektivwechsel), Netzwerk (Host oder Mitspieler). +## Modi: Einzelspieler, Kampagne, Anleitung, Netzwerk (Host, Mitspieler oder Zuschauer). var app var n_opp: int var stage := -1 # Kampagnenstufe oder -1 var tutorial := false -var hotseat_decks: Array = [] -var hotseat_ai := 0 var net_play := false # Netzwerkspiel (auch als Zuschauer) var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1 var net: Net @@ -41,7 +39,6 @@ var gp_index: int: get: return pad.index var challenge := {} # wöchentliche Herausforderung oder leer -var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner var skip_ai := false # Gegnerzüge im Schnelldurchlauf var smart_ai := true # Vorausschau-KI (Leicht: einfache KI) var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion @@ -83,8 +80,6 @@ var reward_label: Label var notice_label: Label var confirm: Control var b_save_leave: Button -var handover: Control -var handover_label: Label var mulligan: BattleMulligan var mulligan_box: Control: get: @@ -129,13 +124,6 @@ func _init(n: int = 2, campaign_stage: int = -1) -> void: stage = campaign_stage -static func hotseat(decks: Array, n_ai: int) -> BattleScreen: - var b := BattleScreen.new(0, -1) - b.hotseat_decks = decks - b.hotseat_ai = n_ai - return b - - static func online(seat: int) -> BattleScreen: var b := BattleScreen.new(0, -1) b.net_play = true @@ -150,12 +138,6 @@ static func guided(part: int = 1) -> BattleScreen: return b -static func against(opps: Array) -> BattleScreen: - var b := BattleScreen.new(opps.size(), -1) - b.preset_opponents = opps - return b - - ## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle). static func resume(data: Dictionary) -> BattleScreen: var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1))) @@ -171,10 +153,6 @@ static func weekly(ch: Dictionary) -> BattleScreen: return b -func is_hotseat() -> bool: - return game != null and game.humans.size() > 1 and not net_play - - func is_online() -> bool: return net_play @@ -189,7 +167,7 @@ func is_client() -> bool: func counts_for_profile() -> bool: - return not is_hotseat() and not is_online() + return not is_online() func _ready() -> void: @@ -213,8 +191,6 @@ func _ready() -> void: return # der erste Zustand kommt vom Host elif saved_game != null: game = saved_game - elif not hotseat_decks.is_empty(): - game = Game.new_hotseat(hotseat_decks, hotseat_ai) elif tutorial: guide = BattleTutorial.new(self, tutorial_part) var r := Game.make_rng() @@ -229,8 +205,6 @@ func _ready() -> void: elif stage >= 0: var r := Game.make_rng() game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules()) - elif not preset_opponents.is_empty(): - game = Game.new_battle_with(app.profile.deck_cards(), preset_opponents, null, false, own_rules()) else: game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules()) smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile() @@ -336,12 +310,6 @@ func _build_static() -> void: b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c")) b_save_leave.add_theme_font_size_override("font_size", 14) Style.button(confirm, "Weiterspielen", Rect2(745, 420, 180, 46), func(): confirm.visible = false) - # Übergabe zwischen Menschen (Hotseat) - handover = _overlay() - Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2) - handover_label = Style.label(handover, "", 30, Style.TEXT, Rect2(340, 310, 600, 44), "center", true) - Style.label(handover, "Maus weitergeben, dann weiter. Die Hand des Vorgängers ist verdeckt.", 16, Style.MUTED, Rect2(340, 362, 600, 24), "center") - Style.button(handover, "Weiter", Rect2(540, 420, 200, 50), take_over, Color("285a3c")) func _layer() -> Control: @@ -475,7 +443,7 @@ func my_turn() -> bool: func any_overlay() -> bool: - return confirm.visible or handover.visible or mulligan.visible or overlay.visible + return confirm.visible or mulligan.visible or overlay.visible func _input(event: InputEvent) -> void: @@ -506,9 +474,7 @@ func _input(event: InputEvent) -> void: if b_undo.visible: undo() KEY_ENTER, KEY_KP_ENTER: - if handover.visible: - take_over() - elif mulligan.visible: + if mulligan.visible: do_mulligan([]) elif overlay.visible: leave() @@ -552,12 +518,7 @@ func _process(delta: float) -> void: return var cur := game.current() if cur.is_human: - if cur != perspective and is_hotseat() and not handover.visible: - handover_label.text = "%s ist dran" % cur.name - handover.visible = true - hand_layer.visible = false - trap_layer.visible = false - elif cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible: + if cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible: show_mulligan() return if is_client(): @@ -623,17 +584,6 @@ func _finish() -> void: refresh() -func take_over() -> void: - handover.visible = false - hand_layer.visible = true - trap_layer.visible = true - perspective = game.current() - cancel() - zoom_player = null - build_hero_boxes() - refresh() - - func say(m: String) -> void: msg_t = 2.5 msg_label.text = m @@ -1110,7 +1060,6 @@ func refresh() -> void: reward_label.text = "Gemeinsame Partie, keine Münzen" overlay.visible = true confirm.visible = false - handover.visible = false mulligan.visible = false diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd index 3a64f3f..439b44a 100644 --- a/scripts/ui/campaign_screen.gd +++ b/scripts/ui/campaign_screen.gd @@ -36,10 +36,6 @@ func build() -> void: p.perk = "" if i == 0 else keys[i - 1] app.save()) pr.add_child(perk_box) - var intro := ["Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★).", - "Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.", - "Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks."] - Style.clabel(col, intro[chapter - 1], 14, Style.MUTED) var offset := (chapter - 1) * Campaign.STAGES.size() for k in Campaign.STAGES.size(): var i := offset + k @@ -50,31 +46,27 @@ func build() -> void: 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, 10) + var pad := MarginContainer.new() # Innenabstand, damit Name und Knopf nicht am Rahmen kleben + for side in ["left", "right"]: + pad.add_theme_constant_override("margin_" + side, 14) + panel.add_child(pad) + var r := Style.row(pad, 10) r.custom_minimum_size = Vector2(0, 50) - 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 nm := Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 420) + nm.size_flags_horizontal = Control.SIZE_EXPAND_FILL # schiebt die Punkte und den Knopf nach rechts var dots := Control.new() - dots.custom_minimum_size = Vector2(50, 0) + dots.custom_minimum_size = Vector2(60, 0) dots.mouse_filter = Control.MOUSE_FILTER_IGNORE var elems: Array = st.elements dots.draw.connect(func(): 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 extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) - var table: String = "Duell" if extra == 0 else "%d am Tisch" % (extra + 2) - var perks_text: String = ", ".join(st.perks.map(func(pk): return Game.PERKS[pk])) - var target := Campaign.target_rating(i, p.target_rating()) - Style.clabel(r, "%s · Stärke %.1f · %d Münzen\n★ %s" % [table, target, st.reward, perks_text], 12, Style.MUTED, "left", false, 360) if done: - Style.clabel(r, "abgeschlossen", 13, Style.GREEN, "right", false, 100) Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i)) elif next: - 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, "", 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.total(): diff --git a/scripts/ui/hotseat_screen.gd b/scripts/ui/hotseat_screen.gd deleted file mode 100644 index bf6bb6e..0000000 --- a/scripts/ui/hotseat_screen.gd +++ /dev/null @@ -1,111 +0,0 @@ -class_name HotseatScreen -extends Control -## Lokal zu mehreren am selben Rechner: Spielerzahl, KI-Zahl und Decks wählen. - -var app -var n_humans := 2 -var n_ai := 0 -var deck_choice: Array = [] # je Mensch: Slot-Index oder -1 für Zufallsdeck -var human_buttons: Array = [] -var ai_buttons: Array = [] -var deck_rows: VBoxContainer -var b_start: Button -var hint: Label - - -func _ready() -> void: - var col := Style.column(self, 760, 40, 10) - Style.clabel(col, "Lokal zu mehreren", 36, Style.GOLD, "center", true) - Style.clabel(col, "Alle spielen am selben Rechner und reichen die Maus weiter. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED) - Style.spacer(col, 10) - Style.clabel(col, "Spieler", 20, Style.TEXT, "center", true) - var hr := Style.row(col, 10) - hr.alignment = BoxContainer.ALIGNMENT_CENTER - for n in range(2, 5): - human_buttons.append(Style.cbutton(hr, "%d Spieler" % n, Vector2(150, 44), func(): set_humans(n))) - Style.clabel(col, "KI-Gegner", 20, Style.TEXT, "center", true) - var ar := Style.row(col, 10) - ar.alignment = BoxContainer.ALIGNMENT_CENTER - for n in range(0, 5): - ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 44), func(): set_ai(n))) - Style.spacer(col, 6) - Style.clabel(col, "Decks", 20, Style.TEXT, "center", true) - deck_rows = VBoxContainer.new() - deck_rows.add_theme_constant_override("separation", 6) - col.add_child(deck_rows) - Style.spacer(col, 6) - hint = Style.clabel(col, "", 15, Style.ORANGE) - var br := Style.row(col, 20) - br.alignment = BoxContainer.ALIGNMENT_CENTER - b_start = Style.cbutton(br, "Start", Vector2(200, 48), start, Color("285a3c")) - Style.cbutton(br, "Zurück", Vector2(160, 48), func(): app.show_screen(MenuScreen.new())) - for i in 4: - deck_choice.append(-1) - var p: Profile = app.profile - if p.deck_valid()[0]: - deck_choice[0] = p.active_deck - refresh() - - -func _input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: - app.show_screen(MenuScreen.new()) - - -func set_humans(n: int) -> void: - n_humans = n - n_ai = mini(n_ai, Game.MAX_OPPONENTS + 1 - n_humans) - refresh() - - -func set_ai(n: int) -> void: - n_ai = n - refresh() - - -func valid_slots() -> Array: - var out := [] - for i in Profile.DECK_SLOTS: - if app.profile.deck_valid(i)[0]: - out.append(i) - return out - - -func refresh() -> void: - var p: Profile = app.profile - for i in human_buttons.size(): - human_buttons[i].disabled = (i + 2) == n_humans - for i in ai_buttons.size(): - ai_buttons[i].disabled = i == n_ai or n_humans + i > Game.MAX_OPPONENTS + 1 - Style.clear(deck_rows) - var slots := valid_slots() - for h in n_humans: - var r := Style.row(deck_rows, 12) - r.alignment = BoxContainer.ALIGNMENT_CENTER - Style.clabel(r, "Spieler %d" % (h + 1), 16, Style.TEXT, "right", true, 140) - var ob := OptionButton.new() - ob.custom_minimum_size = Vector2(340, 36) - ob.add_item("Zufallsdeck", -1) - for slot in slots: - var elems := Game.deck_elements(p.deck_cards(slot)) - ob.add_item("%s (%s)" % [p.deck_name(slot), ", ".join(elems)], slot) - ob.select(ob.get_item_index(deck_choice[h]) if deck_choice[h] >= 0 and slots.has(deck_choice[h]) else 0) - var idx := h - ob.item_selected.connect(func(i: int): deck_choice[idx] = ob.get_item_id(i)) - r.add_child(ob) - hint.text = "%d am Tisch" % (n_humans + n_ai) - - -func start() -> void: - var p: Profile = app.profile - var r := Game.make_rng() - var decks := [] - for h in n_humans: - var slot: int = deck_choice[h] - if slot >= 0 and p.deck_valid(slot)[0]: - decks.append(p.deck_cards(slot)) - else: - var pool: Array = Cards.ELEMENTS.duplicate() - Game.shuffle(pool, r) - decks.append(Game.build_opponent_deck(r, [pool[0], pool[1]])) - app.show_screen(BattleScreen.hotseat(decks, n_ai)) diff --git a/scripts/ui/hotseat_screen.gd.uid b/scripts/ui/hotseat_screen.gd.uid deleted file mode 100644 index 6b9ec77..0000000 --- a/scripts/ui/hotseat_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://crlne4y0uiygl diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd index f3eaf90..78d2210 100644 --- a/scripts/ui/menu_screen.gd +++ b/scripts/ui/menu_screen.gd @@ -4,7 +4,6 @@ extends Control var app var b_single: Button var b_campaign: Button -var b_hotseat: Button var b_online: Button var b_tutorial: Button var b_challenge: Button @@ -44,7 +43,6 @@ func _ready() -> void: Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null) b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c")) b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c")) - b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c")) b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c")) b_challenge = Style.cbutton(grid, "Herausforderung", sz, func(): app.show_screen(ChallengeScreen.new()), Color("285a3c")) Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new())) diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index a9e59af..0ae4553 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -1,109 +1,79 @@ class_name OpponentSelectScreen extends Control -## Einzelspieler: Schwierigkeit, Gegnerzahl und Vorschau der Gegner (Element, Stärke). +## Einzelspieler: Schwierigkeit wählen und die Gegnerzahl schieben. Die Gegnerdecks +## werden erst beim Start zufällig gezogen (siehe BattleScreen und DeckGen). var app var rating_label: Label -var tip_label: Label var diff_buttons := {} -var previews := {} # n -> Array von Game.Player -var preview_labels := {} -var b_tip: Button -var b_reroll: Button -var rerolls_left := 3 +var count_slider: HSlider +var count_label: Label +var info_label: Label func _ready() -> void: - var col := Style.column(self, 1000, 30, 10) + var col := Style.column(self, 1000, 60, 14) Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) - Style.clabel(col, "Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben. Du gewinnst, wenn alle anderen besiegt sind." % Game.START_LIFE, 15, Style.MUTED) + Style.spacer(col, 10) Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true) var dr := Style.row(col, 10) dr.alignment = BoxContainer.ALIGNMENT_CENTER for key in ["leicht", "normal", "schwer"]: var k: String = key diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k)) - rating_label = Style.clabel(col, "", 14, Style.MUTED) - var tr_row := Style.row(col, 10) - tr_row.alignment = BoxContainer.ALIGNMENT_CENTER - tip_label = Style.clabel(tr_row, "", 14, Style.GOLD, "right", false, 520) - b_tip = Style.cbutton(tr_row, "Übernehmen", Vector2(140, 32), take_tip) - Style.spacer(col, 2) - for i in range(1, Game.MAX_OPPONENTS + 1): - var n := i - var r := Style.row(col, 16) - r.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(r, "%d Gegner" % n, Vector2(150, 46), func(): start(n), Color("285a3c")) - var info := (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n] - Style.clabel(r, info, 13, Style.MUTED, "left", false, 150) - preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600) - preview_labels[n].autowrap_mode = TextServer.AUTOWRAP_WORD - preview_labels[n].custom_minimum_size = Vector2(600, 0) - preview_labels[n].size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - Style.spacer(col, 4) + rating_label = Style.clabel(col, "", 15, Style.MUTED) + Style.spacer(col, 24) + count_label = Style.clabel(col, "", 20, Style.TEXT, "center", true) + var sr := Style.row(col, 12) + sr.alignment = BoxContainer.ALIGNMENT_CENTER + Style.clabel(sr, "1", 15, Style.MUTED, "right", false, 20) + count_slider = HSlider.new() + count_slider.min_value = 1 + count_slider.max_value = Game.MAX_OPPONENTS + count_slider.step = 1 + count_slider.value = 1 + count_slider.tick_count = Game.MAX_OPPONENTS + count_slider.ticks_on_borders = true + count_slider.custom_minimum_size = Vector2(420, 32) + count_slider.value_changed.connect(func(_v): refresh()) + sr.add_child(count_slider) + Style.clabel(sr, str(Game.MAX_OPPONENTS), 15, Style.MUTED, "left", false, 20) + info_label = Style.clabel(col, "", 15, Style.MUTED) + Style.spacer(col, 30) var br := Style.row(col, 16) br.alignment = BoxContainer.ALIGNMENT_CENTER - 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) + Style.cbutton(br, "Starten", Vector2(190, 46), start, Color("285a3c")) + Style.cbutton(br, "Zurück", Vector2(150, 46), func(): app.show_screen(MenuScreen.new())) + refresh() func set_difficulty(key: String) -> void: app.profile.settings.difficulty = key app.save() - refresh(true) + refresh() -func take_tip() -> void: - var tip: String = app.profile.suggest_difficulty() - if tip != "": - set_difficulty(tip) +func opponents() -> int: + return int(count_slider.value) -## 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() -> void: + app.show_screen(BattleScreen.new(opponents())) -func start(n: int) -> void: - app.show_screen(BattleScreen.against(previews[n])) - - -## Baut die Gegner für jede Anzahl vor und zeigt Element und Stärke; der Kampf nutzt genau diese. -func refresh(reroll: bool = false) -> void: +func refresh() -> void: var p: Profile = app.profile - var d := p.difficulty() for key in diff_buttons: var b: Button = diff_buttons[key] var active: bool = key == p.settings.difficulty b.add_theme_stylebox_override("normal", Style.box(Color("285a3c") if active else Style.PANEL_LIGHT, 8, Style.GOLD if active else Style.MUTED, 2)) b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Color("285a3c") if active else Style.PANEL_LIGHT), 8, Style.GOLD if active else Style.MUTED, 2)) - var arche: String = tr("schwache Archetypen") if d.tier == "leicht" else (tr("starke Archetypen") if d.tier == "schwer" else tr("alle Archetypen")) - rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s") % [ - p.deck_rating(), p.target_rating(), arche, d.reward, tr("Vorausschau") if d.smart else tr("einfach")] - var tip := p.suggest_difficulty() - tip_label.text = (tr("Tipp: %s") % ((tr("%d Siege in Folge, versuche %s") % [p.records.streak, tr(Profile.DIFFICULTIES[tip].name)]) if p.records.streak >= 3 - else (tr("%d Niederlagen in Folge, versuche %s") % [p.records.loss_streak, tr(Profile.DIFFICULTIES[tip].name)]))) if tip != "" else "" - b_tip.visible = tip != "" - if reroll: - var r := Game.make_rng() - for n in range(1, Game.MAX_OPPONENTS + 1): - var opps := [] - for i in n: - opps.append(Game.random_opponent(r, i + 1, Game.START_LIFE, p.target_rating(), str(d.tier))) - previews[n] = opps - b_reroll.text = tr("Neu würfeln (%d)") % rerolls_left - b_reroll.disabled = rerolls_left <= 0 - for n in previews: - var parts: Array[String] = [] - for o in previews[n]: - parts.append("%s (%.1f)" % [o.title, Game.deck_rating(o.deck)]) - preview_labels[n].text = ", ".join(parts) + rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f") % [p.deck_rating(), p.target_rating()] + var n := opponents() + count_label.text = tr("1 Gegner") if n == 1 else tr("%d Gegner") % n + info_label.text = (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n] func _input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + if event.is_action_pressed("ui_cancel"): app.show_screen(MenuScreen.new()) diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index 399065f..6089cf1 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -300,7 +300,6 @@ static func rules_text() -> String: lines.append("Ein unterbrochener Einzelspieler-Kampf lässt sich im Menü fortsetzen.") lines.append("") lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.") - lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.") lines.append("Netzwerkspiel: Fällt der Host aus, übernimmt ein Mitspieler; Zuschauer sehen die Partie ohne Hände.") lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.") lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine") diff --git a/tests/test_runner.gd b/tests/test_runner.gd index 781ece6..bc5f0e7 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -97,7 +97,6 @@ func _initialize() -> void: test_kampagne() test_fallen() test_ki_sieht_fallen_nicht() - test_hotseat() test_zufallssamen() test_deck_slots_und_werkstatt() test_spielstand_sicherung() @@ -810,34 +809,6 @@ func test_ki_sieht_fallen_nicht() -> void: check(g.clone_for(h).players[hi].traps.size() == 1, "eigene Fallen bleiben in der eigenen Kopie") -func test_hotseat() -> void: - var r := RandomNumberGenerator.new() - r.seed = 3 - var d := Profile.starter().deck_cards() - var g := Game.new_hotseat([d, d], 1, r) - check(g.players.size() == 3 and g.humans.size() == 2, "zwei Menschen und eine KI") - check(g.humans[0].name == "Spieler 1" and g.humans[1].name == "Spieler 2", "Spielernamen") - var ai = g.players.filter(func(p): return not p.is_human)[0] - g.damage(ai, 99) - g.check_end() - check(g.winner == "", "zwei Menschen übrig: weiter") - g.damage(g.humans[1], 99) - g.check_end() - check(g.winner == "human" and g.winner_player == g.humans[0], "Spieler 1 gewinnt") - var g2 := Game.new_hotseat([d, d], 1, r) - for hu in g2.humans: - g2.damage(hu, 99) - g2.check_end() - check(g2.winner == "opponents" and g2.winner_player != null and not g2.winner_player.is_human, "KI gewinnt, wenn alle Menschen tot sind") - var g3 := Game.new_hotseat([d, d, d], 0, r) - check(g3.players.size() == 3 and g3.opponents.size() == 2, "drei Menschen ohne KI") - var guard := 0 - while g3.winner == "" and guard < 300: - AI.take_turn(g3, g3.current(), true) - guard += 1 - check(g3.winner == "human" and g3.winner_player != null, "Hotseat-Partie endet mit Sieger") - - func test_zufallssamen() -> void: Game.test_seed = 77 var a := Game.make_rng().randi() diff --git a/tests/ui_test.gd b/tests/ui_test.gd index a929347..30983ec 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -168,7 +168,11 @@ func _ready() -> void: test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung, test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster] if not fast: - suite += [test_hotseat, test_netzwerk, test_host_wechsel, test_zuschauer, test_kampf.bind(2, 3), test_kampf_abbrechen] + suite += [test_kampf.bind(2, 3), test_kampf_abbrechen] + # Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten. + # Sie laufen nur auf Wunsch: DINGER_NETZ=1 (oder gezielt über DINGER_ONLY). + if OS.get_environment("DINGER_NETZ") != "": + suite += [test_netzwerk, test_host_wechsel, test_zuschauer] suite.append(test_profil_zuruecksetzen) var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer if only != "": @@ -373,25 +377,38 @@ func human_turn(scr) -> void: func start_battle(n_opp: int) -> void: await click(find_button("Einzelspieler")) check(screen() is OpponentSelectScreen, "Gegnerauswahl") - await click(find_button("%d Gegner" % n_opp)) + screen().count_slider.value = n_opp + await frames(1) + await click(find_button("Starten")) func test_gegnerauswahl() -> void: await click(find_button("Einzelspieler")) var sel = screen() check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet") - check("Deckstärke" in sel.rating_label.text, "Deckstärke angezeigt") - check(sel.preview_labels[3].text.count("(") == 3 and sel.previews[3].size() == 3, "Vorschau zeigt drei Gegner mit Stärke") - for i in 3: - await click(sel.b_reroll) - check(sel.rerolls_left == 0 and sel.b_reroll.disabled, "höchstens dreimal neu würfeln") + check("Deckstärke" in sel.rating_label.text and "Gegnerdecks" in sel.rating_label.text + and not ("Archetypen" in sel.rating_label.text or "KI" in sel.rating_label.text), "unter der Schwierigkeit nur die zwei Stärken") + check(find_button("Neu würfeln") == null and find_button("1 Gegner") == null and find_button("Starten") != null, "kein Würfeln und keine Gegnerknöpfe, nur Starten") + check(sel.count_slider.min_value == 1 and sel.count_slider.max_value == Game.MAX_OPPONENTS and sel.count_slider.step == 1, "Gegnerzahl als Schieberegler von 1 bis %d" % Game.MAX_OPPONENTS) + check(sel.opponents() == 1 and sel.count_label.text == "1 Gegner" and "Duell" in sel.info_label.text, "Regler beginnt beim Duell") + var rect: Rect2 = sel.count_slider.get_global_rect() + await click_at(Vector2(rect.end.x - 4, rect.get_center().y)) # ganz rechts anklicken + check(sel.opponents() == Game.MAX_OPPONENTS and sel.count_label.text == "%d Gegner" % Game.MAX_OPPONENTS, "Mausklick am rechten Rand stellt %d Gegner ein" % Game.MAX_OPPONENTS) + sel.count_slider.grab_focus() + await key(KEY_LEFT) + check(sel.opponents() == Game.MAX_OPPONENTS - 1, "Pfeiltaste links nimmt einen Gegner weg") + sel.count_slider.value = 4 + await frames(1) + check(sel.opponents() == 4 and sel.count_label.text == "4 Gegner" and "5 am Tisch" in sel.info_label.text + and str(Profile.REWARD_WIN[4]) in sel.info_label.text, "Regler auf 4: fünf am Tisch mit Belohnung") await click(find_button("Schwer")) check(main.profile.settings.difficulty == "schwer" and Profile.load_or_new(SAVE).settings.difficulty == "schwer", "Schwer gewählt und gespeichert") - var previewed: Array = sel.previews[1].map(func(o): return o.title) await shot("07_gegnerauswahl") - await click(find_button("1 Gegner")) + sel.count_slider.value = 1 + await frames(1) + await click(find_button("Starten")) var hard = screen() - check(hard.game.opponents.map(func(o): return o.title) == previewed, "Kampf nutzt die vorgeschauten Gegner") + check(hard is BattleScreen and hard.game.opponents.size() == 1, "Kampf mit einem Gegner gestartet") var own := Game.deck_rating(main.profile.deck_cards()) var opp: Game.Player = hard.game.opponents[0] var opp_rating := Game.deck_rating(opp.deck + opp.hand) @@ -399,22 +416,13 @@ func test_gegnerauswahl() -> void: await give_up() await click(find_button("Einzelspieler")) await click(find_button("Leicht")) - await click(find_button("1 Gegner")) + await click(find_button("Starten")) var easy = screen() opp = easy.game.opponents[0] check(Game.deck_rating(opp.deck + opp.hand) <= own * 1.0 + 0.15 and not easy.smart_ai, "Leicht: schwächeres Deck, einfache KI") await give_up() await click(find_button("Einzelspieler")) await click(find_button("Normal")) - main.profile.records.streak = 3 - main.profile.records.margins = [20, 22, 18] - screen().refresh() - check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei deutlichen Siegen") - await click(find_button("Übernehmen")) - check(main.profile.settings.difficulty == "schwer", "Vorschlag übernommen") - main.profile.records.streak = 0 - main.profile.records.margins = [] - await click(find_button("Normal")) await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") for n in [1, 5]: @@ -707,40 +715,6 @@ func test_herausforderung() -> void: check(screen() is MenuScreen, "zurück im Menü") -func test_hotseat() -> void: - await click(find_button("Lokal zu mehreren")) - var scr = screen() - check(scr is HotseatScreen, "Hotseat-Auswahl") - await click(find_button("3 Spieler")) - await click(find_button("1 KI")) - check(scr.n_humans == 3 and scr.n_ai == 1, "3 Menschen, 1 KI") - await shot("17_hotseat") - await click(find_button("Start")) - var b = screen() - check(b is BattleScreen and b.is_hotseat() and b.game.players.size() == 4, "Hotseat-Kampf gestartet") - b.ai_delay = 0.0 - var guard := 0 - while not b.handover.visible and b.game.winner == "" and guard < 400: - if b.game.current() == b.perspective: - await clear_mulligan(b) - if b.game.current() == b.perspective: - await click(b.b_end) - await frames(1) - guard += 1 - check(b.handover.visible, "Übergabe erscheint") - var cur: Game.Player = b.game.current() - check(cur.is_human and cur != b.perspective, "anderer Mensch ist dran") - await shot("18_hotseat_uebergabe") - await click(find_button("Weiter")) - check(b.perspective == cur and not b.handover.visible, "Perspektive gewechselt") - check(b.hand_views.size() == cur.hand.size() and b.name_label.text == cur.name, "Hand und Name des neuen Spielers") - check(b.mulligan_box.visible, "neuer Spieler bekommt seine Mulligan-Maske") - await clear_mulligan(b) - check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch") - await shot("19_hotseat_kampf") - await leave_battle() - - func test_sprache() -> void: await click(find_button("Einstellungen")) var scr = screen() @@ -1028,7 +1002,7 @@ func test_zuschauer() -> void: check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.mulligan_box.visible and not b.b_end.visible, "Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0)) await shot("35_zuschauer") - check(not b.handover.visible and not b.is_hotseat(), "Zuschauer bekommt keine Hotseat-Übergabe") + check(not b.b_end.visible and not b.b_undo.visible, "Zuschauer hat keine Zugknöpfe") if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein await leave_battle() else: diff --git a/translations/ui.csv b/translations/ui.csv index 541bf99..8d9a0f0 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -3,7 +3,6 @@ Dinger,Dinger Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all Einzelspieler,Single player Kampagne,Campaign -Lokal zu mehreren,Local multiplayer Netzwerkspiel,Network game Booster öffnen,Open boosters Deck bauen,Build deck @@ -95,20 +94,11 @@ Schwierigkeit,Difficulty Leicht,Easy Normal,Normal Schwer,Hard -"Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s","Your deck strength %.1f · Opponent decks %.1f · %s · Coins x%.2f · AI: %s" -schwache Archetypen,weaker archetypes -starke Archetypen,stronger archetypes -alle Archetypen,all archetypes -Vorausschau,lookahead -einfach,simple Stärke %.1f,Strength %.1f "Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects" -Tipp: %s,Tip: %s -Übernehmen,Apply Neu würfeln,Reroll Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s) Anleitung 2: Fallen,Tutorial 2: Traps -Neu würfeln (%d),Reroll (%d) Profil zurücksetzen,Reset profile Große Schrift,Large text Verwerfen,Discard @@ -126,13 +116,10 @@ Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthän "Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.","End your turn and wait. If Mara attacks you or a creature, the attack fizzles and her creature freezes." Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.,Field combo Steam: a water creature next to a fire creature freezes the strongest enemy creature. Play both in sequence. "Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!",Undo (U) takes back a creature you just played as long as nothing was triggered. Good luck against Mara! -"%d Siege in Folge, versuche %s","%d wins in a row, try %s" -"%d Niederlagen in Folge, versuche %s","%d losses in a row, try %s" Duell,Duel %d am Tisch,%d at the table %d Spieler am Tisch,%d players at the table %d Münzen,%d coins -Stärke %.1f · %d Münzen,Strength %.1f · %d coins Abbrechen,Cancel "Ja, zurücksetzen","Yes, reset" "Speichern und zurück","Save and go back" @@ -217,3 +204,8 @@ Kapitel %d,Chapter %d Zuschauen,Watch Zuschauer,Spectator "Zuschauer: Hände sind verdeckt","Spectator: hands are hidden" +Deine Deckstärke %.1f · Gegnerdecks %.1f,Your deck strength %.1f · Opponent decks %.1f +Starten,Start +1 Gegner,1 opponent +%d Gegner,%d opponents +Übernehmen,Apply diff --git a/translations/ui.en.translation b/translations/ui.en.translation index a7a5135..6994370 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ