ajhahn.de
← Theria
GDScript 693 lines
class_name JungleDecor
extends RefCounted
## The three-dimensional map objects laid over the flat field so the arena reads as a real
## place — the mountain wall ringing the bounds, the rolling hills, a central landmark marking
## the middle, the scattered jungle growth, and the props that dress each neutral camp. Built
## once when the scene is raised, on top of MapView's flat lane/river/bridge decor.
##
## Everything here is procedural low-poly: faceted cones, domes, blades, and boxes assembled
## into a few batched meshes, each vertex carrying its own colour (rock grey rising to a light
## cap, trunk wood, leaf green fading to a sun-tipped edge). One shared toon material
## (foliage.gdshader) bands them into the same low-poly light family as the units, so a fern
## and a hero read as one art direction. No imported art — the geometry is the asset.
##
## Placement reads straight from MapData (bounds, lanes, river, camps, towers, nexuses), the
## one geometry source the sim/bots/tests already share, so the decor cannot drift from the
## simulated map: it never lands on a lane, in the river, on a structure, or out of bounds, and
## the jungle thins toward the lanes the way a beaten travel corridor would.

const FOLIAGE_SHADER: Shader = preload("res://src/client/foliage.gdshader")

## The reflection across the x = z plane — the world-space form of MapData's (x, y) → (y, x) field
## mirror, swapping the X and Z axes. Applied to the side batch to build team 1's half from team
## 0's, so the decorated halves are exact reflections and neither team gets more cover.
const MIRROR := Transform3D(Vector3(0, 0, 1), Vector3(0, 1, 0), Vector3(1, 0, 0), Vector3.ZERO)

## A fixed seed so the scatter is identical every launch — the map is a designed place, not a
## fresh roll each match, and a reproducible layout is reviewable.
const SCATTER_SEED := 0x7E51A9

## How far inside the bounds the scatter keeps, leaving the rim to the mountain wall.
const FIELD_INSET := 600.0

## Lane / river / camp / structure clearances: no object lands within these of the named
## feature, so the playfield stays legible and nothing clips a unit's path or a building.
const LANE_HALF := 115.0  # mirrors MapView.LANE_WIDTH * 0.5 (the drawn path half-width)
const RIVER_HALF := 165.0  # mirrors MapView.RIVER_WIDTH * 0.5
const LANE_PLANT_MIN := LANE_HALF + 40.0
const RIVER_PLANT_MIN := RIVER_HALF + 110.0
const CAMP_CLEAR := 250.0
const TOWER_CLEAR := 360.0
const NEXUS_CLEAR := 620.0
const SPAWN_CLEAR := 380.0

## Jungle density falloff: a plant is rejected near a lane and reaches full density only deep in
## the jungle, so the growth thins toward the travelled lanes.
const JUNGLE_FULL := 1050.0

## Jungle walls (LoL-style paths): a boulder is drawn on every blocker point MapData lays down —
## the lane-flanking walls (broken by the gank gaps) and the camp pockets — so the rocks you see
## are exactly the rocks that block. The layout, its offsets, gaps, and the y = x mirror all live in
## MapData (the shared collision source); this file only dresses each point. Each visual boulder is
## sized a little larger than its collision footprint (MapData.WALL_RADIUS) so the solid circle
## always sits within the rock a player sees.
const BLOCKER_ROCK_MIN := 120.0  # ≥ MapData.WALL_RADIUS + the jitter below, so the rock covers it
const BLOCKER_ROCK_MAX := 175.0
const BLOCKER_JITTER := 20.0

## The central landmark: a wide low mound at the map centre crowned by one grand tree and ringed
## with standing stones, so the middle of the symmetric map is unmistakable. Its footprint is
## kept clear of other scatter.
const CENTER_RADIUS := 460.0

# --- palette (baked into the meshes as per-vertex colour) ----------------------------------
const ROCK_LOW := Color(0.26, 0.30, 0.25)  # mossy base of a peak / boulder
const ROCK_HIGH := Color(0.49, 0.50, 0.52)  # bare upper rock
const ROCK_CAP := Color(0.74, 0.76, 0.79)  # light, weathered crown
const WOOD_LOW := Color(0.25, 0.17, 0.10)
const WOOD_HIGH := Color(0.34, 0.24, 0.15)
const LEAF_LOW := Color(0.12, 0.33, 0.15)
const LEAF_HIGH := Color(0.27, 0.53, 0.25)
const FROND_LOW := Color(0.18, 0.40, 0.18)
const FROND_TIP := Color(0.42, 0.60, 0.27)  # sun-caught leaf edge
const GRASS_LOW := Color(0.13, 0.30, 0.12)  # the ground shader's low green — hills read as turf
const GRASS_HIGH := Color(0.22, 0.45, 0.18)  # the ground shader's high green
const MUSH_STEM := Color(0.86, 0.84, 0.74)
const MUSH_CAP := Color(0.62, 0.26, 0.20)
const TOTEM_WOOD := Color(0.31, 0.20, 0.12)
const TOTEM_CARVE := Color(0.58, 0.43, 0.20)
const BANNER_CLOTH := Color(0.64, 0.21, 0.17)


