Commit
Theria
feat: dirt-path lanes, toon-water river, plank bridges
modified CHANGELOG.md
@@ -55,6 +55,18 @@ protocol version.
the field reads as clumped grass under the models rather than a dead slab. The lane dirt paths,
the river, and the camp markers still lay over it. Presentation only — the simulation and the
netcode protocol are unchanged.
- Field units now read cleanly against the ground with a rim light and a drop shadow: a
hard-edged fresnel rim lights each model's silhouette so it pops off the grass, and a soft blob
shadow sized to the unit's own footprint sits under every hero, creep, and structure so it
stands on the ground rather than floating. Presentation only — the simulation and the netcode
protocol are unchanged.
- The map decor is reworked into the jungle look: the lanes are trampled dirt paths (dappled
worn earth with edges frayed into the grass), and the river is a stylized toon water — a
smoothed, gently meandering watercourse with drifting current bands and shallow banks. Both are
now built as one continuous mitred ribbon mesh per shape rather than a row of separate boxes, so
a winding lane or river turns its bends without angular gaps. A flat wooden plank bridge is laid
wherever a lane crosses the river (the top lane crosses twice), found by intersecting the lane
and river geometry. Presentation only — the simulation and the netcode protocol are unchanged.
- The follow-camera now eases to the hero instead of locking to it 1:1, so a sharp turn or a
respawn glides the view rather than snapping it; and while the hero is gone (dead, or not yet
spawned) the camera rests where the hero last stood instead of jumping to the arena centre.
modified src/client/map_view.gd
@@ -13,53 +13,243 @@ extends RefCounted
## not z-fight the ground plane it lies on.
const DECOR_Y := 2.0
## Lanes: a sandy ribbon of this width tracing each lane corridor.
## 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 LANE_COLOR := Color(0.40, 0.36, 0.25)
const PATH_SHADER: Shader = preload("res://src/client/path.gdshader")
## River: a blue ribbon over the lanes, tracing the watercourse.
const RIVER_WIDTH := 210.0
const RIVER_COLOR := Color(0.17, 0.34, 0.52)
## 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 sandy ribbon, the river as a
## blue ribbon over them, and a disc at every jungle camp. Drawn in that order so the river
## layers over the lanes. Call once, after the ground plane exists.
## 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():
_lay_ribbon(parent, MapData.lane_path(lane, 0), LANE_WIDTH, LANE_COLOR)
_lay_ribbon(parent, MapData.river_polyline(), RIVER_WIDTH, RIVER_COLOR)
_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)
## Lays a flat ribbon of `width` and `color` along a polyline's segments under `parent`, each
## a thin box set on the ground at the segment midpoint and yawed to its heading — so a lane
## or the river reads as a continuous painted strip rather than a row of disconnected marks.
static func _lay_ribbon(
parent: Node3D, points: PackedVector2Array, width: float, color: 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:
var material := _flat_material(color)
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:
var a := points[i]
var b := points[i + 1]
var delta := b - a
var length := delta.length()
if length <= 0.0:
continue
var strip := MeshInstance3D.new()
_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(width, 1.0, length)
strip.mesh = box
strip.material_override = material
var mid := (a + b) * 0.5
strip.position = Vector3(mid.x, DECOR_Y, mid.y)
strip.rotation.y = atan2(delta.x, delta.y)
parent.add_child(strip)
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.
@@ -79,3 +269,19 @@ 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
added src/client/path.gdshader
@@ -0,0 +1,72 @@
shader_type spatial;
render_mode specular_disabled, cull_disabled;
// The trampled dirt path a lane wears — worn earth beaten through the jungle grass rather
// than a flat tan strip. Two dirt tones are dappled into toon patches like the grass, and
// the path's long edges are frayed away with a noise cut so the border breaks into the
// grass irregularly instead of as a clean ruled line. Cel-banded to match every other
// surface. Driven per lane ribbon by MapView, which passes the ribbon's half-width.
// The two dirt tones the worn path dapples between — a dark damp earth and a lighter dust.
uniform vec3 dirt_low : source_color = vec3(0.20, 0.15, 0.10);
uniform vec3 dirt_high : source_color = vec3(0.40, 0.32, 0.20);
// Clump tightness of the dirt dapple (per world unit) and how many flat toon steps it bands
// into — the same treatment the grass takes, so path and grass read as one family.
uniform float patch_scale = 0.006;
uniform float patch_steps = 4.0;
// Edge fray: where across the ribbon (0 centre … 1 edge, read from UV.x) the fray starts, the
// noise frequency that breaks the border up, and how hard the noise bites into it.
uniform float fray_start : hint_range(0.0, 1.0) = 0.62;
uniform float fray_scale = 0.012;
uniform float fray_jitter : hint_range(0.0, 1.0) = 0.55;
// The toon light ramp shared with the units and the grass.
const float MID_TONE = 0.5;
const float LOW_CUT = 0.25;
const float HIGH_CUT = 0.6;
varying vec3 world_pos;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float value_noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
// Fray the long edges into the grass: past `fray_start` toward the edge, a noise cut eats
// the border away irregularly, so the path dissolves into grass rather than ruling a clean
// line. The centre stays solid. UV.x runs 0 (left) … 1 (right) across the ribbon.
float edge = clamp(abs(UV.x - 0.5) * 2.0, 0.0, 1.0);
float bite = (value_noise(world_pos.xz * fray_scale) - 0.5) * fray_jitter;
if (edge - fray_start + bite > 0.0 && edge > fray_start) {
discard;
}
float t = floor(value_noise(world_pos.xz * patch_scale) * patch_steps) / (patch_steps - 1.0);
ALBEDO = mix(dirt_low, dirt_high, t);
ROUGHNESS = 1.0;
METALLIC = 0.0;
}
void light() {
float ndl = max(dot(NORMAL, LIGHT), 0.0);
float tone = step(LOW_CUT, ndl) * MID_TONE + step(HIGH_CUT, ndl) * (1.0 - MID_TONE);
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR * ATTENUATION * tone;
}
added src/client/water.gdshader
@@ -0,0 +1,86 @@
shader_type spatial;
render_mode specular_disabled, cull_disabled;
// The river surface — a stylised toon water in place of the flat blue strip. A slow broad
// tone varies the deep channel; crisp highlight bands drift along the current (TIME) so it
// reads as flowing water; the banks lighten to a shallow tone and then fray irregularly into
// the grass. Cel-banded like every other surface. Driven per river ribbon by MapView, which
// passes the ribbon's half-width so the bank shaping works in object space.
//
// The flow bands ride a world-space diagonal (x - z) — the axis the river runs along — so the
// pattern is continuous across the separate ribbon segments (no seams at the joins) and scrolls
// roughly along the watercourse.
// Deep channel, mid surface, the bright crest the drifting bands lift to, and the lighter
// shallow tone the water fades to at its banks.
uniform vec3 water_deep : source_color = vec3(0.04, 0.14, 0.32);
uniform vec3 water_mid : source_color = vec3(0.09, 0.27, 0.48);
uniform vec3 water_crest : source_color = vec3(0.34, 0.60, 0.78);
uniform vec3 water_shallow : source_color = vec3(0.28, 0.52, 0.58);
// Broad tone clump size, and the drifting flow bands: their spacing (frequency), scroll speed,
// where along the wave the crest cuts in, and how far the crest lifts toward `water_crest`.
uniform float tone_scale = 0.004;
uniform float wave_freq = 0.013;
uniform float wave_speed = 2.2;
uniform float wave_cut : hint_range(0.0, 1.0) = 0.62;
uniform float wave_strength : hint_range(0.0, 1.0) = 0.5;
// Bank shaping (across the ribbon, 0 centre … 1 edge from UV.x): where the shallow lightening
// begins, where the fray begins, and the noise that breaks the bank up.
uniform float shallow_start : hint_range(0.0, 1.0) = 0.5;
uniform float fray_start : hint_range(0.0, 1.0) = 0.82;
uniform float fray_scale = 0.012;
uniform float fray_jitter : hint_range(0.0, 1.0) = 0.4;
const float MID_TONE = 0.5;
const float LOW_CUT = 0.25;
const float HIGH_CUT = 0.6;
varying vec3 world_pos;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float value_noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
float edge = clamp(abs(UV.x - 0.5) * 2.0, 0.0, 1.0); // 0 centre … 1 bank, from the ribbon UV
// Fray the banks into the grass, the same noise cut the dirt path takes.
float bite = (value_noise(world_pos.xz * fray_scale) - 0.5) * fray_jitter;
if (edge > fray_start && edge - fray_start + bite > 0.0) {
discard;
}
// Broad slow tone across the channel, then crisp highlight bands drifting along the flow.
float tone = value_noise(world_pos.xz * tone_scale);
vec3 surface = mix(water_deep, water_mid, tone);
float wave = sin((world_pos.x - world_pos.z) * wave_freq - TIME * wave_speed) * 0.5 + 0.5;
float crest = smoothstep(wave_cut, wave_cut + 0.08, wave);
surface = mix(surface, water_crest, crest * wave_strength);
float shallow = smoothstep(shallow_start, 1.0, edge);
ALBEDO = mix(surface, water_shallow, shallow);
ROUGHNESS = 1.0;
METALLIC = 0.0;
}
void light() {
float ndl = max(dot(NORMAL, LIGHT), 0.0);
float toon = step(LOW_CUT, ndl) * MID_TONE + step(HIGH_CUT, ndl) * (1.0 - MID_TONE);
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR * ATTENUATION * toon;
}