Compare commits

...

5 Commits

Author SHA1 Message Date
Brian Fertig 8cba629232 Add "lift kit" for Campaign 2 bus
Campaign 2's larger wheels now hang further under the chassis via a new `wheelRestOffsetY` theme knob (42 vs 34 design units), making the bus visibly sit taller. The wishbone anchor and initial wheel position derive from this per-campaign offset, so both size and mount point stay correctly centered on the axle line.

- Add `BUS.wheelRestOffsetY2` in config
- Expose `wheelRestOffsetY` in theme `bus` blocks (default = campaign 1)
- Use themed offset in Bus constructor for anchor and wheel placement
- Update docs/comments to explain the lift-kit behavior
2026-08-23 18:39:07 -06:00
Brian Fertig eaeebf1980 Add campaign 2 bus wheel variant (bigger wheels via theme)
- Introduce `bus_wheel_2` asset and register it in the asset manifest
- Add `BUS.wheelRadius2` config for the larger wheel radius
- Extend themes with a `bus` block to select wheel texture and radius per campaign; campaign02 uses the new bigger wheels while keeping the same axle center
- Make `Bus` accept a bus theme parameter and use provided wheel texture/radius when creating wheels
- Update `PlayScene` to pass the active theme's `bus` settings into the Bus constructor
- Document the new per-campaign bus art/sizing behavior in sprites.md
2026-08-23 18:33:48 -06:00
Brian Fertig 3033c1d62d `feat(level-select): open on the player's current campaign by default`
Instead of always starting at campaign 1 (index 0), `LevelSelectScene` now initializes to the campaign containing the first uncleared level in game order, so re-entering the map lands you where you left off. Falls back to campaign 1 if everything is cleared; empty campaigns are skipped since they can't hold the current level.
2026-08-23 17:14:09 -06:00
Brian Fertig c17c0cf7e8 Updated Campaign 2 terrain 2026-08-23 17:10:10 -06:00
Brian Fertig c2abb5c4ea Add per-campaign theming system for in-level visuals
- Create `src/data/themes.js` with theme definitions (background color, parallax layers) and a `getTheme()` resolver that falls back to campaign 1's default look
- Update `PlayScene` to load the active campaign's theme instead of hardcoding colors/texture keys
- Register campaign 2's `_2` background assets in the asset manifest with placeholder fallbacks
- Add updated background art for campaign 2 (`bg_far_2.png`)
2026-08-23 16:49:09 -06:00
13 changed files with 664 additions and 45 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

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

@ -44,6 +44,15 @@ with it (same ratios, just re-derive from that one constant).
no separate left/right variant needed. It's a real physics body, so it no separate left/right variant needed. It's a real physics body, so it
visually spins as the bus drives; a plain hubcap/circle reads better at visually spins as the bus drives; a plain hubcap/circle reads better at
speed than fine detail. speed than fine detail.
- Campaign 2 uses its own wheel image, `bus_wheel_2.png` (bigger - it's
displayed at a ~1.2x radius around the same axle center, selected via the
`bus` block in `src/data/themes.js`), its own chassis image,
`bus_chassis_2.png` (same 340x128 design box, physics body unchanged -
only the texture is swapped per campaign), and a "lift kit": the wheel
mount point sits ~8 design units lower under the chassis (see
`wheelRestOffsetY2` in `src/config.js` and the `bus` block in
`src/data/themes.js`), so it reads as a lifted bus. Both wheels still
share one image per campaign.
- If you want a bus with different proportions (longer, taller, etc.) than - If you want a bus with different proportions (longer, taller, etc.) than
340x128, tell me the size you want art at - that number is also the physics 340x128, tell me the size you want art at - that number is also the physics
body size in `src/config.js` (`BUS.chassisWidth`/`chassisHeight`), so it body size in `src/config.js` (`BUS.chassisWidth`/`chassisHeight`), so it

View File