## The rolling hills as a samplable height field: each terrain dome recorded as its field-space
## centre, horizontal radius, and peak height, so a unit's render Y can ride the surface instead of
## clipping through it. The dome is an exact half-ellipsoid (see `_dome`), so the surface height at
## a horizontal distance d from a centre is h·√(1 − (d/r)²). Rebuilt each `build()`; the mirrored
## side swells are recorded on both halves so a hill lifts a unit identically on either team's side.
static var _hills: Array[Dictionary] = []


## The terrain height at a field point: the tallest hill surface over it, or 0 on the flat. Cheap —
## a distance check per recorded dome, called per unit per frame. The sim stays flat (collision and
## pathing are 2D); this only lifts the 3D view so a unit walks over a mound rather than through it.
static func height_at(xz: Vector2) -> float:
	var y := 0.0
	for hill in _hills:
		var r := float(hill["r"])
		var d := xz.distance_to(hill["pos"])
		if d >= r:
			continue
		var t := d / r
		var e := float(hill["h"]) * sqrt(1.0 - t * t)
		if e > y:
			y = e
	return y


## Builds every map object under `parent`, batched into a few meshes. Call once, after the ground
## plane and MapView decor exist. Returns the FADE material so the caller can feed it the hero's
## position each frame (the canopy fade); the solid material never fades.
##
## Two axes of batching. By mirror: `axis` batches hold the self-symmetric decor on the mirror
## line (central marker, midline ridge, on-axis camps), drawn once; `side` batches hold the random
## decor, generated only on team 0's half and drawn again through a reflection across the x = z
## plane (MapData's field mirror), so team 1's half is an exact reflection — neither team gets more
## cover. By material: `solid` decor (mountains, rocks, walls, hills, camps, low cover) never fades;
## only the tall `fade` canopy (palms and trees) dissolves over the player's hero so it stays seen.
static func build(parent: Node3D) -> ShaderMaterial:
	_hills.clear()  # rebuilt below as the terrain domes are laid, so height_at() reflects this map
	var rng := RandomNumberGenerator.new()
	rng.seed = SCATTER_SEED
	var solid_mat := _material()
	var fade_mat := _material()
	var solid_axis := _new_surface()
	var solid_side := _new_surface()
	var fade_axis := _new_surface()
	var fade_side := _new_surface()
	_build_wall(solid_side, fade_side, rng)
	_build_terrain(solid_axis, solid_side, fade_axis, rng)
	_build_blockers(solid_axis, rng)
	_build_plants(solid_side, fade_side, rng)
	_build_camps(solid_axis, solid_side, rng)
	_emit(parent, solid_mat, solid_axis, false)
	_emit(parent, solid_mat, solid_side, false)
	_emit(parent, solid_mat, solid_side, true)
	_emit(parent, fade_mat, fade_axis, false)
	_emit(parent, fade_mat, fade_side, false)
	_emit(parent, fade_mat, fade_side, true)
	return fade_mat


## Commits a SurfaceTool batch as a MeshInstance3D wearing the shared material — mirrored across
## the y = x axis when `mirror` is set, so one generated half yields both. Drops an empty batch.
static func _emit(parent: Node3D, material: Material, st: SurfaceTool, mirror: bool) -> void:
	var mesh := st.commit()
	if mesh.get_surface_count() == 0:
		return
	var inst := MeshInstance3D.new()
	inst.mesh = mesh
	inst.material_override = material
	if mirror:
		inst.transform = MIRROR
	parent.add_child(inst)


static func _material() -> ShaderMaterial:
	var mat := ShaderMaterial.new()
	mat.shader = FOLIAGE_SHADER
	return mat


# === boundary wall =========================================================================

