diff --git a/README.md b/README.md index 8326f3d..3929585 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,17 @@ Der Spielstand liegt unter `user://spielstand.json` ## Spielsysteme - 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos sind die Namen verschmolzener Wesen -- Karten haben nur Element, Angriff und Leben; die Kosten sind Angriff plus Leben, halbiert und aufgerundet +- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet, mindestens 1 - Mana: 5 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen, Verbessern und Verschmelzen -- Kein Deckbau: beide ziehen vom gemeinsamen Stapel (jede Karte dreimal). Zu Beginn fünf sehen, drei behalten; Ziehen kostet 1 Mana und zeigt drei, eine bleibt; angeboten wird nur, was das Mana dieses Zugs hergibt, der Rest wandert unter den Stapel -- Verbessern: Schild (3) und Doppelschlag (4) lassen sich für Mana auf jede Hand- oder Brettkarte kaufen (Rechtsklick oder Y). Frisch gelegte Wesen greifen sofort an, zu Zugbeginn heilen die eigenen Wesen ganz -- Verdeckt legen: gleich teuer, das Wesen greift nicht an und zählt nicht für die Elementregel; aufdecken ist kostenlos, ein Angriff deckt es auf -- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Holo-Karten: selten farbiger Rahmen, Schein und Edelstein, episch ein wandernder Lichtstreifen, legendär Funkeln, mythisch schillernde Farben; auch auf dem Feld für alle sichtbar, Werte und Text bleiben gleich -- Booster (100 Münzen): fünf gewürfelte Stufen, höhere werten die Karte auf, der Rest wird zu Staub. Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück -- Vier offene Wesen eines Elements auf dem Tisch: dieses Element bekommt +1/+1, das Element, das es schlägt, −1/−1 (Wasser > Feuer > Luft > Erde > Wasser) -- Elementvorteil beim Kampf: der Angreifer macht 1 Schaden mehr im Vorteil und 1 weniger im Nachteil, der Konter bleibt gleich, gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, auch bei der Vier-Elemente-Regel zählt sein bester Wert -- Angriffe treffen erst Wesen; das Leben ist nur ohne Wesen angreifbar. Schaden über das Leben eines Wesens hinaus schlägt auf den Besitzer durch -- Verschmelzen: zwei eigene Wesen werden eins mit dem besseren Angriff und Leben plus 1, Elementen und Eigenschaften beider; kostet 1 Mana, verdoppelt je weiterer Verschmelzung (1, 2, 4, 8); drei oder mehr Elemente ergeben eine Chimäre +- Kein Deckbau: beide ziehen vom gemeinsamen Stapel (jede Karte dreimal). Zu Beginn fünf Karten, Ziehen kostet 1 Mana und bringt eine; es kommt nur, was das Mana dieses Zugs hergibt, der Rest wandert unter den Stapel +- Eigenschaften: Spott (2, muss zuerst angegriffen werden), Flink (2, greift sofort an), Regeneration (3, heilt am Zugende ganz) und Doppelschlag (4) lassen sich für Mana auf jede Hand- oder Brettkarte kaufen (Rechtsklick oder Y). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst +- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an und zählt nicht für die Elementboni; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf +- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Yu-Gi-Oh: Common matt, Rare silberner Name und Rahmen mit Glanzstreifen, Super Rare Holo-Bild, Ultra Rare goldener Name und Rahmen mit Holo-Bild, Ultimate Rare Relief, Holo auf Bild und Rahmen, Funkeln und schillernde Farben; auch auf dem Feld für alle sichtbar, Werte bleiben gleich. Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern +- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (50/25/15/8/2 %, mindestens eine Rare), höhere werten die Karte auf, der Rest wird zu Staub (50/100/200/500/1000). Aufwerten kostet 100/200/500/1000 Staub, eine Stufe abgeben bringt den Zerlegewert zurück +- Elementboni: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); drei offene Wesen eines Elements auf dem Tisch geben dem Element +1/+1 (fällt der Bonus weg, sinkt nur das Höchstleben). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni +- Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): der Angreifer macht 1 Schaden mehr im Vorteil und 1 weniger im Nachteil, der Konter bleibt gleich, gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht +- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar. Nichts schlägt durch +- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde halten, auf dem Brett wie auf der Hand (Gamepad: Y, „Verschmelzen mit …“). Das neue Wesen hat je Wert den besseren plus ein Drittel des schwächeren (aufgerundet), Kosten passend dazu, Elemente und Eigenschaften beider; kostet 1 Mana, verdoppelt je weiterer Verschmelzung (1, 2, 4, 8); drei oder mehr Elemente ergeben eine Chimäre - Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 1 Mana; die Hand fasst 5 Karten, das Feld 5 Wesen - Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach - KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread @@ -70,7 +70,7 @@ 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, Mana, Ziehen, verdeckte Wesen, Elementregel, Kampf, Verbessern, Verschmelzen +- `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verbessern, Verschmelzen - `scripts/deckgen.gd` – gemeinsamer Stapel und Gegner - `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) diff --git a/anforderungen.md b/anforderungen.md index 43252e4..2f8186f 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -12,20 +12,22 @@ ## Sammeln - Jede Karte gehört jedem von Anfang an: wer zum ersten Mal spielt, kann so gut sein wie wer tausend Partien hat -- Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik wie bei Holo-Karten: je höher die Stufe, desto mehr Glanz (Rahmen, Schein, Lichtstreifen, Funkeln, Schillern), auch im Netzwerkspiel sichtbar -- Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub -- Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück -- Kein Deckbau: alle ziehen vom gemeinsamen Stapel (jede Karte dreimal); zu Beginn fünf sehen, drei behalten; Ziehen kostet 1 Mana und zeigt drei, eine bleibt; angeboten wird nur, was man sich in diesem Zug leisten kann -- Mana für Ziehen, Legen (Kosten = Angriff + Leben, halbiert), Verbessern und Verschmelzen; 5 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten -- Karten haben nur Angriff und Leben; Eigenschaften (Schild, Doppelschlag) kauft man per Rechtsklick für Mana dazu; gelegte Wesen greifen sofort an, eigene Wesen heilen zu Zugbeginn -- Wesen lassen sich verdeckt legen; aufdecken ist frei, ein Angriff deckt auf -- Leben erst ohne Wesen angreifbar; Überschuss schlägt durch +- Gesammelt wird die Seltenheitsstufe einer Karte (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare), und die ist reine Optik wie bei Yu-Gi-Oh: silberner oder goldener Name, Holo-Bild, Relief, Funkeln, Schillern; auch im Netzwerkspiel sichtbar; Fusionen tragen die höhere Stufe der Eltern +- Booster öffnen (Chancen 50/25/15/8/2 %, mindestens eine Rare): höhere Stufen werten Karten auf, der Rest wird zu Staub (50/100/200/500/1000) +- Staub wertet gezielt auf (100/200/500/1000), eine Stufe abgeben bringt den Zerlegewert zurück +- Kein Deckbau: alle ziehen vom gemeinsamen Stapel (jede Karte dreimal); zu Beginn fünf Karten; Ziehen kostet 1 Mana und bringt eine Karte; es kommt nur, was man sich in diesem Zug leisten kann +- Mana für Ziehen, Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet, mindestens 1), Verbessern und Verschmelzen; 5 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten +- Karten haben nur Angriff und Leben; Eigenschaften kauft man per Rechtsklick für Mana dazu, auf Hand und Brett: Spott 2 (muss zuerst angegriffen werden), Flink 2 (greift sofort an), Regeneration 3 (heilt sich am Zugende ganz), Doppelschlag 4 +- Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst +- Wesen lassen sich verdeckt legen (mittlere Maustaste oder Kartenmenü); aufdecken ist frei, frühestens im nächsten Zug, dann greift es sofort an; ein Angriff deckt auf +- Leben erst ohne Wesen angreifbar; Wesen mit Spott zuerst; nichts schlägt durch - Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen ## Elemente - Jede Karte gehört zu genau einem Element - Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) -- Vier offene Wesen eines Elements: +1/+1 für das Element, −1/−1 für das, das es schlägt; die Karte zeigt vorher, was sie bekäme -- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (der Angreifer macht 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, der Konter bleibt gleich; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, und nimmt auch bei der Vier-Elemente-Regel seinen besten Wert) -- Zwei eigene Wesen lassen sich für Mana verschmelzen: das neue erbt Elemente und Eigenschaften beider, seine Werte sind jeweils der bessere Wert plus 1 +- Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag) +- Drei offene Wesen eines Elements auf dem Tisch: +1/+1 für das Element, kein Malus; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni +- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (der Angreifer macht 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, der Konter bleibt gleich; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht) +- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde stillhalten, auf dem Brett oder auf der Hand (Gamepad: über das Kartenmenü); das neue erbt Elemente und Eigenschaften beider, je Wert den besseren plus ein Drittel des schwächeren (aufgerundet), Kosten folgen den Werten; Fusionskosten 1 Mana, verdoppelt je weiterer Verschmelzung - Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus diff --git a/design.md b/design.md index 2d66481..f2934bc 100644 --- a/design.md +++ b/design.md @@ -8,10 +8,10 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt kurz. Auf dem Brett: eigene Seite unten, Gegner oben, Verlauf links, Zug beenden rechts, Wesen als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt. -- **Hearthstones Entdecken** – drei Karten sehen, eine nehmen: Zufall mit Entscheidung. Jede - Wahl zeigt an, was zum eigenen Brett passt (Elementbonus als „+1/+1“ oder „−1/−1“). -- **Yu-Gi-Oh** – verdeckt gelegte Monster, Angriffe erst auf Monster, Kampfschaden schlägt - durch, Fusion. +- **Teamfight Tactics** – Synergien durch gleiche Elemente: zwei eigene geben den Effekt des + Elements, drei auf dem Tisch den Wertebonus; die Elementleiste zeigt den Stand. +- **Yu-Gi-Oh** – verdeckt gelegte Monster, Angriffe erst auf Monster, Fusion durch Übereinander- + legen (hier: ziehen und halten). - **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole statt Sätze. - **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen, @@ -22,12 +22,16 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw ## Karte - Kostenkristall oben links, Bild (Elementsymbol) oben, Namensband auf 36 % Höhe, darunter - gekaufte Eigenschaften als Symbole (Schild, gekreuzte Klingen = Doppelschlag), unten Angriff (gelb) und Leben (rot) als Kreise, dazwischen der Edelstein der + Eigenschaften als Symbole (Ausrufezeichen = Spott, Doppelpfeil = Flink, Kreuz = Regeneration, gekreuzte Klingen = Doppelschlag; gekaufte weiß, vom Element in dessen Farbe), unten Angriff (gelb) und Leben (rot) als Kreise, dazwischen der Edelstein der Stufe. Karten haben keinen Text: nur Element, Angriff, Leben. - Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol. - Die Hand sind bis zu fünf große Karten; Details kommen aus der schwebenden Vorschau. -- Seltenheit: gewöhnlich schlicht; selten Rahmen, Schein, Edelstein, stehender Lichtstreifen; - episch wandernder Lichtstreifen; legendär Funkeln; mythisch schillernde Farben. +- Seltenheit nach Yu-Gi-Oh: Common matt; Rare silberner Name und Rahmen, stehender Glanzstreifen, + Edelstein; Super Rare Holo-Bild (wandernder Regenbogenschimmer über dem Symbol); Ultra Rare + goldener Name und Rahmen plus Holo-Bild; Ultimate Rare Relief am Symbol, Holo auf Bild und + Rahmen, Funkeln, schillernde Farben. Fusionen tragen die höhere Stufe ihrer Eltern. +- Mehrere Elemente: der Hintergrund trägt das erste Element, ein rundes Medaillon hinter dem + Symbol zeigt alle Elemente als Tortenstücke. - Verdeckte fremde Wesen sind ein Kartenrücken mit „?“; eigene verdeckte zeigen die Karte mit dem Vermerk „verdeckt“. @@ -37,33 +41,34 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben: - **Hand** als bis zu fünf große Karten, links der Zieh-Stapel mit „◆ 1“ (Klick zieht), rechts „Zug beenden“. -- **Wahl** als Überlagerung: zu Beginn „Wähle 3 von 5“, nach dem Ziehen „Wähle 1 von 3“; solange - sie offen ist, geht nichts anderes. -- **Eigenes Brett** mit fünf sichtbaren Plätzen, färbt sich mit dem aktiven Feld; links daneben - die eigene Leiste (Leben, Mana, Einkommen, Zug), rechts „Verschmelzen“ und „Rückgängig“, nur - wenn sie gerade möglich sind. -- **Elementzähler** mit vier Punkten je Element: offene Wesen auf dem Tisch; voll heißt +1/+1, - das geschlagene Element bekommt −1/−1. -- **Kartenmenü** per Rechtsklick oder Y auf Hand- und Brettkarten: verdeckt legen, aufdecken, - Schild und Doppelschlag kaufen. +- **Verschmelzen** per Ziehen: eine Hand- oder Brettkarte auf eine passende ziehen, ein + goldener Ring füllt sich eine Sekunde lang, dann verschmelzen die beiden. Mit Gamepad über + das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner). +- **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana, + Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist. +- **Elementleiste** mit drei Punkten je Element: offene Wesen auf dem Tisch; voll heißt +1/+1. + Daneben das Symbol des Sondereffekts, sobald zwei eigene Wesen des Elements liegen. +- **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick + oder Y auf Hand- und Brettkarten: verdeckt legen, aufdecken, Spott, Flink, Regeneration und + Doppelschlag kaufen; per Y zusätzlich verschmelzen. - **Gegner** oben in der Mitte mit seinem Brett in voller Größe. Das Spiel ist ein Duell wie Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner macht Ziele, Texte und Balance eindeutig. - **Vorschau** schwebt neben der Karte, auf die man zeigt (über Ladenkarten), mit Resonanz, Feld und Feldkombo darunter. - **Verlauf** und **Aufgeben** als kleine Knöpfe oben links; der Verlauf öffnet als Überlagerung. -- Hand- und Wahlkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen +- Handkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht. ## Bewegung und Ton - Zugbeginn: Band „Dein Zug“ quer über die Mitte, Ton. -- Ziehen: die Wahl wird ausgeteilt; die genommene Karte fliegt auf die Hand. - Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein. - Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl. -- Aufdecken: „Aufgedeckt!“ am Wesen; Verbessern: die Eigenschaft steigt grün auf. +- Aufdecken: „Aufgedeckt!“ am Wesen; Verbessern: die Eigenschaft steigt grün auf; Regeneration am + Zugende ebenso. - Karten heben sich beim Zeigen an, Knöpfe federn beim Drücken, Bildschirme blenden ein. -- Booster: Karten drehen sich auf, seltene Stufen blitzen in ihrer Farbe auf. +- Booster: Karten drehen sich auf, Stufen ab Rare blitzen in ihrer Farbe auf. - Alles hängt an der Einstellung „Animationen“. ## Menüs diff --git a/scripts/ai.gd b/scripts/ai.gd index 7814fb7..1bbf6fd 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -10,10 +10,10 @@ class_name AI static func creature_value(c: Game.Creature) -> float: var v := float(c.power() + c.health) - if c.shield: - v += 1.0 if c.attacks_per_turn() == 2: v += c.power() * 0.5 + if c.has("Regeneration"): + v += 1.0 return v @@ -61,26 +61,25 @@ static func evaluate(g: Game, p: Game.Player) -> float: else: score -= threat * 0.3 else: - score -= threat * 0.12 # Durchschlag: auch hinter Wesen tut Angriffskraft weh + score -= threat * 0.12 # auch hinter Wesen tut gegnerische Angriffskraft weh return score # --- Vorausschau --------------------------------------------------------------- const MAX_TARGETS := 4 # je Angreifer nur die aussichtsreichsten Ziele prüfen -static var allow_hidden := true # zum Messen: verdeckt legen erwägen static var allow_upgrade := true # zum Messen: Verbessern erwägen const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug -## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest; Durchschlag zählt mit. +## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest. static func _attack_rank(cr: Game.Creature, t) -> float: if t is Game.Player: return 50.0 - t.life if _kills(cr, t): - return (100.0 if not _kills(t, cr) else 60.0) + _value(t) + _overflow(cr, t) + return (100.0 if not _kills(t, cr) else 60.0) + _value(t) return -float(t.health) @@ -98,22 +97,20 @@ static func _top(list: Array, rank: Callable, n: int) -> Array: static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array: var out := [] for card in p.hand: - if not game.can_play(p, card)[0]: - continue - out.append({"type": "play", "card": card, "face_down": false}) - if allow_hidden and game.forecast(p, card) < 0: - out.append({"type": "play", "card": card, "face_down": true}) + if game.can_play(p, card)[0]: + out.append({"type": "play", "card": card, "face_down": false}) + var has_target := not game.enemies_of(p).is_empty() for cr in p.board: - if cr.hidden and game.can_reveal(p, cr)[0]: - out.append({"type": "reveal", "creature": cr}) - elif not cr.hidden and allow_upgrade: + if cr.hidden: + if game.can_reveal(p, cr)[0]: + out.append({"type": "reveal", "creature": cr}) + elif allow_upgrade and has_target: # Nur Verbesserungen, die sich noch in diesem Zug auszahlen können: Doppelschlag für - # ein Wesen, das gleich angreift; Schild nur, wenn kein Wesen mehr spielbar ist. - var has_target := not game.enemies_of(p).is_empty() - if cr.attacks_left > 0 and has_target and game.can_upgrade(p, cr, "Doppelschlag")[0]: + # ein Wesen, das gleich angreift; Flink für eines, das sonst erst nächsten Zug dürfte. + if cr.can_attack() and game.can_upgrade(p, cr, "Doppelschlag")[0]: out.append({"type": "upgrade", "creature": cr, "keyword": "Doppelschlag"}) - if not p.hand.any(func(c): return game.can_play(p, c)[0]) and game.can_upgrade(p, cr, "Schild")[0]: - out.append({"type": "upgrade", "creature": cr, "keyword": "Schild"}) + if not cr.ready and not cr.has("Flink") and cr.power() > 0 and game.can_upgrade(p, cr, "Flink")[0]: + out.append({"type": "upgrade", "creature": cr, "keyword": "Flink"}) var pairs := [] for i in p.board.size(): for j in range(i + 1, p.board.size()): @@ -178,8 +175,6 @@ static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary static func choose_action(game: Game, p: Game.Player) -> Dictionary: if game.winner != "" or game.enemies_of(p).is_empty(): return {"type": "end"} - if not p.offer.is_empty(): - return {"type": "pick", "index": pick_index(game, p)} var view := game.masked_clone(p) var me: Game.Player = view.players[game.players.find(p)] var t0 := Time.get_ticks_msec() @@ -228,18 +223,9 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: static func _kills(attacker, defender) -> bool: if defender is Game.Player: return attacker.power() >= defender.life - if defender.shield: - return false return attacker.power() + Cards.matchup(attacker.card.elements, defender.card.elements) >= defender.health -## Schaden, der beim Angriff auf das Wesen auf dessen Besitzer durchschlägt. -static func _overflow(attacker, defender) -> int: - if defender is Game.Player or defender.shield: - return 0 - return maxi(0, attacker.power() + Cards.matchup(attacker.card.elements, defender.card.elements) - defender.health) - - static func _value(c) -> int: if c is Game.Player: return 1000 @@ -257,40 +243,11 @@ static func _best(list: Array, key: Callable): return best -## Wert einer angebotenen Karte für p: Werte je Mana, jetzt spielbar zählt extra, der -## Elementbonus und ein Vorteil gegen das gegnerische Brett auch; viel zu teuer zählt ab. -static func offer_score(game: Game, p: Game.Player, c: Cards.Card) -> float: - var score := float(c.attack + c.health) / maxf(1.0, c.cost) * 2.0 + c.attack + c.health - if c.cost <= p.energy: - score += 3.0 - if c.cost > p.max_energy + 2: - score -= 2.0 * (c.cost - p.max_energy - 2) - score += 2.0 * game.forecast(p, c) - for e in game.enemies_of(p): - for o in e.board: - if not o.hidden: - score += 1.0 * Cards.matchup(c.elements, o.card.elements) - return score - - -## Welche der angebotenen Karten p nimmt. -static func pick_index(game: Game, p: Game.Player) -> int: - var best := 0 - var best_score := -INF - for i in p.offer.size(): - var s := offer_score(game, p, p.offer[i]) - if s > best_score: - best_score = s - best = i - return best - - static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: if game.winner != "" or game.enemies_of(p).is_empty(): return {"type": "end"} - if not p.offer.is_empty(): - return {"type": "pick", "index": pick_index(game, p)} - # Gierig: erst das teuerste Spielbare legen, dann aufdecken und angreifen, dann ziehen. + # Gierig: erst das teuerste Spielbare legen (bei vollem Feld die zwei schwächsten Wesen + # verschmelzen), dann aufdecken und angreifen, dann Doppelschlag kaufen, dann ziehen. var playable := p.hand.filter(func(c): return game.can_play(p, c)[0]) if not playable.is_empty(): Game.shuffle(playable, game.rng) @@ -299,6 +256,11 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: if c.cost > card.cost: card = c return {"type": "play", "card": card, "face_down": false} + if p.board.size() >= Game.MAX_BOARD and p.hand.any(func(c): return c.cost <= p.energy - 1): + var weakest: Array = p.board.duplicate() + weakest.sort_custom(func(a, b): return _value(a) < _value(b)) + if game.can_fuse(p, weakest[0], weakest[1])[0]: + return {"type": "fuse", "creature": weakest[0], "other": weakest[1]} for cr in p.board: if cr.hidden and game.can_reveal(p, cr)[0]: return {"type": "reveal", "creature": cr} @@ -307,6 +269,10 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: var t = pick_attack_target(game, p, cr) if t != null: return {"type": "attack", "creature": cr, "target": t} + if not game.enemies_of(p).is_empty(): + var strongest = _best(p.board.filter(func(c): return not c.hidden and game.can_upgrade(p, c, "Doppelschlag")[0]), func(c): return c.power()) + if strongest != null: + return {"type": "upgrade", "creature": strongest, "keyword": "Doppelschlag"} if game.can_draw(p)[0]: return {"type": "draw"} return {"type": "end"} @@ -337,8 +303,6 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void match action.type: "play": game.play_card(p, action.card, bool(action.get("face_down", false))) - "pick": - game.pick(p, int(action.index)) "draw": game.draw(p) "reveal": diff --git a/scripts/campaign.gd b/scripts/campaign.gd index 4edff67..e33aebc 100644 --- a/scripts/campaign.gd +++ b/scripts/campaign.gd @@ -10,7 +10,7 @@ const STAGES := [ {"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"}, {"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"}, {"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"}, - {"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "schild_wesen"}, + {"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "alle_flink"}, {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "verbessern_billig"}, {"name": "Archimagier", "title": "Meister der Elemente", "life": 28, "reward": 500, "perk": "start_energie"}, ] @@ -20,12 +20,12 @@ const STAGES := [ const STAGES2 := [ {"name": "Fenn", "title": "Flammenzähmer", "life": 22, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, {"name": "Mara", "title": "Herrin der Gezeiten", "life": 22, "reward": 300, "perks": ["leben_35", "extra_mana"]}, - {"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "schild_wesen"]}, + {"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "alle_flink"]}, {"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "verbessern_billig"]}, {"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]}, {"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]}, {"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]}, - {"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, + {"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["alle_flink", "leben_35"]}, {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["verbessern_billig", "start_energie"]}, {"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]}, ] @@ -34,13 +34,13 @@ const STAGES2 := [ ## Kapitel 3: drei Merkmale und volle Tische. const STAGES3 := [ {"name": "Fenn", "title": "Aschefürst", "life": 20, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]}, - {"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]}, - {"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "schild_wesen", "leben_35"]}, + {"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "alle_flink"]}, + {"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "alle_flink", "leben_35"]}, {"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "verbessern_billig", "extra_mana"]}, {"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]}, {"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]}, {"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_doppelt", "wesen_leben", "start_energie"]}, - {"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["schild_wesen", "leben_35", "element_doppelt"]}, + {"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["alle_flink", "leben_35", "element_doppelt"]}, {"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["verbessern_billig", "start_energie", "wesen_leben"]}, {"name": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]}, ] diff --git a/scripts/cards.gd b/scripts/cards.gd index e5dbe38..d6c0c1d 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -24,18 +24,27 @@ const COMBOS := { # Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf. const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"} # Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich. -const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] +# Stufen wie bei Yu-Gi-Oh: Common grau, Rare silber, Super Rare blau, Ultra Rare gold, +# Ultimate Rare schillert durch alle Farben. +const RARITIES: Array[String] = ["Common", "Rare", "Super Rare", "Ultra Rare", "Ultimate Rare"] const RARITY_COLORS := { - "gewöhnlich": Color("aaaaaa"), - "selten": Color("4682e6"), - "episch": Color("aa46dc"), - "legendär": Color("f0aa28"), - "mythisch": Color("ff5a8c"), + "Common": Color("aaaaaa"), + "Rare": Color("c8d0dc"), + "Super Rare": Color("4682e6"), + "Ultra Rare": Color("f0aa28"), + "Ultimate Rare": Color("ff5a8c"), } -# Schlüsselwörter haben Karten nicht von Haus aus; sie werden für Mana dazugekauft (Game.upgrade). -const KEYWORDS: Array[String] = ["Schild", "Doppelschlag"] -const KEYWORD_COST := {"Schild": 3, "Doppelschlag": 4} -const KEYWORD_TEXT := {"Schild": "Blockt den ersten Schaden", "Doppelschlag": "Greift zweimal je Zug"} +# Eigenschaften haben Karten nicht von Haus aus; sie werden für Mana dazugekauft (Game.upgrade) +# oder kommen vom Elementpaar der eigenen Seite (ELEMENT_KEYWORD, siehe Game.Creature.has). +const KEYWORDS: Array[String] = ["Spott", "Flink", "Regeneration", "Doppelschlag"] +const KEYWORD_COST := {"Spott": 2, "Flink": 2, "Regeneration": 3, "Doppelschlag": 4} +const KEYWORD_TEXT := { + "Spott": "Muss zuerst angegriffen werden", + "Flink": "Greift sofort an", + "Regeneration": "Heilt sich am Zugende ganz", + "Doppelschlag": "Greift zweimal je Zug", +} +const ELEMENT_KEYWORD := {"Erde": "Spott", "Luft": "Flink", "Wasser": "Regeneration", "Feuer": "Doppelschlag"} const DATA_PATH := "res://data/karten.json" @@ -85,17 +94,16 @@ static func best_element(own: Array, other: Array) -> String: return best -## Welches Element ein Element schlägt (und von welchem es geschlagen wird). -static func beaten_by(element: String) -> String: - for e in BEATS: - if BEATS[e] == element: - return e - return "" - - -## Kosten einer Karte: Angriff plus Leben, halbiert und aufgerundet. +## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, mindestens 1. static func cost_of(attack: int, health: int) -> int: - return ceili((attack + health) / 2.0) + return maxi(1, (2 * attack + health) / 3) + + +## Wert eines verschmolzenen Wesens: der bessere Wert plus ein Drittel des schwächeren, +## aufgerundet. Bei gleichen Werten sind das zwei Drittel der Summe, sonst mehr; nie schlechter +## als das stärkere Wesen, und jeder Partner ab 1 bringt mindestens +1. +static func fusion_value(a: int, b: int) -> int: + return maxi(a, b) + ceili(mini(a, b) / 3.0) ## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt). @@ -103,9 +111,13 @@ static func fusion_name(elements: Array) -> String: return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]]) -## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten, die Werte -## bestimmt Game.fuse, eine Verschmelzung mehr als der erfahrenere Elternteil. -static func fused(a: Card, b: Card, attack: int, health: int) -> Card: +## Karte des verschmolzenen Wesens: Elemente und Eigenschaften beider Karten, Werte nach +## fusion_value (oder die übergebenen), eine Verschmelzung mehr als der erfahrenere Elternteil. +static func fused(a: Card, b: Card, attack: int = -1, health: int = -1) -> Card: + if attack < 0: + attack = fusion_value(a.attack, b.attack) + if health < 0: + health = fusion_value(a.health, b.health) var els := [] for e in a.elements + b.elements: if not (e in els): @@ -122,6 +134,7 @@ static func fused(a: Card, b: Card, attack: int, health: int) -> Card: if k in a.keywords or k in b.keywords: c.keywords.append(k) c.fusions = maxi(a.fusions, b.fusions) + 1 + c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt; Game.fuse rechnet die Sammlung ein return c @@ -145,9 +158,10 @@ class Card extends RefCounted: var elements: Array var attack: int = 0 var health: int = 0 - var cost: int = 0 # abgeleitet: (Angriff + Leben) / 2, aufgerundet + var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, mindestens 1 var keywords: Array = [] # dazugekauft, je Exemplar var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …) + var look := -1 # Seltenheitsstufe verschmolzener Karten (die höhere der Eltern); -1: aus der Sammlung des Besitzers func to_dict() -> Dictionary: var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health} @@ -155,8 +169,14 @@ class Card extends RefCounted: d["keywords"] = keywords if fusions > 0: d["fusions"] = fusions + if look >= 0: + d["look"] = look return d + ## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks). + func rarity_in(looks: Dictionary) -> String: + return Cards.RARITIES[look if look >= 0 else int(looks.get(id, 0))] + static func from_dict(d: Dictionary) -> Card: var c := Card.new() c.id = d.id @@ -168,6 +188,7 @@ class Card extends RefCounted: c.cost = Cards.cost_of(c.attack, c.health) c.keywords = Array(d.get("keywords", [])) c.fusions = int(d.get("fusions", 0)) + c.look = int(d.get("look", -1)) return c ## Eigenes Exemplar (Hand, Brett): Schlüsselwörter gehören dem Exemplar, nicht dem Pool. @@ -190,7 +211,7 @@ class Card extends RefCounted: func fusion_cost() -> int: return 1 << fusions - ## Kartentext: die gekauften Schlüsselwörter. + ## Kartentext: die gekauften Eigenschaften. func text() -> String: if keywords.is_empty(): return "" diff --git a/scripts/game.gd b/scripts/game.gd index bd53dbf..35aebf2 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -1,17 +1,16 @@ class_name Game extends RefCounted ## Spielregeln: Duell, Mana je Zug, Ziehen aus dem gemeinsamen Stapel, verdeckte Wesen, -## Vier-Elemente-Regel, Verschmelzen und Verbessern. +## Elementboni, Verschmelzen und Verbessern. const MAX_BOARD := 5 const MAX_HAND := 5 -const START_MANA := 5 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt -const DRAW_COST := 1 # Ziehen: drei Karten sehen, eine behalten -const OFFER_SIZE := 3 -const START_OFFER := 5 # zu Beginn fünf sehen, drei behalten -const START_PICKS := 3 +const START_HAND := 5 # Startkarten, alle im ersten Zug bezahlbar +const START_MANA := 5 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt +const DRAW_COST := 1 # Ziehen: eine bezahlbare Karte vom Stapel const START_LIFE := 30 -const ELEMENT_STACK := 4 # so viele offene Wesen eines Elements auf dem Tisch: +1/+1, das geschlagene Element -1/-1 +const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements +const ELEMENT_STACK := 3 # so viele offene Wesen eines Elements auf dem Tisch: +1/+1 # Merkmale der Kampagnen-Bosse (Player.perks). const PERKS := { @@ -21,7 +20,7 @@ const PERKS := { "wesen_leben": "Wesen kommen mit +1 Leben", "extra_mana": "Bekommt jeden Zug 1 Mana mehr", "leben_35": "Beginnt mit 5 Leben mehr", - "schild_wesen": "Wesen kommen mit Schild", + "alle_flink": "Wesen greifen sofort an", "verbessern_billig": "Eigenschaften kosten 1 weniger", "start_energie": "Hat im ersten Zug 2 Mana mehr", "element_doppelt": "Elementbonus zählt doppelt", @@ -49,10 +48,10 @@ class Creature extends RefCounted: var attack: int var health: int var max_health: int - var shield: bool - var attacks_left: int - var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht für die Elementregel - var aura := 0 # Vier-Elemente-Regel: +1 oder -1 auf den Angriff (Leben wird direkt angepasst) + var attacks_done := 0 # Angriffe in diesem Zug + var ready := false # lag schon zu Beginn des eigenen Zugs: darf angreifen und aufgedeckt werden + var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht für die Elementboni + var aura := 0 # Elementbonus (+1 je Element mit vollem Stapel); das Leben ist schon angepasst func _init(c: Cards.Card, o, id: int) -> void: card = c @@ -62,12 +61,35 @@ class Creature extends RefCounted: attack = c.attack health = c.health max_health = c.health - shield = "Schild" in c.keywords - attacks_left = attacks_per_turn() # frisch gelegte Wesen greifen sofort an func owner(): return owner_ref.get_ref() + ## Eigenschaft: gekauft, als Sondereffekt des Elementpaars der eigenen Seite oder als + ## Boss-Merkmal. Verdeckte Wesen haben keine. + func has(kw: String) -> bool: + if hidden: + return false + if kw in card.keywords: + return true + var o = owner() + if o == null: + return false + if kw == "Flink" and "alle_flink" in o.perks: + return true + for e in card.elements: + if Cards.ELEMENT_KEYWORD.get(e, "") == kw and o.element_count(e) >= Game.ELEMENT_PAIR: + return true + return false + + ## Alle wirksamen Eigenschaften in fester Reihenfolge. + func keywords() -> Array[String]: + var out: Array[String] = [] + for k in Cards.KEYWORDS: + if has(k): + out.append(k) + return out + ## Angriffswert inklusive Elementbonus und Boss-Merkmalen. func power() -> int: var v := attack + aura @@ -78,18 +100,22 @@ class Creature extends RefCounted: return v func attacks_per_turn() -> int: - return 2 if "Doppelschlag" in card.keywords else 1 + return 2 if has("Doppelschlag") else 1 + func attacks_left() -> int: + return maxi(0, attacks_per_turn() - attacks_done) + + ## Angreifen darf, wer offen liegt, noch Angriffe hat und seit einer Runde liegt (oder flink ist). func can_attack() -> bool: - return attacks_left > 0 and not hidden and power() > 0 + return not hidden and attacks_left() > 0 and power() > 0 and (ready or has("Flink")) func clone(new_owner) -> Creature: var c := Creature.new(card, new_owner, uid) c.attack = attack c.health = health c.max_health = max_health - c.shield = shield - c.attacks_left = attacks_left + c.attacks_done = attacks_done + c.ready = ready c.hidden = hidden c.aura = aura return c @@ -101,8 +127,6 @@ class Player extends RefCounted: var is_human: bool var life: int var max_life: int - var offer: Array = [] # offene Wahl: angebotene Karten (Start: 5, Ziehen: 3) - var picks_left := 0 # wie viele davon noch zu nehmen sind var hand: Array = [] # eigene Exemplare (Cards.Card.copy) var board: Array = [] var energy := 0 # Mana in diesem Zug; der Rest verfällt @@ -123,11 +147,17 @@ class Player extends RefCounted: func alive() -> bool: return life > 0 + ## Offene eigene Wesen mit diesem Element. + func element_count(e: String) -> int: + var n := 0 + for c in board: + if not c.hidden and e in c.card.elements: + n += 1 + return n + func clone() -> Player: var p := Player.new(name, is_human, life, title) p.max_life = max_life - p.offer = offer.duplicate() - p.picks_left = picks_left p.hand = hand.duplicate() p.energy = energy p.max_energy = max_energy @@ -235,7 +265,6 @@ func masked_clone(viewer: Player) -> Game: c.attack = c.card.attack c.health = c.card.health c.max_health = c.card.health - c.shield = false return g @@ -250,8 +279,8 @@ func start(fixed_order: bool = false) -> void: p.order = i # Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra. p.start_bonus = 0 if i == 0 else 1 - p.offer = draw_cards(START_OFFER, START_MANA + p.start_bonus) - p.picks_left = START_PICKS + for c in draw_cards(START_HAND, START_MANA + p.start_bonus): + p.hand.append(c.copy()) turn = 1 current_idx = 0 say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) @@ -282,17 +311,10 @@ func draw_cards(n: int, max_cost: int) -> Array: return out -## Solange eine Wahl offen ist, geht nichts anderes. -func blocked(p: Player) -> String: - return "Erst wählen" if not p.offer.is_empty() else "" - - -## Ziehen: kostet DRAW_COST, zeigt OFFER_SIZE Karten, eine davon kommt auf die Hand (siehe pick). +## Ziehen: kostet DRAW_COST, eine Karte kommt auf die Hand. func can_draw(p: Player) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] - if blocked(p) != "": - return [false, blocked(p)] if p.hand.size() >= MAX_HAND: return [false, "Deine Hand ist voll"] if p.energy < draw_cost(p): @@ -315,36 +337,13 @@ func draw(p: Player) -> String: if not ok[0]: return ok[1] p.energy -= draw_cost(p) - p.offer = draw_cards(OFFER_SIZE, p.max_energy) - p.picks_left = 1 + var card: Cards.Card = draw_cards(1, p.max_energy)[0] + p.hand.append(card.copy()) say("%s zieht." % p.name) emit({"type": "draw", "player": players.find(p)}) return "" -## Nimmt eine der angebotenen Karten auf die Hand; jederzeit erlaubt, auch außerhalb des Zugs. -## Was liegen bleibt, wandert unter den Stapel. -func pick(p: Player, index: int) -> String: - if winner != "": - return "Vorbei" - if p.offer.is_empty(): - return "Keine Wahl offen" - if index < 0 or index >= p.offer.size(): - return "Keine solche Karte" - var card: Cards.Card = p.offer[index] - p.hand.append(card.copy()) - p.offer.remove_at(index) - p.picks_left -= 1 - if p.picks_left <= 0 or p.hand.size() >= MAX_HAND: - for rest in p.offer: - deck.insert(0, rest) - p.offer = [] - p.picks_left = 0 - say("%s nimmt eine Karte." % p.name) - emit({"type": "pick", "player": players.find(p)}) - return "" - - func begin_turn(p: Player) -> void: p.max_energy = START_MANA + p.turns p.energy = p.max_energy + (1 if "extra_mana" in p.perks else 0) # der Rest vom letzten Zug ist verfallen @@ -355,18 +354,22 @@ func begin_turn(p: Player) -> void: say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus]) p.start_bonus = 0 for c in p.board: - c.health = c.max_health # Wesen erholen sich zu Zugbeginn ganz - c.attacks_left = c.attacks_per_turn() + c.ready = true # lag eine Runde: greift jetzt an, Verdecktes darf aufgedeckt werden + c.attacks_done = 0 p.turns += 1 say("--- Zug %d: %s ---" % [turn, p.name]) emit({"type": "turn", "player": players.find(p)}) +## Zugende: Wesen mit Regeneration heilen ganz, dann ist der andere dran. func end_turn() -> void: if winner != "": return - while not current().offer.is_empty(): # offene Wahl: die erste Karte, damit nichts verfällt - pick(current(), 0) + for c in current().board: + if c.has("Regeneration") and c.health < c.max_health: + c.health = c.max_health + say("%s regeneriert sich." % c.name) + emit({"type": "heal", "uid": c.uid}) var n := players.size() for i in range(1, n + 1): var idx := (current_idx + i) % n @@ -384,8 +387,6 @@ func end_turn() -> void: func can_play(p: Player, card: Cards.Card) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] - if blocked(p) != "": - return [false, blocked(p)] if not (card in p.hand): return [false, "Nicht auf der Hand"] if p.board.size() >= MAX_BOARD: @@ -395,7 +396,8 @@ func can_play(p: Player, card: Cards.Card) -> Array: return [true, ""] -## Spielt eine Karte, offen oder verdeckt. Gibt "" zurück oder eine Fehlermeldung. +## Spielt eine Karte, offen oder verdeckt. Das Wesen greift erst im nächsten Zug an (außer flink). +## Gibt "" zurück oder eine Fehlermeldung. func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String: var ok := can_play(p, card) if not ok[0]: @@ -410,8 +412,6 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String: if "wesen_leben" in p.perks: cr.health += 1 cr.max_health += 1 - if "schild_wesen" in p.perks: - cr.shield = true p.board.append(cr) if face_down: say("%s legt ein Wesen verdeckt." % p.name) @@ -424,16 +424,17 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String: return "" -## Ein eigenes verdecktes Wesen aufdecken: kostenlos, im eigenen Zug. +## Ein eigenes verdecktes Wesen aufdecken: kostenlos, im eigenen Zug, frühestens einen Zug +## nach dem Legen. Aufgedeckt greift es sofort an. func can_reveal(p: Player, cr: Creature) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] - if blocked(p) != "": - return [false, blocked(p)] if cr == null or not (cr in p.board): return [false, "Nur eigene Wesen"] if not cr.hidden: return [false, "%s liegt schon offen" % cr.name] + if not cr.ready: + return [false, "%s kann erst im nächsten Zug aufgedeckt werden" % cr.name] return [true, ""] @@ -447,7 +448,7 @@ func reveal(p: Player, cr: Creature) -> String: return "" -## Deckt ein Wesen auf; die Elementregel gilt sofort. +## Deckt ein Wesen auf; die Elementboni gelten sofort. func _flip(cr: Creature) -> void: cr.hidden = false say("%s wird aufgedeckt." % cr.name) @@ -455,14 +456,12 @@ func _flip(cr: Creature) -> void: apply_auras() -# --- Verbessern: Schlüsselwörter für Mana ----------------------------------- +# --- Verbessern: Eigenschaften für Mana ------------------------------------- ## target: eigenes Wesen auf dem Brett oder eigene Handkarte. func can_upgrade(p: Player, target, keyword: String) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] - if blocked(p) != "": - return [false, blocked(p)] if not Cards.KEYWORD_COST.has(keyword): return [false, "Unbekannte Eigenschaft"] var card: Cards.Card = target.card if target is Creature else target @@ -489,12 +488,6 @@ func upgrade(p: Player, target, keyword: String) -> String: target.card = card else: p.hand[p.hand.find(target)] = card - if target is Creature: - match keyword: - "Schild": - target.shield = true - "Doppelschlag": - target.attacks_left = mini(target.attacks_per_turn(), target.attacks_left + 1) say("%s bekommt %s." % [card.name, keyword]) emit({"type": "upgrade", "player": players.find(p), "keyword": keyword, "uid": target.uid if target is Creature else -1}) return "" @@ -502,40 +495,69 @@ func upgrade(p: Player, target, keyword: String) -> String: # --- Verschmelzen ------------------------------------------------------------ -## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einem der beiden steckt. -static func fusion_cost(a: Creature, b: Creature) -> int: - return 1 << maxi(a.card.fusions, b.card.fusions) +static func card_of(x) -> Cards.Card: + return x.card if x is Creature else x -func can_fuse(p: Player, a: Creature, b: Creature) -> Array: +## Seltenheitsstufe einer Karte von p: bei verschmolzenen die geerbte, sonst die der Sammlung. +static func look_of(p: Player, c: Cards.Card) -> int: + return c.look if c.look >= 0 else int(p.looks.get(c.id, 0)) + + +## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einer der beiden steckt. +static func fusion_cost(a, b) -> int: + return 1 << maxi(card_of(a).fusions, card_of(b).fusions) + + +## a und b: zwei eigene Wesen auf dem Brett oder zwei eigene Handkarten. +func can_fuse(p: Player, a, b) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] - if blocked(p) != "": - return [false, blocked(p)] if a == null or b == null or a == b: - return [false, "Zwei verschiedene Wesen wählen"] - if not (a in p.board) or not (b in p.board): - return [false, "Nur eigene Wesen"] + return [false, "Zwei verschiedene Karten wählen"] + if a is Creature and b is Creature: + if not (a in p.board) or not (b in p.board): + return [false, "Nur eigene Wesen"] + elif a is Creature or b is Creature: + return [false, "Nur Hand mit Hand oder Brett mit Brett"] + elif not (a in p.hand) or not (b in p.hand): + return [false, "Nicht auf der Hand"] if p.energy < fusion_cost(a, b): return [false, "Nicht genug Mana"] return [true, ""] -## Verschmilzt zwei eigene Wesen zu einem offenen Wesen: Elemente und Schlüsselwörter beider, -## jeweils der bessere Wert (Angriff, Leben, Lebenshöchstwert) plus 1. Der Elementbonus der -## Eltern wird herausgerechnet, das neue Wesen bekommt seinen eigenen. -func fuse(p: Player, a: Creature, b: Creature) -> String: +## Verschmilzt zwei Handkarten zu einer oder zwei eigene Wesen zu einem offenen: Elemente und +## Eigenschaften beider, Werte nach Cards.fusion_value, Kosten folgen den Werten. +func fuse(p: Player, a, b) -> String: var ok := can_fuse(p, a, b) if not ok[0]: return ok[1] p.energy -= fusion_cost(a, b) - var card := Cards.fused(a.card, b.card, maxi(a.attack, b.attack) + 1, - maxi(a.max_health - a.aura, b.max_health - b.aura) + 1) + if a is Creature: + return _fuse_board(p, a, b) + var card := Cards.fused(a, b) + card.look = maxi(look_of(p, a), look_of(p, b)) + var at := p.hand.find(a) + p.hand.erase(a) + p.hand.erase(b) + p.hand.insert(mini(at, p.hand.size()), card) + say("%s und %s verschmelzen auf der Hand zu %s (%d/%d)." % [a.name, b.name, card.name, card.attack, card.health]) + emit({"type": "fuse", "player": players.find(p), "uid": -1, "gone": []}) + return "" + + +## Auf dem Brett zählen die Werte der Wesen (ohne Elementbonus, den bekommt das neue selbst); +## das Ergebnis ist nur angriffsbereit, wenn beide es waren. +func _fuse_board(p: Player, a: Creature, b: Creature) -> String: + var card := Cards.fused(a.card, b.card, Cards.fusion_value(a.attack, b.attack), + Cards.fusion_value(a.max_health - a.aura, b.max_health - b.aura)) + card.look = maxi(look_of(p, a.card), look_of(p, b.card)) var cr := Creature.new(card, p, next_uid) next_uid += 1 - cr.health = maxi(a.health - a.aura, b.health - b.aura) + 1 - cr.shield = cr.shield or a.shield or b.shield - cr.attacks_left = mini(a.attacks_left, b.attacks_left) + cr.health = mini(card.health, Cards.fusion_value(mini(a.health, a.max_health - a.aura), mini(b.health, b.max_health - b.aura))) + cr.ready = a.ready and b.ready + cr.attacks_done = maxi(a.attacks_done, b.attacks_done) var at := p.board.find(a) p.board.erase(a) p.board.erase(b) @@ -548,7 +570,7 @@ func fuse(p: Player, a: Creature, b: Creature) -> String: return "" -# --- Vier-Elemente-Regel ----------------------------------------------------- +# --- Elementboni --------------------------------------------------------------- ## Offene Wesen je Element auf dem ganzen Tisch. func element_counts() -> Dictionary: @@ -563,24 +585,16 @@ func element_counts() -> Dictionary: return counts -## Bonus aus der Elementregel für diese Elemente: +1, wenn ELEMENT_STACK offene Wesen des -## Elements stehen, -1, wenn das Element, das es schlägt, den Stapel voll hat. -## Mehrere Elemente nehmen den besten Wert. +## +1 je Element mit ELEMENT_STACK offenen Wesen auf dem Tisch; mehrere Elemente zählen alle. static func aura_of(elements: Array, counts: Dictionary) -> int: - var best := 0 - for i in elements.size(): - var e: String = elements[i] - var a := 0 + var a := 0 + for e in elements: if int(counts[e]) >= ELEMENT_STACK: a += 1 - var predator := Cards.beaten_by(e) - if predator != "" and int(counts[predator]) >= ELEMENT_STACK: - a -= 1 - best = a if i == 0 else maxi(best, a) - return best + return a -## Bonus eines offenen Wesens aus der Elementregel; verdeckte zählen nicht. +## Bonus eines offenen Wesens; verdeckte zählen nicht und bekommen nichts. func aura_for(c: Creature, counts: Dictionary) -> int: if c.hidden: return 0 @@ -591,7 +605,8 @@ func aura_for(c: Creature, counts: Dictionary) -> int: return a -## Rechnet die Elementregel neu; Leben folgt dem Bonus direkt (+1/+1 bzw. -1/-1). +## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er, +## sinkt nur das Höchstleben, das Leben wird höchstens darauf gekappt. func apply_auras() -> void: var counts := element_counts() for p in players: @@ -599,8 +614,8 @@ func apply_auras() -> void: var a := aura_for(c, counts) if a != c.aura: var delta: int = a - c.aura - c.health += delta c.max_health += delta + c.health = mini(c.health + maxi(delta, 0), c.max_health) c.aura = a @@ -614,14 +629,15 @@ func forecast(p: Player, card: Cards.Card) -> int: # --- Kampf ------------------------------------------------------------------- -## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat. +## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat; Wesen mit Spott +## müssen vor den anderen angegriffen werden. func can_attack(cr: Creature, target) -> Array: if winner != "" or cr.owner() != current(): return [false, "Nicht dein Zug"] - if blocked(cr.owner()) != "": - return [false, blocked(cr.owner())] if cr.hidden: return [false, "%s liegt verdeckt" % cr.name] + if not cr.ready and not cr.has("Flink") and cr.attacks_left() > 0 and cr.power() > 0: + return [false, "%s greift erst im nächsten Zug an" % cr.name] if not cr.can_attack(): return [false, "%s kann nicht angreifen" % cr.name] var tgt_owner = target.owner() if target is Creature else target @@ -629,40 +645,24 @@ func can_attack(cr: Creature, target) -> Array: return [false, "Kein gültiges Ziel"] if not (target is Creature) and not tgt_owner.board.is_empty(): return [false, "Erst die Wesen"] + if target is Creature and not target.has("Spott") and tgt_owner.board.any(func(c): return c.has("Spott")): + return [false, "Erst die Wesen mit Spott"] return [true, ""] -## Greift an. Ein verdecktes Ziel wird zuerst aufgedeckt. Schaden über das Leben eines Wesens -## hinaus schlägt auf seinen Besitzer durch (sonst wäre eine Mauer unverwundbar). -## Gibt "" zurück oder eine Fehlermeldung. +## Greift an. Ein verdecktes Ziel wird zuerst aufgedeckt. Gibt "" zurück oder eine Fehlermeldung. func attack(cr: Creature, target) -> String: var ok := can_attack(cr, target) if not ok[0]: return ok[1] - cr.attacks_left -= 1 + cr.attacks_done += 1 if target is Creature and target.hidden: _flip(target) - if target.health <= 0: # der Elementbonus hat es schon erledigt - cleanup() - check_end() - return "" say("%s greift %s an." % [cr.name, target.name]) emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)}) - var dealt := cr.power() + matchup_bonus(cr, target) - if target is Creature: - var had_shield: bool = target.shield - var hp_before: int = target.health - damage(target, dealt) - if not had_shield and dealt > hp_before: - var o = target.owner() - var excess := dealt - hp_before - o.life -= excess - say("%d Schaden schlagen durch auf %s." % [excess, o.name]) - emit({"type": "damage", "target": ref_of(o), "amount": excess}) - if target.power() > 0: # wer nichts austeilt, kontert auch nicht; der Konter kennt keinen Elementvorteil - damage(cr, target.power()) - else: - damage(target, dealt) + damage(target, cr.power() + matchup_bonus(cr, target)) + if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht; der Konter kennt keinen Elementvorteil + damage(cr, target.power()) cleanup() check_end() return "" @@ -680,11 +680,6 @@ func damage(target, amount: int) -> void: if amount <= 0: return if target is Creature: - if target.shield: - target.shield = false - say("Schild von %s blockt den Schaden." % target.name) - emit({"type": "blocked", "target": ref_of(target)}) - return target.health -= amount else: target.life -= amount @@ -692,7 +687,7 @@ func damage(target, amount: int) -> void: emit({"type": "damage", "target": ref_of(target), "amount": amount}) -## Räumt Tote ab; danach gilt die Elementregel neu, was weitere Tote nach sich ziehen kann. +## Räumt Tote ab; danach gelten die Elementboni neu, was weitere Tote nach sich ziehen kann. func cleanup() -> void: for round in 8: var died := false diff --git a/scripts/net.gd b/scripts/net.gd index b5d9d9f..db70f9f 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -386,10 +386,6 @@ func _apply(id: int, action: Dictionary) -> void: var p: Game.Player = game.players[seats[id]] if game.winner != "": return - if str(action.get("type", "")) == "pick": # wählen geht jederzeit, auch außerhalb des Zugs - game.pick(p, int(action.get("index", -1))) - action_applied.emit() - return if game.current() != p: return match str(action.get("type", "")): @@ -415,9 +411,14 @@ func _apply(id: int, action: Dictionary) -> void: game.attack(cr, t) "draw": game.draw(p) - "fuse": - var a := game.find_creature(int(action.get("uid", -1))) - var b := game.find_creature(int(action.get("other", -1))) + "fuse": # zwei Wesen (uid, other) oder zwei Handkarten (hand, other_hand) + var a = game.find_creature(int(action.get("uid", -1))) + var b = game.find_creature(int(action.get("other", -1))) + var i := int(action.get("hand", -1)) + var j := int(action.get("other_hand", -1)) + if a == null and i >= 0 and j >= 0 and i < p.hand.size() and j < p.hand.size(): + a = p.hand[i] + b = p.hand[j] if a != null and b != null: game.fuse(p, a, b) "end": diff --git a/scripts/netstate.gd b/scripts/netstate.gd index 54f2e4d..338d310 100644 --- a/scripts/netstate.gd +++ b/scripts/netstate.gd @@ -11,7 +11,7 @@ static func card_of(ref) -> Cards.Card: return Cards.Card.from_dict(ref) if ref is Dictionary else Cards.by_id(ref).copy() -## Zustand aus Sicht eines Spielers: fremde Hand nur als Anzahl, fremde Wahl gar nicht, fremde +## Zustand aus Sicht eines Spielers: fremde Hand nur als Anzahl, fremde ## verdeckte Wesen ohne Karte und Werte. viewer -1: alles sichtbar (Host-Wechsel); -2: Zuschauer. static func snapshot(g: Game, viewer: int) -> Dictionary: var ps := [] @@ -23,14 +23,12 @@ static func snapshot(g: Game, viewer: int) -> Dictionary: var masked: bool = c.hidden and not mine board.append({"uid": c.uid, "card": card_ref(Cards.unknown() if masked else c.card), "attack": 0 if masked else c.attack, "health": 0 if masked else c.health, - "max_health": 0 if masked else c.max_health, "shield": c.shield and not masked, - "attacks_left": c.attacks_left, "hidden": c.hidden, "aura": c.aura}) + "max_health": 0 if masked else c.max_health, "attacks_done": c.attacks_done, + "ready": c.ready, "hidden": c.hidden, "aura": c.aura}) ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life, "energy": p.energy, "max_energy": p.max_energy, "start_bonus": p.start_bonus, "order": p.order, "perks": p.perks, "looks": p.looks, "turns": p.turns, - "hand": p.hand.map(card_ref) if mine else p.hand.size(), - "offer": p.offer.map(func(c): return c.id) if mine else [], "picks_left": p.picks_left, - "board": board}) + "hand": p.hand.map(card_ref) if mine else p.hand.size(), "board": board}) var evs := [] for ev in g.events: var e: Dictionary = ev.duplicate() @@ -64,17 +62,14 @@ static func from_snapshot(d: Dictionary) -> Game: else: for i in int(pd.hand): p.hand.append(placeholder) - for cid in pd.get("offer", []): - p.offer.append(Cards.by_id(cid)) - p.picks_left = int(pd.get("picks_left", 0)) for cd in pd.board: var card := card_of(cd.card) var c := Game.Creature.new(card, p, int(cd.uid)) c.attack = int(cd.attack) c.health = int(cd.health) c.max_health = int(cd.max_health) - c.shield = bool(cd.shield) - c.attacks_left = int(cd.attacks_left) + c.attacks_done = int(cd.get("attacks_done", 0)) + c.ready = bool(cd.get("ready", false)) c.hidden = bool(cd.get("hidden", false)) c.aura = int(cd.get("aura", 0)) p.board.append(c) diff --git a/scripts/profile.gd b/scripts/profile.gd index 612daa5..2b3eaaa 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -8,7 +8,7 @@ const SAVE_VERSION := 6 const SAVE_PATH := "user://spielstand.json" const BOOSTER_COST := 100 const BOOSTER_SIZE := 5 -const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5} +const RARITY_WEIGHTS := {"Common": 50.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 8.0, "Ultimate Rare": 2.0} const START_COINS := 150 const REWARD_WIN := 100 const REWARD_LOSS := 50 @@ -19,12 +19,12 @@ const DIFFICULTIES := { "schwer": {"name": "Schwer", "life": 35, "reward": 1.5, "smart": true}, } # Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub. -const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600} -const DUST_UPGRADE := {"selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} +const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000} +const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000} var coins := 0 var dust := 0 -var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = gewöhnlich +var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = Common var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen 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 (Kampagnenfortschritt) @@ -116,7 +116,7 @@ func looks() -> Dictionary: return out -## Anzahl Karten über der gewöhnlichen Stufe. +## Anzahl Karten über Common. func upgraded_count() -> int: var n := 0 for cid in collection: @@ -142,7 +142,7 @@ func upgrade(cid: String) -> String: func dismantle(cid: String) -> String: var lvl := level(cid) if lvl == 0: - return "Gewöhnliche Karten lassen sich nicht zerlegen" + return "Common-Karten lassen sich nicht zerlegen" dust += DUST_DISMANTLE[Cards.RARITIES[lvl]] collection[cid] = lvl - 1 return "" @@ -158,8 +158,8 @@ static func roll_booster(r: RandomNumberGenerator) -> Array: var out := [] for i in BOOSTER_SIZE: var rar: String = rarities[r.rand_weighted(weights)] - if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "gewöhnlich"): - rar = "selten" # mindestens eine seltene Karte pro Booster + if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "Common"): + rar = "Rare" # mindestens eine Rare pro Booster out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar}) return out diff --git a/scripts/ui/battle_fx.gd b/scripts/ui/battle_fx.gd index 107a882..f28cfd7 100644 --- a/scripts/ui/battle_fx.gd +++ b/scripts/ui/battle_fx.gd @@ -32,8 +32,9 @@ func play_events(evs: Array, centers: Dictionary) -> void: "damage": sfx("damage") popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay) - "blocked": - popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay) + "heal": + sfx("heal") + popup(tr("Regeneration"), Style.GREEN, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0) "play": sfx("click" if ev.get("hidden", false) else "play") "reveal": @@ -174,33 +175,6 @@ func fly_in(tile: CreatureView, from: Rect2) -> void: tile.position = tile.slot) -## Eine gekaufte Karte fliegt aus dem Laden auf die Hand. -func fly_card(card: Cards.Card, from: Rect2, to: Rect2, rarity: String) -> void: - if not enabled: - return - var v := CardView.new(card, from.size, false, rarity) - v.position = from.position - v.mouse_filter = Control.MOUSE_FILTER_IGNORE - v.pivot_offset = Vector2.ZERO - screen.fx_layer.add_child(v) - var tw: Tween = screen.create_tween().set_parallel(true) - tw.tween_property(v, "position", to.position, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT) - tw.tween_property(v, "scale", to.size / from.size, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT) - tw.tween_property(v, "modulate:a", 0.0, 0.1).set_delay(0.2) - tw.chain().tween_callback(v.queue_free) - - -## Ladenkarten werden nacheinander ausgeteilt. -func deal(v: CardView, i: int) -> void: - if not enabled: - return - v.modulate.a = 0.0 - v.position.y += 24 - var tw: Tween = screen.create_tween().set_parallel(true) - tw.tween_property(v, "modulate:a", 1.0, 0.15).set_delay(i * 0.05) - tw.tween_property(v, "position:y", v.position.y - 24, 0.2).set_delay(i * 0.05).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) - - ## Kurzes Zittern des Bretts beim Treffer. func shake() -> void: if not enabled: diff --git a/scripts/ui/battle_gamepad.gd b/scripts/ui/battle_gamepad.gd index fae319a..581cf08 100644 --- a/scripts/ui/battle_gamepad.gd +++ b/scripts/ui/battle_gamepad.gd @@ -1,8 +1,9 @@ class_name BattleGamepad extends RefCounted ## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen -## und Ziele (bei offener Wahl über deren Karten), A bestätigt, B bricht ab, Start beendet den -## Zug, Y öffnet das Kartenmenü (im Gegnerzug: überspringen), X macht rückgängig. +## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y öffnet das Kartenmenü (im +## Gegnerzug: überspringen), X macht rückgängig. Verschmelzen: im Menü „Verschmelzen mit …“, +## dann A auf dem Partner. var screen # BattleScreen var active := false @@ -19,12 +20,6 @@ func button(button: int) -> void: active = true if screen.game == null: return - if not screen.pick_views.is_empty() and zone != "pick": - zone = "pick" - index = 0 - elif screen.pick_views.is_empty() and zone == "pick": - zone = "hand" - index = 0 match button: JOY_BUTTON_START: if screen.my_turn() and not screen.any_overlay(): @@ -44,9 +39,9 @@ func button(button: int) -> void: if not items.is_empty() and zone in ["hand", "own"]: var item = items[clampi(index, 0, items.size() - 1)] if zone == "hand": - screen.open_menu(item.card, Rect2(item.position, item.size)) + screen.open_menu(item.card, Rect2(item.position, item.size), true) else: - screen.open_menu(item.creature, Rect2(item.slot, item.size)) + screen.open_menu(item.creature, Rect2(item.slot, item.size), true) JOY_BUTTON_X: if screen.b_undo.visible: screen.undo() @@ -82,14 +77,10 @@ func items() -> Array: match zone: "deck": return [screen.deck] if screen.deck.visible else [] - "pick": - return screen.pick_views "hand": return screen.hand_views "own": - if screen.fusing: # zum Verschmelzen zählt jedes eigene Wesen, nicht nur angriffsbereite - return screen.own_views - return screen.own_views.filter(func(v): return v.creature.can_attack()) + return screen.own_views "target": return targets return [] @@ -115,9 +106,6 @@ func confirm() -> void: match zone: "deck": screen.on_click("deck", null) - "pick": - screen.do_pick(index) - index = 0 "hand": screen.on_click("hand", item.card) "own": diff --git a/scripts/ui/battle_layout.gd b/scripts/ui/battle_layout.gd index 0ba1571..6d40c00 100644 --- a/scripts/ui/battle_layout.gd +++ b/scripts/ui/battle_layout.gd @@ -1,7 +1,7 @@ class_name BattleLayout ## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Hand mit dem ## Zieh-Stapel links und Zug beenden rechts, eigenes Brett (mit eigener Leiste links), -## Feldband, oben der Gegner mit seinem Brett. Eine offene Wahl liegt als Überlagerung darüber. +## Elementband, oben der Gegner mit seinem Brett. const B_GIVEUP := Rect2(10, 10, 96, 32) const B_LOG := Rect2(112, 10, 96, 32) @@ -10,8 +10,7 @@ const FIELD := Rect2(240, 226, 800, 32) const MSG := Rect2(240, 262, 800, 22) const OWN_PANEL := Rect2(240, 288, 800, 160) const STATUS := Rect2(20, 288, 210, 160) -const B_FUSE := Rect2(1050, 298, 210, 40) -const B_UNDO := Rect2(1050, 346, 210, 40) +const B_UNDO := Rect2(1050, 298, 210, 40) const OWN_X := 345 const HAND_W := 150 const HAND_H := 205 @@ -19,8 +18,6 @@ const HAND_GAP := 10 const HAND_Y := 532 const DECK := Rect2(50, 540, 130, 182) const B_END := Rect2(1060, 600, 200, 60) -const PICK_PANEL := Rect2(120, 150, 1040, 440) -const PICK_CARD := Vector2(180, 252) const LOG_PANEL := Rect2(240, 60, 800, 560) const PREVIEW_SIZE := Vector2(180, 252) const CENTER_X := 240 @@ -36,14 +33,6 @@ static func opp_slot(j: int) -> Rect2: return Rect2(OWN_X + j * 120, 76, 110, 140) -## Angebotene Karten einer Wahl, mittig im Wahlfenster. -static func pick_rect(count: int, i: int) -> Rect2: - var gap := 20 - var total := count * PICK_CARD.x + maxi(0, count - 1) * gap - var x := PICK_PANEL.position.x + (PICK_PANEL.size.x - total) / 2 - return Rect2(Vector2(x + i * (PICK_CARD.x + gap), PICK_PANEL.position.y + 96), PICK_CARD) - - ## Hand: bis zu fünf große Karten unten, mittig. static func hand_rect(count: int, i: int) -> Rect2: var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP @@ -51,7 +40,7 @@ static func hand_rect(count: int, i: int) -> Rect2: return Rect2(x + i * (HAND_W + HAND_GAP), HAND_Y, HAND_W, HAND_H) -## Schwebende Vorschau neben der gezeigten Karte: über Hand- und Ladenkarten, sonst rechts +## Schwebende Vorschau neben der gezeigten Karte: über Handkarten, sonst rechts ## davon (links, wenn rechts kein Platz ist); nie aus dem Bild. static func preview_rect(anchor: Rect2) -> Rect2: if anchor.position.y >= HAND_Y - 10: @@ -64,7 +53,7 @@ static func preview_rect(anchor: Rect2) -> Rect2: return Rect2(Vector2(x, y), PREVIEW_SIZE) -## Zusatzzeile (Resonanz, Feld, Feldkombo) neben der Vorschau, rechts davon oder links. +## Zusatzzeile (Elementbonus, Eigenschaften) neben der Vorschau, rechts davon oder links. static func info_rect(preview: Rect2) -> Rect2: var w := 230.0 var x := preview.end.x + 8 diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 53f211d..5c8b2c7 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -1,8 +1,10 @@ class_name BattleScreen extends Control ## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx. -## Rechtsklick (oder Y) auf eine Handkarte oder ein eigenes Wesen öffnet das Kartenmenü: -## verdeckt legen, aufdecken, Eigenschaften kaufen. +## Mittlere Maustaste auf eine Handkarte legt sie verdeckt. Rechtsklick auf eine Handkarte oder +## ein eigenes Wesen öffnet das Kartenmenü: verdeckt legen, aufdecken, Eigenschaften kaufen. +## Verschmelzen: eine Karte auf eine andere ziehen und dort eine Sekunde stillhalten. Mit +## Gamepad öffnet Y das Menü, dort zusätzlich mit „Verschmelzen mit …“. ## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer). var app @@ -14,8 +16,16 @@ var game: Game var perspective: Game.Player # der Mensch, dessen Hand unten liegt var fx: BattleFx var selected = null # Game.Creature -var fusing := false # Verschmelzen gewählt: zwei eigene Wesen anklicken -var fuse_first = null # Game.Creature, erstes Wesen der Verschmelzung +var fuse_first = null # Karte oder Wesen, für das im Menü „Verschmelzen mit …“ gewählt wurde +var hold_time := 1.0 # so lange still über dem Ziel halten, dann verschmelzen die beiden +var press_target = null # Karte oder Wesen unter der gedrückten linken Maustaste +var press_view: Control = null +var press_pos := Vector2.ZERO +var drag_ghost: Control = null # schwebende Kopie der gezogenen Karte +var drag_target = null # mögliches Verschmelzungsziel unter dem Zeiger +var drag_target_view: Control = null +var hold_t := 0.0 +var hold_pos := Vector2.ZERO var msg_t := 0.0 var ai_delay := 0.75 var ai_timer := 0.0 @@ -40,7 +50,6 @@ var undo_t := 0.0 # Ansichten (auch für Tests) var tiles := {} var hand_views: Array = [] -var pick_views: Array = [] # Karten der offenen Wahl var own_views: Array = [] var enemy_views: Array = [] var hero_panels := {} # Player -> Panel @@ -50,9 +59,6 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms) var hero_layer: Control var tile_layer: Control var hand_layer: Control -var pick_layer: Control # Überlagerung der offenen Wahl -var pick_box: Control # ihr Inhalt (wird je Wahl neu aufgebaut) -var pick_key := "" # welche Wahl gerade aufgebaut ist var deck: Deck var log_overlay: Control var b_log: Button @@ -67,7 +73,6 @@ var income_label: Label var status_label: Label var msg_label: Label var b_end: Button -var b_fuse: Button var b_giveup: Button var element_view: ElementView var info_label: Label # Vorschau: Elementbonus, Merkmale @@ -197,9 +202,6 @@ func _build_static() -> void: energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + 12, sy + 78, 100, 34), "left", true) income_label = Style.label(self, "", 15, Style.MUTED, Rect2(sx + 100, sy + 88, 100, 22), "left") status_label = Style.label(self, "", 13, Style.MUTED, Rect2(sx + 12, sy + 124, 190, 22), "left") - b_fuse = Style.button(self, "Verschmelzen", L.B_FUSE, toggle_fuse, Color("3c2858")) - b_fuse.add_theme_font_size_override("font_size", 15) - b_fuse.visible = false b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo) b_undo.add_theme_font_size_override("font_size", 15) b_undo.visible = false @@ -222,11 +224,6 @@ func _build_static() -> void: info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART info_label.visible = false fx_layer = _layer() - # offene Wahl: drei sehen, eine nehmen (zu Beginn fünf sehen, drei nehmen) - pick_layer = Style.overlay(self) - pick_box = Control.new() - pick_box.mouse_filter = Control.MOUSE_FILTER_IGNORE - pick_layer.add_child(pick_box) # Kartenmenü: durchsichtiger Fänger, der das Menü beim Klick daneben schließt menu_layer = Control.new() menu_layer.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -372,6 +369,20 @@ func _input(event: InputEvent) -> void: cancel_all() get_viewport().set_input_as_handled() # sonst schließt derselbe Klick das Menü gleich wieder return + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_MIDDLE: + var under := get_viewport().gui_get_hovered_control() + if under is CardView and under in hand_views and my_turn() and not any_overlay(): + cancel() + play(under.card, true) + after_action() + get_viewport().set_input_as_handled() + return + if event is InputEventMouseMotion: + _drag_motion(event.position) + return + if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + _release() + return if event is InputEventJoypadButton and event.pressed: pad.button(event.button_index) return @@ -385,7 +396,7 @@ func _input(event: InputEvent) -> void: close_menu() elif log_overlay.visible: log_overlay.visible = false - elif selected != null or fusing: + elif selected != null or fuse_first != null: cancel_all() elif not any_overlay(): confirm.visible = true @@ -401,11 +412,9 @@ func _input(event: InputEvent) -> void: if overlay.visible: leave() _: - if event.keycode >= KEY_1 and event.keycode <= KEY_9 and not spectating() and not any_overlay(): + if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay(): var idx: int = event.keycode - KEY_1 - if not perspective.offer.is_empty(): - do_pick(idx) - elif my_turn() and idx < perspective.hand.size(): + if idx < perspective.hand.size(): on_click("hand", perspective.hand[idx]) elif event.keycode == KEY_Z and my_turn() and not any_overlay(): on_click("deck", null) @@ -429,6 +438,15 @@ func _process(delta: float) -> void: undo_t -= delta if undo_t <= 0.0: clear_undo() + if drag_ghost != null and drag_target != null: + _set_hold(hold_t + delta) + if hold_t >= hold_time: + var a = press_target + var b = drag_target + press_target = null + _end_drag() + do_fuse(a, b) + after_action() if is_online() and net.is_host: net.tick() if game.winner != "": @@ -503,8 +521,109 @@ func say(m: String) -> void: func cancel() -> void: selected = null - fusing = false fuse_first = null + press_target = null + _end_drag() + + +# --- Ziehen mit der Maus: Klick beim Loslassen, Verschmelzen nach einer Sekunde über dem Ziel --- + +func _press(obj, view: Control, pos: Vector2) -> void: + _end_drag() + press_target = obj + press_view = view + press_pos = pos + + +func _drag_motion(pos: Vector2) -> void: + if press_target == null: + return + if drag_ghost == null: + if pos.distance_to(press_pos) < 8.0 or not my_turn() or any_overlay(): + return + _start_drag(pos) + drag_ghost.position = pos - drag_ghost.size / 2 + var t = _fusion_target_at(pos) + if t != drag_target or pos.distance_to(hold_pos) > 6.0: + _set_hold(0.0) + drag_target = t + drag_target_view = _view_of(t) + hold_pos = pos + + +## Loslassen ohne Ziehen ist der Klick. +func _release() -> void: + var obj = press_target + var dragged := drag_ghost != null + press_target = null + _end_drag() + if obj == null: + return + if dragged: + refresh() + elif obj is Cards.Card: + on_click("hand", obj) + else: + on_tile_click(obj) + + +func _start_drag(pos: Vector2) -> void: + show_preview(null, false) + if press_target is Cards.Card: + var size := Vector2(BattleLayout.HAND_W, BattleLayout.HAND_H) * 0.8 + drag_ghost = CardView.new(press_target, size, false, press_target.rarity_in(perspective.looks)) + else: + drag_ghost = CreatureView.new(press_target, BattleLayout.own_slot(0).size) + drag_ghost.mouse_filter = Control.MOUSE_FILTER_IGNORE + drag_ghost.modulate.a = 0.85 + drag_ghost.position = pos - drag_ghost.size / 2 + fx_layer.add_child(drag_ghost) + if press_view != null: + press_view.modulate.a = 0.4 + + +func _end_drag() -> void: + if drag_ghost != null: + drag_ghost.queue_free() + drag_ghost = null + if is_instance_valid(press_view): + press_view.modulate.a = 1.0 + _set_hold(0.0) + drag_target = null + drag_target_view = null + + +func _set_hold(v: float) -> void: + hold_t = v + if is_instance_valid(drag_target_view): + drag_target_view.hold = clampf(v / hold_time, 0.0, 1.0) if v > 0.0 else 0.0 + drag_target_view.queue_redraw() + + +## Karte oder Wesen unter pos, mit dem sich die gezogene Karte verschmelzen lässt. +func _fusion_target_at(pos: Vector2): + for v in (hand_views if press_target is Cards.Card else own_views): + var obj = v.card if v is CardView else v.creature + var r: Rect2 = Rect2(v.slot, v.size) if v is CreatureView else v.get_global_rect() # Kacheln zählen an ihrem Platz, auch während sie dorthin gleiten + if obj != press_target and r.has_point(pos) and game.can_fuse(perspective, press_target, obj)[0]: + return obj + return null + + +func _view_of(obj) -> Control: + if obj is Cards.Card: + for v in hand_views: + if v.card == obj: + return v + elif obj is Game.Creature and tiles.has(obj.uid): + return tiles[obj.uid] + return null + + +## Mögliche Partner für „Verschmelzen mit …“: andere Handkarten bzw. andere eigene Wesen. +func fusion_partners(target) -> Array: + var pool: Array = perspective.hand if target is Cards.Card else perspective.board + return pool.filter(func(o): return o != target and game.can_fuse(perspective, target, o)[0]) func play(card: Cards.Card, face_down: bool = false) -> void: @@ -556,10 +675,12 @@ func do_upgrade(target, keyword: String) -> void: after_action() -## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. -func open_menu(target, anchor: Rect2) -> void: +## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. full: auch verschmelzen anbieten +## (Gamepad, das nicht ziehen kann). +func open_menu(target, anchor: Rect2, full: bool = false) -> void: if spectating() or not my_turn(): return + cancel() Style.clear(menu_layer) menu_target = target var entries := [] # [Text, Callable, aktiv] @@ -570,8 +691,14 @@ func open_menu(target, anchor: Rect2) -> void: entries.append([tr("Verdeckt legen ◆ %d") % card.cost, func(): play(card, true) after_action(), ok[0]]) - elif target.hidden: + elif not is_card and target.hidden: entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]]) + if full: + var partners := fusion_partners(target) + entries.append([tr("Verschmelzen mit … ◆ %d") % card.fusion_cost(), func(): + fuse_first = target + say(tr("Ziel wählen")) + refresh(), not partners.is_empty()]) for kw in Cards.KEYWORDS: var k: String = kw var ok := game.can_upgrade(perspective, target, k) @@ -607,16 +734,7 @@ func close_menu() -> void: menu_target = null -## Verschmelzen an- und abwählen (zwei eigene Wesen, siehe Game.fuse). -func toggle_fuse() -> void: - var on := not fusing - cancel() - fusing = on - say(tr("Zwei eigene Wesen wählen") if on else tr("Abgebrochen")) - after_action() - - -## Ziehen: kostet Mana, öffnet die Wahl aus drei Karten. +## Ziehen: kostet Mana, eine Karte kommt auf die Hand. func do_draw() -> void: clear_undo() if is_client(): @@ -629,31 +747,15 @@ func do_draw() -> void: fx.sfx("buy") -## Nimmt eine Karte der offenen Wahl auf die Hand (jederzeit, auch außerhalb des Zugs). -func do_pick(index: int) -> void: - if spectating() or index < 0 or index >= pick_views.size(): - return - clear_undo() - var from := Rect2(pick_views[index].position, pick_views[index].size) - var card: Cards.Card = pick_views[index].card - if is_client(): - net.send_action({"type": "pick", "index": index}) - return - var err := game.pick(perspective, index) - if err != "": - say(tr(err)) - return - fx.sfx("play") - fx.fly_card(card, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1), - Cards.RARITIES[int(perspective.looks.get(card.id, 0))]) - after_action() - - +## Verschmilzt zwei Handkarten oder zwei eigene Wesen (siehe Game.fuse). func do_fuse(a, b) -> void: cancel() clear_undo() if is_client(): - net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid}) + if a is Cards.Card: + net.send_action({"type": "fuse", "hand": perspective.hand.find(a), "other_hand": perspective.hand.find(b)}) + else: + net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid}) return var err := game.fuse(perspective, a, b) if err != "": @@ -703,17 +805,13 @@ func on_click(kind: String, obj) -> void: var g := game if not my_turn() or any_overlay(): return - if fusing: - if kind != "own": - cancel() - say(tr("Abgebrochen")) - elif fuse_first == null: - fuse_first = obj - say(tr("Zweites Wesen wählen")) - elif obj == fuse_first: - fuse_first = null + if fuse_first != null: + var first = fuse_first + fuse_first = null + if obj != null and obj != first and g.can_fuse(perspective, first, obj)[0]: + do_fuse(first, obj) else: - do_fuse(fuse_first, obj) + say(tr("Abgebrochen")) after_action() return if selected != null: @@ -745,6 +843,8 @@ func on_click(kind: String, obj) -> void: if obj.can_attack(): selected = obj say(tr("Wähle ein Angriffsziel")) + elif not obj.ready: + say(tr("%s greift erst im nächsten Zug an") % obj.card.title()) else: say(tr("%s kann nicht angreifen") % obj.card.title()) after_action() @@ -783,7 +883,7 @@ func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: Strin info_label.text = info if entered else "" if entered and card != null: var r := BattleLayout.preview_rect(anchor) - var v := CardView.new(card, r.size, false, Cards.RARITIES[int(owner_looks.get(card.id, 0))]) + var v := CardView.new(card, r.size, false, card.rarity_in(owner_looks)) v.position = r.position v.mouse_filter = Control.MOUSE_FILTER_IGNORE preview_holder.add_child(v) @@ -792,69 +892,32 @@ func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: Strin info_label.size = ir.size -## Eigene Hand- und Wahlkarte: zeigt den Elementbonus, den sie offen gelegt bekäme. +## Eigene Handkarte: zeigt den Elementbonus, den sie offen gelegt bekäme. func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void: + if drag_ghost != null: + return var a := game.forecast(perspective, card) var info := "" if a > 0: info = tr("Elementbonus +%d/+%d") % [a, a] - elif a < 0: - info = tr("Elementbonus %d/%d") % [a, a] show_preview(card, entered, perspective.looks, info, anchor) -## Offene Wahl als Überlagerung: nur neu aufbauen, wenn sich das Angebot geändert hat. -func build_pick() -> void: - var h := perspective - var key := "" - if not spectating() and not h.offer.is_empty(): - key = "%d:%s" % [h.picks_left, ",".join(h.offer.map(func(c): return c.id))] - if key == pick_key: - return - pick_key = key - Style.clear(pick_box) - pick_views = [] - if key == "": - pick_layer.visible = false - return - var box := BattleLayout.PICK_PANEL - Style.panel(pick_box, box, Style.PANEL, 12, Style.GOLD, 2) - Style.label(pick_box, tr("Wähle %d von %d") % [h.picks_left, h.offer.size()], 26, Style.GOLD, - Rect2(box.position.x, box.position.y + 24, box.size.x, 40), "center", true) - for i in h.offer.size(): - var card: Cards.Card = h.offer[i] - var r := BattleLayout.pick_rect(h.offer.size(), i) - var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))]) - v.position = r.position - v.lift = true - apply_forecast(v) - var idx := i - v.clicked.connect(func(_view): do_pick(idx)) - pick_box.add_child(v) - pick_views.append(v) - pick_layer.visible = true - if Style.animations: - for i in pick_views.size(): - fx.deal(pick_views[i], i) - - -## Hand- und Wahlkarten tragen den Elementbonus als Vorschau. -func apply_forecast(v: CardView) -> void: - v.bonus = game.forecast(perspective, v.card) - - -## Was den Angriff eines Wesens gerade hebt oder senkt. +## Was ein Wesen gerade hebt, was es kann und ob es schon angreift. func creature_info(cr: Game.Creature) -> String: var parts: Array[String] = [] if cr.aura > 0: parts.append(tr("Elementbonus +%d/+%d") % [cr.aura, cr.aura]) - elif cr.aura < 0: - parts.append(tr("Elementbonus %d/%d") % [cr.aura, cr.aura]) var perk := cr.power() - cr.attack - cr.aura if perk > 0: parts.append(tr("Merkmal +%d") % perk) + for k in cr.keywords(): + if not (k in cr.card.keywords): + parts.append(tr("%s durch das Element") % tr(k)) if cr.hidden: parts.append(tr("verdeckt")) + elif not cr.ready and not cr.has("Flink") and cr.owner() == perspective: + parts.append(tr("greift erst im nächsten Zug an")) return " · ".join(parts) @@ -1020,10 +1083,10 @@ func refresh() -> void: else: show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size))) var uid := cr.uid - tile.clicked.connect(func(_v): + tile.pressed.connect(func(_v, pos): var c := game.find_creature(uid) if c != null: - on_tile_click(c)) + _press(c, tile, pos)) if arrivals.has(cr.uid): fx.fly_in(tile, arrivals[cr.uid]) arrivals.erase(cr.uid) @@ -1037,7 +1100,7 @@ func refresh() -> void: if cr == selected or cr == fuse_first: b = Style.YELLOW w = 4 - elif fusing and p == h and mine: + elif fuse_first != null and p == h and mine and g.can_fuse(h, fuse_first, cr)[0]: b = Style.PURPLE w = 3 elif selected != null and g.can_attack(selected, cr)[0]: @@ -1060,7 +1123,6 @@ func refresh() -> void: var t: CreatureView = tiles[uid] tiles.erase(uid) fx.fade_out(t) - build_pick() deck.visible = not spectating() deck.enabled = mine and g.can_draw(h)[0] deck.count = g.deck.size() @@ -1075,15 +1137,20 @@ func refresh() -> void: var card: Cards.Card = h.hand[i] var playable: bool = mine and g.can_play(h, card)[0] var r := BattleLayout.hand_rect(h.hand.size(), i) - var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))]) + var v := CardView.new(card, r.size, false, card.rarity_in(h.looks)) v.position = r.position v.lift = mine - apply_forecast(v) - if playable: + v.bonus = g.forecast(h, card) + if fuse_first != null and mine and g.can_fuse(h, fuse_first, card)[0]: + v.border = Style.PURPLE + elif card == fuse_first: + v.border = Style.YELLOW + elif playable: v.border = Style.GREEN - v.dim = not playable + v.dim = not playable and card != fuse_first + v.drag_enabled = true v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size))) - v.clicked.connect(func(_view): on_click("hand", card)) + v.pressed.connect(func(_view, pos): _press(card, v, pos)) hand_layer.add_child(v) hand_views.append(v) @@ -1103,18 +1170,16 @@ func refresh() -> void: b_skip.visible = ai_running b_end.visible = not ai_running and not spectating() b_undo.visible = undo_state != null and mine - b_fuse.visible = mine and not spectating() and h.board.size() >= 2 - b_fuse.text = tr("Abbrechen") if fusing else tr("Verschmelzen") - element_view.update_from(g) + element_view.update_from(g, h) var lines: Array[String] = [] var start := maxi(0, g.log.size() - 60) for i in range(start, g.log.size()): var line := g.log[i] if line.begins_with("---"): lines.append("[color=#6eaaf0]%s[/color]" % line) - elif " verschmelzen zu " in line or " wird aufgedeckt." in line: + elif " verschmelzen " in line or " wird aufgedeckt." in line: lines.append("[color=#8c78dc]%s[/color]" % line) - elif " bekommt " in line: + elif " bekommt " in line or " regeneriert sich." in line: lines.append("[color=#5ad282]%s[/color]" % line) else: lines.append(line) diff --git a/scripts/ui/booster_screen.gd b/scripts/ui/booster_screen.gd index 8821351..f4de3cc 100644 --- a/scripts/ui/booster_screen.gd +++ b/scripts/ui/booster_screen.gd @@ -87,7 +87,7 @@ func buy() -> void: func reveal(i: int) -> void: revealed[i] = true fresh = {i: 0.0} - app.sfx.play("combo" if cards[i].rarity != "gewöhnlich" else "click") + app.sfx.play("combo" if cards[i].rarity != "Common" else "click") refresh() @@ -111,7 +111,7 @@ func flip(v: CardView, delay: float, rarity: String) -> void: var tw := create_tween() tw.tween_interval(delay) tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) - if rarity == "gewöhnlich": + if rarity == "Common": return var burst := Style.panel(fx_layer, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12) burst.pivot_offset = burst.size / 2 diff --git a/scripts/ui/card_view.gd b/scripts/ui/card_view.gd index 2b6a07b..d6ee151 100644 --- a/scripts/ui/card_view.gd +++ b/scripts/ui/card_view.gd @@ -1,23 +1,27 @@ class_name CardView extends Control -## Zeichnet eine Karte (Laden, Hand, Sammlung, Booster, Vorschau). -## Aufbau wie bei Hearthstone: Kostenkristall oben links, Bild, Namensband, Schlüsselwörter als -## Symbole, Effekttext, Angriff und Leben als Kreise unten. Das Element steht im Hintergrund, -## die Seltenheit in Rahmen, Glanz und Edelstein. Sehr flache Karten (Hand) zeigen nur das Nötigste. +## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau). +## Aufbau wie bei Hearthstone: Kostenkristall oben links, Bild, Namensband, Eigenschaften als +## Symbole, Angriff und Leben als Kreise unten. Das erste Element färbt den Hintergrund, weitere +## Elemente zeigt ein Medaillon als Tortenstücke hinter dem Bild; die Seltenheit steckt in Name, +## Rahmen, Glanz und Edelstein (siehe Style). Sehr flache Karten zeigen nur das Nötigste. signal clicked(view: CardView) +signal pressed(view: CardView, pos: Vector2) # nur mit drag_enabled: der Kampf macht daraus Klick oder Ziehen signal hover(card: Cards.Card, entered: bool) var card: Cards.Card var border = null # Color oder null (dann Rahmen der Seltenheitsstufe) -var rarity := "gewöhnlich" # nur Optik +var rarity := "Common" # nur Optik var dim := false -var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme (+1 oder -1) -var lift := false # hebt sich beim Zeigen an (Hand und Laden) +var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme +var lift := false # hebt sich beim Zeigen an (Hand) +var drag_enabled := false # Hand im Kampf: Drücken meldet pressed statt clicked +var hold := 0.0 # Fortschritt des Verschmelzens, wenn eine Karte darüber gehalten wird (0..1) ## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick. -func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "gewöhnlich") -> void: +func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "Common") -> void: card = c rarity = rar set_process(Style.rarity_animated(rarity)) # bewegter Glanz @@ -55,7 +59,10 @@ func _process(_delta: float) -> void: func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: accept_event() - clicked.emit(self) + if drag_enabled: + pressed.emit(self, event.global_position) + else: + clicked.emit(self) elif has_focus() and event.is_action_pressed("ui_accept"): accept_event() clicked.emit(self) @@ -65,7 +72,7 @@ func _draw() -> void: var rect := Rect2(Vector2.ZERO, size) var seed := hash(card.id) var frame: Color = border if border != null else Style.rarity_frame(rarity, seed) - if rarity != "gewöhnlich": + if rarity != "Common": Style.glow(self, rect, Style.rarity_color(rarity, seed), 8, 8 if size.x >= 180 else 6) Style.fill_elements(self, rect, card.elements, 8) if size.y < 90: @@ -76,9 +83,14 @@ func _draw() -> void: draw_rect(rect, Color(0, 0, 0, 0.47)) if has_focus(): Style.border(self, rect, Style.GOLD, 4, 8) + if hold > 0.0: + var c := size / 2 + var r := minf(size.x, size.y) * 0.3 + draw_circle(c, r + 6, Color(0, 0, 0, 0.55)) + draw_arc(c, r, -PI / 2, -PI / 2 + TAU * hold, 40, Style.GOLD, 5.0, true) -## Volle Karte: Laden, Sammlung, Booster, Vorschau. +## Volle Karte: Sammlung, Booster, Vorschau. func _draw_full(rect: Rect2, frame: Color, seed: int) -> void: var big := size.x >= 180 var mid := size.x >= 140 @@ -90,14 +102,18 @@ func _draw_full(rect: Rect2, frame: Color, seed: int) -> void: var icon := 11.0 if big else (9.0 if mid else 7.0) # Bild: das Elementsymbol füllt den oberen Teil var band_top: float = size.y * 0.36 - Style.glyph(self, card, Vector2(size.x / 2, band_top * 0.56), minf(size.x * 0.62, band_top * 0.95), 0.4) - Style.rarity_sheen(self, rect, rarity, seed) + var gc := Vector2(size.x / 2, band_top * 0.56) + var gs := minf(size.x * 0.62, band_top * 0.95) + if card.is_combo(): + Style.element_medallion(self, gc, gs * 0.6, card.elements) + Style.glyph(self, card, gc, gs, 0.4, Style.rarity_relief(rarity)) + Style.rarity_sheen(self, rect, rarity, seed, Rect2(0, 0, size.x, band_top)) # Namensband über die volle Breite var band_h: float = Style.fs(f_name) + 10 var band := Rect2(0, band_top, size.x, band_h) draw_rect(band, Color(0.05, 0.05, 0.08, 0.82)) var nf := Style.fit_size(card.title(), true, f_name, size.x - 12) - Style.text(self, Style.fit(card.title(), true, Style.fs(nf), size.x - 12), nf, Style.TEXT, Vector2(size.x / 2, band.get_center().y), "center", true) + Style.text(self, Style.fit(card.title(), true, Style.fs(nf), size.x - 12), nf, Style.rarity_name_color(rarity, seed), Vector2(size.x / 2, band.get_center().y), "center", true) var y := band.end.y + 4 # Kombo eines verschmolzenen Wesens if card.is_combo(): @@ -146,9 +162,8 @@ func _draw_full(rect: Rect2, frame: Color, seed: int) -> void: # Werte springen aus den unteren Ecken, dazwischen der Edelstein Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat) Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.CRIMSON, f_stat) - if bonus != 0: - Style.text_outlined(self, ("+%d/+%d" % [bonus, bonus]) if bonus > 0 else ("%d/%d" % [bonus, bonus]), f_text, - Style.GREEN if bonus > 0 else Style.RED, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3) + if bonus > 0: + Style.text_outlined(self, "+%d/+%d" % [bonus, bonus], f_text, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3) Style.rarity_gem(self, rarity, Vector2(size.x / 2, size.y - r_stat + 1), 8 if big else 6, seed) Style.border(self, rect, frame, 3 if big else 2, 8) Style.cost_crystal(self, Vector2(r_cost + 2, r_cost + 2), r_cost, card.cost, f_stat) @@ -160,7 +175,7 @@ func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void: Style.rarity_sheen(self, rect, rarity, seed) Style.border(self, rect, frame, 2, 8) Style.cost_crystal(self, Vector2(13, 13), 11, card.cost, 13) - var name_w: float = size.x - 30 - (12 if rarity != "gewöhnlich" else 0) + var name_w: float = size.x - 30 - (12 if rarity != "Common" else 0) var nf := Style.fit_size(card.title(), true, 12, name_w) Style.text(self, Style.fit(card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(28, 13), "midleft", true) Style.rarity_gem(self, rarity, Vector2(size.x - 9, 13), 5, seed) @@ -174,6 +189,6 @@ func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void: x += 18 Style.stat_circle(self, Vector2(11, size.y - 11), 10, str(card.attack), Style.AMBER, 12) Style.stat_circle(self, Vector2(size.x - 11, size.y - 11), 10, str(card.health), Style.CRIMSON, 12) - if bonus != 0: - Style.text_outlined(self, ("+%d" % bonus) if bonus > 0 else str(bonus), 10, Style.GREEN if bonus > 0 else Style.RED, Vector2(4, size.y - 29), "midleft", 3) + if bonus > 0: + Style.text_outlined(self, "+%d" % bonus, 10, Style.GREEN, Vector2(4, size.y - 29), "midleft", 3) Style.rarity_sparkle(self, rect, rarity, seed) diff --git a/scripts/ui/creature_view.gd b/scripts/ui/creature_view.gd index 83264a2..c9d06dc 100644 --- a/scripts/ui/creature_view.gd +++ b/scripts/ui/creature_view.gd @@ -2,7 +2,7 @@ class_name CreatureView extends Control ## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen). -signal clicked(view: CreatureView) +signal pressed(view: CreatureView, pos: Vector2) # linke Maustaste gedrückt; der Kampf macht daraus Klick oder Ziehen signal hover(card: Cards.Card, entered: bool) var creature: Game.Creature @@ -13,6 +13,7 @@ var slot: Vector2 # Zielposition im Raster var busy := false # wird gerade animiert (kein Neupositionieren) var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses Wesen (+1/-1) var secret := false # verdecktes fremdes Wesen: nur der Rücken ist zu sehen +var hold := 0.0 # Fortschritt des Verschmelzens, wenn eine Karte darüber gehalten wird (0..1) func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void: @@ -35,27 +36,31 @@ func _process(_delta: float) -> void: func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: accept_event() - clicked.emit(self) + pressed.emit(self, event.global_position) func rarity() -> String: var owner = creature.owner() - return Cards.RARITIES[int(owner.looks.get(creature.card.id, 0)) if owner != null else 0] + return creature.card.rarity_in(owner.looks if owner != null else {}) func _draw() -> void: var rect := Rect2(Vector2.ZERO, size) if secret: _draw_back(rect) + _draw_hold() return var small := size.x < 100 var rar := rarity() var seed := hash(creature.card.id) + creature.uid - if rar != "gewöhnlich": + if rar != "Common": Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 6) Style.fill_elements(self, rect, creature.card.elements, 6) - Style.glyph(self, creature.card, Vector2(size.x / 2, size.y * 0.5), size.x * 0.75, 0.3) - Style.rarity_sheen(self, rect, rar, seed) + var gc := Vector2(size.x / 2, size.y * 0.5) + if creature.card.is_combo(): + Style.element_medallion(self, gc, size.x * 0.42, creature.card.elements) + Style.glyph(self, creature.card, gc, size.x * 0.75, 0.3, Style.rarity_relief(rar)) + Style.rarity_sheen(self, rect, rar, seed, rect) var bcol: Color = border if border != null else Style.rarity_frame(rar, seed) if flash: bcol = Style.RED @@ -64,19 +69,19 @@ func _draw() -> void: var name_w: float = size.x - (30 if matchup != 0 else 6) # Platz für den Vorteilskreis oben rechts var nf := Style.fit_size(creature.card.title(), true, fs, name_w) draw_rect(Rect2(2, 2, size.x - 4, Style.fs(fs) + 8), Color(0.05, 0.05, 0.08, 0.7)) - Style.text(self, Style.fit(creature.card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(3 + name_w / 2, 6), "midtop", true) - # Schlüsselwörter als Symbole (Schild nur, solange er steht) + Style.text(self, Style.fit(creature.card.title(), true, Style.fs(nf), name_w), nf, Style.rarity_name_color(rar, seed), Vector2(3 + name_w / 2, 6), "midtop", true) + # Eigenschaften als Symbole: gekaufte weiß, die vom Elementpaar in der Elementfarbe var icon := 7.0 if small else 9.0 - var kws: Array[String] = [] - for k in Cards.KEYWORDS: - if k in creature.card.keywords and (k != "Schild" or creature.shield): - kws.append(k) - if creature.shield and not ("Schild" in kws): - kws.append("Schild") + var kws := creature.keywords() var x := size.x / 2 - (kws.size() * (icon * 2 + 6) - 6) / 2.0 var y := Style.fs(fs) + 14 + icon for k in kws: - Style.keyword_icon(self, k, Vector2(x + icon, y), icon) + var col := Color("f2f2f6") + if not (k in creature.card.keywords): + for e in creature.card.elements: + if Cards.ELEMENT_KEYWORD.get(e, "") == k: + col = Style.lighter(Cards.ELEMENT_COLORS[e], 0.45) + Style.keyword_icon(self, k, Vector2(x + icon, y), icon, col) x += icon * 2 + 6 _draw_stats(15 if small else 17, 10 if small else 12, 12.0 if small else 14.0) Style.rarity_gem(self, rar, Vector2(size.x / 2, size.y - 9), 5, seed) @@ -84,6 +89,17 @@ func _draw() -> void: draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55)) Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3) Style.rarity_sparkle(self, rect, rar, seed) + _draw_hold() + + +## Ring, der sich füllt, solange eine Karte zum Verschmelzen darüber gehalten wird. +func _draw_hold() -> void: + if hold <= 0.0: + return + var c := size / 2 + var r := minf(size.x, size.y) * 0.3 + draw_circle(c, r + 6, Color(0, 0, 0, 0.55)) + draw_arc(c, r, -PI / 2, -PI / 2 + TAU * hold, 40, Style.GOLD, 5.0, true) ## Verdecktes fremdes Wesen: ein Kartenrücken. @@ -98,7 +114,7 @@ func _draw_back(rect: Rect2) -> void: pass # unbekanntes Element: kein Vorteil anzeigen -## Angriff (grün oder rot, wenn die Elementregel ihn hebt oder senkt), Leben, Elementvorteil. +## Angriff (grün, wenn der Elementbonus ihn hebt), Leben, Elementvorteil des Angreifers. func _draw_stats(f: int, f_small: int, r: float) -> void: var extra := creature.power() - creature.attack var atk_col := Style.GREEN if extra > 0 else (Style.RED if extra < 0 else Style.AMBER) diff --git a/scripts/ui/element_view.gd b/scripts/ui/element_view.gd index 389a8e9..d0e53f1 100644 --- a/scripts/ui/element_view.gd +++ b/scripts/ui/element_view.gd @@ -1,9 +1,11 @@ class_name ElementView extends Control -## Die Vier-Elemente-Regel auf einen Blick: offene Wesen je Element auf dem Tisch. Ab vier -## bekommt das Element +1/+1 und das Element, das es schlägt, -1/-1. +## Elementboni auf einen Blick: offene Wesen je Element auf dem Tisch (ab drei +1/+1) und der +## Sondereffekt der eigenen Seite (ab zwei eigenen: Erde Spott, Luft Flink, Wasser Regeneration, +## Feuer Doppelschlag), als Symbol in der Elementfarbe. -var counts := {} +var counts := {} # Tisch +var own := {} # eigene Seite func _init(rect: Rect2) -> void: @@ -12,10 +14,13 @@ func _init(rect: Rect2) -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE for e in Cards.ELEMENTS: counts[e] = 0 + own[e] = 0 -func update_from(g: Game) -> void: +func update_from(g: Game, me: Game.Player) -> void: counts = g.element_counts() + for e in Cards.ELEMENTS: + own[e] = me.element_count(e) if me != null else 0 queue_redraw() @@ -23,9 +28,8 @@ func strong(e: String) -> bool: return int(counts.get(e, 0)) >= Game.ELEMENT_STACK -func weak(e: String) -> bool: - var predator := Cards.beaten_by(e) - return predator != "" and strong(predator) +func paired(e: String) -> bool: + return int(own.get(e, 0)) >= Game.ELEMENT_PAIR ## Der Inhalt als Text (Tests, Protokoll). @@ -35,8 +39,8 @@ func text() -> String: var s := "%s %d" % [tr(e), int(counts.get(e, 0))] if strong(e): s += " (+1/+1)" - if weak(e): - s += " (-1/-1)" + if paired(e): + s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e]) parts.append(s) return " · ".join(parts) @@ -51,9 +55,10 @@ func _draw() -> void: var n := int(counts.get(e, 0)) if strong(e): draw_style_box(Style.box(Color(col, 0.35), 6, col, 2), Rect2(x0 + 2, 0, w - 4, size.y)) - elif weak(e): - draw_style_box(Style.box(Color(Style.RED, 0.12), 6, Style.RED, 1), Rect2(x0 + 2, 0, w - 4, size.y)) - Style.text(self, tr(e), 13, Style.TEXT if strong(e) else Style.MUTED, Vector2(x0 + 12, cy), "midleft", strong(e)) + elif paired(e): + draw_style_box(Style.box(Color(col, 0.18), 6, Color(col, 0.6), 1), Rect2(x0 + 2, 0, w - 4, size.y)) + var active := strong(e) or paired(e) + Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(x0 + 12, cy), "midleft", active) var px := x0 + 12 + Style.FONT_BOLD.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 12 for k in Game.ELEMENT_STACK: var c := Vector2(px + k * 13, cy) @@ -62,12 +67,11 @@ func _draw() -> void: else: draw_circle(c, 4.5, Color(0, 0, 0, 0.35)) draw_arc(c, 4.5, 0, TAU, 16, Color(col, 0.6), 1.0) + px += Game.ELEMENT_STACK * 13 + 2 if n > Game.ELEMENT_STACK: - Style.text(self, "+%d" % (n - Game.ELEMENT_STACK), 11, Style.MUTED, Vector2(px + Game.ELEMENT_STACK * 13 + 2, cy), "midleft") - var tag := "" + Style.text(self, "+%d" % (n - Game.ELEMENT_STACK), 11, Style.MUTED, Vector2(px, cy), "midleft") + px += 18 + if paired(e): + Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + 10, cy), 7, Style.lighter(col, 0.45)) if strong(e): - tag = "+1/+1" - elif weak(e): - tag = "-1/-1" - if tag != "": - Style.text(self, tag, 13, Style.GREEN if strong(e) else Style.RED, Vector2(x0 + w - 12, cy), "midright", true) + Style.text(self, "+1/+1", 13, Style.GREEN, Vector2(x0 + w - 12, cy), "midright", true) diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index 77bdb71..bb81d66 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -1,7 +1,7 @@ class_name OpponentSelectScreen extends Control ## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf -## per Ziehen (siehe DeckGen.offer_cards). +## vom gemeinsamen Stapel (siehe Game.start). var app var diff_buttons := {} diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index b5f2321..9eadb84 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -309,43 +309,79 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius # --- Seltenheit als Aussehen ------------------------------------------------------ +# Common matt; Rare silberner Name und Rahmen mit stehendem Glanzstreifen; Super Rare Holo-Bild +# (wandernder Regenbogenschimmer); Ultra Rare goldener Name und Rahmen plus Holo-Bild; +# Ultimate Rare Relief, Holo auf Bild und Rahmen, Funkeln, schillernde Farben. -## Ab episch bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu. +## Ab Super Rare bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu. static func rarity_animated(rar: String) -> bool: return animations and Cards.RARITIES.find(rar) >= 2 -## Farbe einer Stufe; mythisch schillert über die Zeit durch alle Farben. +## Farbe einer Stufe; Ultimate Rare schillert über die Zeit durch alle Farben. static func rarity_color(rar: String, seed: int = 0) -> Color: - if rar == "mythisch" and animations: + if rar == "Ultimate Rare" and animations: return Color.from_hsv(fmod(Time.get_ticks_msec() / 1000.0 * 0.12 + (seed % 97) / 97.0, 1.0), 0.55, 1.0) return Cards.RARITY_COLORS[rar] -## Rahmenfarbe einer Stufe: gewöhnlich bleibt schlicht, alles darüber trägt seine Farbe. +## Rahmenfarbe einer Stufe: Common bleibt schlicht, alles darüber trägt seine Farbe. static func rarity_frame(rar: String, seed: int = 0) -> Color: - return Color("3a3e4c") if rar == "gewöhnlich" else rarity_color(rar, seed) + return Color("3a3e4c") if rar == "Common" else rarity_color(rar, seed) -## Lichtstreifen wie auf einer Folienkarte: selten steht er, ab episch wandert er über die Karte. -static func rarity_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void: +## Farbe des Kartennamens: Rare silbern, Ultra Rare golden, Ultimate Rare schillernd, sonst weiß. +static func rarity_name_color(rar: String, seed: int = 0) -> Color: + match rar: + "Rare", "Ultra Rare", "Ultimate Rare": + return lighter(rarity_color(rar, seed), 0.25) + return TEXT + + +## Ultimate Rare zeigt das Bild als Relief (Licht- und Schattenkante). +static func rarity_relief(rar: String) -> bool: + return Cards.RARITIES.find(rar) >= 4 + + +## Glanz wie auf einer Folienkarte. Rare: ein stehender weißer Streifen über die Karte. +## Ab Super Rare: Holo, ein wandernder Regenbogenschimmer über das Bild (picture); Ultimate Rare +## zusätzlich schwach über die ganze Karte. +static func rarity_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int, picture: Rect2 = Rect2()) -> void: var lvl := Cards.RARITIES.find(rar) if lvl < 1: return + if picture.size.x <= 0: + picture = rect + if lvl == 1: + _sheen_bands(ci, rect, (seed % 100) / 100.0, [[1.0, Color(1, 1, 1, 0.07)], [0.55, Color(1, 1, 1, 0.09)], [0.22, Color(1, 1, 1, 0.13)]]) + return var phase := (seed % 100) / 100.0 - if lvl >= 2 and animations: + if animations: phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0) + var holo := [] + for i in 4: + holo.append([1.0 - i * 0.22, Color.from_hsv(fmod(phase + i * 0.17, 1.0), 0.6, 1.0, 0.16)]) + _sheen_bands(ci, picture, phase, holo) + if lvl >= 4: + var soft := [] + for i in 3: + soft.append([1.0 - i * 0.3, Color.from_hsv(fmod(phase + 0.5 + i * 0.2, 1.0), 0.5, 1.0, 0.07)]) + _sheen_bands(ci, rect, fmod(phase + 0.5, 1.0), soft) + + +## Schräge Streifen über rect, auf rect beschnitten. layers: [Breitenanteil, Farbe]. +static func _sheen_bands(ci: CanvasItem, rect: Rect2, phase: float, layers: Array) -> void: var frame := PackedVector2Array([rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]) var w := rect.size.x * 0.34 var k := rect.size.y * 0.75 # Schräge var x0 := rect.position.x - w + (rect.size.x + w + k) * phase - for layer in [[1.0, 0.07], [0.55, 0.09], [0.22, 0.13]]: + for layer in layers: var lw: float = w * layer[0] var lx := x0 + (w - lw) / 2 var band := PackedVector2Array([Vector2(lx, rect.position.y), Vector2(lx + lw, rect.position.y), Vector2(lx + lw - k, rect.end.y), Vector2(lx - k, rect.end.y)]) for poly in Geometry2D.intersect_polygons(band, frame): if poly.size() >= 3 and _area(poly) > 2.0: # Splitter am Rand lassen sich nicht zeichnen - ci.draw_colored_polygon(poly, Color(1, 1, 1, layer[1])) + ci.draw_colored_polygon(poly, layer[1]) static func _area(poly: PackedVector2Array) -> float: @@ -357,15 +393,13 @@ static func _area(poly: PackedVector2Array) -> float: return absf(a) / 2.0 -## Funkeln ab legendär: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen. +## Funkeln bei Ultimate Rare: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen. ## avoid: Bereich, der frei bleibt (der Kartentext). static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int, avoid: Rect2 = Rect2()) -> void: - var lvl := Cards.RARITIES.find(rar) - if lvl < 3: + if Cards.RARITIES.find(rar) < 4: return var t := Time.get_ticks_msec() / 1000.0 - var n := 5 if lvl == 3 else 9 - for i in n: + for i in 9: var h := absi(hash(Vector2i(seed, i))) var p := rect.position + Vector2(8 + (h % 1000) / 1000.0 * (rect.size.x - 16), 8 + ((h / 1000) % 1000) / 1000.0 * (rect.size.y - 16)) var tw := 0.5 + 0.5 * sin(t * 2.5 + i * 1.9 + seed % 7) if animations else 0.35 + 0.65 * ((i * 7 + seed) % 5) / 4.0 @@ -388,9 +422,9 @@ static func glow(ci: CanvasItem, rect: Rect2, color: Color, radius: int = 8, spr ci.draw_style_box(sb, rect) -## Edelstein in der Farbe der Stufe (ab selten). +## Edelstein in der Farbe der Stufe (ab Rare). static func rarity_gem(ci: CanvasItem, rar: String, c: Vector2, s: float, seed: int = 0) -> void: - if rar == "gewöhnlich": + if rar == "Common": return var col := rarity_color(rar, seed) var diamond := func(h: float, k: Color) -> void: @@ -400,23 +434,28 @@ static func rarity_gem(ci: CanvasItem, rar: String, c: Vector2, s: float, seed: ci.draw_colored_polygon(PackedVector2Array([c + Vector2(0, -s * 0.7), c + Vector2(s * 0.3, -s * 0.15), c + Vector2(-s * 0.35, -s * 0.1)]), Color(1, 1, 1, 0.55)) -## Hintergrund in Elementfarbe; ein verschmolzenes Wesen zeigt seine Elemente als Streifen. +## Hintergrund in der Farbe des ersten Elements; weitere Elemente zeigt element_medallion. static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void: - if elements.size() == 1: - ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect) - return + ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect) + + +## Medaillon eines verschmolzenen Wesens: ein Kreis hinter dem Bild, als Tortenstücke in den +## Farben seiner Elemente, mit dunklem Rand. +static func element_medallion(ci: CanvasItem, c: Vector2, r: float, elements: Array) -> void: + ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.75)) var n := elements.size() - var w := rect.size.x / n for i in n: - var sb := StyleBoxFlat.new() - sb.bg_color = Cards.ELEMENT_COLORS[elements[i]] - if i == 0: - sb.corner_radius_top_left = radius - sb.corner_radius_bottom_left = radius - if i == n - 1: - sb.corner_radius_top_right = radius - sb.corner_radius_bottom_right = radius - ci.draw_style_box(sb, Rect2(rect.position + Vector2(floorf(i * w), 0), Vector2(ceilf(w), rect.size.y))) + var from := -PI / 2 + TAU * i / n + var to := -PI / 2 + TAU * (i + 1) / n + var pts := PackedVector2Array([c]) + var steps := 24 + for k in steps + 1: + var ang := lerpf(from, to, float(k) / steps) + pts.append(c + Vector2(cos(ang), sin(ang)) * r) + ci.draw_colored_polygon(pts, Cards.ELEMENT_COLORS[elements[i]]) + for i in n: + var ang := -PI / 2 + TAU * i / n + ci.draw_line(c, c + Vector2(cos(ang), sin(ang)) * r, Color(0.05, 0.05, 0.08, 0.75), 2.0) # --- Kartenteile ------------------------------------------------------ @@ -444,20 +483,18 @@ static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, col text_outlined(ci, value, font, Color.WHITE, c, "center", 3) -## Schlüsselwort als Symbol auf dunklem Kreis: Schild Schild, Doppelschlag gekreuzte Klingen. +## Eigenschaft als Symbol auf dunklem Kreis: Spott Ausrufezeichen, Flink Doppelpfeil, +## Regeneration Kreuz, Doppelschlag gekreuzte Klingen. static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void: ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7)) var w := maxf(1.5, r * 0.22) match kw: - "Wächter": - ci.draw_rect(Rect2(c + Vector2(-r * 0.75, -r * 0.15), Vector2(r * 1.5, r * 0.85)), col) - for i in 3: - ci.draw_rect(Rect2(c + Vector2(-r * 0.75 + i * r * 0.6, -r * 0.6), Vector2(r * 0.3, r * 0.5)), col) - "Schild": - var pts := PackedVector2Array([c + Vector2(-r * 0.7, -r * 0.65), c + Vector2(r * 0.7, -r * 0.65), c + Vector2(r * 0.7, r * 0.05), - c + Vector2(0, r * 0.8), c + Vector2(-r * 0.7, r * 0.05)]) - ci.draw_colored_polygon(pts, col) - ci.draw_line(c + Vector2(0, -r * 0.55), c + Vector2(0, r * 0.6), Color(0.05, 0.05, 0.08, 0.6), w) + "Spott": + ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.2), col, w * 1.6) + ci.draw_circle(c + Vector2(0, r * 0.6), w * 0.95, col) + "Regeneration": + ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.7), col, w * 1.5) + ci.draw_line(c + Vector2(-r * 0.7, 0), c + Vector2(r * 0.7, 0), col, w * 1.5) "Flink": for i in 2: var x := c.x - r * 0.55 + i * r * 0.55 @@ -471,10 +508,24 @@ static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: # --- Kartensymbole (prozedural, je Element) -------------------------- -static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: float = 0.95) -> void: +## relief: Schatten- und Lichtkante unter dem Symbol (Ultimate Rare). +static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: float = 0.95, relief: bool = false) -> void: + if relief: + ci.draw_set_transform(Vector2(2, 2)) + _glyph_plain(ci, card, center, size, Color(0, 0, 0, alpha * 0.6)) + ci.draw_set_transform(Vector2(-1, -1)) + _glyph_plain(ci, card, center, size, Color(1, 1, 1, alpha * 0.5)) + ci.draw_set_transform(Vector2.ZERO) + _glyph_plain(ci, card, center, size, Color(0, 0, 0, 0), alpha) + + +## Symbol in einer festen Farbe (Relief) oder, bei durchsichtigem tint, in Elementfarben. +static func _glyph_plain(ci: CanvasItem, card, center: Vector2, size: float, tint: Color, alpha: float = 0.95) -> void: var variant := absi(hash(card.id)) % 3 var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38) col.a = alpha + if tint.a > 0: + col = tint var elems: Array = card.elements if elems.size() == 1: _element_glyph(ci, elems[0], center, size, variant, col) @@ -483,6 +534,8 @@ static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: flo _element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col) var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38) col2.a = alpha + if tint.a > 0: + col2 = tint _element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2) if elems.size() >= 3: ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2) diff --git a/tests/frame_probe.gd b/tests/frame_probe.gd index 498479e..c30873f 100644 --- a/tests/frame_probe.gd +++ b/tests/frame_probe.gd @@ -19,15 +19,14 @@ func _ready() -> void: await get_tree().process_frame await get_tree().process_frame var h: Game.Player = scr.game.human - for i in 3: - scr.game.pick(h, 0) h.energy = 10 - scr.game.draw(h) - scr.game.pick(h, 0) + h.hand.pop_back() + if scr.game.current() == h: + scr.game.draw(h) var t0 := Time.get_ticks_usec() for i in 30: scr.refresh() - print("refresh() mit 4 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30)) + print("refresh() mit 5 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30)) scr.ai_delay = 0.2 measuring = true var start := Time.get_ticks_msec() diff --git a/tests/net_bot.gd b/tests/net_bot.gd index 462d0aa..ad91e2b 100644 --- a/tests/net_bot.gd +++ b/tests/net_bot.gd @@ -73,16 +73,13 @@ func _act_host() -> void: _decide(game.players[seat]) -## Einfache Züge mit kurzer Bedenkzeit: erst eine offene Wahl treffen, dann das erste -## spielbare Wesen ohne Ziel legen, dann angreifen, dann ziehen, sonst Zug beenden. +## Einfache Züge mit kurzer Bedenkzeit: das erste spielbare Wesen legen, dann angreifen, +## dann ziehen, sonst Zug beenden. func _decide(me: Game.Player) -> void: var g := game await get_tree().create_timer(0.25).timeout if game != g or (hosting and (net.game == null or net.game.current_idx != seat)): return # inzwischen ein neuer Zustand - if not me.offer.is_empty(): - net.send_action({"type": "pick", "index": 0}) - return for i in me.hand.size(): var card: Cards.Card = me.hand[i] if game.can_play(me, card)[0]: diff --git a/tests/perf_probe.gd b/tests/perf_probe.gd index 9e6bb5d..79bc8c5 100644 --- a/tests/perf_probe.gd +++ b/tests/perf_probe.gd @@ -25,7 +25,6 @@ func _initialize() -> void: var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid) uid += 1 c.ready = true - c.attacks_left = 1 p.board.append(c) var h: Game.Player = g.players[0] h.energy = 10 diff --git a/tests/test_runner.gd b/tests/test_runner.gd index 622c2c6..8676d9f 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -43,7 +43,7 @@ var next_uid := 9000 func put(p: Game.Player, cid: String, ready: bool = true, hidden: bool = false) -> Game.Creature: next_uid += 1 var cr := Game.Creature.new(C(cid), p, next_uid) - cr.attacks_left = cr.attacks_per_turn() if ready else 0 + cr.ready = ready cr.hidden = hidden p.board.append(cr) return cr @@ -58,7 +58,8 @@ func _initialize() -> void: test_wesen_und_angriff() test_elementvorteil() test_verdeckt() - test_vier_elemente() + test_elementboni() + test_spott_und_regeneration() test_verbessern() test_verschmelzen() test_limits() @@ -94,12 +95,12 @@ func test_kartenpool() -> void: for e in Cards.ELEMENTS: check(Cards.all().any(func(c): return c.elements == [e]), "Element vorhanden: " + e) for c in Cards.all(): - check(c.health > 0 and c.cost == ceili((c.attack + c.health) / 2.0), "Kosten = (Angriff + Leben) / 2: " + c.id) + check(c.health > 0 and c.cost == maxi(1, floori((2 * c.attack + c.health) / 3.0)), "Kosten = (2 · Angriff + Leben) / 3, abgerundet: " + c.id) check(c.keywords.is_empty() and c.text() == "", "Karten kommen ohne Eigenschaften: " + c.id) - check(C("f_kobold").cost == 2 and C("ff_phoenix").cost == 7 and C("e_wall").cost == 2, "Kostenbeispiele") + check(C("f_kobold").cost == 1 and C("f_wolf").cost == 2 and C("ff_phoenix").cost == 7 and C("e_wall").cost == 1 and C("f_drache").cost == 5, "Kostenbeispiele") var a := C("f_wolf") var b := C("f_wolf") - a.keywords.append("Schild") + a.keywords.append("Spott") check(b.keywords.is_empty() and Cards.by_id("f_wolf").keywords.is_empty(), "Exemplare teilen sich keine Eigenschaften") @@ -108,7 +109,6 @@ func test_kombos() -> void: check(Cards.combo_name(["Luft", "Feuer"]) == "Blitz", "Luft+Feuer = Blitz") check(not C("ff_phoenix").is_combo() and Cards.fusion_name(["Feuer"]) == "Inferno", "Kombos gibt es nur beim Verschmelzen") check(Cards.combo_name(["Feuer", "Wasser", "Erde"]) == "Chimäre", "drei Elemente sind eine Chimäre") - check(Cards.beaten_by("Feuer") == "Wasser" and Cards.beaten_by("Erde") == "Luft", "wer wen schlägt") ## Mana: 5 im ersten Zug, jede Runde eins mehr, der Rest verfällt; Legen kostet die Kartenkosten. @@ -129,101 +129,78 @@ func test_mana_und_kosten() -> void: check(h.energy == Game.START_MANA + 21, "keine Obergrenze") h.energy = 5 check(g.play_card(h, h.hand[0]) == "" and h.energy == 5 - C("f_wolf").cost, "Ausspielen kostet die Kartenkosten") - check(g.can_play(h, h.hand[0])[1] == "Nicht genug Mana", "Drache (6) zu teuer bei %d Mana" % h.energy) + check(g.can_play(h, h.hand[0])[1] == "Nicht genug Mana", "Drache (5) zu teuer bei %d Mana" % h.energy) + check(Cards.fusion_value(4, 2) == 5 and Cards.fusion_value(8, 1) == 9 and Cards.fusion_value(2, 2) == 3 and Cards.fusion_value(0, 4) == 4 and Cards.fusion_value(8, 8) == 11, "Fusionswert: besserer Wert plus ein Drittel des schwächeren") -## Ziehen: 1 Mana, drei vom gemeinsamen Stapel, eine behalten; der Rest unter den Stapel. +## Start: fünf bezahlbare Karten, keine Wahl. Ziehen: 1 Mana, eine bezahlbare Karte vom Stapel. func test_ziehen() -> void: var r := RandomNumberGenerator.new() r.seed = 1 var g := Game.new_battle(r) - check(g.deck.size() == Cards.all().size() * DeckGen.COPIES - 2 * Game.START_OFFER, "Stapel: jede Karte dreimal, zehn schon ausgegeben") + check(g.deck.size() == Cards.all().size() * DeckGen.COPIES - 2 * Game.START_HAND, "Stapel: jede Karte dreimal, zehn schon ausgegeben") for p in g.players: - check(p.offer.size() == Game.START_OFFER and p.picks_left == Game.START_PICKS and p.hand.is_empty(), "Start: fünf zur Wahl, drei zu nehmen") - check(p.offer.all(func(c): return c.cost <= Game.START_MANA + p.start_bonus), "nur Karten, die im ersten Zug spielbar sind") + check(p.hand.size() == Game.START_HAND, "Start: fünf Karten auf der Hand") + check(p.hand.all(func(c): return c.cost <= Game.START_MANA + p.start_bonus), "nur Karten, die im ersten Zug spielbar sind") + check(p.hand.all(func(c): return c != Cards.by_id(c.id)), "eigene Exemplare") var first: Game.Player = g.current() - var deck_before := g.deck.size() - var wanted: Cards.Card = first.offer[4] - check(g.pick(first, 4) == "" and first.hand.size() == 1 and first.hand[0].id == wanted.id and first.hand[0] != wanted, "erste Startkarte genommen, als eigenes Exemplar") - check(first.offer.size() == 4 and first.picks_left == 2, "vier bleiben, zwei noch zu nehmen") - check(g.draw(first) == "Erst wählen" and g.can_play(first, first.hand[0])[1] == "Erst wählen", "bei offener Wahl geht nichts anderes") - g.pick(first, 0) - g.pick(first, 0) - check(first.hand.size() == 3 and first.offer.is_empty() and g.deck.size() == deck_before + 2, "drei behalten, die anderen zwei liegen unter dem Stapel") + check(g.draw(first) == "Deine Hand ist voll", "fünf Handkarten sind das Limit") + first.hand.pop_back() var mana := first.energy - check(g.draw(first) == "" and first.energy == mana - 1 and first.offer.size() == 3 and first.picks_left == 1, "gezogen: 1 Mana, drei zur Wahl") - check(first.offer.all(func(c): return c.cost <= first.max_energy), "nur Karten bis zum Mana dieses Zugs") - check(first.offer[0] != first.offer[1] and first.offer[1] != first.offer[2], "drei verschiedene Karten") - check(g.pick(first, 1) == "" and first.hand.size() == 4 and first.offer.is_empty(), "eine genommen, Wahl zu") - check(g.pick(first, 0) == "Keine Wahl offen", "ohne Wahl nichts zu nehmen") + var deck_before := g.deck.size() + check(g.draw(first) == "" and first.energy == mana - 1 and first.hand.size() == Game.START_HAND, "gezogen: 1 Mana, eine Karte") + check(first.hand.back().cost <= first.max_energy and g.deck.size() == deck_before - 1, "bezahlbar; zu teure liegen wieder unten im Stapel") first.energy = 0 + first.hand.pop_back() check(g.draw(first) == "Nicht genug Mana", "ohne Mana kein Ziehen") - first.energy = 5 - g.draw(first) - g.pick(first, 0) - check(first.hand.size() == Game.MAX_HAND and g.draw(first) == "Deine Hand ist voll", "fünf Handkarten sind das Limit") var second: Game.Player = g.enemy_of(first) - check(g.pick(second, 0) == "" and second.hand.size() == 1, "wählen geht auch außerhalb des Zugs") - g.end_turn() # jetzt ist der Zweite dran, seine Wahl ist noch offen - g.end_turn() # sein Zugende nimmt den Rest von vorn - check(second.hand.size() == 3 and second.offer.is_empty(), "Zugende nimmt offene Karten von vorn") + check(g.draw(second) == "Nicht dein Zug", "nur im eigenen Zug") # Leerer Stapel wird neu gemischt g.deck = [] - var cur: Game.Player = g.current() - cur.energy = 3 - cur.hand.pop_back() # Platz auf der Hand - check(g.draw(cur) == "" and cur.offer.size() == 3 and g.deck.size() > 0 and g.log.any(func(l): return l.begins_with("Der Stapel wird neu gemischt")), "leerer Stapel: neu gemischt") - # Mit viel Mana tauchen auch teure Karten auf; zu teure wandern unter den Stapel - g.pick(cur, 0) - cur.max_energy = 20 - cur.energy = 20 - var expensive_before: int = g.deck.filter(func(c): return c.cost > 8).size() - g.draw(cur) - g.pick(cur, 0) - var bottom: Array = g.deck.slice(0, 5) - check(expensive_before > 0 and (g.deck.size() + 1 + cur.offer.size()) > 0 and bottom.size() == 5, "Stapel bleibt vollständig") + first.energy = 3 + check(g.draw(first) == "" and g.deck.size() > 0 and g.log.any(func(l): return l.begins_with("Der Stapel wird neu gemischt")), "leerer Stapel: neu gemischt") + # Mit viel Mana tauchen auch teure Karten auf + first.max_energy = 20 + first.energy = 20 + var seen_expensive := false + for i in 12: + first.hand = [] + g.draw(first) + if first.hand[0].cost > 5: + seen_expensive = true + check(seen_expensive, "mit 20 Mana kommen auch teure Karten") +## Gelegte Wesen greifen erst im nächsten Zug an; kein Durchschlag, keine Heilung zu Zugbeginn. func test_wesen_und_angriff() -> void: var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - check(g.play_card(h, h.hand[0]) == "" and h.board.size() == 1 and h.board[0].can_attack(), "gespielt und sofort angriffsbereit") + check(g.play_card(h, h.hand[0]) == "" and h.board.size() == 1 and not h.board[0].can_attack(), "gespielt, greift noch nicht an") var wolf: Game.Creature = h.board[0] + check(g.can_attack(wolf, o)[1] == "Flammenwolf greift erst im nächsten Zug an", "Meldung: erst nächsten Zug") + g.begin_turn(h) + check(wolf.ready and wolf.can_attack(), "nach einer Runde angriffsbereit") var e := put(o, "e_wall") # 0/4 check(g.can_attack(wolf, o)[1] == "Erst die Wesen", "Leben erst ohne Wesen angreifbar") check(g.attack(wolf, e) == "" and e.health == 4 - 3 and wolf.health == 2, "Angriff: Schaden, kein Konter von 0 Angriff") check(g.can_attack(wolf, e)[1].ends_with("kann nicht angreifen"), "nur ein Angriff je Zug") g.begin_turn(o) - check(e.health == 4, "zu Zugbeginn erholt sich das Wesen seines Besitzers ganz") + check(e.health == 1, "keine Heilung zu Zugbeginn") g.begin_turn(h) + var life := o.life g.attack(wolf, e) - check(e.health == 1 and o.board.size() == 1, "Angriff: wieder nur 3 Schaden") - e.health = 1 + check(o.board.is_empty() and g.log.any(func(l): return l == "Steinwall stirbt.") and o.life == life, "Wesen stirbt, nichts schlägt durch") g.begin_turn(h) - g.attack(wolf, e) - check(o.board.is_empty() and g.log.any(func(l): return l == "Steinwall stirbt."), "Wesen stirbt, Überschuss schlägt durch") - g.begin_turn(h) - var life0 := o.life - check(g.attack(wolf, o) == "" and o.life == life0 - 3, "ohne Wesen trifft es den Helden") + check(g.attack(wolf, o) == "" and o.life == life - 3, "ohne Wesen trifft es den Helden") # Gegenschlag var troll := put(o, "e_troll") # 3/6 g.begin_turn(h) g.attack(wolf, troll) check(troll.health == 3 and wolf.health <= 0 and not (wolf in h.board), "Konter tötet den Wolf") - # Durchschlag: was über das Leben des Wesens hinausgeht, trifft den Besitzer - var drache := put(h, "f_drache") # 6/5 - var kobold := put(o, "f_kobold") # 2/1 - var life := o.life - g.attack(drache, kobold) - check(not (kobold in o.board) and o.life == life - 5 and g.log.any(func(l): return l == "5 Schaden schlagen durch auf Gegner."), "Durchschlag: 6 gegen 1 Leben, 5 gehen durch") - var sch := put(o, "f_kobold") - sch.shield = true - life = o.life - g.begin_turn(h) - g.attack(drache, sch) - check(sch.health == 1 and o.life == life, "Schild blockt alles, nichts schlägt durch") + g.begin_turn(o) + check(troll.health == 3, "der Troll bleibt verletzt") func test_elementvorteil() -> void: @@ -242,7 +219,7 @@ func test_elementvorteil() -> void: var wolf := put(o, "f_wolf") # Feuer 3/2 var life: int = o.life g.attack(tiefen, wolf) - check(wolf.health <= 0 and o.life == life - 3 and tiefen.health == 6 - 3, "Wasser gegen Feuer: +1 beim Zuschlagen, der Konter bleibt gleich") + check(wolf.health <= 0 and o.life == life and tiefen.health == 6 - 3, "Wasser gegen Feuer: +1 beim Zuschlagen, der Konter bleibt gleich, nichts schlägt durch") var s3 := setup() var w3 := put(s3[1], "f_wolf") var t3 := put(s3[2], "w_tiefen") @@ -255,7 +232,8 @@ func test_elementvorteil() -> void: check(s2[2].life == 13, "gegen Helden gibt es keinen Vorteil") -## Verdeckt: greift nicht an, zählt nicht für die Elementregel, wird beim Angriff aufgedeckt. +## Verdeckt: greift nicht an, zählt nicht für die Elementboni, darf erst nach einer Runde aufgedeckt +## werden, greift dann sofort an; ein Angriff deckt es auf. func test_verdeckt() -> void: var s := setup(["f_wolf", "f_kobold"]) var g: Game = s[0] @@ -265,12 +243,13 @@ func test_verdeckt() -> void: check(g.play_card(h, h.hand[0], true) == "" and h.board[0].hidden and h.energy == mana - C("f_wolf").cost, "verdeckt gelegt, gleich teuer") var wolf: Game.Creature = h.board[0] check(g.log.back() == "Spieler legt ein Wesen verdeckt." and g.events.back().hidden, "Verlauf verrät die Karte nicht") + check(g.reveal(h, wolf) == "Flammenwolf kann erst im nächsten Zug aufgedeckt werden" and wolf.hidden, "im Legezug nicht aufdecken") g.begin_turn(h) check(not wolf.can_attack() and g.can_attack(wolf, o)[1] == "Flammenwolf liegt verdeckt", "verdeckt greift nicht an") check(g.reveal(h, wolf) == "" and not wolf.hidden and wolf.can_attack(), "aufgedeckt: kann sofort angreifen") check(g.reveal(h, wolf) != "", "offen bleibt offen") # Der Gegner deckt beim Angriff auf - var kobold := put(o, "f_kobold", true, true) + var kobold := put(o, "f_kobold", false, true) o.hand = [] check(g.attack(wolf, kobold) == "" and not kobold.hidden and kobold.health <= 0, "Angriff deckt auf und tötet") check(g.log.any(func(l): return l == "Feuerkobold wird aufgedeckt."), "Aufdecken steht im Verlauf") @@ -288,45 +267,104 @@ func test_verdeckt() -> void: check(own.card == "f_asche", "der Besitzer sieht seine verdeckte Karte") -## Vier offene Wesen eines Elements: +1/+1 für das Element, -1/-1 für das, das es schlägt. -func test_vier_elemente() -> void: +## Elementboni: zwei eigene offene Wesen eines Elements geben den Sondereffekt (Erde Spott, Luft +## Flink, Wasser Regeneration, Feuer Doppelschlag); drei auf dem Tisch geben dem Element +1/+1. +func test_elementboni() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var fire := [] - for i in 3: - fire.append(put(h, "f_wolf")) + var fire := [put(h, "f_wolf"), put(h, "f_kobold")] var bird := put(o, "l_wind") # Luft 1/1 g.apply_auras() - check(fire[0].aura == 0 and bird.aura == 0, "drei reichen nicht") - var hidden_fire := put(o, "f_kobold", true, true) + check(fire[0].aura == 0 and fire[1].aura == 0, "zwei reichen nicht für +1/+1") + check(fire[0].has("Doppelschlag") and fire[0].attacks_per_turn() == 2 and fire[0].keywords() == ["Doppelschlag"], "zwei eigene Feuer: Doppelschlag für beide") + var hidden_fire := put(o, "f_asche", true, true) g.apply_auras() - check(fire[0].aura == 0, "verdeckte zählen nicht") - check(g.element_counts()["Feuer"] == 3, "Zähler ohne Verdeckte") - var fourth := put(o, "f_asche") # Feuer beim Gegner zählt auch + check(fire[0].aura == 0 and g.element_counts()["Feuer"] == 2 and not hidden_fire.has("Doppelschlag"), "verdeckte zählen nicht und bekommen nichts") + var third := put(o, "f_ritter") # Feuer beim Gegner zählt für den Tisch g.apply_auras() - check(fire[0].aura == 1 and fire[0].power() == 4 and fire[0].health == 3 and fourth.aura == 1, "vier Feuer: +1/+1 für alle Feuerwesen") - check(bird.aura == -1 and bird.power() == 0 and bird.health == 0, "Luft wird geschlagen: -1/-1") - g.cleanup() - check(not (bird in o.board), "das 1/1 ist daran gestorben") - g.reveal(o, hidden_fire) if g.current() == o else g._flip(hidden_fire) - check(hidden_fire.aura == 1, "aufgedeckt zählt es und bekommt den Bonus sofort") - o.board.erase(fourth) + check(fire[0].aura == 1 and fire[0].power() == 4 and fire[0].health == 3 and third.aura == 1, "drei Feuer auf dem Tisch: +1/+1 für alle Feuerwesen") + check(bird.aura == 0 and bird.health == 1, "kein Malus für das geschlagene Element") + check(not third.has("Doppelschlag"), "der Gegner hat nur ein offenes Feuer: kein Sondereffekt") + g._flip(hidden_fire) + check(hidden_fire.aura == 1 and third.has("Doppelschlag") and hidden_fire.has("Doppelschlag"), "aufgedeckt zählt es sofort: Bonus und Paar") + fire[0].health = 1 + o.board.erase(third) o.board.erase(hidden_fire) g.apply_auras() - check(fire[0].aura == 0 and fire[0].health == 2, "Bonus geht wieder, Leben folgt") + check(fire[0].aura == 0 and fire[0].max_health == 2 and fire[0].health == 1 and fire[1].health == 1, "Bonus geht wieder: nur das Höchstleben sinkt, Leben wird höchstens gekappt") # Vorschau - check(g.forecast(h, C("f_kobold")) == 1 and g.forecast(h, C("l_wind")) == 0 and g.forecast(h, C("w_geist")) == 0, "Vorschau: Feuer würde den Stapel voll machen") + check(g.forecast(h, C("f_asche")) == 1 and g.forecast(h, C("l_wind")) == 0, "Vorschau: Feuer würde den Stapel voll machen") # Merkmal: doppelt h.perks = ["element_doppelt"] - put(h, "f_kobold") + put(h, "f_asche") g.apply_auras() check(fire[0].aura == 2 and fire[0].power() == 5, "Elementbonus zählt doppelt") - check(g.forecast(h, C("l_wind")) == -2, "Vorschau: Luft bekäme jetzt -2/-2") + h.perks = [] + # Die anderen Paare + var walls := [put(h, "e_wall"), put(h, "e_wall")] + check(walls[0].has("Spott") and not fire[0].has("Spott"), "zwei Erde: Spott nur für Erdwesen") + var winds := [put(h, "l_wind", false), put(h, "l_wind", false)] + check(winds[0].has("Flink") and winds[0].can_attack(), "zwei Luft: Flink, frisch gelegt greift an") + var geister := [put(h, "w_geist"), put(h, "w_geist")] + check(geister[0].has("Regeneration"), "zwei Wasser: Regeneration") + # Verschmolzene Wesen zählen für beide Elemente und bekommen beide Effekte und Boni + var s2 := setup() + var g2: Game = s2[0] + var h2: Game.Player = s2[1] + var dampf := put(h2, "f_wolf") + dampf.card = Cards.fused(C("f_wolf"), C("w_geist")) + var fw := put(h2, "f_kobold") + var ww := put(h2, "w_geist") + g2.apply_auras() + check(dampf.has("Doppelschlag") and dampf.has("Regeneration") and dampf.keywords() == ["Regeneration", "Doppelschlag"], "Dampf hat beide Sondereffekte") + check(fw.has("Doppelschlag") and ww.has("Regeneration"), "und zählt für beide Paare") + put(s2[2], "f_asche") + put(s2[2], "w_reiter") + g2.apply_auras() + check(dampf.aura == 2 and fw.aura == 1 and ww.aura == 1, "drei Feuer und drei Wasser: Dampf bekommt +2/+2") -## Verbessern: Eigenschaften für Mana, je Exemplar. +## Spott zieht Angriffe auf sich; Regeneration heilt am Ende des eigenen Zugs. +func test_spott_und_regeneration() -> void: + var s := setup() + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2] + var wolf := put(h, "f_wolf") + var wall := put(o, "e_wall") + var kobold := put(o, "f_kobold") + wall.card.keywords.append("Spott") + check(g.can_attack(wolf, kobold)[1] == "Erst die Wesen mit Spott" and g.can_attack(wolf, wall)[0], "Spott muss zuerst angegriffen werden") + var secret := put(o, "e_troll", true, true) + secret.card.keywords.append("Spott") + check(not secret.has("Spott") and g.can_attack(wolf, kobold)[1] == "Erst die Wesen mit Spott", "verdeckter Spott zählt nicht") + o.board.erase(secret) + g.attack(wolf, wall) + wall.health = 0 + g.cleanup() + g.begin_turn(h) + check(g.can_attack(wolf, kobold)[0], "ohne Spötter ist alles angreifbar") + # Regeneration + var s2 := setup() + var g2: Game = s2[0] + var h2: Game.Player = s2[1] + var reg := put(h2, "w_tiefen") + reg.card.keywords.append("Regeneration") + var plain := put(h2, "e_troll") + var enemy := put(s2[2], "w_reiter") + enemy.card.keywords.append("Regeneration") + reg.health = 1 + plain.health = 1 + enemy.health = 1 + g2.end_turn() + check(reg.health == reg.max_health and g2.log.any(func(l): return l == "Tiefenwächter regeneriert sich."), "Regeneration heilt am Zugende ganz") + check(plain.health == 1 and enemy.health == 1, "ohne Regeneration und beim Gegner heilt nichts") + check(g2.events.any(func(e): return e.type == "heal" and e.uid == reg.uid), "Ereignis fürs Heilen") + + +## Verbessern: Eigenschaften für Mana, je Exemplar, auf Hand und Brett. func test_verbessern() -> void: var s := setup(["f_wolf"]) var g: Game = s[0] @@ -334,24 +372,26 @@ func test_verbessern() -> void: var o: Game.Player = s[2] h.energy = 20 var card: Cards.Card = h.hand[0] - check(g.upgrade(h, card, "Schild") == "" and h.energy == 20 - 3 and "Schild" in h.hand[0].keywords, "Schild für die Handkarte gekauft") + check(g.upgrade(h, card, "Spott") == "" and h.energy == 20 - 2 and "Spott" in h.hand[0].keywords, "Spott für die Handkarte gekauft") check(h.hand[0] != card and Cards.by_id("f_wolf").keywords.is_empty(), "eigenes Exemplar, der Pool bleibt sauber") - check(g.upgrade(h, h.hand[0], "Schild") != "", "nicht doppelt") + check(g.upgrade(h, h.hand[0], "Spott") != "", "nicht doppelt") g.play_card(h, h.hand[0]) var wolf: Game.Creature = h.board[0] var after_play := h.energy - check(wolf.shield and wolf.can_attack(), "mit Schild gelegt, greift sofort an") - check(g.upgrade(h, wolf, "Doppelschlag") == "" and wolf.attacks_per_turn() == 2 and wolf.attacks_left == 2 and h.energy == after_play - 4, "Doppelschlag: zwei Angriffe") - check(wolf.card.text() == "Schild, Doppelschlag.", "Kartentext zeigt die Eigenschaften") - check(g.upgrade(h, wolf, "Doppelschlag") != "" and g.upgrade(h, put(o, "e_wall"), "Schild") != "" and g.upgrade(h, wolf, "Flink") != "", "nicht doppelt, nicht fremd, nicht unbekannt") - h.energy = 2 + check(wolf.has("Spott") and not wolf.can_attack(), "mit Spott gelegt, greift noch nicht an") + check(g.upgrade(h, wolf, "Flink") == "" and wolf.can_attack() and h.energy == after_play - 2, "Flink: greift sofort an") + check(g.upgrade(h, wolf, "Doppelschlag") == "" and wolf.attacks_per_turn() == 2 and wolf.attacks_left() == 2 and h.energy == after_play - 6, "Doppelschlag: zwei Angriffe") + check(g.upgrade(h, wolf, "Regeneration") == "" and h.energy == after_play - 9, "Regeneration kostet 3") + check(wolf.card.text() == "Spott, Flink, Regeneration, Doppelschlag.", "Kartentext zeigt die Eigenschaften") + check(g.upgrade(h, wolf, "Doppelschlag") != "" and g.upgrade(h, put(o, "e_wall"), "Spott") != "" and g.upgrade(h, wolf, "Schild") != "", "nicht doppelt, nicht fremd, nicht unbekannt") + h.energy = 1 var k2 := put(h, "f_kobold") - check(g.upgrade(h, k2, "Schild") == "Nicht genug Mana", "zu teuer") + check(g.upgrade(h, k2, "Spott") == "Nicht genug Mana", "zu teuer") h.perks = ["verbessern_billig"] - check(g.upgrade(h, k2, "Schild") == "" and h.energy == 0, "Merkmal: Eigenschaften kosten 1 weniger") + check(g.upgrade(h, k2, "Spott") == "" and h.energy == 0, "Merkmal: Eigenschaften kosten 1 weniger") -## Verschmelzen: der bessere Wert von beidem plus 1, Kosten 1, 2, 4, 8 … +## Verschmelzen: besserer Wert plus ein Drittel des schwächeren, Eigenschaften beider, Kosten 1, 2, 4, 8 …; auch auf der Hand. func test_verschmelzen() -> void: var s := setup() var g: Game = s[0] @@ -359,46 +399,60 @@ func test_verschmelzen() -> void: var a := put(h, "f_wolf") # 3/2 var b := put(h, "w_tiefen") # 4/6 b.health = 4 - a.card.keywords.append("Schild") + a.card.keywords.append("Spott") check(Game.fusion_cost(a, b) == 1, "erste Verschmelzung kostet 1") check(g.fuse(h, a, b) == "" and h.board.size() == 1 and h.energy == 9, "verschmolzen") var f: Game.Creature = h.board[0] - check(f.name == "Dampf" and f.attack == 5 and f.max_health == 7 and f.health == 5, "Dampf mit dem besseren Angriff und Leben plus 1") - check("Schild" in f.card.keywords and f.card.fusions == 1 and f.card.cost == Cards.cost_of(5, 7), "Eigenschaften geerbt, einmal verschmolzen") - check(f.card.elements == ["Feuer", "Wasser"], "beide Elemente") - var c := put(h, "e_wall") + check(f.name == "Dampf" and f.attack == 5 and f.max_health == 7 and f.health == 5, "Dampf: 4+1 Angriff, 6+1 Leben, aktuell 4+1") + check("Spott" in f.card.keywords and f.card.fusions == 1 and f.card.cost == 5, "Eigenschaften geerbt, einmal verschmolzen, Kosten 5") + check(f.card.elements == ["Feuer", "Wasser"] and f.ready, "beide Elemente, beide waren bereit") + var c := put(h, "e_wall", false) check(Game.fusion_cost(f, c) == 2, "zweite Verschmelzung kostet 2") g.fuse(h, f, c) var f2: Game.Creature = h.board[0] - check(f2.name == "Chimäre" and f2.card.fusions == 2 and h.energy == 7, "Chimäre aus drei Elementen") + check(f2.name == "Chimäre" and f2.card.fusions == 2 and h.energy == 7 and not f2.ready, "Chimäre aus drei Elementen, nicht bereit, weil der Wall es nicht war") var d := put(h, "l_wind") check(Game.fusion_cost(f2, d) == 4, "dritte kostet 4") h.energy = 3 check(g.fuse(h, f2, d) == "Nicht genug Mana", "zu teuer") - check(g.can_fuse(h, f2, f2)[1] == "Zwei verschiedene Wesen wählen", "nicht mit sich selbst") + check(g.can_fuse(h, f2, f2)[1] == "Zwei verschiedene Karten wählen", "nicht mit sich selbst") # Verdeckte verschmelzen offen var s2 := setup() var x := put(s2[1], "f_wolf", true, true) var y := put(s2[1], "f_kobold", true, true) s2[0].fuse(s2[1], x, y) - check(not s2[1].board[0].hidden, "das Ergebnis liegt offen") - # Elementbonus der Eltern zählt nicht doppelt; Dampf nimmt den besseren Elementwert + check(not s2[1].board[0].hidden and s2[1].board[0].name == "Inferno", "das Ergebnis liegt offen") + # Elementbonus der Eltern zählt nicht doppelt; Dampf bekommt die Boni beider Elemente var s3 := setup() var g3: Game = s3[0] var h3: Game.Player = s3[1] var fa := put(h3, "f_wolf") var fb := put(h3, "w_tiefen") - for i in 3: + for i in 2: put(h3, "f_kobold") g3.apply_auras() - check(fa.aura == 1 and fa.max_health == 3, "vier Feuer vor dem Verschmelzen") + check(fa.aura == 1 and fa.max_health == 3, "drei Feuer vor dem Verschmelzen") g3.fuse(h3, fa, fb) var ff: Game.Creature = h3.board[0] - check(ff.aura == 1 and ff.max_health == 8 and ff.health == 8 and ff.power() == 6, "nach dem Verschmelzen zählt der Bonus nur einmal") - for i in 3: + check(ff.aura == 1 and ff.max_health == 8 and ff.health == 8 and ff.power() == 6, "nach dem Verschmelzen zählt der Bonus nur einmal (7 + 1)") + for i in 2: put(h3, "w_geist") g3.apply_auras() - check(g3.element_counts()["Wasser"] == 4 and ff.aura == 1, "Wasser +1 und Feuer -1: Dampf nimmt den besseren Wert") + check(g3.element_counts()["Wasser"] == 3 and ff.aura == 2 and ff.max_health == 9, "Feuer und Wasser voll: Dampf bekommt +2/+2") + # Auf der Hand + var s4 := setup(["f_wolf", "w_tiefen", "l_wind"]) + var g4: Game = s4[0] + var h4: Game.Player = s4[1] + var hand_a: Cards.Card = h4.hand[0] + var hand_b: Cards.Card = h4.hand[1] + check(g4.can_fuse(h4, hand_a, put(h4, "e_wall"))[1] == "Nur Hand mit Hand oder Brett mit Brett", "Hand und Brett mischen sich nicht") + check(g4.fuse(h4, hand_a, hand_b) == "" and h4.hand.size() == 2 and h4.energy == 9, "zwei Handkarten verschmolzen, 1 Mana") + var hc: Cards.Card = h4.hand[0] + check(hc.name == "Dampf" and hc.attack == 5 and hc.health == 7 and hc.cost == 5 and hc.fusions == 1, "Dampf auf der Hand mit 5/7 und Kosten 5") + var lava := Cards.fused(C("f_asche"), C("f_kobold"), 4, 8) + check(Cards.fused(lava, C("f_kobold")).attack == 5 and Cards.fused(lava, C("f_kobold")).health == 9, "ein Kobold macht Lava 4/8 zu 5/9, nie schlechter") + check(g4.play_card(h4, hc) == "" and h4.energy == 4 and h4.board.back().name == "Dampf", "Ausspielen kostet die neuen Kosten") + check(g4.log.any(func(l): return l == "Flammenwolf und Tiefenwächter verschmelzen auf der Hand zu Dampf (5/7)."), "Verlauf") func test_limits() -> void: @@ -422,7 +476,7 @@ func test_gleiche_regeln() -> void: var g := Game.new_battle(r) var first: Game.Player = g.players[0] check(g.current() == first and first.max_energy == Game.START_MANA and first.energy == Game.START_MANA, "Startspieler: 5 Mana, kein Bonus") - check(first.offer.size() == Game.START_OFFER and first.hand.is_empty(), "jeder beginnt mit einer Wahl aus fünf und leerer Hand") + check(g.players.all(func(p): return p.hand.size() == Game.START_HAND), "jeder beginnt mit fünf Karten") for p in g.players: check(p.life == Game.START_LIFE, "gleiches Leben") if p != first: @@ -474,7 +528,7 @@ func test_booster() -> void: for i in 50: var rolled := Profile.roll_booster(r) check(rolled.size() == 5, "5 Karten") - check(rolled.any(func(x): return x.rarity != "gewöhnlich"), "mindestens eine seltene") + check(rolled.any(func(x): return x.rarity != "Common"), "mindestens eine Rare") var p := Profile.new() p.coins = Profile.BOOSTER_COST var got := p.open_booster(r) @@ -490,7 +544,23 @@ func test_booster() -> void: p.collection["f_wolf"] = Cards.RARITIES.size() - 1 check(p.upgrade("f_wolf") != "", "über die höchste Stufe hinaus geht nicht") p.collection.erase("f_wolf") - check(p.dismantle("f_wolf") != "" and p.rarity("f_wolf") == "gewöhnlich", "gewöhnliche Karten gehören allen") + check(p.dismantle("f_wolf") != "" and p.rarity("f_wolf") == "Common", "Common-Karten gehören allen") + check(Profile.RARITY_WEIGHTS.values().reduce(func(a, b): return a + b, 0.0) == 100.0, "Chancen ergeben 100 %") + check(Profile.DUST_UPGRADE["Rare"] == 100 and Profile.DUST_UPGRADE["Ultimate Rare"] == 1000 and Profile.DUST_DISMANTLE["Common"] == 50 and Profile.DUST_DISMANTLE["Ultimate Rare"] == 1000, "Staubpreise") + # Verschmolzene Karten tragen die höhere Stufe der Eltern + var s := setup(["f_wolf", "w_tiefen"]) + var g: Game = s[0] + var h: Game.Player = s[1] + h.looks = {"w_tiefen": 3} + check(h.hand[0].rarity_in(h.looks) == "Common" and h.hand[1].rarity_in(h.looks) == "Ultra Rare", "Stufe aus der Sammlung") + g.fuse(h, h.hand[0], h.hand[1]) + check(h.hand[0].look == 3 and h.hand[0].rarity_in(h.looks) == "Ultra Rare", "Fusion auf der Hand: die höhere Stufe gewinnt") + var kobold := put(h, "f_kobold") + g.play_card(h, h.hand[0]) + var fused: Game.Creature = h.board[1] + g.fuse(h, fused, kobold) + check(h.board[0].card.look == 3 and h.board[0].card.rarity_in(h.looks) == "Ultra Rare", "auch auf dem Brett und über zwei Fusionen") + check(Cards.Card.from_dict(h.board[0].card.to_dict()).look == 3, "die Stufe reist mit der Karte") func test_spielstand() -> void: @@ -500,7 +570,7 @@ func test_spielstand() -> void: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) var p := Profile.load_or_new(path) - check(p.coins == Profile.START_COINS and p.collection.is_empty(), "Starterprofil: alle Karten gewöhnlich") + check(p.coins == Profile.START_COINS and p.collection.is_empty(), "Starterprofil: alle Karten Common") check(FileAccess.file_exists(path), "Datei angelegt") p.coins = 999 p.collection["f_wolf"] = 2 @@ -525,8 +595,8 @@ func test_kopie() -> void: check(g2.players[1].life == 11 and o.life == 15, "Kopie verändert das Original nicht") g2.play_card(g2.players[0], g2.players[0].hand[0]) check(h.hand.size() == 1 and g2.players[0].hand.is_empty(), "Hand der Kopie unabhängig") - g2.upgrade(g2.players[0], a2, "Schild") - check(not a.shield and a.card.keywords.is_empty(), "Verbessern in der Kopie lässt das Original unberührt") + g2.upgrade(g2.players[0], a2, "Spott") + check(not a.has("Spott") and a.card.keywords.is_empty() and a2.has("Spott"), "Verbessern in der Kopie lässt das Original unberührt") check(g2.log.is_empty() and g2.events.is_empty(), "Kopie schreibt keinen Verlauf") @@ -667,16 +737,15 @@ func test_merkmale() -> void: var s := setup(["f_wolf", "l_falke"]) var g: Game = s[0] var h: Game.Player = s[1] - h.perks = ["feuer_angriff", "wesen_angriff", "wesen_leben", "schild_wesen", "ziehen_gratis"] + h.perks = ["feuer_angriff", "wesen_angriff", "wesen_leben", "alle_flink", "ziehen_gratis"] g.play_card(h, h.hand[0]) var wolf: Game.Creature = h.board[0] - check(wolf.power() == 3 + 1 + 1 and wolf.health == 3 and wolf.shield and wolf.can_attack(), "Feuer +1, Wesen +1/+1, Schild") + check(wolf.power() == 3 + 1 + 1 and wolf.health == 3 and wolf.has("Flink") and wolf.can_attack(), "Feuer +1, Wesen +1/+1, flink") g.play_card(h, h.hand[0]) check(h.board[1].power() == 3 + 1, "Luft: nur Wesen +1") g.deck = DeckGen.deck(g.rng) var mana := h.energy - check(g.draw(h) == "" and h.energy == mana, "Merkmal: Ziehen kostet nichts") - g.pick(h, 0) + check(g.draw(h) == "" and h.energy == mana and h.hand.size() == 1, "Merkmal: Ziehen kostet nichts") for perk in Game.PERKS: check(Campaign.STAGES.any(func(st): return st.perk == perk), "Merkmal vergeben: " + perk) var r := RandomNumberGenerator.new() @@ -695,17 +764,18 @@ func test_merkmale() -> void: func test_ki_zwei_schritte() -> void: - # Gegner hat 5 Leben und kein Wesen; der Wüstenkönig (5 Angriff) liegt auf der Hand: - # erst ausspielen, dann mit ihm töten. - var s := setup(["el_koenig"]) + # Gegner hat 5 Leben und kein Wesen; der Wüstenkönig (5 Angriff) liegt frisch auf dem Brett: + # erst Flink kaufen, dann mit ihm töten. + var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] h.turns = 2 o.life = 5 + var king := put(h, "el_koenig", false) put(h, "f_wolf", false) var act := AI.choose_action(g, h) - check(act.type == "play" and act.card == h.hand[0] and not act.face_down, "KI spielt zuerst den König, offen") + check(act.type == "upgrade" and act.creature == king and act.keyword == "Flink", "KI kauft zuerst Flink für den König") AI.apply_action(g, h, act) var act2 := AI.choose_action(g, h) check(act2.type == "attack" and act2.target == o, "dann tödlicher Angriff") @@ -718,26 +788,26 @@ func test_snapshot() -> void: var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - g.upgrade(h, h.hand[0], "Schild") + g.upgrade(h, h.hand[0], "Spott") g.play_card(h, h.hand[0]) - put(o, "e_troll") + var troll := put(o, "e_troll") + troll.attacks_done = 1 var hi := g.players.find(h) var oi := g.players.find(o) h.energy = 5 o.energy = 5 g.deck = DeckGen.deck(g.rng) g.draw(h) - o.offer = g.draw_cards(3, 5) - o.picks_left = 1 var snap := g.snapshot(hi) var json: Dictionary = JSON.parse_string(JSON.stringify(snap)) var g2 := Game.from_snapshot(json) check(g2.players.size() == 2 and g2.turn == g.turn and g2.current_idx == g.current_idx and g2.deck.size() == g.deck.size(), "Grunddaten und Stapelgröße") - check(g2.players[hi].hand.is_empty(), "eigene Hand sichtbar") + check(g2.players[hi].hand.size() == 1 and g2.players[hi].hand[0].id == h.hand[0].id, "eigene Hand sichtbar") check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0].id != "w_frost", "fremde Hand nur als Anzahl") - check(g2.players[oi].offer.is_empty() and g2.players[hi].offer.size() == Game.OFFER_SIZE and g2.players[hi].picks_left == 1, "eigene Wahl sichtbar, fremde nicht") var cr2 := g2.find_creature(h.board[0].uid) - check(cr2 != null and cr2.card.id == "f_wolf" and cr2.owner() == g2.players[hi] and cr2.shield and "Schild" in cr2.card.keywords, "Wesen mit Besitzer, Zustand und gekaufter Eigenschaft") + check(cr2 != null and cr2.card.id == "f_wolf" and cr2.owner() == g2.players[hi] and cr2.has("Spott") and not cr2.ready, "Wesen mit Besitzer, Zustand und gekaufter Eigenschaft") + var troll2 := g2.find_creature(troll.uid) + check(troll2.ready and troll2.attacks_done == 1, "Bereitschaft und Angriffe reisen mit") check(g2.events.size() == g.events.size() and g2.events.back().type == g.events.back().type, "Ereignisse übertragen") check(g2.draw(g2.players[oi]) == "Nicht dein Zug", "Kopie kennt die Regeln") var snap_o := g.snapshot(oi) @@ -773,9 +843,6 @@ func test_ki_zeitbudget() -> void: r.seed = 12 var g := Game.new_battle(r) var h: Game.Player = g.players[0] - for p in g.players: - p.offer = [] - p.picks_left = 0 h.energy = 30 for i in Game.MAX_HAND: h.hand.append(C("f_ritter")) @@ -796,11 +863,11 @@ func test_ki_zeitbudget() -> void: func test_englische_karten() -> void: check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen") var ritter := C("f_ritter") - ritter.keywords = ["Schild", "Doppelschlag"] + ritter.keywords = ["Spott", "Doppelschlag"] TranslationServer.set_locale("en") - check(ritter.title() == "Blazing Knight" and ritter.text() == "Shield, Double Strike.", "Ritter auf Englisch: %s / %s" % [ritter.title(), ritter.text()]) + check(ritter.title() == "Blazing Knight" and ritter.text() == "Taunt, Double Strike.", "Ritter auf Englisch: %s / %s" % [ritter.title(), ritter.text()]) TranslationServer.set_locale("de") - check(ritter.title() == "Lodernder Ritter" and ritter.text() == "Schild, Doppelschlag.", "zurück auf Deutsch") + check(ritter.title() == "Lodernder Ritter" and ritter.text() == "Spott, Doppelschlag.", "zurück auf Deutsch") ## Voller Spielstand (viewer -1) stellt Hände und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes. @@ -826,7 +893,7 @@ func test_kampf_speichern() -> void: guard += 1 check(g2.winner != "", "der volle Spielstand ist bis zum Ende spielbar") var s2: Dictionary = g.snapshot(-2) - check(s2.players.all(func(p): return not (p.hand is Array) and p.offer.is_empty()), "Zuschauer-Sicht ohne Hände und Wahlen") + check(s2.players.all(func(p): return not (p.hand is Array)), "Zuschauer-Sicht ohne Hände") ## Jeder tr("...")-Text in der Oberfläche hat einen englischen Eintrag. @@ -843,7 +910,7 @@ func test_uebersetzung_vollstaendig() -> void: key = line.substr(0, line.find(",")) keys[key] = true var missing := [] - for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + Cards.KEYWORD_TEXT.values() + Game.PERKS.values(): + for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + Cards.KEYWORD_TEXT.values() + Game.PERKS.values() + Cards.ELEMENT_KEYWORD.values(): if not keys.has(k): missing.append(k) var d := DirAccess.open("res://scripts/ui") diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 052d976..de54b15 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -54,11 +54,11 @@ func right_click() -> void: await right_click_at(Vector2(5, 5)) -func right_click_at(pos: Vector2) -> void: +func right_click_at(pos: Vector2, button: int = MOUSE_BUTTON_RIGHT) -> void: var down := InputEventMouseButton.new() down.position = pos down.global_position = pos - down.button_index = MOUSE_BUTTON_RIGHT + down.button_index = button down.pressed = true get_viewport().push_input(down) var up: InputEventMouseButton = down.duplicate() @@ -219,20 +219,42 @@ func target_view(scr, valid: Array) -> Control: return null -## Nimmt alle offenen Wahlen des Spielers (immer die erste Karte), auch über das Netz. -func pick_all(scr) -> void: - var guard := 0 - while scr.game != null and not scr.perspective.offer.is_empty() and guard < 60: - guard += 1 - if scr.pick_views.is_empty(): - await frames(1) - continue - var n: int = scr.perspective.offer.size() - await click(scr.pick_views[0]) - var w := 0 - while scr.game != null and scr.perspective.offer.size() == n and w < 120: - await frames(1) - w += 1 +## Zieht eine Karte oder Kachel auf eine andere und hält sie dort, bis die beiden verschmelzen. +func drag_fuse(scr, from: Control, to: Control) -> void: + await frames(20) # Kacheln sind noch unterwegs (Einfliegen, Nachrücken) + await settle(scr) + var a := from.get_global_rect().get_center() + var b := to.get_global_rect().get_center() + var down := InputEventMouseButton.new() + down.position = a + down.global_position = a + down.button_index = MOUSE_BUTTON_LEFT + down.pressed = true + get_viewport().push_input(down) + await frames(1) + for i in range(1, 6): + var m := InputEventMouseMotion.new() + m.position = a.lerp(b, i / 5.0) + m.global_position = m.position + m.button_mask = MOUSE_BUTTON_MASK_LEFT + get_viewport().push_input(m) + await frames(1) + check(scr.drag_ghost != null and scr.drag_target != null, "Karte schwebt über einem Verschmelzungsziel") + var waited := 0 + while scr.drag_ghost != null and waited < 240: + var still := InputEventMouseMotion.new() # die Maus bleibt auf dem Ziel, auch wenn die echte zuckt + still.position = b + still.global_position = b + still.button_mask = MOUSE_BUTTON_MASK_LEFT + get_viewport().push_input(still) + await frames(1) + waited += 1 + var up: InputEventMouseButton = down.duplicate() + up.pressed = false + up.position = b + up.global_position = b + get_viewport().push_input(up) + await frames(2) ## Legt, was bezahlbar ist. @@ -257,10 +279,9 @@ func human_turn(scr) -> void: if g.winner != "" or not g.can_draw(g.human)[0]: break var mana: int = g.human.energy + var hand: int = g.human.hand.size() await click(scr.deck) - check(g.human.energy == mana - Game.DRAW_COST and not scr.pick_views.is_empty(), "gezogen: drei zur Wahl") - await pick_all(scr) - check(scr.pick_views.is_empty(), "gewählt") + check(g.human.energy == mana - Game.DRAW_COST and g.human.hand.size() == hand + 1, "gezogen: eine Karte") for i in 12: var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack()) if ready.is_empty() or g.winner != "": @@ -284,7 +305,7 @@ func human_turn(scr) -> void: targets.append(c) await settle(scr) await click(target_view(scr, targets)) - check(scr.selected == null and cr.attacks_left < cr.attacks_per_turn(), "Angriff ausgeführt") + check(scr.selected == null and cr.attacks_done > 0, "Angriff ausgeführt") if g.winner == "": await click(scr.b_end) @@ -293,7 +314,6 @@ func start_battle() -> void: await click(find_button("Einzelspieler")) check(screen() is OpponentSelectScreen, "Gegnerauswahl") await click(find_button("Starten")) - await pick_all(screen()) func test_gegnerauswahl() -> void: @@ -310,12 +330,9 @@ func test_gegnerauswahl() -> void: check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet") var opp: Game.Player = hard.game.opponent check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life) - check(hard.game.human.offer.size() == Game.START_OFFER and hard.game.human.hand.is_empty() and hard.pick_layer.visible, - "Start: fünf Karten zur Wahl, Hand leer") - check(hard.game.human.offer.all(func(c): return c.cost <= Game.START_MANA + 1), "alle fünf sind im ersten Zug spielbar") - await shot("05_startwahl") - await pick_all(hard) - check(hard.game.human.hand.size() == Game.START_PICKS and not hard.pick_layer.visible, "drei behalten, Wahl geschlossen") + check(hard.game.human.hand.size() == Game.START_HAND and hard.hand_views.size() == Game.START_HAND, "Start: fünf Karten auf der Hand") + check(hard.game.human.hand.all(func(c): return c.cost <= Game.START_MANA + 1), "alle fünf sind im ersten Zug spielbar") + await shot("05_starthand") check(hard.hero_panels[opp].position == BattleLayout.OPP_BOX.position, "der Gegner steht oben in der Mitte") await shot("11_kampf") await give_up() @@ -323,7 +340,6 @@ func test_gegnerauswahl() -> void: await click(find_button("Leicht")) await click(find_button("Starten")) var easy = screen() - await pick_all(easy) opp = easy.game.opponent check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life) check(easy.game.players[0].max_energy == Game.START_MANA and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide") @@ -393,21 +409,21 @@ func test_sammlung() -> void: await frames(1) var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0] await click(phoenix) - check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "gewöhnlich", "Karte gewählt, noch gewöhnlich") + check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "Common", "Karte gewählt, noch Common") await click(scr.b_upgrade) - check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["selten"], "auf selten aufgewertet") + check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet") scr.search_box.text = "" scr.search_box.text_changed.emit("") await frames(1) - var first: CardView = scr.grid.views()[0] - check(first.card.id == "ff_phoenix" and first.rarity == "selten", "die aufgewertete Karte steht vorn in der Sammlung") + var front: Array = scr.grid.views().slice(0, p.upgraded_count()) + check(front.all(func(v): return v.rarity != "Common") and front.any(func(v): return v.card.id == "ff_phoenix" and v.rarity == "Rare"), "aufgewertete Karten stehen vorn in der Sammlung, der Phönix als Rare dabei") scr.search_box.text = "Phönix" scr.search_box.text_changed.emit("Phönix") await frames(1) check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert") var dust: int = p.dust await click(scr.b_dismantle) - check(p.level("ff_phoenix") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["selten"], "wieder zerlegt") + check(p.level("ff_phoenix") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt") await shot("20_sammlung") await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") @@ -487,22 +503,21 @@ func test_kampf_steuerung() -> void: scr.ai_delay = 0.0 await wait_for_human(scr) var g: Game = scr.game - g.human.energy = 20 # die Wahl ist zufällig, hier soll nur die Steuerung geprüft werden - g.human.hand = [Cards.by_id("f_wolf")] + g.human.energy = 20 # die Starthand ist zufällig, hier soll nur die Steuerung geprüft werden + g.human.hand = [Cards.by_id("f_wolf").copy()] scr.refresh() - check(scr.hand_views.size() == 1 and scr.deck.enabled and scr.pick_views.is_empty(), "eine Handkarte, Stapel bereit, keine Wahl offen") - # Ziehen: 1 Mana weg, drei zur Wahl, eine landet auf der Hand + check(scr.hand_views.size() == 1 and scr.deck.enabled, "eine Handkarte, Stapel bereit") + # Ziehen: 1 Mana weg, eine Karte landet auf der Hand var mana: int = g.human.energy await click(scr.deck) - check(g.human.energy == mana - Game.DRAW_COST and scr.pick_views.size() == Game.OFFER_SIZE and scr.pick_layer.visible, "gezogen: drei Karten zur Wahl") - var wanted: Cards.Card = scr.pick_views[1].card - await click(scr.pick_views[1]) - check(g.human.hand.size() == 2 and g.human.hand[1].id == wanted.id and not scr.pick_layer.visible, "gewählt, liegt auf der Hand") - # Ausspielen kostet die Kartenkosten + check(g.human.energy == mana - Game.DRAW_COST and g.human.hand.size() == 2 and scr.hand_views.size() == 2, "gezogen: eine Karte auf der Hand") + check(g.human.hand[1].cost <= g.human.max_energy, "nur bezahlbare Karten") + # Ausspielen kostet die Kartenkosten; das Wesen greift noch nicht an var board_before: int = g.human.board.size() mana = g.human.energy await key(KEY_1) check(g.human.board.size() == board_before + 1 and g.human.energy == mana - Cards.by_id("f_wolf").cost, "Wesen per Taste gespielt, Kosten bezahlt") + check(not g.human.board.back().can_attack(), "frisch gelegt greift es noch nicht an") # Rückgängig: Wesen zurück auf die Hand, Mana zurück check(scr.b_undo.visible, "Rückgängig angeboten") await click(scr.b_undo) @@ -513,21 +528,16 @@ func test_kampf_steuerung() -> void: check(g.human.board.size() == board_before + 1, "erneut gespielt") # Ziehen per Taste mana = g.human.energy + var hand_before: int = g.human.hand.size() await key(KEY_Z) - check(g.human.energy == mana - Game.DRAW_COST and scr.pick_layer.visible, "Z zieht") - await key(KEY_1) - check(not scr.pick_layer.visible, "1 wählt die erste Karte") + check(g.human.energy == mana - Game.DRAW_COST and g.human.hand.size() == hand_before + 1, "Z zieht") await shot("22_kampf_steuerung") - # Gamepad: A auf dem Stapel zieht, Steuerkreuz wählt die zweite Karte, A nimmt sie + # Gamepad: A auf dem Stapel zieht await pad(JOY_BUTTON_DPAD_LEFT) check(scr.gp_zone == "deck" and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf dem Stapel") - var hand_before: int = g.human.hand.size() + hand_before = g.human.hand.size() await pad(JOY_BUTTON_A) - check(scr.pick_layer.visible, "Gamepad A zieht") - await pad(JOY_BUTTON_DPAD_RIGHT) - check(scr.gp_zone == "pick" and scr.gp_index == 1, "Zeiger auf der zweiten Karte der Wahl") - await pad(JOY_BUTTON_A) - check(g.human.hand.size() == hand_before + 1 and not scr.pick_layer.visible, "Gamepad A nimmt die Karte") + check(g.human.hand.size() == hand_before + 1, "Gamepad A zieht") scr.ai_delay = 0.75 await key(KEY_SPACE) check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") @@ -562,7 +572,6 @@ func test_kampf_grenzen() -> void: while g.human.hand.size() < Game.MAX_HAND and guard < 30: guard += 1 await click(scr.deck) - await pick_all(scr) check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND) check(not scr.deck.enabled, "bei voller Hand lässt sich nichts ziehen") await click(scr.deck) @@ -573,20 +582,17 @@ func test_kampf_grenzen() -> void: guard += 1 if g.human.hand.is_empty(): await click(scr.deck) - await pick_all(scr) await key(KEY_1) check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD) if g.human.hand.is_empty(): await click(scr.deck) - await pick_all(scr) await key(KEY_1) check(scr.msg_label.text == tr("Dein Feld ist voll"), "Meldung bei vollem Feld: " + scr.msg_label.text) await shot("25_kampf_voll") - # Verschmelzen macht wieder Platz + # Verschmelzen per Ziehen macht wieder Platz var before: int = g.human.board.size() - await click(scr.b_fuse) - await click(scr.own_views[0]) - await click(scr.own_views[1]) + scr.hold_time = 0.3 + await drag_fuse(scr, scr.own_views[0], scr.own_views[1]) check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei") # Ohne Mana kein Ziehen g.human.energy = 0 @@ -596,12 +602,13 @@ func test_kampf_grenzen() -> void: await give_up() -## Elementregel, verdeckt legen, Kartenmenü und Verschmelzen im Kampf. +## Elementboni, verdeckt legen, Kartenmenü und Verschmelzen (per Ziehen und per Menü) im Kampf. func test_kampf_elemente_und_verschmelzen() -> void: await start_battle() var scr = screen() check(scr is BattleScreen, "Kampf gestartet") scr.ai_delay = 0.0 + scr.hold_time = 0.3 await wait_for_human(scr) var g: Game = scr.game g.human.board.clear() @@ -609,7 +616,7 @@ func test_kampf_elemente_und_verschmelzen() -> void: var put := func(p: Game.Player, id: String, hidden: bool = false) -> Game.Creature: var cr := Game.Creature.new(Cards.by_id(id).copy(), p, g.next_uid) g.next_uid += 1 - cr.attacks_left = 1 + cr.ready = true cr.hidden = hidden p.board.append(cr) return cr @@ -620,56 +627,70 @@ func test_kampf_elemente_und_verschmelzen() -> void: var secret: Game.Creature = put.call(scr.opponent(), "f_kobold", true) g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()] g.human.energy = 20 + g.human.looks = {"w_reiter": 3} g.apply_auras() scr.refresh() await frames(2) - check(scr.element_view.text() == "Feuer 2 · Wasser 1 · Erde 0 · Luft 1", "Zähler ohne das verdeckte Wesen: " + scr.element_view.text()) - check(scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "fremdes verdecktes Wesen zeigt nur den Rücken, noch kein Bonus in Sicht") - # verdeckt legen über das Kartenmenü (Rechtsklick auf die Handkarte) - var menu_text := "Verdeckt legen ◆ %d" % Cards.by_id("f_kobold").cost + check(scr.element_view.text() == "Feuer 2 (Doppelschlag) · Wasser 1 · Erde 0 · Luft 1", "Zähler ohne das verdeckte Wesen, Feuerpaar: " + scr.element_view.text()) + check(g.human.board[0].keywords() == ["Doppelschlag"] and scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 1, "Feuerpaar gibt Doppelschlag; fremdes Verdecktes zeigt den Rücken; der Kobold würde den Stapel voll machen") + # Rechtsklick auf die Handkarte: Menü mit Verdeckt legen und Eigenschaften, ohne Verschmelzen await hover(scr.hand_views[0]) await right_click_at(scr.hand_views[0].get_global_rect().get_center()) - check(scr.menu_layer.visible and find_button(menu_text, scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü mit „Verdeckt legen“") + check(scr.menu_layer.visible and find_button("Verdeckt legen ◆ 1", scr.menu_layer) != null and find_button("Spott ◆ 2", scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü") + check(find_button("Verschmelzen mit … ◆ 1", scr.menu_layer) == null, "kein Verschmelzen im Rechtsklick-Menü") await shot("23_kartenmenue") - await click(find_button(menu_text, scr.menu_layer)) + await right_click() + check(not scr.menu_layer.visible, "Rechtsklick schließt das Menü") + # Mittlere Maustaste legt die Handkarte verdeckt + var mana: int = g.human.energy + await right_click_at(scr.hand_views[0].get_global_rect().get_center(), MOUSE_BUTTON_MIDDLE) var mine_hidden: Game.Creature = g.human.board[3] - check(mine_hidden.hidden and not scr.menu_layer.visible and scr.element_view.text().begins_with("Feuer 2"), "verdeckt gelegt, zählt nicht") - # aufdecken per Klick auf das eigene verdeckte Wesen (erst, wenn es aus der Hand angekommen ist) + check(mine_hidden.hidden and g.human.energy == mana - 1 and scr.element_view.text().begins_with("Feuer 2"), "mittlere Maustaste legt verdeckt, zählt nicht") + # Rechtsklick auf ein eigenes Wesen: Eigenschaften kaufen + await right_click_at(scr.own_views[0].get_global_rect().get_center()) + check(scr.menu_layer.visible and find_button("Doppelschlag ◆ 4", scr.menu_layer) != null and find_button("Verschmelzen mit … ◆ 1", scr.menu_layer) == null, "Wesen-Menü mit Eigenschaften, ohne Verschmelzen") + var wolf: Game.Creature = g.human.board[0] + await click(find_button("Spott ◆ %d" % Cards.KEYWORD_COST["Spott"], scr.menu_layer)) + check(wolf.has("Spott") and "Spott" in wolf.card.keywords and g.human.energy == mana - 1 - Cards.KEYWORD_COST["Spott"], "Spott gekauft") while scr.own_views[3].busy: await frames(1) await click(scr.own_views[3]) - check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3"), "aufgedeckt, zählt jetzt") - put.call(g.human, "f_kobold") - g.apply_auras() - scr.refresh() - await frames(2) - check(scr.element_view.text().begins_with("Feuer 4 (+1/+1)") and "Luft 1 (-1/-1)" in scr.element_view.text(), "vier Feuer: Bonus und Malus angezeigt: " + scr.element_view.text()) - check(g.human.board[0].power() == 4 and falke.power() == 2, "Feuerwesen +1, Luftwesen -1") - check(scr.hand_views[0].bonus == -1, "die Luftkarte auf der Hand zeigt -1") - await shot("23b_vier_feuer") - # Verbessern über das Menü - var wolf: Game.Creature = g.human.board[0] - scr.open_menu(wolf, Rect2(scr.own_views[0].slot, scr.own_views[0].size)) - var mana: int = g.human.energy - await click(find_button("Schild ◆ %d" % Cards.KEYWORD_COST["Schild"], scr.menu_layer)) - check(wolf.shield and "Schild" in wolf.card.keywords and g.human.energy == mana - Cards.KEYWORD_COST["Schild"], "Schild gekauft") + check(mine_hidden.hidden and scr.msg_label.text == "Feuerkobold kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text) + mine_hidden.ready = true + await click(scr.own_views[3]) + check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3 (+1/+1)"), "aufgedeckt: drei Feuer, +1/+1: " + scr.element_view.text()) + check(wolf.power() == 4 and falke.power() == 3 and mine_hidden.can_attack(), "Feuerwesen +1, kein Malus für Luft, aufgedeckt greift sofort an") + check(scr.hand_views[0].bonus == 0, "die Luftkarte auf der Hand zeigt keinen Bonus") + await shot("23b_drei_feuer") # Angriffsziel: Elementvorteil, verdeckte ohne Anzeige, Held gesperrt await click(scr.own_views[0]) check(scr.selected == wolf and scr.tiles[falke.uid].matchup == 1 and scr.tiles[secret.uid].matchup == 0, "Angreifer gewählt: +1 gegen Luft, nichts gegen Verdeckte") check(not g.can_attack(wolf, scr.opponent())[0], "Held nicht angreifbar, solange Wesen stehen") await click(scr.tiles[secret.uid]) check(not secret.hidden and not (secret in scr.opponent().board), "Angriff deckt auf und tötet den Kobold") + check(wolf.attacks_left() == 1, "Doppelschlag: ein Angriff bleibt") await right_click() - # Verschmelzen - await click(scr.b_fuse) - check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt") - await click(scr.own_views[1]) - check(scr.fuse_first != null, "erstes Wesen gewählt") - await shot("24_verschmelzen") + # Verschmelzen per Ziehen: den Aschekrieger auf den Wellenreiter ziehen und kurz halten mana = g.human.energy - await click(scr.own_views[2]) - check(g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt") - check(not scr.fusing, "Modus beendet") + var before: int = g.human.board.size() + await drag_fuse(scr, scr.own_views[1], scr.own_views[2]) + check(g.human.board.size() == before - 1 and g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt") + var dampf: Game.Creature = g.human.board.filter(func(c): return c.name == "Dampf")[0] + check(scr.tiles[dampf.uid].rarity() == "Ultra Rare" and dampf.card.look == 3, "die Fusion trägt die höhere Stufe (Wellenreiter war Ultra Rare)") + await shot("24_verschmelzen") + # Verschmelzen auf der Hand per Ziehen + g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()] + scr.refresh() + await frames(1) + await drag_fuse(scr, scr.hand_views[0], scr.hand_views[1]) + check(g.human.hand.size() == 2 and g.human.hand[0].name == "Inferno" and g.human.hand[0].attack == 3 and g.human.hand[0].health == 2, "zwei Kobolde auf der Hand zu Inferno 3/2") + # Gamepad-Menü (Y) bietet Verdeckt legen und Verschmelzen mit … + scr.open_menu(g.human.hand[0], Rect2(scr.hand_views[0].position, scr.hand_views[0].size), true) + check(find_button("Verdeckt legen ◆ 2", scr.menu_layer) != null, "Gamepad-Menü: Verdeckt legen") + await click(find_button("Verschmelzen mit … ◆ 2", scr.menu_layer)) + check(scr.fuse_first == g.human.hand[0], "Partner wählen") + await click(scr.hand_views[1]) + check(g.human.hand.size() == 1 and g.human.hand[0].name == "Blitz", "Inferno und Windgeist zu Blitz") await give_up() @@ -701,7 +722,6 @@ func test_netzwerk() -> void: var b = screen() check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host") b.ai_delay = 0.0 - await pick_all(b) var rounds := 0 while b.game != null and b.game.winner == "" and rounds < 2: if b.game.current() == b.perspective: @@ -807,7 +827,6 @@ func test_host_wechsel() -> void: await frames(1) w += 1 check(b.game != null and main.net.i_am_successor and not main.net.backup.is_empty(), "Nachfolger kennt den vollen Spielstand") - await pick_all(b) main.net.rejoin_seconds = 3.0 _bot_quit(true) w = 0 @@ -881,7 +900,6 @@ func test_kampagne() -> void: await click(find_button("Kämpfen")) var battle = screen() check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") - await pick_all(battle) check(battle.game.opponent.name == "Fenn", "Fenn ist der Gegner") check(battle.game.opponent.perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv") await shot("16_kampagne_kampf") @@ -897,7 +915,6 @@ func test_kampagne() -> void: await shot("27_kampagne_kapitel2") await click(find_button("Kämpfen")) var b2 = screen() - await pick_all(b2) check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 22, "Kapitel-2-Boss mit zwei Merkmalen und 22 Leben") check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf") await give_up() @@ -951,16 +968,17 @@ func test_kampf_abbrechen() -> void: # Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit g.human.energy = 10 var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000) + wolf.ready = true g.human.board.append(wolf) g.human.hand.append(Cards.by_id("w_reiter")) - g.human.looks["w_reiter"] = Cards.RARITIES.find("episch") + g.human.looks["w_reiter"] = Cards.RARITIES.find("Super Rare") scr.refresh() var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0] - check(reiter_view.rarity == "episch", "Handkarte trägt die eigene Stufe") + check(reiter_view.rarity == "Super Rare", "Handkarte trägt die eigene Stufe") await hover(scr.element_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird await hover(reiter_view) var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView) - check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls") + check(previews.size() == 1 and previews[0].rarity == "Super Rare", "Vorschau ebenfalls") await shot("21_kampf_vorschau") if not scr.own_views.is_empty(): await click(scr.own_views[0]) diff --git a/translations/ui.csv b/translations/ui.csv index 7fd94c5..a1d347a 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -65,13 +65,12 @@ Große Schrift,Large text Abbrechen,Cancel "Ja, zurücksetzen","Yes, reset" Flink,Swift -Schild,Shield Doppelschlag,Double Strike -gewöhnlich,common -selten,rare -episch,epic -legendär,legendary -mythisch,mythic +Common,Common +Rare,Rare +Super Rare,Super Rare +Ultra Rare,Ultra Rare +Ultimate Rare,Ultimate Rare Feuer,Fire Wasser,Water Erde,Earth @@ -84,9 +83,6 @@ Zuschauer,Spectator Starten,Start Sammlung,Collection Verschmelzen,Fuse -Zwei eigene Wesen wählen,Pick two of your creatures -Zweites Wesen wählen,Pick the second creature -Zwei verschiedene Wesen wählen,Pick two different creatures Nur eigene Wesen,Only your own creatures Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d %s ist jetzt %s,%s is now %s @@ -99,7 +95,7 @@ Keine Karte gewählt,No card selected Zerlegen (+%d Staub),Dismantle (+%d dust) Nicht genug Staub (%d nötig),Not enough dust (%d needed) Schon auf der höchsten Stufe,Already at the top tier -Gewöhnliche Karten lassen sich nicht zerlegen,Common cards cannot be dismantled +Common-Karten lassen sich nicht zerlegen,Common cards cannot be dismantled Aufgewertet,Upgraded +%d Staub,+%d dust Dein Zug,Your turn @@ -109,19 +105,13 @@ Profil wirklich zurücksetzen?,Really reset your profile? %d Leben,%d life Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win Start gegen die KI,Start against the AI -Wähle %d von %d,Pick %d of %d Ziehen,Draw -Erst wählen,Pick first Deine Hand ist voll,Your hand is full -Keine Wahl offen,Nothing to pick -Keine solche Karte,No such card -Vorbei,Over Verdeckt legen ◆ %d,Set face-down ◆ %d Aufdecken,Reveal verdeckt,face-down Aufgedeckt!,Revealed! Elementbonus +%d/+%d,Element bonus +%d/+%d -Elementbonus %d/%d,Element bonus %d/%d Merkmal +%d,Perk +%d nächster Zug ◆ %d,next turn ◆ %d Erst die Wesen,Creatures first @@ -133,7 +123,6 @@ Wesen kommen mit +1 Angriff,Creatures enter with +1 attack Hat im ersten Zug 2 Mana mehr,Has 2 extra mana on the first turn Elementbonus zählt doppelt,Element bonus counts twice Beginnt mit 5 Leben mehr,Starts with 5 extra life -Blockt den ersten Schaden,Blocks the first damage Greift zweimal je Zug,Attacks twice per turn Verdeckt,Face-down Inferno,Inferno @@ -149,6 +138,19 @@ Sand,Sand Feuerwesen haben +1 Angriff,Fire creatures have +1 attack Wesen kommen mit +1 Leben,Creatures enter with +1 health Bekommt jeden Zug 1 Mana mehr,Gets 1 extra mana each turn -Wesen kommen mit Schild,Creatures enter with a shield Ziehen kostet kein Mana,Drawing costs no mana Eigenschaften kosten 1 weniger,Traits cost 1 less +Spott,Taunt +Regeneration,Regeneration +Muss zuerst angegriffen werden,Must be attacked first +Greift sofort an,Attacks right away +Heilt sich am Zugende ganz,Heals fully at the end of the turn +Wesen greifen sofort an,Creatures attack right away +Verschmelzen mit … ◆ %d,Fuse with … ◆ %d +Ziel wählen,Pick the partner +Erst die Wesen mit Spott,Taunting creatures first +%s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn +%s greift erst im nächsten Zug an,%s attacks next turn +greift erst im nächsten Zug an,attacks next turn +%s durch das Element,%s from its element +Zwei verschiedene Karten wählen,Pick two different cards diff --git a/translations/ui.en.translation b/translations/ui.en.translation index c0e3f8c..bedd614 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