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. export default class Terrain { constructor(scene, levelData) { this.scene = scene; this.bodies = []; this.graphics = scene.add.graphics(); for (const segment of levelData.terrain) { this._buildSegment(segment.points); this._drawSegment(segment.points); } if (levelData.obstacles) { for (const obstacle of levelData.obstacles) { this._buildObstacle(obstacle); } } } _buildSegment(points) { for (let i = 0; i < points.length - 1; i++) { const a = points[i]; const b = points[i + 1]; const dx = b.x - a.x; const dy = b.y - a.y; if (Math.hypot(dx, dy) < 1) continue; // A perfectly vertical a-b (dx === 0, seen in hand-authored data as // float-rounding near-duplicate points, e.g. two points a couple of Y // units apart at the same X) makes the straight-down-extruded quad // below degenerate: shifting a vertical edge straight down keeps every // corner at the same X, so it has zero width/area, which Matter's // fromVertices can't turn into a body. Skip it - the segments on // either side still meet at both of this pair's endpoints, so physics // coverage stays continuous either way. if (Math.abs(dx) < 1) continue; // Built from the exact same 4 corners _fillFromTop draws (a, b, and // both shifted straight down by TERRAIN_DEPTH) rather than a rotated // rectangle, so the body's top edge is a-b itself and can never // diverge from the drawn line. A previous version offset a rotated // rectangle's center straight down, which shifted its top edge // sideways from a-b by TERRAIN_DEPTH/2*sin(angle) on any slope; a // later fix offset perpendicular to the segment instead, which kept // the top edge glued to a-b but, on a near-vertical segment (a steep // dropOff cliff), pointed that offset almost entirely sideways - // ballooning the body out under whatever terrain preceded the cliff, // well above where anything was ever drawn. Extruding straight down // from the real points (matching the visual fill exactly, at every // slope) has no such failure mode. const cx = (a.x + b.x) / 2; const cy = (a.y + b.y) / 2 + TERRAIN_DEPTH / 2; const vertices = [ { x: a.x, y: a.y }, { x: b.x, y: b.y }, { x: b.x, y: b.y + TERRAIN_DEPTH }, { x: a.x, y: a.y + TERRAIN_DEPTH }, ]; const body = this.scene.matter.add.fromVertices(cx, cy, vertices, { isStatic: true, friction: 0.95, label: 'terrain', }); this.bodies.push(body); } } _buildObstacle(obstacle) { if (obstacle.type !== 'ramp') return; const body = this.scene.matter.add.rectangle(obstacle.x, obstacle.y, obstacle.width, obstacle.height, { isStatic: true, angle: obstacle.angle || 0, friction: 0.95, label: 'terrain', }); this.bodies.push(body); } // 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(color, 1); g.beginPath(); g.moveTo(points[0].x, points[0].y); for (const p of points) g.lineTo(p.x, p.y); for (let i = points.length - 1; i >= 0; i--) g.lineTo(points[i].x, points[i].y + depth); g.closePath(); g.fillPath(); } _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(); } } } }