## The mountain wall: a ridge of overlapping faceted peaks ringing the bounds, two staggered
## rows deep for silhouette, with the occasional tree breaking the rock so the rim reads as a
## forested mountain edge rather than a clean rampart. Only team 0's half is built here (peaks on
## its side of the y = x axis); the caller's mirror reflects it onto team 1's rim.
static func _build_wall(st: SurfaceTool, fade: SurfaceTool, rng: RandomNumberGenerator) -> void:
	var b := MapData.BOUNDS
	var corners := [
		[Vector2(b.position.x, b.position.y), Vector2(b.end.x, b.position.y)],
		[Vector2(b.end.x, b.position.y), Vector2(b.end.x, b.end.y)],
		[Vector2(b.end.x, b.end.y), Vector2(b.position.x, b.end.y)],
		[Vector2(b.position.x, b.end.y), Vector2(b.position.x, b.position.y)],
	]
	var spacing := 540.0
	for edge in corners:
		var a: Vector2 = edge[0]
		var c: Vector2 = edge[1]
		var length := a.distance_to(c)
		var dir := (c - a) / length
		var inward := Vector2(-dir.y, dir.x)
		if inward.dot(-a) < 0.0:  # point the inward normal toward the map centre
			inward = -inward
		var steps := int(length / spacing)
		for i in steps:
			var t := (float(i) + 0.5) / float(steps)
			var base := a + dir * (length * t)
			if base.y < base.x:  # team 1's half — left to the mirror
				continue
			# back row: tall peaks sitting on the rim, pushed a touch outward
			var back := base - inward * rng.randf_range(40.0, 160.0)
			_mountain(st, _w(back), rng.randf_range(560.0, 800.0), rng.randf_range(1000.0, 1650.0), rng)
			# front row: shorter peaks pulled inboard, with trees mixed in
			var front := base + inward * rng.randf_range(220.0, 460.0) + dir * rng.randf_range(-160.0, 160.0)
			if rng.randf() < 0.24:
				_tree(fade, _w(front), rng.randf_range(0.9, 1.3), rng)
			else:
				_mountain(st, _w(front), rng.randf_range(360.0, 560.0), rng.randf_range(540.0, 980.0), rng)


# === terrain: hills + central landmark =====================================================

## The rolling relief and the central marker. The central marker and the midline ridge sit on the
## mirror axis and go in the `axis` batch (drawn once); the scattered swells go in the `side`
## batch (team 0's half, mirrored by the caller). Swells are wide, very low grass mounds kept off
## lanes, river, camps, and structures so they never deform a path or lift a building.
static func _build_terrain(
	axis: SurfaceTool, side: SurfaceTool, fade_axis: SurfaceTool, rng: RandomNumberGenerator
) -> void:
	# central landmark: a low mound crowned by a grand tree and ringed with standing stones
	_dome(axis, _w(Vector2.ZERO), CENTER_RADIUS, 18.0, 3, 9, GRASS_LOW, GRASS_HIGH, 0.05, rng)
	_hills.append({"pos": Vector2.ZERO, "r": CENTER_RADIUS, "h": 18.0})
	_tree(fade_axis, Vector3(0.0, 14.0, 0.0), 1.9, rng)  # the grand central tree, on the mound
	var stones := 6
	for i in stones:
		var a := TAU * float(i) / float(stones)
		var p := Vector2(cos(a), sin(a)) * 300.0
		var top := _w(p) + Vector3(0.0, 11.0, 0.0)
		_rock(axis, top, rng.randf_range(70.0, 110.0), rng.randf_range(120.0, 200.0), rng)
	_build_midline_ridge(axis, rng)
	# scattered swells — sparse, low mounds so the ground rolls a little. A unit's view rides the
	# surface (height_at, applied client-side), so a mound lifts a hero over it rather than swallowing
	# it; the swells are still kept off lanes, river, and structures so a wide body never spills onto
	# a travelled path or a building, and recorded into the height field on both mirror halves.
	var step := 1500.0
	var span := MapData.BOUNDS.size.x * 0.5 - FIELD_INSET
	var x := -span
	while x <= span:
		var z := -span
		while z <= span:
			var p := Vector2(x, z) + Vector2(rng.randf_range(-380.0, 380.0), rng.randf_range(-380.0, 380.0))
			z += step
			if p.y < p.x:  # team 1's half — the mirror fills it
				continue
			var radius := rng.randf_range(440.0, 720.0)
			if _near_lane(p) < radius + LANE_HALF + 150.0:
				continue
			if _near_river(p) < radius + RIVER_HALF + 150.0:
				continue
			if _blocked(p, radius + 220.0, radius) or p.length() < CENTER_RADIUS + radius:
				continue
			var height := rng.randf_range(5.0, 11.0)
			_dome(side, _w(p), radius, height, 3, 8, GRASS_LOW, GRASS_HIGH, 0.08, rng)
			_hills.append({"pos": p, "r": radius, "h": height})
			_hills.append({"pos": Vector2(p.y, p.x), "r": radius, "h": height})  # the x = z mirror
		x += step


## The midline marker: a long low ridge of overlapping mounds running the mirror axis (y = x),
## the line halfway between the two bases, so the middle of the symmetric map reads at a glance.
## Broken where it would cross a lane or the river — gaps at the crossings — so the ridge never
## blocks a travelled path or dams the water. A touch taller than the ambient swells so it reads
## as a deliberate landform, but still low enough not to swallow a unit crossing the quiet mid.
static func _build_midline_ridge(st: SurfaceTool, rng: RandomNumberGenerator) -> void:
	var radius := 360.0
	var span := MapData.BOUNDS.size.x * 0.5 - FIELD_INSET
	var s := -span
	while s <= span:
		var p := Vector2(s, s)  # every point on the mirror axis is (s, s)
		s += 300.0
		if _near_lane(p) < radius + LANE_HALF + 90.0:
			continue
		if _near_river(p) < radius + RIVER_HALF + 90.0:
			continue
		if _blocked(p, radius + 160.0, radius):
			continue
		var rr := radius * rng.randf_range(0.85, 1.05)
		var hh := rng.randf_range(14.0, 22.0)
		_dome(st, _w(p), rr, hh, 3, 8, GRASS_LOW, GRASS_HIGH, 0.10, rng)
		_hills.append({"pos": p, "r": rr, "h": hh})


# === scattered jungle growth ===============================================================

## The general jungle objects: a dense jittered grid of plants over the open ground, each kind
## picked at random — leafy bushes, tall shrubs, ferns, grass tufts, palms, mossy rocks, and
## mushroom clusters — so the field reads as thick jungle. Only team 0's half is grown; the
## caller's mirror reflects it. Density still follows distance from the nearest lane (sparser
## beside a travelled lane, thicker deep in the jungle) but never thins to bare ground.
static func _build_plants(st: SurfaceTool, fade: SurfaceTool, rng: RandomNumberGenerator) -> void:
	var step := 360.0
	var span := MapData.BOUNDS.size.x * 0.5 - FIELD_INSET
	var x := -span
	while x <= span:
		var z := -span
		while z <= span:
			var p := Vector2(x, z) + Vector2(rng.randf_range(-160.0, 160.0), rng.randf_range(-160.0, 160.0))
			z += step
			if p.y < p.x:  # team 1's half — the mirror fills it
				continue
			var d_lane := _near_lane(p)
			if d_lane < LANE_PLANT_MIN or _near_river(p) < RIVER_PLANT_MIN:
				continue
			if _blocked(p, CAMP_CLEAR) or p.length() < CENTER_RADIUS:
				continue
			var density := 0.22 + 0.5 * smoothstep(LANE_PLANT_MIN, JUNGLE_FULL, d_lane)
			if rng.randf() > density:
				continue
			_plant(st, fade, p, rng)
		x += step


## Places one random jungle plant. The tall canopy (palms, broadleafs) goes in the `fade` batch so
## it dissolves over the hero; the low cover and the odd boulder stay solid in `st`.
static func _plant(
	st: SurfaceTool, fade: SurfaceTool, p: Vector2, rng: RandomNumberGenerator
) -> void:
	var roll := rng.randf()
	var foot := _w(p)
	if roll < 0.18:
		_grass(st, foot, rng)
	elif roll < 0.33:
		_fern(st, foot, rng)
	elif roll < 0.47:
		_bush(st, foot, rng.randf_range(70.0, 120.0), rng.randf_range(60.0, 110.0), rng)
	elif roll < 0.55:
		_shrub(st, foot, rng)
	elif roll < 0.83:
		_palm(fade, foot, rng)  # palms — the bulk of the jungle canopy
	elif roll < 0.90:
		_tree(fade, foot, rng.randf_range(0.8, 1.2), rng)  # a few broadleafs for variety
	elif roll < 0.96:
		_mushrooms(st, foot, rng)
	else:
		_rock(st, foot, rng.randf_range(60.0, 130.0), rng.randf_range(70.0, 160.0), rng)


# === neutral camps =========================================================================

