Add washboard section types (flat / up / down) to the level editor

This commit is contained in:
Brian Fertig 2026-08-23 12:46:35 -06:00
parent a12ae73b8a
commit 3be7b857ed
5 changed files with 142 additions and 17 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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');

View File

@ -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();
}