Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen, drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall, ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73, M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
26 KiB
GDScript
641 lines
26 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] = []
|
|
## Neutral grotto boss (team 2) and the resulting claim for this combat phase.
|
|
const NEUTRAL: int = 2
|
|
var grotto_boss: LuneUnit = null
|
|
var grotto_resolved: bool = true
|
|
var grotto_claim_team: int = -1
|
|
var grotto_active_team: int = -1
|
|
var grotto_outcome: String = "none"
|
|
var _next_id: int = 1
|
|
var _team_count: Array[int] = [0, 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 spawn_at(kind: String, team: int, zone: String, definition: Dictionary, at: Vector2, slot: int = -1, uid: String = "") -> LuneUnit:
|
|
var unit: LuneUnit = spawn(kind, team, "mid", definition, 0.0, 0.0, slot, uid)
|
|
unit.zone = zone
|
|
unit.lane = zone
|
|
unit.position = at
|
|
unit.previous_position = at
|
|
return unit
|
|
|
|
func add_grotto_boss(definition: Dictionary) -> LuneUnit:
|
|
grotto_boss = spawn_at("boss", NEUTRAL, "grotto", definition, map.grotto_boss_point())
|
|
grotto_boss.dormant = true
|
|
grotto_resolved = false
|
|
return grotto_boss
|
|
|
|
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: {}, 2: {}}
|
|
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)
|
|
|
|
static func hostile_teams(team: int) -> Array:
|
|
return [0, 1] if team == NEUTRAL else [1 - team, NEUTRAL]
|
|
|
|
## Mobile units of the given teams near a point; the caller checks distance.
|
|
func _near_mobile(center: Vector2, radius: float, teams: Array) -> 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 teams:
|
|
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)
|
|
_update_grotto()
|
|
_lap("resolve")
|
|
_check_end()
|
|
|
|
## Expeditions fight each other first; the boss wakes when at most one team remains.
|
|
func _update_grotto() -> void:
|
|
if grotto_resolved or grotto_boss == null:
|
|
return
|
|
var teams_alive: Array[int] = []
|
|
for unit: LuneUnit in units:
|
|
if unit.alive and unit.zone == "grotto" and unit.team != NEUTRAL and not teams_alive.has(unit.team):
|
|
teams_alive.append(unit.team)
|
|
if grotto_boss.dormant and teams_alive.size() <= 1:
|
|
grotto_boss.dormant = false
|
|
grotto_active_team = teams_alive[0] if teams_alive.size() == 1 else -1
|
|
if not grotto_boss.alive:
|
|
grotto_resolved = true
|
|
grotto_claim_team = grotto_active_team
|
|
grotto_outcome = "success" if grotto_claim_team >= 0 else "failed"
|
|
elif teams_alive.is_empty():
|
|
grotto_resolved = true
|
|
grotto_outcome = "failed"
|
|
|
|
func _check_end() -> void:
|
|
if not destroyed_cores.is_empty():
|
|
finished = true
|
|
end_reason = "core"
|
|
return
|
|
var mobile_alive: bool = not grotto_resolved
|
|
for unit: LuneUnit in units:
|
|
if unit.alive and unit.is_mobile() and unit.zone == "lane":
|
|
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)
|
|
# Participation XP: every opposing hero near the death shares it, whoever landed the hit.
|
|
var xp: int = 0
|
|
match unit.kind:
|
|
"monster": xp = 12 + round_number
|
|
"champion": xp = 30 + 2 * round_number
|
|
"boss": xp = LuneContent.BOSS_XP
|
|
"troop", "hero": xp = 15
|
|
"tower", "core": xp = LuneContent.BUILDING_XP
|
|
var heroes: Array[String] = []
|
|
var radius: float = LuneContent.XP_RADIUS + (2.0 if unit.is_building() else 0.0)
|
|
for other: LuneUnit in units:
|
|
if other.kind == "hero" and other.alive and other.zone == unit.zone and other.team != unit.team and other.position.distance_to(unit.position) <= radius:
|
|
heroes.append(other.persistent_uid)
|
|
event.xp = xp
|
|
event.xp_heroes = heroes
|
|
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 and not other.dormant and not unit.dormant
|
|
|
|
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
|
|
var priority: String = str(unit.def.get("priority", ""))
|
|
if priority == "building":
|
|
# Siege champions go for the nearest attackable building before anything else.
|
|
for id: String in buildings:
|
|
var building: LuneUnit = buildings[id]
|
|
if _hostile(unit, building) and building_attackable(building, gates):
|
|
var reach_d: float = unit.position.distance_to(building.position) - balance.building_radius
|
|
if reach_d <= balance.aggro_radius and reach_d < best_distance:
|
|
best = building
|
|
best_distance = reach_d
|
|
if best != null:
|
|
return best
|
|
for other: LuneUnit in _near_mobile(unit.position, radius, hostile_teams(unit.team)):
|
|
if not _hostile(unit, other):
|
|
continue
|
|
var d: float = unit.position.distance_to(other.position)
|
|
# Pressure champions treat support and ranged enemies as 6 m closer.
|
|
if priority == "support" and other.role() in ["support", "ranged"]:
|
|
d -= 6.0
|
|
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 == "grotto":
|
|
if unit.kind != "boss" and grotto_boss != null and grotto_boss.alive:
|
|
var gap_boss: float = unit.position.distance_to(grotto_boss.position) - unit.attack_range()
|
|
if gap_boss > 0.0:
|
|
goal = grotto_boss.position
|
|
travel = minf(step_length, gap_boss + 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 *= 2.1 if unit.has_talent("durchschlag") else 1.5
|
|
elif unit.ability() == "g06":
|
|
damage *= 1.75
|
|
if unit.hit_count % 3 == 0 and unit.has_talent("stammschlag"):
|
|
damage *= 1.6
|
|
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)
|
|
# Item effects come last and never trigger further on-hit effects.
|
|
var item_splash: float = float(unit.def.get("item_splash", 0.0))
|
|
if item_splash > 0.0:
|
|
_splash(unit, target, living, gates, item_splash, 1.5, 2)
|
|
|
|
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", "ch_schutz": 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 hostile_teams(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 or other.dormant:
|
|
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:
|
|
var regen: float = float(unit.def.get("regen", 0.0))
|
|
if regen > 0.0 and unit.hp < unit.max_hp:
|
|
_queue_heal(unit, unit.max_hp * regen * balance.sim_step)
|
|
if unit.has_talent("schutzwurzeln") and unit.combat_time + 0.000001 >= unit.aura_ready:
|
|
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.0, 4):
|
|
_pending_effects.append([ally, "bark_guard", "reduction", 0.10, 4.5])
|
|
unit.aura_ready = unit.combat_time + 4.0
|
|
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"
|
|
var radius: float = 4.5 if upgraded else 4.0
|
|
var count: int = 3 if upgraded else 1
|
|
var amount: float = 80.0 if upgraded else 65.0
|
|
if unit.has_talent("tiefe_quelle"):
|
|
count = 1
|
|
amount = 190.0
|
|
elif unit.has_talent("weite_quelle"):
|
|
radius = 5.5
|
|
count = 5
|
|
amount = 65.0
|
|
for ally: LuneUnit in _nearby(unit, living, true, unit.position, radius, count, null, true):
|
|
if ally.hp < ally.max_hp:
|
|
_queue_heal(ally, amount)
|
|
if unit.has_talent("quellschild"):
|
|
_pending_shields.append([ally, 70.0, 4.0])
|
|
success = true
|
|
"ch_schutz":
|
|
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0, 4)
|
|
for ally: LuneUnit in allies:
|
|
_pending_shields.append([ally, 120.0, 6.0])
|
|
success = not allies.is_empty()
|
|
"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
|
|
if unit.has_talent("wurzelbeben") and hits % 3 == 0:
|
|
_splash(unit, primary, living, gates, 0.50, 2.0, 3)
|
|
if unit.has_talent("splitterschuss") and hits % 4 == 0:
|
|
_splash(unit, primary, living, gates, 0.45, 2.0, 3)
|
|
if unit.has_talent("keimsaat") and hits % 2 == 0:
|
|
_pending_dots.append([primary, "seed_burn", 9.0, 4.0, int(unit.def.attack_type)])
|
|
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
|