@ -29,6 +29,10 @@ export const BUS = {
// the axles are NOT symmetric around the chassis center). Offsets are from // the axles are NOT symmetric around the chassis center). Offsets are from
// the chassis center; re-measure these if bus_chassis.png is replaced. // the chassis center; re-measure these if bus_chassis.png is replaced.
wheelRadius: 17 * WORLD_SCALE, wheelRadius: 17 * WORLD_SCALE,
// Campaign 2's bus uses the bigger bus_wheel_2.png (125px vs bus_wheel.png's
// 104px, ~1.2x) around the SAME wheel center - 17 * 1.2 ≈ 20. See
// `bus` in data/themes.js, which selects it for campaign02 only.
wheelRadius2: 20 * WORLD_SCALE,
rearWheelOffsetX: -35 * WORLD_SCALE, rearWheelOffsetX: -35 * WORLD_SCALE,
frontWheelOffsetX: 64 * WORLD_SCALE, frontWheelOffsetX: 64 * WORLD_SCALE,
// Where the wheel's CENTER rests at equilibrium. Raised from the // Where the wheel's CENTER rests at equilibrium. Raised from the
@ -37,6 +41,10 @@ export const BUS = {
// bus_chassis.png's painted wheel wells (a gap opens above the tire), // bus_chassis.png's painted wheel wells (a gap opens above the tire),
// a deliberate look/feel tradeoff rather than a measurement. // a deliberate look/feel tradeoff rather than a measurement.
wheelRestOffsetY: 34 * WORLD_SCALE, wheelRestOffsetY: 34 * WORLD_SCALE,
// Campaign 2's "lift kit": the same mount point 8 design units lower
// (see `bus` in data/themes.js), so the bigger wheels hang further under
// the chassis and the bus visibly sits taller.
wheelRestOffsetY2: 42 * WORLD_SCALE,
// Horizontal spread between the two suspension links each wheel hangs // Horizontal spread between the two suspension links each wheel hangs
// from (a "wishbone") - a single point-to-point constraint can't resist // from (a "wishbone") - a single point-to-point constraint can't resist
// sideways drift at all (it's radially symmetric), so each wheel is held // sideways drift at all (it's radially symmetric), so each wheel is held

View File

@ -11,9 +11,10 @@ import level10 from './level10.js';
import level11 from './level11.js'; import level11 from './level11.js';
// Campaigns group levels into themed "maps" for the level select screen. // Campaigns group levels into themed "maps" for the level select screen.
// Each campaign gets its own background art (bgKey) and theme, so adding a // Each campaign gets its own background art (bgKey), menu theme music
// new campaign is: create its levels, add a bg image + manifest entry, add a // (musicKey), and in-level look (data/themes.js), so adding a new campaign
// row here. Node positions are in 960x540 design units (NOT multiplied by // is: create its levels, add a bg image + manifest entry, add an entry to
// THEMES, add a row here. Node positions are in 960x540 design units (NOT multiplied by
// WORLD_SCALE - the scene scales them), and the path is rendered as a line // WORLD_SCALE - the scene scales them), and the path is rendered as a line
// through them in order. `positions` may be omitted to auto-place the // through them in order. `positions` may be omitted to auto-place the
// campaign's levels: a 2-lane circuit (top lane right->left, bottom lane // campaign's levels: a 2-lane circuit (top lane right->left, bottom lane

144
src/data/themes.js Normal file
View File

@ -0,0 +1,144 @@
import { BUS } from '../config.js';
// Per-campaign themes for the IN-LEVEL (play) presentation - the parallax
// background stack, base camera color, terrain look, and the bus's own
// art/sizing (e.g. campaign 2's bigger wheels) 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 - 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 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
// Phaser's setBackgroundColor().
bgColor: '#8fc7e8',
// The bus's own look (see Bus.js's constructor and BUS in config.js).
// `chassisKey` is the chassis texture (purely visual - the physics body
// stays the same BUS.chassisWidth/Height rectangle either way);
// `wheelTextureKey` is the texture both wheels render with; `wheelRadius`
// is the physics/display radius in world pixels; `wheelRestOffsetY` is
// the wheel mount point, local to the chassis (y down) - raising it is
// the "lift kit" knob (campaign 2). A bigger radius keeps the wheel
// centered on its axle (the wishbone anchors derive from these
// offsets, so they stay put) - only the size changes.
bus: {
chassisKey: 'bus_chassis',
wheelTextureKey: 'bus_wheel',
wheelRadius: BUS.wheelRadius,
wheelRestOffsetY: BUS.wheelRestOffsetY,
},
// The three parallax layers, back to front. `far` is the full-viewport
// sky; `mid`/`near` are bottom-anchored ground tiles (see PlayScene's
// _buildParallax). Keys are texture keys, not file paths.
parallax: {
far: 'bg_far',
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 presentation as-is.
campaign01: DEFAULT_THEME,
// Campaign 2 ("Underprivileged Community") - the urban _2 background set,
// a dusk base color matched to bg_far_2's sky, the asphalt-terrain
// look above, and the bigger bus_wheel_2.png wheels (same center, ~1.2x
// radius - see BUS.wheelRadius2 in config.js).
campaign02: {
bgColor: '#92656d',
parallax: {
far: 'bg_far_2',
mid: 'bg_mid_2',
near: 'bg_near_2',
},
terrain: URBAN_TERRAIN,
bus: {
chassisKey: 'bus_chassis_2',
wheelTextureKey: 'bus_wheel_2',
wheelRadius: BUS.wheelRadius2,
// "Lift kit": drop the wheel mount point 8 design units below
// campaign 1's (see BUS.wheelRestOffsetY2) - the bus sits taller and
// the bigger wheels hang lower under the body.
wheelRestOffsetY: BUS.wheelRestOffsetY2,
},
},
// Add campaign03: { ... } here when it's ready.
};
// Resolves the theme for a campaign id, always returning a complete theme
// (falls back to the default for unknown/absent campaigns). Nested objects
// like `bus` are whole-value overrides, so a campaign entry that sets them
// must set every field it wants (they're small and stable - that's fine).
export function getTheme(campaignId) {
const theme = THEMES[campaignId];
return theme ? { ...DEFAULT_THEME, ...theme } : DEFAULT_THEME;
}

View File

@ -8,9 +8,29 @@ import { BUS } from '../config.js';
// with no vertical give, which is too harsh for a bus that needs real // with no vertical give, which is too harsh for a bus that needs real
// suspension travel to absorb bumps. // suspension travel to absorb bumps.
export default class Bus { export default class Bus {
constructor(scene, x, y, angle = 0) { constructor(scene, x, y, angle = 0, busTheme = null) {
this.scene = scene; this.scene = scene;
const matter = scene.matter; const matter = scene.matter;
// Per-campaign bus look (see `bus` in data/themes.js): chassis and
// wheel texture, the physics/display wheel radius, and the wheel
// mount point (wheelRestOffsetY - campaign 2's "lift kit", which drops
// the wheels further under the chassis). Defaults to campaign 1's
// values, so existing callers (and any bus built outside a themed
// scene) are unchanged. The chassis texture is visual only - the
// physics body stays the same BUS.chassisWidth/Height rectangle
// either way. (The wishbone anchors below are DERIVED from these
// values, so a bigger radius and/or a lower mount point both stay
// correctly centered on the same axle line.)
const {
chassisKey = 'bus_chassis',
wheelTextureKey = 'bus_wheel',
wheelRadius = BUS.wheelRadius,
wheelRestOffsetY = BUS.wheelRestOffsetY,
} = busTheme || {};
this.chassisKey = chassisKey;
this.wheelTextureKey = wheelTextureKey;
this.wheelRadius = wheelRadius;
this._wheelRestOffsetY = wheelRestOffsetY;
this.group = matter.world.nextGroup(true); this.group = matter.world.nextGroup(true);
// Dedicated category so the compartment fixtures below can target kids // Dedicated category so the compartment fixtures below can target kids
@ -36,7 +56,10 @@ export default class Bus {
// so the spring pulling the wheel out to its full constraint length is // so the spring pulling the wheel out to its full constraint length is
// what lands it exactly on wheelRestOffsetY (anchorY + travel = restY), // what lands it exactly on wheelRestOffsetY (anchorY + travel = restY),
// while still leaving suspensionTravel of compress/droop room. // while still leaving suspensionTravel of compress/droop room.
const anchorY = BUS.wheelRestOffsetY - BUS.suspensionTravel; // this._wheelRestOffsetY is the campaign's mount point (the lift-kit
// knob) - raising it lowers the wheel under the chassis and, since
// the anchor rides with it, the bus visibly sits taller.
const anchorY = this._wheelRestOffsetY - BUS.suspensionTravel;
// The wheel doesn't hang straight down from a single point (that point // The wheel doesn't hang straight down from a single point (that point
// is split into two, spread by axleSpread), so the constraint's target // is split into two, spread by axleSpread), so the constraint's target
// length is the hypotenuse to the spread anchor, not just the vertical // length is the hypotenuse to the spread anchor, not just the vertical
@ -44,7 +67,7 @@ export default class Bus {
// of hanging level, snapping into place on the first physics step. // of hanging level, snapping into place on the first physics step.
const constraintLength = Math.hypot(BUS.axleSpread, BUS.suspensionTravel); const constraintLength = Math.hypot(BUS.axleSpread, BUS.suspensionTravel);
this.chassis = matter.add.image(x, y, 'bus_chassis', null, { this.chassis = matter.add.image(x, y, this.chassisKey, null, {
shape: { type: 'rectangle', width: BUS.chassisWidth, height: BUS.chassisHeight }, shape: { type: 'rectangle', width: BUS.chassisWidth, height: BUS.chassisHeight },
chamfer: { radius: BUS.chassisHeight * 0.35 }, chamfer: { radius: BUS.chassisHeight * 0.35 },
density: BUS.chassisDensity, density: BUS.chassisDensity,
@ -60,9 +83,9 @@ export default class Bus {
// alpha. // alpha.
this.chassis.setDepth(2); this.chassis.setDepth(2);
const wheelY = y + BUS.wheelRestOffsetY; const wheelY = y + this._wheelRestOffsetY;
this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY); this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY, this.wheelTextureKey, this.wheelRadius);
this.wheelFront = this._createWheel(x + BUS.frontWheelOffsetX, wheelY); this.wheelFront = this._createWheel(x + BUS.frontWheelOffsetX, wheelY, this.wheelTextureKey, this.wheelRadius);
this.wheelRearLinks = this._attachWishbone(this.wheelRear, BUS.rearWheelOffsetX, anchorY, constraintLength); this.wheelRearLinks = this._attachWishbone(this.wheelRear, BUS.rearWheelOffsetX, anchorY, constraintLength);
this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength); this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength);
@ -225,9 +248,9 @@ export default class Bus {
]; ];
} }
_createWheel(x, y) { _createWheel(x, y, textureKey, radius) {
const wheel = this.scene.matter.add.image(x, y, 'bus_wheel', null, { const wheel = this.scene.matter.add.image(x, y, textureKey, null, {
shape: { type: 'circle', radius: BUS.wheelRadius }, shape: { type: 'circle', radius },
density: BUS.wheelDensity, density: BUS.wheelDensity,
friction: BUS.wheelFriction, friction: BUS.wheelFriction,
frictionStatic: BUS.wheelFrictionStatic, frictionStatic: BUS.wheelFrictionStatic,
@ -235,7 +258,7 @@ export default class Bus {
restitution: BUS.wheelRestitution, restitution: BUS.wheelRestitution,
collisionFilter: { group: this.group, category: this.busCategory }, collisionFilter: { group: this.group, category: this.busCategory },
}); });
wheel.setDisplaySize(BUS.wheelRadius * 2, BUS.wheelRadius * 2); wheel.setDisplaySize(radius * 2, radius * 2);
wheel.setDepth(2); // matches the chassis - see its setDepth comment wheel.setDepth(2); // matches the chassis - see its setDepth comment
return wheel; return wheel;
} }

View File

@ -1,21 +1,55 @@
import { TERRAIN_DEPTH, WORLD_SCALE } from '../config.js'; import { TERRAIN_DEPTH, WORLD_SCALE } from '../config.js';
// Palette picked to match assets/backgrounds/bg_mid.png and bg_near.png's // The ground's look is a per-campaign theme (see src/data/themes.js): the
// painterly foliage (deep leaf greens, an olive/yellow-green highlight, warm // palette + a `decor` style are injected at construction time, so each
// rust accents on a few "turning" leaves) so the drivable ground reads as // campaign can render the drivable surface in its own idiom. The two
// the same world as the parallax behind it, with a dirt-road strip riding // built-in styles:
// right on the surface where the bus actually touches down. // 'foliage' - campaign 1's baseline: painterly greens matching
const COLORS = { // 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, roadTop: 0xa5814f,
roadEdge: 0x5c4128, roadEdge: 0x5c4128,
roadPebble: 0x50381f, roadPebble: 0x50381f,
roadHighlight: 0xc9a56d, roadHighlight: 0xc9a56d,
subsoil: 0x3f5c28, subsoil: 0x3f5c28,
foliageMid: 0x36531f, mid: 0x36531f,
foliageDeep: 0x223a15, deep: 0x223a15,
tufts: [0x4a7a2e, 0x6fa23f, 0x9bbf4a, 0x8a5a3a], 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 // How deep (from the surface line, straight down) each visual layer reaches
// - not the physics depth (TERRAIN_DEPTH, which just needs to be deep // - not the physics depth (TERRAIN_DEPTH, which just needs to be deep
// enough nothing ever tunnels through the bottom). Purely a "how many // 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 SUBSOIL_DEPTH = ROAD_DEPTH + 46 * WORLD_SCALE;
const MID_FOLIAGE_DEPTH = TERRAIN_DEPTH * 0.55; 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 // 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 // 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 // retry would make the ground visibly "shuffle" between attempts at the
// same spot, which reads as a bug even though it's purely decorative. // 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 // each converted into a chain of angled static rectangle bodies extruded
// downward - simpler and more robust than concave polygon decomposition. // downward - simpler and more robust than concave polygon decomposition.
export default class Terrain { export default class Terrain {
constructor(scene, levelData) { constructor(scene, levelData, palette) {
this.scene = scene; this.scene = scene;
this.palette = getTerrainPalette(palette);
this.bodies = []; this.bodies = [];
this.textObjects = [];
this.graphics = scene.add.graphics(); this.graphics = scene.add.graphics();
for (const segment of levelData.terrain) { for (const segment of levelData.terrain) {
@ -138,29 +198,52 @@ export default class Terrain {
} }
_drawSegment(points) { _drawSegment(points) {
this._fillFromTop(points, TERRAIN_DEPTH, COLORS.foliageDeep); const p = this.palette;
this._fillFromTop(points, MID_FOLIAGE_DEPTH, COLORS.foliageMid); this._fillFromTop(points, TERRAIN_DEPTH, p.deep);
this._fillFromTop(points, SUBSOIL_DEPTH, COLORS.subsoil); this._fillFromTop(points, MID_FOLIAGE_DEPTH, p.mid);
this._fillFromTop(points, ROAD_DEPTH, COLORS.roadTop); this._fillFromTop(points, SUBSOIL_DEPTH, p.subsoil);
this._fillFromTop(points, ROAD_DEPTH, p.roadTop);
this._drawRoadTexture(points); this._drawRoadTexture(points);
const g = this.graphics; const g = this.graphics;
g.lineStyle(4 * WORLD_SCALE, COLORS.roadEdge, 1); g.lineStyle(4 * WORLD_SCALE, p.roadEdge, 1);
g.beginPath(); g.beginPath();
g.moveTo(points[0].x, points[0].y); 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(); 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();
} }
// Scatters small pebble/rut flecks across the dirt band so it doesn't // 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 road band so it doesn't
// read as a flat color fill - sampled a few times per segment rather // 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 // than per original point (point spacing depends on the level/editor's
// width settings, so this keeps texture density roughly constant // width settings, so this keeps texture density roughly constant
// regardless of how the terrain was authored). // regardless of how the terrain was authored).
_drawRoadTexture(points) { _drawRoadTexture(points) {
const p = this.palette;
const g = this.graphics; const g = this.graphics;
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < points.length - 1; i++) {
const a = points[i]; const a = points[i];
@ -177,19 +260,20 @@ export default class Terrain {
const isHighlight = hashRandom(seed + 2) > 0.55; const isHighlight = hashRandom(seed + 2) > 0.55;
const radius = (1.3 + hashRandom(seed + 3) * 1.5) * WORLD_SCALE; 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); g.fillCircle(px, py, radius);
} }
} }
} }
// Sparse little grass/leaf blades poking up right at the road's edge, // 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 // leaned and colored from the foliage palette - breaks up the otherwise
// otherwise perfectly straight edge line and is what actually reads as // perfectly straight edge line and is what actually reads as "foliage"
// "foliage" rather than just a flat green fill underneath. // rather than just a flat green fill underneath.
_drawFoliageTufts(points) { _drawFoliageTufts(points) {
const p = this.palette;
const g = this.graphics; const g = this.graphics;
const palette = COLORS.tufts; const palette = p.tufts || OUTDOORS_PALETTE.tufts;
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < points.length - 1; i++) {
const a = points[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

@ -61,7 +61,7 @@ export default class LevelSelectScene extends Phaser.Scene {
// shown; it handles first-time start, campaign-to-campaign swaps, and // shown; it handles first-time start, campaign-to-campaign swaps, and
// re-entry from the main menu alike. // re-entry from the main menu alike.
this.campaignIndex = 0; this.campaignIndex = this._defaultCampaignIndex();
this._groups = { bg: [], path: [], nodes: [], tags: [] }; this._groups = { bg: [], path: [], nodes: [], tags: [] };
this._transitioning = false; this._transitioning = false;
this._pulseTween = null; this._pulseTween = null;
@ -108,6 +108,18 @@ export default class LevelSelectScene extends Phaser.Scene {
// ------------------------------------------------------------------ build // ------------------------------------------------------------------ build
// Enter on the campaign the player is currently on: the one containing the
// first uncleared level in game order (levels unlock strictly in that order,
// so the campaign holding it is always a campaign that is unlocked). If
// everything is cleared, fall back to campaign 1 (index 0). An empty
// campaign can't hold the current level, so it's skipped.
_defaultCampaignIndex() {
for (let i = 0; i < CAMPAIGNS.length; i++) {
if (CAMPAIGNS[i].levels.some((l) => !isLevelComplete(l.id))) return i;
}
return 0;
}
_buildCampaign(instant = false) { _buildCampaign(instant = false) {
for (const key of Object.keys(this._groups)) { for (const key of Object.keys(this._groups)) {
for (const obj of this._groups[key]) obj.destroy(); for (const obj of this._groups[key]) obj.destroy();

View File

@ -1,6 +1,7 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE, COMPARTMENT_DEBUG } from '../config.js'; import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE, COMPARTMENT_DEBUG } from '../config.js';
import { getLevelById } from '../data/levels/index.js'; import { getLevelById, getCampaignOfLevel } from '../data/levels/index.js';
import { getTheme } from '../data/themes.js';
import Bus from '../entities/Bus.js'; import Bus from '../entities/Bus.js';
import Terrain from '../entities/Terrain.js'; import Terrain from '../entities/Terrain.js';
import FinishLine from '../entities/FinishLine.js'; import FinishLine from '../entities/FinishLine.js';
@ -33,14 +34,31 @@ export default class PlayScene extends Phaser.Scene {
create() { create() {
stopMenuMusic(this); stopMenuMusic(this);
this.cameras.main.setBackgroundColor('#8fc7e8'); // Per-campaign theme (data/themes.js) - drives the base color and the
// parallax art below. Campaign 1 is the default look; each new campaign
// can override any of it.
const campaign = getCampaignOfLevel(this.levelId);
this.theme = getTheme(campaign ? campaign.id : null);
this.cameras.main.setBackgroundColor(this.theme.bgColor);
this._buildParallax(); 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.finishLine = new FinishLine(this, this.level);
this.bus = new Bus(this, this.level.startPosition.x, this.level.startPosition.y, this.level.startAngle || 0); this.bus = new Bus(
this,
this.level.startPosition.x,
this.level.startPosition.y,
this.level.startAngle || 0,
// Campaign 2 gets its own wheel art + size from here (see the `bus`
// block in data/themes.js); campaign 1 uses the defaults in Bus.js.
this.theme.bus
);
this.kidManager = new KidManager(this, this.bus, this.level.kidsAboard); this.kidManager = new KidManager(this, this.bus, this.level.kidsAboard);
@ -111,9 +129,9 @@ export default class PlayScene extends Phaser.Scene {
// manually scrubs its tilePositionX each frame based on progress from // manually scrubs its tilePositionX each frame based on progress from
// start to goal, so the single image pans start-to-end exactly once // start to goal, so the single image pans start-to-end exactly once
// over the course of the level. // over the course of the level.
const farNative = this._nativeTextureSize('bg_far'); const farNative = this._nativeTextureSize(this.theme.parallax.far);
const farScale = GAME_HEIGHT / farNative.height; const farScale = GAME_HEIGHT / farNative.height;
this.bgFar = this.add.tileSprite(0, 0, GAME_WIDTH, GAME_HEIGHT, 'bg_far').setOrigin(0, 0); this.bgFar = this.add.tileSprite(0, 0, GAME_WIDTH, GAME_HEIGHT, this.theme.parallax.far).setOrigin(0, 0);
this.bgFar.setScrollFactor(0, 0); this.bgFar.setScrollFactor(0, 0);
this.bgFar.tileScaleX = farScale; this.bgFar.tileScaleX = farScale;
this.bgFar.tileScaleY = farScale; this.bgFar.tileScaleY = farScale;
@ -125,8 +143,8 @@ export default class PlayScene extends Phaser.Scene {
// count isn't pinned to an exact number - it falls out of each layer's // count isn't pinned to an exact number - it falls out of each layer's
// target height, and near's is shorter than mid's, so it naturally // target height, and near's is shorter than mid's, so it naturally
// repeats more often across the same span. // repeats more often across the same span.
this._addGroundLayer('bg_mid', bounds.x, width, GAME_HEIGHT * 0.6, 0.45); this._addGroundLayer(this.theme.parallax.mid, bounds.x, width, GAME_HEIGHT * 0.6, 0.45);
this._addGroundLayer('bg_near', bounds.x, width, GAME_HEIGHT * 0.35, 0.75); this._addGroundLayer(this.theme.parallax.near, bounds.x, width, GAME_HEIGHT * 0.35, 0.75);
} }
_nativeTextureSize(key) { _nativeTextureSize(key) {
@ -343,6 +361,7 @@ export default class PlayScene extends Phaser.Scene {
// internally (verified against the vendored source), so this was always // internally (verified against the vendored source), so this was always
// redundant, not just now-unsafe. // redundant, not just now-unsafe.
if (this.compartmentDebug) this.compartmentDebug.destroy(); if (this.compartmentDebug) this.compartmentDebug.destroy();
if (this.terrain) this.terrain.destroy();
if (this.gForceMonitor) this.gForceMonitor.destroy(); if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy(); if (this.kidManager) this.kidManager.destroy();
if (this.smashItems) this.smashItems.destroy(); if (this.smashItems) this.smashItems.destroy();

View File

@ -5,7 +5,14 @@ import { WORLD_SCALE } from '../config.js';
// nothing else in the codebase needs to change (see placeholderTextures.js). // nothing else in the codebase needs to change (see placeholderTextures.js).
export const ASSET_MANIFEST = [ export const ASSET_MANIFEST = [
{ key: 'bus_chassis', path: 'assets/sprites/bus_chassis.png', width: 170 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x2255aa }, { key: 'bus_chassis', path: 'assets/sprites/bus_chassis.png', width: 170 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x2255aa },
// Campaign 2's chassis art - same 340x128 design box, different look.
// Picked per campaign via the `bus` theme entry in data/themes.js; the
// physics body is unchanged (see Bus.js's constructor).
{ key: 'bus_chassis_2', path: 'assets/sprites/bus_chassis_2.png', width: 170 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x2255aa },
{ key: 'bus_wheel', path: 'assets/sprites/bus_wheel.png', width: 34 * WORLD_SCALE, height: 34 * WORLD_SCALE, kind: 'circle', color: 0x222222 }, { key: 'bus_wheel', path: 'assets/sprites/bus_wheel.png', width: 34 * WORLD_SCALE, height: 34 * WORLD_SCALE, kind: 'circle', color: 0x222222 },
// Campaign 2's bigger wheels (125px source vs bus_wheel.png's 104px) -
// picked per campaign via the `bus` theme entry in data/themes.js.
{ key: 'bus_wheel_2', path: 'assets/sprites/bus_wheel_2.png', width: 34 * WORLD_SCALE, height: 34 * WORLD_SCALE, kind: 'circle', color: 0x222222 },
// Real art is a 5-frame sheet (one distinct kid per frame, 56x56px each, // Real art is a 5-frame sheet (one distinct kid per frame, 56x56px each,
// matching BUS.maxSeats) - frameWidth/frameHeight (raw source pixels, NOT // matching BUS.maxSeats) - frameWidth/frameHeight (raw source pixels, NOT
// WORLD_SCALE-relative) make PreloadScene load it as a spritesheet // WORLD_SCALE-relative) make PreloadScene load it as a spritesheet
@ -22,9 +29,15 @@ export const ASSET_MANIFEST = [
// 9 frames of 64x64 bonus items (CTC box + 2 firework frames, guitar + 2, // 9 frames of 64x64 bonus items (CTC box + 2 firework frames, guitar + 2,
// cash + 2 - see BONUS_ITEM in config.js and BonusItemManager.js). // cash + 2 - see BONUS_ITEM in config.js and BonusItemManager.js).
{ key: 'bonus_items', path: 'assets/sprites/bonus-items.png', width: 64 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0xe0a040, frameWidth: 64, frameHeight: 64 }, { key: 'bonus_items', path: 'assets/sprites/bonus-items.png', width: 64 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0xe0a040, frameWidth: 64, frameHeight: 64 },
// Campaign 1 ("Evergreen Lake") parallax set - see DEFAULT_THEME in
// data/themes.js. The _2 set is campaign 2's; both are 3136x672 natively
// (width/height below only feed the placeholder fallback, never display).
{ key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true }, { key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true },
{ key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true }, { key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true },
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true }, { key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },
{ key: 'bg_far_2', path: 'assets/backgrounds/bg_far_2.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x92656d, tileable: true },
{ key: 'bg_mid_2', path: 'assets/backgrounds/bg_mid_2.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x644c44, tileable: true },
{ key: 'bg_near_2', path: 'assets/backgrounds/bg_near_2.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x554239, tileable: true },
// Intro screen's poster art (960x540 native design aspect) and the // Intro screen's poster art (960x540 native design aspect) and the
// standalone wordmark graphic IntroScene layers on top of it, animated - // standalone wordmark graphic IntroScene layers on top of it, animated -
// see IntroScene._buildLogo. // see IntroScene._buildLogo.