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>
436 lines
18 KiB
GDScript
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
|