Compare commits

...

5 Commits

Author SHA1 Message Date
Brian Fertig 3b8c13fb4e feat: complete level 01, engine audio, menu music, editor enhancements
- Implement EngineSound system that pitches engine-heavy.mp3 based on rear
  wheel angular velocity, with rate smoothing to avoid stair-stepping
- Add continuous menu music across Intro → MainMenu → LevelSelect scenes
- Add per-level voice-over support (level01: "Get the Money!")
- Redesign level01 with hand-crafted terrain sections (Flat, Incline,
  Jump, Roller, Descent, Bumps, Flat, Descent, Bumps, Flat)
- Editor: per-section width and vertical scale (vScale) controls
- Editor: smooth entry blending between sections using angle-based
  interpolation (avoids slope overshoot from naive height blending)
- Editor: new size-panel UI with range/number inputs and smooth checkbox
- Fix KidManager re-boarding: ejected kids must be settled before
  re-boarding to prevent mid-air teleportation
- Load audio assets in PreloadScene; handle missing voice files gracefully
2026-08-20 10:10:19 -06:00
Brian Fertig f3e7f96220 Fix all-kids-lost failing too early; add bus-fell fail condition
The level now only fails on "all kids lost" once every ejected kid has
settled outside the bus (restSpeed/settleDuration hysteresis) or fallen
past the camera bounds, giving a rescue window after a jump. Also fails
when the bus itself drops below the level, and guards the compartment
clamp with a distance radius so far-away kids aren't yanked toward the
bus.
2026-08-19 21:40:03 -06:00
Brian Fertig c4954b2292 Replace impact-based kid ejection with open-top compartment
Ejection used to be g-force-triggered, but the suspension isolates the
chassis from shocks so the old threshold was effectively unreachable -
kids were never actually being ejected. Instead the bus now carries an
invisible three-sided box (floor + left/right walls, deliberately no
ceiling) pinned to the chassis with rigid constraints. The fixtures are
standalone bodies in a dedicated kid collision category, not compounded
into the chassis, because Matter only consults the root body's filter
on compounds, so only standalone bodies can be made to collide with kids
while the chassis part stays exempt.

KidManager now checks each kid's rotation-corrected chassis-local
position every physics tick and flips state in both directions: crossing
above the open top marks the kid ejected (light custom gravity,
kid_ejected texture), and drifting back down through it re-marks the kid
aboard. A per-tick hard clamp keeps kids from tunneling through the
floor/walls on a fast hit. Seat constraints and the settle timer are
gone - containment is purely physical collision. GForceMonitor is
demoted to a debug-only readout for landing diagnosis.

Kid art is now 5-frame spritesheets (one distinct kid per seat), with
the frame picked by seat index in Kid.js, and render order is fixed with
explicit depths (kids 1, bus chassis/wheels 2).
2026-08-19 21:05:08 -06:00
Brian Fertig 9892df762c 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).
2026-08-19 20:58:01 -06:00
Brian Fertig 4973b22f7c Add level editor and retune physics/camera for bouncier, floatier driving
- New browser level editor (editor.html + src/editor/): assemble levels
  from snap-together section generators (flat/inclines/hills/ditch/jump)
  with a live canvas preview, metadata form, and export to ready-to-drop
  levelNN.js source; example export registered as level04.js.
- Add parallax background sprites (bg_far/mid/near).
- Bus retune: softer, bouncier suspension (stiffness 0.45->0.35, damping
  0.08->0.035, travel 12->30, restitution 0.3->0.45) with wheel rest
  point raised 22->34 for a bouncier stance; drive force cut ~60%
  (0.011->0.0045) for a much longer runway and lower top speed.
- Gravity 1 -> 0.675 for ~1.5x hang time off jumps; constraintIterations
  2 -> 10 so the soft wishbone springs converge within one step.
- Bus: clamp each wheel to the underside of its wishbone anchor line on
  every matter afterupdate, fixing wheels punching through to the
  mirrored solution and sticking high on the chassis after hard impacts;
  add no-op destroy() for the world listener.
- CameraRig: speed-driven horizontal lookahead (bus framed ~10% from the
  left edge at rest, out to 1/3 of the screen at speed) via followOffset;
  PlayScene scales the g-force grace period to 5.5s for the new gravity
  and calls the new update()/destroy() hooks.
- Extend level01's terrain runway by 30x.
2026-08-19 20:07:24 -06:00
33 changed files with 2662 additions and 158 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
assets/fx/engine-heavy.mp3 Normal file

Binary file not shown.

BIN
assets/music/main-title.mp3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/sprites/kid_idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/voice/level01.mp3 Normal file

Binary file not shown.

266
editor.html Normal file
View File

@ -0,0 +1,266 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Monsterplex Level Editor</title>
<link rel="icon" href="assets/favicon.png">
<style>
html, body {
margin: 0;
padding: 0;
background: #0b0e14;
color: #e8eef7;
font-family: monospace;
}
body {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-size: 22px;
color: #ffffff;
margin: 0 0 4px;
}
p.subtitle {
color: #9aa7bb;
margin: 0 0 20px;
font-size: 13px;
}
section {
background: #131826;
border: 1px solid #232c40;
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 16px;
}
h2 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9aa7bb;
margin: 0 0 10px;
}
#palette {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.palette-btn {
background: #2255aa;
color: #ffffff;
border: 1px solid #1a1f29;
border-radius: 4px;
padding: 8px 12px;
font-family: monospace;
font-size: 13px;
cursor: pointer;
}
.palette-btn:hover {
background: #2f6ac0;
}
#track {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 40px;
margin-bottom: 10px;
}
.track-item {
background: #1a2236;
border: 1px solid #2c374f;
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
color: #c8d3e0;
}
.track-item.selected {
background: #2255aa;
border-color: #4a8fe0;
color: #ffffff;
}
.track-item.locked {
opacity: 0.7;
}
#track-controls button, #export-panel button {
background: #2255aa;
color: #fff;
border: 1px solid #1a1f29;
border-radius: 4px;
padding: 6px 12px;
font-family: monospace;
font-size: 12px;
cursor: pointer;
margin-right: 6px;
}
#track-controls button:disabled, #export-panel button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
#size-panel {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #232c40;
}
.size-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 220px;
}
.size-field label {
font-size: 12px;
color: #9aa7bb;
}
.size-field-row {
display: flex;
align-items: center;
gap: 8px;
}
.size-field-row input[type="range"] {
flex: 1;
}
.size-field-row input[type="number"] {
width: 64px;
background: #0b0e14;
border: 1px solid #2c374f;
color: #e8eef7;
border-radius: 4px;
padding: 4px 6px;
font-family: monospace;
font-size: 12px;
}
.size-field-row input:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #e8eef7;
cursor: pointer;
}
.checkbox-row:has(input:disabled) {
opacity: 0.35;
cursor: not-allowed;
}
#metadata-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
#metadata-form label {
display: flex;
flex-direction: column;
font-size: 12px;
color: #9aa7bb;
gap: 4px;
}
#metadata-form input, #metadata-form textarea {
background: #0b0e14;
border: 1px solid #2c374f;
color: #e8eef7;
border-radius: 4px;
padding: 6px 8px;
font-family: monospace;
font-size: 13px;
}
#preview-scroll {
overflow-x: auto;
border: 1px solid #232c40;
border-radius: 4px;
background: #0b0e14;
}
#preview-canvas {
display: block;
}
#export-textarea {
width: 100%;
box-sizing: border-box;
height: 220px;
background: #0b0e14;
color: #c8d3e0;
border: 1px solid #2c374f;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
padding: 8px;
margin-bottom: 10px;
resize: vertical;
}
#empty-state {
color: #9aa7bb;
font-size: 13px;
padding: 20px;
text-align: center;
}
</style>
</head>
<body>
<h1>Monsterplex Level Editor</h1>
<p class="subtitle">Build a level from snap-together sections, then export it as a level file for src/data/levels/.</p>
<section>
<h2>Level Details</h2>
<div id="metadata-form"></div>
</section>
<section>
<h2>Sections</h2>
<div id="palette"></div>
</section>
<section>
<h2>Sequence</h2>
<div id="track"></div>
<div id="track-controls">
<button id="move-left-btn">&#9664; Move Left</button>
<button id="move-right-btn">Move Right &#9654;</button>
<button id="remove-btn">&#10005; Remove</button>
</div>
<div id="size-panel" hidden>
<div class="size-field">
<label for="width-range">Width (horizontal)</label>
<div class="size-field-row">
<input type="range" id="width-range" min="250" max="2100" step="10">
<input type="number" id="width-number" min="250" max="2100" step="10">
</div>
</div>
<div class="size-field">
<label for="vscale-range">Vertical Scale</label>
<div class="size-field-row">
<input type="range" id="vscale-range" min="0.3" max="2.5" step="0.05">
<input type="number" id="vscale-number" min="0.3" max="2.5" step="0.05">
</div>
</div>
<div class="size-field">
<label for="smooth-checkbox">Entry</label>
<label class="checkbox-row">
<input type="checkbox" id="smooth-checkbox">
Smooth (round off the seam with the previous section)
</label>
</div>
</div>
</section>
<section>
<h2>Preview</h2>
<div id="preview-scroll"><canvas id="preview-canvas"></canvas></div>
</section>
<section>
<h2>Export</h2>
<textarea id="export-textarea" readonly spellcheck="false"></textarea>
<button id="download-btn">Download levelNN.js</button>
</section>
<script type="module" src="./src/editor/main.js"></script>
</body>
</html>

View File

