Compare commits
3 Commits
3b8c13fb4e
...
17fe6efd10
| Author | SHA1 | Date |
|---|---|---|
|
|
17fe6efd10 | |
|
|
0c80e4774e | |
|
|
5eea8872dc |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -4,6 +4,7 @@ export default {
|
|||
name: "GoFundMe",
|
||||
description: "Get the Money!",
|
||||
kidsAboard: 4,
|
||||
timeLimit: 60,
|
||||
startPosition: { x: 200, y: 700 },
|
||||
startAngle: 0,
|
||||
terrain: [
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default {
|
|||
name: 'Big Air',
|
||||
description: 'Clear the gap and land level, or the kids pay for it.',
|
||||
kidsAboard: 3,
|
||||
timeLimit: 20,
|
||||
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
|
||||
startAngle: 0,
|
||||
terrain: [{ points: runway }, { points: landing }],
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export default {
|
|||
name: 'Tight Squeeze',
|
||||
description: 'A steep valley and a long run of sharp bumps - lean early, lean often.',
|
||||
kidsAboard: 4,
|
||||
timeLimit: 20,
|
||||
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
|
||||
startAngle: 0,
|
||||
terrain: [{ points: buildPoints() }],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export default {
|
|||
name: "Mega Drop",
|
||||
description: "",
|
||||
kidsAboard: 5,
|
||||
timeLimit: 76,
|
||||
startPosition: { x: 200, y: 700 },
|
||||
startAngle: 0,
|
||||
terrain: [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,41 @@
|
|||
import { TERRAIN_DEPTH, WORLD_SCALE } from '../config.js';
|
||||
|
||||
// Palette picked to match assets/backgrounds/bg_mid.png and bg_near.png's
|
||||
// painterly foliage (deep leaf greens, an olive/yellow-green highlight, warm
|
||||
// rust accents on a few "turning" leaves) so the drivable ground reads as
|
||||
// the same world as the parallax behind it, with a dirt-road strip riding
|
||||
// right on the surface where the bus actually touches down.
|
||||
const COLORS = {
|
||||
roadTop: 0xa5814f,
|
||||
roadEdge: 0x5c4128,
|
||||
roadPebble: 0x50381f,
|
||||
roadHighlight: 0xc9a56d,
|
||||
subsoil: 0x3f5c28,
|
||||
foliageMid: 0x36531f,
|
||||
foliageDeep: 0x223a15,
|
||||
tufts: [0x4a7a2e, 0x6fa23f, 0x9bbf4a, 0x8a5a3a],
|
||||
};
|
||||
|
||||
// How deep (from the surface line, straight down) each visual layer reaches
|
||||
// - not the physics depth (TERRAIN_DEPTH, which just needs to be deep
|
||||
// enough nothing ever tunnels through the bottom). Purely a "how many
|
||||
// pixels of dirt before it turns into foliage" tuning knob.
|
||||
const ROAD_DEPTH = 22 * WORLD_SCALE;
|
||||
const SUBSOIL_DEPTH = ROAD_DEPTH + 46 * WORLD_SCALE;
|
||||
const MID_FOLIAGE_DEPTH = TERRAIN_DEPTH * 0.55;
|
||||
|
||||
// Deterministic hash-based PRNG (mulberry32-style mix) instead of
|
||||
// Math.random(), so the road's pebble scatter and the grass tufts look the
|
||||
// same every time a level is (re)built - a fresh Math.random seed every
|
||||
// retry would make the ground visibly "shuffle" between attempts at the
|
||||
// same spot, which reads as a bug even though it's purely decorative.
|
||||
function hashRandom(seed) {
|
||||
let t = (seed ^ 0x6d2b79f5) >>> 0;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
// Ground is stored as left-to-right surface polylines (easy to hand-author),
|
||||
// each converted into a chain of angled static rectangle bodies extruded
|
||||
// downward - simpler and more robust than concave polygon decomposition.
|
||||
|
|
@ -55,21 +91,120 @@ export default class Terrain {
|
|||
this.bodies.push(body);
|
||||
}
|
||||
|
||||
_drawSegment(points) {
|
||||
// Fills the ribbon bounded above by the surface polyline and below by
|
||||
// that same polyline shifted straight down by `depth` AT EVERY POINT (not
|
||||
// just its two ends - a segment can run for thousands of units and climb
|
||||
// or drop a lot along the way, so a bottom edge built from only the first
|
||||
// and last point would just be one long straight diagonal across the
|
||||
// whole thing, nowhere near a constant `depth` below the actual terrain
|
||||
// in between). Layers are drawn deepest-first in _drawSegment, each
|
||||
// shallower fill simply capping the top portion of the previous one - a
|
||||
// cheap way to get bands that follow the terrain's contour without
|
||||
// computing separate band-only polygons.
|
||||
_fillFromTop(points, depth, color) {
|
||||
const g = this.graphics;
|
||||
g.fillStyle(0x3c8f3c, 1);
|
||||
g.fillStyle(color, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(points[0].x, points[0].y + TERRAIN_DEPTH);
|
||||
g.moveTo(points[0].x, points[0].y);
|
||||
for (const p of points) g.lineTo(p.x, p.y);
|
||||
const last = points[points.length - 1];
|
||||
g.lineTo(last.x, last.y + TERRAIN_DEPTH);
|
||||
for (let i = points.length - 1; i >= 0; i--) g.lineTo(points[i].x, points[i].y + depth);
|
||||
g.closePath();
|
||||
g.fillPath();
|
||||
}
|
||||
|
||||
g.lineStyle(4 * WORLD_SCALE, 0x2c6e2c, 1);
|
||||
_drawSegment(points) {
|
||||
this._fillFromTop(points, TERRAIN_DEPTH, COLORS.foliageDeep);
|
||||
this._fillFromTop(points, MID_FOLIAGE_DEPTH, COLORS.foliageMid);
|
||||
this._fillFromTop(points, SUBSOIL_DEPTH, COLORS.subsoil);
|
||||
this._fillFromTop(points, ROAD_DEPTH, COLORS.roadTop);
|
||||
|
||||
this._drawRoadTexture(points);
|
||||
|
||||
const g = this.graphics;
|
||||
g.lineStyle(4 * WORLD_SCALE, COLORS.roadEdge, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(points[0].x, points[0].y);
|
||||
for (const p of points) g.lineTo(p.x, p.y);
|
||||
g.strokePath();
|
||||
|
||||
this._drawFoliageTufts(points);
|
||||
}
|
||||
|
||||
// Scatters small pebble/rut flecks across the dirt band so it doesn't
|
||||
// read as a flat color fill - sampled a few times per segment rather
|
||||
// than per original point (point spacing depends on the level/editor's
|
||||
// width settings, so this keeps texture density roughly constant
|
||||
// regardless of how the terrain was authored).
|
||||
_drawRoadTexture(points) {
|
||||
const g = this.graphics;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const segLen = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
if (segLen < 1) continue;
|
||||
|
||||
const count = Math.max(1, Math.round(segLen / (26 * WORLD_SCALE)));
|
||||
for (let j = 0; j < count; j++) {
|
||||
const seed = Math.round(a.x) * 97 + i * 131 + j * 17;
|
||||
const t = (j + 0.5) / count;
|
||||
const px = a.x + (b.x - a.x) * t + (hashRandom(seed) - 0.5) * 16 * WORLD_SCALE;
|
||||
const py = a.y + (b.y - a.y) * t + (0.25 + hashRandom(seed + 1) * 0.65) * ROAD_DEPTH;
|
||||
|
||||
const isHighlight = hashRandom(seed + 2) > 0.55;
|
||||
const radius = (1.3 + hashRandom(seed + 3) * 1.5) * WORLD_SCALE;
|
||||
g.fillStyle(isHighlight ? COLORS.roadHighlight : COLORS.roadPebble, isHighlight ? 0.5 : 0.45);
|
||||
g.fillCircle(px, py, radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse little grass/leaf blades poking up right at the road's edge,
|
||||
// leaned and colored from the bg_mid/bg_near palette - breaks up the
|
||||
// otherwise perfectly straight edge line and is what actually reads as
|
||||
// "foliage" rather than just a flat green fill underneath.
|
||||
_drawFoliageTufts(points) {
|
||||
const g = this.graphics;
|
||||
const palette = COLORS.tufts;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const segLen = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
if (segLen < 1) continue;
|
||||
|
||||
const seed0 = Math.round(a.x) * 53 + i * 197;
|
||||
if (hashRandom(seed0) > 0.4) continue; // keep tufts sparse, not on every segment
|
||||
|
||||
const dirX = (b.x - a.x) / segLen;
|
||||
const dirY = (b.y - a.y) / segLen;
|
||||
// Perpendicular to the segment, rotated so it points away from the
|
||||
// fill (i.e. "up" relative to the local slope, not world-up).
|
||||
const nx = dirY;
|
||||
const ny = -dirX;
|
||||
|
||||
const t = 0.3 + hashRandom(seed0 + 1) * 0.4;
|
||||
const baseX = a.x + (b.x - a.x) * t;
|
||||
const baseY = a.y + (b.y - a.y) * t;
|
||||
|
||||
const bladeCount = 3 + Math.floor(hashRandom(seed0 + 2) * 3);
|
||||
for (let k = 0; k < bladeCount; k++) {
|
||||
const seed = seed0 + k * 11 + 3;
|
||||
const spread = (hashRandom(seed) - 0.5) * 14 * WORLD_SCALE;
|
||||
const height = (7 + hashRandom(seed + 1) * 11) * WORLD_SCALE;
|
||||
const lean = (hashRandom(seed + 2) - 0.5) * 6 * WORLD_SCALE;
|
||||
const color = palette[Math.floor(hashRandom(seed + 3) * palette.length)];
|
||||
|
||||
const rootX = baseX + dirX * spread;
|
||||
const rootY = baseY + dirY * spread;
|
||||
const tipX = rootX + nx * height + lean;
|
||||
const tipY = rootY + ny * height;
|
||||
|
||||
g.lineStyle(2.4 * WORLD_SCALE, color, 0.9);
|
||||
g.beginPath();
|
||||
g.moveTo(rootX, rootY);
|
||||
g.lineTo(tipX, tipY);
|
||||
g.strokePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createButton } from '../util/ui.js';
|
|||
const REASON_TEXT = {
|
||||
'all-kids-lost': 'Every kid got thrown off the bus!',
|
||||
'bus-fell': 'The bus fell into the gap!',
|
||||
'time-up': 'Ran out of time before reaching the finish line!',
|
||||
};
|
||||
|
||||
export default class LevelFailedScene extends Phaser.Scene {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -20,6 +21,8 @@ export default class PlayScene extends Phaser.Scene {
|
|||
this.levelId = data.levelId;
|
||||
this.level = getLevelById(this.levelId);
|
||||
this._levelEnded = false;
|
||||
this._timerStarted = false;
|
||||
this._timeRemaining = this.level.timeLimit;
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -30,6 +33,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);
|
||||
|
||||
|
|
@ -60,10 +64,14 @@ export default class PlayScene extends Phaser.Scene {
|
|||
if (this._levelEnded) return;
|
||||
|
||||
const intent = this.inputController.getIntent();
|
||||
if (!this._timerStarted && intent.throttle > 0) this._timerStarted = true;
|
||||
|
||||
this.bus.applyIntent(intent);
|
||||
this.engineSound.update();
|
||||
this.finishLine.update(time);
|
||||
this.cameraRig.update();
|
||||
this._updateParallax();
|
||||
this._updateTimer(delta);
|
||||
this._checkBusFell();
|
||||
this._checkAllKidsLost();
|
||||
|
||||
|
|
@ -165,6 +173,28 @@ export default class PlayScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Counts down only once the player has actually hit the gas (throttle >
|
||||
// 0) for the first time - sitting idle at the start line costs nothing,
|
||||
// matching "the clock is your speedrun, not your reaction time" framing.
|
||||
_updateTimer(delta) {
|
||||
if (!this._timerStarted || this._timeRemaining <= 0) return;
|
||||
|
||||
this._timeRemaining = Math.max(0, this._timeRemaining - delta / 1000);
|
||||
this.timerText.setText(this._formatTime(this._timeRemaining));
|
||||
this.timerText.setColor(this._timeRemaining <= 10 ? '#c0392b' : '#1a1f29');
|
||||
|
||||
if (this._timeRemaining <= 0) {
|
||||
this.events.emit('level-failed', { reason: 'time-up' });
|
||||
}
|
||||
}
|
||||
|
||||
_formatTime(seconds) {
|
||||
const whole = Math.max(0, Math.ceil(seconds));
|
||||
const m = Math.floor(whole / 60);
|
||||
const s = whole % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
_buildGoal() {
|
||||
const goal = this.level.goal;
|
||||
this.goalBody = this.matter.add.rectangle(goal.x, goal.y, goal.width, goal.height, {
|
||||
|
|
@ -193,6 +223,14 @@ export default class PlayScene extends Phaser.Scene {
|
|||
backgroundColor: '#ffffffaa',
|
||||
}).setScrollFactor(0).setDepth(10);
|
||||
|
||||
this.timerText = this.add.text(GAME_WIDTH - startX, y, this._formatTime(this._timeRemaining), {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${32 * WORLD_SCALE}px`,
|
||||
fontStyle: 'bold',
|
||||
color: '#1a1f29',
|
||||
backgroundColor: '#ffffffaa',
|
||||
}).setOrigin(1, 0).setScrollFactor(0).setDepth(10);
|
||||
|
||||
if (DEBUG) {
|
||||
this.debugText = this.add.text(startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', {
|
||||
fontFamily: 'monospace',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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