Compare commits

..

No commits in common. "3b8c13fb4ef00f218159ef37c218c7f63b51c313" and "ffe29468d6cf0ea004910f6b1253582854e8b1b6" have entirely different histories.

33 changed files with 155 additions and 2659 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

View File

@ -1,266 +0,0 @@
<!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,12 +25,11 @@ export const BUS = {
wheelRadius: 17 * WORLD_SCALE,
rearWheelOffsetX: -35 * WORLD_SCALE,
frontWheelOffsetX: 64 * 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,
// 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,
// 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
@ -40,31 +39,6 @@ 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,
@ -80,16 +54,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.45,
wheelRestitution: 0.3,
// Softer and less damped than before - more travel, and lets it actually
// oscillate (bounce) instead of settling immediately - "more shocks."
suspensionStiffness: 0.35,
suspensionDamping: 0.035,
suspensionStiffness: 0.45,
suspensionDamping: 0.08,
// 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: 30 * WORLD_SCALE,
suspensionTravel: 12 * WORLD_SCALE,
// Angular velocities/torque steps are rotational (radians), not linear
// pixel distances, so they're left unscaled - wheel radius already scaled,
@ -111,102 +85,43 @@ 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.
// 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,
driveAcceleration: 0.011,
brakeAcceleration: 0.007,
// 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,
// 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,
};
// 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.
// relative to the bus, and ejectThresholdG doesn't need re-tuning.
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,
// 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,
seatStiffness: 0.22,
seatDamping: 0.1,
seatLength: 4 * WORLD_SCALE,
settleTimeMs: 2000,
};
export const CAMERA = {
lerpX: 0.1,
lerpY: 0.1,
// 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,
deadzoneWidth: 220 * 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,9 +1,8 @@
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, level04];
export const LEVELS = [level01, level02, level03];
export function getLevelById(id) {
return LEVELS.find((level) => level.id === id);

View File

@ -1,387 +1,20 @@
// Generated by editor.html - review before dropping into src/data/levels/.
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);
export default {
id: "level01",
name: "GoFundMe",
description: "Get the Money!",
kidsAboard: 4,
startPosition: { x: 200, y: 700 },
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 },
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: 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 },
] },
],
terrain: [{ points }],
obstacles: [],
goal: { x: 25420, y: 406, width: 280, height: 640 },
cameraBounds: { x: -600, y: -522, width: 27060, height: 2414 },
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 },
};

View File

@ -1,473 +0,0 @@
// 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 },
};

View File

@ -1,41 +0,0 @@
// 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');
}

View File

@ -1,241 +0,0 @@
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,
},
};
}

View File

@ -1,386 +0,0 @@
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();

View File

@ -1,152 +0,0 @@
// 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,24 +13,6 @@ 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
@ -50,15 +32,10 @@ export default class Bus {
density: BUS.chassisDensity,
friction: BUS.chassisFriction,
frictionAir: BUS.chassisFrictionAir,
collisionFilter: { group: this.group, category: this.busCategory },
collisionFilter: { group: this.group },
});
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);
@ -68,109 +45,6 @@ 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
@ -229,10 +103,9 @@ export default class Bus {
frictionStatic: BUS.wheelFrictionStatic,
frictionAir: BUS.wheelFrictionAir,
restitution: BUS.wheelRestitution,
collisionFilter: { group: this.group, category: this.busCategory },
collisionFilter: { group: this.group },
});
wheel.setDisplaySize(BUS.wheelRadius * 2, BUS.wheelRadius * 2);
wheel.setDepth(2); // matches the chassis - see its setDepth comment
return wheel;
}
@ -300,11 +173,4 @@ 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,97 +1,71 @@
import { KID } from '../config.js';
import Phaser from 'phaser';
import { GFORCE, 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
this._slowSince = null; // see isSettled()
this.state = 'aboard'; // aboard | ejected | settled
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,
// 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 },
collisionFilter: { group: bus.group },
});
this.image.setDisplaySize(KID.radius * 2, KID.radius * 2);
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);
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;
}
// 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;
eject(chassisVelocity, gForce) {
if (this.state !== 'aboard') return;
this.state = 'ejected';
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;
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;
}
// 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;
update(time) {
if (this.state === 'ejected' && time >= this._settleAt) {
this.state = 'settled';
}
// 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,22 +23,7 @@ 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).
// 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,
gravity: { x: 0, y: 1 * WORLD_SCALE },
debug: DEBUG,
},
},

View File

