diff --git a/assets/fx/crowd-cheer.mp3 b/assets/fx/crowd-cheer.mp3 new file mode 100644 index 0000000..1b23c86 Binary files /dev/null and b/assets/fx/crowd-cheer.mp3 differ diff --git a/assets/fx/kid-count.mp3 b/assets/fx/kid-count.mp3 new file mode 100644 index 0000000..5bc6136 Binary files /dev/null and b/assets/fx/kid-count.mp3 differ diff --git a/assets/fx/score-count.mp3 b/assets/fx/score-count.mp3 new file mode 100644 index 0000000..888432c Binary files /dev/null and b/assets/fx/score-count.mp3 differ diff --git a/assets/voice/kid-fall-01.mp3 b/assets/voice/kid-fall-01.mp3 new file mode 100644 index 0000000..0f09c39 Binary files /dev/null and b/assets/voice/kid-fall-01.mp3 differ diff --git a/assets/voice/kid-fall-02.mp3 b/assets/voice/kid-fall-02.mp3 new file mode 100644 index 0000000..e9dfe2f Binary files /dev/null and b/assets/voice/kid-fall-02.mp3 differ diff --git a/assets/voice/kid-fall-03.mp3 b/assets/voice/kid-fall-03.mp3 new file mode 100644 index 0000000..6fa6939 Binary files /dev/null and b/assets/voice/kid-fall-03.mp3 differ diff --git a/src/entities/FinishLine.js b/src/entities/FinishLine.js new file mode 100644 index 0000000..ed9c341 --- /dev/null +++ b/src/entities/FinishLine.js @@ -0,0 +1,159 @@ +import { WORLD_SCALE } from '../config.js'; + +// A racing-style finish marker at the level's goal: a black/white checker +// pattern painted across the road surface (straddling goal.x, the same x +// the win sensor is centered on - see PlayScene._buildGoal), plus a +// pennant on a pole just past it that ripples like cloth. Purely visual - +// the actual win condition is still PlayScene's goal sensor body. + +const CHECKER_HALF_WIDTH = 45 * WORLD_SCALE; +const CHECKER_COLUMNS = 9; +// Matches Terrain's own ROAD_DEPTH so the checker pattern reads as paint on +// the dirt band rather than floating above or sinking below it. +const CHECKER_ROAD_DEPTH = 22 * WORLD_SCALE; +const CHECKER_LIGHT = 0xf4ede0; +const CHECKER_DARK = 0x1c2029; + +const POLE_GAP = 20 * WORLD_SCALE; +const POLE_HEIGHT = 132 * WORLD_SCALE; +const POLE_WIDTH = 5 * WORLD_SCALE; +const POLE_COLOR = 0x4a4a4a; +const POLE_CAP_COLOR = 0xd8d8d8; + +const FLAG_WIDTH = 46 * WORLD_SCALE; +const FLAG_HEIGHT = 30 * WORLD_SCALE; +const FLAG_COLS = 4; +const FLAG_ROWS = 2; +// How far (vertically) the flag's free edge ripples from its rest position, +// and how fast that ripple animates - purely a feel/tuning knob. +const FLAG_RIPPLE_AMPLITUDE = 6 * WORLD_SCALE; +const FLAG_RIPPLE_SPEED = 0.0032; + +// Ground height at world x, linearly interpolated between whichever two +// terrain points bracket it. Falls back to the nearest single point if x +// somehow lands outside every segment (e.g. mid-gap) - a slightly +// mispositioned checker/pole beats a crash, and goals are always placed on +// solid ground in practice. +function groundYAt(levelData, x) { + for (const segment of levelData.terrain) { + const points = segment.points; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + if (x >= a.x && x <= b.x) { + const t = (x - a.x) / (b.x - a.x || 1); + return a.y + (b.y - a.y) * t; + } + } + } + + const allPoints = levelData.terrain.flatMap((s) => s.points); + let nearest = allPoints[0]; + for (const p of allPoints) { + if (Math.abs(p.x - x) < Math.abs(nearest.x - x)) nearest = p; + } + return nearest ? nearest.y : 0; +} + +export default class FinishLine { + constructor(scene, levelData) { + this.levelData = levelData; + this.goalX = levelData.goal.x; + // Just past the checkered ground, in the direction of travel, so the + // pole doesn't stand in the middle of the driving line - the bus + // crosses the checkered pavement, then passes the flag. + this.poleX = this.goalX + CHECKER_HALF_WIDTH + POLE_GAP; + this.poleGroundY = groundYAt(levelData, this.poleX); + this.poleTopY = this.poleGroundY - POLE_HEIGHT; + + // Static ground checker + pole on one Graphics object (drawn once); + // the flag gets its own since it's redrawn every frame to ripple. + this.groundGraphics = scene.add.graphics().setDepth(0.5); + this._drawCheckeredGround(); + this._drawPole(); + + this.flagGraphics = scene.add.graphics().setDepth(0.5); + this._drawFlag(0); + } + + update(time) { + this._drawFlag(time); + } + + _drawCheckeredGround() { + const g = this.groundGraphics; + const startX = this.goalX - CHECKER_HALF_WIDTH; + const colWidth = (CHECKER_HALF_WIDTH * 2) / CHECKER_COLUMNS; + const rowDepth = CHECKER_ROAD_DEPTH / 2; + + for (let col = 0; col < CHECKER_COLUMNS; col++) { + const x0 = startX + col * colWidth; + const x1 = x0 + colWidth; + const y0 = groundYAt(this.levelData, x0); + const y1 = groundYAt(this.levelData, x1); + + for (let row = 0; row < 2; row++) { + const isDark = (col + row) % 2 === 0; + const d0 = rowDepth * row; + const d1 = rowDepth * (row + 1); + + g.fillStyle(isDark ? CHECKER_DARK : CHECKER_LIGHT, 1); + g.beginPath(); + g.moveTo(x0, y0 + d0); + g.lineTo(x1, y1 + d0); + g.lineTo(x1, y1 + d1); + g.lineTo(x0, y0 + d1); + g.closePath(); + g.fillPath(); + } + } + } + + _drawPole() { + const g = this.groundGraphics; + g.fillStyle(POLE_COLOR, 1); + g.fillRect(this.poleX - POLE_WIDTH / 2, this.poleTopY, POLE_WIDTH, this.poleGroundY - this.poleTopY); + g.fillStyle(POLE_CAP_COLOR, 1); + g.fillCircle(this.poleX, this.poleTopY, POLE_WIDTH * 0.9); + } + + // Redraws the flag as a grid of checker quads whose free (right-hand) + // edge is offset by a per-column sine wave - phase staggered by column so + // it reads as a ripple traveling along the cloth, amplitude fading to + // zero at the pole-mounted edge (col 0) since that side is pinned. + _drawFlag(time) { + const g = this.flagGraphics; + g.clear(); + + const colWidth = FLAG_WIDTH / FLAG_COLS; + const rowHeight = FLAG_HEIGHT / FLAG_ROWS; + const topY = this.poleTopY + 4 * WORLD_SCALE; + + const columnOffset = (col) => { + const reach = col / FLAG_COLS; + return Math.sin(time * FLAG_RIPPLE_SPEED - col * 0.9) * FLAG_RIPPLE_AMPLITUDE * reach; + }; + + for (let col = 0; col < FLAG_COLS; col++) { + const x0 = this.poleX + col * colWidth; + const x1 = this.poleX + (col + 1) * colWidth; + const off0 = columnOffset(col); + const off1 = columnOffset(col + 1); + + for (let row = 0; row < FLAG_ROWS; row++) { + const isDark = (col + row) % 2 === 0; + const y0 = topY + row * rowHeight; + const y1 = topY + (row + 1) * rowHeight; + + g.fillStyle(isDark ? CHECKER_DARK : CHECKER_LIGHT, 1); + g.beginPath(); + g.moveTo(x0, y0 + off0); + g.lineTo(x1, y0 + off1); + g.lineTo(x1, y1 + off1); + g.lineTo(x0, y1 + off0); + g.closePath(); + g.fillPath(); + } + } + } +} diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index 0611bb4..3cb67d9 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -3,6 +3,7 @@ import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE } from '../config.js'; import { getLevelById } from '../data/levels/index.js'; import Bus from '../entities/Bus.js'; import Terrain from '../entities/Terrain.js'; +import FinishLine from '../entities/FinishLine.js'; import InputController from '../systems/InputController.js'; import GForceMonitor from '../systems/GForceMonitor.js'; import KidManager from '../systems/KidManager.js'; @@ -30,6 +31,7 @@ export default class PlayScene extends Phaser.Scene { this._buildParallax(); this.terrain = new Terrain(this, this.level); + this.finishLine = new FinishLine(this, this.level); this.bus = new Bus(this, this.level.startPosition.x, this.level.startPosition.y, this.level.startAngle || 0); @@ -62,6 +64,7 @@ export default class PlayScene extends Phaser.Scene { const intent = this.inputController.getIntent(); this.bus.applyIntent(intent); this.engineSound.update(); + this.finishLine.update(time); this.cameraRig.update(); this._updateParallax(); this._checkBusFell(); diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 292a967..d28c58c 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -4,6 +4,7 @@ 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'; +import { loadKidFallSounds } from '../util/kidFallSound.js'; export default class PreloadScene extends Phaser.Scene { constructor() { @@ -28,6 +29,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('main_title_music', 'assets/music/main-title.mp3'); this.load.audio('engine_heavy', 'assets/fx/engine-heavy.mp3'); + loadKidFallSounds(this.load); // 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 diff --git a/src/systems/KidManager.js b/src/systems/KidManager.js index 097be45..c9a66b6 100644 --- a/src/systems/KidManager.js +++ b/src/systems/KidManager.js @@ -1,5 +1,6 @@ import { KID, BUS } from '../config.js'; import Kid from '../entities/Kid.js'; +import { playRandomKidFallSound } from '../util/kidFallSound.js'; export default class KidManager { constructor(scene, bus, kidsAboard) { @@ -66,6 +67,7 @@ export default class KidManager { if (isAboveOpenTop && kid.state === 'aboard') { kid.markEjected(); + playRandomKidFallSound(this.scene); anyChanged = true; } else if (!isAboveOpenTop && kid.state === 'ejected' && kid.isSettled(this.scene.time.now)) { // Re-boarding is gated on the kid actually settling (see Kid.isSettled), diff --git a/src/util/kidFallSound.js b/src/util/kidFallSound.js new file mode 100644 index 0000000..d97f0cf --- /dev/null +++ b/src/util/kidFallSound.js @@ -0,0 +1,24 @@ +// Three interchangeable "kid falls off the bus" clips - one plays at random +// each time a kid is ejected (see KidManager._onAfterUpdate) so it doesn't +// repeat identically every time. +const KEYS = ['kid_fall_1', 'kid_fall_2', 'kid_fall_3']; +const PATHS = [ + 'assets/voice/kid-fall-01.mp3', + 'assets/voice/kid-fall-02.mp3', + 'assets/voice/kid-fall-03.mp3', +]; + +export function loadKidFallSounds(loader) { + KEYS.forEach((key, i) => loader.audio(key, PATHS[i])); +} + +// Plays one of the three clips chosen at random, skipping any that failed +// to load (same load-tolerant pattern as voiceLine.js) rather than +// assuming all three are always present. +export function playRandomKidFallSound(scene) { + const available = KEYS.filter((key) => scene.cache.audio.exists(key)); + if (available.length === 0) return; + + const key = available[Math.floor(Math.random() * available.length)]; + scene.sound.add(key).play(); +}