@ -25,11 +25,12 @@ export const BUS = {
wheelRadius: 17 * WORLD_SCALE,
rearWheelOffsetX: -35 * WORLD_SCALE,
frontWheelOffsetX: 64 * WORLD_SCALE,
// Where the wheel's CENTER rests at equilibrium - this is the measured
// wheel-well position itself, not an attachment point the wheel then
// hangs further below (that was the placeholder-art version's model,
// before there was real art dictating exactly where the wheel belongs).
wheelRestOffsetY: 22 * WORLD_SCALE,
// Where the wheel's CENTER rests at equilibrium. Raised from the
// wheel-well-measured 22 to 34 to lift the chassis and drop the tires
// further down for a bouncier stance - this now visibly departs from
// 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,
// 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
@ -39,6 +40,31 @@ export const BUS = {
maxSeats: 5,
// The invisible box kids ride in, local to the chassis (0,0 = chassis
// center, y down): solid floor + left/right walls, but deliberately no
// ceiling fixture at all - see Bus.js's _buildCompartment. A kid whose
// own local Y (rotation-corrected relative to the chassis) rises above
// compartmentTopY has nothing left to collide with and is airborne; the
// same check runs both directions each tick (see KidManager), so falling
// back below it while inside compartmentHalfWidth re-contains the kid.
// Tune by playtesting - not physically derived.
compartmentHalfWidth: 75 * WORLD_SCALE,
compartmentWallThickness: 10 * WORLD_SCALE,
compartmentFloorThickness: 10 * WORLD_SCALE,
compartmentFloorY: 12 * WORLD_SCALE,
compartmentTopY: -60 * WORLD_SCALE,
// The clamp in KidManager only makes sense as a same-tick tunnel-through
// guard against the compartment's OWN fixtures - a kid further than this
// from the chassis was never inside the compartment to begin with, and
// local-frame coordinates alone can't tell "just tunneled through" apart
// from "resting somewhere else in the level entirely" without a distance
// check like this (a kid at rest far away can still end up with local
// coordinates that look like a boundary violation purely because the
// chassis itself has moved since). Generous enough to still catch a
// genuinely fast tunnel-through, nowhere near large enough to reach a kid
// that's actually elsewhere on the level.
compartmentClampRadius: 400 * WORLD_SCALE,
// Density/friction/stiffness/damping are dimensionless ratios, not pixel
// distances - they don't scale with WORLD_SCALE.
chassisDensity: 0.0015,
@ -54,16 +80,16 @@ export const BUS = {
// and was the main reason the bus felt sluggish rather than Trials-fast.
wheelFrictionAir: 0.008,
// Some bounce on landing, not just suspension absorbing everything.
wheelRestitution: 0.3,
wheelRestitution: 0.45,
// Softer and less damped than before - more travel, and lets it actually
// oscillate (bounce) instead of settling immediately - "more shocks."
suspensionStiffness: 0.45,
suspensionDamping: 0.08,
suspensionStiffness: 0.35,
suspensionDamping: 0.035,
// How far the wheel can move up/down from wheelRestOffsetY. This will
// visibly pop the wheel outside its wheel well on a hard landing - that's
// the tradeoff for real suspension travel/bounce instead of a stiff ride.
suspensionTravel: 12 * WORLD_SCALE,
suspensionTravel: 30 * WORLD_SCALE,
// Angular velocities/torque steps are rotational (radians), not linear
// pixel distances, so they're left unscaled - wheel radius already scaled,
@ -85,43 +111,102 @@ export const BUS = {
// (Bus.js computes force = chassis mass * this) rather than a raw force,
// so it stays correct if chassisDensity/size ever changes - tune these
// to change top speed feel.
driveAcceleration: 0.011,
// Cut by ~60% (0.011 -> 0.0045) for a much longer runway to top speed.
// Note this also lowers the top speed itself, not just the time to reach
// it - top speed is where this force balances chassisFrictionAir drag, so
// a smaller force settles at a lower equilibrium speed too.
driveAcceleration: 0.0045,
brakeAcceleration: 0.007,
// Bumped up to stay responsive at the new higher speed - sluggish lean
// control would feel wrong on a bus that's now moving quickly.
maxLeanAngularVelocity: 0.13,
leanTorqueStep: 0.018,
// Cut by more than half (both the cap and the per-tick torque step) for a
// much slower, more gradual lean - easier to fine-tune mid-air at the
// cost of snappiness.
maxLeanAngularVelocity: 0.06,
leanTorqueStep: 0.004,
};
// Kid ejection is no longer impact-triggered (see KidManager/Bus's
// compartment box) - GFORCE now only drives the DEBUG g-force readout in
// PlayScene, kept because it's still a useful "how hard did that landing
// hit" diagnostic on its own.
export const GFORCE = {
// 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.
pixelsPerMeter: 50 * WORLD_SCALE,
ejectThresholdG: 6, // primary tuning knob - not physically derived, tune by playtesting
gracePeriodMs: 500,
ejectImpulseMagnitude: 0.03 * WORLD_SCALE,
// A single hard landing can stay above threshold for several consecutive
// physics ticks; without a cooldown that would eject several kids from one
// impact instead of one. Debounces ejections to one per impact.
ejectCooldownMs: 400,
};
export const KID = {
radius: 14 * WORLD_SCALE,
density: 0.001,
friction: 0.6,
seatStiffness: 0.22,
seatDamping: 0.1,
seatLength: 4 * WORLD_SCALE,
settleTimeMs: 2000,
// While outside the compartment (past the open top - see
// BUS.compartmentTopY), 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 a 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,
// How slow (raw Matter velocity magnitude, same convention as everything
// else that reads body.velocity directly in this codebase) an ejected
// kid needs to be moving to count as "settled" outside the bus - used by
// PlayScene to delay the all-kids-lost failure until every kid has
// actually landed and stopped, not the instant the last one leaves the
// compartment, so a jump that pops everyone loose still leaves a window
// to drive back underneath and catch them. Calibrated via a headless
// matter-js simulation of a kid landing with realistic horizontal speed
// under ejectedGravityScale: settles below this within ~3s of landing,
// fully stops within ~10s - tune by playtesting if that feels off.
restSpeedThreshold: 1,
// Must stay below restSpeedThreshold continuously for this long to count
// as settled, not just an instant - a kid arcing under weak gravity can
// pass through near-zero speed for a single tick right at the peak of
// its jump while still very much airborne (about to fall again, not
// landed), so a momentary dip alone isn't proof it's actually stopped.
settleDurationMs: 500,
};
export const CAMERA = {
lerpX: 0.1,
lerpY: 0.1,
deadzoneWidth: 220 * WORLD_SCALE,
// Horizontal position is speed-driven (see CameraRig.update - it steers
// via followOffset.x) rather than a dead zone, so this is kept tiny
// rather than removed, just to dodge exact-equality edge cases in
// Phaser's deadzone math. Vertical still uses a real dead zone below.
deadzoneWidth: 2 * WORLD_SCALE,
deadzoneHeight: 140 * WORLD_SCALE,
// Where the bus sits on screen, as a fraction of screen width from the
// left edge - primary tuning knobs, not physically derived, tune by
// playtesting. restScreenFraction leaves enough room for the chassis
// (half its width is ~0.09 of the screen) not to clip off the left edge
// while still reading as "almost touching."
restScreenFraction: 0.1,
maxSpeedScreenFraction: 1 / 3,
// Horizontal chassis speed (px/s) at which framing reaches
// maxSpeedScreenFraction; clamped beyond it. There's no hard speed cap in
// the drive physics (chassisFrictionAir just balances driveAcceleration
// out asymptotically), so this is a starting estimate - tune by
// playtesting alongside the fractions above.
lookaheadMaxSpeed: 110 * WORLD_SCALE,
};
export const TERRAIN_DEPTH = 400 * WORLD_SCALE;
// engine-heavy.mp3 loops for the whole level, pitched by EngineSound via
// Sound.setRate() to track the rear wheel's angular velocity (see
// Bus.applyIntent - it's the only wheel driven directly, so its spin IS the
// engine's rev signal). minRate is idle (throttle released, wheel spun down
// by wheelFrictionAir), maxRate is pinned to BUS.maxWheelAngularVelocity.
export const ENGINE = {
minRate: 0.75,
maxRate: 1.9,
volume: 0.5,
// Per-frame lerp factor from the current playback rate toward the target
// rate - smooths over physics-tick-to-tick noise (wheel bounce, brief
// airborne spin) so the pitch rises/falls like a real engine instead of
// stair-stepping with every torque tick.
rateSmoothing: 0.12,
};

View File

@ -1,8 +1,9 @@
import level01 from './level01.js';
import level02 from './level02.js';
import level03 from './level03.js';
import level04 from './level04.js';
export const LEVELS = [level01, level02, level03];
export const LEVELS = [level01, level02, level03, level04];
export function getLevelById(id) {
return LEVELS.find((level) => level.id === id);

View File

@ -1,20 +1,387 @@
import { generateWave } from './terrainHelpers.js';
import { WORLD_SCALE } from '../../config.js';
const END_X = 3600 * WORLD_SCALE;
const BASE_Y = 420 * WORLD_SCALE;
const points = generateWave(-200 * WORLD_SCALE, END_X, 80 * WORLD_SCALE, BASE_Y, 30 * WORLD_SCALE, 260 * WORLD_SCALE);
// Generated by editor.html - review before dropping into src/data/levels/.
export default {
id: 'level01',
name: 'First Hills',
description: 'Gentle rolling hills - learn to drive and lean.',
kidsAboard: 5,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
id: "level01",
name: "GoFundMe",
description: "Get the Money!",
kidsAboard: 4,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [{ points }],
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 840 },
{ x: 1140, y: 836 },
{ x: 1210, y: 830 },
{ x: 1280, y: 820 },
{ x: 1350, y: 808 },
{ x: 1420, y: 792 },
{ x: 1490, y: 776 },
{ x: 1560, y: 760 },
{ x: 1630, y: 744 },
{ x: 1700, y: 728 },
{ x: 1770, y: 712 },
{ x: 1840, y: 696 },
{ x: 1910, y: 680 },
{ x: 1980, y: 664 },
{ x: 2050, y: 648 },
{ x: 2120, y: 632 },
{ x: 2190, y: 616 },
{ x: 2260, y: 600 },
{ x: 2330, y: 584 },
{ x: 2400, y: 568 },
{ x: 2470, y: 568 },
{ x: 2540, y: 568 },
{ x: 2610, y: 568 },
{ x: 2680, y: 568 },
{ x: 2750, y: 568 },
{ x: 2820, y: 568 },
{ x: 2890, y: 568 },
{ x: 2960, y: 568 },
{ x: 3030, y: 568 },
{ x: 3100, y: 568 },
{ x: 3170, y: 568 },
{ x: 3240, y: 568 },
{ x: 3310, y: 568 },
{ x: 3380, y: 568 },
{ x: 3450, y: 568 },
{ x: 3520, y: 568 },
{ x: 3590, y: 568 },
{ x: 3660, y: 568 },
{ x: 3730, y: 568 },
{ x: 3800, y: 568 },
{ x: 3870, y: 568 },
{ x: 3940, y: 564 },
{ x: 4010, y: 554 },
{ x: 4080, y: 538 },
{ x: 4150, y: 518 },
{ x: 4220, y: 494 },
{ x: 4290, y: 468 },
{ x: 4360, y: 444 },
{ x: 4430, y: 444 },
] },
{ points: [
{ x: 4780, y: 456 },
{ x: 4850, y: 464 },
{ x: 4920, y: 486 },
{ x: 4990, y: 512 },
{ x: 5060, y: 540 },
{ x: 5130, y: 560 },
{ x: 5200, y: 568 },
{ x: 5270, y: 576 },
{ x: 5340, y: 586 },
{ x: 5410, y: 596 },
{ x: 5480, y: 610 },
{ x: 5550, y: 624 },
{ x: 5620, y: 640 },
{ x: 5690, y: 656 },
{ x: 5760, y: 672 },
{ x: 5830, y: 688 },
{ x: 5900, y: 704 },
{ x: 5970, y: 720 },
{ x: 6040, y: 736 },
{ x: 6110, y: 752 },
{ x: 6180, y: 768 },
{ x: 6250, y: 784 },
{ x: 6320, y: 800 },
{ x: 6390, y: 816 },
{ x: 6460, y: 832 },
{ x: 6530, y: 848 },
{ x: 6600, y: 864 },
{ x: 6670, y: 880 },
{ x: 6740, y: 894 },
{ x: 6810, y: 910 },
{ x: 6880, y: 922 },
{ x: 6950, y: 934 },
{ x: 7020, y: 944 },
{ x: 7090, y: 954 },
{ x: 7160, y: 968 },
{ x: 7230, y: 980 },
{ x: 7300, y: 994 },
{ x: 7370, y: 1008 },
{ x: 7440, y: 1020 },
{ x: 7510, y: 1034 },
{ x: 7580, y: 1046 },
{ x: 7650, y: 1056 },
{ x: 7720, y: 1066 },
{ x: 7790, y: 1074 },
{ x: 7860, y: 1080 },
{ x: 7930, y: 1082 },
{ x: 8000, y: 1084 },
{ x: 8000, y: 1086 },
{ x: 8070, y: 1088 },
{ x: 8140, y: 1090 },
{ x: 8210, y: 1090 },
{ x: 8280, y: 1092 },
{ x: 8350, y: 1092 },
{ x: 8420, y: 1092 },
{ x: 8490, y: 1092 },
{ x: 8560, y: 1092 },
{ x: 8630, y: 1092 },
{ x: 8700, y: 1092 },
{ x: 8770, y: 1092 },
{ x: 8840, y: 1092 },
{ x: 8910, y: 1092 },
{ x: 8980, y: 1092 },
{ x: 9050, y: 1092 },
{ x: 9120, y: 1092 },
{ x: 9190, y: 1092 },
{ x: 9260, y: 1092 },
{ x: 9330, y: 1092 },
{ x: 9400, y: 1092 },
{ x: 9470, y: 1092 },
{ x: 9540, y: 1090 },
{ x: 9610, y: 1088 },
{ x: 9680, y: 1084 },
{ x: 9750, y: 1080 },
{ x: 9820, y: 1074 },
{ x: 9890, y: 1064 },
{ x: 9960, y: 1048 },
{ x: 10030, y: 1030 },
{ x: 10100, y: 1010 },
{ x: 10170, y: 988 },
{ x: 10240, y: 970 },
{ x: 10310, y: 954 },
{ x: 10380, y: 944 },
{ x: 10450, y: 942 },
{ x: 10520, y: 944 },
{ x: 10590, y: 954 },
{ x: 10660, y: 970 },
{ x: 10730, y: 988 },
{ x: 10800, y: 1010 },
{ x: 10870, y: 1010 },
{ x: 10940, y: 1010 },
{ x: 11010, y: 1010 },
{ x: 11080, y: 1010 },
{ x: 11150, y: 1010 },
{ x: 11220, y: 1010 },
{ x: 11290, y: 1010 },
{ x: 11360, y: 1010 },
{ x: 11430, y: 1010 },
{ x: 11500, y: 1010 },
{ x: 11570, y: 1010 },
{ x: 11640, y: 1010 },
{ x: 11710, y: 1010 },
{ x: 11780, y: 1010 },
{ x: 11850, y: 1010 },
{ x: 11920, y: 1010 },
{ x: 11990, y: 1010 },
{ x: 12060, y: 1010 },
{ x: 12130, y: 1010 },
{ x: 12200, y: 1010 },
{ x: 12270, y: 1010 },
{ x: 12340, y: 1010 },
{ x: 12410, y: 1010 },
{ x: 12480, y: 1010 },
{ x: 12550, y: 1010 },
{ x: 12620, y: 1010 },
{ x: 12690, y: 1010 },
{ x: 12760, y: 1010 },
{ x: 12830, y: 1010 },
{ x: 12900, y: 1010 },
{ x: 12970, y: 1010 },
{ x: 13040, y: 1010 },
{ x: 13110, y: 1010 },
{ x: 13180, y: 1010 },
{ x: 13250, y: 1010 },
{ x: 13320, y: 1010 },
{ x: 13390, y: 1010 },
{ x: 13460, y: 1010 },
{ x: 13530, y: 1010 },
{ x: 13600, y: 1010 },
{ x: 13670, y: 1008 },
{ x: 13740, y: 1004 },
{ x: 13810, y: 990 },
{ x: 13880, y: 970 },
{ x: 13950, y: 942 },
{ x: 14020, y: 908 },
{ x: 14090, y: 872 },
{ x: 14160, y: 838 },
{ x: 14230, y: 838 },
] },
{ points: [
{ x: 14580, y: 850 },
{ x: 14650, y: 862 },
{ x: 14720, y: 890 },
{ x: 14790, y: 930 },
{ x: 14860, y: 968 },
{ x: 14930, y: 998 },
{ x: 15000, y: 1010 },
{ x: 15070, y: 1022 },
{ x: 15140, y: 1030 },
{ x: 15210, y: 1032 },
{ x: 15280, y: 1030 },
{ x: 15350, y: 1022 },
{ x: 15420, y: 1010 },
{ x: 15490, y: 998 },
{ x: 15560, y: 986 },
{ x: 15630, y: 972 },
{ x: 15700, y: 960 },
{ x: 15770, y: 946 },
{ x: 15840, y: 932 },
{ x: 15910, y: 920 },
{ x: 15980, y: 908 },
{ x: 16050, y: 898 },
{ x: 16120, y: 888 },
{ x: 16190, y: 880 },
{ x: 16260, y: 874 },
{ x: 16330, y: 870 },
{ x: 16400, y: 870 },
{ x: 16470, y: 870 },
{ x: 16540, y: 870 },
{ x: 16610, y: 870 },
{ x: 16680, y: 870 },
{ x: 16750, y: 870 },
{ x: 16820, y: 870 },
{ x: 16890, y: 870 },
{ x: 16960, y: 870 },
{ x: 17030, y: 870 },
{ x: 17100, y: 870 },
{ x: 17170, y: 870 },
{ x: 17240, y: 870 },
{ x: 17310, y: 870 },
{ x: 17380, y: 870 },
{ x: 17450, y: 870 },
{ x: 17520, y: 870 },
{ x: 17590, y: 870 },
{ x: 17660, y: 870 },
{ x: 17730, y: 870 },
{ x: 17800, y: 870 },
{ x: 17870, y: 870 },
{ x: 17940, y: 868 },
{ x: 18010, y: 864 },
{ x: 18080, y: 858 },
{ x: 18150, y: 848 },
{ x: 18220, y: 838 },
{ x: 18290, y: 824 },
{ x: 18360, y: 812 },
{ x: 18430, y: 798 },
{ x: 18500, y: 786 },
{ x: 18570, y: 772 },
{ x: 18640, y: 760 },
{ x: 18710, y: 746 },
{ x: 18780, y: 732 },
{ x: 18850, y: 720 },
{ x: 18920, y: 706 },
{ x: 18990, y: 694 },
{ x: 19060, y: 680 },
{ x: 19130, y: 666 },
{ x: 19200, y: 654 },
{ x: 19270, y: 640 },
{ x: 19340, y: 628 },
{ x: 19410, y: 614 },
{ x: 19480, y: 600 },
{ x: 19500, y: 598 },
{ x: 19570, y: 582 },
{ x: 19640, y: 566 },
{ x: 19710, y: 550 },
{ x: 19780, y: 534 },
{ x: 19850, y: 518 },
{ x: 19920, y: 502 },
{ x: 19990, y: 486 },
{ x: 20060, y: 470 },
{ x: 20130, y: 454 },
{ x: 20200, y: 438 },
{ x: 20270, y: 422 },
{ x: 20340, y: 406 },
{ x: 20410, y: 390 },
{ x: 20480, y: 374 },
{ x: 20550, y: 358 },
{ x: 20620, y: 342 },
{ x: 20690, y: 326 },
{ x: 20760, y: 310 },
{ x: 20830, y: 294 },
{ x: 20900, y: 278 },
{ x: 20970, y: 378 },
{ x: 21040, y: 462 },
{ x: 21110, y: 518 },
{ x: 21180, y: 538 },
{ x: 21250, y: 518 },
{ x: 21320, y: 462 },
{ x: 21390, y: 378 },
{ x: 21460, y: 278 },
{ x: 21530, y: 294 },
{ x: 21600, y: 310 },
{ x: 21670, y: 326 },
{ x: 21740, y: 342 },
{ x: 21810, y: 358 },
{ x: 21880, y: 374 },
{ x: 21950, y: 390 },
{ x: 22020, y: 406 },
{ x: 22090, y: 422 },
{ x: 22160, y: 438 },
{ x: 22230, y: 454 },
{ x: 22300, y: 470 },
{ x: 22370, y: 486 },
{ x: 22440, y: 502 },
{ x: 22510, y: 518 },
{ x: 22580, y: 534 },
{ x: 22650, y: 550 },
{ x: 22720, y: 566 },
{ x: 22790, y: 582 },
{ x: 22860, y: 598 },
{ x: 22930, y: 614 },
{ x: 23000, y: 626 },
{ x: 23070, y: 636 },
{ x: 23140, y: 642 },
{ x: 23210, y: 646 },
{ x: 23280, y: 646 },
{ x: 23350, y: 646 },
{ x: 23420, y: 646 },
{ x: 23490, y: 646 },
{ x: 23560, y: 646 },
{ x: 23630, y: 646 },
{ x: 23700, y: 646 },
{ x: 23770, y: 646 },
{ x: 23840, y: 646 },
{ x: 23910, y: 646 },
{ x: 23980, y: 646 },
{ x: 24050, y: 646 },
{ x: 24120, y: 646 },
{ x: 24190, y: 646 },
{ x: 24260, y: 646 },
{ x: 24330, y: 646 },
{ x: 24400, y: 646 },
{ x: 24470, y: 646 },
{ x: 24540, y: 646 },
{ x: 24610, y: 646 },
{ x: 24680, y: 646 },
{ x: 24750, y: 646 },
{ x: 24820, y: 646 },
{ x: 24890, y: 646 },
{ x: 24960, y: 646 },
{ x: 25030, y: 646 },
{ x: 25100, y: 646 },
{ x: 25170, y: 646 },
{ x: 25240, y: 646 },
{ x: 25310, y: 646 },
{ x: 25380, y: 646 },
{ x: 25450, y: 646 },
{ x: 25520, y: 646 },
{ x: 25590, y: 646 },
{ x: 25660, y: 646 },
] },
],
obstacles: [],
goal: { x: END_X - 120 * WORLD_SCALE, y: BASE_Y - 120 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 320 * WORLD_SCALE },
cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE },
goal: { x: 25420, y: 406, width: 280, height: 640 },
cameraBounds: { x: -600, y: -522, width: 27060, height: 2414 },
};

