class_name MapView extends RefCounted ## The static map decor laid on the ground so the playfield reads — the lanes, the river, ## and the jungle camps — kept out of the match presenter so `main.gd` stays the driver and ## this stays the map painter. ## ## Every shape is read straight from MapData, the one geometry source the sim, the bots, and ## the tests already share, so the drawn map cannot drift from the simulated one. Pure ## presentation: flat coloured strips and discs lifted a hair above the ground to clear ## z-fighting, drawn once when the scene is built. ## The lift above the ground (y = 0) every flat decor piece sits at, so a painted strip does ## not z-fight the ground plane it lies on. const DECOR_Y := 2.0 ## Lanes: a trampled dirt path of this width tracing each lane corridor, drawn with the ## shared path shader (PATH_SHADER — dappled worn earth, edges frayed into the grass) so a ## lane reads as a footpath beaten through the jungle rather than a flat sandy strip. const LANE_WIDTH := 230.0 const PATH_SHADER: Shader = preload("res://src/client/path.gdshader") ## River: a wide watercourse tracing the river polyline, drawn with the shared water shader ## (WATER_SHADER — drifting toon water, shallow banks frayed into the grass) so it reads as a ## stylised river rather than a flat blue strip. Wider than a lane, and lifted a hair over the ## lanes so a lane crossing reads as water-over-path without z-fighting. const RIVER_WIDTH := 330.0 const WATER_SHADER: Shader = preload("res://src/client/water.gdshader") ## River course shaping, so the watercourse reads as a natural river rather than a kinked ## polyline: the stored points are Catmull-Rom subdivided into a smooth rounded curve, then a ## gentle sideways meander is layered on (windowed to zero at the map-edge ends) so even the ## straight stretches wander a little. Amplitude in world units; waves over the whole course. const RIVER_SUBDIV := 10 const RIVER_MEANDER_AMP := 90.0 const RIVER_MEANDER_WAVES := 5.0 ## Jungle camp: a flat disc marker on the ground. const CAMP_RADIUS := 95.0 const CAMP_COLOR := Color(0.28, 0.40, 0.30) ## Bridge: a flat wooden plank deck carried over the river wherever a lane crosses it, so a ## lane reads as crossing on a footbridge rather than wading the water. A bare deck of cross ## planks — no rails — yawed to the lane's heading and cel-shaded wood (the unit shader, no ## team tint) so it sits in the same toon family as everything else, laid just over the water. const CEL_SHADER: Shader = preload("res://src/client/cel.gdshader") const BRIDGE_WOOD := Color(0.36, 0.24, 0.13) const BRIDGE_WIDTH := 280.0 # across the lane, a touch wider than the path it carries const BRIDGE_SPAN := 560.0 # along the lane, long enough to clear the wide river at an angle const BRIDGE_DECK_Y := 4.0 # deck height — flat, just over the water so the lane crosses it const PLANK_DEPTH := 40.0 # each cross plank's size along the lane const PLANK_GAP := 12.0 # bare gap between planks const PLANK_THICK := 7.0 ## Paints the whole static map under `parent`: each lane as a trampled dirt ribbon, the river ## as a wider water ribbon over them, a flat plank bridge at each lane–river crossing, and a ## disc at every jungle camp. Drawn in that order so the river layers over the lanes and the ## bridges over the river. Call once, after the ground plane exists. static func build(parent: Node3D) -> void: var path_material := _path_material() for lane in MapData.lane_count(): _build_ribbon_mesh(parent, MapData.lane_path(lane, 0), LANE_WIDTH, path_material) var river := _meander( _smooth_polyline(MapData.river_polyline(), RIVER_SUBDIV), RIVER_MEANDER_AMP, RIVER_MEANDER_WAVES, ) _build_ribbon_mesh(parent, river, RIVER_WIDTH, _water_material(), DECOR_Y + 1.0) _lay_bridges(parent) for camp in MapData.JUNGLE_CAMPS: _mark_disc(parent, camp, CAMP_RADIUS, CAMP_COLOR) ## Builds a ribbon (lane or river) as one continuous flat mesh tracing a polyline, wearing ## `material` at height `y`. Each polyline point gets a left/right vertex offset by the mitred ## normal — the bisector of its two segment directions — so consecutive quads share an edge and ## the ribbon turns each bend without the angular gaps a row of separate boxes leaves. `UV.x` ## carries the across-position (0 left … 1 right) so the path/water shader can shade and fray ## the banks; vertices are baked in world space and the mesh sits at the origin. static func _build_ribbon_mesh( parent: Node3D, points: PackedVector2Array, width: float, material: Material, y := DECOR_Y ) -> void: if points.size() < 2: return var half := width * 0.5 var left: Array[Vector3] = [] var right: Array[Vector3] = [] for i in points.size(): var off := _ribbon_offset(points, i, half) var p := points[i] left.append(Vector3(p.x + off.x, y, p.y + off.y)) right.append(Vector3(p.x - off.x, y, p.y - off.y)) var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) for i in points.size() - 1: _ribbon_vert(st, left[i], 0.0) _ribbon_vert(st, right[i], 1.0) _ribbon_vert(st, left[i + 1], 0.0) _ribbon_vert(st, right[i], 1.0) _ribbon_vert(st, right[i + 1], 1.0) _ribbon_vert(st, left[i + 1], 0.0) var mesh_inst := MeshInstance3D.new() mesh_inst.mesh = st.commit() mesh_inst.material_override = material parent.add_child(mesh_inst) ## Emits one ribbon vertex with its across-position in `UV.x` and an up normal (the ribbon lies ## flat, so the lighting reads it as ground), for the SurfaceTool to assemble. static func _ribbon_vert(st: SurfaceTool, v: Vector3, u: float) -> void: st.set_uv(Vector2(u, 0.0)) st.set_normal(Vector3.UP) st.add_vertex(v) ## The sideways offset (half the width, mitred) from a polyline point `i` to its left edge — ## the bisector of the two adjacent segments' left normals, lengthened so the ribbon keeps its ## width through the bend. Clamped so a sharp turn cannot spike the miter to a long spar. The ## right edge is the point minus this. Endpoints use their single segment normal. static func _ribbon_offset(points: PackedVector2Array, i: int, half: float) -> Vector2: var n_in := Vector2.ZERO var n_out := Vector2.ZERO if i > 0: var d := (points[i] - points[i - 1]).normalized() n_in = Vector2(-d.y, d.x) if i < points.size() - 1: var d := (points[i + 1] - points[i]).normalized() n_out = Vector2(-d.y, d.x) var normal := n_in + n_out if normal.length() < 0.0001: normal = n_out if n_in == Vector2.ZERO else n_in normal = normal.normalized() var reference := n_out if n_in == Vector2.ZERO else n_in var cos_half := maxf(normal.dot(reference), 0.35) # clamp: a sharp bend can't spike the miter return normal * (half / cos_half) ## A polyline rounded into a smooth curve: each stored segment is replaced by `subdivisions` ## Catmull-Rom samples through the points, so the kinks at the corners become gentle bends. The ## end points are held (the spline duplicates them as its phantom neighbours), so the course ## still meets the map edges where it did. Returns the original points unchanged when too short. static func _smooth_polyline(points: PackedVector2Array, subdivisions: int) -> PackedVector2Array: if points.size() < 3 or subdivisions < 2: return points var last := points.size() - 1 var out := PackedVector2Array() for i in last: var p0 := points[maxi(i - 1, 0)] var p1 := points[i] var p2 := points[i + 1] var p3 := points[mini(i + 2, last)] for s in subdivisions: out.append(_catmull_rom(p0, p1, p2, p3, float(s) / float(subdivisions))) out.append(points[last]) return out ## The Catmull-Rom point at `t` in [0, 1] on the segment p1→p2, with p0/p3 the neighbours that ## set the curve's incoming and outgoing tangents. Passes through p1 and p2. static func _catmull_rom(p0: Vector2, p1: Vector2, p2: Vector2, p3: Vector2, t: float) -> Vector2: var t2 := t * t var t3 := t2 * t return 0.5 * ( 2.0 * p1 + (-p0 + p2) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3 ) ## Adds a gentle sideways meander to a course, so even its straight runs wander like a real ## river: each point is pushed along its perpendicular by a sine over the arc length, its ## amplitude windowed by `sin(pi * s)` so the displacement tapers to zero at both ends and the ## course still anchors at the map edges. `amp` is the peak offset (world units), `waves` the ## number of full meanders along the course. Static — computed once when the map is built. static func _meander(points: PackedVector2Array, amp: float, waves: float) -> PackedVector2Array: var count := points.size() if count < 3 or amp <= 0.0: return points var total := 0.0 for i in count - 1: total += points[i].distance_to(points[i + 1]) if total <= 0.0: return points var out := PackedVector2Array() var travelled := 0.0 for i in count: if i > 0: travelled += points[i].distance_to(points[i - 1]) var s := travelled / total var tangent := (points[mini(i + 1, count - 1)] - points[maxi(i - 1, 0)]).normalized() var perpendicular := Vector2(-tangent.y, tangent.x) var offset := amp * sin(PI * s) * sin(s * waves * TAU) out.append(points[i] + perpendicular * offset) return out ## Lays a flat plank bridge at every point a lane crosses the river, sharing one wood material. static func _lay_bridges(parent: Node3D) -> void: var wood := _wood_material() for crossing in _lane_river_crossings(): _build_bridge(parent, crossing["pos"], crossing["dir"], wood) ## Every point a lane corridor crosses the river, each as `{pos, dir}` — the crossing point and ## the lane's heading there, so a bridge can be laid spanning the water along the lane. Found by ## intersecting each lane segment against each river segment, so it tracks the geometry rather ## than hard-coding where the top lane meets the water (it crosses twice). static func _lane_river_crossings() -> Array: var river := MapData.river_polyline() var out: Array = [] for lane in MapData.lane_count(): var path := MapData.lane_path(lane, 0) for i in path.size() - 1: for j in river.size() - 1: var hit = Geometry2D.segment_intersects_segment( path[i], path[i + 1], river[j], river[j + 1] ) if hit != null: out.append({"pos": hit, "dir": (path[i + 1] - path[i]).normalized()}) return out ## Builds a flat plank deck at `pos`, yawed so it runs along `dir` (the lane's heading): a row ## of cross planks spanning BRIDGE_SPAN along the lane at deck height, no rails. The planks are ## laid in the deck's local space, so the whole bridge turns as one with the lane it carries. static func _build_bridge(parent: Node3D, pos: Vector2, dir: Vector2, wood: Material) -> void: var bridge := Node3D.new() bridge.position = Vector3(pos.x, 0.0, pos.y) bridge.rotation.y = atan2(dir.x, dir.y) parent.add_child(bridge) var pitch := PLANK_DEPTH + PLANK_GAP var count := int(BRIDGE_SPAN / pitch) var start := -0.5 * float(count - 1) * pitch for k in count: var plank := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(BRIDGE_WIDTH, PLANK_THICK, PLANK_DEPTH) plank.mesh = box plank.material_override = wood plank.position = Vector3(0.0, BRIDGE_DECK_Y, start + float(k) * pitch) bridge.add_child(plank) ## The cel-shaded wood material the bridge planks share: the unit cel shader at the wood tone ## with no team tint, so the bridge reads in the same toon family. One instance for all planks. static func _wood_material() -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = CEL_SHADER mat.set_shader_parameter("albedo", BRIDGE_WOOD) mat.set_shader_parameter("tint_strength", 0.0) return mat ## Marks a flat disc of `radius` and `color` on the ground at a field point — a jungle camp. static func _mark_disc(parent: Node3D, pos: Vector2, radius: float, color: Color) -> void: var disc := MeshInstance3D.new() var cyl := CylinderMesh.new() cyl.top_radius = radius cyl.bottom_radius = radius cyl.height = 1.0 disc.mesh = cyl disc.material_override = _flat_material(color) disc.position = Vector3(pos.x, DECOR_Y, pos.y) parent.add_child(disc) static func _flat_material(color: Color) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = color return mat ## The trampled dirt-path material the lane ribbons share. One instance for both lanes; the ## shader reads the across-position from the ribbon mesh UVs, so it needs no width told to it. static func _path_material() -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = PATH_SHADER return mat ## The water material the whole river ribbon wears. One instance; the shader reads the ## across-position from the ribbon mesh UVs for its shallow band and bank fray. static func _water_material() -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = WATER_SHADER return mat