Compare commits
5 Commits
a728a51cd2
...
8cba629232
| Author | SHA1 | Date |
|---|---|---|
|
|
8cba629232 | |
|
|
eaeebf1980 | |
|
|
3033c1d62d | |
|
|
c17c0cf7e8 | |
|
|
c2abb5c4ea |
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 |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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
|
||||
visually spins as the bus drives; a plain hubcap/circle reads better at
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ export const BUS = {
|
|||
// the axles are NOT symmetric around the chassis center). Offsets are from
|
||||
// the chassis center; re-measure these if bus_chassis.png is replaced.
|
||||
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,
|
||||
frontWheelOffsetX: 64 * WORLD_SCALE,
|
||||
// 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),
|
||||
// a deliberate look/feel tradeoff rather than a measurement.
|
||||
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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import level10 from './level10.js';
|
|||
import level11 from './level11.js';
|
||||
|
||||
// Campaigns group levels into themed "maps" for the level select screen.
|
||||
// Each campaign gets its own background art (bgKey) and theme, so adding a
|
||||
// new campaign is: create its levels, add a bg image + manifest entry, add a
|
||||
// row here. Node positions are in 960x540 design units (NOT multiplied by
|
||||
// Each campaign gets its own background art (bgKey), menu theme music
|
||||
// (musicKey), and in-level look (data/themes.js), so adding a new campaign
|
||||
// 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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -8,9 +8,29 @@ import { BUS } from '../config.js';
|
|||
// with no vertical give, which is too harsh for a bus that needs real
|
||||
// suspension travel to absorb bumps.
|
||||
export default class Bus {
|
||||
constructor(scene, x, y, angle = 0) {
|
||||
constructor(scene, x, y, angle = 0, busTheme = null) {
|
||||
this.scene = scene;
|
||||
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);
|
||||
// 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
|
||||
// what lands it exactly on wheelRestOffsetY (anchorY + travel = restY),
|
||||
// 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
|
||||
// is split into two, spread by axleSpread), so the constraint's target
|
||||
// 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.
|
||||
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 },
|
||||
chamfer: { radius: BUS.chassisHeight * 0.35 },
|
||||
density: BUS.chassisDensity,
|
||||
|
|
@ -60,9 +83,9 @@ export default class Bus {
|
|||
// alpha.
|
||||
this.chassis.setDepth(2);
|
||||
|
||||
const wheelY = y + BUS.wheelRestOffsetY;
|
||||
this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY);
|
||||
this.wheelFront = this._createWheel(x + BUS.frontWheelOffsetX, wheelY);
|
||||
const wheelY = y + this._wheelRestOffsetY;
|
||||
this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY, this.wheelTextureKey, this.wheelRadius);
|
||||
this.wheelFront = this._createWheel(x + BUS.frontWheelOffsetX, wheelY, this.wheelTextureKey, this.wheelRadius);
|
||||
|
||||
this.wheelRearLinks = this._attachWishbone(this.wheelRear, BUS.rearWheelOffsetX, anchorY, constraintLength);
|
||||
this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength);
|
||||
|
|
@ -225,9 +248,9 @@ export default class Bus {
|
|||
];
|
||||
}
|
||||
|
||||
_createWheel(x, y) {
|
||||
const wheel = this.scene.matter.add.image(x, y, 'bus_wheel', null, {
|
||||
shape: { type: 'circle', radius: BUS.wheelRadius },
|
||||
_createWheel(x, y, textureKey, radius) {
|
||||
const wheel = this.scene.matter.add.image(x, y, textureKey, null, {
|
||||
shape: { type: 'circle', radius },
|
||||
density: BUS.wheelDensity,
|
||||
friction: BUS.wheelFriction,
|
||||
frictionStatic: BUS.wheelFrictionStatic,
|
||||
|
|
@ -235,7 +258,7 @@ export default class Bus {
|
|||
restitution: BUS.wheelRestitution,
|
||||
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
|
||||
return wheel;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default class LevelSelectScene extends Phaser.Scene {
|
|||
// shown; it handles first-time start, campaign-to-campaign swaps, and
|
||||
// re-entry from the main menu alike.
|
||||
|
||||
this.campaignIndex = 0;
|
||||
this.campaignIndex = this._defaultCampaignIndex();
|
||||
this._groups = { bg: [], path: [], nodes: [], tags: [] };
|
||||
this._transitioning = false;
|
||||
this._pulseTween = null;
|
||||
|
|
@ -108,6 +108,18 @@ export default class LevelSelectScene extends Phaser.Scene {
|
|||
|
||||
// ------------------------------------------------------------------ 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) {
|
||||
for (const key of Object.keys(this._groups)) {
|
||||
for (const obj of this._groups[key]) obj.destroy();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Phaser from 'phaser';
|
||||
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 Terrain from '../entities/Terrain.js';
|
||||
import FinishLine from '../entities/FinishLine.js';
|
||||
|
|
@ -33,14 +34,31 @@ export default class PlayScene extends Phaser.Scene {
|
|||
create() {
|
||||
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.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);
|
||||
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);
|
||||
|
||||
|
|
@ -111,9 +129,9 @@ export default class PlayScene extends Phaser.Scene {
|
|||
// manually scrubs its tilePositionX each frame based on progress from
|
||||
// start to goal, so the single image pans start-to-end exactly once
|
||||
// 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;
|
||||
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.tileScaleX = 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
|
||||
// target height, and near's is shorter than mid's, so it naturally
|
||||
// repeats more often across the same span.
|
||||
this._addGroundLayer('bg_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.mid, bounds.x, width, GAME_HEIGHT * 0.6, 0.45);
|
||||
this._addGroundLayer(this.theme.parallax.near, bounds.x, width, GAME_HEIGHT * 0.35, 0.75);
|
||||
}
|
||||
|
||||
_nativeTextureSize(key) {
|
||||
|
|
@ -343,6 +361,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();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ import { WORLD_SCALE } from '../config.js';
|
|||
// nothing else in the codebase needs to change (see placeholderTextures.js).
|
||||
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 },
|
||||
// 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 },
|
||||
// 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,
|
||||
// matching BUS.maxSeats) - frameWidth/frameHeight (raw source pixels, NOT
|
||||
// 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,
|
||||
// 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 },
|
||||
// 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_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_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
|
||||
// standalone wordmark graphic IntroScene layers on top of it, animated -
|
||||
// see IntroScene._buildLogo.
|
||||
|
|
|
|||
Loading…
Reference in New Issue