Updated Campaign 2 terrain
This commit is contained in:
parent
c2abb5c4ea
commit
c17c0cf7e8
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue