saumwacht-godot/scripts/luneraria/core/lune_combat.gd
Martin Stickelbroeck f53859a298 M2: Helden, Items, Talente, Angriffschampions und Bossgrotte
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>
2026-09-27 22:33:11 +02:00

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