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