diff --git a/README.md b/README.md index 8dd8641..c2ec66e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,15 @@ Der Spielstand liegt unter `user://spielstand.json` - Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie) - Kartentext passt sich der Schriftgröße an, Gamepad auch in Menüs (Fokusrahmen), Anleitung 2 erzwingt Maras Angriff in die Falle - KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso +- KI-Bewertung mit Bedrohung (gegnerische Angriffskraft ohne eigenen Wächter, tödliche besonders) und Führenden-Bremse; im Duell 62:38 gegen die alte Bewertung +- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) werden laufend gespeichert und lassen sich im Menü fortsetzen +- Gamepad im Deckbau, in der Werkstatt und beim Booster: Karten, Deckzeilen und Kartenrücken sind fokussierbar, A wählt, B geht zurück +- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`) +- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau +- Kapitel 3 der Kampagne (drei Merkmale, volle Tische), acht neue Karten (je Elementpaar eine, zwei Fallen), Elementbooster für 120 Münzen +- Netzwerk: fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung (er bekommt laufend den vollen Spielstand), die anderen verbinden neu; Zuschauer sehen die Partie ohne Hände +- Balance: Sand gibt +1/+1 statt Schild, Erde+Luft und Erde leicht geschwächt, Wasser angehoben; Archetypen mit 200 Partien je Kombination gemessen +- Große Schrift: Kartennamen neben dem Kostenkreis, Kartentext läuft nicht mehr über, breitere Knöpfe in Booster, Deckbau und Werkstatt - Übersetzungsprüfung: jeder tr()-Text und jeder Anleitungsschritt braucht einen englischen Eintrag ## Tests @@ -57,6 +66,8 @@ godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen K godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH) DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots +DINGER_ONLY=test_host_wechsel,test_zuschauer godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests +N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern ``` diff --git a/anforderungen.md b/anforderungen.md index 142939f..c8c4d68 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -6,14 +6,15 @@ - Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln - Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl - Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt -- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich +- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich, auch als Zuschauer +- Ein unterbrochener Kampf lässt sich später fortsetzen - Geführte erste Partie als Einstieg - Wöchentliche Herausforderungen und Bestenliste als Langzeitziel ## Sammeln - Booster öffnen - Karten haben Seltenheiten -- Aus der Sammlung werden Decks gebaut (mehrere Slots) +- Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt - Überschüssige Karten lassen sich zerlegen und neue herstellen ## Elemente diff --git a/data/karten.json b/data/karten.json index 3b48e1b..8d9bcae 100644 --- a/data/karten.json +++ b/data/karten.json @@ -3,6 +3,7 @@ { "id": "f_funke", "name": "Funke", + "name_en": "Spark", "elements": [ "Feuer" ], @@ -20,6 +21,7 @@ { "id": "f_kobold", "name": "Feuerkobold", + "name_en": "Fire Kobold", "elements": [ "Feuer" ], @@ -32,6 +34,7 @@ { "id": "f_wolf", "name": "Flammenwolf", + "name_en": "Flame Wolf", "elements": [ "Feuer" ], @@ -44,6 +47,7 @@ { "id": "f_feuerball", "name": "Feuerball", + "name_en": "Fireball", "elements": [ "Feuer" ], @@ -61,6 +65,7 @@ { "id": "f_asche", "name": "Aschekrieger", + "name_en": "Ash Warrior", "elements": [ "Feuer" ], @@ -73,6 +78,7 @@ { "id": "f_drache", "name": "Feuerdrache", + "name_en": "Fire Dragon", "elements": [ "Feuer" ], @@ -92,6 +98,7 @@ { "id": "w_eis", "name": "Eissplitter", + "name_en": "Ice Shard", "elements": [ "Wasser" ], @@ -108,6 +115,7 @@ { "id": "w_geist", "name": "Flussgeist", + "name_en": "River Spirit", "elements": [ "Wasser" ], @@ -127,6 +135,7 @@ { "id": "w_reiter", "name": "Wellenreiter", + "name_en": "Wave Rider", "elements": [ "Wasser" ], @@ -139,6 +148,7 @@ { "id": "w_gezeiten", "name": "Gezeiten", + "name_en": "Tides", "elements": [ "Wasser" ], @@ -156,6 +166,7 @@ { "id": "w_frost", "name": "Frostmagier", + "name_en": "Frost Mage", "elements": [ "Wasser" ], @@ -174,6 +185,7 @@ { "id": "w_kraken", "name": "Kraken", + "name_en": "Kraken", "elements": [ "Wasser" ], @@ -192,6 +204,7 @@ { "id": "e_wall", "name": "Steinwall", + "name_en": "Stone Wall", "elements": [ "Erde" ], @@ -207,6 +220,7 @@ { "id": "e_wurzel", "name": "Wurzelwächter", + "name_en": "Root Guardian", "elements": [ "Erde" ], @@ -222,6 +236,7 @@ { "id": "e_haerten", "name": "Verhärten", + "name_en": "Harden", "elements": [ "Erde" ], @@ -240,6 +255,7 @@ { "id": "e_golem", "name": "Felsgolem", + "name_en": "Rock Golem", "elements": [ "Erde" ], @@ -252,13 +268,14 @@ { "id": "e_troll", "name": "Bergtroll", + "name_en": "Mountain Troll", "elements": [ "Erde" ], "cost": 4, "rarity": "gewöhnlich", "kind": "Wesen", - "attack": 4, + "attack": 3, "health": 6, "keywords": [ "Wächter" @@ -267,6 +284,7 @@ { "id": "e_baum", "name": "Uralter Baum", + "name_en": "Ancient Tree", "elements": [ "Erde" ], @@ -289,6 +307,7 @@ { "id": "l_wind", "name": "Windgeist", + "name_en": "Wind Spirit", "elements": [ "Luft" ], @@ -304,6 +323,7 @@ { "id": "l_falke", "name": "Falke", + "name_en": "Falcon", "elements": [ "Luft" ], @@ -319,6 +339,7 @@ { "id": "l_stoss", "name": "Windstoß", + "name_en": "Gust", "elements": [ "Luft" ], @@ -335,6 +356,7 @@ { "id": "l_taenzer", "name": "Sturmtänzer", + "name_en": "Storm Dancer", "elements": [ "Luft" ], @@ -350,6 +372,7 @@ { "id": "l_schlange", "name": "Luftschlange", + "name_en": "Air Serpent", "elements": [ "Luft" ], @@ -365,6 +388,7 @@ { "id": "l_greif", "name": "Greif", + "name_en": "Griffin", "elements": [ "Luft" ], @@ -387,6 +411,7 @@ { "id": "ff_walze", "name": "Flammenwalze", + "name_en": "Flame Roller", "elements": [ "Feuer", "Feuer" @@ -405,6 +430,7 @@ { "id": "ff_elementar", "name": "Inferno-Elementar", + "name_en": "Inferno Elemental", "elements": [ "Feuer", "Feuer" @@ -425,6 +451,7 @@ { "id": "ff_phoenix", "name": "Phönix", + "name_en": "Phoenix", "elements": [ "Feuer", "Feuer" @@ -448,6 +475,7 @@ { "id": "ww_eiszeit", "name": "Eiszeit", + "name_en": "Ice Age", "elements": [ "Wasser", "Wasser" @@ -470,6 +498,7 @@ { "id": "ww_schrecken", "name": "Tiefseeschrecken", + "name_en": "Deep Sea Terror", "elements": [ "Wasser", "Wasser" @@ -490,6 +519,7 @@ { "id": "ww_leviathan", "name": "Leviathan", + "name_en": "Leviathan", "elements": [ "Wasser", "Wasser" @@ -518,6 +548,7 @@ { "id": "ee_bastion", "name": "Felsbastion", + "name_en": "Rock Bastion", "elements": [ "Erde", "Erde" @@ -535,6 +566,7 @@ { "id": "ee_riese", "name": "Bergriese", + "name_en": "Mountain Giant", "elements": [ "Erde", "Erde" @@ -551,6 +583,7 @@ { "id": "ee_titan", "name": "Titan der Erde", + "name_en": "Titan of Earth", "elements": [ "Erde", "Erde" @@ -575,6 +608,7 @@ { "id": "ll_wirbel", "name": "Wirbelsturm", + "name_en": "Whirlwind", "elements": [ "Luft", "Luft" @@ -597,6 +631,7 @@ { "id": "ll_adler", "name": "Sturmadler", + "name_en": "Storm Eagle", "elements": [ "Luft", "Luft" @@ -614,6 +649,7 @@ { "id": "ll_herr", "name": "Herr der Winde", + "name_en": "Lord of Winds", "elements": [ "Luft", "Luft" @@ -638,6 +674,7 @@ { "id": "fw_wolke", "name": "Dampfwolke", + "name_en": "Steam Cloud", "elements": [ "Feuer", "Wasser" @@ -660,6 +697,7 @@ { "id": "fw_kessel", "name": "Kesselgeist", + "name_en": "Cauldron Spirit", "elements": [ "Feuer", "Wasser" @@ -685,6 +723,7 @@ { "id": "fw_geysir", "name": "Geysir-Drache", + "name_en": "Geyser Dragon", "elements": [ "Feuer", "Wasser" @@ -709,6 +748,7 @@ { "id": "fe_golem", "name": "Lavagolem", + "name_en": "Lava Golem", "elements": [ "Feuer", "Erde" @@ -729,6 +769,7 @@ { "id": "fe_wall", "name": "Magmawall", + "name_en": "Magma Wall", "elements": [ "Feuer", "Erde" @@ -752,6 +793,7 @@ { "id": "fe_vulkan", "name": "Vulkanherz", + "name_en": "Volcano Heart", "elements": [ "Feuer", "Erde" @@ -775,6 +817,7 @@ { "id": "fl_schlag", "name": "Blitzschlag", + "name_en": "Lightning Strike", "elements": [ "Feuer", "Luft" @@ -793,6 +836,7 @@ { "id": "fl_vogel", "name": "Donnervogel", + "name_en": "Thunderbird", "elements": [ "Feuer", "Luft" @@ -810,6 +854,7 @@ { "id": "fl_gott", "name": "Gewittergott", + "name_en": "Thunder God", "elements": [ "Feuer", "Luft" @@ -833,6 +878,7 @@ { "id": "we_kroete", "name": "Sumpfkröte", + "name_en": "Swamp Toad", "elements": [ "Wasser", "Erde" @@ -855,6 +901,7 @@ { "id": "we_moor", "name": "Moorwächter", + "name_en": "Bog Guardian", "elements": [ "Wasser", "Erde" @@ -862,7 +909,7 @@ "cost": 5, "rarity": "episch", "kind": "Wesen", - "attack": 3, + "attack": 4, "health": 8, "keywords": [ "Wächter", @@ -872,11 +919,12 @@ { "id": "we_koloss", "name": "Sumpfkoloss", + "name_en": "Swamp Colossus", "elements": [ "Wasser", "Erde" ], - "cost": 8, + "cost": 7, "rarity": "legendär", "kind": "Wesen", "attack": 5, @@ -895,6 +943,7 @@ { "id": "wl_boe", "name": "Sturmbö", + "name_en": "Squall", "elements": [ "Wasser", "Luft" @@ -917,6 +966,7 @@ { "id": "wl_nebel", "name": "Nebelreiter", + "name_en": "Mist Rider", "elements": [ "Wasser", "Luft" @@ -924,7 +974,7 @@ "cost": 4, "rarity": "episch", "kind": "Wesen", - "attack": 3, + "attack": 4, "health": 3, "keywords": [ "Flink" @@ -939,6 +989,7 @@ { "id": "wl_gischt", "name": "Gischtläufer", + "name_en": "Spray Runner", "elements": [ "Wasser", "Luft" @@ -962,6 +1013,7 @@ { "id": "wl_koenigin", "name": "Sturmkönigin", + "name_en": "Storm Queen", "elements": [ "Wasser", "Luft" @@ -969,7 +1021,7 @@ "cost": 7, "rarity": "legendär", "kind": "Wesen", - "attack": 5, + "attack": 6, "health": 6, "keywords": [ "Flink" @@ -984,6 +1036,7 @@ { "id": "el_laeufer", "name": "Sandläufer", + "name_en": "Sand Runner", "elements": [ "Erde", "Luft" @@ -992,7 +1045,7 @@ "rarity": "selten", "kind": "Wesen", "attack": 2, - "health": 3, + "health": 2, "keywords": [ "Flink", "Schild" @@ -1001,6 +1054,7 @@ { "id": "el_duene", "name": "Dünenwächter", + "name_en": "Dune Guardian", "elements": [ "Erde", "Luft" @@ -1018,6 +1072,7 @@ { "id": "el_koenig", "name": "Wüstenkönig", + "name_en": "Desert King", "elements": [ "Erde", "Luft" @@ -1025,8 +1080,8 @@ "cost": 7, "rarity": "legendär", "kind": "Wesen", - "attack": 6, - "health": 8, + "attack": 5, + "health": 7, "keywords": [ "Wächter", "Flink", @@ -1044,6 +1099,7 @@ { "id": "f_glutpfeil", "name": "Glutpfeil", + "name_en": "Ember Arrow", "elements": [ "Feuer" ], @@ -1061,6 +1117,7 @@ { "id": "f_elementar", "name": "Feuerelementar", + "name_en": "Fire Elemental", "elements": [ "Feuer" ], @@ -1073,6 +1130,7 @@ { "id": "f_ritter", "name": "Lodernder Ritter", + "name_en": "Blazing Knight", "elements": [ "Feuer" ], @@ -1095,6 +1153,7 @@ { "id": "f_sturm", "name": "Flammensturm", + "name_en": "Firestorm", "elements": [ "Feuer" ], @@ -1112,6 +1171,7 @@ { "id": "w_schild", "name": "Gezeitenschild", + "name_en": "Tide Shield", "elements": [ "Wasser" ], @@ -1129,6 +1189,7 @@ { "id": "w_qualle", "name": "Quallenschwarm", + "name_en": "Jellyfish Swarm", "elements": [ "Wasser" ], @@ -1148,6 +1209,7 @@ { "id": "w_tiefen", "name": "Tiefenwächter", + "name_en": "Deep Guardian", "elements": [ "Wasser" ], @@ -1163,6 +1225,7 @@ { "id": "w_flut", "name": "Flutwelle", + "name_en": "Flood Wave", "elements": [ "Wasser" ], @@ -1184,6 +1247,7 @@ { "id": "e_steinhaut", "name": "Steinhaut", + "name_en": "Stone Skin", "elements": [ "Erde" ], @@ -1200,6 +1264,7 @@ { "id": "e_kiesel", "name": "Kieselgolem", + "name_en": "Pebble Golem", "elements": [ "Erde" ], @@ -1212,6 +1277,7 @@ { "id": "e_hirte", "name": "Baumhirte", + "name_en": "Tree Shepherd", "elements": [ "Erde" ], @@ -1234,6 +1300,7 @@ { "id": "e_beben", "name": "Erdbeben", + "name_en": "Earthquake", "elements": [ "Erde" ], @@ -1256,6 +1323,7 @@ { "id": "l_brise", "name": "Brise", + "name_en": "Breeze", "elements": [ "Luft" ], @@ -1273,6 +1341,7 @@ { "id": "l_fuchs", "name": "Wolkenfuchs", + "name_en": "Cloud Fox", "elements": [ "Luft" ], @@ -1288,6 +1357,7 @@ { "id": "l_himmel", "name": "Himmelsstoß", + "name_en": "Sky Strike", "elements": [ "Luft" ], @@ -1310,6 +1380,7 @@ { "id": "l_laeufer", "name": "Windläufer", + "name_en": "Wind Runner", "elements": [ "Luft" ], @@ -1325,6 +1396,7 @@ { "id": "ff_glut", "name": "Glutherz", + "name_en": "Ember Heart", "elements": [ "Feuer", "Feuer" @@ -1345,6 +1417,7 @@ { "id": "ww_abyss", "name": "Abyssalwächter", + "name_en": "Abyssal Guardian", "elements": [ "Wasser", "Wasser" @@ -1368,6 +1441,7 @@ { "id": "ee_faust", "name": "Felsenfaust", + "name_en": "Rock Fist", "elements": [ "Erde", "Erde" @@ -1384,6 +1458,7 @@ { "id": "ll_zwilling", "name": "Zwillingswind", + "name_en": "Twin Wind", "elements": [ "Luft", "Luft" @@ -1401,6 +1476,7 @@ { "id": "fw_nebel", "name": "Nebelgeist", + "name_en": "Mist Spirit", "elements": [ "Feuer", "Wasser" @@ -1425,6 +1501,7 @@ { "id": "fe_schlange", "name": "Magmaschlange", + "name_en": "Magma Serpent", "elements": [ "Feuer", "Erde" @@ -1445,6 +1522,7 @@ { "id": "fl_bote", "name": "Sturmbote", + "name_en": "Storm Herald", "elements": [ "Feuer", "Luft" @@ -1468,6 +1546,7 @@ { "id": "we_moorkroete", "name": "Moorkröte", + "name_en": "Bog Toad", "elements": [ "Wasser", "Erde" @@ -1491,6 +1570,7 @@ { "id": "wl_boen", "name": "Böenreiter", + "name_en": "Gale Rider", "elements": [ "Wasser", "Luft" @@ -1514,6 +1594,7 @@ { "id": "el_duenen", "name": "Dünenläufer", + "name_en": "Dune Runner", "elements": [ "Erde", "Luft" @@ -1536,6 +1617,7 @@ { "id": "f_funkenwerfer", "name": "Funkenwerfer", + "name_en": "Spark Thrower", "elements": [ "Feuer" ], @@ -1555,6 +1637,7 @@ { "id": "f_zunge", "name": "Flammenzunge", + "name_en": "Flame Tongue", "elements": [ "Feuer" ], @@ -1567,6 +1650,7 @@ { "id": "f_magma", "name": "Magmaschlag", + "name_en": "Magma Blow", "elements": [ "Feuer" ], @@ -1584,6 +1668,7 @@ { "id": "f_falle", "name": "Feuerfalle", + "name_en": "Fire Trap", "elements": [ "Feuer" ], @@ -1602,18 +1687,20 @@ { "id": "w_schlange", "name": "Flussschlange", + "name_en": "River Serpent", "elements": [ "Wasser" ], "cost": 3, "rarity": "gewöhnlich", "kind": "Wesen", - "attack": 3, + "attack": 4, "health": 4 }, { "id": "w_taucher", "name": "Perlentaucher", + "name_en": "Pearl Diver", "elements": [ "Wasser" ], @@ -1621,7 +1708,7 @@ "rarity": "gewöhnlich", "kind": "Wesen", "attack": 4, - "health": 4, + "health": 5, "effects": [ { "kind": "draw", @@ -1633,6 +1720,7 @@ { "id": "w_quelle", "name": "Heilende Quelle", + "name_en": "Healing Spring", "elements": [ "Wasser" ], @@ -1650,6 +1738,7 @@ { "id": "w_falle", "name": "Frostfalle", + "name_en": "Frost Trap", "elements": [ "Wasser" ], @@ -1671,6 +1760,7 @@ { "id": "e_lehm", "name": "Lehmkrieger", + "name_en": "Clay Warrior", "elements": [ "Erde" ], @@ -1683,6 +1773,7 @@ { "id": "e_steinschlag", "name": "Steinschlag", + "name_en": "Rockfall", "elements": [ "Erde" ], @@ -1700,13 +1791,14 @@ { "id": "e_hueter", "name": "Waldhüter", + "name_en": "Forest Warden", "elements": [ "Erde" ], "cost": 5, "rarity": "selten", "kind": "Wesen", - "attack": 5, + "attack": 4, "health": 7, "keywords": [ "Wächter" @@ -1715,6 +1807,7 @@ { "id": "e_falle", "name": "Erdwall", + "name_en": "Earth Wall", "elements": [ "Erde" ], @@ -1733,6 +1826,7 @@ { "id": "l_bote", "name": "Windbote", + "name_en": "Wind Herald", "elements": [ "Luft" ], @@ -1748,6 +1842,7 @@ { "id": "l_rueckenwind", "name": "Rückenwind", + "name_en": "Tailwind", "elements": [ "Luft" ], @@ -1770,6 +1865,7 @@ { "id": "l_jaeger", "name": "Himmelsjäger", + "name_en": "Sky Hunter", "elements": [ "Luft" ], @@ -1785,6 +1881,7 @@ { "id": "l_falle", "name": "Windfalle", + "name_en": "Wind Trap", "elements": [ "Luft" ], @@ -1802,6 +1899,7 @@ { "id": "f_aschefalle", "name": "Aschefalle", + "name_en": "Ash Trap", "elements": [ "Feuer" ], @@ -1820,6 +1918,7 @@ { "id": "w_nebelfalle", "name": "Nebelfalle", + "name_en": "Mist Trap", "elements": [ "Wasser" ], @@ -1842,6 +1941,7 @@ { "id": "e_dornen", "name": "Dornenfalle", + "name_en": "Thorn Trap", "elements": [ "Erde" ], @@ -1865,6 +1965,7 @@ { "id": "e_sandfalle", "name": "Sandfalle", + "name_en": "Sand Trap", "elements": [ "Erde" ], @@ -1882,6 +1983,7 @@ { "id": "l_boenfalle", "name": "Böenfalle", + "name_en": "Gale Trap", "elements": [ "Luft" ], @@ -1899,6 +2001,7 @@ { "id": "l_spiegel", "name": "Spiegelfalle", + "name_en": "Mirror Trap", "elements": [ "Luft" ], @@ -1917,6 +2020,7 @@ { "id": "w_ebbe", "name": "Ebbe", + "name_en": "Ebb", "elements": [ "Wasser" ], @@ -1938,6 +2042,7 @@ { "id": "w_tiefenstoss", "name": "Tiefenstoß", + "name_en": "Deep Strike", "elements": [ "Wasser" ], @@ -1955,6 +2060,7 @@ { "id": "w_strudel", "name": "Strudel", + "name_en": "Maelstrom", "elements": [ "Wasser" ], @@ -1976,6 +2082,7 @@ { "id": "fff_sonne", "name": "Kern der Sonne", + "name_en": "Core of the Sun", "elements": [ "Feuer", "Feuer", @@ -2000,6 +2107,7 @@ { "id": "www_ozean", "name": "Herz des Ozeans", + "name_en": "Heart of the Ocean", "elements": [ "Wasser", "Wasser", @@ -2028,6 +2136,7 @@ { "id": "eee_gebirge", "name": "Seele des Gebirges", + "name_en": "Soul of the Mountains", "elements": [ "Erde", "Erde", @@ -2059,6 +2168,7 @@ { "id": "lll_himmel", "name": "Atem des Himmels", + "name_en": "Breath of the Sky", "elements": [ "Luft", "Luft", @@ -2079,6 +2189,193 @@ "target": "all_enemy_creatures" } ] + }, + { + "id": "fw_siedend", + "name": "Siedende Flut", + "name_en": "Boiling Flood", + "elements": [ + "Feuer", + "Wasser" + ], + "cost": 5, + "rarity": "episch", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 2 + }, + { + "kind": "freeze", + "target": "strongest_enemy_creature" + } + ] + }, + { + "id": "fe_aschegolem", + "name": "Aschegolem", + "name_en": "Ash Golem", + "elements": [ + "Feuer", + "Erde" + ], + "cost": 6, + "rarity": "episch", + "kind": "Wesen", + "attack": 5, + "health": 7, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 2 + } + ] + }, + { + "id": "fl_sturmfalke", + "name": "Sturmfalke", + "name_en": "Storm Falcon", + "elements": [ + "Feuer", + "Luft" + ], + "cost": 5, + "rarity": "episch", + "kind": "Wesen", + "attack": 4, + "health": 3, + "keywords": [ + "Flink", + "Doppelschlag" + ] + }, + { + "id": "we_schlammriese", + "name": "Schlammriese", + "name_en": "Mud Giant", + "elements": [ + "Wasser", + "Erde" + ], + "cost": 6, + "rarity": "episch", + "kind": "Wesen", + "attack": 4, + "health": 9, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "freeze", + "target": "enemy_creature" + }, + { + "kind": "heal", + "target": "self_hero", + "amount": 3 + } + ] + }, + { + "id": "wl_sturmgeist", + "name": "Sturmgeist", + "name_en": "Storm Spirit", + "elements": [ + "Wasser", + "Luft" + ], + "cost": 5, + "rarity": "episch", + "kind": "Wesen", + "attack": 4, + "health": 4, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "bounce", + "target": "enemy_creature" + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "el_sandsturm", + "name": "Sandsturm", + "name_en": "Sandstorm", + "elements": [ + "Erde", + "Luft" + ], + "cost": 5, + "rarity": "episch", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 2 + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "w_flutfalle", + "name": "Flutfalle", + "name_en": "Flood Trap", + "elements": [ + "Wasser" + ], + "cost": 3, + "rarity": "selten", + "kind": "Falle", + "trigger": "creature_played", + "effects": [ + { + "kind": "freeze", + "target": "trigger_source" + }, + { + "kind": "heal", + "target": "self_hero", + "amount": 2 + } + ] + }, + { + "id": "f_funkenfalle", + "name": "Funkenfalle", + "name_en": "Spark Trap", + "elements": [ + "Feuer" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Falle", + "trigger": "attacked", + "effects": [ + { + "kind": "damage", + "target": "trigger_source", + "amount": 2 + } + ] } ] -} \ No newline at end of file +} diff --git a/scripts/ai.gd b/scripts/ai.gd index 5e06578..45d4bca 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -30,13 +30,16 @@ static func board_value(p: Game.Player) -> float: return v -## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, -## die der Gegner negativ. Ein besiegter Gegner ist viel wert. +## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner +## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen). +## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders. static func evaluate(g: Game, p: Game.Player) -> float: if not p.alive(): return -10000.0 var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5 var alive_enemies := 0 + var threat := 0.0 + var leader := 0.0 for e in g.players: if e == p: continue @@ -44,11 +47,28 @@ static func evaluate(g: Game, p: Game.Player) -> float: score += 40.0 continue alive_enemies += 1 - score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 + var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 + score -= ev + leader = maxf(leader, ev) if e.life <= 6: score += 3.0 # fast besiegt: dranbleiben + for c in e.board: + if not c.frozen: + threat += c.power() * c.attacks_per_turn() if alive_enemies == 0: return 10000.0 + score -= leader * 0.25 + var guarded := false + for c in p.board: + if c.taunt(): + guarded = true + break + if guarded: + score -= threat * 0.1 + elif threat >= p.life: + score -= 25.0 + else: + score -= threat * 0.3 return score diff --git a/scripts/campaign.gd b/scripts/campaign.gd index d5f2b97..b86494e 100644 --- a/scripts/campaign.gd +++ b/scripts/campaign.gd @@ -1,5 +1,5 @@ class_name Campaign -## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch. +## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch. const STAGES := [ {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"}, @@ -30,22 +30,40 @@ const STAGES2 := [ ] +## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks. +const STAGES3 := [ + {"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "zauber_schaden"]}, + {"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "schild_wesen"]}, + {"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]}, + {"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_karten"]}, + {"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]}, + {"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]}, + {"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]}, + {"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]}, + {"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 1000, "perks": ["alle_flink", "start_energie", "zauber_schaden"]}, + {"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "leben_35"]}, +] + +const CHAPTERS := [STAGES, STAGES2, STAGES3] + # Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks. const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6] const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4] +const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4] +const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3] static func target_rating(stage_index: int, player_rating: float) -> float: - var base: float = MIN_RATING[stage_index] if stage_index < STAGES.size() else MIN_RATING2[stage_index - STAGES.size()] + var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()] return maxf(base, player_rating) static func total() -> int: - return STAGES.size() + STAGES2.size() + return STAGES.size() * CHAPTERS.size() static func chapter(stage: int) -> int: - return 1 if stage < STAGES.size() else 2 + return stage / STAGES.size() + 1 ## Stufe mit einheitlichem Feld "perks". @@ -54,7 +72,7 @@ static func stage(i: int) -> Dictionary: var st: Dictionary = STAGES[i].duplicate() st["perks"] = [st.perk] return st - return STAGES2[i - STAGES.size()] + return CHAPTERS[chapter(i) - 1][i % STAGES.size()] ## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist). diff --git a/scripts/cards.gd b/scripts/cards.gd index 11763db..074ac09 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -33,7 +33,7 @@ const FIELD_COMBO_TEXT := { "Blitz": "2 Schaden an den schwächsten Gegner", "Schlamm": "Heile dich um 3", "Sturm": "Ziehe 1 Karte", - "Sand": "Das neue Wesen erhält Schild", + "Sand": "Das neue Wesen erhält +1/+1", } const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern." const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] @@ -82,7 +82,7 @@ static func field_combo_effect(name: String) -> Effect: "Sturm": return Effect.new("draw", "none", 1) "Sand": - return Effect.new("shield", "self_creature") + return Effect.new("buff", "self_creature", 1, 1) return null @@ -119,62 +119,73 @@ class Effect extends RefCounted: static func from_dict(d: Dictionary) -> Effect: return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", ""))) - func text() -> String: + ## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl). + func text(trigger: String = "") -> String: var n := str(amount) if target == "trigger_source": + var played := trigger == "creature_played" match kind: "damage": - return n + " Schaden an den Angreifer" + if played: + return tr("%s Schaden an dieses Wesen") % n + return tr("%s Schaden an den Angreifer") % n "freeze": - return "Der Angreifer wird eingefroren" + if played: + return tr("Es wird eingefroren") + return tr("Der Angreifer wird eingefroren") "bounce": - return "Es geht zurück auf die Hand" + return tr("Es geht zurück auf die Hand") match kind: "cancel": - return "Der Angriff verpufft" + return tr("Der Angriff verpufft") "summon": - return "Beschwöre einen " + Cards.by_id(card_id).name + return tr("Beschwöre einen %s") % Cards.by_id(card_id).title() "damage": match target: "enemy_creature": - return n + " Schaden an ein gegnerisches Wesen" + return tr("%s Schaden an ein gegnerisches Wesen") % n "enemy_any": - return n + " Schaden an ein Ziel" + return tr("%s Schaden an ein Ziel") % n "all_enemy_creatures": - return n + " Schaden an alle gegnerischen Wesen" + return tr("%s Schaden an alle gegnerischen Wesen") % n "all_enemy_heroes": - return n + " Schaden an alle Gegner" + return tr("%s Schaden an alle Gegner") % n "all_enemies": - return n + " Schaden an alle Gegner und ihre Wesen" + return tr("%s Schaden an alle Gegner und ihre Wesen") % n "heal": - return "Heile dich um " + n + return tr("Heile dich um %s") % n "draw": - return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten" + if amount == 1: + return tr("Ziehe 1 Karte") + return tr("Ziehe %s Karten") % n "freeze": if target == "enemy_creature": - return "Friere ein gegnerisches Wesen ein" - return "Friere alle gegnerischen Wesen ein" + return tr("Friere ein gegnerisches Wesen ein") + if target == "strongest_enemy_creature": + return tr("Friere das stärkste gegnerische Wesen ein") + return tr("Friere alle gegnerischen Wesen ein") "bounce": if target == "enemy_creature": - return "Ein gegnerisches Wesen zurück auf die Hand" - return "Alle gegnerischen Wesen zurück auf die Hand" + return tr("Ein gegnerisches Wesen zurück auf die Hand") + return tr("Alle gegnerischen Wesen zurück auf die Hand") "buff": var b := "+%d/+%d" % [amount, amount2] if target == "friendly_creature": - return "Ein befreundetes Wesen erhält " + b - return "Alle befreundeten Wesen erhalten " + b + return tr("Ein befreundetes Wesen erhält %s") % b + return tr("Alle befreundeten Wesen erhalten %s") % b "shield": if target == "friendly_creature": - return "Ein befreundetes Wesen erhält Schild" + return tr("Ein befreundetes Wesen erhält Schild") if target == "self_creature": - return "Erhält Schild" - return "Alle befreundeten Wesen erhalten Schild" + return tr("Erhält Schild") + return tr("Alle befreundeten Wesen erhalten Schild") return "" class Card extends RefCounted: var id: String - var name: String + var name: String # deutscher Name (auch im Verlauf und in Tests) + var name_en := "" # englischer Name var elements: Array var cost: int var rarity: String @@ -192,7 +203,7 @@ class Card extends RefCounted: return kind == "Falle" func to_dict() -> Dictionary: - var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind} + var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind} if is_creature(): d["attack"] = attack d["health"] = health @@ -208,6 +219,7 @@ class Card extends RefCounted: var c := Card.new() c.id = d.id c.name = d.name + c.name_en = str(d.get("name_en", "")) c.elements = Array(d.elements) c.cost = int(d.cost) c.rarity = d.rarity @@ -220,6 +232,12 @@ class Card extends RefCounted: c.trigger = str(d.get("trigger", "")) return c + ## Anzeigename in der aktuellen Sprache. + func title() -> String: + if name_en != "" and TranslationServer.get_locale().begins_with("en"): + return name_en + return name + func is_combo() -> bool: return elements.size() >= 2 @@ -238,19 +256,19 @@ class Card extends RefCounted: var kws: Array[String] = [] for k in Cards.KEYWORDS: if k in keywords: - kws.append(k) + kws.append(tr(k)) parts.append(", ".join(kws) + ".") if not effects.is_empty(): var fx: Array[String] = [] for f in effects: - fx.append(f.text()) + fx.append(f.text(trigger)) var s := ", ".join(fx) + "." if is_trap(): - if trigger == "creature_played": - s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird") - parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s]) + parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s]) + elif is_creature(): + parts.append(tr("Beim Ausspielen: %s") % s) else: - parts.append(("Beim Ausspielen: " + s) if is_creature() else s) + parts.append(s) return " ".join(parts) diff --git a/scripts/game.gd b/scripts/game.gd index 4257a4c..11050c3 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -315,7 +315,7 @@ func mulligan(p: Player, cards: Array) -> String: if n > 0: shuffle(p.deck, rng) draw(p, n) - say("%s tauscht %d Karten." % [p.name, n] if n > 0 else "%s behält die Hand." % p.name) + say(("%s tauscht 1 Karte." % p.name if n == 1 else "%s tauscht %d Karten." % [p.name, n]) if n > 0 else "%s behält die Hand." % p.name) emit({"type": "mulligan", "player": players.find(p), "count": n}) return "" diff --git a/scripts/net.gd b/scripts/net.gd index 6037bd3..0f7b3ac 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -8,6 +8,8 @@ signal started(seat: int) signal snapshot_received(snap: Dictionary) signal action_applied signal ended(reason: String) +signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt +signal status_changed const PORT := 24455 @@ -23,15 +25,25 @@ var my_seat := -1 var game: Game # Host var status := "" var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten -const REJOIN_SECONDS := 60.0 +var rejoin_seconds := 60.0 +var spectator := false # eigene Rolle: nur zuschauen +var spectators := {} # Host: peer_id -> Name +var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts +var i_am_successor := false +var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion +var backup_names := {} # Nachfolger: Platz -> Name aller Menschen +var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild +var reconnecting := false func _ready() -> void: multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) multiplayer.connected_to_server.connect(_on_connected) - multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen")) - multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet")) + multiplayer.connection_failed.connect(func(): + if not reconnecting: + _fail("Verbindung fehlgeschlagen")) + multiplayer.server_disconnected.connect(_on_server_lost) func host(port: int = PORT) -> Error: @@ -45,6 +57,7 @@ func host(port: int = PORT) -> Error: connected = true names = {1: my_name} decks = {1: my_deck} + spectators = {} status = "Warte auf Mitspieler …" lobby_changed.emit() return OK @@ -76,6 +89,13 @@ func leave() -> void: my_seat = -1 game = null status = "" + spectator = false + spectators = {} + successor_ip = "" + i_am_successor = false + backup = {} + backup_names = {} + last_winner = "" func local_addresses() -> Array: @@ -91,7 +111,114 @@ func _fail(reason: String) -> void: func _on_connected() -> void: connected = true status = "Verbunden" - register.rpc_id(1, my_name, my_deck) + register.rpc_id(1, my_name, my_deck, spectator) + + +# --- Host-Wechsel --------------------------------------------------------------- +# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen +# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der +# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück. + +func _successor_id() -> int: + var ids: Array = seats.keys().filter(func(id): return id != 1) + ids.sort() + return ids[0] if not ids.is_empty() else -1 + + +func _update_successor() -> void: + if not is_host or game == null: + return + var succ := _successor_id() + var ip := "" + if succ > 0 and peer != null and peer.get_peer(succ) != null: + ip = peer.get_peer(succ).get_remote_address() + for id in seats: + if id != 1: + set_successor.rpc_id(id, ip, id == succ) + + +## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel. +func _seat_names() -> Dictionary: + var out := {} + for id in seats: + out[seats[id]] = names.get(id, "") + for pname in waiting: + out[waiting[pname].seat] = pname + return out + + +@rpc("authority", "reliable") +func set_successor(ip: String, me: bool) -> void: + successor_ip = ip + i_am_successor = me + + +@rpc("authority", "reliable") +func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void: + backup = snap + backup_names = seat_names + + +func _on_server_lost() -> void: + if is_host: + return + if last_winner != "": + _fail("Host hat das Spiel beendet") + elif i_am_successor and not backup.is_empty(): + _become_host() + elif successor_ip != "": + status = "Host weg, verbinde mit dem Nachfolger …" + status_changed.emit() + _reconnect(successor_ip) + else: + _fail("Host hat das Spiel beendet") + + +func _become_host() -> void: + var snap := backup + var seat_names := backup_names + var seat := my_seat + if multiplayer.multiplayer_peer != null: + multiplayer.multiplayer_peer.close() + var err := FAILED + for attempt in 5: + err = host() + if err == OK: + break + await get_tree().create_timer(1.0).timeout + if err != OK: + _fail("Host weg, Übernahme fehlgeschlagen") + return + game = Game.from_snapshot(snap) + seats = {1: seat} + my_seat = seat + waiting = {} + for s in seat_names: + if int(s) != seat: + waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()} + game.say("%s übernimmt als Host." % my_name) + status = "Du bist jetzt Host, die anderen verbinden neu …" + successor_ip = "" + i_am_successor = false + backup = {} + migrated.emit() + + +func _reconnect(ip: String) -> void: + reconnecting = true + await get_tree().create_timer(2.0).timeout + for attempt in 5: + if join(ip) == OK: + var t := 0.0 + while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING: + await get_tree().process_frame + t += get_process_delta_time() + if connected: + reconnecting = false + return + await get_tree().create_timer(1.0).timeout + reconnecting = false + _fail("Nachfolge-Host nicht erreichbar") func _on_peer_connected(_id: int) -> void: @@ -104,14 +231,16 @@ func _on_peer_disconnected(id: int) -> void: var pname: String = names.get(id, "") names.erase(id) decks.erase(id) + spectators.erase(id) if game != null and seats.has(id): var p: Game.Player = game.players[seats[id]] if game.winner == "": waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()} - game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)]) + game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)]) seats.erase(id) + _update_successor() action_applied.emit() - lobby_update.rpc(names) + lobby_update.rpc(names, spectators) lobby_changed.emit() @@ -123,7 +252,7 @@ func tick() -> void: for pname in waiting.keys(): var w: Dictionary = waiting[pname] var p: Game.Player = game.players[w.seat] - if now - float(w.since) > REJOIN_SECONDS: + if now - float(w.since) > rejoin_seconds: waiting.erase(pname) game.say("%s kehrt nicht zurück und scheidet aus." % p.name) game.damage(p, 999) @@ -137,10 +266,18 @@ func tick() -> void: @rpc("any_peer", "reliable") -func register(name: String, deck_ids: Array) -> void: +func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void: if not is_host: return var id := multiplayer.get_remote_sender_id() + if as_spectator: + spectators[id] = name + lobby_update.rpc(names, spectators) + lobby_changed.emit() + if game != null: + start_game.rpc_id(id, -1) + receive_snapshot.rpc_id(id, game.snapshot(-2)) + return if game != null: # Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz if waiting.has(name): @@ -150,19 +287,21 @@ func register(name: String, deck_ids: Array) -> void: game.say("%s ist zurück." % name) start_game.rpc_id(id, seats[id]) receive_snapshot.rpc_id(id, game.snapshot(seats[id])) + _update_successor() action_applied.emit() else: status = "%s wollte beitreten, das Spiel läuft schon" % name return names[id] = name decks[id] = deck_ids - lobby_update.rpc(names) + lobby_update.rpc(names, spectators) lobby_changed.emit() @rpc("authority", "reliable") -func lobby_update(n: Dictionary) -> void: +func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void: names = n + spectators = sp lobby_changed.emit() @@ -194,6 +333,9 @@ func start_battle(n_ai: int) -> void: for id in ids: if id != 1: start_game.rpc_id(id, seats[id]) + for id in spectators: + start_game.rpc_id(id, -1) + _update_successor() started.emit(my_seat) @@ -203,17 +345,24 @@ func start_game(seat: int) -> void: started.emit(seat) -## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht). +## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes, +## der Nachfolger zusätzlich den vollen Spielstand). func broadcast() -> void: if not is_host or game == null: return for id in seats: if id != 1: receive_snapshot.rpc_id(id, game.snapshot(seats[id])) + for id in spectators: + receive_snapshot.rpc_id(id, game.snapshot(-2)) + var succ := _successor_id() + if succ > 0: + receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names()) @rpc("authority", "reliable") func receive_snapshot(snap: Dictionary) -> void: + last_winner = str(snap.get("winner", "")) snapshot_received.emit(snap) diff --git a/scripts/netstate.gd b/scripts/netstate.gd index a0d6a59..48fb4d2 100644 --- a/scripts/netstate.gd +++ b/scripts/netstate.gd @@ -3,11 +3,12 @@ class_name NetState ## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl. +## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer). static func snapshot(g: Game, viewer: int) -> Dictionary: var ps := [] for i in g.players.size(): var p: Game.Player = g.players[i] - var mine := i == viewer + var mine := i == viewer or viewer == -1 var board := [] for c in p.board: board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance, @@ -17,7 +18,8 @@ static func snapshot(g: Game, viewer: int) -> Dictionary: "energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus, "order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done, "hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(), - "deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(), + "deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(), + "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(), "board": board}) var evs := [] for ev in g.events: @@ -51,8 +53,12 @@ static func from_snapshot(d: Dictionary) -> Game: else: for i in int(pd.hand): p.hand.append(placeholder) - for i in int(pd.deck): - p.deck.append(placeholder) + if pd.deck is Array: + for cid in pd.deck: + p.deck.append(Cards.by_id(cid)) + else: + for i in int(pd.deck): + p.deck.append(placeholder) if pd.traps is Array: for cid in pd.traps: p.traps.append(Cards.by_id(cid)) diff --git a/scripts/profile.gd b/scripts/profile.gd index f766f7f..d43d97d 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -6,6 +6,8 @@ const SAVE_VERSION := 5 const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert const SAVE_PATH := "user://spielstand.json" const BOOSTER_COST := 100 +const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos) +const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen 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 START_COINS := 150 @@ -34,6 +36,7 @@ const ACHIEVEMENTS := [ {"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100}, {"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300}, {"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500}, + {"id": "archimagier", "name": "Archimagier", "text": "Schließe Kapitel 3 der Kampagne ab.", "reward": 800}, {"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150}, {"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100}, ] @@ -336,7 +339,15 @@ func dismantle_extras() -> int: # --- Booster ---------------------------------------------------------------------- -static func roll_booster(r: RandomNumberGenerator) -> Array: +## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element. +static func booster_pool(rarity: String, element: String) -> Array: + var pool := Cards.of_rarity(rarity) + if element != "": + pool = pool.filter(func(c): return element in c.elements) + return pool + + +static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array: var rarities := RARITY_WEIGHTS.keys() var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) var cards := [] @@ -345,23 +356,49 @@ static func roll_booster(r: RandomNumberGenerator) -> Array: if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \ and cards.all(func(c): return c.rarity == "gewöhnlich"): rarity = "selten" # mindestens eine seltene Karte pro Booster - var pool := Cards.of_rarity(rarity) + var pool := booster_pool(rarity, element) cards.append(pool[r.randi_range(0, pool.size() - 1)]) return cards -## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen. -func open_booster(r: RandomNumberGenerator = null) -> Array: - if coins < BOOSTER_COST: +## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar). +static func deck_code(card_ids: Array) -> String: + var counts := {} + for cid in card_ids: + counts[cid] = counts.get(cid, 0) + 1 + var keys := counts.keys() + keys.sort() + return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]])) + + +## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code. +static func parse_deck_code(code: String) -> Array: + var s := code.strip_edges() + if not s.begins_with(CODE_PREFIX): + return [] + var out := [] + for part in s.substr(CODE_PREFIX.length()).split(",", false): + var kv: PackedStringArray = part.strip_edges().split("*") + if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int(): + return [] + for i in clampi(int(kv[1]), 0, Game.MAX_COPIES): + out.append(kv[0]) + return out + + +## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen. +func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array: + var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST + if coins < cost: return [] if r == null: r = Game.make_rng() - coins -= BOOSTER_COST - var cards := roll_booster(r) + coins -= cost + var cards := roll_booster(r, element) booster_since_mythic += 1 var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: - var pool := Cards.of_rarity("mythisch") + var pool := booster_pool("mythisch", element) cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] has_mythic = true if has_mythic: @@ -478,6 +515,8 @@ func _achieved(id: String, ctx: Dictionary) -> bool: "kampagnenheld": return campaign_stage >= Campaign.STAGES.size() "elementmeister": + return campaign_stage >= 2 * Campaign.STAGES.size() + "archimagier": return campaign_stage >= Campaign.total() "serie": return records.best_streak >= 5 diff --git a/scripts/ui/battle_fx.gd b/scripts/ui/battle_fx.gd index cae53ce..7a96f85 100644 --- a/scripts/ui/battle_fx.gd +++ b/scripts/ui/battle_fx.gd @@ -63,7 +63,7 @@ func play_events(evs: Array, centers: Dictionary) -> void: screen.preview_timer = 1.8 "mulligan": if ev.count > 0: - popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) + popup(("tauscht 1 Karte" if int(ev.count) == 1 else "tauscht %d Karten" % ev.count), Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) func center_of(ref: Dictionary, centers: Dictionary) -> Vector2: diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index f64bf3d..7ec7404 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -9,7 +9,8 @@ var stage := -1 # Kampagnenstufe oder -1 var tutorial := false var hotseat_decks: Array = [] var hotseat_ai := 0 -var online_seat := -1 # Netzwerk: eigener Platz, sonst -1 +var net_play := false # Netzwerkspiel (auch als Zuschauer) +var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1 var net: Net var game: Game var perspective: Game.Player # der Mensch, dessen Hand unten liegt @@ -44,6 +45,7 @@ var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner var skip_ai := false # Gegnerzüge im Schnelldurchlauf var smart_ai := true # Vorausschau-KI (Leicht: einfache KI) var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion +var saved_game: Game = null # fortgesetzter Kampf (siehe resume) var undo_log_len := 0 var undo_t := 0.0 @@ -80,6 +82,7 @@ var result_label: Label var reward_label: Label var notice_label: Label var confirm: Control +var b_save_leave: Button var handover: Control var handover_label: Label var mulligan: BattleMulligan @@ -135,6 +138,7 @@ static func hotseat(decks: Array, n_ai: int) -> BattleScreen: static func online(seat: int) -> BattleScreen: var b := BattleScreen.new(0, -1) + b.net_play = true b.online_seat = seat return b @@ -152,6 +156,15 @@ static func against(opps: Array) -> BattleScreen: return b +## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle). +static func resume(data: Dictionary) -> BattleScreen: + var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1))) + s.challenge = data.get("challenge", {}) + s.played_ids = Array(data.get("played_ids", [])) + s.saved_game = Game.from_snapshot(data.game) + return s + + static func weekly(ch: Dictionary) -> BattleScreen: var b := BattleScreen.new(int(ch.n_opp), -1) b.challenge = ch @@ -159,11 +172,16 @@ static func weekly(ch: Dictionary) -> BattleScreen: func is_hotseat() -> bool: - return game != null and game.humans.size() > 1 and online_seat < 0 + return game != null and game.humans.size() > 1 and not net_play func is_online() -> bool: - return online_seat >= 0 + return net_play + + +## Zuschauer im Netzwerk: kein eigener Platz, keine Hand. +func spectating() -> bool: + return is_online() and online_seat < 0 func is_client() -> bool: @@ -184,6 +202,8 @@ func _ready() -> void: if is_online(): net = app.net net.ended.connect(_on_net_ended) + net.migrated.connect(_on_migrated) + net.status_changed.connect(_on_net_status) if net.is_host: game = net.game net.action_applied.connect(after_action) @@ -191,6 +211,8 @@ func _ready() -> void: net.snapshot_received.connect(_on_snapshot) wait_label.visible = true return # der erste Zustand kommt vom Host + elif saved_game != null: + game = saved_game elif not hotseat_decks.is_empty(): game = Game.new_hotseat(hotseat_decks, hotseat_ai) elif tutorial: @@ -227,7 +249,7 @@ func own_rules() -> Dictionary: func _begin() -> void: n_opp = game.players.size() - 1 if is_online(): - perspective = game.players[online_seat] + perspective = game.players[maxi(online_seat, 0)] else: game.events.clear() perspective = game.current() if game.current().is_human else game.humans[0] @@ -235,6 +257,8 @@ func _begin() -> void: if is_online() and net.is_host: net.broadcast() game.events.clear() + b_save_leave.visible = resumable() + save_battle() refresh() @@ -304,7 +328,14 @@ func _build_static() -> void: notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center") Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c")) # Aufgeben bestätigen - confirm = Style.confirm(self, "Wirklich aufgeben?", "Der Kampf endet ohne Münzen.", "Ja, aufgeben", leave, "Weiterspielen") + confirm = Style.overlay(self) + Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2) + Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true) + Style.label(confirm, tr("Aufgeben beendet den Kampf ohne Münzen.") + "\n" + tr("Speichern hebt ihn für später auf."), 15, Style.MUTED, Rect2(340, 356, 600, 48), "center") + Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828")) + b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c")) + b_save_leave.add_theme_font_size_override("font_size", 14) + Style.button(confirm, "Weiterspielen", Rect2(745, 420, 180, 46), func(): confirm.visible = false) # Übergabe zwischen Menschen (Hotseat) handover = _overlay() Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2) @@ -324,9 +355,31 @@ func _overlay() -> Control: return Style.overlay(self) +## Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) lassen sich unterbrechen und fortsetzen. +func resumable() -> bool: + return counts_for_profile() and not tutorial + + +func save_battle() -> void: + if not resumable() or game == null or game.winner != "": + return + var d := {"version": 1, "n_opp": n_opp, "stage": stage, "challenge": challenge, "played_ids": played_ids, "game": game.snapshot(-1)} + var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE) + if f != null: + f.store_string(JSON.stringify(d)) + f.close() + + +func save_and_leave() -> void: + save_battle() + app.show_screen(MenuScreen.new()) + + func leave() -> void: if is_online(): net.leave() + if resumable() and game != null and game.winner == "": + app.clear_battle_save() # aufgegeben if tutorial: if tutorial_part == 1: app.profile.tutorial_done = true @@ -398,7 +451,9 @@ func build_hero_boxes() -> void: if not o.perks.is_empty(): title += " ★" labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT, - Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (44 if c else 20), 26), "left", true) + Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (58 if c else 20), 26), "left", true) + labels.name.clip_text = true + labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED, Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true) labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED, @@ -414,6 +469,8 @@ func build_hero_boxes() -> void: # --- Ablauf und Eingabe ------------------------------------------------------------------ func my_turn() -> bool: + if spectating(): + return false return game != null and game.winner == "" and game.current() == perspective @@ -500,7 +557,7 @@ func _process(delta: float) -> void: handover.visible = true hand_layer.visible = false trap_layer.visible = false - elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible: + elif cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible: show_mulligan() return if is_client(): @@ -558,6 +615,7 @@ func _finish() -> void: app.profile.tutorial2_done = true app.save() app.sfx.play("win" if won else "lose") + app.clear_battle_save() else: var wp := game.winner_player var mine: bool = wp != null and wp == perspective @@ -704,7 +762,7 @@ func on_click(kind: String, obj) -> void: selected = obj say(tr("Wähle ein Angriffsziel")) else: - say(tr("%s kann nicht angreifen") % obj.name) + say(tr("%s kann nicht angreifen") % obj.card.title()) after_action() @@ -761,13 +819,13 @@ func combo_hints(card: Cards.Card) -> Array[String]: var combos := {} for other in perspective.board: if other.card.elements.any(func(e): return e in card.elements): - partners.append(other.name) + partners.append(other.card.title()) for e1 in card.elements: for e2 in other.card.elements: if e1 != e2: var name := Cards.combo_name([e1, e2]) if not combos.has(name): - combos[name] = other.name + combos[name] = other.card.title() if not partners.is_empty(): out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)]) for name in combos: @@ -784,11 +842,28 @@ func _on_snapshot(snap: Dictionary) -> void: if first: _begin() return - perspective = game.players[online_seat] + perspective = game.players[maxi(online_seat, 0)] build_hero_boxes() after_action() +## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter. +func _on_migrated() -> void: + game = net.game + if not net.action_applied.is_connected(after_action): + net.action_applied.connect(after_action) + wait_label.visible = false + perspective = game.players[online_seat] + build_hero_boxes() + say("Du bist jetzt Host.") + after_action() + + +func _on_net_status() -> void: + wait_label.text = net.status + wait_label.visible = true + + func _on_net_ended(reason: String) -> void: if game == null or game.winner == "": say(reason) @@ -814,6 +889,7 @@ func after_action() -> void: centers[ref_key(ev[key])] = target_center(ev[key]) refresh() fx.play_events(evs, centers) + save_battle() func _note_events(evs: Array) -> void: @@ -962,21 +1038,24 @@ func refresh() -> void: trap_views.append(tv) Style.clear(hand_layer) - for i in h.hand.size(): - 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) - v.position = r.position - if card == pending: - v.border = Style.YELLOW - elif playable: - v.border = Style.GREEN - v.dim = not playable - v.hover.connect(show_hand_preview) - v.clicked.connect(func(_view): on_click("hand", card)) - hand_layer.add_child(v) - hand_views.append(v) + if spectating(): + Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center") + else: + for i in h.hand.size(): + 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) + v.position = r.position + if card == pending: + v.border = Style.YELLOW + elif playable: + v.border = Style.GREEN + v.dim = not playable + v.hover.connect(show_hand_preview) + v.clicked.connect(func(_view): on_click("hand", card)) + hand_layer.add_child(v) + hand_views.append(v) var bar_border = Style.BLUE if mine else null if int(flash_until.get(g.players.find(h), 0)) > now: @@ -987,10 +1066,12 @@ func refresh() -> void: energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] deck_label.text = "Deck %d" % h.deck.size() status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name]) + if spectating(): + status_label.text = tr("Zuschauer") + " · " + status_label.text b_end.disabled = not mine var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client() b_skip.visible = ai_running - b_end.visible = not ai_running + b_end.visible = not ai_running and not spectating() b_undo.visible = undo_state != null and mine var lines: Array[String] = [] var start := maxi(0, g.log.size() - 60) diff --git a/scripts/ui/battle_tutorial.gd b/scripts/ui/battle_tutorial.gd index b16ff6e..0eefc68 100644 --- a/scripts/ui/battle_tutorial.gd +++ b/scripts/ui/battle_tutorial.gd @@ -38,8 +38,11 @@ func stage_index() -> int: return 0 if part == 1 else 1 +## Teil 1: ein Wesen für 1 Energie auf der Hand, damit der erste Schritt immer möglich ist. ## Teil 2: Lehrkarten auf die eigene Hand; Mara bekommt Wesen, damit sie sicher in die Falle läuft. func inject_cards(g: Game) -> void: + if part == 1 and not g.human.hand.any(func(c): return c.is_creature() and c.cost == 1): + g.human.hand.append(Cards.by_id("f_kobold")) if part == 2: g.human.hand.append(Cards.by_id("w_falle")) g.human.hand.append(Cards.by_id("w_reiter")) diff --git a/scripts/ui/booster_screen.gd b/scripts/ui/booster_screen.gd index 9ce0a81..31c32c8 100644 --- a/scripts/ui/booster_screen.gd +++ b/scripts/ui/booster_screen.gd @@ -15,6 +15,7 @@ var b_all: Button var b_improve: Button var suggestion := "" var slots: Control +var element_buttons: Array = [] class CardBack extends Control: @@ -24,15 +25,21 @@ class CardBack extends Control: size = sz custom_minimum_size = sz mouse_filter = Control.MOUSE_FILTER_STOP + focus_mode = Control.FOCUS_ALL + focus_entered.connect(queue_redraw) + focus_exited.connect(queue_redraw) func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: accept_event() clicked.emit() + elif has_focus() and event.is_action_pressed("ui_accept"): + accept_event() + clicked.emit() func _draw() -> void: var rect := Rect2(Vector2.ZERO, size) - draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect) + draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect) Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6) Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true) @@ -48,15 +55,20 @@ func _ready() -> void: coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center") Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.", 16, Style.MUTED, Rect2(0, 115, 1280, 24), "center") + Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST, + 14, Style.MUTED, Rect2(0, 586, 1280, 22), "center") + for i in Cards.ELEMENTS.size(): + var e: String = Cards.ELEMENTS[i] + element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))) slots = Control.new() slots.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(slots) - msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center") - b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628")) - b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all) + msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center") + b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628")) + b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all) b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c")) b_improve.visible = false - Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new())) + Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new())) refresh() @@ -65,10 +77,10 @@ func _input(event: InputEvent) -> void: app.show_screen(MenuScreen.new()) -func buy() -> void: +func buy(element: String = "") -> void: var p: Profile = app.profile var before: Dictionary = p.collection.duplicate() - var got: Array = p.open_booster() + var got: Array = p.open_booster(null, element) if got.is_empty(): msg_label.text = "Nicht genug Münzen" msg_label.add_theme_color_override("font_color", Style.RED) @@ -106,7 +118,7 @@ func improve() -> void: var err: String = app.profile.improve_deck_with(suggestion) if err == "": app.save() - msg_label.text = "%s ist jetzt im Deck %s" % [Cards.by_id(suggestion).name, app.profile.deck_name()] + msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()] msg_label.add_theme_color_override("font_color", Style.GREEN) suggestion = "" else: @@ -119,6 +131,8 @@ func refresh() -> void: var p: Profile = app.profile coins_label.text = "Münzen: %d" % p.coins b_buy.disabled = p.coins < Profile.BOOSTER_COST + for b in element_buttons: + b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST b_all.disabled = cards.is_empty() or revealed.size() >= 5 # Deck-Vorschlag, sobald alle Karten offen liegen suggestion = "" @@ -129,7 +143,7 @@ func refresh() -> void: suggestion = better[0] b_improve.visible = suggestion != "" if suggestion != "": - b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).name, p.deck_name()] + b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()] Style.clear(slots) for i in 5: var r := slot_rect(i) @@ -140,7 +154,7 @@ func refresh() -> void: var v := CardView.new(c, r.size) v.position = r.position slots.add_child(v) - Style.label(slots, c.rarity, 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) + Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) if new_ids.has(c.id): Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) else: diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd index 6d5c96a..3a64f3f 100644 --- a/scripts/ui/campaign_screen.gd +++ b/scripts/ui/campaign_screen.gd @@ -8,7 +8,7 @@ var perk_box: OptionButton func _ready() -> void: var p: Profile = app.profile - chapter = 2 if p.campaign_stage >= Campaign.STAGES.size() else 1 + chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size()) build() @@ -19,9 +19,9 @@ func build() -> void: Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true) var tabs := Style.row(col, 10) tabs.alignment = BoxContainer.ALIGNMENT_CENTER - var b1 := Style.cbutton(tabs, "Kapitel 1", Vector2(160, 36), func(): set_chapter(1), Color("285a3c") if chapter == 1 else null) - var b2 := Style.cbutton(tabs, "Kapitel 2", Vector2(160, 36), func(): set_chapter(2), Color("285a3c") if chapter == 2 else null) - b2.disabled = p.campaign_stage < Campaign.STAGES.size() + for c in Campaign.CHAPTERS.size(): + var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null) + b.disabled = p.campaign_stage < c * Campaign.STAGES.size() if p.perk_unlocked(): var pr := Style.row(tabs, 8) Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110) @@ -36,9 +36,11 @@ func build() -> void: p.perk = "" if i == 0 else keys[i - 1] app.save()) pr.add_child(perk_box) - Style.clabel(col, "Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★)." if chapter == 1 - else "Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.", 14, Style.MUTED) - var offset := 0 if chapter == 1 else Campaign.STAGES.size() + var intro := ["Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★).", + "Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.", + "Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks."] + Style.clabel(col, intro[chapter - 1], 14, Style.MUTED) + var offset := (chapter - 1) * Campaign.STAGES.size() for k in Campaign.STAGES.size(): var i := offset + k var st := Campaign.stage(i) @@ -76,7 +78,7 @@ func build() -> void: var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass) b.disabled = true if p.campaign_stage >= Campaign.total(): - Style.clabel(col, "Beide Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true) + Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true) var br := Style.row(col) br.alignment = BoxContainer.ALIGNMENT_CENTER Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new())) diff --git a/scripts/ui/card_grid.gd b/scripts/ui/card_grid.gd index 690c1dd..d849cda 100644 --- a/scripts/ui/card_grid.gd +++ b/scripts/ui/card_grid.gd @@ -17,6 +17,7 @@ func _init(rect: Rect2) -> void: position = rect.position size = rect.size horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + follow_focus = true # Gamepad: der fokussierten Karte hinterherscrollen grid = GridContainer.new() grid.columns = COLS grid.add_theme_constant_override("h_separation", GAP) @@ -31,7 +32,7 @@ func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void: var cell := Control.new() cell.custom_minimum_size = Vector2(CW, CH + 16) cell.mouse_filter = Control.MOUSE_FILTER_IGNORE - var v := CardView.new(c, Vector2(CW, CH)) + var v := CardView.new(c, Vector2(CW, CH), true) v.dim = dim_fn.call(c) v.clicked.connect(func(_view): card_clicked.emit(c)) v.hover.connect(func(card, entered): card_hover.emit(card, entered)) diff --git a/scripts/ui/card_view.gd b/scripts/ui/card_view.gd index 3a447ee..c0df950 100644 --- a/scripts/ui/card_view.gd +++ b/scripts/ui/card_view.gd @@ -10,19 +10,31 @@ var border = null # Color oder null (dann Seltenheitsfarbe) var dim := false -func _init(c: Cards.Card, sz: Vector2) -> void: +## 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) -> void: card = c custom_minimum_size = sz size = sz mouse_filter = Control.MOUSE_FILTER_STOP mouse_entered.connect(func(): hover.emit(card, true)) mouse_exited.connect(func(): hover.emit(card, false)) + if focusable: + focus_mode = Control.FOCUS_ALL + focus_entered.connect(func(): + hover.emit(card, true) + queue_redraw()) + focus_exited.connect(func(): + hover.emit(card, false) + queue_redraw()) 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) + elif has_focus() and event.is_action_pressed("ui_accept"): + accept_event() + clicked.emit(self) func _draw() -> void: @@ -41,10 +53,11 @@ func _draw() -> void: draw_circle(cc, r, Color("14141c")) Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true) # Name und Kombo - var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12) - Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true) + var name_left := 2 * r + 10 + var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6) + Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true) if mid: - var sub: String = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind + var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind) Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop") # Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest var tsize := Style.fs(f_text) @@ -60,11 +73,14 @@ func _draw() -> void: var gsize: float = minf(band_bottom - band_top, size.x * 0.42) Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize) draw_rect(box, Color(0.06, 0.06, 0.09, 0.67)) + var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)), - card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, -1, Style.TEXT) + card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT) # Werte if card.is_creature(): Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true) Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true) if dim: draw_rect(rect, Color(0, 0, 0, 0.47)) + if has_focus(): + Style.border(self, rect, Style.GOLD, 4, 8) diff --git a/scripts/ui/creature_view.gd b/scripts/ui/creature_view.gd index 38895aa..17069b3 100644 --- a/scripts/ui/creature_view.gd +++ b/scripts/ui/creature_view.gd @@ -44,7 +44,7 @@ func _draw() -> void: _draw_mini() return var fs := 11 if small else 13 - var nm := Style.fit(creature.name, true, fs, size.x - 6) + var nm := Style.fit(creature.card.title(), true, fs, size.x - 6) Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true) var badges: Array[String] = [] if creature.taunt(): diff --git a/scripts/ui/deck_screen.gd b/scripts/ui/deck_screen.gd index e41c4e4..b0ab75c 100644 --- a/scripts/ui/deck_screen.gd +++ b/scripts/ui/deck_screen.gd @@ -28,6 +28,8 @@ var b_activate: Button var b_autofill: Button var name_box: LineEdit var confirm: Control +var code_dialog: Control +var code_box: LineEdit class DeckRow extends Control: @@ -43,26 +45,30 @@ class DeckRow extends Control: size = sz custom_minimum_size = sz mouse_filter = Control.MOUSE_FILTER_STOP - mouse_entered.connect(func(): - lit = true + focus_mode = Control.FOCUS_ALL + var light := func(on: bool): + lit = on queue_redraw() - hover.emit(card, true)) - mouse_exited.connect(func(): - lit = false - queue_redraw() - hover.emit(card, false)) + hover.emit(card, on) + mouse_entered.connect(light.bind(true)) + mouse_exited.connect(light.bind(false)) + focus_entered.connect(light.bind(true)) + focus_exited.connect(light.bind(false)) func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: accept_event() clicked.emit(card) + elif has_focus() and event.is_action_pressed("ui_accept"): + accept_event() + clicked.emit(card) func _draw() -> void: if lit: draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size)) for k in card.elements.size(): draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]]) - Style.text(self, "%dx %s" % [count, card.name], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft") + Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft") Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true) @@ -93,10 +99,11 @@ func _ready() -> void: Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) for i in Cards.ELEMENTS.size(): var e: String = Cards.ELEMENTS[i] - Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) + var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) + fb.add_theme_font_size_override("font_size", 14) search_box = LineEdit.new() - search_box.position = Vector2(400, 55) - search_box.size = Vector2(160, 34) + search_box.position = Vector2(424, 55) + search_box.size = Vector2(136, 34) search_box.placeholder_text = "Suche" search_box.text_changed.connect(func(t: String): search = t @@ -150,9 +157,23 @@ func _ready() -> void: curve.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(curve) msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24)) - Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c")) - Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck) - Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), leave) + Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c")) + Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck) + Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code) + Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave) + # Deck-Code zum Teilen: kopieren, einfügen, übernehmen + code_dialog = Style.overlay(self) + Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2) + Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true) + Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center") + code_box = LineEdit.new() + code_box.position = Vector2(270, 368) + code_box.size = Vector2(740, 36) + code_dialog.add_child(code_box) + Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text)) + Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get()) + Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c")) + Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false) confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.", "Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD) preview = CardView.new(Cards.all()[0], Vector2(200, 280)) @@ -174,9 +195,11 @@ func leave() -> void: func _input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus(): + if event.is_action_pressed("ui_cancel") and not search_box.has_focus() and not name_box.has_focus() and not code_box.has_focus(): if confirm.visible: confirm.visible = false + elif code_dialog.visible: + code_dialog.visible = false else: leave() @@ -244,7 +267,7 @@ func cards() -> Array: out = out.filter(func(c): return filter in c.elements) if search != "": var q := search.to_lower() - out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower()) + out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower()) match sort_mode: 0: out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name)) @@ -308,6 +331,34 @@ func save_deck() -> void: refresh() +func open_code() -> void: + code_box.text = Profile.deck_code(deck) + code_dialog.visible = true + + +## Übernimmt einen Deck-Code; Karten, die in der Sammlung fehlen, werden gemeldet und weggelassen. +func apply_code(code: String) -> void: + var ids: Array = Profile.parse_deck_code(code) + if ids.is_empty(): + say("Ungültiger Code", false) + return + var p: Profile = app.profile + var missing := {} + deck = [] + for cid in ids: + var owned: int = p.collection.get(cid, 0) + if deck.count(cid) < owned and deck.size() < Game.DECK_SIZE: + deck.append(cid) + else: + missing[cid] = true + code_dialog.visible = false + if missing.is_empty(): + say("Code übernommen", true) + else: + say(tr("Code übernommen, es fehlen: ") + ", ".join(missing.keys().map(func(cid): return Cards.by_id(cid).title())), false) + refresh() + + func deck_cards() -> Array: var out := [] for cid in deck: @@ -361,7 +412,7 @@ func refresh() -> void: deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT) var elems := Game.deck_elements(dc) elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b)) - elems_label.text = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–") + elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–") if not dc.is_empty(): elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc) var ok: Array = Game.validate_deck(dc, p.collection) diff --git a/scripts/ui/lobby_screen.gd b/scripts/ui/lobby_screen.gd index f601fb3..008b0c4 100644 --- a/scripts/ui/lobby_screen.gd +++ b/scripts/ui/lobby_screen.gd @@ -9,6 +9,7 @@ var list_label: Label var ip_box: LineEdit var b_host: Button var b_join: Button +var b_watch: Button var b_start: Button var ai_buttons: Array = [] var n_ai := 0 @@ -32,6 +33,7 @@ func _ready() -> void: ip_box.text = "127.0.0.1" hr.add_child(ip_box) b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join) + b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch) addr_label = Style.clabel(col, "", 14, Style.MUTED) status_label = Style.clabel(col, "", 16, Style.BLUE) Style.spacer(col, 6) @@ -71,6 +73,13 @@ func _join() -> void: refresh() +## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit. +func _watch() -> void: + net.spectator = true + net.join(ip_box.text.strip_edges()) + refresh() + + func _set_ai(n: int) -> void: n_ai = n refresh() @@ -96,9 +105,12 @@ func refresh() -> void: ids.sort() for id in ids: lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""]) + for id in net.spectators: + lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")]) list_label.text = "\n".join(lines) if not lines.is_empty() else "–" b_host.disabled = net.connected b_join.disabled = net.connected + b_watch.disabled = net.connected ip_box.editable = not net.connected var humans := ids.size() b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 1e37964..e1a0fe3 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -40,6 +40,7 @@ func apply_settings() -> void: func reset_profile() -> void: profile = Profile.starter() profile.refresh_daily() + clear_battle_save() save() apply_settings() @@ -108,6 +109,16 @@ func save() -> void: profile.save(save_path) +## Datei für einen unterbrochenen Kampf (neben dem Spielstand). +func battle_save_path() -> String: + return save_path.get_basename() + "_kampf.json" + + +func clear_battle_save() -> void: + if FileAccess.file_exists(battle_save_path()): + DirAccess.remove_absolute(ProjectSettings.globalize_path(battle_save_path())) + + func _exit_tree() -> void: if net != null and net.is_inside_tree(): net.leave() diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd index 100d9a0..f3eaf90 100644 --- a/scripts/ui/menu_screen.gd +++ b/scripts/ui/menu_screen.gd @@ -8,6 +8,7 @@ var b_hotseat: Button var b_online: Button var b_tutorial: Button var b_challenge: Button +var b_resume: Button var coins_label: Label var coll_label: Label var deck_label: Label @@ -36,6 +37,8 @@ func _ready() -> void: var sz := Vector2(270, 44) var p0: Profile = app.profile var part2: bool = p0.tutorial_done and not p0.tutorial2_done + if FileAccess.file_exists(app.battle_save_path()): + b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628")) b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz, func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)), Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null) @@ -58,6 +61,17 @@ func _ready() -> void: refresh() +## Unterbrochenen Kampf laden; eine unlesbare Datei wird verworfen. +func _resume() -> void: + var json := JSON.new() + var text := FileAccess.get_file_as_string(app.battle_save_path()) + if json.parse(text) != OK or not (json.data is Dictionary) or not json.data.has("game"): + app.clear_battle_save() + app.show_screen(MenuScreen.new()) + return + app.show_screen(BattleScreen.resume(json.data)) + + func refresh() -> void: var p: Profile = app.profile var ok: Array = p.deck_valid() @@ -74,7 +88,7 @@ func refresh() -> void: coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [ p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()] var elems := Game.deck_elements(p.deck_cards()) - deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else tr("leer")] + deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")] var task := p.daily_task() if p.daily.done: daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index 480a6ab..a9e59af 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -37,6 +37,9 @@ func _ready() -> void: var info := (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n] Style.clabel(r, info, 13, Style.MUTED, "left", false, 150) preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600) + preview_labels[n].autowrap_mode = TextServer.AUTOWRAP_WORD + preview_labels[n].custom_minimum_size = Vector2(600, 0) + preview_labels[n].size_flags_horizontal = Control.SIZE_SHRINK_BEGIN Style.spacer(col, 4) var br := Style.row(col, 16) br.alignment = BoxContainer.ALIGNMENT_CENTER diff --git a/scripts/ui/settings_screen.gd b/scripts/ui/settings_screen.gd index 11c07b4..cae8aaf 100644 --- a/scripts/ui/settings_screen.gd +++ b/scripts/ui/settings_screen.gd @@ -70,7 +70,7 @@ func _ready() -> void: language = OptionButton.new() language.custom_minimum_size = Vector2(280, 36) language.add_item("Deutsch", 0) - language.add_item("English (Oberfläche)", 1) + language.add_item("English", 1) language.select(1 if s.language == "en" else 0) language.item_selected.connect(_on_language) grid.add_child(language) diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index 680c1f2..399065f 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -285,7 +285,7 @@ static func rules_text() -> String: Cards.RESONANCE_TEXT, "Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):", " Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an das stärkste gegnerische Wesen, Blitz 2 Schaden", - " an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.", + " an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen +1/+1.", "", "Schlüsselwörter:", ] @@ -295,9 +295,13 @@ static func rules_text() -> String: lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES]) lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.") lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.") + lines.append("Elementbooster: 5 Karten eines Elements (und seiner Kombos) für %d Münzen." % Profile.ELEMENT_BOOSTER_COST) + lines.append("Deck-Code: im Deckbau unter „Code“ kopieren und mit anderen teilen.") + lines.append("Ein unterbrochener Einzelspieler-Kampf lässt sich im Menü fortsetzen.") lines.append("") lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.") lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.") + lines.append("Netzwerkspiel: Fällt der Host aus, übernimmt ein Mitspieler; Zuschauer sehen die Partie ohne Hände.") lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.") lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine") lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.") diff --git a/scripts/ui/workshop_screen.gd b/scripts/ui/workshop_screen.gd index 0501cbf..971751d 100644 --- a/scripts/ui/workshop_screen.gd +++ b/scripts/ui/workshop_screen.gd @@ -24,9 +24,10 @@ func _ready() -> void: Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) for i in Cards.ELEMENTS.size(): var e: String = Cards.ELEMENTS[i] - Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) + var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) + fb.add_theme_font_size_override("font_size", 14) search_box = LineEdit.new() - search_box.position = Vector2(400, 55) + search_box.position = Vector2(424, 55) search_box.size = Vector2(200, 34) search_box.placeholder_text = "Suche" search_box.text_changed.connect(func(t: String): @@ -51,7 +52,7 @@ func _ready() -> void: func _input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus(): + if event.is_action_pressed("ui_cancel") and not search_box.has_focus(): app.show_screen(MenuScreen.new()) @@ -66,7 +67,7 @@ func cards() -> Array: out = out.filter(func(c): return filter in c.elements) if search != "": var q := search.to_lower() - out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower()) + out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower()) return out @@ -88,7 +89,7 @@ func dismantle() -> void: if err == "": app.sfx.play("click") app.save() - say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true) + say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[selected.rarity]], true) else: say(err, false) refresh() @@ -102,7 +103,7 @@ func craft() -> void: app.sfx.play("play") app.save() var notes: Array[String] = app.profile.take_notices() - say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true) + say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true) else: say(err, false) refresh() diff --git a/tests/net_bot.gd b/tests/net_bot.gd index 9fb0798..21a14f6 100644 --- a/tests/net_bot.gd +++ b/tests/net_bot.gd @@ -1,11 +1,14 @@ extends Node -## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge. +## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er +## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI). ## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot +## godot --path . tests/net_bot.tscn -- --host --name=BotHost var net: Net var seat := -1 var game: Game var t := 0.0 +var hosting := false func _ready() -> void: @@ -16,20 +19,37 @@ func _ready() -> void: ip = a.substr(5) if a.begins_with("--name="): bot_name = a.substr(7) + if a == "--host": + hosting = true net = Net.new() net.name = "Net" get_tree().root.add_child.call_deferred(net) net.my_name = bot_name net.my_deck = Profile.starter().deck.duplicate() - net.started.connect(func(s): seat = s) + net.started.connect(func(s): + seat = s + if hosting: + net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut + _act_host.call_deferred()) net.snapshot_received.connect(_on_snapshot) net.ended.connect(func(_r): get_tree().quit()) await get_tree().process_frame - net.join(ip) + if hosting: + net.host() + net.lobby_changed.connect(func(): + if net.game == null and net.names.size() >= 2: + net.start_battle(0)) + net.action_applied.connect(func(): + net.broadcast() + _act_host.call_deferred()) + else: + net.join(ip) func _process(delta: float) -> void: t += delta + if hosting and net.game != null: + net.tick() if t > 180.0 or FileAccess.file_exists("user://bot_quit"): net.leave() get_tree().quit() @@ -43,7 +63,23 @@ func _on_snapshot(snap: Dictionary) -> void: return if game.current_idx != seat: return - var me: Game.Player = game.players[seat] + _decide(game.players[seat]) + + +func _act_host() -> void: + if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat: + return + game = net.game + _decide(game.players[seat]) + + +## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel, +## dann angreifen, 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.mulligan_done and me.turns == 1: net.send_action({"type": "mulligan", "hand": []}) return diff --git a/tests/strength_probe.gd b/tests/strength_probe.gd index ed39896..129bc70 100644 --- a/tests/strength_probe.gd +++ b/tests/strength_probe.gd @@ -16,7 +16,7 @@ func _initialize() -> void: var r := RandomNumberGenerator.new() r.seed = 21 var arts := archetypes() - var n := 60 + var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60 var results := {} for a in arts: var wins := 0 diff --git a/tests/test_runner.gd b/tests/test_runner.gd index 4bae64f..781ece6 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -55,7 +55,11 @@ func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature: func _initialize() -> void: + TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg test_kartenpool() + test_kampf_speichern() + test_elementbooster_und_deckcode() + test_englische_karten() test_kombos() test_wesen_spielen() test_flink() @@ -117,7 +121,7 @@ func _initialize() -> void: func test_kartenpool() -> void: - check(Cards.all().size() == 110, "110 Karten") + check(Cards.all().size() == 118, "118 Karten") for key in Cards.COMBOS: var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key) check(found, "Kombo vorhanden: " + key) @@ -462,7 +466,7 @@ func test_spielstand() -> void: p.coins = 999 p.save(path) var p2 := Profile.load_or_new(path) - check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 42, "geladen") + check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == Cards.of_rarity("gewöhnlich").size() + 2, "geladen") for suffix in ["", ".bak"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) @@ -551,7 +555,7 @@ func test_feldkombos() -> void: s[2][1].life = 5 g.play_card(h, C("l_wind")) check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner") - # Schlamm heilt, Sturm zieht, Sand gibt Schild + # Schlamm heilt, Sturm zieht, Sand gibt +1/+1 s = setup(["e_wall", "l_wind", "w_reiter"]) g = s[0] h = s[1] @@ -562,13 +566,13 @@ func test_feldkombos() -> void: var hand_before := h.hand.size() g.play_card(h, C("l_wind")) check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)") - check(not h.board[2].shield, "nur eine Feldkombo je Ausspielen") + check(h.board[2].attack == 1, "nur eine Feldkombo je Ausspielen") s = setup(["l_wind"]) g = s[0] h = s[1] put(h, "e_wall") g.play_card(h, C("l_wind")) - check(h.board[1].shield, "Sand: neues Wesen hat Schild") + check(h.board[1].attack == 2 and h.board[1].health == 2, "Sand: neues Wesen erhält +1/+1") # Gleiche Elemente lösen keine Feldkombo aus s = setup(["f_kobold"]) g = s[0] @@ -736,7 +740,7 @@ func test_fallen() -> void: g.end_turn() var a := put(o, "e_troll") check(g.attack(a, h) == "", "Angriff in die Feuerfalle") - check(h.traps.is_empty() and a.health == 3 and h.life == 26, "Feuerfalle: 3 Schaden, Angriff geht durch") + check(h.traps.is_empty() and a.health == 3 and h.life == 27, "Feuerfalle: 3 Schaden, Angriff geht durch") # Frostfalle bricht den Angriff ab s = setup(["w_falle"]) g = s[0] @@ -1089,7 +1093,7 @@ func test_deck_umbenennen_und_auffuellen() -> void: func test_neue_fallen() -> void: - check(Cards.all().filter(func(c): return c.is_trap()).size() == 10, "zehn Fallen") + check(Cards.all().filter(func(c): return c.is_trap()).size() == 12, "zwölf Fallen") check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser") check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text") # Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft @@ -1144,7 +1148,11 @@ func test_neue_fallen() -> void: func test_kapitel_zwei_und_merkmalwahl() -> void: - check(Campaign.total() == 20 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2, "zwanzig Stufen in zwei Kapiteln") + check(Campaign.total() == 30 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(29) == 3, "dreißig Stufen in drei Kapiteln") + check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx" and Campaign.target_rating(29, 1.0) == 4.4, "Kapitel 3 mit drei Merkmalen und höherer Zielstärke") + for st in Campaign.STAGES3: + for pk in st.perks: + check(Game.PERKS.has(pk), "Merkmal %s bekannt" % pk) for i in Campaign.total(): var st := Campaign.stage(i) check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1)) @@ -1362,6 +1370,73 @@ func test_ki_zeitbudget() -> void: check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern") +## Elementbooster enthalten nur Karten des Elements; Deck-Codes sind umkehrbar und robust. +## Englisch: jede Karte hat einen englischen Namen, Kartentexte und Schlüsselwörter werden übersetzt. +func test_englische_karten() -> void: + check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen") + var de: String = Cards.by_id("f_funke").text() + TranslationServer.set_locale("en") + var funke := Cards.by_id("f_funke") + var falle := Cards.by_id("w_falle") + var wall := Cards.by_id("e_wall") + check(funke.title() == "Spark" and funke.text() == "2 damage to a target.", "Funke auf Englisch: %s / %s" % [funke.title(), funke.text()]) + check(falle.text().begins_with("Trap. When an enemy creature attacks") and "The attack fizzles" in falle.text(), "Fallentext auf Englisch: %s" % falle.text()) + check(wall.text() == "Guardian." and Cards.by_id("l_spiegel").text().ends_with("Summon a Wind Spirit."), "Schlüsselwort und Beschwörung auf Englisch") + TranslationServer.set_locale("de") + check(funke.title() == "Funke" and funke.text() == de, "zurück auf Deutsch") + + +func test_elementbooster_und_deckcode() -> void: + var p := Profile.starter() + p.coins = 500 + var r := RandomNumberGenerator.new() + r.seed = 5 + var got: Array = p.open_booster(r, "Wasser") + check(got.size() == 5 and got.all(func(c): return "Wasser" in c.elements) and p.coins == 500 - Profile.ELEMENT_BOOSTER_COST, "Wasser-Booster: fünf Wasserkarten, %d Münzen" % Profile.ELEMENT_BOOSTER_COST) + p.coins = 100 + check(p.open_booster(r, "Feuer").is_empty() and p.coins == 100, "Elementbooster braucht %d Münzen" % Profile.ELEMENT_BOOSTER_COST) + for e in Cards.ELEMENTS: + for rar in Profile.RARITY_WEIGHTS: + check(not Profile.booster_pool(rar, e).is_empty(), "%s hat %s Karten" % [e, rar]) + var deck: Array = Profile.starter().deck + var code := Profile.deck_code(deck) + check(code.begins_with(Profile.CODE_PREFIX) and Profile.parse_deck_code(code).size() == 20, "Deck-Code hin und zurück") + var back: Array = Profile.parse_deck_code(code) + back.sort() + var orig: Array = deck.duplicate() + orig.sort() + check(back == orig, "Deck-Code enthält genau die Karten") + check(Profile.parse_deck_code("kaputt").is_empty() and Profile.parse_deck_code("DINGER1:x_nix*2").is_empty() + and Profile.parse_deck_code("DINGER1:f_funke*9").size() == Game.MAX_COPIES, "ungültige Codes leer, Kopien gedeckelt") + check(Cards.by_id("w_flutfalle").is_trap() and Cards.by_id("fl_sturmfalke").combo() == "Blitz" and Cards.by_id("el_sandsturm").is_combo(), "neue Karten im Pool") + + +## Voller Spielstand (viewer -1) stellt Hände, Decks und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes. +func test_kampf_speichern() -> void: + var r := RandomNumberGenerator.new() + r.seed = 7 + var g := Game.new_battle(Profile.starter().deck_cards(), 2, r) + g.start() + AI.take_turn(g, g.current()) + var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1))) + var g2 := Game.from_snapshot(d) + var same := g2.players.size() == g.players.size() + for i in g.players.size(): + var a: Game.Player = g.players[i] + var b: Game.Player = g2.players[i] + var ids := func(cs: Array): return cs.map(func(c): return c.id) + same = same and ids.call(a.hand) == ids.call(b.hand) and ids.call(a.deck) == ids.call(b.deck) \ + and a.life == b.life and a.board.size() == b.board.size() and a.is_human == b.is_human + check(same and g2.current_idx == g.current_idx and g2.turn == g.turn and g2.human.is_human, "voller Spielstand: Hände, Decks und Bretter erhalten") + var guard := 0 + while g2.winner == "" and guard < 300: + AI.take_turn(g2, g2.current()) + guard += 1 + check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar") + var s2: Dictionary = g.snapshot(-2) + check(s2.players.all(func(p): return not (p.hand is Array) and not (p.traps is Array) and not (p.deck is Array)), "Zuschauer-Sicht ohne Hände, Decks und Fallen") + + ## Jeder tr("...")-Text in der Oberfläche und jeder Anleitungsschritt hat einen englischen Eintrag. func test_uebersetzung_vollstaendig() -> void: var keys := {} @@ -1376,6 +1451,9 @@ 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 + ["Wesen", "Zauber", "Falle"] + Cards.TRIGGER_TEXT.values(): + if not keys.has(k): + missing.append(k) var d := DirAccess.open("res://scripts/ui") d.list_dir_begin() var name := d.get_next() @@ -1389,6 +1467,11 @@ func test_uebersetzung_vollstaendig() -> void: if not keys.has(k) and not (k in missing): missing.append(k) name = d.get_next() + var rx_cards := RegEx.new() + rx_cards.compile("tr\\(\"([^\"]+)\"\\)") + for m in rx_cards.search_all(FileAccess.get_file_as_string("res://scripts/cards.gd")): + if not keys.has(m.get_string(1)) and not (m.get_string(1) in missing): + missing.append(m.get_string(1)) for stepset in [BattleTutorial.STEPS1, BattleTutorial.STEPS2]: for st in stepset: if not keys.has(st[0]): diff --git a/tests/ui_test.gd b/tests/ui_test.gd index d25c5a7..a929347 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -157,16 +157,22 @@ func _ready() -> void: for suffix in ["", ".bak", ".corrupt"]: if FileAccess.file_exists(SAVE + suffix): DirAccess.remove_absolute(abs + suffix) + if FileAccess.file_exists(SAVE.get_basename() + "_kampf.json"): + DirAccess.remove_absolute(abs.get_basename() + "_kampf.json") main = load("res://scenes/main.tscn").instantiate() main.save_path = SAVE add_child(main) await frames(3) var fast := OS.get_environment("DINGER_FAST") != "" var suite := [test_menu_und_regeln, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, - test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung] + test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung, + test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster] if not fast: - suite += [test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen] + suite += [test_hotseat, test_netzwerk, test_host_wechsel, test_zuschauer, test_kampf.bind(2, 3), test_kampf_abbrechen] suite.append(test_profil_zuruecksetzen) + var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer + if only != "": + suite = suite.filter(func(t: Callable): return t.get_method() in only.split(",")) for t in suite: await ensure_menu() await t.call() @@ -443,6 +449,97 @@ func give_up() -> void: check(screen() is MenuScreen, "Aufgeben führt ins Menü") +## Gamepad im Deckbau: Karten und Deckzeilen sind fokussierbar, A wirkt wie ein Klick, B geht zurück. +func test_gamepad_deckbau() -> void: + await click(find_button("Deck bauen")) + var scr = screen() + var before: int = scr.deck.size() + await pad(JOY_BUTTON_DPAD_DOWN) + check(get_viewport().gui_get_focus_owner() != null and main.pad_mode, "Gamepad setzt Fokus im Deckbau") + var row: Control = scr.deck_list.get_child(0) + row.grab_focus() + await frames(1) + check(row.has_focus() and scr.preview.visible, "Deckzeile fokussierbar, Vorschau folgt") + await pad(JOY_BUTTON_A) + check(scr.deck.size() == before - 1, "A entfernt die fokussierte Karte aus dem Deck") + var elems: Array = Game.deck_elements(scr.deck_cards()) + var target: CardView = null + for v in scr.grid.views(): + if not v.dim and v.card.elements.all(func(e): return e in elems): + target = v + break + target.grab_focus() + await frames(1) + check(target.has_focus() and scr.preview.visible and scr.preview.card == target.card, "Karte im Raster fokussierbar mit Vorschau") + var target_id: String = target.card.id + await pad(JOY_BUTTON_A) # das Raster wird danach neu aufgebaut, target ist dann freigegeben + check(scr.deck.size() == before and target_id in scr.deck, "A nimmt die fokussierte Karte ins Deck (%d Karten, Meldung '%s')" % [scr.deck.size(), scr.msg_label.text]) + await shot("31_gamepad_deckbau") + await pad(JOY_BUTTON_B) + check(scr.confirm.visible, "B fragt bei ungespeicherten Änderungen nach") + await click(find_button("Verwerfen")) + check(screen() is MenuScreen, "Verwerfen führt ins Menü") + await hover(main) + + +## Deck-Code kopieren, einfügen und übernehmen. +func test_deckcode() -> void: + await click(find_button("Deck bauen")) + var scr = screen() + await click(find_button("Code")) + check(scr.code_dialog.visible and scr.code_box.text.begins_with(Profile.CODE_PREFIX), "Code-Dialog zeigt den Deck-Code") + await click(find_button("In Zwischenablage")) + check(DisplayServer.clipboard_get() == scr.code_box.text, "Code in der Zwischenablage") + await shot("32_deckcode") + scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,x_nix*1" + await click(find_button("Übernehmen")) + check(scr.deck.size() == Game.DECK_SIZE and "Ungültiger Code" in scr.msg_label.text, "ungültiger Code wird abgelehnt") + scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,fff_sonne*1" + await click(find_button("Übernehmen")) + check(scr.deck == ["f_funke", "f_funke", "e_wall"] and "Kern der Sonne" in scr.msg_label.text and not scr.code_dialog.visible, "Code übernommen, fehlende Karte gemeldet") + await click(find_button("Zurück")) + await click(find_button("Verwerfen")) + check(screen() is MenuScreen, "Deckbau verlassen") + + +## Elementbooster liefert nur Karten des Elements. +func test_elementbooster() -> void: + main.profile.coins = 300 + await click(find_button("Booster öffnen")) + var scr = screen() + await click(find_button("Feuer")) + check(scr.cards.size() == 5 and scr.cards.all(func(c): return "Feuer" in c.elements) and main.profile.coins == 300 - Profile.ELEMENT_BOOSTER_COST, "Feuer-Booster: fünf Feuerkarten") + await click(find_button("Alle aufdecken")) + await shot("33_elementbooster") + await click(find_button("Zurück")) + + +## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei. +func test_kampf_fortsetzen() -> void: + await start_battle(1) + var scr = screen() + scr.ai_delay = 0.0 + await wait_for_human(scr) + var kampf: String = main.battle_save_path() + check(FileAccess.file_exists(kampf), "Kampf wird laufend gespeichert") + await click(scr.b_end) + await wait_for_human(scr) + var turn: int = scr.game.turn + var life: int = scr.game.human.life + var hand: int = scr.game.human.hand.size() + await click(find_button("Aufgeben")) + await click(find_button("Speichern und zurück")) + check(screen() is MenuScreen and find_button("Kampf fortsetzen") != null, "Speichern führt ins Menü mit Fortsetzen-Knopf") + await click(find_button("Kampf fortsetzen")) + var scr2 = screen() + check(scr2 is BattleScreen and scr2.game.turn == turn and scr2.game.human.life == life and scr2.game.human.hand.size() == hand + and scr2.game.current() == scr2.game.human, "Kampf fortgesetzt mit gleichem Zug, Leben und Hand") + scr2.ai_delay = 0.0 + await shot("30_kampf_fortgesetzt") + await give_up() + check(not FileAccess.file_exists(kampf) and find_button("Kampf fortsetzen") == null, "Aufgeben löscht den gespeicherten Kampf") + + func test_einstellungen() -> void: await click(find_button("Einstellungen")) var scr = screen() @@ -684,7 +781,7 @@ func test_anleitung() -> void: await shot("22_anleitung_mulligan") await click(scr.b_swap) check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht") - check(g.log.any(func(l): return "tauscht 1 Karten" in l), "Tausch im Verlauf") + check(g.log.any(func(l): return "tauscht 1 Karte" in l), "Tausch im Verlauf") check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar") var idx := -1 for i in g.human.hand.size(): @@ -738,7 +835,7 @@ func test_anleitung() -> void: check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug") var step_before: int = scr.tutorial_step await click(scr.b_tut_next) - check(scr.tutorial_step == step_before + 1, "Weiter-Knopf") + check(scr.tutorial_step >= step_before + 1, "Weiter-Knopf (Schritt %d)" % step_before) await key(KEY_ESCAPE) check(scr.confirm.visible, "Esc öffnet Aufgeben") await key(KEY_ESCAPE) @@ -834,6 +931,117 @@ func test_netzwerk() -> void: check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü") +func _bot_args(extra: Array) -> Array: + return ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--"] + extra + + +func _bot_quit(on: bool) -> void: + var path := "user://bot_quit" + if on: + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string("x") + f.close() + elif FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +## Verbindet sich mit dem Bot-Host (mehrere Versuche, bis er lauscht). +func _join_bot_host(watch: bool) -> void: + await click(find_button("Netzwerkspiel")) + var lobby = screen() + lobby.ip_box.text = "127.0.0.1" + for attempt in 8: + await click(find_button("Zuschauen" if watch else "Beitreten")) + var w := 0 + while not main.net.connected and w < 180: + await frames(1) + w += 1 + if main.net.connected: + return + main.net.leave() + await frames(30) + + +## Der Host (ein Bot) verschwindet: der einzige Mitspieler übernimmt und spielt zu Ende. +func test_host_wechsel() -> void: + _bot_quit(false) + var pid := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"])) + check(pid > 0, "Bot-Host gestartet") + await frames(30) + await _join_bot_host(false) + var w := 0 + while not (screen() is BattleScreen) and w < 900: + await frames(1) + w += 1 + var b = screen() + check(b is BattleScreen and b.is_client(), "als Mitspieler beim Bot-Host im Kampf") + w = 0 + while (b.game == null or main.net.backup.is_empty()) and w < 600: + 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") + main.net.rejoin_seconds = 3.0 + _bot_quit(true) + w = 0 + while not main.net.is_host and w < 900: + await frames(1) + w += 1 + check(main.net.is_host and b.game == main.net.game and screen() == b and b.game.log.any(func(l): return "übernimmt als Host" in l), + "Host-Wechsel: Mitspieler führt das Spiel weiter") + check(main.net.waiting.has("BotHost"), "Platz des alten Hosts bleibt zunächst frei") + await shot("34_host_wechsel") + # Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus + b.ai_delay = 0.0 + await clear_mulligan(b) + if b.game.current() == b.perspective: + await click(b.b_end) + w = 0 + while b.game.winner == "" and w < 900: + await frames(1) + w += 1 + check(b.game.log.any(func(l): return "übersprungen" in l), "Zug des fehlenden Hosts übersprungen") + check(b.game.winner != "" and b.game.winner_player == b.perspective and b.game.log.any(func(l): return "kehrt nicht zurück" in l), + "nach der Frist scheidet der alte Host aus, Partie gewonnen (Sieger '%s', Zug %d)" % [b.game.winner, b.game.turn]) + await leave_battle() + _bot_quit(false) + check(screen() is MenuScreen and not main.net.connected, "nach dem Host-Wechsel zurück im Menü") + + +## Zuschauer: sieht die Partie zweier Bots ohne Hände und ohne eigene Züge. +func test_zuschauer() -> void: + _bot_quit(false) + var pid_h := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"])) + await frames(30) + var pid_c := OS.create_process(OS.get_executable_path(), _bot_args(["--ip=127.0.0.1", "--name=Bot"])) + check(pid_h > 0 and pid_c > 0, "zwei Bots gestartet") + await _join_bot_host(true) + var w := 0 + while not (screen() is BattleScreen) and w < 1200: + await frames(1) + w += 1 + var b = screen() + check(b is BattleScreen and b.spectating(), "als Zuschauer im Kampf") + w = 0 + while (b.game == null or b.game.turn < 2) and w < 900: + await frames(1) + w += 1 + check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.mulligan_box.visible and not b.b_end.visible, + "Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0)) + await shot("35_zuschauer") + check(not b.handover.visible and not b.is_hotseat(), "Zuschauer bekommt keine Hotseat-Übergabe") + if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein + await leave_battle() + else: + var w2 := 0 + while not (screen() is MenuScreen) and w2 < 300: + await frames(1) + w2 += 1 + check(screen() is MenuScreen and not main.net.connected, "Zuschauer zurück im Menü") + _bot_quit(true) + await frames(60) + _bot_quit(false) + + func test_kampagne() -> void: await click(find_button("Kampagne")) var scr = screen() diff --git a/translations/ui.csv b/translations/ui.csv index ddd3a26..541bf99 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -135,3 +135,85 @@ Duell,Duel Stärke %.1f · %d Münzen,Strength %.1f · %d coins Abbrechen,Cancel "Ja, zurücksetzen","Yes, reset" +"Speichern und zurück","Save and go back" +"Kampf fortsetzen","Resume battle" +Aufgeben beendet den Kampf ohne Münzen.,Surrendering ends the battle without coins. +Speichern hebt ihn für später auf.,Saving keeps it for later. +%s Schaden an den Angreifer,%s damage to the attacker +%s Schaden an dieses Wesen,%s damage to that creature +Der Angreifer wird eingefroren,The attacker is frozen +Es wird eingefroren,It is frozen +Es geht zurück auf die Hand,It returns to its owner's hand +Der Angriff verpufft,The attack fizzles +Beschwöre einen %s,Summon a %s +%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature +%s Schaden an ein Ziel,%s damage to a target +%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures +%s Schaden an alle Gegner,%s damage to all opponents +%s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures +Heile dich um %s,Heal yourself for %s +Ziehe 1 Karte,Draw 1 card +Ziehe %s Karten,Draw %s cards +Friere ein gegnerisches Wesen ein,Freeze an enemy creature +Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature +Friere alle gegnerischen Wesen ein,Freeze all enemy creatures +Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand +Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands +Ein befreundetes Wesen erhält %s,A friendly creature gets %s +Alle befreundeten Wesen erhalten %s,All friendly creatures get %s +Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield +Erhält Schild,Gets Shield +Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield +Falle. %s: %s,Trap. %s: %s +Beim Ausspielen: %s,On play: %s +Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift,When an enemy creature attacks you or one of your creatures +Wenn ein Gegner ein Wesen ausspielt,When an opponent plays a creature +Wächter,Guardian +Flink,Swift +Schild,Shield +Doppelschlag,Double Strike +Wesen,Creature +Zauber,Spell +Falle,Trap +gewöhnlich,common +selten,rare +episch,epic +legendär,legendary +mythisch,mythic +Feuer,Fire +Wasser,Water +Erde,Earth +Luft,Air +Inferno,Inferno +Tiefsee,Deep Sea +Berg,Mountain +Orkan,Hurricane +Dampf,Steam +Lava,Lava +Blitz,Lightning +Schlamm,Mud +Sturm,Storm +Sand,Sand +Sonne,Sun +Ozean,Ocean +Gebirge,Mountains +Himmel,Sky +"Code übernommen, es fehlen: ","Code applied, missing: " +Elemente: ,Elements: +%s zerlegt (+%d Staub),%s dismantled (+%d dust) +%s hergestellt,%s crafted +%s ist jetzt im Deck %s,%s is now in deck %s +Deck-Code,Deck code +In Zwischenablage,To clipboard +Aus Zwischenablage,From clipboard +Code,Code +Ungültiger Code,Invalid code +Code übernommen,Code applied +Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.,Copy to share or paste a code. Cards you do not own are skipped. +Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos +"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks." +Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good! +Kapitel %d,Chapter %d +Zuschauen,Watch +Zuschauer,Spectator +"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden" diff --git a/translations/ui.en.translation b/translations/ui.en.translation index 75023f7..a7a5135 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