Updated Campaign 2 terrain

This commit is contained in:
Brian Fertig 2026-08-23 17:10:10 -06:00
parent c2abb5c4ea
commit c17c0cf7e8
5 changed files with 487 additions and 36 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/sprites/wh2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,23 +1,75 @@
// Per-campaign themes for the IN-LEVEL (play) presentation - the parallax
// background stack and base camera color shown while actually driving a
// level. This is distinct from a campaign's *map* art (bgKey) and menu
// music (musicKey), which live on the campaign object in data/levels/index.js.
// background stack, base camera color, and terrain look shown while actually
// driving a level. This is distinct from a campaign's *map* art (bgKey) and
// menu music (musicKey), which live on the campaign object in
// data/levels/index.js.
//
// Adding a new campaign: add one entry below keyed by the campaign's `id`
// (see data/levels/index.js). You may set the whole object or only the bits
// you want to change; any field you leave out (or the entire entry) falls
// back to DEFAULT_THEME - the campaign 1 "Evergreen Lake" look. So a brand
// new campaign is already themed by default and only needs an entry for
// whatever it wants to customize (swap the art, tweak the base color, add a
// new theme axis for this campaign only, etc.).
// you want to change - getTheme() merges your entry over DEFAULT_THEME, the
// campaign 1 "Evergreen Lake" look - so any field you leave out (or an
// entire missing entry) falls back to the baseline. A brand-new campaign is
// therefore already fully themed and only needs an entry for whatever it
// wants to customize (swap the art, recolor the terrain, add a new theme
// axis for this campaign only, etc.).
//
// Every texture key referenced here must exist in util/assetManifest.js so
// PreloadScene loads it (and a placeholder is generated if the file is
// missing).
// ---------------------------------------------------------------------------
// Terrain styles - the palettes Terrain.js draws the drivable ground with.
// `decor` picks the edge-decoration pass: 'foliage' = grass/leaf tufts
// (the default), 'urban' = graffiti tags, trash, a faded painted centerline,
// oil stains, and a dusk rim light (see Terrain's _drawUrbanDecor & friends).
// ---------------------------------------------------------------------------
// Campaign 1 baseline: painterly foliage + dirt road (Terrain.js's original
// hardcoded palette, moved here untouched - greens picked to match
// assets/backgrounds/bg_mid.png and bg_near.png's foliage).
export const OUTDOORS_TERRAIN = {
decor: 'foliage',
roadTop: 0xa5814f,
roadEdge: 0x5c4128,
roadPebble: 0x50381f,
roadHighlight: 0xc9a56d,
subsoil: 0x3f5c28,
mid: 0x36531f,
deep: 0x223a15,
tufts: [0x4a7a2e, 0x6fa23f, 0x9bbf4a, 0x8a5a3a],
};
// Campaign 2: urban wasteland. The road is dark warm asphalt that reads
// clearly against bg_mid_2/bg_near_2's brown-dusk silhouettes (which hover
// around #2a2329-#473737) - the surface sits a distinct step lighter/warmer
// while the exposed underlayers drop DARKER than the backdrop, so a cut
// cross-section reads as a shadowed trench receding into the scene. The
// faded yellow centerline, neon graffiti, scattered trash, and oil stains
// carry the "abandoned city road" story.
export const URBAN_TERRAIN = {
decor: 'urban',
roadTop: 0x322d2a,
roadEdge: 0x161211,
roadPebble: 0x221c19,
roadHighlight: 0x554b43,
subsoil: 0x241e20,
mid: 0x1a1518,
deep: 0x110e10,
// Faded painted centerline (worn-down yellow, low alpha).
lineColor: 0xd9b44a,
lineAlpha: 0.3,
// Dusk rim light along the road surface (matches bg_far_2's rose sky).
edgeGlow: 0x92656d,
// Neon spray-paint colors for the graffiti tags.
graffiti: [0xff5d8f, 0x53e0d0, 0xffd23e, 0xff8c42, 0xf4f0e6],
// Trash fleck colors, indexed by kind in Terrain:
// [0] crumpled paper, [1]/[2] bottle caps, [3] rusted rebar, [4] glass.
trash: [0xd8d3c8, 0xd85a4a, 0x5a8fd8, 0x6e4632, 0x9fd8e0],
};
// The default / campaign 1 look. Other campaigns inherit from this for any
// field they don't override, so this stays the single source for "the
// baseline art".
// baseline presentation".
export const DEFAULT_THEME = {
// Camera background color, drawn behind the parallax stack and only
// visible through gaps. A hex string, the same format the scenes pass to
@ -31,14 +83,17 @@ export const DEFAULT_THEME = {
mid: 'bg_mid',
near: 'bg_near',
},
// The drivable ground's palette + decoration style (see above).
terrain: OUTDOORS_TERRAIN,
};
export const THEMES = {
// Campaign 1 uses the baseline art as-is.
// Campaign 1 uses the baseline presentation as-is.
campaign01: DEFAULT_THEME,
// Campaign 2 ("Underprivileged Community") - the urban _2 background set,
// with a dusk base color matched to bg_far_2's sky.
// a dusk base color matched to bg_far_2's sky, and the asphalt-terrain
// look above.
campaign02: {
bgColor: '#92656d',
parallax: {
@ -46,6 +101,7 @@ export const THEMES = {
mid: 'bg_mid_2',
near: 'bg_near_2',
},
terrain: URBAN_TERRAIN,
},
// Add campaign03: { ... } here when it's ready.
};
@ -53,5 +109,6 @@ export const THEMES = {
// Resolves the theme for a campaign id, always returning a complete theme
// (falls back to the default for unknown/absent campaigns).
export function getTheme(campaignId) {
return THEMES[campaignId] || DEFAULT_THEME;
const theme = THEMES[campaignId];
return theme ? { ...DEFAULT_THEME, ...theme } : DEFAULT_THEME;
}

View File

@ -1,21 +1,55 @@
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 = {
// The ground's look is a per-campaign theme (see src/data/themes.js): the
// palette + a `decor` style are injected at construction time, so each
// campaign can render the drivable surface in its own idiom. The two
// built-in styles:
// 'foliage' - campaign 1's baseline: painterly greens matching
// assets/backgrounds/bg_mid.png and bg_near.png, pebbled dirt band,
// grass/leaf tufts on the edge.
// 'urban' - campaign 2's wasteland road: dark warm asphalt that sits a
// distinct step lighter/warmer than bg_mid_2/bg_near_2's brown-dusk
// silhouettes (so the surface reads as the driveable strip) while the
// exposed underlayers drop DARKER than the backdrop, reading as a
// shadowed cut cross-section. A faded painted centerline, oil stains,
// neon graffiti, and scattered trash carry the "abandoned city road"
// story on top of the asphalt.
const OUTDOORS_PALETTE = {
decor: 'foliage',
roadTop: 0xa5814f,
roadEdge: 0x5c4128,
roadPebble: 0x50381f,
roadHighlight: 0xc9a56d,
subsoil: 0x3f5c28,
foliageMid: 0x36531f,
foliageDeep: 0x223a15,
mid: 0x36531f,
deep: 0x223a15,
tufts: [0x4a7a2e, 0x6fa23f, 0x9bbf4a, 0x8a5a3a],
};
const URBAN_PALETTE = {
decor: 'urban',
roadTop: 0x322d2a,
roadEdge: 0x161211,
roadPebble: 0x221c19,
roadHighlight: 0x554b43,
subsoil: 0x241e20,
mid: 0x1a1518,
deep: 0x110e10,
lineColor: 0xd9b44a,
lineAlpha: 0.3,
graffiti: [0xff5d8f, 0x53e0d0, 0xffd23e, 0xff8c42, 0xf4f0e6],
// Trash fleck colors, indexed by kind in the trash helpers below:
// [0] crumpled paper, [1]/[2] bottle caps, [3] rusted rebar, [4] glass.
trash: [0xd8d3c8, 0xd85a4a, 0x5a8fd8, 0x6e4632, 0x9fd8e0],
};
// Accepts a themed palette object (PlayScene passes theme.terrain), with a
// sane fallback so direct construction (editor tooling, future test
// harnesses) still renders the baseline look.
function getTerrainPalette(palette) {
return palette && palette.roadTop != null ? palette : OUTDOORS_PALETTE;
}
// 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
@ -24,8 +58,32 @@ const ROAD_DEPTH = 22 * WORLD_SCALE;
const SUBSOIL_DEPTH = ROAD_DEPTH + 46 * WORLD_SCALE;
const MID_FOLIAGE_DEPTH = TERRAIN_DEPTH * 0.55;
// Faded painted centerline: a worn-out dashed yellow line riding inside the
// road band. Low alpha + a slight per-segment offset jitter is what makes it
// read as faded paint, not a fresh stripe.
const LINE_Y_OFFSET = 0.34 * ROAD_DEPTH;
const LINE_WIDTH = 4 * WORLD_SCALE;
const DASH_LEN = 46 * WORLD_SCALE;
const DASH_GAP = 58 * WORLD_SCALE;
// Dark oil/tar stains pooled in the road band (they sit on the surface in
// the game's top-down-ish view, so a darkened smear reads as a stain).
const OIL_SPACING = 430 * WORLD_SCALE;
const OIL_INSET_MIN = 0.2 * ROAD_DEPTH;
const OIL_INSET_MAX = SUBSOIL_DEPTH - ROAD_DEPTH * 0.4;
const OIL_MAX_LEN = 30 * WORLD_SCALE;
// Surface-decoration spacing (urban style): graffiti is sparse and
// statement-sized, trash is common and tiny.
const TAG_SPACING = 330 * WORLD_SCALE;
const TRASH_SPACING = 54 * WORLD_SCALE;
const GHOST_WORDS = ['no', 'run', '13', '45', 'out', 'exit', '44', 'go'];
// Stencil words are real Text objects (Phaser's Graphics has no fillText),
// so they're tracked separately and destroyed with everything else.
// Deterministic hash-based PRNG (mulberry32-style mix) instead of
// Math.random(), so the road's pebble scatter and the grass tufts look the
// Math.random(), so the pebble scatter, stains, tags, and trash 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.
@ -40,9 +98,11 @@ function hashRandom(seed) {
// 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) {
constructor(scene, levelData, palette) {
this.scene = scene;
this.palette = getTerrainPalette(palette);
this.bodies = [];
this.textObjects = [];
this.graphics = scene.add.graphics();
for (const segment of levelData.terrain) {
@ -138,29 +198,52 @@ export default class Terrain {
}
_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);
const p = this.palette;
this._fillFromTop(points, TERRAIN_DEPTH, p.deep);
this._fillFromTop(points, MID_FOLIAGE_DEPTH, p.mid);
this._fillFromTop(points, SUBSOIL_DEPTH, p.subsoil);
this._fillFromTop(points, ROAD_DEPTH, p.roadTop);
this._drawRoadTexture(points);
const g = this.graphics;
g.lineStyle(4 * WORLD_SCALE, COLORS.roadEdge, 1);
g.lineStyle(4 * WORLD_SCALE, p.roadEdge, 1);
g.beginPath();
g.moveTo(points[0].x, points[0].y);
for (const p of points) g.lineTo(p.x, p.y);
for (const pt of points) g.lineTo(pt.x, pt.y);
g.strokePath();
this._drawFoliageTufts(points);
// Dusk rim light on the very surface line (urban only) - a soft rose
// stroke from the campaign sky (bg_far_2) that catches the road's edge
// like late sun, tying the asphalt to the backdrop without the bus
// ever sitting on a line.
if (p.decor === 'urban' && p.edgeGlow) {
g.lineStyle(2 * WORLD_SCALE, p.edgeGlow, 0.35);
g.beginPath();
g.moveTo(points[0].x, points[0].y);
for (const pt of points) g.lineTo(pt.x, pt.y);
g.strokePath();
}
// Edge decoration is style-specific: grass/leaf tufts for the outdoors
// look, the full wasteland dressing (centerline, stains, tags, trash)
// for urban.
if (p.decor === 'urban') {
this._drawFadedCenterline(points);
this._drawOilStains(points);
this._drawUrbanDecor(points);
} else {
this._drawFoliageTufts(points);
}
}
// Scatters small pebble/rut flecks across the dirt band so it doesn't
// Scatters small pebble/rut flecks across the road 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 p = this.palette;
const g = this.graphics;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
@ -177,19 +260,20 @@ export default class Terrain {
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.fillStyle(isHighlight ? p.roadHighlight : p.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.
// leaned and colored from the foliage 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 p = this.palette;
const g = this.graphics;
const palette = COLORS.tufts;
const palette = p.tufts || OUTDOORS_PALETTE.tufts;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
@ -232,4 +316,310 @@ export default class Terrain {
}
}
}
// A worn-out dashed yellow centerline riding just inside the road band.
// Dash phase is continuous along the whole polyline (distAlong carries
// across segments), with a per-segment vertical jitter so the line looks
// hand-painted-and-faded rather than a crisp machine stripe.
_drawFadedCenterline(points) {
const p = this.palette;
const g = this.graphics;
if (points.length < 2) return;
g.lineStyle(LINE_WIDTH, p.lineColor, p.lineAlpha);
const dashCycle = DASH_LEN + DASH_GAP;
// Anchor the dash phase to the segment's start x (deterministic, like
// every other decoration seed) so it doesn't depend on how the level's
// segments are chunked.
const phase = (Math.round(points[0].x) * 7919) % dashCycle;
let distAlong = 0;
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 dirX = (b.x - a.x) / segLen;
const dirY = (b.y - a.y) / segLen;
const off = LINE_Y_OFFSET * (1 + (hashRandom(Math.round(a.x) * 31 + i) - 0.5) * 0.5);
// Perpendicular "down" into the road band - the paint is ON the
// asphalt, which in this side view is the band below the surface line
// (same convention as the pebble flecks).
const nx = -dirY;
const ny = dirX;
let dashStart = (((phase - distAlong) % dashCycle + dashCycle) % dashCycle) - dashCycle;
while (dashStart < segLen) {
const s = Math.max(0, dashStart);
const e = Math.min(segLen, dashStart + DASH_LEN);
if (e > s) {
g.beginPath();
g.moveTo(a.x + dirX * s + nx * off, a.y + dirY * s + ny * off);
g.lineTo(a.x + dirX * e + nx * off, a.y + dirY * e + ny * off);
g.strokePath();
}
dashStart += dashCycle;
}
distAlong += segLen;
}
}
// Sparse dark oil/tar smears pooled across the road band - ellipses that
// follow the segment's tangent (so on slopes they lie "along" the road,
// not at world axis) and fade down through the band like a stain soaking
// into the asphalt.
_drawOilStains(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 < OIL_SPACING) continue;
const count = Math.floor(segLen / OIL_SPACING);
for (let j = 0; j < count; j++) {
const seed = Math.round(a.x) * 131 + i * 389 + j * 593;
if (hashRandom(seed) > 0.62) continue; // roughly one stain every ~700 units
const dirX = (b.x - a.x) / segLen;
const dirY = (b.y - a.y) / segLen;
const t = (j + 0.5) / count;
const baseX = a.x + (b.x - a.x) * t;
const baseY = a.y + (b.y - a.y) * t;
const len = (0.45 + hashRandom(seed + 1) * 0.55) * OIL_MAX_LEN;
const thick = len * (0.3 + hashRandom(seed + 4) * 0.18);
// Pooled DOWN into the road band (the asphalt is the band below the
// surface line), never in the air above it.
const inset = OIL_INSET_MIN + hashRandom(seed + 2) * (OIL_INSET_MAX - OIL_INSET_MIN);
const cx = baseX - dirY * inset + (hashRandom(seed + 5) - 0.5) * 10 * WORLD_SCALE;
const cy = baseY + dirX * inset;
// fillEllipse isn't rotatable, so approximate a tangent-aligned
// smear with two quads along the road direction sharing a rounder
// core - reads as an elongated stain at any slope.
const hx = dirX * len * 0.5;
const hy = dirY * len * 0.5;
const wx = -dirY * thick * 0.5;
const wy = dirX * thick * 0.5;
g.fillStyle(0x0b0806, 0.5 + hashRandom(seed + 3) * 0.3);
g.fillTriangle(cx - hx - wx, cy - hy - wy, cx + hx + wx, cy + hy + wy, cx + wx - hx * 0.2, cy + wy - hy * 0.2);
g.fillTriangle(cx - hx - wx, cy - hy - wy, cx + hx + wx, cy + hy + wy, cx - wx - hx * 0.2, cy - wy - hy * 0.2);
g.fillEllipse(cx, cy, thick, thick);
}
}
}
// The wasteland surface dressing: sparse neon graffiti tags plus a steady
// scatter of tiny trash. Both are seeded (hashRandom) so they're stable
// across retries, and both ride the local surface tangent so they align
// with the road on slopes.
_drawUrbanDecor(points) {
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;
this._scatterAlong(points, i, a, b, segLen, TAG_SPACING, 0.45, (x, y, nx, ny, seed) => this._graffitiTag(x, y, nx, ny, seed));
this._scatterAlong(points, i, a, b, segLen, TRASH_SPACING, 0.75, (x, y, nx, ny, seed) => this._trashItem(x, y, nx, ny, seed));
}
}
// Walks one terrain sub-segment at `spacing` intervals, jittering each
// slot's position and dropping `draw` there for the slots that pass the
// `density` roll - the shared sampler for graffiti and trash. Each item is
// inset slightly DOWN into the road band (a point on the asphalt in this
// side view lives a few px below the surface line, same as the pebbles),
// so decorations sit on the road instead of floating in the air above it.
_scatterAlong(points, i, a, b, segLen, spacing, density, draw) {
const count = Math.max(1, Math.round(segLen / spacing));
for (let j = 0; j < count; j++) {
const seed = Math.round(a.x) * 911 + i * 233 + j * 67;
if (hashRandom(seed) > density) continue;
const t = Math.max(0.02, Math.min(0.98, (j + hashRandom(seed + 5) * 0.7 - 0.35) / count));
const dirX = (b.x - a.x) / segLen;
const dirY = (b.y - a.y) / segLen;
const inset = (1.5 + hashRandom(seed + 6) * 4) * WORLD_SCALE;
draw(a.x + (b.x - a.x) * t - dirY * inset, a.y + (b.y - a.y) * t + dirX * inset, dirY, -dirX, seed);
}
}
// One graffiti "tag": a small neon mark painted on the surface, picked
// from three silhouettes (stacked wedge lines, a scribble wave, a ghost
// stencil word) so the street varies instead of repeating one motif.
_graffitiTag(x, y, nx, ny, seed) {
const p = this.palette;
const colors = p.graffiti || URBAN_PALETTE.graffiti;
const color = colors[Math.floor(hashRandom(seed + 7) * colors.length)];
const alpha = 0.4 + hashRandom(seed + 8) * 0.35;
const kind = hashRandom(seed + 9);
if (kind < 0.4) this._graffitiTaglines(x, y, nx, ny, color, alpha, seed);
else if (kind < 0.72) this._graffitiScribble(x, y, nx, ny, color, alpha, seed);
else this._ghostWord(x, y, color, alpha, seed);
}
// A short cluster of angled neon strokes painted on the surface - reads
// as a quick spray-painted tag mark at this scale (kept flat against the
// road, not spiking up out of it).
_graffitiTaglines(x, y, nx, ny, color, alpha, seed) {
const g = this.graphics;
const lineCount = 2 + Math.floor(hashRandom(seed + 12) * 2);
for (let k = 0; k < lineCount; k++) {
const h = (5 + hashRandom(seed + 13 + k * 5) * 10) * WORLD_SCALE;
const off = (k - (lineCount - 1) / 2) * (4.5 + hashRandom(seed + 40 + k) * 3) * WORLD_SCALE;
const slant = (hashRandom(seed + 60 + k) - 0.5) * 6 * WORLD_SCALE;
g.lineStyle(2.4 * WORLD_SCALE, color, alpha * (k === lineCount - 1 ? 1 : 0.8));
g.beginPath();
g.moveTo(x + off - h * 0.5 + slant, y - 0.5 * WORLD_SCALE);
g.lineTo(x + off + h * 0.5 + slant, y + 2.5 * WORLD_SCALE);
g.strokePath();
}
}
// A rough back-and-forth scribble wave painted along the surface (kept
// within the road band - a small arc above the surface line is fine
// since it reads as wet paint, but nothing tall).
_graffitiScribble(x, y, nx, ny, color, alpha, seed) {
const g = this.graphics;
const loops = 2 + Math.floor(hashRandom(seed + 21) * 2);
const rx = (4 + hashRandom(seed + 22) * 3) * WORLD_SCALE;
const ry = (2.5 + hashRandom(seed + 23) * 3) * WORLD_SCALE;
g.lineStyle(2.6 * WORLD_SCALE, color, alpha);
let px = x - rx * loops;
let py = y + 1 * WORLD_SCALE;
for (let k = 0; k < loops; k++) {
const endX = px + rx * 2 + (hashRandom(seed + 30 + k) - 0.5) * 4 * WORLD_SCALE;
const endY = y + 1 * WORLD_SCALE;
const sign = k % 2 === 0 ? 1 : -1; // alternate arcs above/below the line
const steps = 7;
let prevX = px;
let prevY = py;
for (let s = 1; s <= steps; s++) {
const f = s / steps;
const ang = f * Math.PI;
const qx = px + (endX - px) * f;
const qy = py + sign * Math.sin(ang) * ry;
g.beginPath();
g.moveTo(prevX, prevY);
g.lineTo(qx, qy);
g.strokePath();
prevX = qx;
prevY = qy;
}
px = endX;
py = endY;
}
}
// A ghosted stencil word (graffiti's staple lettering style) painted
// directly on the asphalt. It's a real Text object rather than a Graphics
// fillText (Phaser's Graphics has no text API), so it's tracked in
// this.textObjects and destroyed with everything else.
_ghostWord(x, y, color, alpha, seed) {
const word = GHOST_WORDS[Math.floor(hashRandom(seed + 31) * GHOST_WORDS.length)];
const text = this.scene.add.text(
x + (hashRandom(seed + 33) - 0.5) * 30 * WORLD_SCALE,
y + 2 * WORLD_SCALE,
word,
{
fontFamily: 'monospace',
fontSize: `${(7 + hashRandom(seed + 32) * 6) * WORLD_SCALE}px`,
fontStyle: 'bold',
}
);
text.setColor(`#${color.toString(16).padStart(6, '0')}`);
text.setAlpha(Math.min(0.55, alpha * 0.8));
this.textObjects.push(text);
}
// One piece of trash: crumpled paper, a bottle cap, a rebar stub, or a
// shard of glass, each with a faint ground-contact shadow so it sits ON
// the asphalt instead of hovering.
_trashItem(x, y, nx, ny, seed) {
const p = this.palette;
const trashColors = p.trash || URBAN_PALETTE.trash;
const kind = Math.floor(hashRandom(seed + 41) * 4);
const alpha = 0.55 + hashRandom(seed + 42) * 0.35;
const s = 0.7 + hashRandom(seed + 43) * 0.8; // size scale
if (kind === 0) this._trashScrap(x, y, nx, ny, trashColors[0], alpha, s, seed);
else if (kind === 1) this._trashCap(x, y, nx, ny, hashRandom(seed + 44) > 0.5 ? trashColors[1] : trashColors[2], alpha, s, seed);
else if (kind === 2) this._trashRebar(x, y, trashColors[3], alpha, s, seed);
else this._trashGlass(x, y, trashColors[4], alpha, s, seed);
}
_trashScrap(x, y, nx, ny, color, alpha, s, seed) {
const g = this.graphics;
const w = (4 + hashRandom(seed + 51) * 4) * WORLD_SCALE * s;
const h = (2.5 + hashRandom(seed + 52) * 2.5) * WORLD_SCALE * s;
const off = (hashRandom(seed + 53) - 0.5) * 6 * WORLD_SCALE;
g.fillStyle(color, alpha);
g.fillTriangle(
x + off, y,
x + off + w + (hashRandom(seed + 54) - 0.5) * 4 * WORLD_SCALE, y - h * 0.6,
x + off + w * 0.3, y + h * 0.3
);
g.fillStyle(0x000000, 0.25);
g.fillEllipse(x + off + w * 0.5, y + 0.8 * WORLD_SCALE, w * 0.8, 2 * WORLD_SCALE);
}
_trashCap(x, y, nx, ny, color, alpha, s, seed) {
const g = this.graphics;
const r = (1.6 + hashRandom(seed + 61) * 1.2) * WORLD_SCALE * s;
const off = (hashRandom(seed + 62) - 0.5) * 8 * WORLD_SCALE;
g.fillStyle(color, alpha);
g.fillCircle(x + off, y - r * 0.5, r);
g.fillStyle(0x1a1a1a, alpha * 0.6);
g.fillCircle(x + off, y - r * 0.5, r * 0.45);
}
_trashRebar(x, y, color, alpha, s, seed) {
const g = this.graphics;
const len = (7 + hashRandom(seed + 71) * 7) * WORLD_SCALE * s;
const dir = hashRandom(seed + 72) > 0.5 ? 1 : -1;
const tilt = (hashRandom(seed + 73) - 0.5) * 5 * WORLD_SCALE;
const x1 = x - dir * len * 0.5 + tilt;
const y1 = y - 1 * WORLD_SCALE;
const x2 = x + dir * len * 0.5 + tilt;
const y2 = y - (2 + hashRandom(seed + 74) * 2) * WORLD_SCALE;
g.lineStyle(2 * WORLD_SCALE, color, alpha);
g.beginPath();
g.moveTo(x1, y1);
g.lineTo(x2, y2);
g.strokePath();
g.fillStyle(color, alpha * 0.9);
g.fillCircle(x2, y2, 1.3 * WORLD_SCALE);
}
_trashGlass(x, y, nx, ny, color, alpha, s, seed) {
const g = this.graphics;
const w = (3.5 + hashRandom(seed + 81) * 3) * WORLD_SCALE * s;
const off = (hashRandom(seed + 82) - 0.5) * 7 * WORLD_SCALE;
g.fillStyle(color, alpha * 0.8);
g.fillTriangle(
x + off, y,
x + off + w, y - 1.5 * WORLD_SCALE,
x + off + w * 0.2, y - 3.5 * WORLD_SCALE
);
g.lineStyle(1.2 * WORLD_SCALE, 0xffffff, 0.5 * alpha);
g.beginPath();
g.moveTo(x + off, y);
g.lineTo(x + off + w * 0.2, y - 3.5 * WORLD_SCALE);
g.strokePath();
}
// Destroys the stencil-word Text objects (Phaser's auto scene teardown
// covers the graphics/bodies, but we destroy these explicitly so a Terrain
// outliving its scene can't leak them - matches PlayScene's _cleanup).
destroy() {
if (this.textObjects) {
for (const text of this.textObjects) text.destroy();
this.textObjects = [];
}
if (this.graphics) this.graphics.destroy();
this.graphics = null;
this.bodies = [];
}
}

View File

@ -44,7 +44,10 @@ export default class PlayScene extends Phaser.Scene {
this._buildParallax();
this.terrain = new Terrain(this, this.level);
// Terrain is themed by campaign (theme.terrain - palette + 'decor'
// style): campaign 1 renders its foliage look, campaign 2 the asphalt
// wasteland (see Terrain.js's OUTDOORS_PALETTE / URBAN_PALETTE).
this.terrain = new Terrain(this, this.level, this.theme.terrain);
this.finishLine = new FinishLine(this, this.level);
this.bus = new Bus(this, this.level.startPosition.x, this.level.startPosition.y, this.level.startAngle || 0);
@ -350,6 +353,7 @@ export default class PlayScene extends Phaser.Scene {
// internally (verified against the vendored source), so this was always
// redundant, not just now-unsafe.
if (this.compartmentDebug) this.compartmentDebug.destroy();
if (this.terrain) this.terrain.destroy();
if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy();
if (this.smashItems) this.smashItems.destroy();