473
src/data/levels/level04.js Normal file
View File

@ -0,0 +1,473 @@
// Generated by editor.html - review before dropping into src/data/levels/.
export default {
id: "level04",
name: "Mega Drop",
description: "",
kidsAboard: 5,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 838 },
{ x: 1140, y: 834 },
{ x: 1210, y: 830 },
{ x: 1280, y: 822 },
{ x: 1350, y: 812 },
{ x: 1420, y: 802 },
{ x: 1490, y: 790 },
{ x: 1560, y: 776 },
{ x: 1630, y: 764 },
{ x: 1700, y: 750 },
{ x: 1770, y: 736 },
{ x: 1840, y: 724 },
{ x: 1910, y: 710 },
{ x: 1980, y: 698 },
{ x: 2050, y: 688 },
{ x: 2120, y: 678 },
{ x: 2190, y: 670 },
{ x: 2260, y: 666 },
{ x: 2330, y: 662 },
{ x: 2400, y: 660 },
{ x: 2470, y: 660 },
{ x: 2540, y: 660 },
{ x: 2610, y: 660 },
{ x: 2680, y: 660 },
{ x: 2750, y: 660 },
{ x: 2820, y: 660 },
{ x: 2890, y: 660 },
{ x: 2960, y: 660 },
{ x: 3030, y: 660 },
{ x: 3100, y: 660 },
{ x: 3170, y: 660 },
{ x: 3240, y: 660 },
{ x: 3310, y: 660 },
{ x: 3380, y: 660 },
{ x: 3450, y: 660 },
{ x: 3520, y: 660 },
{ x: 3590, y: 660 },
{ x: 3660, y: 660 },
{ x: 3730, y: 660 },
{ x: 3800, y: 660 },
{ x: 3870, y: 676 },
{ x: 3940, y: 692 },
{ x: 4010, y: 708 },
{ x: 4080, y: 724 },
{ x: 4150, y: 740 },
{ x: 4220, y: 756 },
{ x: 4290, y: 772 },
{ x: 4360, y: 788 },
{ x: 4430, y: 804 },
{ x: 4500, y: 820 },
{ x: 4570, y: 836 },
{ x: 4640, y: 852 },
{ x: 4710, y: 868 },
{ x: 4780, y: 884 },
{ x: 4850, y: 900 },
{ x: 4920, y: 916 },
{ x: 4990, y: 932 },
{ x: 5060, y: 948 },
{ x: 5130, y: 964 },
{ x: 5200, y: 980 },
{ x: 5270, y: 996 },
{ x: 5340, y: 1012 },
{ x: 5410, y: 1028 },
{ x: 5480, y: 1044 },
{ x: 5550, y: 1060 },
{ x: 5620, y: 1076 },
{ x: 5690, y: 1092 },
{ x: 5760, y: 1108 },
{ x: 5830, y: 1124 },
{ x: 5900, y: 1140 },
{ x: 5970, y: 1156 },
{ x: 6040, y: 1172 },
{ x: 6110, y: 1188 },
{ x: 6180, y: 1204 },
{ x: 6250, y: 1220 },
{ x: 6320, y: 1236 },
{ x: 6390, y: 1252 },
{ x: 6460, y: 1268 },
{ x: 6530, y: 1284 },
{ x: 6600, y: 1300 },
{ x: 6670, y: 1298 },
{ x: 6740, y: 1294 },
{ x: 6810, y: 1290 },
{ x: 6880, y: 1282 },
{ x: 6950, y: 1272 },
{ x: 7020, y: 1262 },
{ x: 7090, y: 1250 },
{ x: 7160, y: 1236 },
{ x: 7230, y: 1224 },
{ x: 7300, y: 1210 },
{ x: 7370, y: 1196 },
{ x: 7440, y: 1184 },
{ x: 7510, y: 1170 },
{ x: 7580, y: 1158 },
{ x: 7650, y: 1148 },
{ x: 7720, y: 1138 },
{ x: 7790, y: 1130 },
{ x: 7860, y: 1126 },
{ x: 7930, y: 1122 },
{ x: 8000, y: 1120 },
{ x: 8070, y: 1122 },
{ x: 8140, y: 1126 },
{ x: 8210, y: 1130 },
{ x: 8280, y: 1138 },
{ x: 8350, y: 1148 },
{ x: 8420, y: 1158 },
{ x: 8490, y: 1170 },
{ x: 8560, y: 1184 },
{ x: 8630, y: 1196 },
{ x: 8700, y: 1210 },
{ x: 8770, y: 1224 },
{ x: 8840, y: 1236 },
{ x: 8910, y: 1250 },
{ x: 8980, y: 1262 },
{ x: 9050, y: 1272 },
{ x: 9120, y: 1282 },
{ x: 9190, y: 1290 },
{ x: 9260, y: 1294 },
{ x: 9330, y: 1298 },
{ 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: [],
goal: { x: 31560, y: 2020, width: 280, height: 640 },
cameraBounds: { x: -600, y: -140, width: 33200, height: 4100 },
};

41
src/editor/exportLevel.js Normal file
View File

@ -0,0 +1,41 @@
// Serializes a level data object (as produced by levelBuilder.js) into the
// literal `export default {...}` JS module source text the game expects to
// find in src/data/levels/levelNN.js. Numbers are emitted as plain scaled
// literals rather than `N * WORLD_SCALE` expressions - levelBuilder.js has
// already fully resolved them, and level03.js already sets precedent for a
// level file built from computed literal points rather than hand-picked
// base-unit expressions.
function pointsLiteral(points, indent) {
const lines = points.map((p) => `${indent} { x: ${p.x}, y: ${p.y} },`);
return `[\n${lines.join('\n')}\n${indent}]`;
}
function terrainLiteral(terrain) {
const segments = terrain.map((segment) => ` { points: ${pointsLiteral(segment.points, ' ')} },`);
return `[\n${segments.join('\n')}\n ]`;
}
function rectLiteral(rect) {
return `{ x: ${rect.x}, y: ${rect.y}, width: ${rect.width}, height: ${rect.height} }`;
}
export function exportLevelToSource(levelData) {
const lines = [
'// Generated by editor.html - review before dropping into src/data/levels/.',
'export default {',
` id: ${JSON.stringify(levelData.id)},`,
` name: ${JSON.stringify(levelData.name)},`,
` description: ${JSON.stringify(levelData.description)},`,
` kidsAboard: ${levelData.kidsAboard},`,
` startPosition: { x: ${levelData.startPosition.x}, y: ${levelData.startPosition.y} },`,
` startAngle: ${levelData.startAngle},`,
` terrain: ${terrainLiteral(levelData.terrain)},`,
' obstacles: [],',
` goal: ${rectLiteral(levelData.goal)},`,
` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`,
'};',
'',
];
return lines.join('\n');
}

241
src/editor/levelBuilder.js Normal file
View File