## Dresses each jungle camp with a ring of boulders around a carved totem flying a small banner,
## so a neutral camp reads as a claimed place rather than a flat disc on the ground. A camp on the
## mirror axis is dressed once into the `axis` batch; a camp on team 0's side goes in the `side`
## batch (the mirror dresses its team 1 partner); a camp on team 1's side is left to the mirror.
static func _build_camps(axis: SurfaceTool, side: SurfaceTool, rng: RandomNumberGenerator) -> void:
	for camp in MapData.JUNGLE_CAMPS:
		if camp.y < camp.x - 1.0:  # team 1's side — the mirror fills it
			continue
		var st := axis if absf(camp.y - camp.x) < 1.0 else side
		var rocks := 5
		var rot := rng.randf() * TAU
		for i in rocks:
			var a := rot + TAU * float(i) / float(rocks)
			var p := camp + Vector2(cos(a), sin(a)) * rng.randf_range(150.0, 195.0)
			_rock(st, _w(p), rng.randf_range(55.0, 95.0), rng.randf_range(70.0, 140.0), rng)
		_totem(st, _w(camp), rng)


## A carved totem post: stacked wood blocks on a base, a bright carved cap, and a banner cloth
## hung from one side. Faces the totem at a random yaw so no two camps line up.
static func _totem(st: SurfaceTool, foot: Vector3, rng: RandomNumberGenerator) -> void:
	var yaw := rng.randf() * TAU
	var post_h := 210.0
	_box(st, foot + Vector3(0.0, post_h * 0.5, 0.0), Vector3(46.0, post_h, 46.0), yaw, TOTEM_WOOD)
	_box(st, foot + Vector3(0.0, post_h + 18.0, 0.0), Vector3(78.0, 36.0, 78.0), yaw, TOTEM_CARVE)
	# a cross-arm the banner hangs from
	_box(st, foot + Vector3(0.0, post_h * 0.62, 0.0), Vector3(120.0, 30.0, 30.0), yaw, TOTEM_WOOD)
	var banner := foot + Vector3(cos(yaw), 0.0, sin(yaw)) * 64.0 + Vector3(0.0, post_h * 0.40, 0.0)
	_box(st, banner, Vector3(8.0, 110.0, 64.0), yaw, BANNER_CLOTH)


# === jungle walls (LoL-style paths) ========================================================

## Draws a boulder on every blocker point MapData lays down — the lane-flanking walls (with their
## gank gaps) and the camp pockets, both halves already mirrored in the data. Drawn once onto the
## non-mirrored `axis` batch (the points carry their own y = x partners), so what renders matches
## the collision set one-for-one. Each rock is sized a little over MapData.WALL_RADIUS, with only a
## small jitter, so the solid circle always sits inside the boulder a player sees.
static func _build_blockers(st: SurfaceTool, rng: RandomNumberGenerator) -> void:
	for p in MapData.jungle_wall_points():
		var jitter := Vector2(
			rng.randf_range(-BLOCKER_JITTER, BLOCKER_JITTER),
			rng.randf_range(-BLOCKER_JITTER, BLOCKER_JITTER),
		)
		_rock(
			st,
			_w(p + jitter),
			rng.randf_range(BLOCKER_ROCK_MIN, BLOCKER_ROCK_MAX),
			rng.randf_range(160.0, 260.0),
			rng,
		)


# === plant builders ========================================================================

## A faceted mountain peak: a two-ring cone with the base radius and apex jittered into a craggy
## silhouette, mossy at the foot, bare rock up the flank, a light weathered crown.
static func _mountain(
	st: SurfaceTool, foot: Vector3, radius: float, height: float, rng: RandomNumberGenerator
) -> void:
	var seg := 8
	var yaw := rng.randf() * TAU
	var center := foot + Vector3(0.0, height * 0.4, 0.0)
	var skew := radius * 0.3
	var apex := foot + Vector3((rng.randf() - 0.5) * skew, height, (rng.randf() - 0.5) * skew)
	var low: Array = []
	var mid: Array = []
	for i in seg:
		var a := yaw + TAU * float(i) / float(seg)
		var rl := radius * rng.randf_range(0.82, 1.12)
		var rm := radius * rng.randf_range(0.45, 0.62)
		low.append(foot + Vector3(cos(a) * rl, 0.0, sin(a) * rl))
		mid.append(foot + Vector3(cos(a) * rm, height * rng.randf_range(0.45, 0.60), sin(a) * rm))
	for i in seg:
		var j := (i + 1) % seg
		_quad_out(st, low[i], low[j], mid[j], mid[i], center, ROCK_LOW, ROCK_LOW, ROCK_HIGH, ROCK_HIGH)
		_face_out(st, mid[i], mid[j], apex, center, ROCK_HIGH, ROCK_HIGH, ROCK_CAP)
		_face_out(st, foot, low[j], low[i], foot, ROCK_LOW, ROCK_LOW, ROCK_LOW)  # base skirt


