364 lines
9.8 KiB
GDScript
364 lines
9.8 KiB
GDScript
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
|