feat: add finish line visual and kid fall voice lines
- Add checkered finish line entity with rippling flag pole (purely visual) - Add three kid fall voice clips with random playback on kid ejection - Add load tolerance for optional voice assets matching voiceLine.js pattern - Preload kid fall sounds in PreloadScene and wire into KidManager
This commit is contained in:
parent
5eea8872dc
commit
0c80e4774e
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE } from '../config.js';
|
||||||
import { getLevelById } from '../data/levels/index.js';
|
import { getLevelById } from '../data/levels/index.js';
|
||||||
import Bus from '../entities/Bus.js';
|
import Bus from '../entities/Bus.js';
|
||||||
import Terrain from '../entities/Terrain.js';
|
import Terrain from '../entities/Terrain.js';
|
||||||
|
import FinishLine from '../entities/FinishLine.js';
|
||||||
import InputController from '../systems/InputController.js';
|
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';
|
||||||
|
|
@ -30,6 +31,7 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
this._buildParallax();
|
this._buildParallax();
|
||||||
|
|
||||||
this.terrain = new Terrain(this, this.level);
|
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);
|
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();
|
const intent = this.inputController.getIntent();
|
||||||
this.bus.applyIntent(intent);
|
this.bus.applyIntent(intent);
|
||||||
this.engineSound.update();
|
this.engineSound.update();
|
||||||
|
this.finishLine.update(time);
|
||||||
this.cameraRig.update();
|
this.cameraRig.update();
|
||||||
this._updateParallax();
|
this._updateParallax();
|
||||||
this._checkBusFell();
|
this._checkBusFell();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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 { LEVELS } from '../data/levels/index.js';
|
||||||
import { voiceKey, voiceAssetPath } from '../util/voiceLine.js';
|
import { voiceKey, voiceAssetPath } from '../util/voiceLine.js';
|
||||||
|
import { loadKidFallSounds } from '../util/kidFallSound.js';
|
||||||
|
|
||||||
export default class PreloadScene extends Phaser.Scene {
|
export default class PreloadScene extends Phaser.Scene {
|
||||||
constructor() {
|
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('main_title_music', 'assets/music/main-title.mp3');
|
||||||
this.load.audio('engine_heavy', 'assets/fx/engine-heavy.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
|
// 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
|
// and lets a missing file 404 like any other optional asset (the
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { KID, BUS } from '../config.js';
|
import { KID, BUS } from '../config.js';
|
||||||
import Kid from '../entities/Kid.js';
|
import Kid from '../entities/Kid.js';
|
||||||
|
import { playRandomKidFallSound } from '../util/kidFallSound.js';
|
||||||
|
|
||||||
export default class KidManager {
|
export default class KidManager {
|
||||||
constructor(scene, bus, kidsAboard) {
|
constructor(scene, bus, kidsAboard) {
|
||||||
|
|
@ -66,6 +67,7 @@ export default class KidManager {
|
||||||
|
|
||||||
if (isAboveOpenTop && kid.state === 'aboard') {
|
if (isAboveOpenTop && kid.state === 'aboard') {
|
||||||
kid.markEjected();
|
kid.markEjected();
|
||||||
|
playRandomKidFallSound(this.scene);
|
||||||
anyChanged = true;
|
anyChanged = true;
|
||||||
} else if (!isAboveOpenTop && kid.state === 'ejected' && kid.isSettled(this.scene.time.now)) {
|
} else if (!isAboveOpenTop && kid.state === 'ejected' && kid.isSettled(this.scene.time.now)) {
|
||||||
// Re-boarding is gated on the kid actually settling (see Kid.isSettled),
|
// Re-boarding is gated on the kid actually settling (see Kid.isSettled),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue