class_name Combat extends RefCounted ## Auto-Kampf zweier Aufstellungen auf einem 7x8-Hexfeld. ## Rein logisch und schrittweise: Bot-Kämpfe laufen sofort durch, ## der Kampf des Spielers wird pro Frame gesteppt. const ARENA_COLS := 7 const ARENA_ROWS := 8 const MOVE_TIME := 0.32 const TIME_LIMIT := 35.0 const CRIT_MULT := 1.5 const MANA_PER_ATTACK := 10.0 const MANA_PER_HIT := 5.0 class CUnit extends RefCounted: var avatar: Avatar var team: int var pos: Vector2i var hp: float var max_hp: float var atk: float var armor: float var aspd: float var atk_range: int var mana: float var max_mana: float var mana_mult: float = 1.0 var crit: float = 0.0 var attack_timer: float = 0.0 var move_timer: float = 0.0 var alive: bool = true var shield: float = 0.0 var shield_timer: float = 0.0 var burn_dps: float = 0.0 var burn_timer: float = 0.0 var slow: float = 0.0 var slow_timer: float = 0.0 var haste: float = 0.0 var haste_timer: float = 0.0 var stun_timer: float = 0.0 var reflect: float = 0.0 var reflect_timer: float = 0.0 var taunt_by = null var taunt_timer: float = 0.0 var target = null var cast_flash: float = 0.0 var hit_flash: float = 0.0 var dealt: float = 0.0 ## ausgeteilter Schaden, für den Rückblick var healed: float = 0.0 func effective_aspd() -> float: return maxf(0.1, aspd * (1.0 + haste) * (1.0 - slow)) var units: Array = [] var time: float = 0.0 var finished: bool = false var winner: int = -1 ## 0 = Team A, 1 = Team B, -1 = unentschieden var survivors: int = 0 var survivor_stars: int = 0 ## Sternsumme der Überlebenden, bestimmt den Schaden var events: Array = [] ## kurze Textmeldungen für das Log ## Treffer, Heilungen und Zauber für Anzeige und Sound. Nur im Spielerkampf ## aufgezeichnet, Bot-Kämpfe laufen still durch. var record_hits: bool = false var hits: Array = [] var _mods := [{}, {}] var _occ := {} var _rand := RandomNumberGenerator.new() func _init(a: PlayerState, b: PlayerState, seed_value: int = 0) -> void: _rand.seed = seed_value if seed_value != 0 else randi() _mods[0] = _build_mods(a) _mods[1] = _build_mods(b) _spawn(a, 0) _spawn(b, 1) _apply_start_effects() # --- Aufbau ---------------------------------------------------------------- func _build_mods(p: PlayerState) -> Dictionary: var counts := p.trait_counts() var m := { "atk": 1.0, "hp": 1.0, "aspd": 1.0, "mana": 1.0, "crit": 0.0, "ice": 0.0, "combos": {}, } for e in counts: var tier := Data.trait_tier(e, counts[e]) if tier <= 0: continue var v: float = Data.ELEMENT_TRAITS[e]["values"][tier - 1] match e: Data.Element.FEUER: m["atk"] += v Data.Element.WASSER: m["mana"] += v Data.Element.WIND: m["aspd"] += v Data.Element.BLITZ: m["crit"] += v Data.Element.ERDE: m["hp"] += v Data.Element.EIS: m["ice"] = maxf(m["ice"], v) for c in p.active_combos(): m["combos"][c["key"]] = true return m func _spawn(p: PlayerState, team: int) -> void: var m: Dictionary = _mods[team] for hex in p.board: var a: Avatar = p.board[hex] var s := a.base_stats() var u := CUnit.new() u.avatar = a u.team = team u.pos = _arena_pos(hex, team) u.max_hp = s["hp"] * m["hp"] u.hp = u.max_hp u.atk = s["atk"] * m["atk"] u.armor = s["armor"] u.aspd = s["aspd"] * m["aspd"] u.atk_range = s["range"] u.max_mana = s["mana"] u.mana = 0.0 u.mana_mult = m["mana"] u.crit = m["crit"] + s.get("crit", 0.0) if _occ.has(u.pos): u.pos = _free_near(u.pos) _occ[u.pos] = u units.append(u) static func _arena_pos(hex: Vector2i, team: int) -> Vector2i: if team == 0: return Vector2i(hex.x, 4 + hex.y) return Vector2i(ARENA_COLS - 1 - hex.x, 3 - hex.y) func _free_near(p: Vector2i) -> Vector2i: for n in Hex.neighbors(p): if _in_arena(n) and not _occ.has(n): return n return p static func _in_arena(p: Vector2i) -> bool: return p.x >= 0 and p.x < ARENA_COLS and p.y >= 0 and p.y < ARENA_ROWS func _apply_start_effects() -> void: for team in 2: if _mods[team]["combos"].has("frost"): for u in units: if u.team != team: u.slow = maxf(u.slow, 0.15) u.slow_timer = 999.0 # --- Simulation ------------------------------------------------------------ func step(dt: float) -> void: if finished: return time += dt for u in units: if u.alive: _tick_status(u, dt) for u in units: if u.alive and u.stun_timer <= 0.0: _act(u, dt) _check_end() func run_to_end(dt: float = 0.05) -> void: var guard := 0 while not finished and guard < 5000: step(dt) guard += 1 if not finished: _resolve_by_hp() func _tick_status(u: CUnit, dt: float) -> void: if u.shield_timer > 0.0: u.shield_timer -= dt if u.shield_timer <= 0.0: u.shield = 0.0 if u.burn_timer > 0.0: u.burn_timer -= dt _apply_damage(null, u, u.burn_dps * dt, true) if u.burn_timer <= 0.0: u.burn_dps = 0.0 if u.slow_timer > 0.0: u.slow_timer -= dt if u.slow_timer <= 0.0: u.slow = 0.0 if u.haste_timer > 0.0: u.haste_timer -= dt if u.haste_timer <= 0.0: u.haste = 0.0 if u.reflect_timer > 0.0: u.reflect_timer -= dt if u.reflect_timer <= 0.0: u.reflect = 0.0 if u.stun_timer > 0.0: u.stun_timer -= dt if u.taunt_timer > 0.0: u.taunt_timer -= dt if u.taunt_timer <= 0.0: u.taunt_by = null u.cast_flash = maxf(0.0, u.cast_flash - dt) u.hit_flash = maxf(0.0, u.hit_flash - dt) func _act(u: CUnit, dt: float) -> void: var t = _pick_target(u) if t == null: return u.target = t var d := Hex.distance(u.pos, t.pos) if d <= u.atk_range: u.attack_timer -= dt * u.effective_aspd() if u.attack_timer <= 0.0: u.attack_timer += 1.0 _basic_attack(u, t) else: u.move_timer -= dt if u.move_timer <= 0.0: u.move_timer = MOVE_TIME _step_towards(u, t) func _pick_target(u: CUnit): if u.taunt_by != null and u.taunt_by.alive and u.taunt_timer > 0.0: return u.taunt_by if u.target != null and u.target.alive: return u.target return _nearest_enemy(u) func _nearest_enemy(u: CUnit, exclude = null): var best = null var best_d := 999 for o in units: if not o.alive or o.team == u.team or o == exclude: continue var d := Hex.distance(u.pos, o.pos) if d < best_d: best_d = d best = o return best func _step_towards(u: CUnit, t: CUnit) -> void: var best := u.pos var best_d := Hex.distance(u.pos, t.pos) for n in Hex.neighbors(u.pos): if not _in_arena(n) or _occ.has(n): continue var d := Hex.distance(n, t.pos) if d < best_d: best_d = d best = n if best != u.pos: _occ.erase(u.pos) u.pos = best _occ[u.pos] = u # --- Angriff und Schaden --------------------------------------------------- func _basic_attack(u: CUnit, t: CUnit) -> void: var dmg := u.atk if u.crit > 0.0 and _rand.randf() < u.crit: dmg *= CRIT_MULT if record_hits: hits.append({"kind": "attack", "pos": t.pos, "from": u.pos, "team": u.team, "ranged": u.atk_range > 1, "element": u.avatar.primary_element(), "amount": 0, "t": time}) _apply_damage(u, t, dmg, false) var ice: float = _mods[u.team]["ice"] if ice > 0.0: t.slow = maxf(t.slow, ice) t.slow_timer = maxf(t.slow_timer, 2.0) if _mods[u.team]["combos"].has("sturmfront"): var second = _nearest_enemy(u, t) if second != null: _apply_damage(u, second, dmg * 0.5, false) u.mana += MANA_PER_ATTACK * u.mana_mult if u.mana >= u.max_mana: _cast(u) func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void: if not tgt.alive or amount <= 0.0: return # Schere-Stein-Papier: Hauptelement und Rolle des Angreifers gegen das Ziel. var counter := 1.0 if src != null and not is_true: counter = Data.counter_multiplier(src.avatar.primary_element(), src.avatar.role, tgt.avatar.primary_element(), tgt.avatar.role) amount *= counter var dmg := amount if is_true else amount * (1.0 - tgt.armor) if tgt.shield > 0.0: var absorbed: float = minf(tgt.shield, dmg) tgt.shield -= absorbed dmg -= absorbed if dmg > 0.0: tgt.hp -= dmg tgt.hit_flash = 0.12 if src != null: src.dealt += dmg if record_hits and src != null: var kind := "dmg" if counter > 1.0: kind = "counter" elif amount > src.atk * 1.4 and not is_true: kind = "crit" _record(kind, tgt, dmg) tgt.mana += MANA_PER_HIT * tgt.mana_mult if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0: _cast(tgt) if src != null and dmg > 0.0: if tgt.reflect > 0.0: src.hp -= dmg * tgt.reflect if src.hp <= 0.0: _kill(src) if _mods[tgt.team]["combos"].has("magma"): src.hp -= dmg * 0.20 if src.hp <= 0.0: _kill(src) if _mods[src.team]["combos"].has("dampf"): src.hp = minf(src.max_hp, src.hp + dmg * 0.18) if tgt.hp <= 0.0: _kill(tgt) func _kill(u: CUnit) -> void: if not u.alive: return u.alive = false u.hp = 0.0 _occ.erase(u.pos) events.append(u.avatar.display_name() + " fällt") func _heal(u: CUnit, amount: float) -> void: if not u.alive: return var real := minf(u.max_hp, u.hp + amount) - u.hp u.hp += real u.healed += real if record_hits and real >= 1.0: _record("heal", u, real) func _record(kind: String, u: CUnit, amount: float) -> void: hits.append({"kind": kind, "pos": u.pos, "team": u.team, "amount": int(round(amount)), "t": time}) # --- Fähigkeiten ---------------------------------------------------------- func _cast(u: CUnit) -> void: u.mana = 0.0 u.cast_flash = 0.35 var elems: Array = u.avatar.elements var heal_bonus := 0.0 for i in elems.size(): if elems[i] == Data.Element.WASSER: heal_bonus += 0.20 * (1.0 if i == 0 else 0.5) var hit_enemies: Array = [] var main_target = u.target if (u.target != null and u.target.alive) else _nearest_enemy(u) if record_hits: hits.append({"kind": "cast", "pos": u.pos, "team": u.team, "amount": 0, "t": time, "role": u.avatar.role, "element": u.avatar.primary_element(), "target": main_target.pos if main_target != null else u.pos}) match u.avatar.role: Data.Role.TANK: var sh := u.atk * (3.0 + 0.5 * float(u.avatar.star - 1)) * (1.0 + heal_bonus) u.shield = maxf(u.shield, sh) u.shield_timer = 4.0 if u.avatar.star >= 2: var n := 0 for o in units: if o.alive and o.team != u.team and Hex.distance(o.pos, u.pos) <= 2 and n < 3: o.taunt_by = u o.taunt_timer = 2.0 o.target = u n += 1 if u.avatar.star >= 3: u.reflect = 0.25 u.reflect_timer = 4.0 Data.Role.DPS: if main_target == null: return var base := u.atk * (3.0 + 0.3 * float(u.avatar.star - 1)) _apply_damage(u, main_target, base, false) hit_enemies.append(main_target) if u.avatar.star == 2: for n in Hex.neighbors(main_target.pos): var o = _occ.get(n) if o != null and o.alive and o.team != u.team: _apply_damage(u, o, u.atk * 1.5, false) hit_enemies.append(o) elif u.avatar.star >= 3: for o in units: if o.alive and o.team != u.team and o != main_target \ and Hex.distance(o.pos, main_target.pos) <= 2: _apply_damage(u, o, u.atk * 2.0, false) hit_enemies.append(o) Data.Role.HEILER: var amount := u.atk * (3.0 - 0.25 * float(u.avatar.star - 1)) * (1.0 + heal_bonus) var allies := _allies_by_missing_hp(u.team, u) var count := 1 if u.avatar.star == 2: count = 3 elif u.avatar.star >= 3: count = allies.size() for i in mini(count, allies.size()): _heal(allies[i], amount) if u.avatar.star >= 3: allies[i].shield = maxf(allies[i].shield, u.atk * 1.5 * (1.0 + heal_bonus)) allies[i].shield_timer = 3.0 _apply_element_effects(u, main_target, hit_enemies) func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void: var elems: Array = u.avatar.elements var combos: Dictionary = _mods[u.team]["combos"] if combos.has("feuersturm"): for o in hit_enemies: o.burn_dps = maxf(o.burn_dps, u.atk * 0.4) o.burn_timer = maxf(o.burn_timer, 3.0) if combos.has("magnetsturm") and main_target != null and main_target.alive: main_target.stun_timer = maxf(main_target.stun_timer, 0.6) if not elems.is_empty(): _apply_form(u, elems[0], main_target, hit_enemies) for i in range(1, elems.size()): var scale := 0.5 match elems[i]: Data.Element.FEUER: for o in hit_enemies: if o.alive: o.burn_dps = maxf(o.burn_dps, u.atk * 0.6 * scale) o.burn_timer = maxf(o.burn_timer, 3.0) Data.Element.WASSER: u.mana += 15.0 * scale Data.Element.WIND: u.haste = maxf(u.haste, 0.30 * scale) u.haste_timer = 3.0 Data.Element.BLITZ: var chained := 0 for o in units: if chained >= 2: break if o.alive and o.team != u.team and o != main_target: _apply_damage(u, o, u.atk * 0.8 * scale, false) chained += 1 Data.Element.ERDE: if main_target != null and main_target.alive: main_target.stun_timer = maxf(main_target.stun_timer, 0.75 * scale) Data.Element.EIS: if main_target != null and main_target.alive: main_target.slow = maxf(main_target.slow, 0.30 * scale) main_target.slow_timer = maxf(main_target.slow_timer, 3.0) ## Verbündete in Heilreichweite, schwächste zuerst. Mindestens der Heiler selbst. ## Die Form des Hauptelements wirkt für jede Rolle gleich: um das Ziel (Gegner) ## oder um den Zaubernden (Verbündete). func _apply_form(u: CUnit, element: int, main_target, hit_enemies: Array) -> void: match element: Data.Element.FEUER: if main_target == null: return for n in Hex.neighbors(main_target.pos): var o = _occ.get(n) if o != null and o.alive and o.team != u.team and not hit_enemies.has(o): _apply_damage(u, o, u.atk * 0.6, false) hit_enemies.append(o) for o in hit_enemies: if o.alive: o.burn_dps = maxf(o.burn_dps, u.atk * 0.6) o.burn_timer = maxf(o.burn_timer, 3.0) Data.Element.WASSER: for o in units: if o.alive and o.team == u.team and o != u and Hex.distance(o.pos, u.pos) <= 2: o.mana = minf(o.max_mana - 1.0, o.mana + 20.0) u.mana += 10.0 Data.Element.WIND: for o in units: if o.alive and o.team == u.team and Hex.distance(o.pos, u.pos) <= 2: o.haste = maxf(o.haste, 0.25) o.haste_timer = maxf(o.haste_timer, 3.0) Data.Element.BLITZ: var chained := 0 for o in units: if chained >= 2: break if o.alive and o.team != u.team and o != main_target: _apply_damage(u, o, u.atk * 0.6, false) hit_enemies.append(o) chained += 1 Data.Element.ERDE: if main_target == null: return main_target.stun_timer = maxf(main_target.stun_timer, 0.6) for n in Hex.neighbors(main_target.pos): var o = _occ.get(n) if o != null and o.alive and o.team != u.team: o.stun_timer = maxf(o.stun_timer, 0.6) Data.Element.EIS: if main_target == null: return for o in units: if o.alive and o.team != u.team and Hex.distance(o.pos, main_target.pos) <= 1: o.slow = maxf(o.slow, 0.30) o.slow_timer = maxf(o.slow_timer, 3.0) func _allies_by_missing_hp(team: int, healer: CUnit) -> Array: var list := [] for o in units: if o.alive and o.team == team and Hex.distance(o.pos, healer.pos) <= Data.HEAL_RANGE: list.append(o) if list.is_empty(): list.append(healer) list.sort_custom(func(a, b): return (a.hp / a.max_hp) < (b.hp / b.max_hp)) return list # --- Ende ------------------------------------------------------------------ func alive_count(team: int) -> int: var n := 0 for u in units: if u.alive and u.team == team: n += 1 return n ## Kurzer Rückblick für ein Team: Überlebende, stärkste Einheit, Gegnerspitze. func recap(team: int) -> Dictionary: var total := 0 var alive := 0 var best = null var best_enemy = null for u in units: if u.team == team: total += 1 if u.alive: alive += 1 if best == null or u.dealt + u.healed > best.dealt + best.healed: best = u elif best_enemy == null or u.dealt > best_enemy.dealt: best_enemy = u return { "total": total, "alive": alive, "best_name": best.avatar.display_name() if best else "-", "best_dealt": int(best.dealt) if best else 0, "best_healed": int(best.healed) if best else 0, "enemy_name": best_enemy.avatar.display_name() if best_enemy else "-", "enemy_dealt": int(best_enemy.dealt) if best_enemy else 0, "duration": time, } func _star_sum(team: int) -> int: var n := 0 for u in units: if u.alive and u.team == team: n += u.avatar.star return n func _check_end() -> void: var a := alive_count(0) var b := alive_count(1) if a == 0 or b == 0: finished = true if a == b: winner = -1 survivors = 0 survivor_stars = 0 else: winner = 0 if a > b else 1 survivors = maxi(a, b) survivor_stars = _star_sum(winner) return if time >= TIME_LIMIT: _resolve_by_hp() func _resolve_by_hp() -> void: finished = true var frac := [0.0, 0.0] for u in units: if u.alive: frac[u.team] += u.hp / u.max_hp if absf(frac[0] - frac[1]) < 0.25: winner = -1 survivors = 0 survivor_stars = 0 else: winner = 0 if frac[0] > frac[1] else 1 survivors = alive_count(winner) survivor_stars = _star_sum(winner)