saumwacht-godot/scripts/luneraria/view/lune_terrain.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

419 lines
17 KiB
GDScript

class_name LuneTerrain
extends RefCounted
## Visual height field of the G1 map, derived from LuneMap alone. Every term depends on
## the axis coordinate u = (x + z) / √2 and the distance a = |z - x| / √2 from the axis,
## or on symmetric map features, so the ground is the exact mirror image across x = z.
## The simulation stays two-dimensional: views read walk_height() to stand units on it.
##
## Regions: the north-western highland (Top) behind an escarpment, the central valley
## with the Kluft river on the axis, the south-eastern lowland (Bottom) with the delta,
## base plateaus, the grotto terrace and a ring of mountains around the playable square.
const EXTENT: float = 60.0
const STEP: float = 1.0
## Coarse outer ring (mountains and the river outlet) around the fine grid.
const OUTER_EXTENT: float = 132.0
const OUTER_STEP: float = 4.0
const OUTER_HOLE: float = 56.0
const WATER_LEVEL: float = -0.35
const BED_LEVEL: float = -1.2
const BASE_HEIGHT: float = 0.6
const BASE_RADIUS: float = 10.6
const HIGHLAND: float = 3.0
const VALLEY: float = 0.18
const LOWLAND: float = -0.1
const ARENA_HEIGHT: float = 1.7
const ARENA_RADIUS: float = 7.8
const PLATFORM_HEIGHT: float = 0.62
const PLATFORM_RADIUS: float = 6.6
const FORD_HEIGHT: float = -0.16
## Lane corridor half widths (flat ground under the paving) per lane.
const CORRIDOR: Dictionary = {"top": 3.6, "mid": 4.5, "bot": 3.6}
const BLEND: float = 5.0
## River half width along the axis coordinate u (piecewise linear).
const RIVER_WIDTH: Array[Vector2] = [Vector2(-11.6, 1.6), Vector2(-9.0, 2.4), Vector2(-4.0, 3.1), Vector2(0.0, 3.5),
Vector2(12.0, 3.0), Vector2(26.0, 3.5), Vector2(32.0, 5.0), Vector2(36.5, 7.2), Vector2(42.0, 13.0), Vector2(60.0, 20.0)]
const RIVER_START: float = -11.6
var map: LuneMap
var size: int = 0
var ground := PackedFloat32Array()
var walk := PackedFloat32Array()
var colors := PackedColorArray()
## lane -> PackedFloat32Array of the lane surface height per metre of lane coordinate.
var lane_profiles: Dictionary = {}
var _noise := FastNoiseLite.new()
var _detail := FastNoiseLite.new()
var _segments: Array = []
var _buckets: Dictionary = {}
## [position, height] of every tower: a flat pad at the height of the lane beside it.
var _pads: Array = []
const BUCKET: float = 6.0
static var _cache: LuneTerrain
## One terrain per map layout and process; a rematch reuses it.
static func for_map(match_map: LuneMap) -> LuneTerrain:
if _cache == null:
_cache = LuneTerrain.new()
_cache.build(match_map)
return _cache
func build(match_map: LuneMap) -> void:
map = match_map
_noise.seed = 4417
_noise.frequency = 0.045
_noise.fractal_octaves = 3
_detail.seed = 911
_detail.frequency = 0.21
_detail.fractal_octaves = 2
for lane: String in LuneMap.LANES:
lane_profiles[lane] = _lane_profile(lane)
_index_segments()
for definition: Dictionary in map.buildings:
if str(definition.tier) in ["inner", "outer"]:
_pads.append([Vector2(definition.position), lane_height(str(definition.lane), map.s_for(int(definition.team), str(definition.lane), float(definition.progress)))])
elif str(definition.tier) == "base":
_pads.append([Vector2(definition.position), BASE_HEIGHT])
var grid: Dictionary = _grid(EXTENT, STEP, 0.0)
size = grid.size
ground = grid.ground
walk = grid.walk
colors = grid.colors
## Evaluates a square grid. Only the Blau side (z >= x) and the axis are computed; the
## grid is symmetric under i <-> j, so the Rot side is copied. `hole` skips the interior
## (used by the coarse outer ring, which also sinks slightly where it overlaps).
func _grid(extent: float, step: float, hole: float) -> Dictionary:
var n: int = int(round(extent * 2.0 / step)) + 1
var heights := PackedFloat32Array()
heights.resize(n * n)
var walks := PackedFloat32Array()
walks.resize(n * n)
var lane_distance := PackedFloat32Array()
lane_distance.resize(n * n)
var carved := PackedByteArray()
carved.resize(n * n)
for j: int in range(n):
for i: int in range(j + 1):
var p := Vector2(-extent + i * step, -extent + j * step)
if hole > 0.0 and maxf(absf(p.x), absf(p.y)) < hole - step:
continue
var sample: Array = _evaluate(p)
var height: float = sample[0]
if hole > 0.0 and maxf(absf(p.x), absf(p.y)) < EXTENT + 0.5:
height -= 0.5
for index: int in [j * n + i, i * n + j]:
heights[index] = height
walks[index] = sample[1]
lane_distance[index] = sample[2]
carved[index] = sample[3]
var grid_colors := PackedColorArray()
grid_colors.resize(n * n)
for j: int in range(n):
for i: int in range(j + 1):
var index: int = j * n + i
var left: float = heights[j * n + maxi(0, i - 1)]
var right: float = heights[j * n + mini(n - 1, i + 1)]
var up: float = heights[maxi(0, j - 1) * n + i]
var down: float = heights[mini(n - 1, j + 1) * n + i]
var slope: float = Vector2(right - left, down - up).length() / (2.0 * step)
var color: Color = _color(Vector2(-extent + i * step, -extent + j * step), heights[index], slope, lane_distance[index], carved[index] == 1)
grid_colors[index] = color
grid_colors[i * n + j] = color
return {"size": n, "extent": extent, "step": step, "hole": hole, "ground": heights, "walk": walks, "colors": grid_colors}
# ---------------------------------------------------------------- queries
static func axis_u(p: Vector2) -> float:
return (p.x + p.y) * 0.70710678
static func axis_a(p: Vector2) -> float:
return absf(p.y - p.x) * 0.70710678
func height_at(p: Vector2) -> float:
return _bilinear(ground, p)
## Height a unit stands on: the lane decks (bridges, ford, platform) instead of the river bed.
func walk_height(p: Vector2) -> float:
return _bilinear(walk, p)
func lane_height(lane: String, s: float) -> float:
var profile: PackedFloat32Array = lane_profiles[lane]
var f: float = clampf(s, 0.0, float(profile.size() - 1))
var i: int = mini(int(f), profile.size() - 2)
return lerpf(profile[i], profile[i + 1], f - i)
func river_half_width(u: float) -> float:
if u < RIVER_START:
return 0.0
for k: int in range(1, RIVER_WIDTH.size()):
if u <= RIVER_WIDTH[k].x:
var a: Vector2 = RIVER_WIDTH[k - 1]
var b: Vector2 = RIVER_WIDTH[k]
return lerpf(a.y, b.y, clampf((u - a.x) / (b.x - a.x), 0.0, 1.0))
return RIVER_WIDTH[RIVER_WIDTH.size() - 1].y
func is_water(p: Vector2) -> bool:
return height_at(p) < WATER_LEVEL - 0.02
func _bilinear(field: PackedFloat32Array, p: Vector2) -> float:
var fx: float = clampf((p.x + EXTENT) / STEP, 0.0, size - 1.001)
var fz: float = clampf((p.y + EXTENT) / STEP, 0.0, size - 1.001)
var i: int = int(fx)
var j: int = int(fz)
var tx: float = fx - i
var tz: float = fz - j
var a: float = lerpf(field[j * size + i], field[j * size + i + 1], tx)
var b: float = lerpf(field[(j + 1) * size + i], field[(j + 1) * size + i + 1], tx)
return lerpf(a, b, tz)
# ---------------------------------------------------------------- regions
func escarpment(a: float) -> float:
return -27.4 + 4.6 * smoothstep(6.0, 20.0, a) + 1.1 * sin(a * 0.43)
## Ground before lanes, river and arena: highland, valley, lowland, bases, mountains.
func _region(p: Vector2) -> float:
var u: float = axis_u(p)
var a: float = axis_a(p)
var edge: float = escarpment(a)
var hills: float = _noise.get_noise_2d(u, a)
var height: float = lerpf(HIGHLAND + hills * 0.5, VALLEY + hills * 0.35, smoothstep(edge - 1.3, edge + 1.3, u))
height = lerpf(height, LOWLAND + hills * 0.22, smoothstep(20.0, 33.0, u))
# The lookout hill beyond the Top corner.
height += 1.1 * (1.0 - smoothstep(4.0, 11.0, p.distance_to(Vector2(-31.0, -31.0))))
for team: int in range(2):
var d: float = p.distance_to(map.core_position(team))
height = lerpf(BASE_HEIGHT, height, smoothstep(BASE_RADIUS, BASE_RADIUS + 3.0, d))
# Mountains around the playable square: a rounded square (superellipse, symmetric under
# x <-> z) with a noisy foot line, so the frame does not read as a straight wall.
var border: float = pow(pow(absf(p.x), 5.0) + pow(absf(p.y), 5.0), 0.2) + hills * 3.4 + _detail.get_noise_2d(u * 0.35, a * 0.35) * 1.8
if border > 39.0:
var over: float = border - 39.0
height += minf(11.0, over * 0.62 + over * over * 0.035) * (0.75 + 0.25 * _detail.get_noise_2d(u * 0.5, a * 0.5))
return height
func _arena_blend(p: Vector2, height: float) -> float:
var d: float = p.distance_to(LuneMap.GROTTO)
if d > ARENA_RADIUS + 3.5:
return height
return lerpf(ARENA_HEIGHT, height, smoothstep(ARENA_RADIUS, ARENA_RADIUS + 3.5, d))
## Lane surface height: the smoothed ground along the lane, lifted onto platform and ford.
func _lane_profile(lane: String) -> PackedFloat32Array:
var path: LuneMap.LanePath = map.lanes[lane]
var count: int = int(ceil(path.length)) + 1
var raw := PackedFloat32Array()
raw.resize(count)
for k: int in range(count):
raw[k] = _arena_blend(path.point_at(float(k)), _region(path.point_at(float(k))))
var smooth: PackedFloat32Array = raw
for pass_index: int in range(3):
var next := PackedFloat32Array()
next.resize(count)
for k: int in range(count):
var total: float = 0.0
var weight: float = 0.0
for o: int in range(-6, 7):
var m: int = clampi(k + o, 0, count - 1)
total += smooth[m]
weight += 1.0
next[k] = total / weight
smooth = next
var middle: float = path.length * 0.5
for k: int in range(count):
var from_middle: float = absf(float(k) - middle)
# The lane leaves the base on the plateau and only then descends.
var at: Vector2 = path.point_at(float(k))
var core_d: float = minf(at.distance_to(map.core_position(0)), at.distance_to(map.core_position(1)))
smooth[k] = lerpf(BASE_HEIGHT, smooth[k], smoothstep(BASE_RADIUS - 1.0, BASE_RADIUS + 5.0, core_d))
if lane == "mid":
smooth[k] = lerpf(PLATFORM_HEIGHT, smooth[k], smoothstep(PLATFORM_RADIUS - 1.0, PLATFORM_RADIUS + 7.0, from_middle))
elif lane == "bot":
smooth[k] = lerpf(FORD_HEIGHT, smooth[k], smoothstep(6.0, 16.0, from_middle))
smooth[k] = maxf(smooth[k], WATER_LEVEL + 0.18)
return smooth
# ---------------------------------------------------------------- lanes (spatial index)
func _index_segments() -> void:
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
for i: int in range(1, path.points.size()):
var a: Vector2 = path.points[i - 1]
var b: Vector2 = path.points[i]
var index: int = _segments.size()
_segments.append([a, b, lane, float(path.cumulative[i - 1]), a.distance_to(b)])
var reach: float = float(CORRIDOR[lane]) + BLEND + 1.0
var lo := Vector2(minf(a.x, b.x) - reach, minf(a.y, b.y) - reach)
var hi := Vector2(maxf(a.x, b.x) + reach, maxf(a.y, b.y) + reach)
for bx: int in range(floori(lo.x / BUCKET), floori(hi.x / BUCKET) + 1):
for bz: int in range(floori(lo.y / BUCKET), floori(hi.y / BUCKET) + 1):
var key := Vector2i(bx, bz)
if not _buckets.has(key):
_buckets[key] = []
(_buckets[key] as Array).append(index)
## [distance, lane, lane coordinate] of the closest lane centre line, or [INF].
func nearest_lane(p: Vector2) -> Array:
var key := Vector2i(floori(p.x / BUCKET), floori(p.y / BUCKET))
if not _buckets.has(key):
return [INF, "", 0.0]
var best: float = INF
var best_lane: String = ""
var best_s: float = 0.0
for index: int in _buckets[key]:
var segment: Array = _segments[index]
var a: Vector2 = segment[0]
var ab: Vector2 = (segment[1] as Vector2) - a
var t: float = clampf((p - a).dot(ab) / maxf(0.0001, ab.length_squared()), 0.0, 1.0)
var d: float = p.distance_to(a + ab * t)
if d < best:
best = d
best_lane = segment[2]
best_s = float(segment[3]) + float(segment[4]) * t
return [best, best_lane, best_s]
# ---------------------------------------------------------------- one grid point
func _evaluate(p: Vector2) -> Array:
var u: float = axis_u(p)
var a: float = axis_a(p)
var height: float = _arena_blend(p, _region(p))
var lane_info: Array = nearest_lane(p)
var lane_d: float = lane_info[0]
var on_lane: bool = false
var lane_surface: float = 0.0
if lane_d < INF:
var corridor: float = CORRIDOR[lane_info[1]]
lane_surface = lane_height(lane_info[1], lane_info[2])
height = lerpf(lane_surface, height, smoothstep(corridor, corridor + BLEND, lane_d))
on_lane = lane_d < corridor
for pad: Array in _pads:
var pad_d: float = p.distance_to(pad[0])
if pad_d < 3.8:
height = lerpf(float(pad[1]), height, smoothstep(2.3, 3.8, pad_d))
# The river is carved last so bridges and the ford span open water.
var width: float = river_half_width(u)
var carved: bool = false
if width > 0.0:
var bank: float = lerpf(BED_LEVEL, height, smoothstep(width - 0.9, width + 1.9, a))
# Round spring pool at the foot of the grotto terrace.
var start: float = smoothstep(RIVER_START - 0.2, RIVER_START + 1.6, u)
bank = lerpf(height, bank, start)
if bank < height:
carved = height - bank > 0.25
height = bank
var walk_height: float = lane_surface if on_lane else maxf(height, WATER_LEVEL)
if p.length() < PLATFORM_RADIUS:
walk_height = maxf(walk_height, PLATFORM_HEIGHT)
return [height, walk_height, lane_d, 1 if carved else 0]
## Blockout palette per zone; alpha carries rockiness for the terrain shader.
func _color(p: Vector2, height: float, slope: float, lane_d: float, carved: bool) -> Color:
var u: float = axis_u(p)
var a: float = axis_a(p)
var variation: float = _detail.get_noise_2d(u * 1.7, a * 1.7)
var grass := Color(.2, .31, .12).lerp(Color(.25, .33, .14), variation * .5 + .5)
var edge: float = escarpment(a)
var color: Color = grass
var rock: float = 0.0
if u < edge + 1.4:
color = Color(.29, .32, .17).lerp(Color(.35, .34, .25), clampf(variation * 1.4, 0.0, 1.0))
rock = clampf(variation * 1.2, 0.0, .6)
if u > 20.0:
color = color.lerp(Color(.17, .25, .12).lerp(Color(.24, .22, .15), clampf(-variation, 0.0, 1.0)), smoothstep(20.0, 30.0, u))
# Steep ground (escarpment, terrace walls, mountains) reads as rock.
if slope > 0.55:
color = color.lerp(Color(.37, .34, .29), smoothstep(0.55, 1.1, slope))
rock = maxf(rock, smoothstep(0.55, 1.1, slope))
if maxf(absf(p.x), absf(p.y)) > 40.0:
rock = maxf(rock, 0.35 + variation * 0.3)
if carved or height < WATER_LEVEL + 0.25:
color = Color(.31, .29, .21).lerp(Color(.15, .16, .12), clampf((WATER_LEVEL + 0.2 - height) / 0.8, 0.0, 1.0))
rock = 0.25
if lane_d < 5.2:
color = color.lerp(Color(.31, .26, .18), 1.0 - smoothstep(3.8, 5.2, lane_d))
for team: int in range(2):
if p.distance_to(map.core_position(team)) < BASE_RADIUS + 0.3:
color = Color(.41, .38, .32).lerp(Color(.36, .33, .28), variation * .5 + .5)
rock = 0.45
if p.distance_to(LuneMap.GROTTO) < ARENA_RADIUS + 0.2:
color = Color(.36, .34, .33)
rock = 0.55
# The palette is authored in sRGB; vertex colours reach the shader as linear values.
var linear: Color = Color(color.r, color.g, color.b).srgb_to_linear()
linear.a = rock
return linear
# ---------------------------------------------------------------- mesh
var _inner_mesh: ArrayMesh
var _outer_mesh: ArrayMesh
func build_mesh() -> ArrayMesh:
return _mesh({"size": size, "extent": EXTENT, "step": STEP, "hole": 0.0, "ground": ground, "colors": colors})
## The distant mountains and the river outlet at 4 m resolution, with a hole for the fine grid.
func build_outer_mesh() -> ArrayMesh:
return _mesh(_grid(OUTER_EXTENT, OUTER_STEP, OUTER_HOLE))
## Cached meshes: a rematch or a second match in the same process reuses them.
func inner_mesh() -> ArrayMesh:
if _inner_mesh == null:
_inner_mesh = build_mesh()
return _inner_mesh
func outer_mesh() -> ArrayMesh:
if _outer_mesh == null:
_outer_mesh = build_outer_mesh()
return _outer_mesh
func _mesh(grid: Dictionary) -> ArrayMesh:
var n: int = grid.size
var extent: float = grid.extent
var step: float = grid.step
var hole: float = grid.hole
var heights: PackedFloat32Array = grid.ground
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
vertices.resize(n * n)
normals.resize(n * n)
for j: int in range(n):
for i: int in range(n):
var index: int = j * n + i
vertices[index] = Vector3(-extent + i * step, heights[index], -extent + j * step)
var left: float = heights[j * n + maxi(0, i - 1)]
var right: float = heights[j * n + mini(n - 1, i + 1)]
var up: float = heights[maxi(0, j - 1) * n + i]
var down: float = heights[mini(n - 1, j + 1) * n + i]
normals[index] = Vector3(left - right, 2.0 * step, up - down).normalized()
var indices := PackedInt32Array()
indices.resize((n - 1) * (n - 1) * 6)
var cursor: int = 0
for j: int in range(n - 1):
for i: int in range(n - 1):
if hole > 0.0:
var cx: float = absf(-extent + (i + 0.5) * step)
var cz: float = absf(-extent + (j + 0.5) * step)
if maxf(cx, cz) < hole:
continue
var a: int = j * n + i
var b: int = a + 1
var c: int = a + n
var d: int = c + 1
# Alternating diagonals; cell (i, j) and its mirror (j, i) share the parity,
# so mirrored cells triangulate as mirror images (clockwise front faces).
if (i + j) % 2 == 0:
indices[cursor] = a; indices[cursor + 1] = b; indices[cursor + 2] = d
indices[cursor + 3] = a; indices[cursor + 4] = d; indices[cursor + 5] = c
else:
indices[cursor] = a; indices[cursor + 1] = b; indices[cursor + 2] = c
indices[cursor + 3] = b; indices[cursor + 4] = d; indices[cursor + 5] = c
cursor += 6
indices.resize(cursor)
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_COLOR] = grid.colors
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh