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 ## Cave notch behind the grotto gate in the grotto's axis frame: u from, u to, half width, ## depth below the terrace (G2, matches build_bossgrotte.py). const CAVE_NOTCH := Vector4(-11.5, -8.5, 1.8, 1.05) 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 + 5.0: return height return _cave_notch(p, lerpf(ARENA_HEIGHT, height, smoothstep(ARENA_RADIUS, ARENA_RADIUS + 3.5, d))) ## G2: the cave behind the grotto gate reaches about a metre below the terrace, so its stairs ## can lead down (presentation only; the rock shell of the grotto hides the notch walls and no ## figure ever stands there). Symmetric about the axis like everything else. func _cave_notch(p: Vector2, height: float) -> float: var u: float = axis_u(p) - axis_u(LuneMap.GROTTO) var a: float = axis_a(p) if u < CAVE_NOTCH.x - .6 or u > CAVE_NOTCH.y + .6 or a > CAVE_NOTCH.z + .6: return height var inside: float = smoothstep(CAVE_NOTCH.x - .4, CAVE_NOTCH.x, u) * (1.0 - smoothstep(CAVE_NOTCH.y, CAVE_NOTCH.y + .4, u)) \ * (1.0 - smoothstep(CAVE_NOTCH.z, CAVE_NOTCH.z + .4, a)) return lerpf(height, minf(height, ARENA_HEIGHT - CAVE_NOTCH.w), inside) ## 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