saumwacht-godot/scripts/luneraria/core/lune_map.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:09:48 +02:00

236 lines
10 KiB
GDScript

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