Rework ejection feel, parallax backgrounds, and camera bounds

- Ejection: drop GFORCE.ejectThresholdG from 6 to 0.5 - the bouncier
  suspension isolates the chassis from shock, so the old threshold was
  effectively unreachable and kids were never actually being ejected.
  Ejected kids now pop straight up through the roof (ignoring chassis
  velocity/tilt) and fly under a lighter gravity (KID.ejectedGravityScale
  0.35, applied via KidManager's beforeupdate handler) for a floaty,
  Snuggle-Truck-style arc.
- Cut maxLeanAngularVelocity and leanTorqueStep in half for a slower,
  more gradual lean that's easier to fine-tune mid-air.
- Parallax: far layer is now screen-fixed, scaled uniformly (no stretch),
  and pans exactly once from start to goal by level progress; mid/near
  layers are bottom-anchored and tiled at their own uniform scales. Kids
  render at depth -1 so the bus always draws over them.
- Level builder: size camera bounds to the level's actual terrain min/max
  Y (with padding, 1000px floor) instead of a fixed 1000 height, so long
  runs of declines no longer push the bus below the camera's reach.
- Add level 04 "Mega Drop" (5 kids, extended terrain ending in the mega
  drop sequence).
This commit is contained in:
Brian Fertig 2026-08-19 20:58:01 -06:00
parent 4973b22f7c
commit 9892df762c
6 changed files with 467 additions and 27 deletions

View File

@ -93,17 +93,28 @@ export const BUS = {
driveAcceleration: 0.0045, driveAcceleration: 0.0045,
brakeAcceleration: 0.007, brakeAcceleration: 0.007,
// Bumped up to stay responsive at the new higher speed - sluggish lean // Cut by more than half (both the cap and the per-tick torque step) for a
// control would feel wrong on a bus that's now moving quickly. // much slower, more gradual lean - easier to fine-tune mid-air at the
maxLeanAngularVelocity: 0.13, // cost of snappiness.
leanTorqueStep: 0.018, maxLeanAngularVelocity: 0.06,
leanTorqueStep: 0.004,
}; };
export const GFORCE = { export const GFORCE = {
// Scaled with WORLD_SCALE so "meters" keeps the same real-world meaning // Scaled with WORLD_SCALE so "meters" keeps the same real-world meaning
// relative to the bus, and ejectThresholdG doesn't need re-tuning. // relative to the bus, and ejectThresholdG doesn't need re-tuning.
pixelsPerMeter: 50 * WORLD_SCALE, pixelsPerMeter: 50 * WORLD_SCALE,
ejectThresholdG: 6, // primary tuning knob - not physically derived, tune by playtesting // The bouncier suspension (see BUS.suspensionStiffness/Damping/Travel)
// isolates the CHASSIS from shock by design - which starves this of any
// signal to trigger on. Verified via a headless matter-js simulation
// replicating these exact constants: even a 1300px vertical drop from
// rest only reaches ~0.8g at the chassis, so the old 3-6g range was
// effectively unreachable - kids were never actually being ejected at
// all, not just "rarely." 0.5 is set from that same data (crossed by
// roughly a 400px+ drop, not by gentle rolling hills) - primary tuning
// knob, re-verify by playtesting since the simulation didn't model real
// terrain bumps, only vertical drops.
ejectThresholdG: 0.5,
gracePeriodMs: 500, gracePeriodMs: 500,
ejectImpulseMagnitude: 0.03 * WORLD_SCALE, ejectImpulseMagnitude: 0.03 * WORLD_SCALE,
// A single hard landing can stay above threshold for several consecutive // A single hard landing can stay above threshold for several consecutive
@ -120,6 +131,14 @@ export const KID = {
seatDamping: 0.1, seatDamping: 0.1,
seatLength: 4 * WORLD_SCALE, seatLength: 4 * WORLD_SCALE,
settleTimeMs: 2000, settleTimeMs: 2000,
// Once ejected, a kid ignores the world's real gravity entirely and
// KidManager applies this fraction of it instead (see
// KidManager._onBeforeUpdate) - horizontal velocity is untouched either
// way (this game's gravity is purely vertical), so a lighter fall is all
// it takes for an ejected kid to hang in the air and arc much further
// than the bus does, Snuggle-Truck style. Primary tuning knob for "how
// floaty," not physically derived.
ejectedGravityScale: 0.35,
}; };
export const CAMERA = { export const CAMERA = {

View File

@ -1,9 +1,9 @@
// Generated by editor.html - review before dropping into src/data/levels/. // Generated by editor.html - review before dropping into src/data/levels/.
export default { export default {
id: "levelCustom", id: "level04",
name: "New Level", name: "Mega Drop",
description: "", description: "",
kidsAboard: 3, kidsAboard: 5,
startPosition: { x: 200, y: 700 }, startPosition: { x: 200, y: 700 },
startAngle: 0, startAngle: 0,
terrain: [ terrain: [
@ -149,9 +149,325 @@ export default {
{ x: 9260, y: 1294 }, { x: 9260, y: 1294 },
{ x: 9330, y: 1298 }, { x: 9330, y: 1298 },
{ x: 9400, y: 1300 }, { x: 9400, y: 1300 },
{ x: 9470, y: 1316 },
{ x: 9540, y: 1332 },
{ x: 9610, y: 1348 },
{ x: 9680, y: 1364 },
{ x: 9750, y: 1380 },
{ x: 9820, y: 1396 },
{ x: 9890, y: 1412 },
{ x: 9960, y: 1428 },
{ x: 10030, y: 1444 },
{ x: 10100, y: 1460 },
{ x: 10170, y: 1476 },
{ x: 10240, y: 1492 },
{ x: 10310, y: 1508 },
{ x: 10380, y: 1524 },
{ x: 10450, y: 1540 },
{ x: 10520, y: 1556 },
{ x: 10590, y: 1572 },
{ x: 10660, y: 1588 },
{ x: 10730, y: 1604 },
{ x: 10800, y: 1620 },
{ x: 10870, y: 1636 },
{ x: 10940, y: 1652 },
{ x: 11010, y: 1668 },
{ x: 11080, y: 1684 },
{ x: 11150, y: 1700 },
{ x: 11220, y: 1716 },
{ x: 11290, y: 1732 },
{ x: 11360, y: 1748 },
{ x: 11430, y: 1764 },
{ x: 11500, y: 1780 },
{ x: 11570, y: 1796 },
{ x: 11640, y: 1812 },
{ x: 11710, y: 1828 },
{ x: 11780, y: 1844 },
{ x: 11850, y: 1860 },
{ x: 11920, y: 1876 },
{ x: 11990, y: 1892 },
{ x: 12060, y: 1908 },
{ x: 12130, y: 1924 },
{ x: 12200, y: 1940 },
{ x: 12270, y: 1956 },
{ x: 12340, y: 1972 },
{ x: 12410, y: 1988 },
{ x: 12480, y: 2004 },
{ x: 12550, y: 2020 },
{ x: 12620, y: 2036 },
{ x: 12690, y: 2052 },
{ x: 12760, y: 2068 },
{ x: 12830, y: 2084 },
{ x: 12900, y: 2100 },
{ x: 12970, y: 2116 },
{ x: 13040, y: 2132 },
{ x: 13110, y: 2148 },
{ x: 13180, y: 2164 },
{ x: 13250, y: 2180 },
{ x: 13320, y: 2196 },
{ x: 13390, y: 2212 },
{ x: 13460, y: 2228 },
{ x: 13530, y: 2244 },
{ x: 13600, y: 2260 },
{ x: 13670, y: 2276 },
{ x: 13740, y: 2292 },
{ x: 13810, y: 2308 },
{ x: 13880, y: 2324 },
{ x: 13950, y: 2340 },
{ x: 14020, y: 2356 },
{ x: 14090, y: 2372 },
{ x: 14160, y: 2388 },
{ x: 14230, y: 2404 },
{ x: 14300, y: 2420 },
{ x: 14370, y: 2436 },
{ x: 14440, y: 2452 },
{ x: 14510, y: 2468 },
{ x: 14580, y: 2484 },
{ x: 14650, y: 2500 },
{ x: 14720, y: 2516 },
{ x: 14790, y: 2532 },
{ x: 14860, y: 2548 },
{ x: 14930, y: 2564 },
{ x: 15000, y: 2580 },
{ x: 15070, y: 2596 },
{ x: 15140, y: 2612 },
{ x: 15210, y: 2628 },
{ x: 15280, y: 2644 },
{ x: 15350, y: 2660 },
{ x: 15420, y: 2676 },
{ x: 15490, y: 2692 },
{ x: 15560, y: 2708 },
{ x: 15630, y: 2724 },
{ x: 15700, y: 2740 },
{ x: 15770, y: 2756 },
{ x: 15840, y: 2772 },
{ x: 15910, y: 2788 },
{ x: 15980, y: 2804 },
{ x: 16050, y: 2820 },
{ x: 16120, y: 2836 },
{ x: 16190, y: 2852 },
{ x: 16260, y: 2868 },
{ x: 16330, y: 2884 },
{ x: 16400, y: 2900 },
{ x: 16470, y: 2900 },
{ x: 16540, y: 2900 },
{ x: 16610, y: 2900 },
{ x: 16680, y: 2900 },
{ x: 16750, y: 2900 },
{ x: 16820, y: 2900 },
{ x: 16890, y: 2900 },
{ x: 16960, y: 2900 },
{ x: 17030, y: 2900 },
{ x: 17100, y: 2900 },
{ x: 17170, y: 2900 },
{ x: 17240, y: 2900 },
{ x: 17310, y: 2900 },
{ x: 17380, y: 2900 },
{ x: 17450, y: 2900 },
{ x: 17520, y: 2900 },
{ x: 17590, y: 2900 },
{ x: 17660, y: 2900 },
{ x: 17730, y: 2900 },
{ x: 17800, y: 2900 },
{ x: 17870, y: 2866 },
{ x: 17940, y: 2830 },
{ x: 18010, y: 2796 },
{ x: 18080, y: 2760 },
{ x: 18150, y: 2726 },
{ x: 18220, y: 2690 },
{ x: 18290, y: 2656 },
{ x: 18360, y: 2620 },
{ x: 18430, y: 2620 },
] },
{ points: [
{ x: 18780, y: 2740 },
{ x: 18850, y: 2752 },
{ x: 18920, y: 2782 },
{ x: 18990, y: 2820 },
{ x: 19060, y: 2858 },
{ x: 19130, y: 2888 },
{ x: 19200, y: 2900 },
{ x: 19270, y: 2928 },
{ x: 19340, y: 2952 },
{ x: 19410, y: 2972 },
{ x: 19480, y: 2986 },
{ x: 19550, y: 2990 },
{ x: 19620, y: 2986 },
{ x: 19690, y: 2972 },
{ x: 19760, y: 2952 },
{ x: 19830, y: 2928 },
{ x: 19900, y: 2900 },
{ x: 19970, y: 2872 },
{ x: 20040, y: 2848 },
{ x: 20110, y: 2828 },
{ x: 20180, y: 2814 },
{ x: 20250, y: 2810 },
{ x: 20320, y: 2814 },
{ x: 20390, y: 2828 },
{ x: 20460, y: 2848 },
{ x: 20530, y: 2872 },
{ x: 20600, y: 2900 },
{ x: 20670, y: 2940 },
{ x: 20740, y: 2980 },
{ x: 20810, y: 3018 },
{ x: 20880, y: 3052 },
{ x: 20950, y: 3084 },
{ x: 21020, y: 3110 },
{ x: 21090, y: 3132 },
{ x: 21160, y: 3148 },
{ x: 21230, y: 3156 },
{ x: 21300, y: 3160 },
{ x: 21370, y: 3156 },
{ x: 21440, y: 3148 },
{ x: 21510, y: 3132 },
{ x: 21580, y: 3110 },
{ x: 21650, y: 3084 },
{ x: 21720, y: 3052 },
{ x: 21790, y: 3018 },
{ x: 21860, y: 2980 },
{ x: 21930, y: 2940 },
{ x: 22000, y: 2900 },
{ x: 22070, y: 2866 },
{ x: 22140, y: 2830 },
{ x: 22210, y: 2796 },
{ x: 22280, y: 2760 },
{ x: 22350, y: 2726 },
{ x: 22420, y: 2690 },
{ x: 22490, y: 2656 },
{ x: 22560, y: 2620 },
{ x: 22630, y: 2620 },
] },
{ points: [
{ x: 22980, y: 2740 },
{ x: 23050, y: 2752 },
{ x: 23120, y: 2782 },
{ x: 23190, y: 2820 },
{ x: 23260, y: 2858 },
{ x: 23330, y: 2888 },
{ x: 23400, y: 2900 },
{ x: 23470, y: 2884 },
{ x: 23540, y: 2868 },
{ x: 23610, y: 2852 },
{ x: 23680, y: 2836 },
{ x: 23750, y: 2820 },
{ x: 23820, y: 2804 },
{ x: 23890, y: 2788 },
{ x: 23960, y: 2772 },
{ x: 24030, y: 2756 },
{ x: 24100, y: 2740 },
{ x: 24170, y: 2724 },
{ x: 24240, y: 2708 },
{ x: 24310, y: 2692 },
{ x: 24380, y: 2676 },
{ x: 24450, y: 2660 },
{ x: 24520, y: 2644 },
{ x: 24590, y: 2628 },
{ x: 24660, y: 2612 },
{ x: 24730, y: 2596 },
{ x: 24800, y: 2580 },
{ x: 24870, y: 2564 },
{ x: 24940, y: 2548 },
{ x: 25010, y: 2532 },
{ x: 25080, y: 2516 },
{ x: 25150, y: 2500 },
{ x: 25220, y: 2484 },
{ x: 25290, y: 2468 },
{ x: 25360, y: 2452 },
{ x: 25430, y: 2436 },
{ x: 25500, y: 2420 },
{ x: 25570, y: 2404 },
{ x: 25640, y: 2388 },
{ x: 25710, y: 2372 },
{ x: 25780, y: 2356 },
{ x: 25850, y: 2340 },
{ x: 25920, y: 2324 },
{ x: 25990, y: 2308 },
{ x: 26060, y: 2292 },
{ x: 26130, y: 2276 },
{ x: 26200, y: 2260 },
{ x: 26270, y: 2244 },
{ x: 26340, y: 2228 },
{ x: 26410, y: 2212 },
{ x: 26480, y: 2196 },
{ x: 26550, y: 2180 },
{ x: 26620, y: 2164 },
{ x: 26690, y: 2148 },
{ x: 26760, y: 2132 },
{ x: 26830, y: 2116 },
{ x: 26900, y: 2100 },
{ x: 26970, y: 2084 },
{ x: 27040, y: 2068 },
{ x: 27110, y: 2052 },
{ x: 27180, y: 2036 },
{ x: 27250, y: 2020 },
{ x: 27320, y: 2004 },
{ x: 27390, y: 1988 },
{ x: 27460, y: 1972 },
{ x: 27530, y: 1956 },
{ x: 27600, y: 1940 },
{ x: 27670, y: 1956 },
{ x: 27740, y: 1972 },
{ x: 27810, y: 1988 },
{ x: 27880, y: 2004 },
{ x: 27950, y: 2020 },
{ x: 28020, y: 2036 },
{ x: 28090, y: 2052 },
{ x: 28160, y: 2068 },
{ x: 28230, y: 2084 },
{ x: 28300, y: 2100 },
{ x: 28370, y: 2116 },
{ x: 28440, y: 2132 },
{ x: 28510, y: 2148 },
{ x: 28580, y: 2164 },
{ x: 28650, y: 2180 },
{ x: 28720, y: 2196 },
{ x: 28790, y: 2212 },
{ x: 28860, y: 2228 },
{ x: 28930, y: 2244 },
{ x: 29000, y: 2260 },
{ x: 29070, y: 2260 },
{ x: 29140, y: 2260 },
{ x: 29210, y: 2260 },
{ x: 29280, y: 2260 },
{ x: 29350, y: 2260 },
{ x: 29420, y: 2260 },
{ x: 29490, y: 2260 },
{ x: 29560, y: 2260 },
{ x: 29630, y: 2260 },
{ x: 29700, y: 2260 },
{ x: 29770, y: 2260 },
{ x: 29840, y: 2260 },
{ x: 29910, y: 2260 },
{ x: 29980, y: 2260 },
{ x: 30050, y: 2260 },
{ x: 30120, y: 2260 },
{ x: 30190, y: 2260 },
{ x: 30260, y: 2260 },
{ x: 30330, y: 2260 },
{ x: 30400, y: 2260 },
{ x: 30470, y: 2260 },
{ x: 30540, y: 2260 },
{ x: 30610, y: 2260 },
{ x: 30680, y: 2260 },
{ x: 30750, y: 2260 },
{ x: 30820, y: 2260 },
{ x: 30890, y: 2260 },
{ x: 30960, y: 2260 },
{ x: 31030, y: 2260 },
{ x: 31100, y: 2260 },
{ x: 31170, y: 2260 },
{ x: 31240, y: 2260 },
{ x: 31310, y: 2260 },
{ x: 31380, y: 2260 },
{ x: 31450, y: 2260 },
{ x: 31520, y: 2260 },
{ x: 31590, y: 2260 },
{ x: 31660, y: 2260 },
{ x: 31730, y: 2260 },
{ x: 31800, y: 2260 },
] }, ] },
], ],
obstacles: [], obstacles: [],
goal: { x: 9160, y: 1060, width: 280, height: 640 }, goal: { x: 31560, y: 2020, width: 280, height: 640 },
cameraBounds: { x: -600, y: 0, width: 10800, height: 2000 }, cameraBounds: { x: -600, y: -140, width: 33200, height: 4100 },
}; };

View File

@ -14,7 +14,17 @@ const GOAL_END_MARGIN = 120;
const GOAL_HEIGHT_OFFSET = 120; const GOAL_HEIGHT_OFFSET = 120;
const CAMERA_LEFT_MARGIN = 300; const CAMERA_LEFT_MARGIN = 300;
const CAMERA_RIGHT_MARGIN = 700; const CAMERA_RIGHT_MARGIN = 700;
const CAMERA_HEIGHT = 1000; // Vertical camera bounds used to be a fixed 1000 regardless of the actual
// terrain - fine for the hand-authored levels (which stay near a constant
// baseline by design), but an editor sequence can chain multiple
// inclines/declines/ditches with no cap on cumulative elevation change, so
// a long run of declines could push the terrain (and the bus) below what a
// fixed-height bound could ever scroll down to reach - the camera would
// clamp at its bounds and the bus would drift off the bottom of the
// screen. Bounds are now sized to the level's actual min/max terrain Y,
// with generous padding, with this as just a floor for very flat levels.
const CAMERA_VERTICAL_PADDING = 400;
const CAMERA_MIN_HEIGHT = 1000;
function scalePoint(p) { function scalePoint(p) {
return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) }; return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) };
@ -69,6 +79,22 @@ export function buildLevelData(sectionTypeIds, metadata) {
const endX = Math.round(baseX * WORLD_SCALE); const endX = Math.round(baseX * WORLD_SCALE);
const groundYAtEnd = Math.round(baseY * WORLD_SCALE); const groundYAtEnd = Math.round(baseY * WORLD_SCALE);
let minTerrainY = Infinity;
let maxTerrainY = -Infinity;
for (const segment of terrainSegments) {
for (const p of segment.points) {
minTerrainY = Math.min(minTerrainY, p.y);
maxTerrainY = Math.max(maxTerrainY, p.y);
}
}
const verticalPadding = Math.round(CAMERA_VERTICAL_PADDING * WORLD_SCALE);
const cameraTop = Math.min(0, minTerrainY - verticalPadding);
const cameraHeight = Math.max(
Math.round(CAMERA_MIN_HEIGHT * WORLD_SCALE),
maxTerrainY + verticalPadding - cameraTop
);
return { return {
id: metadata.id, id: metadata.id,
name: metadata.name, name: metadata.name,
@ -89,9 +115,9 @@ export function buildLevelData(sectionTypeIds, metadata) {
}, },
cameraBounds: { cameraBounds: {
x: -Math.round(CAMERA_LEFT_MARGIN * WORLD_SCALE), x: -Math.round(CAMERA_LEFT_MARGIN * WORLD_SCALE),
y: 0, y: cameraTop,
width: endX + Math.round(CAMERA_RIGHT_MARGIN * WORLD_SCALE), width: endX + Math.round(CAMERA_RIGHT_MARGIN * WORLD_SCALE),
height: Math.round(CAMERA_HEIGHT * WORLD_SCALE), height: cameraHeight,
}, },
}; };
} }

