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