@ -0,0 +1,241 @@
import { generateSection, POINT_STEP } from './sections.js';
import { WORLD_SCALE } from '../config.js';
// Base-unit constants mirroring the values every hand-written level file
// (level01/02/03.js) already uses, so editor-built levels look/feel
// consistent with the shipped ones.
const TERRAIN_LEAD_IN = 200;
const INITIAL_GROUND_Y = 420;
const BUS_SPAWN_X = 100;
const BUS_SPAWN_DROP = 70;
const GOAL_WIDTH = 140;
const GOAL_HEIGHT = 320;
const GOAL_END_MARGIN = 120;
const GOAL_HEIGHT_OFFSET = 120;
const CAMERA_LEFT_MARGIN = 300;
const CAMERA_RIGHT_MARGIN = 700;
// 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) {
return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) };
}
// How far into a "smooth" section's own point run (base design units, same
// space sections.js generates in) the blend runs before handing off to the
// section's natural, unmodified curve - a fraction of the section's own
// width, clamped so a very short section can't have the blend swallow its
// whole point run, and a very long one doesn't get an absurdly long taper.
const SMOOTH_BLEND_FRACTION = 0.3;
const SMOOTH_BLEND_MIN = POINT_STEP * 2;
const SMOOTH_BLEND_MAX = 220;
function lastPair(points) {
if (!points || points.length < 2) return null;
return [points[points.length - 2], points[points.length - 1]];
}
function smoothstep(u) {
const c = Math.max(0, Math.min(1, u));
return c * c * (3 - 2 * c);
}
// Rounds off the sharp corner where a section's own point run meets
// whatever terrain came immediately before it - from the BUS's
// perspective, not the terrain's. A naive fix blends the *height* (fit a
// curve through the two boundary points and their tangents), but a
// position-matched curve can - and did - swing the instantaneous slope
// PAST either boundary's angle to hit both the position and tangent
// targets at once, which is a bigger jolt to the chassis than the sharp
// corner it was meant to replace.
//
// This instead blends the ANGLE the bus actually feels: interpolate the
// heading smoothly (via smoothstep, so the turn eases in/out rather than
// snapping to a constant turn rate) from the incoming heading to the
// section's own natural heading at the end of the blend window, then
// integrate that heading back into y positions. The angle is then
// guaranteed to move monotonically between the two endpoint angles -
// no overshoot, so nowhere in the blend does the bus turn harder than the
// sharper of the two corners it's smoothing between.
//
// Because the blended angle profile is only an approximation of the
// original curve's true path between those two x's, the blended points
// generally won't land exactly back on the original curve's height at the
// blend's far end - so everything past the blend window (left untouched,
// still the section's original shape) is rigidly shifted vertically by
// that small residual to reconnect seamlessly. That shift preserves every
// slope in the untouched tail exactly (a vertical translation doesn't
// change slopes), so the returned `delta` just needs to be carried into
// this section's endY too, since the section's actual endpoint moved by
// the same amount.
//
// prevPair is [secondToLast, last] of whatever raw point run preceded this
// one, or null for the very first section (nothing to blend against, so
// this is a no-op).
function smoothEntry(prevPair, points, width) {
if (!prevPair || points.length < 3) return { points, delta: 0 };
const [p0, p1] = prevPair;
if (p1.x === p0.x) return { points, delta: 0 };
const angleIn = Math.atan2(p1.y - p0.y, p1.x - p0.x);
const blendLen = Math.min(SMOOTH_BLEND_MAX, Math.max(SMOOTH_BLEND_MIN, width * SMOOTH_BLEND_FRACTION));
const startX = points[0].x;
let idxB = points.findIndex((p) => p.x - startX >= blendLen);
if (idxB <= 0) idxB = points.length - 1;
const a = points[0];
const b = points[idxB];
if (b.x === a.x) return { points, delta: 0 };
const prevB = points[Math.max(0, idxB - 1)];
const nextB = points[Math.min(points.length - 1, idxB + 1)];
const angleOut = prevB.x === nextB.x ? angleIn : Math.atan2(nextB.y - prevB.y, nextB.x - prevB.x);
const blended = [{ x: a.x, y: a.y }];
let prevAngle = angleIn;
for (let i = 1; i <= idxB; i++) {
const p = points[i];
const u = (p.x - a.x) / (b.x - a.x);
const angle = angleIn + (angleOut - angleIn) * smoothstep(u);
// Trapezoidal step (average of this segment's start/end angle) for a
// closer height estimate than a single-sample slope would give.
const avgSlope = (Math.tan(prevAngle) + Math.tan(angle)) / 2;
const prev = blended[blended.length - 1];
blended.push({ x: p.x, y: prev.y + avgSlope * (p.x - prev.x) });
prevAngle = angle;
}
const delta = blended[blended.length - 1].y - b.y;
const result = blended.map((p) => ({ x: p.x, y: Math.round(p.y) }));
for (let i = idxB + 1; i < points.length; i++) {
result.push({ x: points[i].x, y: Math.round(points[i].y + delta) });
}
return { points: result, delta };
}
// Appends scaled points onto an accumulator array, skipping a leading point
// that exactly duplicates the accumulator's current last point (happens at
// every block boundary, since each block's first sample is the previous
// block's last x/y by construction).
function appendPoints(accumulator, points) {
for (const p of points) {
const scaled = scalePoint(p);
const last = accumulator[accumulator.length - 1];
if (last && last.x === scaled.x && last.y === scaled.y) continue;
accumulator.push(scaled);
}
}
// sections: ordered array of { type, width, vScale, smooth } - type is an
// id from sections.js's SECTION_TYPES, width/vScale independently stretch
// or contract that section horizontally/vertically (see sections.js's
// SECTION_WIDTH_MIN/MAX and SECTION_VSCALE_MIN/MAX for editor bounds), and
// smooth rounds off the sharp corner where this section's terrain meets
// the previous section's (see smoothEntry above) - off by default, since a
// "Sharp Incline" or a jump's ramp is often supposed to look abrupt.
// metadata: { id, name, description, kidsAboard }.
// Returns a full level data object matching level01.js's shape, or null if
// sections is empty.
export function buildLevelData(sections, metadata) {
if (!sections || sections.length === 0) return null;
let baseX = -TERRAIN_LEAD_IN;
let baseY = INITIAL_GROUND_Y;
const terrainSegments = [];
let currentPoints = [];
// Raw (pre-scale) [secondToLast, last] points of whatever terrain run
// immediately precedes the section currently being generated - the
// reference smoothEntry blends a "smooth" section's start into.
let prevPair = null;
for (const section of sections) {
const result = generateSection(section.type, baseX, baseY, section.width, section.vScale);
let endY = result.endY;
if (result.type === 'jump') {
// Only the takeoff (the jump's own "beginning") gets smoothed - the
// far side of the gap is a fresh terrain segment with no seam to
// round off, and a takeoff shifted by a small delta doesn't need
// correcting against the landing since nothing spans the gap.
const takeoffPoints = section.smooth ? smoothEntry(prevPair, result.takeoffPoints, section.width).points : result.takeoffPoints;
appendPoints(currentPoints, takeoffPoints);
terrainSegments.push({ points: currentPoints });
currentPoints = [];
appendPoints(currentPoints, result.landingPoints);
prevPair = lastPair(result.landingPoints);
} else {
let points = result.points;
if (section.smooth) {
const smoothed = smoothEntry(prevPair, result.points, section.width);
points = smoothed.points;
endY += smoothed.delta;
}
appendPoints(currentPoints, points);
prevPair = lastPair(points);
}
baseY = endY;
baseX += section.width;
}
if (currentPoints.length > 0) {
terrainSegments.push({ points: currentPoints });
}
const endX = Math.round(baseX * 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 {
id: metadata.id,
name: metadata.name,
description: metadata.description,
kidsAboard: metadata.kidsAboard,
startPosition: {
x: Math.round(BUS_SPAWN_X * WORLD_SCALE),
y: Math.round((INITIAL_GROUND_Y - BUS_SPAWN_DROP) * WORLD_SCALE),
},
startAngle: 0,
terrain: terrainSegments,
obstacles: [],
goal: {
x: endX - Math.round(GOAL_END_MARGIN * WORLD_SCALE),
y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE),
width: Math.round(GOAL_WIDTH * WORLD_SCALE),
height: Math.round(GOAL_HEIGHT * WORLD_SCALE),
},
cameraBounds: {
x: -Math.round(CAMERA_LEFT_MARGIN * WORLD_SCALE),
y: cameraTop,
width: endX + Math.round(CAMERA_RIGHT_MARGIN * WORLD_SCALE),
height: cameraHeight,
},
};
}

386
src/editor/main.js Normal file
View File

