saumwacht-godot/scripts/luneraria/core/lune_combat.gd
Martin Stickelbroeck c27a0097c3 M1: spielbarer Drei-Lane-Kern mit Karte, HUD, Menü und Abnahme
Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die
alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm,
HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden
aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller
(Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch.
Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün.

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

525 lines
21 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 _team_count: Array[int] = [0, 0]
## Units without a target look for one every RETARGET steps, staggered by team_index.
const RETARGET: int = 4
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 = []
# Per-step caches: building gates, march limits and a spatial grid of mobile units.
var _attackable: Dictionary = {}
var _limits: Dictionary = {}
var _grid: Dictionary = {}
const CELL: float = 4.0
## Optional section timings (microseconds) for performance work; off by default.
var profiling: bool = false
var profile: Dictionary = {}
var _mark: int = 0
func _lap(section: String) -> void:
if profiling:
var now: int = Time.get_ticks_usec()
profile[section] = int(profile.get(section, 0)) + now - _mark
_mark = now
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.team_index = _team_count[unit.team]
_team_count[unit.team] += 1
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.team_index = _team_count[team]
_team_count[team] += 1
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:
if not unit.alive:
return false
if _attackable.has(unit.building_id):
return _attackable[unit.building_id]
return LuneMap.is_attackable(map.building_def(unit.building_id), gates)
func _prepare_caches(living: Array[LuneUnit], gates: Dictionary) -> void:
_attackable.clear()
for id: String in buildings:
_attackable[id] = LuneMap.is_attackable(map.building_def(id), gates)
_limits.clear()
for team: int in range(2):
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[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 != team and building.lane == lane:
limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5)
_limits[team * 3 + LuneMap.LANES.find(lane)] = limit
_grid = {0: {}, 1: {}}
for unit: LuneUnit in living:
if unit.is_mobile():
var cells: Dictionary = _grid[unit.team]
var key := Vector2i(floori(unit.position.x / CELL), floori(unit.position.y / CELL))
if not cells.has(key):
cells[key] = []
(cells[key] as Array).append(unit)
## Mobile units of one team (or both with team = -1) near a point; the caller checks distance.
func _near_mobile(center: Vector2, radius: float, team: int = -1) -> Array:
var result: Array = []
var reach: int = ceili(radius / CELL)
var cx: int = floori(center.x / CELL)
var cy: int = floori(center.y / CELL)
for side: int in ([0, 1] if team < 0 else [team]):
var cells: Dictionary = _grid[side]
if cells.is_empty():
continue
for dx: int in range(-reach, reach + 1):
for dy: int in range(-reach, reach + 1):
var bucket: Variant = cells.get(Vector2i(cx + dx, cy + dy))
if bucket != null:
result.append_array(bucket)
return result
# ---------------------------------------------------------------- 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()
_mark = Time.get_ticks_usec()
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
_prepare_caches(living, gates)
_lap("caches")
for unit: LuneUnit in living:
var dot: float = unit.advance_status(dt)
if dot > 0.0:
_queue_damage(unit, dot, gates)
_lap("status")
for unit: LuneUnit in living:
if unit.is_mobile():
_ability_step(unit, living)
_lap("abilities")
for unit: LuneUnit in living:
if unit.is_mobile() or (unit.kind == "tower"):
var keep: bool = unit.target != null and unit.target.alive
if keep or (steps + unit.team_index) % RETARGET == 0:
unit.target = _choose_target(unit, living, gates)
_lap("targets")
var moves: Dictionary = {}
for unit: LuneUnit in living:
if unit.is_mobile():
moves[unit.id] = _plan_move(unit, living, dt)
_lap("plan_move")
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]))
_lap("apply_move")
for unit: LuneUnit in living:
_attack(unit, living, gates, dt)
_lap("attacks")
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)
_lap("resolve")
_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 _near_mobile(unit.position, radius, 1 - unit.team):
if not _hostile(unit, other):
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 id: String in buildings:
var other: LuneUnit = buildings[id]
if 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 key: int = unit.team * 3 + LuneMap.LANES.find(unit.lane)
if _limits.has(key):
return _limits[key]
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:
limit = minf(limit, path.length - float(map.building_def(id).progress) - 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)
var off: float = unit.position.distance_to(anchor)
if off > 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 off > 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 _near_mobile(unit.position, 1.1, unit.team):
if other == unit 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:
# Mirrored teams sidestep in mirrored directions.
offset = Vector2.RIGHT if unit.id < other.id else Vector2.LEFT
if unit.team == 1:
offset = LuneMap.mirror(offset)
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] = []
# Buildings can be splash victims; they are few, so check them directly.
var pool: Array = _near_mobile(center, radius, unit.team if allied else 1 - unit.team)
if not allied:
for id: String in buildings:
pool.append(buildings[id])
for other: LuneUnit in pool:
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