feat: complete level 01, engine audio, menu music, editor enhancements

- Implement EngineSound system that pitches engine-heavy.mp3 based on rear
  wheel angular velocity, with rate smoothing to avoid stair-stepping
- Add continuous menu music across Intro → MainMenu → LevelSelect scenes
- Add per-level voice-over support (level01: "Get the Money!")
- Redesign level01 with hand-crafted terrain sections (Flat, Incline,
  Jump, Roller, Descent, Bumps, Flat, Descent, Bumps, Flat)
- Editor: per-section width and vertical scale (vScale) controls
- Editor: smooth entry blending between sections using angle-based
  interpolation (avoids slope overshoot from naive height blending)
- Editor: new size-panel UI with range/number inputs and smooth checkbox
- Fix KidManager re-boarding: ejected kids must be settled before
  re-boarding to prevent mid-air teleportation
- Load audio assets in PreloadScene; handle missing voice files gracefully
This commit is contained in:
Brian Fertig 2026-08-20 10:10:19 -06:00
parent f3e7f96220
commit 3b8c13fb4e
18 changed files with 861 additions and 61 deletions

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

Binary file not shown.

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

Binary file not shown.

BIN
assets/voice/level01.mp3 Normal file

Binary file not shown.

View File

@ -99,6 +99,58 @@
opacity: 0.35; opacity: 0.35;
cursor: not-allowed; 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 { #metadata-form {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@ -173,6 +225,29 @@
<button id="move-right-btn">Move Right &#9654;</button> <button id="move-right-btn">Move Right &#9654;</button>
<button id="remove-btn">&#10005; Remove</button> <button id="remove-btn">&#10005; Remove</button>
</div> </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>
<section> <section>

View File

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

View File

@ -1,4 +1,4 @@
import { generateSection, SECTION_WIDTH } from './sections.js'; import { generateSection, POINT_STEP } from './sections.js';
import { WORLD_SCALE } from '../config.js'; import { WORLD_SCALE } from '../config.js';
// Base-unit constants mirroring the values every hand-written level file // Base-unit constants mirroring the values every hand-written level file
@ -30,6 +30,100 @@ function scalePoint(p) {
return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) }; return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) };
} }
// 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 // Appends scaled points onto an accumulator array, skipping a leading point
// that exactly duplicates the accumulator's current last point (happens at // that exactly duplicates the accumulator's current last point (happens at
// every block boundary, since each block's first sample is the previous // every block boundary, since each block's first sample is the previous
@ -43,33 +137,57 @@ function appendPoints(accumulator, points) {
} }
} }
// sectionTypeIds: ordered array of ids from sections.js's SECTION_TYPES. // 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 }. // metadata: { id, name, description, kidsAboard }.
// Returns a full level data object matching level01.js's shape, or null if // Returns a full level data object matching level01.js's shape, or null if
// sectionTypeIds is empty. // sections is empty.
export function buildLevelData(sectionTypeIds, metadata) { export function buildLevelData(sections, metadata) {
if (!sectionTypeIds || sectionTypeIds.length === 0) return null; if (!sections || sections.length === 0) return null;
let baseX = -TERRAIN_LEAD_IN; let baseX = -TERRAIN_LEAD_IN;
let baseY = INITIAL_GROUND_Y; let baseY = INITIAL_GROUND_Y;
const terrainSegments = []; const terrainSegments = [];
let currentPoints = []; 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 typeId of sectionTypeIds) { for (const section of sections) {
const result = generateSection(typeId, baseX, baseY, SECTION_WIDTH); const result = generateSection(section.type, baseX, baseY, section.width, section.vScale);
let endY = result.endY;
if (result.type === 'jump') { if (result.type === 'jump') {
appendPoints(currentPoints, result.takeoffPoints); // 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 }); terrainSegments.push({ points: currentPoints });
currentPoints = []; currentPoints = [];
appendPoints(currentPoints, result.landingPoints); appendPoints(currentPoints, result.landingPoints);
prevPair = lastPair(result.landingPoints);
} else { } else {
appendPoints(currentPoints, result.points); 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 = result.endY; baseY = endY;
baseX += SECTION_WIDTH; baseX += section.width;
} }
if (currentPoints.length > 0) { if (currentPoints.length > 0) {

View File

@ -1,14 +1,24 @@
import { SECTION_TYPES } from './sections.js'; 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 { buildLevelData } from './levelBuilder.js';
import { exportLevelToSource } from './exportLevel.js'; import { exportLevelToSource } from './exportLevel.js';
import { BUS } from '../config.js'; import { BUS } from '../config.js';
// The leading section is always 'flat' and locked at index 0, so a jump // 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 - // section can never end up as the very first thing the bus drives onto -
// see the "Safety default" note in the level editor plan. // 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 = { const state = {
metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 }, metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 },
sequence: ['flat'], sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }],
selectedIndex: null, selectedIndex: null,
}; };
@ -17,6 +27,12 @@ const trackEl = document.getElementById('track');
const moveLeftBtn = document.getElementById('move-left-btn'); const moveLeftBtn = document.getElementById('move-left-btn');
const moveRightBtn = document.getElementById('move-right-btn'); const moveRightBtn = document.getElementById('move-right-btn');
const removeBtn = document.getElementById('remove-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 metadataFormEl = document.getElementById('metadata-form');
const previewScrollEl = document.getElementById('preview-scroll'); const previewScrollEl = document.getElementById('preview-scroll');
const previewCanvas = document.getElementById('preview-canvas'); const previewCanvas = document.getElementById('preview-canvas');
@ -25,6 +41,10 @@ const downloadBtn = document.getElementById('download-btn');
const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId; 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() { function buildPalette() {
paletteEl.innerHTML = ''; paletteEl.innerHTML = '';
for (const { id, label } of SECTION_TYPES) { for (const { id, label } of SECTION_TYPES) {
@ -32,7 +52,7 @@ function buildPalette() {
btn.className = 'palette-btn'; btn.className = 'palette-btn';
btn.textContent = label; btn.textContent = label;
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
state.sequence.push(id); state.sequence.push({ type: id, width: SECTION_WIDTH, vScale: 1, smooth: false });
render(); render();
}); });
paletteEl.appendChild(btn); paletteEl.appendChild(btn);
@ -94,14 +114,22 @@ function clamp(n, min, max) {
return Math.max(min, Math.min(max, n)); 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() { function buildTrack() {
trackEl.innerHTML = ''; trackEl.innerHTML = '';
state.sequence.forEach((typeId, index) => { state.sequence.forEach((section, index) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'track-item'; item.className = 'track-item';
if (index === state.selectedIndex) item.classList.add('selected'); if (index === state.selectedIndex) item.classList.add('selected');
if (index === 0) item.classList.add('locked'); if (index === 0) item.classList.add('locked');
item.textContent = `${index + 1}. ${labelFor(typeId)}${index === 0 ? ' (locked)' : ''}`; item.textContent = `${index + 1}. ${labelFor(section.type)}${sizeSuffix(section)}${index === 0 ? ' (locked)' : ''}`;
item.addEventListener('click', () => { item.addEventListener('click', () => {
state.selectedIndex = index; state.selectedIndex = index;
render(); render();
@ -118,6 +146,56 @@ function updateTrackControls() {
removeBtn.disabled = i === null || locked; 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', () => { moveLeftBtn.addEventListener('click', () => {
const i = state.selectedIndex; const i = state.selectedIndex;
if (i === null || i <= 1) return; if (i === null || i <= 1) return;
@ -271,6 +349,7 @@ downloadBtn.addEventListener('click', () => {
function render() { function render() {
buildTrack(); buildTrack();
updateTrackControls(); updateTrackControls();
updateSizePanel();
currentLevelData = buildLevelData(state.sequence, state.metadata); currentLevelData = buildLevelData(state.sequence, state.metadata);
@ -290,6 +369,18 @@ function render() {
downloadBtn.disabled = false; 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(); buildPalette();
buildMetadataForm(); buildMetadataForm();
initSizeControls();
render(); render();

View File

@ -6,6 +6,15 @@
export const SECTION_WIDTH = 700; export const SECTION_WIDTH = 700;
export const POINT_STEP = 35; 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 = [ export const SECTION_TYPES = [
{ id: 'flat', label: 'Flat' }, { id: 'flat', label: 'Flat' },
{ id: 'smoothIncline', label: 'Smooth Incline' }, { id: 'smoothIncline', label: 'Smooth Incline' },
@ -56,33 +65,39 @@ function flat(startX, startY, width) {
return { type: 'simple', points, endY: startY }; return { type: 'simple', points, endY: startY };
} }
function smoothIncline(startX, startY, width) { function smoothIncline(startX, startY, width, vScale) {
const points = sampleBlock(startX, startY, width, (t) => -RISE_SMOOTH * smoothstep(t)); const rise = RISE_SMOOTH * vScale;
return { type: 'simple', points, endY: startY - RISE_SMOOTH }; const points = sampleBlock(startX, startY, width, (t) => -rise * smoothstep(t));
return { type: 'simple', points, endY: startY - rise };
} }
function sharpIncline(startX, startY, width) { function sharpIncline(startX, startY, width, vScale) {
const points = sampleBlock(startX, startY, width, (t) => -RISE_SHARP * t); const rise = RISE_SHARP * vScale;
return { type: 'simple', points, endY: startY - RISE_SHARP }; const points = sampleBlock(startX, startY, width, (t) => -rise * t);
return { type: 'simple', points, endY: startY - rise };
} }
function smoothDecline(startX, startY, width) { function smoothDecline(startX, startY, width, vScale) {
const points = sampleBlock(startX, startY, width, (t) => RISE_SMOOTH * smoothstep(t)); const rise = RISE_SMOOTH * vScale;
return { type: 'simple', points, endY: startY + RISE_SMOOTH }; const points = sampleBlock(startX, startY, width, (t) => rise * smoothstep(t));
return { type: 'simple', points, endY: startY + rise };
} }
function sharpDecline(startX, startY, width) { function sharpDecline(startX, startY, width, vScale) {
const points = sampleBlock(startX, startY, width, (t) => RISE_SHARP * t); const rise = RISE_SHARP * vScale;
return { type: 'simple', points, endY: startY + RISE_SHARP }; const points = sampleBlock(startX, startY, width, (t) => rise * t);
return { type: 'simple', points, endY: startY + rise };
} }
function rollingHills(startX, startY, width) { function rollingHills(startX, startY, width, vScale) {
const points = sampleBlock(startX, startY, width, (t) => AMP_HILLS * Math.sin(2 * Math.PI * t)); 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 }; return { type: 'simple', points, endY: startY };
} }
function ditch(startX, startY, width) { function ditch(startX, startY, width, vScale) {
const points = sampleBlock(startX, startY, width, (t) => DEPTH_DITCH * Math.sin(Math.PI * t)); const depth = DEPTH_DITCH * vScale;
const points = sampleBlock(startX, startY, width, (t) => depth * Math.sin(Math.PI * t));
return { type: 'simple', points, endY: startY }; return { type: 'simple', points, endY: startY };
} }
@ -90,17 +105,20 @@ function ditch(startX, startY, width) {
// between), mirroring how level02.js builds its jump: a "runway" segment // 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 // ending at a raised lip, then a real x-gap, then a "landing" segment
// starting lower (simulating the fall across the gap). // starting lower (simulating the fall across the gap).
function jump(startX, startY, width) { 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 w1 = width * JUMP_TAKEOFF_FRACTION;
const w2 = width * JUMP_GAP_FRACTION; const w2 = width * JUMP_GAP_FRACTION;
const w3 = width - w1 - w2; const w3 = width - w1 - w2;
const rampLen = w1 - JUMP_LIP_LEN; const rampLen = w1 - JUMP_LIP_LEN;
const lipY = startY - JUMP_TAKEOFF_RISE; const lipY = startY - takeoffRise;
const takeoffPoints = []; const takeoffPoints = [];
for (let x = startX; x <= startX + w1; x += POINT_STEP) { for (let x = startX; x <= startX + w1; x += POINT_STEP) {
const localX = x - startX; const localX = x - startX;
const y = localX <= rampLen ? startY - JUMP_TAKEOFF_RISE * (localX / rampLen) : lipY; const y = localX <= rampLen ? startY - takeoffRise * (localX / rampLen) : lipY;
takeoffPoints.push({ x, y: Math.round(y) }); takeoffPoints.push({ x, y: Math.round(y) });
} }
const takeoffLast = takeoffPoints[takeoffPoints.length - 1]; const takeoffLast = takeoffPoints[takeoffPoints.length - 1];
@ -109,7 +127,7 @@ function jump(startX, startY, width) {
} }
const landingStartX = startX + w1 + w2; const landingStartX = startX + w1 + w2;
const landingStartY = lipY + JUMP_LANDING_DROP; const landingStartY = lipY + landingDrop;
const endY = startY; const endY = startY;
const landingPoints = sampleBlock(landingStartX, landingStartY, w3, (t) => (endY - landingStartY) * smoothstep(t)); const landingPoints = sampleBlock(landingStartX, landingStartY, w3, (t) => (endY - landingStartY) * smoothstep(t));
@ -127,8 +145,8 @@ const GENERATORS = {
jump, jump,
}; };
export function generateSection(typeId, startX, startY, width = SECTION_WIDTH) { export function generateSection(typeId, startX, startY, width = SECTION_WIDTH, vScale = 1) {
const generator = GENERATORS[typeId]; const generator = GENERATORS[typeId];
if (!generator) throw new Error(`Unknown section type: ${typeId}`); if (!generator) throw new Error(`Unknown section type: ${typeId}`);
return generator(startX, startY, width); return generator(startX, startY, width, vScale);
} }

View File

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

View File

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

View File

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

View File

@ -7,6 +7,9 @@ import InputController from '../systems/InputController.js';
import GForceMonitor from '../systems/GForceMonitor.js'; import GForceMonitor from '../systems/GForceMonitor.js';
import KidManager from '../systems/KidManager.js'; import KidManager from '../systems/KidManager.js';
import CameraRig from '../systems/CameraRig.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 { export default class PlayScene extends Phaser.Scene {
constructor() { constructor() {
@ -20,6 +23,8 @@ export default class PlayScene extends Phaser.Scene {
} }
create() { create() {
stopMenuMusic(this);
this.cameras.main.setBackgroundColor('#8fc7e8'); this.cameras.main.setBackgroundColor('#8fc7e8');
this._buildParallax(); this._buildParallax();
@ -32,6 +37,9 @@ export default class PlayScene extends Phaser.Scene {
this.inputController = new InputController(this); 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 = new GForceMonitor(this, this.bus.chassis.body);
this.gForceMonitor.resetBaseline(); this.gForceMonitor.resetBaseline();
@ -53,6 +61,7 @@ export default class PlayScene extends Phaser.Scene {
const intent = this.inputController.getIntent(); const intent = this.inputController.getIntent();
this.bus.applyIntent(intent); this.bus.applyIntent(intent);
this.engineSound.update();
this.cameraRig.update(); this.cameraRig.update();
this._updateParallax(); this._updateParallax();
this._checkBusFell(); this._checkBusFell();
@ -241,6 +250,8 @@ export default class PlayScene extends Phaser.Scene {
// redundant, not just now-unsafe. // redundant, not just now-unsafe.
if (this.gForceMonitor) this.gForceMonitor.destroy(); if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy(); if (this.kidManager) this.kidManager.destroy();
if (this.engineSound) this.engineSound.destroy();
if (this.voiceSound) this.voiceSound.stop();
if (this.bus) this.bus.destroy(); if (this.bus) this.bus.destroy();
} }
} }

View File

@ -2,6 +2,8 @@ import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { ASSET_MANIFEST } from '../util/assetManifest.js'; import { ASSET_MANIFEST } from '../util/assetManifest.js';
import { generatePlaceholder } from '../util/placeholderTextures.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 { export default class PreloadScene extends Phaser.Scene {
constructor() { constructor() {
@ -23,6 +25,17 @@ export default class PreloadScene extends Phaser.Scene {
this.load.image(entry.key, entry.path); 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() { create() {

View File

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

View File

@ -45,11 +45,11 @@ export default class KidManager {
// Bus.js's wheel wishbone, since the fixtures' own collision response // Bus.js's wheel wishbone, since the fixtures' own collision response
// alone isn't a hard guarantee against a fast enough hit), then checks // 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 // the (now-corrected) position against the compartment's open top in the
// chassis's own rotated frame and flips state whichever way that puts it // chassis's own rotated frame. A kid drifting up past the open top
// - a kid drifting up past the open top becomes ejected, and a floating // becomes ejected immediately. A floating kid drifting back down through
// kid drifting back down through it becomes aboard again, both from the // the open top only re-boards once it has actually come to rest inside
// same check. There's no synthetic impulse or event driving either // the compartment (see Kid.isSettled), so a kid still mid-arc can't
// direction - it's purely "is this kid still inside the box." // teleport back in mid-air.
_onAfterUpdate() { _onAfterUpdate() {
const chassis = this.bus.chassis; const chassis = this.bus.chassis;
const cos = Math.cos(chassis.rotation); const cos = Math.cos(chassis.rotation);
@ -67,7 +67,12 @@ export default class KidManager {
if (isAboveOpenTop && kid.state === 'aboard') { if (isAboveOpenTop && kid.state === 'aboard') {
kid.markEjected(); kid.markEjected();
anyChanged = true; anyChanged = true;
} else if (!isAboveOpenTop && kid.state === 'ejected') { } 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(); kid.markAboard();
anyChanged = true; anyChanged = true;
} }

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

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

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

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