@ -0,0 +1,386 @@
import {
SECTION_TYPES,
SECTION_WIDTH,
SECTION_WIDTH_MIN,
SECTION_WIDTH_MAX,
SECTION_VSCALE_MIN,
SECTION_VSCALE_MAX,
} from './sections.js';
import { buildLevelData } from './levelBuilder.js';
import { exportLevelToSource } from './exportLevel.js';
import { BUS } from '../config.js';
// The leading section is always 'flat' and locked at index 0, so a jump
// section can never end up as the very first thing the bus drives onto -
// see the "Safety default" note in the level editor plan. Each sequence
// entry carries its own width/vScale so sections can be individually
// stretched/contracted horizontally (width) or vertically (vScale, a
// multiplier on that section's rise/amplitude/depth - see sections.js).
const state = {
metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 },
sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }],
selectedIndex: null,
};
const paletteEl = document.getElementById('palette');
const trackEl = document.getElementById('track');
const moveLeftBtn = document.getElementById('move-left-btn');
const moveRightBtn = document.getElementById('move-right-btn');
const removeBtn = document.getElementById('remove-btn');
const sizePanelEl = document.getElementById('size-panel');
const widthRangeEl = document.getElementById('width-range');
const widthNumberEl = document.getElementById('width-number');
const vscaleRangeEl = document.getElementById('vscale-range');
const vscaleNumberEl = document.getElementById('vscale-number');
const smoothCheckboxEl = document.getElementById('smooth-checkbox');
const metadataFormEl = document.getElementById('metadata-form');
const previewScrollEl = document.getElementById('preview-scroll');
const previewCanvas = document.getElementById('preview-canvas');
const exportTextarea = document.getElementById('export-textarea');
const downloadBtn = document.getElementById('download-btn');
const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId;
// 'flat' has no rise/amplitude/depth for vScale to multiply - disable that
// control rather than leave it silently do nothing.
const FLAT_LIKE_TYPES = new Set(['flat']);
function buildPalette() {
paletteEl.innerHTML = '';
for (const { id, label } of SECTION_TYPES) {
const btn = document.createElement('button');
btn.className = 'palette-btn';
btn.textContent = label;
btn.addEventListener('click', () => {
state.sequence.push({ type: id, width: SECTION_WIDTH, vScale: 1, smooth: false });
render();
});
paletteEl.appendChild(btn);
}
}
function buildMetadataForm() {
metadataFormEl.innerHTML = '';
const makeField = (labelText, inputEl) => {
const label = document.createElement('label');
label.textContent = labelText;
label.appendChild(inputEl);
metadataFormEl.appendChild(label);
return inputEl;
};
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.value = state.metadata.id;
idInput.addEventListener('input', () => {
state.metadata.id = idInput.value;
render();
});
makeField('Level ID', idInput);
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = state.metadata.name;
nameInput.addEventListener('input', () => {
state.metadata.name = nameInput.value;
render();
});
makeField('Name', nameInput);
const descInput = document.createElement('input');
descInput.type = 'text';
descInput.value = state.metadata.description;
descInput.addEventListener('input', () => {
state.metadata.description = descInput.value;
render();
});
makeField('Description', descInput);
const kidsInput = document.createElement('input');
kidsInput.type = 'number';
kidsInput.min = '1';
kidsInput.max = String(BUS.maxSeats);
kidsInput.value = String(state.metadata.kidsAboard);
kidsInput.addEventListener('input', () => {
const n = clamp(parseInt(kidsInput.value, 10) || 1, 1, BUS.maxSeats);
state.metadata.kidsAboard = n;
render();
});
makeField(`Kids Aboard (max ${BUS.maxSeats})`, kidsInput);
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function sizeSuffix(section) {
const parts = [];
if (section.width !== SECTION_WIDTH) parts.push(`${Math.round(section.width)}w`);
if (!FLAT_LIKE_TYPES.has(section.type) && section.vScale !== 1) parts.push(`${section.vScale.toFixed(2)}x`);
if (section.smooth) parts.push('smooth');
return parts.length ? ` (${parts.join(', ')})` : '';
}
function buildTrack() {
trackEl.innerHTML = '';
state.sequence.forEach((section, index) => {
const item = document.createElement('div');
item.className = 'track-item';
if (index === state.selectedIndex) item.classList.add('selected');
if (index === 0) item.classList.add('locked');
item.textContent = `${index + 1}. ${labelFor(section.type)}${sizeSuffix(section)}${index === 0 ? ' (locked)' : ''}`;
item.addEventListener('click', () => {
state.selectedIndex = index;
render();
});
trackEl.appendChild(item);
});
}
function updateTrackControls() {
const i = state.selectedIndex;
const locked = i === 0;
moveLeftBtn.disabled = i === null || i <= 1;
moveRightBtn.disabled = i === null || locked || i === state.sequence.length - 1;
removeBtn.disabled = i === null || locked;
}
function updateSizePanel() {
const i = state.selectedIndex;
if (i === null) {
sizePanelEl.hidden = true;
return;
}
sizePanelEl.hidden = false;
const section = state.sequence[i];
widthRangeEl.value = String(section.width);
widthNumberEl.value = String(Math.round(section.width));
const vScaleDisabled = FLAT_LIKE_TYPES.has(section.type);
vscaleRangeEl.disabled = vScaleDisabled;
vscaleNumberEl.disabled = vScaleDisabled;
vscaleRangeEl.value = String(section.vScale);
vscaleNumberEl.value = section.vScale.toFixed(2);
// Smoothing blends into the seam with the *previous* section - the
// leading section has none, so there's nothing for it to do there.
smoothCheckboxEl.disabled = i === 0;
smoothCheckboxEl.checked = section.smooth;
}
function setSelectedWidth(value) {
const i = state.selectedIndex;
if (i === null) return;
state.sequence[i].width = clamp(value, SECTION_WIDTH_MIN, SECTION_WIDTH_MAX);
render();
}
function setSelectedVScale(value) {
const i = state.selectedIndex;
if (i === null) return;
state.sequence[i].vScale = clamp(value, SECTION_VSCALE_MIN, SECTION_VSCALE_MAX);
render();
}
widthRangeEl.addEventListener('input', () => setSelectedWidth(parseFloat(widthRangeEl.value)));
widthNumberEl.addEventListener('change', () => setSelectedWidth(parseFloat(widthNumberEl.value) || SECTION_WIDTH));
vscaleRangeEl.addEventListener('input', () => setSelectedVScale(parseFloat(vscaleRangeEl.value)));
vscaleNumberEl.addEventListener('change', () => setSelectedVScale(parseFloat(vscaleNumberEl.value) || 1));
smoothCheckboxEl.addEventListener('change', () => {
const i = state.selectedIndex;
if (i === null) return;
state.sequence[i].smooth = smoothCheckboxEl.checked;
render();
});
moveLeftBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i <= 1) return;
[state.sequence[i - 1], state.sequence[i]] = [state.sequence[i], state.sequence[i - 1]];
state.selectedIndex = i - 1;
render();
});
moveRightBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i === 0 || i === state.sequence.length - 1) return;
[state.sequence[i + 1], state.sequence[i]] = [state.sequence[i], state.sequence[i + 1]];
state.selectedIndex = i + 1;
render();
});
removeBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i === 0) return;
state.sequence.splice(i, 1);
state.selectedIndex = null;
render();
});
// --- Preview -----------------------------------------------------------
const PADDING = 40;
const H_PX_PER_UNIT = 0.12;
const CANVAS_HEIGHT = 460;
function computeBounds(levelData) {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const segment of levelData.terrain) {
for (const p of segment.points) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
}
}
minX = Math.min(minX, levelData.startPosition.x);
maxX = Math.max(maxX, levelData.goal.x + levelData.goal.width / 2);
minY = Math.min(minY, levelData.startPosition.y - 100, levelData.goal.y - levelData.goal.height / 2);
maxY = Math.max(maxY, levelData.goal.y + levelData.goal.height / 2);
return { minX, maxX, minY, maxY };
}
function drawPreview(levelData) {
const { minX, maxX, minY, maxY } = computeBounds(levelData);
const contentWidth = Math.max(1, maxX - minX);
const contentHeight = Math.max(1, maxY - minY);
const width = Math.max(600, Math.round(contentWidth * H_PX_PER_UNIT) + PADDING * 2);
const height = CANVAS_HEIGHT;
previewCanvas.width = width;
previewCanvas.height = height;
const vScale = (height - PADDING * 2) / contentHeight;
const screenX = (worldX) => (worldX - minX) * H_PX_PER_UNIT + PADDING;
const screenY = (worldY) => (worldY - minY) * vScale + PADDING;
const ctx = previewCanvas.getContext('2d');
ctx.fillStyle = '#0b0e14';
ctx.fillRect(0, 0, width, height);
levelData.terrain.forEach((segment, i) => {
const pts = segment.points;
ctx.beginPath();
ctx.moveTo(screenX(pts[0].x), height);
for (const p of pts) ctx.lineTo(screenX(p.x), screenY(p.y));
ctx.lineTo(screenX(pts[pts.length - 1].x), height);
ctx.closePath();
ctx.fillStyle = '#3c8f3c';
ctx.fill();
ctx.beginPath();
ctx.moveTo(screenX(pts[0].x), screenY(pts[0].y));
for (const p of pts) ctx.lineTo(screenX(p.x), screenY(p.y));
ctx.strokeStyle = '#2c6e2c';
ctx.lineWidth = 3;
ctx.stroke();
if (i < levelData.terrain.length - 1) {
const nextPts = levelData.terrain[i + 1].points;
const gx1 = screenX(pts[pts.length - 1].x);
const gx2 = screenX(nextPts[0].x);
const mid = (gx1 + gx2) / 2;
ctx.save();
ctx.setLineDash([6, 6]);
ctx.strokeStyle = '#f2c14e';
ctx.beginPath();
ctx.moveTo(mid, PADDING);
ctx.lineTo(mid, height - PADDING);
ctx.stroke();
ctx.restore();
ctx.fillStyle = '#f2c14e';
ctx.font = '12px monospace';
ctx.fillText('GAP', mid - 14, PADDING - 8);
}
});
const sx = screenX(levelData.startPosition.x);
const sy = screenY(levelData.startPosition.y);
ctx.fillStyle = '#ffe066';
ctx.beginPath();
ctx.arc(sx, sy, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1f29';
ctx.font = 'bold 12px monospace';
ctx.fillText('S', sx - 4, sy + 4);
const g = levelData.goal;
const gx = screenX(g.x);
const gyTop = screenY(g.y - g.height / 2);
const gyBottom = screenY(g.y + g.height / 2);
const gWidthPx = g.width * H_PX_PER_UNIT;
ctx.fillStyle = 'rgba(242, 193, 78, 0.25)';
ctx.fillRect(gx - gWidthPx / 2, gyTop, gWidthPx, gyBottom - gyTop);
ctx.strokeStyle = '#f2c14e';
ctx.lineWidth = 2;
ctx.strokeRect(gx - gWidthPx / 2, gyTop, gWidthPx, gyBottom - gyTop);
ctx.fillStyle = '#f2c14e';
ctx.font = '12px monospace';
ctx.fillText('GOAL', gx - 16, gyTop - 6);
}
// --- Export --------------------------------------------------------------
let currentLevelData = null;
downloadBtn.addEventListener('click', () => {
if (!currentLevelData) return;
const source = exportLevelToSource(currentLevelData);
const blob = new Blob([source], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentLevelData.id || 'levelCustom'}.js`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
});
// --- Render loop -----------------------------------------------------------
function render() {
buildTrack();
updateTrackControls();
updateSizePanel();
currentLevelData = buildLevelData(state.sequence, state.metadata);
if (!currentLevelData) {
previewScrollEl.innerHTML = '<div id="empty-state">Add a section to see the preview.</div>';
exportTextarea.value = '';
downloadBtn.disabled = true;
return;
}
if (!previewScrollEl.contains(previewCanvas)) {
previewScrollEl.innerHTML = '';
previewScrollEl.appendChild(previewCanvas);
}
drawPreview(currentLevelData);
exportTextarea.value = exportLevelToSource(currentLevelData);
downloadBtn.disabled = false;
}
function initSizeControls() {
for (const el of [widthRangeEl, widthNumberEl]) {
el.min = String(SECTION_WIDTH_MIN);
el.max = String(SECTION_WIDTH_MAX);
}
for (const el of [vscaleRangeEl, vscaleNumberEl]) {
el.min = String(SECTION_VSCALE_MIN);
el.max = String(SECTION_VSCALE_MAX);
}
}
buildPalette();
buildMetadataForm();
initSizeControls();
render();

152
src/editor/sections.js Normal file
View File

@ -0,0 +1,152 @@
// Pure geometry - zero imports, no WORLD_SCALE, no DOM. Every generator works
// in "base design units" (the same pre-WORLD_SCALE space level files author
// in) so this module is trivially usable from plain Node for testing and
// stays decoupled from both Phaser and the editor's DOM code.
export const SECTION_WIDTH = 700;
export const POINT_STEP = 35;
// Bounds the editor's width/vertical-scale controls clamp to. Width floor
// keeps jump's fixed-length lip (JUMP_LIP_LEN) from eating the whole takeoff
// ramp at the smallest width; the rest are just sane "still looks like a
// track section" limits, not physically derived.
export const SECTION_WIDTH_MIN = 250;
export const SECTION_WIDTH_MAX = 2100;
export const SECTION_VSCALE_MIN = 0.3;
export const SECTION_VSCALE_MAX = 2.5;
export const SECTION_TYPES = [
{ id: 'flat', label: 'Flat' },
{ id: 'smoothIncline', label: 'Smooth Incline' },
{ id: 'sharpIncline', label: 'Sharp Incline' },
{ id: 'smoothDecline', label: 'Smooth Decline' },
{ id: 'sharpDecline', label: 'Sharp Decline' },
{ id: 'rollingHills', label: 'Rolling Hills' },
{ id: 'ditch', label: 'Ditch' },
{ id: 'jump', label: 'Jump' },
];
const RISE_SMOOTH = 90;
const RISE_SHARP = 160;
const AMP_HILLS = 45;
const DEPTH_DITCH = 130;
const JUMP_TAKEOFF_FRACTION = 0.45;
const JUMP_GAP_FRACTION = 0.25;
// JUMP_LANDING_FRACTION is implicit: 1 - JUMP_TAKEOFF_FRACTION - JUMP_GAP_FRACTION
const JUMP_TAKEOFF_RISE = 140;
const JUMP_LANDING_DROP = 60;
const JUMP_LIP_LEN = POINT_STEP;
function smoothstep(t) {
const c = Math.max(0, Math.min(1, t));
return c * c * (3 - 2 * c);
}
// Samples x from startX to startX+width every POINT_STEP (inclusive of both
// ends), calling shape(t) for the y-offset at each sample, t in [0,1].
function sampleBlock(startX, startY, width, shape) {
const points = [];
for (let x = startX; x <= startX + width; x += POINT_STEP) {
const t = (x - startX) / width;
points.push({ x, y: Math.round(startY + shape(t)) });
}
// Guard against floating-point step accumulation leaving the last sample
// short of startX+width.
const last = points[points.length - 1];
if (last.x < startX + width - 1) {
points.push({ x: startX + width, y: Math.round(startY + shape(1)) });
}
return points;
}
function flat(startX, startY, width) {
const points = sampleBlock(startX, startY, width, () => 0);
return { type: 'simple', points, endY: startY };
}
function smoothIncline(startX, startY, width, vScale) {
const rise = RISE_SMOOTH * vScale;
const points = sampleBlock(startX, startY, width, (t) => -rise * smoothstep(t));
return { type: 'simple', points, endY: startY - rise };
}
function sharpIncline(startX, startY, width, vScale) {
const rise = RISE_SHARP * vScale;
const points = sampleBlock(startX, startY, width, (t) => -rise * t);
return { type: 'simple', points, endY: startY - rise };
}
function smoothDecline(startX, startY, width, vScale) {
const rise = RISE_SMOOTH * vScale;
const points = sampleBlock(startX, startY, width, (t) => rise * smoothstep(t));
return { type: 'simple', points, endY: startY + rise };
}
function sharpDecline(startX, startY, width, vScale) {
const rise = RISE_SHARP * vScale;
const points = sampleBlock(startX, startY, width, (t) => rise * t);
return { type: 'simple', points, endY: startY + rise };
}
function rollingHills(startX, startY, width, vScale) {
const amp = AMP_HILLS * vScale;
const points = sampleBlock(startX, startY, width, (t) => amp * Math.sin(2 * Math.PI * t));
return { type: 'simple', points, endY: startY };
}
function ditch(startX, startY, width, vScale) {
const depth = DEPTH_DITCH * vScale;
const points = sampleBlock(startX, startY, width, (t) => depth * Math.sin(Math.PI * t));
return { type: 'simple', points, endY: startY };
}
// The one type that produces two separate point runs (a real terrain gap in
// between), mirroring how level02.js builds its jump: a "runway" segment
// ending at a raised lip, then a real x-gap, then a "landing" segment
// starting lower (simulating the fall across the gap).
function jump(startX, startY, width, vScale) {
const takeoffRise = JUMP_TAKEOFF_RISE * vScale;
const landingDrop = JUMP_LANDING_DROP * vScale;
const w1 = width * JUMP_TAKEOFF_FRACTION;
const w2 = width * JUMP_GAP_FRACTION;
const w3 = width - w1 - w2;
const rampLen = w1 - JUMP_LIP_LEN;
const lipY = startY - takeoffRise;
const takeoffPoints = [];
for (let x = startX; x <= startX + w1; x += POINT_STEP) {
const localX = x - startX;
const y = localX <= rampLen ? startY - takeoffRise * (localX / rampLen) : lipY;
takeoffPoints.push({ x, y: Math.round(y) });
}
const takeoffLast = takeoffPoints[takeoffPoints.length - 1];
if (takeoffLast.x < startX + w1 - 1) {
takeoffPoints.push({ x: startX + w1, y: Math.round(lipY) });
}
const landingStartX = startX + w1 + w2;
const landingStartY = lipY + landingDrop;
const endY = startY;
const landingPoints = sampleBlock(landingStartX, landingStartY, w3, (t) => (endY - landingStartY) * smoothstep(t));
return { type: 'jump', takeoffPoints, landingPoints, endY };
}
const GENERATORS = {
flat,
smoothIncline,
sharpIncline,
smoothDecline,
sharpDecline,
rollingHills,
ditch,
jump,
};
export function generateSection(typeId, startX, startY, width = SECTION_WIDTH, vScale = 1) {
const generator = GENERATORS[typeId];
if (!generator) throw new Error(`Unknown section type: ${typeId}`);
return generator(startX, startY, width, vScale);
}

View File

@ -13,6 +13,24 @@ export default class Bus {
const matter = scene.matter;
this.group = matter.world.nextGroup(true);
// Dedicated category so the compartment fixtures below can target kids
// specifically. They're standalone bodies (not part of the chassis),
// pinned to it via rigid constraints rather than compounded into it -
// Matter's own compound-body collision detection only ever consults the
// ROOT body's collisionFilter to decide whether a pair is even
// considered (verified against the vendored source: per-part filters on
// a compound are read for shape/response, never for the go/no-go
// decision), so a compounded fixture couldn't be selectively made to
// collide with kids while the chassis part stays exempt - only
// standalone bodies with their own root-level filter can do that.
this.kidCategory = matter.body.nextCategory();
// Chassis/wheels' own category, so kids can be exempted from just this
// bus's structure via mask rather than by sharing `group` with it -
// sharing a group is what used to also make every kid on this bus
// mutually exempt from every OTHER kid (group exemption doesn't
// distinguish which other body), which is why they were passing
// through each other.
this.busCategory = matter.body.nextCategory();
// The anchor sits suspensionTravel above where the wheel actually rests,
// so the spring pulling the wheel out to its full constraint length is
@ -32,10 +50,15 @@ export default class Bus {
density: BUS.chassisDensity,
friction: BUS.chassisFriction,
frictionAir: BUS.chassisFrictionAir,
collisionFilter: { group: this.group },
collisionFilter: { group: this.group, category: this.busCategory },
});
this.chassis.setDisplaySize(BUS.chassisWidth, BUS.chassisHeight);
this.chassis.setAngle(Phaser.Math.RadToDeg(angle));
// Explicit depth (rather than default 0) so it's guaranteed to draw
// over the kids riding behind it (see Kid.js's depth) regardless of
// creation order - kept fully opaque per its real art, not faked with
// alpha.
this.chassis.setDepth(2);
const wheelY = y + BUS.wheelRestOffsetY;
this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY);
@ -45,6 +68,109 @@ export default class Bus {
this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength);
this.seatOffsets = this._buildSeatOffsets();
this._buildCompartment(x, y);
// The wishbone above only constrains each wheel's DISTANCE from its two
// anchors - it has no notion of which side of them the wheel is on. Two
// positions satisfy "equidistant from both anchors": the normal one
// below the anchor line, and one mirrored above it. A hard enough impact
// can carry the wheel across that line within a single physics step, and
// the spring converges on the mirrored (wrong) solution just as happily
// - the wheel visibly "punches through" to sit high on the chassis and
// stays there, since it's now a stable equilibrium. This is a hard stop
// enforced every tick to make crossing impossible, since no amount of
// spring tuning or solver iteration can fix an ambiguity the constraint
// itself can't see.
this._anchorY = anchorY;
this._onAfterUpdate = this._onAfterUpdate.bind(this);
scene.matter.world.on('afterupdate', this._onAfterUpdate);
}
// Builds the invisible floor + left/right wall bodies kids ride in -
// solid on three sides, deliberately no ceiling body at all (that's what
// makes the top "open" - see KidManager, which drives the actual
// aboard/ejected state from a kid's position relative to
// BUS.compartmentTopY, not from anything here). Each fixture is a real,
// separate dynamic body (not compounded - see the kidCategory comment
// above) rigidly pinned to the chassis with the same two-point "wishbone"
// technique as the wheels, just tuned near-rigid (stiffness 1, length 0)
// instead of springy, so it tracks the chassis's position AND rotation
// together rather than swinging around a single point. Given non-trivial
// density (not negligible) so a colliding kid gets a normal, solid-feeling
// collision response rather than punching through underweight fixtures -
// KidManager's per-tick clamp is the actual containment guarantee
// (mirroring the wheel's hard clamp above), this is just what makes
// contact feel physical before that clamp would ever need to matter.
_buildCompartment(x, y) {
const wallHeight = (BUS.compartmentFloorY - BUS.compartmentTopY) + BUS.compartmentFloorThickness;
const wallCenterY = (BUS.compartmentFloorY + BUS.compartmentTopY) / 2;
this.compartmentFloor = this._buildFixture(x, y, 0, BUS.compartmentFloorY, BUS.compartmentHalfWidth * 2, BUS.compartmentFloorThickness);
this.compartmentLeftWall = this._buildFixture(x, y, -BUS.compartmentHalfWidth, wallCenterY, BUS.compartmentWallThickness, wallHeight);
this.compartmentRightWall = this._buildFixture(x, y, BUS.compartmentHalfWidth, wallCenterY, BUS.compartmentWallThickness, wallHeight);
}
_buildFixture(chassisX, chassisY, localX, localY, width, height) {
const matter = this.scene.matter;
const body = matter.add.rectangle(chassisX + localX, chassisY + localY, width, height, {
density: BUS.wheelDensity,
friction: BUS.chassisFriction,
collisionFilter: { mask: this.kidCategory },
});
const pinSpread = Math.max(5, Math.min(width, height) / 2);
const pinOptions = (anchorX) => ({
pointA: { x: anchorX, y: localY },
pointB: { x: 0, y: 0 },
});
matter.add.constraint(this.chassis, body, 0, 1, pinOptions(localX - pinSpread));
matter.add.constraint(this.chassis, body, 0, 1, pinOptions(localX + pinSpread));
return body;
}
_onAfterUpdate() {
this._clampWheelToWishbone(this.wheelRear);
this._clampWheelToWishbone(this.wheelFront);
}
// Keeps a wheel from ever crossing above its wishbone's anchor line (both
// anchors share the same anchorY, so the line is horizontal in the
// chassis's own rotated frame).
_clampWheelToWishbone(wheel) {
const chassis = this.chassis;
const cos = Math.cos(chassis.rotation);
const sin = Math.sin(chassis.rotation);
const dx = wheel.x - chassis.x;
const dy = wheel.y - chassis.y;
// Un-rotate the wheel's offset into the chassis's own frame, where the
// anchor line is simply y = this._anchorY.
const localX = dx * cos + dy * sin;
const localY = -dx * sin + dy * cos;
// Small margin below the true anchor line so the clamp settles instead
// of the spring and the clamp fighting exactly at the boundary.
const limit = this._anchorY + BUS.axleSpread;
if (localY >= limit) return;
const worldDX = localX * cos - limit * sin;
const worldDY = localX * sin + limit * cos;
wheel.setPosition(chassis.x + worldDX, chassis.y + worldDY);
// Kill only the velocity component still driving it through the line
// (in the chassis's frame), so it doesn't immediately re-punch next
// tick - like a real suspension bump stop absorbing the hit rather than
// bouncing off it. Sideways/rolling relative motion is left untouched.
const chassisBody = chassis.body;
const relVX = wheel.body.velocity.x - chassisBody.velocity.x;
const relVY = wheel.body.velocity.y - chassisBody.velocity.y;
const localVX = relVX * cos + relVY * sin;
const localVY = -relVX * sin + relVY * cos;
if (localVY < 0) {
const newRelVX = localVX * cos;
const newRelVY = localVX * sin;
wheel.setVelocity(chassisBody.velocity.x + newRelVX, chassisBody.velocity.y + newRelVY);
}
}
// Drive/brake apply a direct force to the chassis (see applyIntent) on top
@ -103,9 +229,10 @@ export default class Bus {
frictionStatic: BUS.wheelFrictionStatic,
frictionAir: BUS.wheelFrictionAir,
restitution: BUS.wheelRestitution,
collisionFilter: { group: this.group },
collisionFilter: { group: this.group, category: this.busCategory },
});
wheel.setDisplaySize(BUS.wheelRadius * 2, BUS.wheelRadius * 2);
wheel.setDepth(2); // matches the chassis - see its setDepth comment
return wheel;
}
@ -173,4 +300,11 @@ export default class Bus {
const seat = this.seatOffsets[seatIndex];
return { x: this.chassis.x + seat.x, y: this.chassis.y + seat.y };
}
destroy() {
// Deliberately empty, matching GForceMonitor.destroy(): Matter World's
// own shutdown already removes every listener registered on it
// (including _onAfterUpdate above), and by the time a scene's
// 'shutdown' handler runs, this.scene.matter.world may already be null.
}
}

View File

@ -1,71 +1,97 @@
import Phaser from 'phaser';
import { GFORCE, KID } from '../config.js';
import { KID } from '../config.js';
// A kid has no constraint holding it to the bus at all - it's a free body,
// contained purely by physically colliding with the invisible floor/wall
// fixtures pinned to the chassis (see Bus.js's _buildCompartment). state is
// a derived label KidManager updates every tick from the kid's actual
// position relative to the chassis's open top (see
// KidManager._onAfterUpdate), not something this class decides on its own.
export default class Kid {
constructor(scene, bus, seatIndex) {
this.scene = scene;
this.bus = bus;
this.seatIndex = seatIndex;
this.state = 'aboard'; // aboard | ejected | settled
this.state = 'aboard'; // aboard | ejected
this._slowSince = null; // see isSettled()
const seat = bus.seatOffsets[seatIndex];
const worldPos = bus.getSeatWorldPosition(seatIndex);
this.image = scene.matter.add.image(worldPos.x, worldPos.y, 'kid_idle', null, {
shape: { type: 'circle', radius: KID.radius },
density: KID.density,
friction: KID.friction,
collisionFilter: { group: bus.group },
// Exempt from the chassis/wheels forever (contained or not) via mask
// rather than by sharing the bus's group - sharing a group would also
// make every kid on this bus mutually exempt from every OTHER kid
// (group exemption is "never collide with anything sharing this
// group," not "never collide with this one specific body"), which is
// exactly why kids used to pass through each other. Containment
// itself is the floor/wall fixtures' job, which target this category
// specifically (see bus.kidCategory in Bus.js) so they still collide
// with the kid despite this mask.
collisionFilter: { category: bus.kidCategory, mask: 0xffffffff & ~bus.busCategory },
});
this.image.setDisplaySize(KID.radius * 2, KID.radius * 2);
this.seatConstraint = scene.matter.add.constraint(bus.chassis, this.image, KID.seatLength, KID.seatStiffness, {
pointA: seat,
pointB: { x: 0, y: 0 },
damping: KID.seatDamping,
});
this._settleAt = 0;
this._applyTexture('kid_idle');
// Between the background/terrain (default depth 0) and the bus
// (explicit depth 2, see Bus.js) - visible over the ground, but drawn
// over by the chassis/wheels, contained or not.
this.image.setDepth(1);
}
eject(chassisVelocity, gForce) {
if (this.state !== 'aboard') return;
// kid_idle/kid_ejected are 5-frame sheets (one distinct kid per frame,
// matching BUS.maxSeats) - picks the frame matching this kid's own seat
// so it stays visually the same character whichever texture it's on.
// Falls back to frame 0 for the single-frame placeholder texture used
// when the real art fails to load (see placeholderTextures.js).
_applyTexture(key) {
if (this.scene.textures.get(key).has(this.seatIndex)) {
this.image.setTexture(key, this.seatIndex);
} else {
this.image.setTexture(key);
}
}
// Called by KidManager when this kid's position rises above the open top
// of the compartment.
markEjected() {
if (this.state === 'ejected') return;
this.state = 'ejected';
this.scene.matter.world.removeConstraint(this.seatConstraint, true);
this.seatConstraint = null;
// Neutral group re-enables normal category/mask collision (was sharing
// the bus's non-colliding group while seated).
this.image.body.collisionFilter.group = 0;
this.image.setTexture('kid_ejected');
const speed = Math.hypot(chassisVelocity.x, chassisVelocity.y) || 1;
const dirX = chassisVelocity.x / speed;
const dirY = chassisVelocity.y / speed;
const severity = Phaser.Math.Clamp(gForce / GFORCE.ejectThresholdG, 1, 3);
this.image.applyForce({
x: dirX * GFORCE.ejectImpulseMagnitude * severity + (Math.random() - 0.5) * GFORCE.ejectImpulseMagnitude * 0.5,
y: dirY * GFORCE.ejectImpulseMagnitude * severity - GFORCE.ejectImpulseMagnitude * 0.4,
});
this._settleAt = this.scene.time.now + KID.settleTimeMs;
this._applyTexture('kid_ejected');
// Ignores the world's real gravity - KidManager applies a lighter,
// custom fraction of it instead (see KidManager._onBeforeUpdate) while
// ejected, so it floats through a much bigger arc than the bus's own
// fall would.
this.image.body.ignoreGravity = true;
}
update(time) {
if (this.state === 'ejected' && time >= this._settleAt) {
this.state = 'settled';
// Called by KidManager when a floating kid drifts back down through the
// open top and lands back inside the compartment.
markAboard() {
if (this.state === 'aboard') return;
this.state = 'aboard';
this._applyTexture('kid_idle');
this.image.body.ignoreGravity = false;
}
// True once this kid's speed has stayed below KID.restSpeedThreshold for
// KID.settleDurationMs straight - used by PlayScene to tell "actually
// landed and stopped" apart from a kid just passing through low speed for
// an instant at the peak of its arc while still airborne. Only meaningful
// to call repeatedly (e.g. every frame) once ejected - the timer resets
// itself the moment speed rises back above threshold.
isSettled(now) {
const v = this.image.body.velocity;
const speed = Math.hypot(v.x, v.y);
if (speed >= KID.restSpeedThreshold) {
this._slowSince = null;
return false;
}
if (this._slowSince === null) this._slowSince = now;
return now - this._slowSince >= KID.settleDurationMs;
}
destroy() {
// No removeConstraint() here (unlike eject(), which runs mid-gameplay
// and needs it): destroy() only ever runs from PlayScene's shutdown
// cleanup, by which point Phaser's Matter World plugin may have already
// torn down (nulled) this.scene.matter.world, and World#shutdown()
// already clears every constraint/body itself anyway.
this.seatConstraint = null;
this.image.destroy();
}
}

View File

@ -23,7 +23,22 @@ window.__PHASER_GAME__ = new Phaser.Game({
// independent) - has to scale with WORLD_SCALE or a 2x-bigger world
// falls proportionally slower (everything free-falls for longer to
// cover the now-doubled pixel distances).
gravity: { x: 0, y: 1 * WORLD_SCALE },
// Dropped from the original 1 - pulled back from an initial 0.35 (a
// lot more airtime) to 0.675, roughly the midpoint, since 0.35 read as
// too floaty. For a given launch speed off a ramp, time-to-apex is
// v/g, so this is still ~1.5x more hang time than the original.
// Floatier overall (slower falling everywhere, not just off jumps) is
// the tradeoff - primary tuning knob for "how floaty," not physically
// derived.
gravity: { x: 0, y: 0.675 * WORLD_SCALE },
// Default is 2. The wheel wishbones are soft spring constraints with a
// short travel range - on a hard landing the low default let a wheel's
// per-tick correction overshoot past its anchor and settle mirrored on
// the wrong side (visually "jumping" higher on the chassis and
// sticking there). More iterations converge the spring solve closer to
// the true equilibrium each tick, which keeps the wheel from punching
// through in the first place.
constraintIterations: 10,
debug: DEBUG,
},
},

View File

@ -1,5 +1,6 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { playMenuMusic } from '../util/music.js';
export default class IntroScene extends Phaser.Scene {
constructor() {
@ -8,6 +9,7 @@ export default class IntroScene extends Phaser.Scene {
create() {
this.cameras.main.setBackgroundColor('#8fc7e8');
playMenuMusic(this);
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 60 * WORLD_SCALE, 'MONSTERPLEX', {
fontFamily: 'monospace',

View File

@ -4,6 +4,7 @@ import { createButton } from '../util/ui.js';
const REASON_TEXT = {
'all-kids-lost': 'Every kid got thrown off the bus!',
'bus-fell': 'The bus fell into the gap!',
};
export default class LevelFailedScene extends Phaser.Scene {

View File

@ -2,6 +2,7 @@ import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { LEVELS } from '../data/levels/index.js';
import { getLevelResult } from '../util/progress.js';
import { playMenuMusic } from '../util/music.js';
export default class LevelSelectScene extends Phaser.Scene {
constructor() {
@ -10,6 +11,7 @@ export default class LevelSelectScene extends Phaser.Scene {
create() {
this.cameras.main.setBackgroundColor('#8fc7e8');
playMenuMusic(this);
this.add.text(GAME_WIDTH / 2, 60 * WORLD_SCALE, 'SELECT LEVEL', {
fontFamily: 'monospace',

View File

@ -1,6 +1,7 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { createButton } from '../util/ui.js';
import { playMenuMusic } from '../util/music.js';
export default class MainMenuScene extends Phaser.Scene {
constructor() {
@ -9,6 +10,7 @@ export default class MainMenuScene extends Phaser.Scene {
create() {
this.cameras.main.setBackgroundColor('#8fc7e8');
playMenuMusic(this);
this.add.text(GAME_WIDTH / 2, 90 * WORLD_SCALE, 'MONSTERPLEX', {
fontFamily: 'monospace',

View File

@ -7,6 +7,9 @@ import InputController from '../systems/InputController.js';
import GForceMonitor from '../systems/GForceMonitor.js';
import KidManager from '../systems/KidManager.js';
import CameraRig from '../systems/CameraRig.js';
import EngineSound from '../systems/EngineSound.js';
import { stopMenuMusic } from '../util/music.js';
import { playLevelVoiceLine } from '../util/voiceLine.js';
export default class PlayScene extends Phaser.Scene {
constructor() {
@ -20,6 +23,8 @@ export default class PlayScene extends Phaser.Scene {
}
create() {
stopMenuMusic(this);
this.cameras.main.setBackgroundColor('#8fc7e8');
this._buildParallax();
@ -32,19 +37,18 @@ export default class PlayScene extends Phaser.Scene {
this.inputController = new InputController(this);
this.engineSound = new EngineSound(this, this.bus);
this.voiceSound = playLevelVoiceLine(this, this.levelId);
this.gForceMonitor = new GForceMonitor(this, this.bus.chassis.body);
// Measured via headless testing: the spawn-to-ground drop settles by
// ~4.5s with the current spawn height/bus mass. Re-measure this if
// startPosition, WORLD_SCALE, or the bus's mass/suspension change again -
// too short and the landing itself trips the g-force ejection check.
this.gForceMonitor.startGracePeriod(5000);
this.gForceMonitor.resetBaseline();
this.cameraRig = new CameraRig(this, this.bus.chassis, this.level.cameraBounds);
this._buildGoal();
this._buildHud();
this.events.on('kid-ejected', this._onKidEjected, this);
this.events.on('kid-status-changed', this._onKidStatusChanged, this);
this.events.on('level-failed', this._onLevelFailed, this);
this.matter.world.on('collisionstart', this._onCollisionStart, this);
@ -57,7 +61,11 @@ export default class PlayScene extends Phaser.Scene {
const intent = this.inputController.getIntent();
this.bus.applyIntent(intent);
this.kidManager.update(time);
this.engineSound.update();
this.cameraRig.update();
this._updateParallax();
this._checkBusFell();
this._checkAllKidsLost();
if (DEBUG && this.debugText) {
const g = this.gForceMonitor.lastGForce || 0;
@ -69,14 +77,92 @@ export default class PlayScene extends Phaser.Scene {
const bounds = this.level.cameraBounds;
const width = bounds.width + 1000 * WORLD_SCALE;
const far = this.add.tileSprite(bounds.x, 0, width, GAME_HEIGHT, 'bg_far').setOrigin(0, 0);
far.setScrollFactor(0.15, 0);
// Far layer fills the full viewport height at ONE uniform scale (same
// 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.setScrollFactor(0.45, 0);
// Mid/near are ground-level layers stacked from the bottom of the
// 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);
near.setScrollFactor(0.75, 0);
_nativeTextureSize(key) {
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;
}
// A bus that's fallen past the bottom of the level's own camera bounds
// is somewhere the camera can never scroll down far enough to show again
// (that's the literal definition of "off screen" here) - treated the
// same as losing every kid, since there's no way to recover either.
_checkBusFell() {
if (this._isBelowCameraBounds(this.bus.chassis.y)) {
this.events.emit('level-failed', { reason: 'bus-fell' });
}
}
_isBelowCameraBounds(y) {
const bounds = this.level.cameraBounds;
return y > bounds.y + bounds.height;
}
// 0 kids aboard isn't itself a fail condition - KidManager just tracks
// who's still seated. A kid popped loose by a jump is still catchable
// (falls down through the compartment's open top and re-boards - see
// KidManager) right up until it's either settled outside the bus or lost
// down a gap, so the level only fails once EVERY kid has reached one of
// those two unrecoverable states, not the instant the last one leaves.
_checkAllKidsLost() {
if (this.kidManager.kidsAboardCount > 0) return;
const allUnrecoverable = this.kidManager.kids.every((kid) => {
return this._isBelowCameraBounds(kid.image.y) || kid.isSettled(this.time.now);
});
if (allUnrecoverable) {
this.events.emit('level-failed', { reason: 'all-kids-lost' });
}
}
_buildGoal() {
@ -117,7 +203,7 @@ export default class PlayScene extends Phaser.Scene {
}
}
_onKidEjected({ kidsAboard, total }) {
_onKidStatusChanged({ kidsAboard, total }) {
const ejectedCount = total - kidsAboard;
for (let i = 0; i < this.hudIcons.length; i++) {
this.hudIcons[i].setAlpha(i < ejectedCount ? 0.2 : 1);
@ -153,7 +239,7 @@ export default class PlayScene extends Phaser.Scene {
}
_cleanup() {
this.events.off('kid-ejected', this._onKidEjected, this);
this.events.off('kid-status-changed', this._onKidStatusChanged, this);
this.events.off('level-failed', this._onLevelFailed, this);
// No matter.world.off() here: Phaser's Matter World plugin registers its
// own 'shutdown' listener before this scene's create() ever runs, so it
@ -164,5 +250,8 @@ export default class PlayScene extends Phaser.Scene {
// redundant, not just now-unsafe.
if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy();
if (this.engineSound) this.engineSound.destroy();
if (this.voiceSound) this.voiceSound.stop();
if (this.bus) this.bus.destroy();
}
}

View File

@ -2,6 +2,8 @@ import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { ASSET_MANIFEST } from '../util/assetManifest.js';
import { generatePlaceholder } from '../util/placeholderTextures.js';
import { LEVELS } from '../data/levels/index.js';
import { voiceKey, voiceAssetPath } from '../util/voiceLine.js';
export default class PreloadScene extends Phaser.Scene {
constructor() {
@ -17,10 +19,25 @@ export default class PreloadScene extends Phaser.Scene {
});
for (const entry of ASSET_MANIFEST) {
if (entry.frameWidth) {
this.load.spritesheet(entry.key, entry.path, { frameWidth: entry.frameWidth, frameHeight: entry.frameHeight });
} else {
this.load.image(entry.key, entry.path);
}
}
this.load.audio('main_title_music', 'assets/music/main-title.mp3');
this.load.audio('engine_heavy', 'assets/fx/engine-heavy.mp3');
// Not every level has a voice-over - this just attempts one per level
// and lets a missing file 404 like any other optional asset (the
// FILE_LOAD_ERROR listener above already tolerates that); PlayScene
// checks the audio cache before playing rather than assuming it loaded.
for (const level of LEVELS) {
this.load.audio(voiceKey(level.id), voiceAssetPath(level.id));
}
}
create() {
for (const entry of ASSET_MANIFEST) {
if (this._failedKeys.has(entry.key) || !this.textures.exists(entry.key)) {

View File

@ -1,15 +1,29 @@
import Phaser from 'phaser';
import { CAMERA } from '../config.js';
export default class CameraRig {
constructor(scene, target, bounds) {
this.scene = scene;
const cam = scene.cameras.main;
this.target = target;
this.cam = scene.cameras.main;
if (bounds) {
cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
this.cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
}
cam.setDeadzone(CAMERA.deadzoneWidth, CAMERA.deadzoneHeight);
cam.startFollow(target, true, CAMERA.lerpX, CAMERA.lerpY);
this.cam.setDeadzone(CAMERA.deadzoneWidth, CAMERA.deadzoneHeight);
this.cam.startFollow(target, true, CAMERA.lerpX, CAMERA.lerpY);
}
// Leans the bus toward the left edge of the screen at rest and out to
// CAMERA.maxSpeedScreenFraction of the way across at speed, so there's
// more visible road ahead the faster the bus is moving. Driven through
// Phaser's followOffset (rather than setting scroll ourselves) so it
// still rides Phaser's own per-frame lerp smoothing and bounds clamping.
update() {
const speed = Math.abs(this.target.body.velocity.x);
const t = Phaser.Math.Clamp(speed / CAMERA.lookaheadMaxSpeed, 0, 1);
const screenFraction = Phaser.Math.Linear(CAMERA.restScreenFraction, CAMERA.maxSpeedScreenFraction, t);
this.cam.followOffset.x = (screenFraction - 0.5) * this.cam.width;
}
}

View File

@ -0,0 +1,32 @@
import Phaser from 'phaser';
import { BUS, ENGINE } from '../config.js';
// Loops engine-heavy.mp3 for the whole level and pitches it to track the
// bus's revs, so it reads as one continuous engine rather than a sample
// that starts/stops with throttle. wheelRear is the only wheel
// applyIntent ever spins directly (see Bus.applyIntent), so its angular
// velocity IS the rev signal - it climbs toward BUS.maxWheelAngularVelocity
// under throttle and bleeds back down via wheelFrictionAir once the gas is
// released, exactly like an engine returning to idle on its own.
export default class EngineSound {
constructor(scene, bus) {
this.bus = bus;
this.rate = ENGINE.minRate;
this.sound = scene.sound.add('engine_heavy', { loop: true, volume: ENGINE.volume, rate: ENGINE.minRate });
this.sound.play();
}
update() {
const wheelBody = this.bus.wheelRear.body;
const revs = Phaser.Math.Clamp(Math.abs(wheelBody.angularVelocity) / BUS.maxWheelAngularVelocity, 0, 1);
const targetRate = Phaser.Math.Linear(ENGINE.minRate, ENGINE.maxRate, revs);
this.rate = Phaser.Math.Linear(this.rate, targetRate, ENGINE.rateSmoothing);
this.sound.setRate(this.rate);
}
destroy() {
this.sound.stop();
}
}

View File

@ -1,24 +1,26 @@
import { GFORCE, PHYSICS_TIMESTEP_SECONDS } from '../config.js';
import { PHYSICS_TIMESTEP_SECONDS, GFORCE } from '../config.js';
// Pure detector: samples the chassis's linear velocity every fixed physics
// step, estimates acceleration via finite difference, and emits
// 'gforce-exceeded' on the scene when it crosses the tunable threshold.
// Ejection logic lives elsewhere (KidManager) so this stays a single-purpose
// sensor.
// Pure debug diagnostic: kid ejection is no longer impact-triggered (see
// KidManager/Bus's open-top compartment box) - this just samples the
// chassis's linear velocity every fixed physics step and estimates
// acceleration via finite difference, purely for the DEBUG g-force readout
// in PlayScene.
export default class GForceMonitor {
constructor(scene, chassisBody) {
this.scene = scene;
this.chassisBody = chassisBody;
this.previousVelocity = { x: 0, y: 0 };
this.graceUntil = 0;
this.enabled = true;
this.lastGForce = 0;
this._onAfterUpdate = this._onAfterUpdate.bind(this);
scene.matter.world.on('afterupdate', this._onAfterUpdate);
}
startGracePeriod(durationMs = GFORCE.gracePeriodMs) {
this.graceUntil = this.scene.time.now + durationMs;
// Resets the velocity baseline to whatever it is right now, so the next
// tick's comparison isn't against a stale one - call this once whatever
// isn't a "real" impact (e.g. the spawn drop) has settled.
resetBaseline() {
const v = this.chassisBody.velocity;
this.previousVelocity = { x: v.x, y: v.y };
}
@ -33,16 +35,9 @@ export default class GForceMonitor {
const accelPxPerSec2 = deltaV / PHYSICS_TIMESTEP_SECONDS;
const accelMPerSec2 = accelPxPerSec2 / GFORCE.pixelsPerMeter;
const gForce = accelMPerSec2 / 9.8;
this.lastGForce = accelMPerSec2 / 9.8;
this.previousVelocity = { x: v.x, y: v.y };
this.lastGForce = gForce;
if (this.scene.time.now < this.graceUntil) return;
if (gForce > GFORCE.ejectThresholdG) {
this.scene.events.emit('gforce-exceeded', { gForce });
}
}
destroy() {

View File

@ -1,5 +1,4 @@
import Phaser from 'phaser';
import { GFORCE } from '../config.js';
import { KID, BUS } from '../config.js';
import Kid from '../entities/Kid.js';
export default class KidManager {
@ -8,47 +7,157 @@ export default class KidManager {
this.bus = bus;
this.total = kidsAboard;
this.kids = [];
this._lastEjectTime = -Infinity;
for (let i = 0; i < kidsAboard; i++) {
this.kids.push(new Kid(scene, bus, i));
}
this._onGForceExceeded = this._onGForceExceeded.bind(this);
scene.events.on('gforce-exceeded', this._onGForceExceeded);
// A kid past the open top (see Kid.markEjected) sets body.ignoreGravity
// so the engine's own gravity application skips it 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 an afterupdate hook.
this._onBeforeUpdate = this._onBeforeUpdate.bind(this);
scene.matter.world.on('beforeupdate', this._onBeforeUpdate);
// Drives the actual aboard/ejected transition, in both directions - see
// _onAfterUpdate.
this._onAfterUpdate = this._onAfterUpdate.bind(this);
scene.matter.world.on('afterupdate', this._onAfterUpdate);
}
get kidsAboardCount() {
return this.kids.filter((kid) => kid.state === 'aboard').length;
}
_onGForceExceeded({ gForce }) {
const now = this.scene.time.now;
if (now - this._lastEjectTime < GFORCE.ejectCooldownMs) return;
_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;
}
}
const aboard = this.kids.filter((kid) => kid.state === 'aboard');
if (aboard.length === 0) return;
// Every physics step: hard-clamps each kid against the floor/walls (see
// _clampToCompartment - same "soft collision + hard clamp" pairing as
// Bus.js's wheel wishbone, since the fixtures' own collision response
// alone isn't a hard guarantee against a fast enough hit), then checks
// the (now-corrected) position against the compartment's open top in the
// chassis's own rotated frame. A kid drifting up past the open top
// becomes ejected immediately. A floating kid drifting back down through
// the open top only re-boards once it has actually come to rest inside
// the compartment (see Kid.isSettled), so a kid still mid-arc can't
// teleport back in mid-air.
_onAfterUpdate() {
const chassis = this.bus.chassis;
const cos = Math.cos(chassis.rotation);
const sin = Math.sin(chassis.rotation);
this._lastEjectTime = now;
const kid = Phaser.Utils.Array.GetRandom(aboard);
kid.eject(this.bus.chassis.body.velocity, gForce);
let anyChanged = false;
for (const kid of this.kids) {
this._clampToCompartment(kid, chassis, cos, sin);
this.scene.events.emit('kid-ejected', {
const dx = kid.image.x - chassis.x;
const dy = kid.image.y - chassis.y;
const localY = -dx * sin + dy * cos;
const isAboveOpenTop = localY < BUS.compartmentTopY;
if (isAboveOpenTop && kid.state === 'aboard') {
kid.markEjected();
anyChanged = true;
} else if (!isAboveOpenTop && kid.state === 'ejected' && kid.isSettled(this.scene.time.now)) {
// Re-boarding is gated on the kid actually settling (see Kid.isSettled),
// not just passing through the compartment's vertical band again. Ejected
// kids run under reduced gravity, so a kid still floating/arc-ing will
// happily drift back through the open top while airborne; without this
// gate it would flip back to aboard mid-air and "teleport" back in.
kid.markAboard();
anyChanged = true;
}
}
if (anyChanged) {
this.scene.events.emit('kid-status-changed', {
kidsAboard: this.kidsAboardCount,
total: this.total,
});
if (this.kidsAboardCount === 0) {
this.scene.events.emit('level-failed', { reason: 'all-kids-lost' });
// No level-failed here even at 0 aboard - PlayScene decides that once
// every kid has actually settled outside the bus (or fallen down a
// gap), not the instant the last one leaves, so a jump that pops
// everyone loose still leaves a window to drive back underneath and
// catch them (see PlayScene._checkAllKidsLost).
}
}
update(time) {
for (const kid of this.kids) kid.update(time);
// Keeps a kid from ever tunneling through the floor or a side wall in a
// single hard hit, in the chassis's own rotated frame (same un-rotate
// technique as Bus.js's wheel clamp). Never clamps the top - that's the
// one side deliberately left open.
_clampToCompartment(kid, chassis, cos, sin) {
const dx = kid.image.x - chassis.x;
const dy = kid.image.y - chassis.y;
// A kid genuinely outside compartmentClampRadius was never inside the
// compartment this tick - skip it entirely, rather than let the below
// checks (which only bound ONE axis each) treat "far away in the other
// axis" as a boundary violation. Without this, a kid resting anywhere
// else in the level could get yanked toward wherever the bus currently
// is, since local-frame coordinates alone don't distinguish "just
// tunneled through" from "elsewhere entirely."
if (dx * dx + dy * dy > BUS.compartmentClampRadius * BUS.compartmentClampRadius) return;
let localX = dx * cos + dy * sin;
let localY = -dx * sin + dy * cos;
let hitFloor = false;
let hitLeftWall = false;
let hitRightWall = false;
const restY = BUS.compartmentFloorY - BUS.compartmentFloorThickness / 2 - KID.radius;
if (localY > restY) {
localY = restY;
hitFloor = true;
}
// Walls only exist at/below the open top - a kid that's already escaped
// upward has nothing left to bump sideways into.
if (localY >= BUS.compartmentTopY) {
const leftBoundary = -BUS.compartmentHalfWidth + BUS.compartmentWallThickness / 2 + KID.radius;
const rightBoundary = BUS.compartmentHalfWidth - BUS.compartmentWallThickness / 2 - KID.radius;
if (localX < leftBoundary) {
localX = leftBoundary;
hitLeftWall = true;
} else if (localX > rightBoundary) {
localX = rightBoundary;
hitRightWall = true;
}
}
if (!hitFloor && !hitLeftWall && !hitRightWall) return;
const worldX = chassis.x + (localX * cos - localY * sin);
const worldY = chassis.y + (localX * sin + localY * cos);
kid.image.setPosition(worldX, worldY);
// Kill only the relative velocity component(s) still driving it through
// whichever boundary caught it, same reasoning as the wheel clamp - a
// bump stop absorbing the hit, not bouncing off it.
const chassisBody = chassis.body;
const body = kid.image.body;
const relVX = body.velocity.x - chassisBody.velocity.x;
const relVY = body.velocity.y - chassisBody.velocity.y;
let localVX = relVX * cos + relVY * sin;
let localVY = -relVX * sin + relVY * cos;
if (hitFloor && localVY > 0) localVY = 0;
if (hitLeftWall && localVX < 0) localVX = 0;
if (hitRightWall && localVX > 0) localVX = 0;
const newRelVX = localVX * cos - localVY * sin;
const newRelVY = localVX * sin + localVY * cos;
kid.image.setVelocity(chassisBody.velocity.x + newRelVX, chassisBody.velocity.y + newRelVY);
}
destroy() {
this.scene.events.off('gforce-exceeded', this._onGForceExceeded);
for (const kid of this.kids) kid.destroy();
}
}

View File

@ -6,8 +6,14 @@ import { WORLD_SCALE } from '../config.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 },
{ key: 'bus_wheel', path: 'assets/sprites/bus_wheel.png', width: 34 * WORLD_SCALE, height: 34 * WORLD_SCALE, kind: 'circle', color: 0x222222 },
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e },
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b },
// 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
// instead of a single squashed image; Kid.js picks the frame matching its
// seat index. width/height here stay the DISPLAY size of one frame, same
// as before - only used for the single-frame placeholder fallback.
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e, frameWidth: 56, frameHeight: 56 },
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b, frameWidth: 56, frameHeight: 56 },
{ 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 },

21
src/util/music.js Normal file
View File

@ -0,0 +1,21 @@
// Menu music spans Intro -> MainMenu -> LevelSelect as one continuous track
// (Phaser's sound manager lives on the game, not the scene, so a Sound
// object started in one scene keeps playing through scene.start() calls
// into the next). playMenuMusic is safe to call from every one of those
// scenes' create() - it no-ops if the track is already playing so
// navigating between them doesn't restart it.
const KEY = 'main_title_music';
export function playMenuMusic(scene) {
const existing = scene.sound.get(KEY);
if (existing && existing.isPlaying) return;
if (existing) {
existing.play();
return;
}
scene.sound.add(KEY, { loop: true, volume: 0.5 }).play();
}
export function stopMenuMusic(scene) {
scene.sound.get(KEY)?.stop();
}

27
src/util/voiceLine.js Normal file
View File

@ -0,0 +1,27 @@
// Per-level voice-over: assets/voice/<levelId>.mp3, if present, plays once
// at the start of that level. Not every level has one - PreloadScene
// attempts to load one for every level in LEVELS and just lets a missing
// file fail (same 404-tolerant pattern as everything in ASSET_MANIFEST),
// so playLevelVoiceLine only has to check whether the load actually landed
// in the audio cache before playing.
const keyFor = (levelId) => `voice_${levelId}`;
export function voiceKey(levelId) {
return keyFor(levelId);
}
export function voiceAssetPath(levelId) {
return `assets/voice/${levelId}.mp3`;
}
// Plays levelId's voice line if assets/voice/<levelId>.mp3 was found at
// load time. Returns the Sound instance (so the caller can stop it early
// on scene teardown) or null if this level has none.
export function playLevelVoiceLine(scene, levelId) {
const key = keyFor(levelId);
if (!scene.cache.audio.exists(key)) return null;
const sound = scene.sound.add(key);
sound.play();
return sound;
}