## A low-poly tree: a tapered trunk and two stacked canopy cones, green fading to a sun-tipped
## crown, hue-jittered per tree so a stand does not look stamped.
static func _tree(st: SurfaceTool, foot: Vector3, scale: float, rng: RandomNumberGenerator) -> void:
	var trunk_h := 90.0 * scale
	var trunk_r := 20.0 * scale
	_cone(st, foot, trunk_r * 1.2, trunk_h, 6, rng.randf() * TAU, WOOD_LOW, WOOD_HIGH, false)
	var hue := rng.randf_range(-0.04, 0.04)
	var low := Color(LEAF_LOW.r, clampf(LEAF_LOW.g + hue, 0.0, 1.0), LEAF_LOW.b)
	var high := Color(LEAF_HIGH.r, clampf(LEAF_HIGH.g + hue, 0.0, 1.0), LEAF_HIGH.b)
	var c1 := foot + Vector3(0.0, trunk_h * 0.7, 0.0)
	_cone(st, c1, 150.0 * scale, 165.0 * scale, 7, rng.randf() * TAU, low, high, true)
	var c2 := c1 + Vector3(0.0, 110.0 * scale, 0.0)
	_cone(st, c2, 100.0 * scale, 150.0 * scale, 7, rng.randf() * TAU, low, high, true)


## A tall jungle shrub: a stack of two leafy domes, broader at the base.
static func _shrub(st: SurfaceTool, foot: Vector3, rng: RandomNumberGenerator) -> void:
	var r := rng.randf_range(95.0, 140.0)
	_dome(st, foot, r, r * 0.9, 3, 7, LEAF_LOW, LEAF_HIGH, 0.18, rng)
	var top := foot + Vector3(0.0, r * 0.55, 0.0)
	_dome(st, top, r * 0.66, r * 0.8, 2, 7, LEAF_LOW, LEAF_HIGH, 0.18, rng)


## A leafy bush: one jittered dome of foliage.
static func _bush(
	st: SurfaceTool, foot: Vector3, radius: float, height: float, rng: RandomNumberGenerator
) -> void:
	_dome(st, foot, radius, height, 2, 7, LEAF_LOW, LEAF_HIGH, 0.22, rng)


## A tropical fern: a low crown of broad blades fanning out and up from the base.
static func _fern(st: SurfaceTool, foot: Vector3, rng: RandomNumberGenerator) -> void:
	var fronds := 7
	var rot := rng.randf() * TAU
	for i in fronds:
		var a := rot + TAU * float(i) / float(fronds) + rng.randf_range(-0.18, 0.18)
		var dir := Vector2(cos(a), sin(a))
		var length := rng.randf_range(95.0, 150.0)
		_blade(st, foot, dir, length, length * 0.55, 36.0, FROND_LOW, FROND_TIP)


## A grass tuft: a few near-vertical thin blades clustered at the base.
static func _grass(st: SurfaceTool, foot: Vector3, rng: RandomNumberGenerator) -> void:
	var blades := 5
	for i in blades:
		var a := rng.randf() * TAU
		var dir := Vector2(cos(a), sin(a))
		var length := rng.randf_range(45.0, 85.0)
		_blade(st, foot, dir, length * 0.35, 14.0, length, GRASS_HIGH, FROND_TIP)


## A palm: a tall thin trunk and a crown of long fronds arcing down from the top.
static func _palm(st: SurfaceTool, foot: Vector3, rng: RandomNumberGenerator) -> void:
	var h := rng.randf_range(300.0, 420.0)
	_cone(st, foot, 20.0, h, 6, rng.randf() * TAU, WOOD_LOW, WOOD_HIGH, false)
	var crown := foot + Vector3(0.0, h, 0.0)
	var fronds := 7
	var rot := rng.randf() * TAU
	for i in fronds:
		var a := rot + TAU * float(i) / float(fronds)
		var dir := Vector2(cos(a), sin(a))
		_blade(st, crown, dir, 180.0, 70.0, -50.0, FROND_LOW, FROND_TIP)


## A mossy boulder: a squat jittered dome of rock.
static func _rock(
	st: SurfaceTool, foot: Vector3, radius: float, height: float, rng: RandomNumberGenerator
) -> void:
	_dome(st, foot, radius, height, 2, 6, ROCK_LOW, ROCK_HIGH, 0.18, rng)


