refactor: add painterly terrain layers with deterministic texturing
Replace flat green ground fill with layered subsoil/road/foliage bands that match the parallax background palette. Add pebble texture on the road surface and sparse grass tufts along the edge for visual depth. Use a hash-based PRNG instead of Math.random() so decorative elements remain consistent across level rebuilds.
This commit is contained in:
parent
3b8c13fb4e
commit
5eea8872dc
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue