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)), Bottom is Top mirrored across the Mid line. ## Both hold by construction, so strecken, towers and spawns are functionally equal. const LANES: Array[String] = ["top", "mid", "bot"] const LANE_NAMES: Dictionary = {"top": "Top", "mid": "Mid", "bot": "Bottom"} # First halves from the Blau end to the centre line; the Rot half is mirrored. const TOP_HALF: Array[Vector2] = [Vector2(-24, 22), Vector2(-26, 6), Vector2(-26.5, -12), Vector2(-25, -25)] const MID_HALF: Array[Vector2] = [Vector2(-21, 21), Vector2(-11, 10), Vector2(0, 0)] const CORE_BLUE := Vector2(-30, 30) const BASE_TOWERS_BLUE: Array[Vector2] = [Vector2(-28.8, 24.6), Vector2(-24.6, 28.8)] ## Neutral boss grotto, north-west of Mid and on the symmetry axis (M2). const GROTTO := Vector2(-11.5, -11.5) const HALF_EXTENT: float = 36.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) func point_at(s: float) -> Vector2: var d: float = clampf(s, 0.0, length) for i: int in range(1, points.size()): if d <= cumulative[i] or i == points.size() - 1: 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)) return points[points.size() - 1] func tangent_at(s: float) -> Vector2: var d: float = clampf(s, 0.0, length) for i: int in range(1, points.size()): if d <= cumulative[i] or i == points.size() - 1: return (points[i] - points[i - 1]).normalized() return Vector2.RIGHT ## Point and unit tangent at s in one pass. func sample(s: float) -> Array: var d: float = clampf(s, 0.0, length) for i: int in range(1, points.size()): if d <= cumulative[i] or i == points.size() - 1: 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] return [points[points.size() - 1], Vector2.RIGHT] 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 func _init(config: LuneBalance = null) -> void: balance = config if config != null else LuneBalance.default_balance() var bot_half: Array[Vector2] = [] for point: Vector2 in TOP_HALF: bot_half.append(mirror_mid(point)) lanes["top"] = LanePath.new(full_lane(TOP_HALF)) lanes["mid"] = LanePath.new(full_lane(MID_HALF)) lanes["bot"] = LanePath.new(full_lane(bot_half)) _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. 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 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