diff --git a/src/data/levels/index.js b/src/data/levels/index.js index 5198098..97279c7 100644 --- a/src/data/levels/index.js +++ b/src/data/levels/index.js @@ -32,6 +32,9 @@ export const CAMPAIGNS = [ id: 'campaign02', name: 'Underprivileged Community', bgKey: 'campaign02_bg', + // Menu music for this campaign's map in LevelSelect (util/music.js + // playMenuTrack); undefined = the main title theme. + musicKey: 'urban_trap_theme', // Its old levels (level04-06) moved into campaign01 while it grows toward // 10; add campaign02's own levels here as they're built. An empty // campaign renders as a bare map with "0/0 cleared" - fine as scaffolding. diff --git a/src/editor/sections.js b/src/editor/sections.js index a77788c..81a3e95 100644 --- a/src/editor/sections.js +++ b/src/editor/sections.js @@ -22,6 +22,9 @@ export const SECTION_TYPES = [ { id: 'smoothDecline', label: 'Smooth Decline' }, { id: 'sharpDecline', label: 'Sharp Decline' }, { id: 'rollingHills', label: 'Rolling Hills' }, + { id: 'washboard', label: 'Washboard' }, + { id: 'washboardUp', label: 'Washboard Up' }, + { id: 'washboardDown', label: 'Washboard Down' }, { id: 'ditch', label: 'Ditch' }, { id: 'jump', label: 'Jump' }, { id: 'dropOff', label: 'Drop Off' }, @@ -35,6 +38,28 @@ const RISE_SHARP = 160; const AMP_HILLS = 45; const DEPTH_DITCH = 130; +// Washboard: a repeating corrugated-bump wave (like washboard/"bumpy road" +// track in Trials-style games) over a flat or sloped baseline. The bump +// count is derived from the section's width (target WASH_PERIOD base units +// per bump cycle) so the bumps keep a roughly constant width when the user +// stretches/contracts a section instead of every bump stretching with it, +// and the floor keeps the pattern visible even at the editor's minimum +// section width. Amplitude, and for the up/down variants the overall rise +// too, scale with vScale like every other section's rise/amplitude/depth. +const WASH_AMPLITUDE = 26; +const WASH_PERIOD = 100; +const WASH_RISE = 120; + +function washBumps(width) { + return Math.max(4, Math.round(width / WASH_PERIOD)); +} + +// 4 samples per bump cycle: enough that each crest and trough lands exactly +// on a sample (a washboard tooth made of a couple of straight segments +// reads as a bumpy road rather than a sawtooth spike), while the default +// 700-wide section stays a modest 29 points. +const WASH_SAMPLES_PER_BUMP = 4; + const JUMP_TAKEOFF_FRACTION = 0.45; const JUMP_GAP_FRACTION = 0.25; // JUMP_LANDING_FRACTION is implicit: 1 - JUMP_TAKEOFF_FRACTION - JUMP_GAP_FRACTION @@ -76,11 +101,13 @@ function smoothstep(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) { +// Samples x from startX to startX+width every `step` (inclusive of both +// ends), calling shape(t) for the y-offset at each sample, t in [0,1]. The +// washboard passes a custom step (4 samples per bump, see +// WASH_SAMPLES_PER_BUMP); every other generator keeps the POINT_STEP grid. +function sampleBlock(startX, startY, width, shape, step = POINT_STEP) { const points = []; - for (let x = startX; x <= startX + width; x += POINT_STEP) { + for (let x = startX; x <= startX + width; x += step) { const t = (x - startX) / width; points.push({ x, y: Math.round(startY + shape(t)) }); } @@ -134,6 +161,39 @@ function ditch(startX, startY, width, vScale) { return { type: 'simple', points, endY: startY }; } +// Washboard (flat): N full sine periods, each one a bump cycle. Sin starts +// and ends at zero displacement, so both ends sit exactly on the neighbor +// sections' baseline (no seam step) while the interior is the uniform +// corrugated profile. +function washboard(startX, startY, width, vScale) { + const amp = WASH_AMPLITUDE * vScale; + const bumps = washBumps(width); + const points = sampleBlock(startX, startY, width, (t) => amp * Math.sin(2 * Math.PI * bumps * t), width / (WASH_SAMPLES_PER_BUMP * bumps)); + return { type: 'simple', points, endY: startY }; +} + +// Washboard Up: the same washboard wave riding on an overall incline. The +// baseline uses smoothstep (zero slope at both ends) like smoothIncline, so +// the section's ends match a flat neighbor's baseline and the climb eases +// in/out instead of ramping at a constant angle under the bumps. +function washboardUp(startX, startY, width, vScale) { + const amp = WASH_AMPLITUDE * vScale; + const rise = WASH_RISE * vScale; + const bumps = washBumps(width); + const points = sampleBlock(startX, startY, width, (t) => -rise * smoothstep(t) + amp * Math.sin(2 * Math.PI * bumps * t), width / (WASH_SAMPLES_PER_BUMP * bumps)); + return { type: 'simple', points, endY: startY - rise }; +} + +// Washboard Down: mirror of washboardUp - a smoothstep decline baseline +// with the washboard wave on top. +function washboardDown(startX, startY, width, vScale) { + const amp = WASH_AMPLITUDE * vScale; + const rise = WASH_RISE * vScale; + const bumps = washBumps(width); + const points = sampleBlock(startX, startY, width, (t) => rise * smoothstep(t) + amp * Math.sin(2 * Math.PI * bumps * t), width / (WASH_SAMPLES_PER_BUMP * bumps)); + return { type: 'simple', points, endY: startY + rise }; +} + // 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 @@ -239,6 +299,9 @@ const GENERATORS = { smoothDecline, sharpDecline, rollingHills, + washboard, + washboardUp, + washboardDown, ditch, jump, dropOff, diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 949d9c6..b1f39da 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -3,7 +3,7 @@ import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { CAMPAIGNS } from '../data/levels/index.js'; import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js'; import { isLevelComplete, getBestScore } from '../util/progress.js'; -import { playMenuMusic } from '../util/music.js'; +import { playMenuTrack } from '../util/music.js'; import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js'; import { createPill, pillInteractive } from '../util/pill.js'; @@ -56,7 +56,10 @@ export default class LevelSelectScene extends Phaser.Scene { create() { this.cameras.main.setBackgroundColor('#8fc7e8'); - playMenuMusic(this); + // Menu music (main title or the campaign's own theme) is started by + // _buildCampaign -> playMenuTrack, which knows which campaign is being + // shown; it handles first-time start, campaign-to-campaign swaps, and + // re-entry from the main menu alike. this.campaignIndex = 0; this._groups = { bg: [], path: [], nodes: [], tags: [] }; @@ -113,6 +116,12 @@ export default class LevelSelectScene extends Phaser.Scene { const campaign = CAMPAIGNS[this.campaignIndex]; const levels = campaign.levels; + + // Menu music follows the campaign: this campaign's theme (musicKey) or + // the main title for campaigns without one. Handles first entry, campaign + // switches (from _shiftCampaign), and re-entry after a level (activeKey + // was cleared by stopMenuMusic, so the track restarts from the top). + playMenuTrack(this, campaign.musicKey); const points = (campaign.positions || autoLayoutPoints(levels.length)).map(scalePos); const completed = levels.map((l) => isLevelComplete(l.id)); // A campaign's first level is playable as soon as the PREVIOUS campaign diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index c79080f..26beced 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -28,6 +28,9 @@ export default class PreloadScene extends Phaser.Scene { } this.load.audio('main_title_music', 'assets/music/main-title.mp3'); + // Campaign 2's level-select theme (see musicKey in data/levels/index.js + // and playMenuTrack in util/music.js). + this.load.audio('urban_trap_theme', 'assets/music/urban-trap-theme.mp3'); this.load.audio('engine_heavy', 'assets/fx/engine-heavy.mp3'); this.load.audio('crowd_cheer', 'assets/fx/crowd-cheer.mp3'); this.load.audio('kid_count', 'assets/fx/kid-count.mp3'); diff --git a/src/util/music.js b/src/util/music.js index 8d6bd53..2b873ce 100644 --- a/src/util/music.js +++ b/src/util/music.js @@ -1,21 +1,68 @@ -// Menu music spans Intro -> MainMenu -> LevelSelect as one continuous track +// Menu music spans Intro -> MainMenu -> LevelSelect as one continuous bed // (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'; +// into the next). Only ONE menu track is active at a time: +// - the main title theme (DEFAULT_KEY) - the default, and what Intro / +// MainMenu always play; +// - a campaign's own theme, if the campaign defines one (musicKey, see +// data/levels/index.js) - LevelSelect switches to it while that +// campaign's map is showing (campaign 2 -> urban-trap-theme) and back +// to the main title for campaigns without one. +// playMenuTrack swaps the active track (stopping the old one); it resumes +// rather than restarts a track that's already active but paused, and no-ops +// if it's already playing. playMenuMusic is the default (main title) +// shorthand the menu scenes call from create(). +const DEFAULT_KEY = 'main_title_music'; -export function playMenuMusic(scene) { - const existing = scene.sound.get(KEY); - if (existing && existing.isPlaying) return; +// The track currently active across the menu scenes (or null if none). +let activeKey = null; + +/** + * Makes `key` the active menu track (stopping the current one if it's a + * different key), or resumes it if it's already active but paused. `key` + * defaults to the main title theme, so `playMenuTrack(scene, undefined)` + * and `playMenuMusic(scene)` are equivalent. A key that never loaded + * (missing file / load failure) silently falls back to the main title - + * the same load-tolerant pattern as voiceLine.js. + */ +export function playMenuTrack(scene, key = DEFAULT_KEY) { + if (!scene.cache.audio.exists(key)) key = DEFAULT_KEY; + + if (activeKey === key) { + // Already the active track: keep it - resume if it was stopped (e.g. by + // PlayScene), no-op if it's still playing. + const existing = scene.sound.get(key); + if (!existing) { + scene.sound.add(key, { loop: true, volume: 0.5 }).play(); + return; + } + if (!existing.isPlaying) existing.play(); + return; + } + + // Swap: stop the current track, then start (or resume) the new one. + if (activeKey) scene.sound.get(activeKey)?.stop(); + activeKey = key; + + const existing = scene.sound.get(key); if (existing) { existing.play(); return; } - scene.sound.add(KEY, { loop: true, volume: 0.5 }).play(); + scene.sound.add(key, { loop: true, volume: 0.5 }).play(); } -export function stopMenuMusic(scene) { - scene.sound.get(KEY)?.stop(); +// Intro / MainMenu always want the main title theme. +export function playMenuMusic(scene) { + playMenuTrack(scene, DEFAULT_KEY); +} + +// PlayScene calls this when gameplay starts: stop WHATEVER menu track is +// active (main title or a campaign theme) and clear the active pointer, so +// the next menu scene's play* call starts its track fresh. +export function stopMenuMusic(scene) { + if (!activeKey) return; + const key = activeKey; + activeKey = null; + scene.sound.get(key)?.stop(); }