Files
elements-autochess/scripts/core/Combat.gd
2026-09-16 12:09:33 +02:00

544 lines
14 KiB
GDScript

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
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:
_record("crit" if amount > src.atk * 1.4 and not is_true else "dmg", 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
if record_hits:
_record("cast", u, 0.0)
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)
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)
for i in elems.size():
var scale := 1.0 if i == 0 else 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.
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)