class_name LuneMap extends RefCounted ## Map data in ground coordinates Vector2(x, z): Blau (team 0) in the south-west, ## Rot (team 1) in the north-east. Rot is the mirror image of Blau across x = z ## (mirror(v) = Vector2(v.y, v.x)); every lane is built from its Blau half and that ## half mirrored, so strecken, towers and spawns are functionally equal by construction. ## G1 layout: Top climbs the north-western highland, Mid crosses the Kluft over the ## central platform, Bottom winds through the south-eastern lowland and fords the river. ## Top and Bottom differ in shape but have exactly the same length (solved at load). const LANES: Array[String] = ["top", "mid", "bot"] const LANE_NAMES: Dictionary = {"top": "Top", "mid": "Mid", "bot": "Bottom"} ## Catmull-Rom control points of each Blau half, from the Blau base to the symmetry axis. ## The last point lies on x = z; the curve is sampled every ~2 m into the lane polyline. const TOP_CONTROL: Array[Vector2] = [Vector2(-27.4, 21.0), Vector2(-26.4, 13.0), Vector2(-28.4, 4.0), Vector2(-26.2, -5.0), Vector2(-28.6, -13.5), Vector2(-28.4, -21.2), Vector2(-25.0, -25.0)] const MID_CONTROL: Array[Vector2] = [Vector2(-21.6, 21.6), Vector2(-16.4, 17.2), Vector2(-10.9, 11.5), Vector2(-5.6, 5.9), Vector2(0, 0)] const CORE_BLUE := Vector2(-30, 30) ## Two base towers in front of each Ancient, 6 m out and 40° either side of the Mid direction. const BASE_TOWERS_BLUE: Array[Vector2] = [Vector2(-29.477, 24.023), Vector2(-24.023, 29.477)] ## Neutral boss grotto on the symmetry axis between Top and Mid, beside the Mid lane. const GROTTO := Vector2(-13.5, -13.5) const HALF_EXTENT: float = 36.0 ## Direction of the symmetry axis (north-west to south-east) and its Blau-side normal. const AXIS := Vector2(0.70710678, 0.70710678) const BLUE_SIDE := Vector2(-0.70710678, 0.70710678) const SAMPLE_SPACING: float = 2.0 var balance: LuneBalance var lanes: Dictionary = {} var buildings: Array[Dictionary] = [] class LanePath: extends RefCounted var points: PackedVector2Array var cumulative: PackedFloat32Array var length: float = 0.0 func _init(source: Array[Vector2]) -> void: points = PackedVector2Array(source) cumulative.append(0.0) for i: int in range(1, points.size()): length += points[i].distance_to(points[i - 1]) cumulative.append(length) ## Index i of the segment [i - 1, i] that contains lane coordinate d (binary search). func _segment(d: float) -> int: var low: int = 1 var high: int = points.size() - 1 while low < high: var middle: int = (low + high) >> 1 if d <= cumulative[middle]: high = middle else: low = middle + 1 return low func point_at(s: float) -> Vector2: var d: float = clampf(s, 0.0, length) var i: int = _segment(d) var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1]) return points[i - 1].lerp(points[i], clampf((d - cumulative[i - 1]) / span, 0.0, 1.0)) func tangent_at(s: float) -> Vector2: var i: int = _segment(clampf(s, 0.0, length)) return (points[i] - points[i - 1]).normalized() ## Point and unit tangent at s in one pass. func sample(s: float) -> Array: var d: float = clampf(s, 0.0, length) var i: int = _segment(d) var a: Vector2 = points[i - 1] var b: Vector2 = points[i] var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1]) return [a.lerp(b, clampf((d - cumulative[i - 1]) / span, 0.0, 1.0)), (b - a) / span] func project(position: Vector2) -> float: var best: float = INF var best_s: float = 0.0 for i: int in range(1, points.size()): var a: Vector2 = points[i - 1] var b: Vector2 = points[i] var ab: Vector2 = b - a var t: float = clampf((position - a).dot(ab) / maxf(0.0001, ab.length_squared()), 0.0, 1.0) var d: float = position.distance_squared_to(a + ab * t) if d < best: best = d best_s = cumulative[i - 1] + ab.length() * t return best_s static func mirror(v: Vector2) -> Vector2: return Vector2(v.y, v.x) static func mirror_mid(v: Vector2) -> Vector2: return Vector2(-v.y, -v.x) static func full_lane(half: Array[Vector2]) -> Array[Vector2]: var result: Array[Vector2] = half.duplicate() for i: int in range(half.size() - 2, -1, -1): result.append(mirror(half[i])) return result ## Samples the Catmull-Rom curve through the control points of a Blau half. The phantom ## point after the axis point is the mirror of the one before it, so the joined lane has ## no kink on the axis (its tangent there is perpendicular to x = z). static func sample_half(control: Array[Vector2]) -> Array[Vector2]: var ext: Array[Vector2] = [control[0] * 2.0 - control[1]] ext.append_array(control) ext.append(mirror(control[control.size() - 2])) var result: Array[Vector2] = [] for i: int in range(1, ext.size() - 2): var p0: Vector2 = ext[i - 1] var p1: Vector2 = ext[i] var p2: Vector2 = ext[i + 1] var p3: Vector2 = ext[i + 2] var steps: int = maxi(2, ceili(p1.distance_to(p2) / SAMPLE_SPACING)) for k: int in range(steps): var t: float = float(k) / steps var t2: float = t * t var t3: float = t2 * t result.append(0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3)) result.append(control[control.size() - 1]) return result ## Bottom half through the south-eastern lowland; `bend` pulls the valley meander inwards. static func bottom_control(bend: float) -> Array[Vector2]: return [Vector2(-21.0, 27.4), Vector2(-12.5, 27.0), Vector2(-4.0, 27.6), Vector2(4.0, 27.0 - bend), Vector2(12.0, 27.4 - 0.6 * bend), Vector2(19.5, 27.0), Vector2(22.2, 27.8), Vector2(25.0, 25.0)] ## Bisection on the meander so that Bottom is exactly as long as Top (fair lane roles). static func solve_bottom(target_length: float) -> Array[Vector2]: var low: float = 0.0 var high: float = 8.0 for i: int in range(60): var bend: float = (low + high) * 0.5 if LanePath.new(full_lane(sample_half(bottom_control(bend)))).length < target_length: low = bend else: high = bend return full_lane(sample_half(bottom_control((low + high) * 0.5))) func _init(config: LuneBalance = null) -> void: balance = config if config != null else LuneBalance.default_balance() lanes["top"] = LanePath.new(full_lane(sample_half(TOP_CONTROL))) lanes["mid"] = LanePath.new(full_lane(sample_half(MID_CONTROL))) lanes["bot"] = LanePath.new(solve_bottom(lanes["top"].length)) _build_buildings() ## Lane coordinate s for a team's own marching progress p (0 at its own base). func s_for(team: int, lane: String, progress: float) -> float: var path: LanePath = lanes[lane] return progress if team == 0 else path.length - progress func progress_for(team: int, lane: String, s: float) -> float: var path: LanePath = lanes[lane] return s if team == 0 else path.length - s ## Blau frame point, mirrored for Rot so both sides are exact images. func lane_point(team: int, lane: String, progress: float, lateral: float = 0.0) -> Vector2: var path: LanePath = lanes[lane] var sampled: Array = path.sample(progress) var tangent: Vector2 = sampled[1] var blue: Vector2 = sampled[0] + Vector2(-tangent.y, tangent.x) * lateral return blue if team == 0 else Vector2(blue.y, blue.x) func lane_forward(team: int, lane: String, progress: float) -> Vector2: var path: LanePath = lanes[lane] var tangent: Vector2 = path.tangent_at(clampf(progress, 0.0, path.length)) return tangent if team == 0 else mirror(tangent) ## Grotto deployment points: one mirrored entrance per team, boss slightly inside. ## The offsets from GROTTO are unchanged since M2, so expedition fights are unchanged. func grotto_entry(team: int) -> Vector2: var blue: Vector2 = GROTTO + Vector2(1.2, 4.6) return blue if team == 0 else mirror(blue) func grotto_boss_point() -> Vector2: return GROTTO + Vector2(-1.5, -1.5) func grotto_center() -> Vector2: return GROTTO ## Army formation area of a team on a lane (lane progress from/to and lateral half width). ## Matches the spawn rows in LuneMatch._spawn_armies: rows of four, 1.2 m apart. func deployment_span() -> Dictionary: return {"from": 0.4, "to": 7.2, "half_width": 2.4} func core_position(team: int) -> Vector2: return CORE_BLUE if team == 0 else mirror(CORE_BLUE) func _build_buildings() -> void: buildings.clear() for team: int in range(2): for lane: String in LANES: var path: LanePath = lanes[lane] for tier: String in ["inner", "outer"]: var fraction: float = balance.inner_tower_fraction if tier == "inner" else balance.outer_tower_fraction var progress: float = path.length * fraction buildings.append({"id": "%s_%s_%s" % ["blau" if team == 0 else "rot", lane, tier], "team": team, "lane": lane, "tier": tier, "progress": progress, "position": lane_point(team, lane, progress, balance.tower_side_offset), "max_hp": balance.tower_inner_hp if tier == "inner" else balance.tower_outer_hp, "damage": balance.tower_damage, "range": balance.tower_range, "bounty": balance.bounty_inner_tower if tier == "inner" else balance.bounty_outer_tower}) for index: int in range(2): var point: Vector2 = BASE_TOWERS_BLUE[index] buildings.append({"id": "%s_base_%d" % ["blau" if team == 0 else "rot", index + 1], "team": team, "lane": "base", "tier": "base", "progress": -1.0, "position": point if team == 0 else mirror(point), "max_hp": balance.base_tower_hp, "damage": balance.base_tower_damage, "range": balance.tower_range, "bounty": balance.bounty_base_tower}) buildings.append({"id": "%s_ancient" % ("blau" if team == 0 else "rot"), "team": team, "lane": "base", "tier": "core", "progress": -1.0, "position": core_position(team), "max_hp": balance.core_hp, "damage": 0.0, "range": 0.0, "bounty": 0}) var _index: Dictionary = {} func building_def(id: String) -> Dictionary: if _index.is_empty(): for entry: Dictionary in buildings: _index[entry.id] = entry return _index.get(id, {}) ## Gate rule shared by every damage source. alive maps building id -> bool. static func is_attackable(definition: Dictionary, alive: Dictionary) -> bool: var prefix: String = "blau" if int(definition.team) == 0 else "rot" match str(definition.tier): "outer": return true "inner": return not alive.get("%s_%s_outer" % [prefix, definition.lane], false) "base": for lane: String in LANES: if not alive.get("%s_%s_outer" % [prefix, lane], false) and not alive.get("%s_%s_inner" % [prefix, lane], false): return true return false "core": return not alive.get(prefix + "_base_1", false) and not alive.get(prefix + "_base_2", false) return false