class_name Game extends RefCounted ## Rundenlogik: 8 Spieler (1 Mensch, 7 Bots), geteilter Pool, Paarungen, Kämpfe. enum Phase { PLANNING, COMBAT, RESULT, GAMEOVER } const BOT_NAMES := [ "Ignis", "Nerea", "Zephyr", "Volt", "Terra", "Frost", "Umbra", ] var players: Array = [] var pool: Pool var rand := RandomNumberGenerator.new() var round_no: int = 0 var phase: int = Phase.PLANNING var human: PlayerState var bots := {} ## pid -> BotAI var pairings: Array = [] ## [{a, b, ghost}] var player_combat: Combat = null var pending: Array = [] ## Ergebnisse der Bot-Kämpfe var last_round_log: Array = [] var plan_left: float = Data.PLAN_SECONDS var timer_enabled: bool = true var human_recap: Dictionary = {} ## Rückblick auf den letzten eigenen Kampf ## Zählt, wie oft der Mensch gewonnen bzw. verloren hat (für Sound und Anzeige). var human_last_result: int = 0 ## 1 Sieg, -1 Niederlage, 0 unentschieden/keine var difficulty: int = Data.Difficulty.NORMAL func start(seed_value: int = 0, p_difficulty: int = Data.Difficulty.NORMAL) -> void: difficulty = p_difficulty rand.seed = seed_value if seed_value != 0 else randi() pool = Pool.new() players.clear() human = PlayerState.new(0, "Du", false) players.append(human) for i in BOT_NAMES.size(): var b := PlayerState.new(i + 1, BOT_NAMES[i], true) players.append(b) var ai := BotAI.new() ai.setup(rand, difficulty) bots[b.pid] = ai round_no = 1 for p in players: p.roll_shop(pool, rand) pairings = _make_pairings() _run_bot_turns() plan_left = Data.PLAN_SECONDS phase = Phase.PLANNING func alive_players() -> Array: var out := [] for p in players: if p.alive: out.append(p) return out # --- Planungsphase --------------------------------------------------------- func _run_bot_turns() -> void: for p in players: if p.is_bot and p.alive: var opp := opponent_of(p) bots[p.pid].take_turn(p, pool, rand, opp) bots[p.pid].arrange(p, opp) ## Planungsuhr. Läuft sie ab, startet der Kampf von selbst. ## Gibt true zurück, wenn dadurch die Phase gewechselt hat. func tick_planning(dt: float) -> bool: if phase != Phase.PLANNING or not timer_enabled: return false plan_left -= dt if plan_left <= 0.0: plan_left = 0.0 start_combat() return true return false # --- Kampfphase ------------------------------------------------------------ func start_combat() -> void: if phase != Phase.PLANNING: return # Abbilder erst jetzt kopieren, damit sie den aktuellen Stand zeigen. for pr in pairings: if pr["ghost"] and pr["ghost_src"] != null: pr["b"] = _make_ghost(pr["ghost_src"]) pending.clear() player_combat = null for pr in pairings: if pr["a"] == human or pr["b"] == human: player_combat = Combat.new(pr["a"], pr["b"], rand.randi()) player_combat.record_hits = true pending.append({"pair": pr, "combat": player_combat}) else: var c := Combat.new(pr["a"], pr["b"], rand.randi()) c.run_to_end() pending.append({"pair": pr, "combat": c}) phase = Phase.COMBAT if player_combat == null: resolve_round() func update_combat(dt: float) -> void: if phase != Phase.COMBAT or player_combat == null: return player_combat.step(dt) if player_combat.finished: resolve_round() func skip_combat() -> void: if phase == Phase.COMBAT and player_combat != null: player_combat.run_to_end() resolve_round() func is_pve_round() -> bool: return round_no <= Data.PVE_ROUNDS ## Wildnis-Gegner: klein, ohne Leben, jeder Spieler bekommt seinen eigenen. func _make_creeps() -> PlayerState: var c := PlayerState.new(-2, "Wildnis", true) c.level = 9 c.board[Vector2i(3, 0)] = Avatar.new(Data.Role.TANK, [Data.Element.ERDE] as Array[int], 1) if round_no >= 2: c.board[Vector2i(2, 1)] = Avatar.new(Data.Role.DPS, [Data.Element.WIND] as Array[int], 1) return c func _make_pairings() -> Array: var alive := alive_players() var out := [] if is_pve_round(): for p in alive: out.append({"a": p, "b": _make_creeps(), "ghost": true, "ghost_src": null}) return out alive.shuffle() var i := 0 while i + 1 < alive.size(): out.append({"a": alive[i], "b": alive[i + 1], "ghost": false}) i += 2 if i < alive.size(): # Ungerade Anzahl: der Rest kämpft gegen ein Abbild eines Gegners. var lone: PlayerState = alive[i] var others := alive.filter(func(p): return p != lone) if others.is_empty(): return out var src: PlayerState = others[rand.randi_range(0, others.size() - 1)] out.append({"a": lone, "b": _make_ghost(src), "ghost": true, "ghost_src": src}) return out func _make_ghost(src: PlayerState) -> PlayerState: var g := PlayerState.new(-1, src.pname + " (Abbild)", true) for hex in src.board: g.board[hex] = src.board[hex] return g # --- Auswertung ------------------------------------------------------------ func resolve_round() -> void: last_round_log.clear() for item in pending: var pr: Dictionary = item["pair"] var c: Combat = item["combat"] if not c.finished: c.run_to_end() var a: PlayerState = pr["a"] var b: PlayerState = pr["b"] var base := Data.round_base_damage(round_no) var dmg := base + c.survivor_stars if a == human or b == human: human_last_result = 0 if c.winner < 0 else (1 if pr["a" if c.winner == 0 else "b"] == human else -1) human_recap = c.recap(0 if a == human else 1) human_recap["opponent"] = (b if a == human else a).pname human_recap["result"] = human_last_result human_recap["damage"] = dmg if c.winner >= 0 else base if c.winner == 0: _apply_result(a, b, dmg, pr["ghost"]) elif c.winner == 1: _apply_result(b, a, dmg, pr["ghost"]) else: a.register_result(false) a.take_damage(base) if not pr["ghost"]: b.register_result(false) b.take_damage(base) last_round_log.append("%s vs %s: unentschieden (-%d)" % [a.pname, b.pname, base]) pending.clear() player_combat = null _check_eliminations() if alive_players().size() <= 1: phase = Phase.GAMEOVER for p in alive_players(): p.placement = 1 return _next_round() func _apply_result(winner: PlayerState, loser: PlayerState, dmg: int, ghost: bool) -> void: winner.register_result(true) if loser.pid >= 0: loser.register_result(false) loser.take_damage(dmg) last_round_log.append("%s schlägt %s (-%d Leben)" % [winner.pname, loser.pname, dmg]) func _check_eliminations() -> void: var still := alive_players().size() for p in players: if p.hp <= 0 and p.alive: p.alive = false p.placement = still still -= 1 last_round_log.append(p.pname + " ist ausgeschieden") func _next_round() -> void: round_no += 1 for p in players: if not p.alive: continue var income: int = p.round_income() p.gold += income p.stats["gold_earned"] += income p.gain_xp(Data.XP_PER_ROUND) p.new_round_shop(pool, rand) pairings = _make_pairings() _run_bot_turns() plan_left = Data.PLAN_SECONDS phase = Phase.PLANNING # --- Speichern ------------------------------------------------------------- func to_dict() -> Dictionary: var ps := [] for p in players: ps.append(p.to_dict()) var ai := {} for pid in bots: ai[str(pid)] = {"pref": Array(bots[pid].pref), "greed": bots[pid].greed} return { "version": 1, "round": round_no, "difficulty": difficulty, "rng_state": str(rand.state), "pool": Array(pool.element_left), "players": ps, "bots": ai, "human_recap": human_recap, "log": last_round_log, } func from_dict(d: Dictionary) -> void: difficulty = int(d["difficulty"]) rand.state = int(str(d["rng_state"])) pool = Pool.new() for i in mini(pool.element_left.size(), d["pool"].size()): pool.element_left[i] = int(d["pool"][i]) players.clear() bots.clear() for pd in d["players"]: var p := PlayerState.from_dict(pd) players.append(p) if not p.is_bot: human = p else: var ai := BotAI.new() ai.setup(rand, difficulty) var bd: Dictionary = d["bots"].get(str(p.pid), {}) if bd.has("pref"): ai.pref.clear() for e in bd["pref"]: ai.pref.append(int(e)) ai.greed = float(bd["greed"]) bots[p.pid] = ai round_no = int(d["round"]) human_recap = d.get("human_recap", {}) last_round_log = d.get("log", []) pairings = _make_pairings() for p in players: if p.is_bot and p.alive: bots[p.pid].arrange(p, opponent_of(p)) plan_left = Data.PLAN_SECONDS phase = Phase.PLANNING func save(path: String = Data.SAVE_PATH) -> bool: if phase != Phase.PLANNING: return false var f := FileAccess.open(path, FileAccess.WRITE) if f == null: return false f.store_string(JSON.stringify(to_dict())) return true static func has_save(path: String = Data.SAVE_PATH) -> bool: return FileAccess.file_exists(path) static func delete_save(path: String = Data.SAVE_PATH) -> void: if FileAccess.file_exists(path): DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) static func load_from(path: String = Data.SAVE_PATH) -> Game: if not FileAccess.file_exists(path): return null var f := FileAccess.open(path, FileAccess.READ) if f == null: return null var parsed = JSON.parse_string(f.get_as_text()) if not (parsed is Dictionary): return null var g := Game.new() g.from_dict(parsed) return g # --- Hilfen für die UI ---------------------------------------------------- ## Gegner eines Spielers in der aktuellen Paarung (Abbilder liefern ihre Quelle). func opponent_of(p: PlayerState) -> PlayerState: for pr in pairings: if pr["a"] == p: return pr["ghost_src"] if (pr["ghost"] and pr["ghost_src"] != null) else pr["b"] if pr["b"] == p: return pr["a"] return null func human_opponent() -> PlayerState: for pr in pairings: if pr["a"] == human: return pr["b"] if pr["b"] == human: return pr["a"] return null func human_team_in_combat() -> int: for pr in pairings: if pr["a"] == human: return 0 if pr["b"] == human: return 1 return 0 func ranking() -> Array: var sorted := players.duplicate() sorted.sort_custom(func(a, b): if a.alive != b.alive: return a.alive return a.hp > b.hp) return sorted