## A small mushroom cluster: a handful of pale stems under red-brown caps.
static func _mushrooms(st: SurfaceTool, foot: Vector3, rng: RandomNumberGenerator) -> void:
	var count := 3
	for i in count:
		var off := Vector3(rng.randf_range(-45.0, 45.0), 0.0, rng.randf_range(-45.0, 45.0))
		var stem := foot + off
		var h := rng.randf_range(28.0, 52.0)
		var r := rng.randf_range(9.0, 16.0)
		_cone(st, stem, r, h, 5, 0.0, MUSH_STEM, MUSH_STEM, false)
		_dome(st, stem + Vector3(0.0, h, 0.0), r * 2.4, r * 1.5, 2, 6, MUSH_CAP, MUSH_CAP, 0.05, rng)


# === geometry primitives ===================================================================

## A cone from `base` upward: `seg` side facets to the apex, plus a base skirt. `cap_tip` colours
## the apex with `c_high`; otherwise the whole cone runs base→tip in the two colours by height.
static func _cone(
	st: SurfaceTool, base: Vector3, radius: float, height: float, seg: int, yaw: float,
	c_low: Color, c_high: Color, _cap_tip: bool
) -> void:
	var apex := base + Vector3(0.0, height, 0.0)
	var center := base + Vector3(0.0, height * 0.5, 0.0)
	var ring: Array = []
	for i in seg:
		var a := yaw + TAU * float(i) / float(seg)
		ring.append(base + Vector3(cos(a) * radius, 0.0, sin(a) * radius))
	for i in seg:
		var j := (i + 1) % seg
		_face_out(st, ring[i], ring[j], apex, center, c_low, c_low, c_high)
		_face_out(st, base, ring[j], ring[i], base, c_low, c_low, c_low)


## A half-ellipsoid dome from `base`, `rings` high by `seg` around, the side radius optionally
## jittered for a craggy or leafy irregular surface. Colours run base→apex.
static func _dome(
	st: SurfaceTool, base: Vector3, radius: float, height: float, rings: int, seg: int,
	c_low: Color, c_high: Color, jitter: float, rng: RandomNumberGenerator
) -> void:
	var center := base + Vector3(0.0, height * 0.45, 0.0)
	var grid: Array = []
	for r_i in rings:
		var t := float(r_i) / float(rings)
		var ph := t * PI * 0.5
		var rr := cos(ph) * radius
		var yy := sin(ph) * height
		var row: Array = []
		for s in seg:
			var a := TAU * float(s) / float(seg)
			var jr := 1.0 + (rng.randf() - 0.5) * jitter if r_i > 0 else 1.0
			row.append(base + Vector3(cos(a) * rr * jr, yy, sin(a) * rr * jr))
		grid.append(row)
	var apex := base + Vector3(0.0, height, 0.0)
	for r_i in rings:
		var ca := c_low.lerp(c_high, float(r_i) / float(rings))
		var cb := c_low.lerp(c_high, float(r_i + 1) / float(rings))
		for s in seg:
			var s2 := (s + 1) % seg
			var lo: Array = grid[r_i]
			if r_i == rings - 1:
				_face_out(st, lo[s], lo[s2], apex, center, ca, ca, c_high)
			else:
				var hi: Array = grid[r_i + 1]
				_quad_out(st, lo[s], lo[s2], hi[s2], hi[s], center, ca, ca, cb, cb)


## A single tapered blade (frond / grass / leaf): a triangle from a base width to a tip lifted
## `lift` above the base and pushed `length` along `dir`. Two-sided via the shader's disabled
## cull, so it lights from either face.
static func _blade(
	st: SurfaceTool, base: Vector3, dir: Vector2, length: float, width: float, lift: float,
	c_low: Color, c_high: Color
) -> void:
	var tip := base + Vector3(dir.x * length, lift, dir.y * length)
	var side := Vector3(-dir.y, 0.0, dir.x) * (width * 0.5)
	_face(st, base - side, base + side, tip, c_low, c_low, c_high)


