diff --git a/assets/sprites/bus_wheel_2.png b/assets/sprites/bus_wheel_2.png new file mode 100644 index 0000000..21a519e Binary files /dev/null and b/assets/sprites/bus_wheel_2.png differ diff --git a/assets/sprites/wh2.png b/assets/sprites/wh2.png new file mode 100644 index 0000000..c1790dc Binary files /dev/null and b/assets/sprites/wh2.png differ diff --git a/src/data/themes.js b/src/data/themes.js index 029537a..ba3733c 100644 --- a/src/data/themes.js +++ b/src/data/themes.js @@ -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; } diff --git a/src/entities/Terrain.js b/src/entities/Terrain.js index 6c34b21..eae3bcf 100644 --- a/src/entities/Terrain.js +++ b/src/entities/Terrain.js @@ -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 = []; + } } diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index 924de9e..0fc30f2 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -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();