Anleitung 2, Gamepad, große Schrift, Profil-Reset, Würfel-Limit, Tipp nach Spielverlauf, KI-Zeitbudget, Deck-Warnung, Gischtläufer, Kampfbildschirm aufgeteilt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
This commit is contained in:
Team3
2026-09-16 16:27:59 +02:00
parent 0dbc98b456
commit 4246d7e970
22 changed files with 728 additions and 164 deletions

View File

@@ -34,7 +34,10 @@ Der Spielstand liegt unter `user://spielstand.json`
- Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
- Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen
- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor
- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben
- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm
- Anleitung Teil 2 (Mulligan, Fallen, Feldkombo Dampf), Gamepad-Steuerung, große Schrift, Profil zurücksetzen
- Gegnervorschau mit höchstens drei Neuwürfen, Schwierigkeitstipp nach Spielverlauf (deutliche Siege oder drei Niederlagen)
- KI mit Zeitbudget je Entscheidung (150 ms), Warnung bei ungespeichertem Deck
## Tests
@@ -70,7 +73,7 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte, Anleitung, Mulligan, Gamepad), Erfolge, Einstellungen, Regeln, Ton
- `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
- `scripts/challenge.gd` – wöchentliche Herausforderungen

View File

@@ -936,6 +936,29 @@
}
]
},
{
"id": "wl_gischt",
"name": "Gischtläufer",
"elements": [
"Wasser",
"Luft"
],
"cost": 2,
"rarity": "selten",
"kind": "Wesen",
"attack": 2,
"health": 2,
"keywords": [
"Flink"
],
"effects": [
{
"kind": "draw",
"target": "none",
"amount": 1
}
]
},
{
"id": "wl_koenigin",
"name": "Sturmkönigin",

View File

@@ -95,6 +95,7 @@ static func map_target(g2: Game, game: Game, t):
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
@@ -111,7 +112,12 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
return {"type": "mulligan", "cards": mulligan_cards(p)}
var best := {"type": "end"}
var best_score := evaluate(game, p) + 0.01
var t0 := Time.get_ticks_msec()
var examined := 0
for action in candidate_actions(game, p):
if examined > 0 and Time.get_ticks_msec() - t0 > time_budget_ms:
break
examined += 1
var g2 := game.clone_for(p)
apply_on_clone(g2, game, action)
var p2: Game.Player = g2.players[game.current_idx]
@@ -119,7 +125,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if g2.winner == "":
var count := 0
for f in candidate_actions(g2, p2):
if count >= FOLLOW_UPS:
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
break
count += 1
var g3 := g2.clone_for(p2)

View File

@@ -7,9 +7,9 @@ const RESONANCE_STEPS := [0, 1, 2, 3, 3]
# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, tests/strength_probe.gd).
const ARCHETYPE_STRENGTH := {
"Feuer": 36, "Wasser": 30, "Erde": 63, "Luft": 50,
"Feuer+Wasser": 46, "Feuer+Erde": 70, "Feuer+Luft": 40,
"Wasser+Erde": 46, "Wasser+Luft": 20, "Erde+Luft": 46,
"Feuer": 40, "Wasser": 56, "Erde": 46, "Luft": 63,
"Feuer+Wasser": 40, "Feuer+Erde": 83, "Feuer+Luft": 40,
"Wasser+Erde": 53, "Wasser+Luft": 23, "Erde+Luft": 60,
}

View File

@@ -64,13 +64,14 @@ var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
var achievements: Array = []
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var daily := {"date": "", "task": "", "progress": 0, "done": false}
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal"}
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe)
var card_stats := {} # id -> {"played", "games", "wins"}
var booster_since_mythic := 0
var tutorial_done := false
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0}
var tutorial2_done := false
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []}
var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung
## Das aktive Deck (Kartenliste des gewählten Slots).
@@ -158,7 +159,8 @@ static func from_dict(d: Dictionary) -> Profile:
var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal"))}
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
"big_font": bool(se.get("big_font", false))}
if not DIFFICULTIES.has(p.settings.difficulty):
p.settings.difficulty = "normal"
if version >= 4:
@@ -174,8 +176,10 @@ static func from_dict(d: Dictionary) -> Profile:
p.perk = ""
var rc = d.get("records", {})
p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)),
"fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0))}
"fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0)),
"margins": Array(rc.get("margins", [])).map(func(m): return int(m))}
p.challenge_week = int(d.get("challenge_week", -1))
p.tutorial2_done = bool(d.get("tutorial2_done", false))
return p
@@ -185,7 +189,7 @@ func to_dict() -> Dictionary:
"stats": stats, "achievements": achievements, "campaign_stage": campaign_stage,
"daily": daily, "settings": settings, "card_stats": card_stats,
"booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done,
"perk": perk, "records": records, "challenge_week": challenge_week}
"perk": perk, "records": records, "challenge_week": challenge_week, "tutorial2_done": tutorial2_done}
## Speichert und sichert den vorherigen Stand als .bak.
@@ -376,8 +380,11 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche.
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
turns: int = 0, challenge: Dictionary = {}, reward_factor: float = 1.0) -> int:
turns: int = 0, challenge: Dictionary = {}, reward_factor: float = 1.0, remaining_life: int = -1) -> int:
stats.games += 1
records.margins.append(maxi(remaining_life, 0) if won else 0)
while records.margins.size() > 5:
records.margins.pop_front()
if won:
records.streak += 1
records.loss_streak = 0
@@ -483,12 +490,18 @@ func difficulty() -> Dictionary:
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
## Vorschlag: nach drei Siegen in Folge eine Stufe rauf, nach drei Niederlagen runter. "" = kein Vorschlag.
## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf
## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag.
func suggest_difficulty() -> String:
var order := ["leicht", "normal", "schwer"]
var i := order.find(str(settings.get("difficulty", "normal")))
if records.streak >= 3 and i < order.size() - 1:
return order[i + 1]
if i < order.size() - 1 and records.streak >= 3:
var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3))
var avg := 0.0
for m in last:
avg += float(m) / last.size()
if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0):
return order[i + 1]
if records.loss_streak >= 3 and i > 0:
return order[i - 1]
return ""

View File

@@ -0,0 +1,139 @@
class_name BattleGamepad
extends RefCounted
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele,
## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig.
var screen # BattleScreen
var active := false
var zone := "hand"
var index := 0
var targets: Array = []
func _init(s) -> void:
screen = s
func button(button: int) -> void:
active = true
if screen.game == null:
return
match button:
JOY_BUTTON_START:
if screen.my_turn() and not screen.any_overlay():
screen.end_turn()
JOY_BUTTON_B:
if screen.confirm.visible:
screen.confirm.visible = false
screen.cancel_all()
zone = "hand"
JOY_BUTTON_Y:
if screen.b_skip.visible:
screen.skip_ai = true
JOY_BUTTON_X:
if screen.b_undo.visible:
screen.undo()
JOY_BUTTON_A:
confirm()
JOY_BUTTON_DPAD_LEFT:
move(-1)
JOY_BUTTON_DPAD_RIGHT:
move(1)
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
if zone != "target":
zone = "own" if zone == "hand" else "hand"
index = 0
screen.refresh()
func items() -> Array:
match zone:
"hand":
return screen.hand_views
"own":
return screen.own_views.filter(func(v): return v.creature.can_attack())
"target":
return targets
return []
func move(dir: int) -> void:
var n := items().size()
if n > 0:
index = posmod(index + dir, n)
func confirm() -> void:
if screen.handover.visible:
screen.take_over()
return
if screen.mulligan.visible:
screen.do_mulligan([])
return
if screen.overlay.visible:
screen.leave()
return
if screen.confirm.visible:
return
var items := items()
if items.is_empty():
return
index = clampi(index, 0, items.size() - 1)
var item = items[index]
match zone:
"hand":
screen.on_click("hand", item.card)
if screen.pending != null:
targets = []
var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type())
for v in screen.enemy_views:
if v.creature in valid:
targets.append(v)
for v in screen.own_views:
if v.creature in valid:
targets.append(v)
for o in screen.others():
if o in valid:
targets.append(screen.hero_panels[o])
zone = "target"
index = 0
"own":
screen.on_click("own", item.creature)
if screen.selected != null:
targets = []
for v in screen.enemy_views:
if screen.game.can_attack(screen.selected, v.creature)[0]:
targets.append(v)
for o in screen.others():
if o.alive() and screen.game.can_attack(screen.selected, o)[0]:
targets.append(screen.hero_panels[o])
zone = "target"
index = 0
"target":
if item is CreatureView:
screen.on_tile_click(item.creature)
else:
for o in screen.hero_panels:
if screen.hero_panels[o] == item:
screen.on_click("hero", o)
zone = "hand"
index = 0
func draw_cursor() -> void:
Style.clear(screen.cursor_layer)
if not active or screen.game == null or not screen.my_turn():
return
var items := items()
if items.is_empty():
return
index = clampi(index, 0, items.size() - 1)
var item = items[index]
var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size)
var hl := BattleScreen.Highlight.new()
hl.position = r.position - Vector2(6, 6)
hl.size = r.size + Vector2(12, 12)
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
screen.cursor_layer.add_child(hl)

View File

@@ -0,0 +1 @@
uid://8wth8k7f7uvk

View File

@@ -0,0 +1,63 @@
class_name BattleMulligan
extends Control
## Mulligan-Maske: Karten anklicken, behalten oder tauschen.
signal decided(indexes: Array)
var layer: Control
var b_keep: Button
var b_swap: Button
var selected: Array = []
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(dark)
Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
layer = Control.new()
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(layer)
b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c"))
b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true))
func open(hand: Array) -> void:
selected = []
visible = true
Style.clear(layer)
for i in hand.size():
var r := BattleLayout.hand_rect(hand.size(), i)
var v := CardView.new(hand[i], r.size)
v.position = Vector2(r.position.x, 322)
var idx := i
v.clicked.connect(func(_view): toggle(idx))
layer.add_child(v)
_borders()
func toggle(idx: int) -> void:
if idx in selected:
selected.erase(idx)
else:
selected.append(idx)
_borders()
func _borders() -> void:
var views := layer.get_children()
for i in views.size():
views[i].border = Style.YELLOW if i in selected else null
views[i].queue_redraw()
b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen"
func finish(swap: bool) -> void:
visible = false
decided.emit(selected.duplicate() if swap else [])

View File

@@ -0,0 +1 @@
uid://gwdm38e83cbj

View File

@@ -24,8 +24,18 @@ var ended := false
var zoom_player = null
var preview_timer := 0.0
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
var mulligan_selected: Array = []
var tutorial_step := 0
var guide: BattleTutorial
var tutorial_part := 1
var tutorial_step: int:
get:
return guide.step if guide != null else 0
var pad: BattleGamepad
var gp_zone: String:
get:
return pad.zone
var gp_index: int:
get:
return pad.index
var challenge := {} # wöchentliche Herausforderung oder leer
var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner
var skip_ai := false # Gegnerzüge im Schnelldurchlauf
@@ -69,10 +79,23 @@ var notice_label: Label
var confirm: Control
var handover: Control
var handover_label: Label
var mulligan_box: Control
var mulligan_layer: Control
var b_keep: Button
var b_swap: Button
var mulligan: BattleMulligan
var mulligan_box: Control:
get:
return mulligan
var mulligan_layer: Control:
get:
return mulligan.layer
var b_keep: Button:
get:
return mulligan.b_keep
var b_swap: Button:
get:
return mulligan.b_swap
var mulligan_selected: Array:
get:
return mulligan.selected
var cursor_layer: Control
var tutorial_panel: Panel
var tutorial_text: RichTextLabel
var b_tut_next: Button
@@ -94,17 +117,6 @@ class Highlight extends Control:
var a := 0.55 + 0.45 * sin(t * 6.0)
Style.border(self, Rect2(Vector2.ZERO, size), Color(Style.GOLD, a), 4, 8)
const TUTORIAL_STEPS := [
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
"played_creature"],
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig +1 Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an alle gegnerischen Wesen.", "combo"],
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
]
func _init(n: int = 2, campaign_stage: int = -1) -> void:
n_opp = n
@@ -124,9 +136,10 @@ static func online(seat: int) -> BattleScreen:
return b
static func guided() -> BattleScreen:
static func guided(part: int = 1) -> BattleScreen:
var b := BattleScreen.new(1, -1)
b.tutorial = true
b.tutorial_part = part
return b
@@ -162,6 +175,7 @@ func _ready() -> void:
var s: Dictionary = app.profile.settings
fx = BattleFx.new(self)
fx.enabled = s.animations
pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed)
_build_static()
if is_online():
@@ -177,10 +191,12 @@ func _ready() -> void:
elif not hotseat_decks.is_empty():
game = Game.new_hotseat(hotseat_decks, hotseat_ai)
elif tutorial:
guide = BattleTutorial.new(self, tutorial_part)
var r := Game.make_rng()
var opps := Campaign.opponents(0, r, Game.deck_rating(app.profile.deck_cards()))
var opps := Campaign.opponents(guide.stage_index(), r, Game.deck_rating(app.profile.deck_cards()))
opps[0].perks = []
game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true)
guide.inject_cards(game.human.hand)
elif not challenge.is_empty():
var rules: Dictionary = challenge.rules.duplicate()
rules["target_rating"] = Game.deck_rating(app.profile.deck_cards())
@@ -273,16 +289,11 @@ func _build_static() -> void:
highlight_layer = _layer()
zoom_layer = _layer()
fx_layer = _layer()
cursor_layer = _layer()
# Mulligan
mulligan_box = _overlay()
Style.panel(mulligan_box, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
Style.label(mulligan_box, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
Style.label(mulligan_box, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
mulligan_layer = Control.new()
mulligan_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
mulligan_box.add_child(mulligan_layer)
b_keep = Style.button(mulligan_box, "Behalten", Rect2(430, 515, 200, 44), func(): do_mulligan(false), Color("285a3c"))
b_swap = Style.button(mulligan_box, "Tauschen", Rect2(650, 515, 200, 44), func(): do_mulligan(true))
mulligan = BattleMulligan.new()
mulligan.decided.connect(do_mulligan)
add_child(mulligan)
# Spielende
overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
@@ -327,8 +338,11 @@ func _overlay() -> Control:
func leave() -> void:
if is_online():
net.leave()
if tutorial and not app.profile.tutorial_done:
app.profile.tutorial_done = true
if tutorial:
if tutorial_part == 1:
app.profile.tutorial_done = true
else:
app.profile.tutorial2_done = true
app.save()
app.show_screen(MenuScreen.new())
@@ -415,7 +429,7 @@ func my_turn() -> bool:
func any_overlay() -> bool:
return confirm.visible or handover.visible or mulligan_box.visible or overlay.visible
return confirm.visible or handover.visible or mulligan.visible or overlay.visible
func _input(event: InputEvent) -> void:
@@ -424,6 +438,9 @@ func _input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
cancel_all()
return
if event is InputEventJoypadButton and event.pressed:
pad.button(event.button_index)
return
if not (event is InputEventKey and event.pressed and not event.echo):
return
match event.keycode:
@@ -445,8 +462,8 @@ func _input(event: InputEvent) -> void:
KEY_ENTER, KEY_KP_ENTER:
if handover.visible:
take_over()
elif mulligan_box.visible:
do_mulligan(false)
elif mulligan.visible:
do_mulligan([])
elif overlay.visible:
leave()
elif tutorial and b_tut_next.visible:
@@ -494,7 +511,7 @@ func _process(delta: float) -> void:
handover.visible = true
hand_layer.visible = false
trap_layer.visible = false
elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan_box.visible and not handover.visible:
elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible:
show_mulligan()
return
if is_client():
@@ -517,9 +534,12 @@ func _finish() -> void:
var won := game.winner == "human"
if counts_for_profile():
var factor: float = float(app.profile.difficulty().reward) if stage < 0 and challenge.is_empty() and not tutorial else 1.0
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge, factor)
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge, factor, game.human.life)
if tutorial:
app.profile.tutorial_done = true
if tutorial_part == 1:
app.profile.tutorial_done = true
else:
app.profile.tutorial2_done = true
app.save()
app.sfx.play("win" if won else "lose")
else:
@@ -610,39 +630,11 @@ func end_turn() -> void:
func show_mulligan() -> void:
mulligan_selected = []
mulligan_box.visible = true
Style.clear(mulligan_layer)
var hand: Array = perspective.hand
for i in hand.size():
var r := BattleLayout.hand_rect(hand.size(), i)
var v := CardView.new(hand[i], r.size)
v.position = Vector2(r.position.x, 322)
var idx := i
v.clicked.connect(func(_view): toggle_mulligan(idx))
mulligan_layer.add_child(v)
_mulligan_borders()
mulligan.open(perspective.hand)
func toggle_mulligan(idx: int) -> void:
if idx in mulligan_selected:
mulligan_selected.erase(idx)
else:
mulligan_selected.append(idx)
_mulligan_borders()
func _mulligan_borders() -> void:
var views := mulligan_layer.get_children()
for i in views.size():
views[i].border = Style.YELLOW if i in mulligan_selected else null
views[i].queue_redraw()
b_swap.text = "Tauschen (%d)" % mulligan_selected.size() if not mulligan_selected.is_empty() else "Tauschen"
func do_mulligan(swap: bool) -> void:
mulligan_box.visible = false
var idxs: Array = mulligan_selected.duplicate() if swap else []
func do_mulligan(idxs: Array) -> void:
mulligan.visible = false
if is_client():
net.send_action({"type": "mulligan", "hand": idxs})
return
@@ -826,69 +818,21 @@ func _note_events(evs: Array) -> void:
# --- Anleitung ------------------------------------------------------------------------------
func tutorial_condition(kind: String) -> bool:
var h := game.human
match kind:
"played_creature":
return game.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
"turn_passed":
return h.turns >= 2
"attacked":
return game.log.any(func(l): return l.ends_with("greift Fenn an.") or (" greift " in l and l.begins_with(h.board[0].name) if not h.board.is_empty() else false))
"resonance":
return h.board.any(func(c): return c.resonance > 0)
"combo":
return game.log.any(func(l): return l.begins_with("Feldkombo"))
"spell":
return game.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
return false
func tutorial_next() -> void:
if tutorial_step < TUTORIAL_STEPS.size() - 1:
tutorial_step += 1
if guide != null and guide.next():
app.sfx.play("click")
refresh()
## Hebt hervor, was der aktuelle Schritt verlangt.
func _tutorial_refresh() -> void:
guide.advance()
tutorial_text.text = guide.text()
b_tut_next.visible = not guide.finished()
func _tutorial_highlights(mine: bool) -> void:
Style.clear(highlight_layer)
if not mine or any_overlay():
return
var g := game
var h := perspective
var rects: Array[Rect2] = []
match TUTORIAL_STEPS[tutorial_step][1]:
"played_creature":
for v in hand_views:
if v.card.is_creature() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"turn_passed":
rects.append(b_end.get_rect())
"attacked":
for v in own_views:
if v.creature.can_attack():
rects.append(Rect2(v.slot, v.size))
if not own_views.is_empty() and own_views.any(func(v): return v.creature.can_attack()):
for o in others():
if o.alive() and hero_panels.has(o):
rects.append(hero_panels[o].get_rect())
"resonance":
for v in hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return e in c.elements)):
rects.append(Rect2(v.position, v.size))
"combo":
for v in hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return not (e in c.elements))):
rects.append(Rect2(v.position, v.size))
"spell":
for v in hand_views:
if v.card.kind == "Zauber" and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
for r in rects:
for r in guide.highlight_rects(mine):
var hl := Highlight.new()
hl.position = r.position - Vector2(4, 4)
hl.size = r.size + Vector2(8, 8)
@@ -896,13 +840,6 @@ func _tutorial_highlights(mine: bool) -> void:
highlight_layer.add_child(hl)
func _tutorial_refresh() -> void:
while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]):
tutorial_step += 1
tutorial_text.text = "%d/%d %s" % [tutorial_step + 1, TUTORIAL_STEPS.size(), TUTORIAL_STEPS[tutorial_step][0]]
b_tut_next.visible = tutorial_step < TUTORIAL_STEPS.size() - 1
# --- Ansicht angleichen --------------------------------------------------------------------
func refresh() -> void:
@@ -1053,6 +990,7 @@ func refresh() -> void:
if tutorial:
_tutorial_refresh()
_tutorial_highlights(mine)
pad.draw_cursor()
Style.clear(zoom_layer)
if zoom_player != null and zoom_player.alive():
@@ -1076,7 +1014,7 @@ func refresh() -> void:
overlay.visible = true
confirm.visible = false
handover.visible = false
mulligan_box.visible = false
mulligan.visible = false
func build_zoom(valid: Array) -> void:

View File

@@ -0,0 +1,144 @@
class_name BattleTutorial
extends RefCounted
## Geführte Partien: Schritte, Bedingungen und Hervorhebungen (ausgelagert aus dem Kampfbildschirm).
const STEPS1 := [
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
"played_creature"],
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an alle gegnerischen Wesen.", "combo"],
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
]
const STEPS2 := [
["Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.", "mulligan"],
["Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.", "trap_set"],
["Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.", "trap_fired"],
["Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.", "combo_dampf"],
["Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!", ""],
]
var screen # BattleScreen
var part := 1
var step := 0
func _init(s, p: int = 1) -> void:
screen = s
part = p
func steps() -> Array:
return STEPS1 if part == 1 else STEPS2
func stage_index() -> int:
return 0 if part == 1 else 1
## Teil 2 bekommt die Lehrkarten auf die Hand.
func inject_cards(hand: Array) -> void:
if part == 2:
hand.append(Cards.by_id("w_falle"))
hand.append(Cards.by_id("w_reiter"))
func condition(kind: String) -> bool:
var g: Game = screen.game
var h: Game.Player = g.human
match kind:
"played_creature":
return g.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
"turn_passed":
return h.turns >= 2
"attacked":
return g.log.any(func(l): return l.begins_with(h.board[0].name + " greift") if not h.board.is_empty() else false) \
or g.log.any(func(l): return l.ends_with("greift Fenn an."))
"resonance":
return h.board.any(func(c): return c.resonance > 0)
"combo":
return g.log.any(func(l): return l.begins_with("Feldkombo"))
"spell":
return g.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
"mulligan":
return h.mulligan_done
"trap_set":
return g.log.any(func(l): return l == "Du legt eine Falle.")
"trap_fired":
return g.log.any(func(l): return l.begins_with("Falle von Du"))
"combo_dampf":
return g.log.any(func(l): return l.begins_with("Feldkombo Dampf"))
return false
func advance() -> void:
while step < steps().size() - 1 and condition(steps()[step][1]):
step += 1
func next() -> bool:
if step < steps().size() - 1:
step += 1
return true
return false
func text() -> String:
return "%d/%d %s" % [step + 1, steps().size(), steps()[step][0]]
func finished() -> bool:
return step >= steps().size() - 1
## Rechtecke, die der aktuelle Schritt hervorhebt.
func highlight_rects(mine: bool) -> Array[Rect2]:
var rects: Array[Rect2] = []
if not mine or screen.any_overlay():
return rects
var g: Game = screen.game
var h: Game.Player = screen.perspective
match steps()[step][1]:
"played_creature":
for v in screen.hand_views:
if v.card.is_creature() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"turn_passed", "trap_fired":
rects.append(screen.b_end.get_rect())
"attacked":
for v in screen.own_views:
if v.creature.can_attack():
rects.append(Rect2(v.slot, v.size))
if screen.own_views.any(func(v): return v.creature.can_attack()):
for o in screen.others():
if o.alive() and screen.hero_panels.has(o):
rects.append(screen.hero_panels[o].get_rect())
"resonance":
for v in screen.hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return e in c.elements)):
rects.append(Rect2(v.position, v.size))
"combo":
for v in screen.hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return not (e in c.elements))):
rects.append(Rect2(v.position, v.size))
"spell":
for v in screen.hand_views:
if v.card.kind == "Zauber" and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"trap_set":
for v in screen.hand_views:
if v.card.is_trap() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"combo_dampf":
var has_fire: bool = h.board.any(func(b): return "Feuer" in b.card.elements)
for v in screen.hand_views:
var c: Cards.Card = v.card
if not c.is_creature() or not g.can_play(h, c)[0]:
continue
if (has_fire and "Wasser" in c.elements) or (not has_fire and "Feuer" in c.elements):
rects.append(Rect2(v.position, v.size))
return rects

View File

@@ -0,0 +1 @@
uid://cg78afyod2873

View File

@@ -27,6 +27,7 @@ var slot_buttons: Array = []
var b_activate: Button
var b_autofill: Button
var name_box: LineEdit
var confirm: Control
class DeckRow extends Control:
@@ -151,7 +152,21 @@ func _ready() -> void:
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c"))
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck)
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), leave)
confirm = Control.new()
confirm.set_anchors_preset(Control.PRESET_FULL_RECT)
confirm.visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
confirm.add_child(dark)
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.GOLD, 2)
Style.label(confirm, "Ungespeicherte Änderungen", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
Style.label(confirm, "Das Deck wurde geändert, aber nicht gespeichert.", 16, Style.MUTED, Rect2(340, 360, 600, 24), "center")
Style.button(confirm, "Verwerfen", Rect2(380, 420, 240, 46), func(): app.show_screen(MenuScreen.new()), Color("5a2828"))
Style.button(confirm, "Weiter bearbeiten", Rect2(660, 420, 240, 46), func(): confirm.visible = false)
add_child(confirm)
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview.visible = false
@@ -159,9 +174,23 @@ func _ready() -> void:
refresh()
func dirty() -> bool:
return deck != app.profile.decks[slot].cards
func leave() -> void:
if dirty():
confirm.visible = true
else:
app.show_screen(MenuScreen.new())
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus():
app.show_screen(MenuScreen.new())
if confirm.visible:
confirm.visible = false
else:
leave()
func _process(_delta: float) -> void:

View File

@@ -23,10 +23,24 @@ func _ready() -> void:
get_tree().root.add_child.call_deferred(net)
profile = Profile.load_or_new(save_path)
profile.refresh_daily()
apply_settings()
show_screen(MenuScreen.new())
func apply_settings() -> void:
sfx.set_volume(profile.settings.volume)
sfx.set_music_volume(profile.settings.music)
set_language(profile.settings.language)
show_screen(MenuScreen.new())
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
theme = Style.make_theme()
## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen.
func reset_profile() -> void:
profile = Profile.starter()
profile.refresh_daily()
save()
apply_settings()
func set_language(code: String) -> void:

View File

@@ -35,8 +35,10 @@ func _ready() -> void:
col.add_child(center)
var sz := Vector2(270, 44)
var p0: Profile = app.profile
b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()),
Color("5a4628") if not p0.tutorial_done else null)
var part2: bool = p0.tutorial_done and not p0.tutorial2_done
b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz,
func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)),
Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null)
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c"))
@@ -82,6 +84,9 @@ func refresh() -> void:
if ok[0] and not p.tutorial_done:
warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos."
warn_label.add_theme_color_override("font_color", Style.GOLD)
elif ok[0] and not p.tutorial2_done:
warn_label.text = "Anleitung 2 zeigt Mulligan, Fallen und die Feldkombo Dampf."
warn_label.add_theme_color_override("font_color", Style.GOLD)
var notes := p.take_notices()
notice_label.text = " · ".join(notes)
if not notes.is_empty():

View File

@@ -9,6 +9,8 @@ var diff_buttons := {}
var previews := {} # n -> Array von Game.Player
var preview_labels := {}
var b_tip: Button
var b_reroll: Button
var rerolls_left := 3
func _ready() -> void:
@@ -38,7 +40,7 @@ func _ready() -> void:
Style.spacer(col, 4)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Neu würfeln", Vector2(150, 40), func(): refresh(true))
b_reroll = Style.cbutton(br, "Neu würfeln", Vector2(190, 40), reroll)
Style.cbutton(br, "Zurück", Vector2(150, 40), func(): app.show_screen(MenuScreen.new()))
refresh(true)
@@ -55,6 +57,14 @@ func take_tip() -> void:
set_difficulty(tip)
## Höchstens dreimal neu würfeln, sonst ließen sich schwache Gegner erwürfeln.
func reroll() -> void:
if rerolls_left <= 0:
return
rerolls_left -= 1
refresh(true)
func start(n: int) -> void:
app.show_screen(BattleScreen.against(previews[n]))
@@ -82,6 +92,8 @@ func refresh(reroll: bool = false) -> void:
for i in n:
opps.append(Game.random_opponent(r, i + 1, Game.START_LIFE, p.target_rating(), str(d.tier)))
previews[n] = opps
b_reroll.text = tr("Neu würfeln (%d)") % rerolls_left
b_reroll.disabled = rerolls_left <= 0
for n in previews:
var parts: Array[String] = []
for o in previews[n]:

View File

@@ -7,6 +7,8 @@ var music: HSlider
var speed: HSlider
var language: OptionButton
var music_label: Label
var big_font: CheckButton
var confirm: Control
var anim: CheckButton
var volume_label: Label
var speed_label: Label
@@ -58,6 +60,12 @@ func _ready() -> void:
anim.toggled.connect(_on_anim)
grid.add_child(anim)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Große Schrift", 20, Style.TEXT, "left", false, 200)
big_font = CheckButton.new()
big_font.button_pressed = bool(s.get("big_font", false))
big_font.toggled.connect(_on_big_font)
grid.add_child(big_font)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200)
language = OptionButton.new()
language.custom_minimum_size = Vector2(280, 36)
@@ -69,13 +77,40 @@ func _ready() -> void:
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.spacer(col, 20)
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
Style.spacer(col, 60)
var r := Style.row(col)
Style.spacer(col, 30)
var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
confirm = Control.new()
confirm.set_anchors_preset(Control.PRESET_FULL_RECT)
confirm.visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
confirm.add_child(dark)
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.RED, 2)
Style.label(confirm, "Profil wirklich zurücksetzen?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
Style.label(confirm, "Sammlung, Decks, Münzen und Fortschritt gehen verloren.", 16, Style.MUTED, Rect2(340, 360, 600, 24), "center")
Style.button(confirm, "Ja, zurücksetzen", Rect2(380, 420, 240, 46), _reset, Color("5a2828"))
Style.button(confirm, "Abbrechen", Rect2(660, 420, 240, 46), func(): confirm.visible = false)
add_child(confirm)
_labels()
func _reset() -> void:
app.reset_profile()
app.show_screen(MenuScreen.new())
func _on_big_font(on: bool) -> void:
app.profile.settings.big_font = on
app.save()
app.apply_settings()
app.show_screen(SettingsScreen.new())
func _labels() -> void:
volume_label.text = "%d %%" % int(round(volume.value * 100))
music_label.text = "%d %%" % int(round(music.value * 100))

View File

@@ -2,6 +2,11 @@ class_name Style
## Farben, Schriften, Theme und Zeichenhilfen.
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
static func fs(size: int) -> int:
return int(round(size * font_scale))
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
const BG := Color("1a1c24")
@@ -38,7 +43,7 @@ static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0
static func make_theme() -> Theme:
var t := Theme.new()
t.default_font = FONT
t.default_font_size = 16
t.default_font_size = fs(16)
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2))
t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2))
@@ -47,7 +52,7 @@ static func make_theme() -> Theme:
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
t.set_color(name, "Button", TEXT)
t.set_color("font_disabled_color", "Button", MUTED)
t.set_font_size("font_size", "Button", 18)
t.set_font_size("font_size", "Button", fs(18))
t.set_color("font_color", "Label", TEXT)
t.set_stylebox("panel", "Panel", box(PANEL, 8))
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
@@ -84,7 +89,7 @@ static func label(parent: Node, text: String, size: int, color: Color, rect: Rec
l.text = text
l.position = rect.position
l.size = rect.size
l.add_theme_font_size_override("font_size", size)
l.add_theme_font_size_override("font_size", fs(size))
l.add_theme_color_override("font_color", color)
if bold:
l.add_theme_font_override("font", FONT_BOLD)
@@ -168,7 +173,8 @@ static func clear(node: Node) -> void:
# --- Zeichenhilfen für _draw() ---------------------------------------------------
static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
var size := fs(size_in)
var font: Font = FONT_BOLD if bold else FONT
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x

View File

@@ -110,12 +110,13 @@ func _initialize() -> void:
test_starter_gegen_passende_gegner()
test_kurve_statistik_vorschlag_verbesserung()
test_wasser_antworten()
test_ki_zeitbudget()
print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed])
quit(1 if failed > 0 else 0)
func test_kartenpool() -> void:
check(Cards.all().size() == 109, "109 Karten")
check(Cards.all().size() == 110, "110 Karten")
for key in Cards.COMBOS:
var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key)
check(found, "Kombo vorhanden: " + key)
@@ -1282,8 +1283,18 @@ func test_kurve_statistik_vorschlag_verbesserung() -> void:
var mono := starter
check(p.suggest_difficulty() == "", "kein Vorschlag am Anfang")
for i in 3:
p.record_battle(true, 1, mono)
check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei Siege: Schwer vorschlagen")
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 4)
check(p.suggest_difficulty() == "", "drei knappe Siege: noch kein Vorschlag")
p.records.streak = 0
p.records.margins = []
for i in 3:
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 20)
check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei deutliche Siege: Schwer vorschlagen")
p.records.streak = 0
p.records.margins = []
for i in 5:
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 2)
check(p.suggest_difficulty() == "schwer" and p.records.margins.size() == 5, "fünf Siege egal wie knapp")
p.settings.difficulty = "schwer"
p.take_notices()
for i in 3:
@@ -1319,3 +1330,27 @@ func test_wasser_antworten() -> void:
g.play_card(h, C("w_strudel"), t)
check(o.board.is_empty() and C("e_troll") in o.hand and h.hand.size() == hand, "Strudel: zurück auf die Hand, eine Karte gezogen")
check(C("w_ebbe").rarity == "gewöhnlich" and Profile.starter().collection.has("w_ebbe"), "Ebbe in der Startsammlung")
func test_ki_zeitbudget() -> void:
var r := RandomNumberGenerator.new()
r.seed = 12
var g := Game.new_battle(Profile.starter().deck_cards(), 5, r)
var h: Game.Player = g.players[0]
h.mulligan_done = true
h.energy = 10
h.max_energy = 10
for i in 8:
h.hand.append(Cards.by_id("f_funke"))
for p in g.players:
for i in 3:
put(p, "e_troll")
AI.time_budget_ms = 1
var t0 := Time.get_ticks_msec()
var act := AI.choose_action(g, h)
var ms := Time.get_ticks_msec() - t0
AI.time_budget_ms = 150
check(act.type in ["play", "attack", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms)
check(Cards.by_id("wl_gischt").keywords == ["Flink"] and Cards.by_id("wl_gischt").combo() == "Sturm", "Gischtläufer für Sturm")
var v := Profile.from_dict(JSON.parse_string(JSON.stringify(Profile.starter().to_dict())))
check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern")

View File

@@ -74,6 +74,14 @@ func key(code: int) -> void:
await frames(2)
func pad(button: int) -> void:
var ev := InputEventJoypadButton.new()
ev.button_index = button
ev.pressed = true
get_viewport().push_input(ev)
await frames(2)
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
func clear_mulligan(scr) -> void:
if scr.mulligan_box.visible:
@@ -155,9 +163,10 @@ func _ready() -> void:
await frames(3)
var fast := OS.get_environment("DINGER_FAST") != ""
var suite := [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_herausforderung]
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung]
if not fast:
suite += [test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]
suite.append(test_profil_zuruecksetzen)
for t in suite:
await ensure_menu()
await t.call()
@@ -290,7 +299,14 @@ func test_deckbau() -> void:
await click(find_button("Auto-Ausfüllen"))
check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar")
await shot("25_deck_slots")
await click(scr.deck_list.get_children()[0]) # ungespeicherte Änderung: eine Karte entfernt
check(scr.dirty(), "Deck geändert")
await click(find_button("Zurück"))
check(scr.confirm.visible and screen() == scr, "Warnung bei ungespeicherten Änderungen")
await click(find_button("Weiter bearbeiten"))
check(not scr.confirm.visible, "weiter bearbeiten")
await click(find_button("Zurück"))
await click(find_button("Verwerfen"))
check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen")
@@ -360,6 +376,9 @@ func test_gegnerauswahl() -> void:
check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet")
check("Deckstärke" in sel.rating_label.text, "Deckstärke angezeigt")
check(sel.preview_labels[3].text.count("(") == 3 and sel.previews[3].size() == 3, "Vorschau zeigt drei Gegner mit Stärke")
for i in 3:
await click(sel.b_reroll)
check(sel.rerolls_left == 0 and sel.b_reroll.disabled, "höchstens dreimal neu würfeln")
await click(find_button("Schwer"))
check(main.profile.settings.difficulty == "schwer" and Profile.load_or_new(SAVE).settings.difficulty == "schwer", "Schwer gewählt und gespeichert")
var previewed: Array = sel.previews[1].map(func(o): return o.title)
@@ -382,11 +401,13 @@ func test_gegnerauswahl() -> void:
await click(find_button("Einzelspieler"))
await click(find_button("Normal"))
main.profile.records.streak = 3
main.profile.records.margins = [20, 22, 18]
screen().refresh()
check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei Siegen")
check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei deutlichen Siegen")
await click(find_button("Übernehmen"))
check(main.profile.settings.difficulty == "schwer", "Vorschlag übernommen")
main.profile.records.streak = 0
main.profile.records.margins = []
await click(find_button("Normal"))
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
@@ -434,7 +455,15 @@ func test_einstellungen() -> void:
check(main.profile.settings.animations == false, "Animationen abschaltbar")
check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte")
scr.anim.button_pressed = true
scr.big_font.button_pressed = true
await frames(2)
check(main.profile.settings.big_font and Style.font_scale > 1.0 and screen() is SettingsScreen, "große Schrift aktiv")
screen().big_font.button_pressed = false
await frames(2)
check(is_equal_approx(Style.font_scale, 1.0), "normale Schrift")
var scr_b = screen()
await shot("13_einstellungen")
scr = scr_b
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
@@ -484,6 +513,45 @@ func test_werkstatt() -> void:
check(screen() is MenuScreen, "zurück im Menü")
func test_anleitung2() -> void:
check(main.profile.tutorial_done and not main.profile.tutorial2_done, "Teil 1 erledigt, Teil 2 offen")
check(find_button("Anleitung 2: Fallen") != null, "Menü bietet Teil 2 an")
await click(find_button("Anleitung 2: Fallen"))
var scr = screen()
check(scr is BattleScreen and scr.tutorial and scr.tutorial_part == 2, "Anleitung 2 gestartet")
scr.ai_delay = 0.0
var g: Game = scr.game
check(g.opponents[0].name == "Mara" and g.human.hand.any(func(c): return c.id == "w_falle") and g.human.hand.any(func(c): return c.id == "w_reiter"), "Mara, Frostfalle und Wellenreiter auf der Hand")
await frames(2)
check(scr.mulligan_box.visible and scr.tutorial_step == 0, "Schritt 1: Mulligan-Maske")
await click(scr.b_keep)
check(scr.tutorial_step == 1 and "Frostfalle" in scr.tutorial_text.text, "Schritt 2: Falle legen")
await shot("29_anleitung2")
# Zug 1 hat nur 1 Energie: Zug beenden, dann Falle legen
await key(KEY_SPACE)
await wait_for_human(scr)
check(scr.highlight_layer.get_child_count() >= 1, "Falle hervorgehoben")
var idx: int = g.human.hand.map(func(c): return c.id).find("w_falle")
check(idx >= 0 and idx < 9, "Falle auf der Hand")
await key(KEY_1 + idx)
check(g.human.traps.size() == 1 and scr.tutorial_step == 2, "Falle gelegt, Schritt 3")
await give_up()
check(main.profile.tutorial2_done and find_button("Anleitung") != null, "Teil 2 erledigt")
func test_profil_zuruecksetzen() -> void:
await click(find_button("Einstellungen"))
await click(find_button("Profil zurücksetzen"))
var scr = screen()
check(scr.confirm.visible, "Rückfrage")
await click(find_button("Abbrechen"))
check(not scr.confirm.visible, "abgebrochen")
await click(find_button("Profil zurücksetzen"))
await click(find_button("Ja, zurücksetzen"))
check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and not main.profile.tutorial_done, "Profil zurückgesetzt")
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
func test_herausforderung() -> void:
await click(find_button("Herausforderung"))
var scr = screen()
@@ -602,6 +670,20 @@ func test_anleitung() -> void:
await key(KEY_1 + idx)
check(g.human.board.size() == 1, "erneut gespielt")
await shot("22_anleitung")
# Gamepad: Zeiger bewegen, Karte spielen
await pad(JOY_BUTTON_DPAD_RIGHT)
check(scr.gp_zone == "hand" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweiter Handkarte")
var pad_idx := -1
for i in g.human.hand.size():
if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]:
pad_idx = i
break
if pad_idx >= 0:
while scr.gp_index != pad_idx:
await pad(JOY_BUTTON_DPAD_RIGHT)
var board_before: int = g.human.board.size()
await pad(JOY_BUTTON_A)
check(g.human.board.size() == board_before + 1, "Gamepad A spielt das Wesen")
scr.ai_delay = 0.75
await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
@@ -639,9 +721,16 @@ func test_netzwerk() -> void:
var pid := OS.create_process(OS.get_executable_path(), args)
check(pid > 0, "Bot-Prozess gestartet")
var wait := 0
while main.net.names.size() < 2 and wait < 900:
while main.net.names.size() < 2 and wait < 600:
await frames(1)
wait += 1
if main.net.names.size() < 2:
print("HINWEIS: Bot kam nicht, zweiter Versuch")
OS.kill(pid)
pid = OS.create_process(OS.get_executable_path(), args)
while main.net.names.size() < 2 and wait < 1500:
await frames(1)
wait += 1
check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path())
await shot("23_lobby")
await click(find_button("Start"))

View File

@@ -107,3 +107,10 @@ Tipp: %s,Tip: %s
Übernehmen,Apply
Neu würfeln,Reroll
Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s)
Anleitung 2: Fallen,Tutorial 2: Traps
Neu würfeln (%d),Reroll (%d)
Profil zurücksetzen,Reset profile
Große Schrift,Large text
Verwerfen,Discard
Weiter bearbeiten,Keep editing
Ungespeicherte Änderungen,Unsaved changes
1 keys en
107 Übernehmen Apply
108 Neu würfeln Reroll
109 Deck verbessern: %s ins aktive Deck (%s) Improve deck: add %s to the active deck (%s)
110 Anleitung 2: Fallen Tutorial 2: Traps
111 Neu würfeln (%d) Reroll (%d)
112 Profil zurücksetzen Reset profile
113 Große Schrift Large text
114 Verwerfen Discard
115 Weiter bearbeiten Keep editing
116 Ungespeicherte Änderungen Unsaved changes

Binary file not shown.