diff --git a/src/entities/Terrain.js b/src/entities/Terrain.js index 75dca85..175026c 100644 --- a/src/entities/Terrain.js +++ b/src/entities/Terrain.js @@ -1,5 +1,41 @@ import { TERRAIN_DEPTH, WORLD_SCALE } from '../config.js'; +// Palette picked to match assets/backgrounds/bg_mid.png and bg_near.png's +// painterly foliage (deep leaf greens, an olive/yellow-green highlight, warm +// rust accents on a few "turning" leaves) so the drivable ground reads as +// the same world as the parallax behind it, with a dirt-road strip riding +// right on the surface where the bus actually touches down. +const COLORS = { + roadTop: 0xa5814f, + roadEdge: 0x5c4128, + roadPebble: 0x50381f, + roadHighlight: 0xc9a56d, + subsoil: 0x3f5c28, + foliageMid: 0x36531f, + foliageDeep: 0x223a15, + tufts: [0x4a7a2e, 0x6fa23f, 0x9bbf4a, 0x8a5a3a], +}; + +// How deep (from the surface line, straight down) each visual layer reaches +// - not the physics depth (TERRAIN_DEPTH, which just needs to be deep +// enough nothing ever tunnels through the bottom). Purely a "how many +// pixels of dirt before it turns into foliage" tuning knob. +const ROAD_DEPTH = 22 * WORLD_SCALE; +const SUBSOIL_DEPTH = ROAD_DEPTH + 46 * WORLD_SCALE; +const MID_FOLIAGE_DEPTH = TERRAIN_DEPTH * 0.55; + +// Deterministic hash-based PRNG (mulberry32-style mix) instead of +// Math.random(), so the road's pebble scatter and the grass tufts look the +// same every time a level is (re)built - a fresh Math.random seed every +// retry would make the ground visibly "shuffle" between attempts at the +// same spot, which reads as a bug even though it's purely decorative. +function hashRandom(seed) { + let t = (seed ^ 0x6d2b79f5) >>> 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + // Ground is stored as left-to-right surface polylines (easy to hand-author), // each converted into a chain of angled static rectangle bodies extruded // downward - simpler and more robust than concave polygon decomposition. @@ -55,21 +91,120 @@ export default class Terrain { this.bodies.push(body); } - _drawSegment(points) { + // Fills the ribbon bounded above by the surface polyline and below by + // that same polyline shifted straight down by `depth` AT EVERY POINT (not + // just its two ends - a segment can run for thousands of units and climb + // or drop a lot along the way, so a bottom edge built from only the first + // and last point would just be one long straight diagonal across the + // whole thing, nowhere near a constant `depth` below the actual terrain + // in between). Layers are drawn deepest-first in _drawSegment, each + // shallower fill simply capping the top portion of the previous one - a + // cheap way to get bands that follow the terrain's contour without + // computing separate band-only polygons. + _fillFromTop(points, depth, color) { const g = this.graphics; - g.fillStyle(0x3c8f3c, 1); + g.fillStyle(color, 1); g.beginPath(); - g.moveTo(points[0].x, points[0].y + TERRAIN_DEPTH); + g.moveTo(points[0].x, points[0].y); for (const p of points) g.lineTo(p.x, p.y); - const last = points[points.length - 1]; - g.lineTo(last.x, last.y + TERRAIN_DEPTH); + for (let i = points.length - 1; i >= 0; i--) g.lineTo(points[i].x, points[i].y + depth); g.closePath(); g.fillPath(); + } - g.lineStyle(4 * WORLD_SCALE, 0x2c6e2c, 1); + _drawSegment(points) { + this._fillFromTop(points, TERRAIN_DEPTH, COLORS.foliageDeep); + this._fillFromTop(points, MID_FOLIAGE_DEPTH, COLORS.foliageMid); + this._fillFromTop(points, SUBSOIL_DEPTH, COLORS.subsoil); + this._fillFromTop(points, ROAD_DEPTH, COLORS.roadTop); + + this._drawRoadTexture(points); + + const g = this.graphics; + g.lineStyle(4 * WORLD_SCALE, COLORS.roadEdge, 1); g.beginPath(); g.moveTo(points[0].x, points[0].y); for (const p of points) g.lineTo(p.x, p.y); g.strokePath(); + + this._drawFoliageTufts(points); + } + + // Scatters small pebble/rut flecks across the dirt band so it doesn't + // read as a flat color fill - sampled a few times per segment rather + // than per original point (point spacing depends on the level/editor's + // width settings, so this keeps texture density roughly constant + // regardless of how the terrain was authored). + _drawRoadTexture(points) { + const g = this.graphics; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const segLen = Math.hypot(b.x - a.x, b.y - a.y); + if (segLen < 1) continue; + + const count = Math.max(1, Math.round(segLen / (26 * WORLD_SCALE))); + for (let j = 0; j < count; j++) { + const seed = Math.round(a.x) * 97 + i * 131 + j * 17; + const t = (j + 0.5) / count; + const px = a.x + (b.x - a.x) * t + (hashRandom(seed) - 0.5) * 16 * WORLD_SCALE; + const py = a.y + (b.y - a.y) * t + (0.25 + hashRandom(seed + 1) * 0.65) * ROAD_DEPTH; + + const isHighlight = hashRandom(seed + 2) > 0.55; + const radius = (1.3 + hashRandom(seed + 3) * 1.5) * WORLD_SCALE; + g.fillStyle(isHighlight ? COLORS.roadHighlight : COLORS.roadPebble, isHighlight ? 0.5 : 0.45); + g.fillCircle(px, py, radius); + } + } + } + + // Sparse little grass/leaf blades poking up right at the road's edge, + // leaned and colored from the bg_mid/bg_near palette - breaks up the + // otherwise perfectly straight edge line and is what actually reads as + // "foliage" rather than just a flat green fill underneath. + _drawFoliageTufts(points) { + const g = this.graphics; + const palette = COLORS.tufts; + + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const segLen = Math.hypot(b.x - a.x, b.y - a.y); + if (segLen < 1) continue; + + const seed0 = Math.round(a.x) * 53 + i * 197; + if (hashRandom(seed0) > 0.4) continue; // keep tufts sparse, not on every segment + + const dirX = (b.x - a.x) / segLen; + const dirY = (b.y - a.y) / segLen; + // Perpendicular to the segment, rotated so it points away from the + // fill (i.e. "up" relative to the local slope, not world-up). + const nx = dirY; + const ny = -dirX; + + const t = 0.3 + hashRandom(seed0 + 1) * 0.4; + const baseX = a.x + (b.x - a.x) * t; + const baseY = a.y + (b.y - a.y) * t; + + const bladeCount = 3 + Math.floor(hashRandom(seed0 + 2) * 3); + for (let k = 0; k < bladeCount; k++) { + const seed = seed0 + k * 11 + 3; + const spread = (hashRandom(seed) - 0.5) * 14 * WORLD_SCALE; + const height = (7 + hashRandom(seed + 1) * 11) * WORLD_SCALE; + const lean = (hashRandom(seed + 2) - 0.5) * 6 * WORLD_SCALE; + const color = palette[Math.floor(hashRandom(seed + 3) * palette.length)]; + + const rootX = baseX + dirX * spread; + const rootY = baseY + dirY * spread; + const tipX = rootX + nx * height + lean; + const tipY = rootY + ny * height; + + g.lineStyle(2.4 * WORLD_SCALE, color, 0.9); + g.beginPath(); + g.moveTo(rootX, rootY); + g.lineTo(tipX, tipY); + g.strokePath(); + } + } } }