## An axis-aligned box rotated `yaw` about Y at `center`, flat-coloured. Built from its eight
## corners with outward-oriented faces, so winding never matters.
static func _box(st: SurfaceTool, center: Vector3, size: Vector3, yaw: float, col: Color) -> void:
	var hx := size.x * 0.5
	var hy := size.y * 0.5
	var hz := size.z * 0.5
	var cs := cos(yaw)
	var sn := sin(yaw)
	var c: Array = []
	var signs := [-1.0, 1.0]
	for iy in 2:
		var sy: float = signs[iy]
		for ix in 2:
			var sx: float = signs[ix]
			for iz in 2:
				var sz: float = signs[iz]
				var lx := sx * hx
				var lz := sz * hz
				c.append(center + Vector3(lx * cs - lz * sn, sy * hy, lx * sn + lz * cs))
	# index = (sy>0)<<2 | (sx>0)<<1 | (sz>0)
	_quad_out(st, c[0], c[1], c[3], c[2], center, col, col, col, col)  # bottom
	_quad_out(st, c[4], c[5], c[7], c[6], center, col, col, col, col)  # top
	_quad_out(st, c[0], c[1], c[5], c[4], center, col, col, col, col)
	_quad_out(st, c[2], c[3], c[7], c[6], center, col, col, col, col)
	_quad_out(st, c[0], c[2], c[6], c[4], center, col, col, col, col)
	_quad_out(st, c[1], c[3], c[7], c[5], center, col, col, col, col)


# === surface plumbing ======================================================================

static func _new_surface() -> SurfaceTool:
	var st := SurfaceTool.new()
	st.begin(Mesh.PRIMITIVE_TRIANGLES)
	return st


## Emits a flat-shaded triangle with a single face normal and per-vertex colours. Used for thin
## two-sided blades where there is no meaningful "outward".
static func _face(
	st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, ca: Color, cb: Color, cc: Color
) -> void:
	var n := (b - a).cross(c - a)
	if n.length_squared() < 0.0001:
		return
	n = n.normalized()
	st.set_color(ca)
	st.set_normal(n)
	st.add_vertex(a)
	st.set_color(cb)
	st.set_normal(n)
	st.add_vertex(b)
	st.set_color(cc)
	st.set_normal(n)
	st.add_vertex(c)


## Like `_face`, but flips the normal to point away from `pivot` (the object's centre), so a
## solid's facets are lit from outside regardless of vertex winding.
static func _face_out(
	st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, pivot: Vector3,
	ca: Color, cb: Color, cc: Color
) -> void:
	var n := (b - a).cross(c - a)
	if n.length_squared() < 0.0001:
		return
	n = n.normalized()
	if n.dot((a + b + c) / 3.0 - pivot) < 0.0:
		n = -n
	st.set_color(ca)
	st.set_normal(n)
	st.add_vertex(a)
	st.set_color(cb)
	st.set_normal(n)
	st.add_vertex(b)
	st.set_color(cc)
	st.set_normal(n)
	st.add_vertex(c)


## Two outward-facing triangles making a quad a→b→c→d.
static func _quad_out(
	st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, d: Vector3, pivot: Vector3,
	ca: Color, cb: Color, cc: Color, cd: Color
) -> void:
	_face_out(st, a, b, c, pivot, ca, cb, cc)
	_face_out(st, a, c, d, pivot, ca, cc, cd)


# === placement helpers =====================================================================

## Lifts a field point to world space, on the ground (y = 0). The decor sits on the plane;
## MapView's flat strips ride a hair above it.
static func _w(p: Vector2) -> Vector3:
	return Vector3(p.x, 0.0, p.y)


## Distance from a field point to the nearest lane corridor centreline.
static func _near_lane(p: Vector2) -> float:
	var best := INF
	for lane in MapData.LANES:
		best = minf(best, _dist_to_polyline(p, lane))
	return best


## Distance from a field point to the river course.
static func _near_river(p: Vector2) -> float:
	return _dist_to_polyline(p, MapData.RIVER)


## Whether a field point falls inside the clearance of any structure or camp — a hard keep-out
## the scatter must skip. `camp_clear` lets hills hold farther off a camp than plants do; `pad`
## widens the tower/nexus/spawn clearances by an object's own radius, so a wide hill's body never
## reaches a building, not just its centre.
static func _blocked(p: Vector2, camp_clear: float, pad := 0.0) -> bool:
	for camp in MapData.JUNGLE_CAMPS:
		if p.distance_to(camp) < camp_clear:
			return true
	for slot in MapData.TOWER_SLOTS:
		if p.distance_to(slot) < TOWER_CLEAR + pad:
			return true
	for team in 2:
		if p.distance_to(MapData.nexus_for_team(team)) < NEXUS_CLEAR + pad:
			return true
		if p.distance_to(MapData.spawn_for_team(team)) < SPAWN_CLEAR + pad:
			return true
	return false


## Shortest distance from `p` to a polyline, the minimum over its segments.
static func _dist_to_polyline(p: Vector2, pts: Array) -> float:
	var best := INF
	for i in pts.size() - 1:
		var closest := Geometry2D.get_closest_point_to_segment(p, pts[i], pts[i + 1])
		best = minf(best, p.distance_to(closest))
	return best