saumwacht-godot/scripts/luneraria/core/lune_combat.gd
Martin Stickelbroeck b565546c37 M1: autoritativer Drei-Lane-Kern (Karte, Kampf, Partie, Wellen, Bots)
LuneMap spiegelsymmetrisch mit 16 Türmen und 2 Ancients, LuneCombat mit
reihenfolgeunabhängigem Zweiphasenschritt und Gebäudesperren für jede
Schadensquelle, LuneMatch mit Planungsentwürfen, atomarer Übernahme,
Einmal-Belohnung und Kernsieg/Unentschieden, endlose LuneWaves, Bots über
dieselbe Befehlsschnittstelle. 87 Kernprüfungen bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:57:37 +02:00

436 lines
18 KiB
GDScript

class_name LuneCombat
extends RefCounted
## One automatic combat phase on all three lanes. Every step first gathers targets,
## movement, hits, heals and effects from the state at the start of the step, then applies
## them together, so processing order never decides who strikes first. Deaths of a step
## resolve together; two Ancients falling in the same step is a draw.
var map: LuneMap
var balance: LuneBalance
var round_number: int = 1
var units: Array[LuneUnit] = []
var buildings: Dictionary = {}
var elapsed: float = 0.0
var steps: int = 0
var finished: bool = false
var end_reason: String = ""
var destroyed_cores: Array[int] = []
var events: Array[Dictionary] = []
var _next_id: int = 1
var _event_serial: int = 0
var _pending_damage: Dictionary = {}
var _pending_heal: Dictionary = {}
var _pending_effects: Array = []
var _pending_dots: Array = []
var _pending_shields: Array = []
func _init(match_map: LuneMap, config: LuneBalance, number: int) -> void:
map = match_map
balance = config
round_number = number
# ---------------------------------------------------------------- setup
func add_building(definition: Dictionary, hp: float) -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = "core" if definition.tier == "core" else "tower"
unit.team = int(definition.team)
unit.lane = str(definition.lane)
unit.building_id = str(definition.id)
unit.def = {"id": definition.id, "display_name": definition.id, "max_hp": definition.max_hp, "damage": definition.damage,
"attack_range": definition.range, "attack_type": 1, "armor_type": 1, "speed": 0.0, "ability": "", "bounty": definition.bounty,
"tier": definition.tier}
unit.position = definition.position
unit.previous_position = unit.position
unit.max_hp = float(definition.max_hp)
unit.hp = clampf(hp, 0.0, unit.max_hp)
unit.alive = unit.hp > 0.0
units.append(unit)
buildings[unit.building_id] = unit
return unit
func spawn(kind: String, team: int, lane: String, definition: Dictionary, progress: float, lateral: float, slot: int = -1, uid: String = "") -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = kind
unit.team = team
unit.lane = lane
unit.def = definition
unit.owner_slot = slot
unit.persistent_uid = uid
unit.progress = progress
unit.lateral = lateral
unit.position = map.lane_point(team, lane, progress, lateral)
unit.previous_position = unit.position
var forward: Vector2 = map.lane_forward(team, lane, progress)
unit.facing = atan2(forward.x, forward.y)
unit.max_hp = float(definition.get("max_hp", 1.0))
unit.hp = unit.max_hp
_start_ability(unit)
units.append(unit)
return unit
func alive_map() -> Dictionary:
var result: Dictionary = {}
for id: String in buildings:
result[id] = (buildings[id] as LuneUnit).alive
return result
func building_attackable(unit: LuneUnit, gates: Dictionary) -> bool:
return unit.alive and LuneMap.is_attackable(map.building_def(unit.building_id), gates)
# ---------------------------------------------------------------- step
func step() -> void:
if finished:
return
var dt: float = balance.sim_step
elapsed += dt
steps += 1
_pending_damage.clear()
_pending_heal.clear()
_pending_effects.clear()
_pending_dots.clear()
_pending_shields.clear()
var gates: Dictionary = alive_map()
var living: Array[LuneUnit] = []
for unit: LuneUnit in units:
if unit.alive:
living.append(unit)
unit.previous_position = unit.position
for unit: LuneUnit in living:
var dot: float = unit.advance_status(dt)
if dot > 0.0:
_queue_damage(unit, dot, gates)
for unit: LuneUnit in living:
if unit.is_mobile():
_ability_step(unit, living)
for unit: LuneUnit in living:
if unit.is_mobile() or (unit.kind == "tower"):
unit.target = _choose_target(unit, living, gates)
var moves: Dictionary = {}
for unit: LuneUnit in living:
if unit.is_mobile():
moves[unit.id] = _plan_move(unit, living, dt)
for unit: LuneUnit in living:
if moves.has(unit.id):
var plan: Array = moves[unit.id]
var next: Vector2 = plan[0]
var heading: Vector2 = next - unit.position
unit.position = next
if heading.length_squared() > 0.000001:
unit.facing = atan2(heading.x, heading.y)
if unit.zone == "lane":
_update_progress(unit, float(plan[1]))
for unit: LuneUnit in living:
_attack(unit, living, gates, dt)
for unit: LuneUnit in living:
if _pending_heal.has(unit.id):
unit.heal(_pending_heal[unit.id])
for entry: Array in _pending_shields:
(entry[0] as LuneUnit).grant_shield(entry[1], entry[2])
for entry: Array in _pending_effects:
(entry[0] as LuneUnit).apply_effect(entry[1], entry[2], entry[3], entry[4])
for entry: Array in _pending_dots:
(entry[0] as LuneUnit).apply_dot(entry[1], entry[2], entry[3], entry[4])
for unit: LuneUnit in living:
if _pending_damage.has(unit.id):
unit.take(_pending_damage[unit.id])
for unit: LuneUnit in living:
if unit.hp <= 0.0 and unit.alive:
unit.alive = false
unit.death_time = elapsed
unit.target = null
_record_death(unit)
_check_end()
func _check_end() -> void:
if not destroyed_cores.is_empty():
finished = true
end_reason = "core"
return
var mobile_alive: bool = false
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile():
mobile_alive = true
break
if not mobile_alive:
finished = true
end_reason = "cleared"
elif elapsed >= balance.combat_time_limit - 0.000001:
finished = true
end_reason = "timeout"
func _record_death(unit: LuneUnit) -> void:
_event_serial += 1
var event: Dictionary = {"serial": _event_serial, "step": steps, "time": elapsed, "team": unit.team, "lane": unit.lane,
"zone": unit.zone, "kind": unit.kind, "unit_id": unit.id, "def_id": str(unit.def.get("id", "")), "bounty": 0,
"owner_slot": unit.owner_slot, "persistent_uid": unit.persistent_uid}
if unit.kind in ["monster", "champion", "boss"]:
event.bounty = int(unit.def.get("bounty", 0))
elif unit.is_building():
event.bounty = int(unit.def.get("bounty", 0))
event.building_id = unit.building_id
if unit.kind == "core" and not destroyed_cores.has(unit.team):
destroyed_cores.append(unit.team)
events.append(event)
# ---------------------------------------------------------------- targeting and movement
func _hostile(unit: LuneUnit, other: LuneUnit) -> bool:
return other.alive and other.team != unit.team and other.zone == unit.zone
func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> LuneUnit:
var current: LuneUnit = unit.target
if current != null and _hostile(unit, current) and unit.position.distance_to(current.position) <= balance.leash_radius:
if not current.is_building() or building_attackable(current, gates):
if unit.kind != "tower" or unit.position.distance_to(current.position) <= unit.attack_range():
return current
var radius: float = unit.attack_range() if unit.kind == "tower" else balance.aggro_radius
var best: LuneUnit = null
var best_distance: float = INF
for other: LuneUnit in living:
if not _hostile(unit, other) or not other.is_mobile():
continue
var d: float = unit.position.distance_to(other.position)
if d <= radius and (d < best_distance - 0.0001 or (absf(d - best_distance) <= 0.0001 and other.id < best.id)):
best = other
best_distance = d
if best != null or unit.kind == "tower":
return best
# Buildings: nearby attackable ones; at the end of a lane the whole base is in reach.
for other: LuneUnit in living:
if not other.is_building() or not _hostile(unit, other) or not building_attackable(other, gates):
continue
var d: float = unit.position.distance_to(other.position) - balance.building_radius
if (d <= radius or unit.reached_base) and d < best_distance:
best = other
best_distance = d
return best
func _march_limit(unit: LuneUnit) -> float:
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = path.length
for id: String in buildings:
var building: LuneUnit = buildings[id]
if building.alive and building.kind == "tower" and building.team != unit.team and building.lane == unit.lane:
var definition: Dictionary = map.building_def(id)
var mine: float = path.length - float(definition.progress)
limit = minf(limit, mine - 1.5)
return limit
## Returns [next position, marching progress]. Marching advances the lane parameter
## directly, so lateral formation offsets cannot stall a unit on the inside of a bend.
func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
var step_length: float = unit.speed() * balance.march_speed_factor * unit.movement_factor() * dt
var goal: Vector2 = unit.position
var travel: float = 0.0
var marched: float = unit.progress
if unit.target != null:
var reach: float = unit.attack_range() + (balance.building_radius if unit.target.is_building() else 0.0)
var gap: float = unit.position.distance_to(unit.target.position) - reach
if gap > 0.0:
goal = unit.target.position
travel = minf(step_length, gap + 0.02)
elif unit.zone == "lane":
var limit: float = _march_limit(unit)
var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral)
if unit.position.distance_to(anchor) > 1.2:
# Rejoin the formation line after a chase before advancing again.
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
elif unit.progress < limit - 0.01:
marched = minf(unit.progress + step_length, limit)
goal = map.lane_point(unit.team, unit.lane, marched, unit.lateral)
travel = unit.position.distance_to(goal)
elif unit.position.distance_to(anchor) > 0.05:
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
var next: Vector2 = unit.position
if travel > 0.0 and goal.distance_squared_to(unit.position) > 0.000001:
next += (goal - unit.position).normalized() * minf(travel, unit.position.distance_to(goal))
# Separation from allies so bodies do not stack; opponents may still close to melee range.
var push := Vector2.ZERO
for other: LuneUnit in living:
if other == unit or not other.is_mobile() or other.team != unit.team or other.zone != unit.zone:
continue
var offset: Vector2 = unit.position - other.position
var distance: float = offset.length()
if distance < 1.1:
if distance < 0.0001:
offset = Vector2.RIGHT if unit.id < other.id else Vector2.LEFT
distance = 0.001
push += offset.normalized() * (1.1 - distance) * 2.0
next += push.limit_length(0.9) * dt
next.x = clampf(next.x, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
next.y = clampf(next.y, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
return [next, marched]
func _update_progress(unit: LuneUnit, marched: float) -> void:
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = _march_limit(unit)
if unit.target != null:
# After a chase the unit continues from where it now stands, never further back.
var blue: Vector2 = unit.position if unit.team == 0 else LuneMap.mirror(unit.position)
marched = maxf(marched, minf(path.project(blue), limit))
unit.progress = maxf(unit.progress, minf(marched, maxf(limit, unit.progress)))
if unit.progress >= path.length - 0.6:
unit.reached_base = true
# ---------------------------------------------------------------- attacks
func _attack(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary, dt: float) -> void:
unit.in_range = false
var target: LuneUnit = unit.target
if target == null or not target.alive or unit.kind == "core":
unit.attack_progress = 0.0
return
var reach: float = unit.attack_range() + (balance.building_radius if target.is_building() else 0.0) + 0.035
if unit.position.distance_to(target.position) > reach:
unit.attack_progress = 0.0
return
if target.is_building() and not building_attackable(target, gates):
unit.attack_progress = 0.0
return
unit.in_range = true
var interval: float = balance.tower_attack_interval if unit.kind == "tower" else 1.0
unit.attack_progress += dt * unit.attack_speed_factor() / interval
while unit.attack_progress >= 1.0 - 0.000001:
unit.attack_progress = maxf(0.0, unit.attack_progress - 1.0)
unit.hit_count += 1
unit.attack_serial += 1
var damage: float = float(unit.def.get("damage", 0.0)) * (1.0 + unit.damage_bonus())
if unit.hit_count % 4 == 0:
if unit.ability() == "g05":
damage *= 1.5
elif unit.ability() == "g06":
damage *= 1.75
if target.is_building():
damage *= float(unit.def.get("siege_factor", 1.0))
_queue_damage(target, target.mitigated(damage, int(unit.def.get("attack_type", 0))), gates)
_on_hit(unit, target, living, gates)
func _queue_damage(target: LuneUnit, amount: float, gates: Dictionary) -> void:
if amount <= 0.0 or not target.alive:
return
# The building gate applies to every source: ranged, splash, DoT and abilities.
if target.is_building() and not building_attackable(target, gates):
return
_pending_damage[target.id] = float(_pending_damage.get(target.id, 0.0)) + amount
func _queue_heal(target: LuneUnit, amount: float) -> void:
_pending_heal[target.id] = float(_pending_heal.get(target.id, 0.0)) + amount
# ---------------------------------------------------------------- abilities (ported from the Saumwacht rules)
func _start_ability(unit: LuneUnit) -> void:
unit.ability_ready = 6.0
match unit.ability():
"w03", "w04": unit.ability_ready = 5.0
"w09", "w10": unit.ability_ready = 4.0
"s02": unit.grant_shield(unit.max_hp * 0.15)
"e08": unit.grant_shield(unit.max_hp * 0.20)
func _nearby(unit: LuneUnit, living: Array[LuneUnit], allied: bool, center: Vector2, radius: float, maximum: int, excluded: LuneUnit = null, health_priority: bool = false) -> Array[LuneUnit]:
var result: Array[LuneUnit] = []
for other: LuneUnit in living:
if not other.alive or other == excluded or other.zone != unit.zone:
continue
if (other.team == unit.team) != allied:
continue
if allied and other.is_building():
continue
if other.position.distance_to(center) <= radius + 0.000001:
result.append(other)
result.sort_custom(func(a: LuneUnit, b: LuneUnit) -> bool:
if health_priority and not is_equal_approx(a.hp / a.max_hp, b.hp / b.max_hp):
return a.hp / a.max_hp < b.hp / b.max_hp
var ad: float = a.position.distance_to(center)
var bd: float = b.position.distance_to(center)
return a.id < b.id if is_equal_approx(ad, bd) else ad < bd)
if result.size() > maximum:
result.resize(maximum)
return result
func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
if unit.combat_time + 0.000001 < unit.ability_ready:
return
var ability: String = unit.ability()
var success: bool = false
match ability:
"w03", "w04":
if unit.hp < unit.max_hp:
_queue_heal(unit, 100.0 if ability == "w04" else 35.0)
success = true
"w09", "w10":
var upgraded: bool = ability == "w10"
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.5 if upgraded else 4.0, 3 if upgraded else 1, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 80.0 if upgraded else 65.0)
success = true
"g09", "g10":
var upgraded: bool = ability == "g10"
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0 if upgraded else 3.0, 4 if upgraded else 3, null, true)
for ally: LuneUnit in allies:
_pending_effects.append([ally, "hearth_warmth", "damage", 0.15 if upgraded else 0.10, 4.0])
if upgraded:
_queue_heal(ally, 45.0)
success = not allies.is_empty()
"e06":
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.0, 100, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 30.0 * float(unit.def.get("hp_factor", 1.0)))
success = true
break
"s03", "e09":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 6.0 if ability == "e09" else 3.0, 3 if ability == "e09" else 4)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "dissonance", "attack_slow", 0.15, 3.0])
success = not victims.is_empty()
"s05":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 5.0, 1)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "rift_wound", "healing_reduction", 0.30, 4.0])
success = not victims.is_empty()
if success:
unit.ability_ready = unit.combat_time + (5.0 if ability in ["w03", "w04"] else (4.0 if ability in ["w09", "w10"] else 6.0))
unit.ability_serial += 1
func _on_hit(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> void:
var ability: String = unit.ability()
var hits: int = unit.hit_count
match ability:
"w01", "w02":
if hits % 3 == 0:
_pending_effects.append([primary, "root_grasp", "movement_slow", 0.30 if ability == "w02" else 0.20, 3.0 if ability == "w02" else 2.0])
if ability == "w02":
_pending_effects.append([primary, "root_grasp_attack", "attack_slow", 0.10, 3.0])
"w05", "w06":
if hits % 4 == 0:
_pending_dots.append([primary, "seed_burn", 10.0 if ability == "w06" else 3.0, 4.0, int(unit.def.attack_type)])
"w07", "w08":
if hits % 3 == 0:
_splash(unit, primary, living, gates, 0.80 if ability == "w08" else 0.60, 2.5 if ability == "w08" else 2.0, 4 if ability == "w08" else 3)
"g01", "g02":
if hits % 3 == 0:
_pending_dots.append([primary, "burn", 12.0 if ability == "g02" else 4.0, 3.0, int(unit.def.attack_type)])
"g07": _splash(unit, primary, living, gates, 0.30, 1.5, 3)
"g08": _splash(unit, primary, living, gates, 0.40, 2.0, 4)
"e05":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.40, 1.5, 2)
"e07":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.0, 3)
"e10":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.5, 4)
"s04":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.50, 2.0, 3)
func _splash(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary, factor: float, radius: float, maximum: int) -> void:
for victim: LuneUnit in _nearby(unit, living, false, primary.position, radius, maximum, primary):
_queue_damage(victim, victim.mitigated(float(unit.def.damage) * factor, int(unit.def.attack_type)), gates)
# ---------------------------------------------------------------- queries for views and tests
func mobile_count(team: int = -1) -> int:
var count: int = 0
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile() and (team < 0 or unit.team == team):
count += 1
return count