diff --git a/assets/fx/engine-heavy.mp3 b/assets/fx/engine-heavy.mp3 new file mode 100644 index 0000000..b7eff3f Binary files /dev/null and b/assets/fx/engine-heavy.mp3 differ diff --git a/assets/music/main-title.mp3 b/assets/music/main-title.mp3 new file mode 100644 index 0000000..2b3a7d1 Binary files /dev/null and b/assets/music/main-title.mp3 differ diff --git a/assets/voice/level01.mp3 b/assets/voice/level01.mp3 new file mode 100644 index 0000000..cbd1141 Binary files /dev/null and b/assets/voice/level01.mp3 differ diff --git a/editor.html b/editor.html index bbb9cc7..9a794f1 100644 --- a/editor.html +++ b/editor.html @@ -99,6 +99,58 @@ 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)); @@ -173,6 +225,29 @@ +
diff --git a/src/config.js b/src/config.js index 6074bb3..6bdea21 100644 --- a/src/config.js +++ b/src/config.js @@ -194,3 +194,19 @@ export const CAMERA = { }; 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, +}; diff --git a/src/data/levels/level01.js b/src/data/levels/level01.js index 11a01ab..1579add 100644 --- a/src/data/levels/level01.js +++ b/src/data/levels/level01.js @@ -1,20 +1,387 @@ -import { generateWave } from './terrainHelpers.js'; -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); - +// Generated by editor.html - review before dropping into src/data/levels/. export default { - id: 'level01', - name: 'First Hills', - description: 'Gentle rolling hills - learn to drive and lean.', - kidsAboard: 5, - startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE }, + id: "level01", + name: "GoFundMe", + description: "Get the Money!", + kidsAboard: 4, + startPosition: { x: 200, y: 700 }, startAngle: 0, - terrain: [{ points }], + terrain: [ + { points: [ + { x: -400, y: 840 }, + { x: -330, y: 840 }, + { x: -260, y: 840 }, + { x: -190, y: 840 }, + { x: -120, y: 840 }, + { x: -50, y: 840 }, + { x: 20, y: 840 }, + { x: 90, y: 840 }, + { x: 160, y: 840 }, + { x: 230, y: 840 }, + { x: 300, y: 840 }, + { x: 370, y: 840 }, + { x: 440, y: 840 }, + { x: 510, y: 840 }, + { x: 580, y: 840 }, + { x: 650, y: 840 }, + { x: 720, y: 840 }, + { x: 790, y: 840 }, + { x: 860, y: 840 }, + { x: 930, y: 840 }, + { x: 1000, y: 840 }, + { x: 1070, y: 840 }, + { x: 1140, y: 836 }, + { x: 1210, y: 830 }, + { x: 1280, y: 820 }, + { x: 1350, y: 808 }, + { x: 1420, y: 792 }, + { x: 1490, y: 776 }, + { x: 1560, y: 760 }, + { x: 1630, y: 744 }, + { x: 1700, y: 728 }, + { x: 1770, y: 712 }, + { x: 1840, y: 696 }, + { x: 1910, y: 680 }, + { x: 1980, y: 664 }, + { x: 2050, y: 648 }, + { x: 2120, y: 632 }, + { x: 2190, y: 616 }, + { x: 2260, y: 600 }, + { x: 2330, y: 584 }, + { x: 2400, y: 568 }, + { x: 2470, y: 568 }, + { x: 2540, y: 568 }, + { x: 2610, y: 568 }, + { x: 2680, y: 568 }, + { x: 2750, y: 568 }, + { x: 2820, y: 568 }, + { x: 2890, y: 568 }, + { x: 2960, y: 568 }, + { x: 3030, y: 568 }, + { x: 3100, y: 568 }, + { x: 3170, y: 568 }, + { x: 3240, y: 568 }, + { x: 3310, y: 568 }, + { x: 3380, y: 568 }, + { x: 3450, y: 568 }, + { x: 3520, y: 568 }, + { x: 3590, y: 568 }, + { x: 3660, y: 568 }, + { x: 3730, y: 568 }, + { x: 3800, y: 568 }, + { x: 3870, y: 568 }, + { x: 3940, y: 564 }, + { x: 4010, y: 554 }, + { x: 4080, y: 538 }, + { x: 4150, y: 518 }, + { x: 4220, y: 494 }, + { x: 4290, y: 468 }, + { x: 4360, y: 444 }, + { x: 4430, y: 444 }, + ] }, + { points: [ + { x: 4780, y: 456 }, + { x: 4850, y: 464 }, + { x: 4920, y: 486 }, + { x: 4990, y: 512 }, + { x: 5060, y: 540 }, + { x: 5130, y: 560 }, + { x: 5200, y: 568 }, + { x: 5270, y: 576 }, + { x: 5340, y: 586 }, + { x: 5410, y: 596 }, + { x: 5480, y: 610 }, + { x: 5550, y: 624 }, + { x: 5620, y: 640 }, + { x: 5690, y: 656 }, + { x: 5760, y: 672 }, + { x: 5830, y: 688 }, + { x: 5900, y: 704 }, + { x: 5970, y: 720 }, + { x: 6040, y: 736 }, + { x: 6110, y: 752 }, + { x: 6180, y: 768 }, + { x: 6250, y: 784 }, + { x: 6320, y: 800 }, + { x: 6390, y: 816 }, + { x: 6460, y: 832 }, + { x: 6530, y: 848 }, + { x: 6600, y: 864 }, + { x: 6670, y: 880 }, + { x: 6740, y: 894 }, + { x: 6810, y: 910 }, + { x: 6880, y: 922 }, + { x: 6950, y: 934 }, + { x: 7020, y: 944 }, + { x: 7090, y: 954 }, + { x: 7160, y: 968 }, + { x: 7230, y: 980 }, + { x: 7300, y: 994 }, + { x: 7370, y: 1008 }, + { x: 7440, y: 1020 }, + { x: 7510, y: 1034 }, + { x: 7580, y: 1046 }, + { x: 7650, y: 1056 }, + { x: 7720, y: 1066 }, + { x: 7790, y: 1074 }, + { x: 7860, y: 1080 }, + { x: 7930, y: 1082 }, + { x: 8000, y: 1084 }, + { x: 8000, y: 1086 }, + { x: 8070, y: 1088 }, + { x: 8140, y: 1090 }, + { x: 8210, y: 1090 }, + { x: 8280, y: 1092 }, + { x: 8350, y: 1092 }, + { x: 8420, y: 1092 }, + { x: 8490, y: 1092 }, + { x: 8560, y: 1092 }, + { x: 8630, y: 1092 }, + { x: 8700, y: 1092 }, + { x: 8770, y: 1092 }, + { x: 8840, y: 1092 }, + { x: 8910, y: 1092 }, + { x: 8980, y: 1092 }, + { x: 9050, y: 1092 }, + { x: 9120, y: 1092 }, + { x: 9190, y: 1092 }, + { x: 9260, y: 1092 }, + { x: 9330, y: 1092 }, + { x: 9400, y: 1092 }, + { x: 9470, y: 1092 }, + { x: 9540, y: 1090 }, + { x: 9610, y: 1088 }, + { x: 9680, y: 1084 }, + { x: 9750, y: 1080 }, + { x: 9820, y: 1074 }, + { x: 9890, y: 1064 }, + { x: 9960, y: 1048 }, + { x: 10030, y: 1030 }, + { x: 10100, y: 1010 }, + { x: 10170, y: 988 }, + { x: 10240, y: 970 }, + { x: 10310, y: 954 }, + { x: 10380, y: 944 }, + { x: 10450, y: 942 }, + { x: 10520, y: 944 }, + { x: 10590, y: 954 }, + { x: 10660, y: 970 }, + { x: 10730, y: 988 }, + { x: 10800, y: 1010 }, + { x: 10870, y: 1010 }, + { x: 10940, y: 1010 }, + { x: 11010, y: 1010 }, + { x: 11080, y: 1010 }, + { x: 11150, y: 1010 }, + { x: 11220, y: 1010 }, + { x: 11290, y: 1010 }, + { x: 11360, y: 1010 }, + { x: 11430, y: 1010 }, + { x: 11500, y: 1010 }, + { x: 11570, y: 1010 }, + { x: 11640, y: 1010 }, + { x: 11710, y: 1010 }, + { x: 11780, y: 1010 }, + { x: 11850, y: 1010 }, + { x: 11920, y: 1010 }, + { x: 11990, y: 1010 }, + { x: 12060, y: 1010 }, + { x: 12130, y: 1010 }, + { x: 12200, y: 1010 }, + { x: 12270, y: 1010 }, + { x: 12340, y: 1010 }, + { x: 12410, y: 1010 }, + { x: 12480, y: 1010 }, + { x: 12550, y: 1010 }, + { x: 12620, y: 1010 }, + { x: 12690, y: 1010 }, + { x: 12760, y: 1010 }, + { x: 12830, y: 1010 }, + { x: 12900, y: 1010 }, + { x: 12970, y: 1010 }, + { x: 13040, y: 1010 }, + { x: 13110, y: 1010 }, + { x: 13180, y: 1010 }, + { x: 13250, y: 1010 }, + { x: 13320, y: 1010 }, + { x: 13390, y: 1010 }, + { x: 13460, y: 1010 }, + { x: 13530, y: 1010 }, + { x: 13600, y: 1010 }, + { x: 13670, y: 1008 }, + { x: 13740, y: 1004 }, + { x: 13810, y: 990 }, + { x: 13880, y: 970 }, + { x: 13950, y: 942 }, + { x: 14020, y: 908 }, + { x: 14090, y: 872 }, + { x: 14160, y: 838 }, + { x: 14230, y: 838 }, + ] }, + { points: [ + { x: 14580, y: 850 }, + { x: 14650, y: 862 }, + { x: 14720, y: 890 }, + { x: 14790, y: 930 }, + { x: 14860, y: 968 }, + { x: 14930, y: 998 }, + { x: 15000, y: 1010 }, + { x: 15070, y: 1022 }, + { x: 15140, y: 1030 }, + { x: 15210, y: 1032 }, + { x: 15280, y: 1030 }, + { x: 15350, y: 1022 }, + { x: 15420, y: 1010 }, + { x: 15490, y: 998 }, + { x: 15560, y: 986 }, + { x: 15630, y: 972 }, + { x: 15700, y: 960 }, + { x: 15770, y: 946 }, + { x: 15840, y: 932 }, + { x: 15910, y: 920 }, + { x: 15980, y: 908 }, + { x: 16050, y: 898 }, + { x: 16120, y: 888 }, + { x: 16190, y: 880 }, + { x: 16260, y: 874 }, + { x: 16330, y: 870 }, + { x: 16400, y: 870 }, + { x: 16470, y: 870 }, + { x: 16540, y: 870 }, + { x: 16610, y: 870 }, + { x: 16680, y: 870 }, + { x: 16750, y: 870 }, + { x: 16820, y: 870 }, + { x: 16890, y: 870 }, + { x: 16960, y: 870 }, + { x: 17030, y: 870 }, + { x: 17100, y: 870 }, + { x: 17170, y: 870 }, + { x: 17240, y: 870 }, + { x: 17310, y: 870 }, + { x: 17380, y: 870 }, + { x: 17450, y: 870 }, + { x: 17520, y: 870 }, + { x: 17590, y: 870 }, + { x: 17660, y: 870 }, + { x: 17730, y: 870 }, + { x: 17800, y: 870 }, + { x: 17870, y: 870 }, + { x: 17940, y: 868 }, + { x: 18010, y: 864 }, + { x: 18080, y: 858 }, + { x: 18150, y: 848 }, + { x: 18220, y: 838 }, + { x: 18290, y: 824 }, + { x: 18360, y: 812 }, + { x: 18430, y: 798 }, + { x: 18500, y: 786 }, + { x: 18570, y: 772 }, + { x: 18640, y: 760 }, + { x: 18710, y: 746 }, + { x: 18780, y: 732 }, + { x: 18850, y: 720 }, + { x: 18920, y: 706 }, + { x: 18990, y: 694 }, + { x: 19060, y: 680 }, + { x: 19130, y: 666 }, + { x: 19200, y: 654 }, + { x: 19270, y: 640 }, + { x: 19340, y: 628 }, + { x: 19410, y: 614 }, + { x: 19480, y: 600 }, + { x: 19500, y: 598 }, + { x: 19570, y: 582 }, + { x: 19640, y: 566 }, + { x: 19710, y: 550 }, + { x: 19780, y: 534 }, + { x: 19850, y: 518 }, + { x: 19920, y: 502 }, + { x: 19990, y: 486 }, + { x: 20060, y: 470 }, + { x: 20130, y: 454 }, + { x: 20200, y: 438 }, + { x: 20270, y: 422 }, + { x: 20340, y: 406 }, + { x: 20410, y: 390 }, + { x: 20480, y: 374 }, + { x: 20550, y: 358 }, + { x: 20620, y: 342 }, + { x: 20690, y: 326 }, + { x: 20760, y: 310 }, + { x: 20830, y: 294 }, + { x: 20900, y: 278 }, + { x: 20970, y: 378 }, + { x: 21040, y: 462 }, + { x: 21110, y: 518 }, + { x: 21180, y: 538 }, + { x: 21250, y: 518 }, + { x: 21320, y: 462 }, + { x: 21390, y: 378 }, + { x: 21460, y: 278 }, + { x: 21530, y: 294 }, + { x: 21600, y: 310 }, + { x: 21670, y: 326 }, + { x: 21740, y: 342 }, + { x: 21810, y: 358 }, + { x: 21880, y: 374 }, + { x: 21950, y: 390 }, + { x: 22020, y: 406 }, + { x: 22090, y: 422 }, + { x: 22160, y: 438 }, + { x: 22230, y: 454 }, + { x: 22300, y: 470 }, + { x: 22370, y: 486 }, + { x: 22440, y: 502 }, + { x: 22510, y: 518 }, + { x: 22580, y: 534 }, + { x: 22650, y: 550 }, + { x: 22720, y: 566 }, + { x: 22790, y: 582 }, + { x: 22860, y: 598 }, + { x: 22930, y: 614 }, + { x: 23000, y: 626 }, + { x: 23070, y: 636 }, + { x: 23140, y: 642 }, + { x: 23210, y: 646 }, + { x: 23280, y: 646 }, + { x: 23350, y: 646 }, + { x: 23420, y: 646 }, + { x: 23490, y: 646 }, + { x: 23560, y: 646 }, + { x: 23630, y: 646 }, + { x: 23700, y: 646 }, + { x: 23770, y: 646 }, + { x: 23840, y: 646 }, + { x: 23910, y: 646 }, + { x: 23980, y: 646 }, + { x: 24050, y: 646 }, + { x: 24120, y: 646 }, + { x: 24190, y: 646 }, + { x: 24260, y: 646 }, + { x: 24330, y: 646 }, + { x: 24400, y: 646 }, + { x: 24470, y: 646 }, + { x: 24540, y: 646 }, + { x: 24610, y: 646 }, + { x: 24680, y: 646 }, + { x: 24750, y: 646 }, + { x: 24820, y: 646 }, + { x: 24890, y: 646 }, + { x: 24960, y: 646 }, + { x: 25030, y: 646 }, + { x: 25100, y: 646 }, + { x: 25170, y: 646 }, + { x: 25240, y: 646 }, + { x: 25310, y: 646 }, + { x: 25380, y: 646 }, + { x: 25450, y: 646 }, + { x: 25520, y: 646 }, + { x: 25590, y: 646 }, + { x: 25660, y: 646 }, + ] }, + ], obstacles: [], - goal: { x: END_X - 120 * WORLD_SCALE, y: BASE_Y - 120 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 320 * WORLD_SCALE }, - cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE }, + goal: { x: 25420, y: 406, width: 280, height: 640 }, + cameraBounds: { x: -600, y: -522, width: 27060, height: 2414 }, }; diff --git a/src/editor/levelBuilder.js b/src/editor/levelBuilder.js index c053201..3d89e5a 100644 --- a/src/editor/levelBuilder.js +++ b/src/editor/levelBuilder.js @@ -1,4 +1,4 @@ -import { generateSection, SECTION_WIDTH } from './sections.js'; +import { generateSection, POINT_STEP } from './sections.js'; import { WORLD_SCALE } from '../config.js'; // 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) }; } +// 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 @@ -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 }. // Returns a full level data object matching level01.js's shape, or null if -// sectionTypeIds is empty. -export function buildLevelData(sectionTypeIds, metadata) { - if (!sectionTypeIds || sectionTypeIds.length === 0) return null; +// 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 typeId of sectionTypeIds) { - const result = generateSection(typeId, baseX, baseY, SECTION_WIDTH); + for (const section of sections) { + const result = generateSection(section.type, baseX, baseY, section.width, section.vScale); + let endY = result.endY; 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 }); currentPoints = []; appendPoints(currentPoints, result.landingPoints); + prevPair = lastPair(result.landingPoints); } 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; - baseX += SECTION_WIDTH; + baseY = endY; + baseX += section.width; } if (currentPoints.length > 0) { diff --git a/src/editor/main.js b/src/editor/main.js index b201809..f3d1d50 100644 --- a/src/editor/main.js +++ b/src/editor/main.js @@ -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 { 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. +// 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: ['flat'], + sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }], selectedIndex: null, }; @@ -17,6 +27,12 @@ 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'); @@ -25,6 +41,10 @@ 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) { @@ -32,7 +52,7 @@ function buildPalette() { btn.className = 'palette-btn'; btn.textContent = label; btn.addEventListener('click', () => { - state.sequence.push(id); + state.sequence.push({ type: id, width: SECTION_WIDTH, vScale: 1, smooth: false }); render(); }); paletteEl.appendChild(btn); @@ -94,14 +114,22 @@ 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((typeId, index) => { + 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(typeId)}${index === 0 ? ' (locked)' : ''}`; + item.textContent = `${index + 1}. ${labelFor(section.type)}${sizeSuffix(section)}${index === 0 ? ' (locked)' : ''}`; item.addEventListener('click', () => { state.selectedIndex = index; render(); @@ -118,6 +146,56 @@ function updateTrackControls() { 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; @@ -271,6 +349,7 @@ downloadBtn.addEventListener('click', () => { function render() { buildTrack(); updateTrackControls(); + updateSizePanel(); currentLevelData = buildLevelData(state.sequence, state.metadata); @@ -290,6 +369,18 @@ function render() { 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(); diff --git a/src/editor/sections.js b/src/editor/sections.js index f61b877..c23a6dc 100644 --- a/src/editor/sections.js +++ b/src/editor/sections.js @@ -6,6 +6,15 @@ 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' }, @@ -56,33 +65,39 @@ function flat(startX, startY, width) { return { type: 'simple', points, endY: startY }; } -function smoothIncline(startX, startY, width) { - const points = sampleBlock(startX, startY, width, (t) => -RISE_SMOOTH * smoothstep(t)); - return { type: 'simple', points, endY: startY - RISE_SMOOTH }; +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) { - const points = sampleBlock(startX, startY, width, (t) => -RISE_SHARP * t); - return { type: 'simple', points, endY: startY - RISE_SHARP }; +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) { - const points = sampleBlock(startX, startY, width, (t) => RISE_SMOOTH * smoothstep(t)); - return { type: 'simple', points, endY: startY + RISE_SMOOTH }; +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) { - const points = sampleBlock(startX, startY, width, (t) => RISE_SHARP * t); - return { type: 'simple', points, endY: startY + RISE_SHARP }; +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) { - const points = sampleBlock(startX, startY, width, (t) => AMP_HILLS * Math.sin(2 * Math.PI * t)); +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) { - const points = sampleBlock(startX, startY, width, (t) => DEPTH_DITCH * Math.sin(Math.PI * t)); +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 }; } @@ -90,17 +105,20 @@ function ditch(startX, startY, width) { // 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) { +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 - JUMP_TAKEOFF_RISE; + 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 - JUMP_TAKEOFF_RISE * (localX / rampLen) : lipY; + const y = localX <= rampLen ? startY - takeoffRise * (localX / rampLen) : lipY; takeoffPoints.push({ x, y: Math.round(y) }); } const takeoffLast = takeoffPoints[takeoffPoints.length - 1]; @@ -109,7 +127,7 @@ function jump(startX, startY, width) { } const landingStartX = startX + w1 + w2; - const landingStartY = lipY + JUMP_LANDING_DROP; + const landingStartY = lipY + landingDrop; const endY = startY; const landingPoints = sampleBlock(landingStartX, landingStartY, w3, (t) => (endY - landingStartY) * smoothstep(t)); @@ -127,8 +145,8 @@ const GENERATORS = { 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]; if (!generator) throw new Error(`Unknown section type: ${typeId}`); - return generator(startX, startY, width); + return generator(startX, startY, width, vScale); } diff --git a/src/scenes/IntroScene.js b/src/scenes/IntroScene.js index b9fe512..77bf21e 100644 --- a/src/scenes/IntroScene.js +++ b/src/scenes/IntroScene.js @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; +import { playMenuMusic } from '../util/music.js'; export default class IntroScene extends Phaser.Scene { constructor() { @@ -8,6 +9,7 @@ export default class IntroScene extends Phaser.Scene { create() { this.cameras.main.setBackgroundColor('#8fc7e8'); + playMenuMusic(this); this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 60 * WORLD_SCALE, 'MONSTERPLEX', { fontFamily: 'monospace', diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 7161405..bc53d14 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -2,6 +2,7 @@ import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { LEVELS } from '../data/levels/index.js'; import { getLevelResult } from '../util/progress.js'; +import { playMenuMusic } from '../util/music.js'; export default class LevelSelectScene extends Phaser.Scene { constructor() { @@ -10,6 +11,7 @@ export default class LevelSelectScene extends Phaser.Scene { create() { this.cameras.main.setBackgroundColor('#8fc7e8'); + playMenuMusic(this); this.add.text(GAME_WIDTH / 2, 60 * WORLD_SCALE, 'SELECT LEVEL', { fontFamily: 'monospace', diff --git a/src/scenes/MainMenuScene.js b/src/scenes/MainMenuScene.js index bc611b3..c408adf 100644 --- a/src/scenes/MainMenuScene.js +++ b/src/scenes/MainMenuScene.js @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { createButton } from '../util/ui.js'; +import { playMenuMusic } from '../util/music.js'; export default class MainMenuScene extends Phaser.Scene { constructor() { @@ -9,6 +10,7 @@ export default class MainMenuScene extends Phaser.Scene { create() { this.cameras.main.setBackgroundColor('#8fc7e8'); + playMenuMusic(this); this.add.text(GAME_WIDTH / 2, 90 * WORLD_SCALE, 'MONSTERPLEX', { fontFamily: 'monospace', diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index 63c0758..0611bb4 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -7,6 +7,9 @@ import InputController from '../systems/InputController.js'; import GForceMonitor from '../systems/GForceMonitor.js'; import KidManager from '../systems/KidManager.js'; import CameraRig from '../systems/CameraRig.js'; +import EngineSound from '../systems/EngineSound.js'; +import { stopMenuMusic } from '../util/music.js'; +import { playLevelVoiceLine } from '../util/voiceLine.js'; export default class PlayScene extends Phaser.Scene { constructor() { @@ -20,6 +23,8 @@ export default class PlayScene extends Phaser.Scene { } create() { + stopMenuMusic(this); + this.cameras.main.setBackgroundColor('#8fc7e8'); this._buildParallax(); @@ -32,6 +37,9 @@ 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(); @@ -53,6 +61,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(); @@ -241,6 +250,8 @@ export default class PlayScene extends Phaser.Scene { // redundant, not just now-unsafe. if (this.gForceMonitor) this.gForceMonitor.destroy(); if (this.kidManager) this.kidManager.destroy(); + if (this.engineSound) this.engineSound.destroy(); + if (this.voiceSound) this.voiceSound.stop(); if (this.bus) this.bus.destroy(); } } diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 55089f4..292a967 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -2,6 +2,8 @@ import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { ASSET_MANIFEST } from '../util/assetManifest.js'; import { generatePlaceholder } from '../util/placeholderTextures.js'; +import { LEVELS } from '../data/levels/index.js'; +import { voiceKey, voiceAssetPath } from '../util/voiceLine.js'; export default class PreloadScene extends Phaser.Scene { constructor() { @@ -23,6 +25,17 @@ export default class PreloadScene extends Phaser.Scene { 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() { diff --git a/src/systems/EngineSound.js b/src/systems/EngineSound.js new file mode 100644 index 0000000..f04f174 --- /dev/null +++ b/src/systems/EngineSound.js @@ -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(); + } +} diff --git a/src/systems/KidManager.js b/src/systems/KidManager.js index 264e2a6..097be45 100644 --- a/src/systems/KidManager.js +++ b/src/systems/KidManager.js @@ -45,11 +45,11 @@ export default class KidManager { // 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 and flips state whichever way that puts it - // - a kid drifting up past the open top becomes ejected, and a floating - // kid drifting back down through it becomes aboard again, both from the - // same check. There's no synthetic impulse or event driving either - // direction - it's purely "is this kid still inside the box." + // 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); @@ -67,7 +67,12 @@ export default class KidManager { if (isAboveOpenTop && kid.state === 'aboard') { kid.markEjected(); 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(); anyChanged = true; } diff --git a/src/util/music.js b/src/util/music.js new file mode 100644 index 0000000..8d6bd53 --- /dev/null +++ b/src/util/music.js @@ -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(); +} diff --git a/src/util/voiceLine.js b/src/util/voiceLine.js new file mode 100644 index 0000000..c2ff48e --- /dev/null +++ b/src/util/voiceLine.js @@ -0,0 +1,27 @@ +// Per-level voice-over: assets/voice/.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/.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; +}