@ -1,6 +1,5 @@
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() {
@ -9,7 +8,6 @@ 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,7 +4,6 @@ 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,7 +2,6 @@ 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() {
@ -11,7 +10,6 @@ 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,7 +1,6 @@
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() {
@ -10,7 +9,6 @@ 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,9 +7,6 @@ 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() {
@ -23,8 +20,6 @@ export default class PlayScene extends Phaser.Scene {
}
create() {
stopMenuMusic(this);
this.cameras.main.setBackgroundColor('#8fc7e8');
this._buildParallax();
@ -37,18 +32,19 @@ 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);
this.gForceMonitor.resetBaseline();
// 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.cameraRig = new CameraRig(this, this.bus.chassis, this.level.cameraBounds);
this._buildGoal();
this._buildHud();
this.events.on('kid-status-changed', this._onKidStatusChanged, this);
this.events.on('kid-ejected', this._onKidEjected, this);
this.events.on('level-failed', this._onLevelFailed, this);
this.matter.world.on('collisionstart', this._onCollisionStart, this);
@ -61,11 +57,7 @@ export default class PlayScene extends Phaser.Scene {
const intent = this.inputController.getIntent();
this.bus.applyIntent(intent);
this.engineSound.update();
this.cameraRig.update();
this._updateParallax();
this._checkBusFell();
this._checkAllKidsLost();
this.kidManager.update(time);
if (DEBUG && this.debugText) {
const g = this.gForceMonitor.lastGForce || 0;
@ -77,92 +69,14 @@ export default class PlayScene extends Phaser.Scene {
const bounds = this.level.cameraBounds;
const width = bounds.width + 1000 * WORLD_SCALE;
// 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 far = this.add.tileSprite(bounds.x, 0, width, GAME_HEIGHT, 'bg_far').setOrigin(0, 0);
far.setScrollFactor(0.15, 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 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);
_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' });
}
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);
}
_buildGoal() {
@ -203,7 +117,7 @@ export default class PlayScene extends Phaser.Scene {
}
}
_onKidStatusChanged({ kidsAboard, total }) {
_onKidEjected({ kidsAboard, total }) {
const ejectedCount = total - kidsAboard;
for (let i = 0; i < this.hudIcons.length; i++) {
this.hudIcons[i].setAlpha(i < ejectedCount ? 0.2 : 1);
@ -239,7 +153,7 @@ export default class PlayScene extends Phaser.Scene {
}
_cleanup() {
this.events.off('kid-status-changed', this._onKidStatusChanged, this);
this.events.off('kid-ejected', this._onKidEjected, 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
@ -250,8 +164,5 @@ 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,8 +2,6 @@ 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() {
@ -19,25 +17,10 @@ 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,29 +1,15 @@
import Phaser from 'phaser';
import { CAMERA } from '../config.js';
export default class CameraRig {
constructor(scene, target, bounds) {
this.scene = scene;
this.target = target;
this.cam = scene.cameras.main;
const cam = scene.cameras.main;
if (bounds) {
this.cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
}
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;
cam.setDeadzone(CAMERA.deadzoneWidth, CAMERA.deadzoneHeight);
cam.startFollow(target, true, CAMERA.lerpX, CAMERA.lerpY);
}
}

View File

@ -1,32 +0,0 @@
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,26 +1,24 @@
import { PHYSICS_TIMESTEP_SECONDS, GFORCE } from '../config.js';
import { GFORCE, PHYSICS_TIMESTEP_SECONDS } from '../config.js';
// 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.
// 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.
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);
}
// 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() {
startGracePeriod(durationMs = GFORCE.gracePeriodMs) {
this.graceUntil = this.scene.time.now + durationMs;
const v = this.chassisBody.velocity;
this.previousVelocity = { x: v.x, y: v.y };
}
@ -35,9 +33,16 @@ export default class GForceMonitor {
const accelPxPerSec2 = deltaV / PHYSICS_TIMESTEP_SECONDS;
const accelMPerSec2 = accelPxPerSec2 / GFORCE.pixelsPerMeter;
this.lastGForce = accelMPerSec2 / 9.8;
const gForce = 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,4 +1,5 @@
import { KID, BUS } from '../config.js';
import Phaser from 'phaser';
import { GFORCE } from '../config.js';
import Kid from '../entities/Kid.js';
export default class KidManager {
@ -7,157 +8,47 @@ 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));
}
// 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);
this._onGForceExceeded = this._onGForceExceeded.bind(this);
scene.events.on('gforce-exceeded', this._onGForceExceeded);
}
get kidsAboardCount() {
return this.kids.filter((kid) => kid.state === 'aboard').length;
}
_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;
}
}
_onGForceExceeded({ gForce }) {
const now = this.scene.time.now;
if (now - this._lastEjectTime < GFORCE.ejectCooldownMs) 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);
const aboard = this.kids.filter((kid) => kid.state === 'aboard');
if (aboard.length === 0) return;
let anyChanged = false;
for (const kid of this.kids) {
this._clampToCompartment(kid, chassis, cos, sin);
this._lastEjectTime = now;
const kid = Phaser.Utils.Array.GetRandom(aboard);
kid.eject(this.bus.chassis.body.velocity, gForce);
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', {
this.scene.events.emit('kid-ejected', {
kidsAboard: this.kidsAboardCount,
total: this.total,
});
// 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).
if (this.kidsAboardCount === 0) {
this.scene.events.emit('level-failed', { reason: 'all-kids-lost' });
}
}
// 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);
update(time) {
for (const kid of this.kids) kid.update(time);
}
destroy() {
this.scene.events.off('gforce-exceeded', this._onGForceExceeded);
for (const kid of this.kids) kid.destroy();
}
}

View File

@ -6,14 +6,8 @@ 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 },
// 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: '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 },
{ 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 },

View File

@ -1,21 +0,0 @@
// 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();
}

View File

@ -1,27 +0,0 @@
// 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;
}