View File

@ -18,6 +18,11 @@ export default class Kid {
collisionFilter: { group: bus.group }, collisionFilter: { group: bus.group },
}); });
this.image.setDisplaySize(KID.radius * 2, KID.radius * 2); this.image.setDisplaySize(KID.radius * 2, KID.radius * 2);
// Bus parts are all left at Phaser's default depth (0) - render kids
// just behind that, so the chassis always draws over them (seated,
// ejected, or falling back down) instead of insertion order deciding
// it implicitly.
this.image.setDepth(-1);
this.seatConstraint = scene.matter.add.constraint(bus.chassis, this.image, KID.seatLength, KID.seatStiffness, { this.seatConstraint = scene.matter.add.constraint(bus.chassis, this.image, KID.seatLength, KID.seatStiffness, {
pointA: seat, pointA: seat,
@ -28,7 +33,7 @@ export default class Kid {
this._settleAt = 0; this._settleAt = 0;
} }
eject(chassisVelocity, gForce) { eject(gForce) {
if (this.state !== 'aboard') return; if (this.state !== 'aboard') return;
this.state = 'ejected'; this.state = 'ejected';
@ -40,14 +45,23 @@ export default class Kid {
this.image.body.collisionFilter.group = 0; this.image.body.collisionFilter.group = 0;
this.image.setTexture('kid_ejected'); this.image.setTexture('kid_ejected');
const speed = Math.hypot(chassisVelocity.x, chassisVelocity.y) || 1; // Opt out of the world's real gravity - KidManager applies a lighter,
const dirX = chassisVelocity.x / speed; // custom fraction of it each tick instead (see
const dirY = chassisVelocity.y / speed; // KidManager._onBeforeUpdate), so the kid floats through a much bigger
// arc than the bus's own fall would.
this.image.body.ignoreGravity = true;
const severity = Phaser.Math.Clamp(gForce / GFORCE.ejectThresholdG, 1, 3); const severity = Phaser.Math.Clamp(gForce / GFORCE.ejectThresholdG, 1, 3);
// Always pops straight up through the roof, never sideways or downward
// - deliberately ignores the chassis's actual velocity/tilt at impact,
// which previously could fling a kid out sideways or into the ground
// depending on which way the bus was moving. Horizontal speed still
// carries over on its own from the velocity the seat constraint was
// already imparting, so no sideways force is needed to "retain" it.
this.image.applyForce({ this.image.applyForce({
x: dirX * GFORCE.ejectImpulseMagnitude * severity + (Math.random() - 0.5) * GFORCE.ejectImpulseMagnitude * 0.5, x: 0,
y: dirY * GFORCE.ejectImpulseMagnitude * severity - GFORCE.ejectImpulseMagnitude * 0.4, y: -GFORCE.ejectImpulseMagnitude * severity,
}); });
this._settleAt = this.scene.time.now + KID.settleTimeMs; this._settleAt = this.scene.time.now + KID.settleTimeMs;

View File

@ -62,6 +62,7 @@ export default class PlayScene extends Phaser.Scene {
this.bus.applyIntent(intent); this.bus.applyIntent(intent);
this.kidManager.update(time); this.kidManager.update(time);
this.cameraRig.update(); this.cameraRig.update();
this._updateParallax();
if (DEBUG && this.debugText) { if (DEBUG && this.debugText) {
const g = this.gForceMonitor.lastGForce || 0; const g = this.gForceMonitor.lastGForce || 0;
@ -73,14 +74,59 @@ export default class PlayScene extends Phaser.Scene {
const bounds = this.level.cameraBounds; const bounds = this.level.cameraBounds;
const width = bounds.width + 1000 * WORLD_SCALE; const width = bounds.width + 1000 * WORLD_SCALE;
const far = this.add.tileSprite(bounds.x, 0, width, GAME_HEIGHT, 'bg_far').setOrigin(0, 0); // Far layer fills the full viewport height at ONE uniform scale (same
far.setScrollFactor(0.15, 0); // factor on both axes, so its native aspect ratio is preserved - no
// stretch), fixed to the screen (scroll factor 0). _updateParallax()
// 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 farScale = GAME_HEIGHT / farNative.height;
this.bgFar = this.add.tileSprite(0, 0, GAME_WIDTH, GAME_HEIGHT, 'bg_far').setOrigin(0, 0);
this.bgFar.setScrollFactor(0, 0);
this.bgFar.tileScaleX = farScale;
this.bgFar.tileScaleY = farScale;
this._bgFarPanRange = Math.max(0, farNative.width - GAME_WIDTH / farScale);
const mid = this.add.tileSprite(bounds.x, GAME_HEIGHT * 0.35, width, GAME_HEIGHT * 0.65, 'bg_mid').setOrigin(0, 0); // Mid/near are ground-level layers stacked from the bottom of the
mid.setScrollFactor(0.45, 0); // screen upward, each at its own uniform (non-stretching) scale, tiled
// across the level via the normal scrollFactor-driven parallax. Repeat
// 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);
}
const near = this.add.tileSprite(bounds.x, GAME_HEIGHT * 0.55, width, GAME_HEIGHT * 0.45, 'bg_near').setOrigin(0, 0); _nativeTextureSize(key) {
near.setScrollFactor(0.75, 0); const src = this.textures.get(key).getSourceImage();
return { width: src.width, height: src.height };
}
// Adds a bottom-anchored parallax layer scaled uniformly (same factor on
// both axes, so its native aspect ratio is preserved - no stretch),
// tiled horizontally across `displayWidth` however many times that scale
// naturally allows.
_addGroundLayer(key, x, displayWidth, targetHeight, scrollFactorX) {
const native = this._nativeTextureSize(key);
const scale = targetHeight / native.height;
const y = GAME_HEIGHT - targetHeight;
const layer = this.add.tileSprite(x, y, displayWidth, targetHeight, key).setOrigin(0, 0);
layer.tileScaleX = scale;
layer.tileScaleY = scale;
layer.setScrollFactor(scrollFactorX, 0);
return layer;
}
// Drives bg_far's pan independent of camera scroll: 0 at the bus's start
// position, 1 at the goal, clamped so overshoot/backing up doesn't run
// the image past its own ends.
_updateParallax() {
if (!this.bgFar) return;
const startX = this.level.startPosition.x;
const endX = this.level.goal.x;
const progress = Phaser.Math.Clamp((this.bus.chassis.x - startX) / Math.max(1, endX - startX), 0, 1);
this.bgFar.tilePositionX = progress * this._bgFarPanRange;
} }
_buildGoal() { _buildGoal() {

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { GFORCE } from '../config.js'; import { GFORCE, KID } from '../config.js';
import Kid from '../entities/Kid.js'; import Kid from '../entities/Kid.js';
export default class KidManager { export default class KidManager {
@ -16,6 +16,25 @@ export default class KidManager {
this._onGForceExceeded = this._onGForceExceeded.bind(this); this._onGForceExceeded = this._onGForceExceeded.bind(this);
scene.events.on('gforce-exceeded', this._onGForceExceeded); scene.events.on('gforce-exceeded', this._onGForceExceeded);
// Ejected kids set body.ignoreGravity (see Kid.eject) so the engine's
// own gravity application skips them entirely; this replaces it with a
// lighter fraction, applied here (before the engine integrates the
// step, matching how Matter itself applies gravity) rather than in
// Kid.update, since it needs the world's live gravity config, which
// KidManager already has scene access to.
this._onBeforeUpdate = this._onBeforeUpdate.bind(this);
scene.matter.world.on('beforeupdate', this._onBeforeUpdate);
}
_onBeforeUpdate() {
const gravity = this.scene.matter.world.engine.gravity;
const g = gravity.y * gravity.scale * KID.ejectedGravityScale;
for (const kid of this.kids) {
if (kid.state === 'aboard') continue;
const body = kid.image.body;
body.force.y += body.mass * g;
}
} }
get kidsAboardCount() { get kidsAboardCount() {
@ -31,7 +50,7 @@ export default class KidManager {
this._lastEjectTime = now; this._lastEjectTime = now;
const kid = Phaser.Utils.Array.GetRandom(aboard); const kid = Phaser.Utils.Array.GetRandom(aboard);
kid.eject(this.bus.chassis.body.velocity, gForce); kid.eject(gForce);
this.scene.events.emit('kid-ejected', { this.scene.events.emit('kid-ejected', {
kidsAboard: this.kidsAboardCount, kidsAboard: this.kidsAboardCount,