commit c4578d57ea48bb50f909851757590f8bf6e9eb0f
parent 089cebc27835aa4ae13536e03a2ee9bcf9d77798
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Wed, 22 Apr 2026 10:58:37 -0700
Try and fix fire truck
Diffstat:
3 files changed, 426 insertions(+), 246 deletions(-)
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -4,68 +4,85 @@ const PALETTE = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6'];
const TINTS = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6];
// Camera / projection
-const CAM_DEPTH = 0.84; // 1/tan(fov/2), fov≈100°
-const HORIZON_FRAC = 0.45; // horizon is at 45% screen height
-const CAM_Y_WORLD = 1500; // camera height above ground in world units
+const CAM_DEPTH = 0.84;
+const HORIZON_FRAC = 0.45;
+const CAM_Y_WORLD = 1500;
-// Road geometry (world units)
+// Road geometry
const ROAD_HALF_W = 1000;
const KERB_W = 120;
const SIDEW_HALF_W = 1700;
-// World objects (world units)
-const BLDG_X_W = 2200; // building X offset from center
-const BLDG_H_W = 600; // building world height (for sprite scaling)
+// Building walls (replaces old sprite pool)
+const BUILDING_SEG_LEN = 900; // world units per building slab
+const BUILDING_TOP_Y = 6000; // world-unit height (well above camera)
+const INTERSECT_HALF_LEN = 950; // half-width of cross-street gap
+const BUILDING_PALETTE = [
+ { fill: 0x3A3A5C, win: 0x7090CC },
+ { fill: 0x4A3A2C, win: 0xFFD8A0 },
+ { fill: 0x2C4A3A, win: 0x90DDA0 },
+ { fill: 0x3C2C4C, win: 0xCCA0FF },
+ { fill: 0x5C3A3A, win: 0xFFC8B8 },
+];
+const BLDG_SEED_L = 1337;
+const BLDG_SEED_R = 9001;
+
+// Decorative objects still using sprites
const PED_X_W = 1450;
const PED_H_W = 100;
const TLIGHT_X_W = 1320;
const TLIGHT_H_W = 160;
-const FIRE_H_W = 180;
+const FIRE_H_W = 220;
// Road scanline
-const SEG_LEN = 200; // world units per segment (stripe alternation)
-const DRAW_SEGS = 120; // max visible segments
+const SEG_LEN = 200;
+const DRAW_SEGS = 120;
// Gameplay
-const DRIVE_SPEED = 550; // world units / second
+const DRIVE_SPEED = 550;
const ROUTE_LEN = 5;
const INTERSECTION_GAP = 5000;
-const PROMPT_DIST = 1800;
+const PROMPT_DIST = 2000;
const ARRIVE_DIST = 700;
-// Object pool sizes
-const BLDG_COUNT = 16;
+// Object pools
const PED_COUNT = 10;
const TLIGHT_COUNT = 6;
+// Turn animation
+const TURN_DURATION_MS = 650;
+
// ── Scene ─────────────────────────────────────────────────────────────────────
class FireTruckScene extends Phaser.Scene {
constructor() {
super({ key: 'FireTruckScene' });
- this.camZ = 0;
- this.state = 'driving'; // driving | awaiting | turning | arrived
- this.route = [];
- this.routeIdx = 0;
- this.round = 1;
- this.turnShift = 0;
+ this.camZ = 0;
+ this.state = 'driving'; // driving | awaiting | turning | arrived
+ this.route = [];
+ this.routeIdx = 0;
+ this.round = 1;
+ this.turnAngle = 0; // radians; drives pivot animation
+ this.fireSide = 1; // +1 right, -1 left
- this.bldgs = [];
this.peds = [];
this.tlights = [];
- this.truckSprite = null;
- this.lightBarL = null;
- this.lightBarR = null;
- this.arrowBg = null;
- this.arrowSprite = null;
- this.arrowText = null;
- this.instrText = null;
- this.roundText = null;
- this.roadGfx = null;
- this.groundRect = null;
- this.fireSprite = null;
+ this.truckSprite = null;
+ this.lightBarL = null;
+ this.lightBarR = null;
+ this.arrowBg = null;
+ this.arrowLeft = null;
+ this.arrowRight = null;
+ this.arrowMain = null;
+ this.arrowText = null;
+ this.instrText = null;
+ this.roundText = null;
+ this.roadGfx = null;
+ this.groundRect = null;
+ this.fireSprite = null;
+ this.fireGlow = null;
this.lightsOn = false;
this.sirenOn = false;
@@ -87,12 +104,11 @@ class FireTruckScene extends Phaser.Scene {
this.lightInterval = null;
}
- // ── Preload: bake all textures ─────────────────────────────────────────────
+ // ── Preload ────────────────────────────────────────────────────────────────
preload() {
this._genParticle();
this._genTruck();
- this._genBuildings();
this._genPedestrian();
this._genTrafficLight();
this._genArrows();
@@ -107,40 +123,18 @@ class FireTruckScene extends Phaser.Scene {
_genTruck() {
const g = this.make.graphics({ add: false });
- g.fillStyle(0xE63946); g.fillRoundedRect(0, 30, 140, 70, 8); // body
- g.fillStyle(0xCC2233); g.fillRoundedRect(100, 10, 40, 90, 8); // cab
- g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 18, 28, 32, 4); // windshield
- g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 56, 28, 18, 4); // side window
- g.fillStyle(0xAA1122); g.fillRect(8, 44, 90, 7); // ladder rail
+ g.fillStyle(0xE63946); g.fillRoundedRect(0, 30, 140, 70, 8);
+ g.fillStyle(0xCC2233); g.fillRoundedRect(100, 10, 40, 90, 8);
+ g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 18, 28, 32, 4);
+ g.fillStyle(0xB8E0FF); g.fillRoundedRect(106, 56, 28, 18, 4);
+ g.fillStyle(0xAA1122); g.fillRect(8, 44, 90, 7);
[18, 36, 54, 72, 88].forEach(x => {
- g.fillStyle(0xAA1122); g.fillRect(x, 40, 4, 15); // rungs
+ g.fillStyle(0xAA1122); g.fillRect(x, 40, 4, 15);
});
- g.fillStyle(0x333333); g.fillRoundedRect(10, 8, 82, 14, 4); // light bar base
+ g.fillStyle(0x333333); g.fillRoundedRect(10, 8, 82, 14, 4);
g.generateTexture('truck', 140, 100); g.destroy();
}
- _genBuildings() {
- const cfgs = [
- { w: 90, h: 120, fill: 0x3A3A5C, win: 0x7090CC },
- { w: 70, h: 160, fill: 0x4A3A2C, win: 0xFFD8A0 },
- { w: 110, h: 100, fill: 0x2C4A3A, win: 0x90DDA0 },
- { w: 80, h: 140, fill: 0x3C2C4C, win: 0xCCA0FF },
- ];
- cfgs.forEach(({ w, h, fill, win }, i) => {
- const g = this.make.graphics({ add: false });
- g.fillStyle(fill); g.fillRect(0, 0, w, h);
- for (let row = 10; row < h - 20; row += 24) {
- for (let col = 8; col < w - 12; col += 18) {
- g.fillStyle(win, Math.random() > 0.3 ? 0.85 : 0.15);
- g.fillRect(col, row, 10, 14);
- }
- }
- // cornice – lighter tone of fill color
- g.fillStyle(Math.min(fill + 0x1a1a1a, 0xffffff)); g.fillRect(0, 0, w, 8);
- g.generateTexture('bldg' + i, w, h); g.destroy();
- });
- }
-
_genPedestrian() {
const g = this.make.graphics({ add: false });
g.fillStyle(0xF4C08A); g.fillCircle(8, 6, 6);
@@ -155,8 +149,8 @@ class FireTruckScene extends Phaser.Scene {
const dimCols = [0x551111, 0x554400, 0x115511];
tlKeys.forEach((key, bi) => {
const g = this.make.graphics({ add: false });
- g.fillStyle(0x555555); g.fillRect(7, 60, 6, 40); // pole
- g.fillStyle(0x222222); g.fillRoundedRect(0, 0, 20, 60, 3); // box
+ g.fillStyle(0x555555); g.fillRect(7, 60, 6, 40);
+ g.fillStyle(0x222222); g.fillRoundedRect(0, 0, 20, 60, 3);
litCols.forEach((c, j) => {
g.fillStyle(bi === j ? c : dimCols[j]);
g.fillCircle(10, 12 + j * 18, 7);
@@ -195,49 +189,43 @@ class FireTruckScene extends Phaser.Scene {
const W = this.scale.width;
const H = this.scale.height;
- // Ground fills the lower half of screen (road draws on top)
this.groundRect = this.add.rectangle(W / 2, H * HORIZON_FRAC, W, H, 0x555544).setOrigin(0.5, 0);
+ this.roadGfx = this.add.graphics();
- // Road (redrawn every frame in update)
- this.roadGfx = this.add.graphics();
-
- // World pools
this._spawnPools();
- // Fire sprite (hidden until destination)
- this.fireSprite = this.add.image(W / 2, H * 0.4, 'fire').setScale(0).setDepth(5);
+ // Fire glow + sprite (on building wall)
+ this.fireGlow = this.add.rectangle(0, 0, 80, 80, 0xFF6600, 0.5).setDepth(4).setVisible(false);
+ this.fireSprite = this.add.image(0, 0, 'fire').setScale(0).setDepth(5).setVisible(false);
- // Truck at bottom-center
this.truckSprite = this.add.image(W / 2, H * 0.85, 'truck').setDepth(10);
-
- // Light bars (Phaser Rectangle objects)
- this.lightBarL = this.add.rectangle(0, 0, 30, 8, 0xE63946).setDepth(11).setVisible(false);
- this.lightBarR = this.add.rectangle(0, 0, 30, 8, 0x1D7CF2).setDepth(11).setVisible(false);
+ this.lightBarL = this.add.rectangle(0, 0, 30, 8, 0xE63946).setDepth(11).setVisible(false);
+ this.lightBarR = this.add.rectangle(0, 0, 30, 8, 0x1D7CF2).setDepth(11).setVisible(false);
this._sizeToScreen(W, H);
// Arrow prompt UI
- this.arrowBg = this.add.rectangle(W / 2, H * 0.3, 120, 120, 0x000000, 0.6)
+ this.arrowBg = this.add.rectangle(W / 2, H * 0.3, 160, 130, 0x000000, 0.6)
.setDepth(20).setVisible(false).setStrokeStyle(3, 0xffffff, 0.8).setOrigin(0.5);
- this.arrowSprite = this.add.image(W / 2, H * 0.28, 'arrow_up')
+ // T-intersection: show both arrows side-by-side
+ this.arrowLeft = this.add.image(W / 2 - 44, H * 0.27, 'arrow_left')
+ .setDepth(21).setVisible(false).setScale(1.0).setAlpha(0.4);
+ this.arrowRight = this.add.image(W / 2 + 44, H * 0.27, 'arrow_right')
+ .setDepth(21).setVisible(false).setScale(1.0).setAlpha(0.4);
+ // 4-way: single arrow
+ this.arrowMain = this.add.image(W / 2, H * 0.27, 'arrow_up')
.setDepth(21).setVisible(false).setScale(1.3);
- this.arrowText = this.add.text(W / 2, H * 0.4, '', {
- fontFamily: 'Fredoka, sans-serif',
- fontSize: '22px',
- color: '#ffffff',
+ this.arrowText = this.add.text(W / 2, H * 0.41, '', {
+ fontFamily: 'Fredoka, sans-serif', fontSize: '22px', color: '#ffffff',
}).setOrigin(0.5).setDepth(22).setVisible(false);
// HUD
this.instrText = this.add.text(W / 2, H * 0.07, '', {
- fontFamily: 'Fredoka, sans-serif',
- fontSize: '20px',
- color: '#eeeeee',
+ fontFamily: 'Fredoka, sans-serif', fontSize: '20px', color: '#eeeeee',
}).setOrigin(0.5).setDepth(30);
this.roundText = this.add.text(12, 8, 'Round 1', {
- fontFamily: 'Fredoka, sans-serif',
- fontSize: '18px',
- color: '#888888',
+ fontFamily: 'Fredoka, sans-serif', fontSize: '18px', color: '#888888',
}).setDepth(30);
// Input
@@ -251,24 +239,14 @@ class FireTruckScene extends Phaser.Scene {
this.scale.on('resize', gs => this.onResize(gs.width, gs.height));
this.newRound();
-
- // Expose scene for browser tests
window.__FT_SCENE__ = this;
}
_spawnPools() {
- const bldgKeys = ['bldg0', 'bldg1', 'bldg2', 'bldg3'];
- for (let i = 0; i < BLDG_COUNT; i++) {
- const key = Phaser.Math.RND.pick(bldgKeys);
- const side = i % 2 === 0 ? -1 : 1;
- const z = 1500 + i * 2000;
- const sp = this.add.image(0, 0, key).setDepth(3).setVisible(false);
- this.bldgs.push({ sp, side, z, key });
- }
for (let i = 0; i < PED_COUNT; i++) {
const side = i % 2 === 0 ? -1 : 1;
const z = 2000 + i * 2800;
- const sp = this.add.image(0, 0, 'ped').setDepth(4).setVisible(false);
+ const sp = this.add.image(0, 0, 'ped').setDepth(6).setVisible(false);
this.peds.push({ sp, side, z });
}
const tlKeys = ['tlight_red', 'tlight_yellow', 'tlight_green'];
@@ -276,7 +254,7 @@ class FireTruckScene extends Phaser.Scene {
const side = i % 2 === 0 ? -1 : 1;
const z = (i + 1) * INTERSECTION_GAP - 500;
const phase = Phaser.Math.Between(0, 2);
- const sp = this.add.image(0, 0, tlKeys[phase]).setDepth(4).setVisible(false);
+ const sp = this.add.image(0, 0, tlKeys[phase]).setDepth(6).setVisible(false);
this.tlights.push({ sp, side, z, phase, timer: 3000 + phase * 1800 });
}
}
@@ -285,7 +263,6 @@ class FireTruckScene extends Phaser.Scene {
const sc = Math.min(W, H) / 340;
const ty = H * 0.82;
this.truckSprite.setScale(sc).setPosition(W / 2, ty);
-
const tw = sc * 140;
const lbW = tw * 0.24;
const lbH = sc * 10;
@@ -294,105 +271,254 @@ class FireTruckScene extends Phaser.Scene {
this.lightBarR.setDisplaySize(lbW, lbH).setPosition(W / 2 + tw * 0.18, lbY);
}
- // ── Update (called every frame) ────────────────────────────────────────────
+ // ── Update ─────────────────────────────────────────────────────────────────
update(time, dt) {
- if (this.state === 'arrived') return;
+ if (this.state === 'arrived') {
+ // Still redraw road/buildings while stopped
+ const W = this.scale.width, H = this.scale.height;
+ this._drawCity(W, H, H * HORIZON_FRAC);
+ return;
+ }
- const W = this.scale.width;
- const H = this.scale.height;
- const horizY = H * HORIZON_FRAC;
+ const W = this.scale.width;
+ const H = this.scale.height;
+ const horizY = H * HORIZON_FRAC;
if (this.state === 'driving' || this.state === 'awaiting') {
this.camZ += DRIVE_SPEED * (dt / 1000);
}
- this._drawRoad(W, H, horizY);
+ this._drawCity(W, H, horizY);
this._updateWorld(W, H, horizY, dt, time);
this._checkIntersection(W, H);
}
- // ── Road rendering (Y-based scanline) ─────────────────────────────────────
- // Iterates from screen bottom upward, computing the Z (and thus depth scale)
- // for each row. This guarantees full coverage from bottom to horizon.
+ // ── City scanline renderer ─────────────────────────────────────────────────
+ // Draws road + kerb + sidewalk + building walls in one Y-scanline pass,
+ // then paints above-horizon building facades in a second pass.
- _drawRoad(W, H, horizY) {
+ _drawCity(W, H, horizY) {
const gfx = this.roadGfx;
gfx.clear();
- const roadColA = 0x3A3A3A;
- const roadColB = 0x484848;
- const kerbColA = 0xDDDDDD;
- const kerbColB = 0x888888;
- const sidewCol = 0x666655;
- const dashCol = 0xCCCC44;
- const cx = W / 2 + this.turnShift;
+ const roadColA = 0x3A3A3A, roadColB = 0x484848;
+ const kerbColA = 0xDDDDDD, kerbColB = 0x888888;
+ const sidewCol = 0x666655;
+ const dashCol = 0xCCCC44;
+
+ // Pivot animation: sway cx and compress road width
+ const pivot = this.turnAngle; // radians (positive = turn right)
+ const cosP = Math.cos(pivot);
+ const sinP = Math.sin(pivot);
+ // Collect intersection Zs for gap detection
+ const iZones = this.route.map(r => r.z);
+
+ // ── Below-horizon scanline ───────────────────────────────────────────────
for (let sy = Math.ceil(H); sy > horizY; sy -= 2) {
const offset = sy - horizY;
- // scale = offset / (CAM_Y_WORLD * H/2); and scale = CAM_DEPTH / dz
const scale = offset / (CAM_Y_WORLD * (H / 2));
const dz = CAM_DEPTH / scale;
const worldZ = this.camZ + dz;
- const segNum = Math.floor(worldZ / SEG_LEN);
- const alt = segNum % 2;
-
- const roadPx = scale * ROAD_HALF_W * (W / 2);
- const kerbPx = scale * (ROAD_HALF_W + KERB_W) * (W / 2);
- const sidePx = scale * SIDEW_HALF_W * (W / 2);
+ // Pivot: nearer rows (small dz) get the largest horizontal sway
+ const sway = sinP * (H * 0.35) * (1 / (1 + dz / 4000));
+ const cx = W / 2 + sway;
+ const wSc = cosP; // lateral compression during turn
+
+ const segNum = Math.floor(worldZ / SEG_LEN);
+ const alt = segNum % 2;
+
+ const roadPx = scale * ROAD_HALF_W * (W / 2) * wSc;
+ const kerbPx = scale * (ROAD_HALF_W + KERB_W) * (W / 2) * wSc;
+ const sidePx = scale * SIDEW_HALF_W * (W / 2) * wSc;
+
+ // Building wall color for this worldZ
+ const inGap = iZones.some(iz => Math.abs(worldZ - iz) < INTERSECT_HALF_LEN);
+
+ if (inGap) {
+ // Cross street: extend road/kerb/sidewalk into the wall zone
+ gfx.fillStyle(sidewCol);
+ gfx.fillRect(0, sy, cx - sidePx, 2);
+ gfx.fillRect(cx + sidePx, sy, W - (cx + sidePx), 2);
+ gfx.fillStyle(alt ? kerbColA : kerbColB);
+ gfx.fillRect(cx - kerbPx, sy, kerbPx - roadPx, 2);
+ gfx.fillRect(cx + roadPx, sy, kerbPx - roadPx, 2);
+ gfx.fillStyle(alt ? roadColA : roadColB);
+ gfx.fillRect(0, sy, cx - kerbPx, 2);
+ gfx.fillRect(cx + kerbPx, sy, W - (cx + kerbPx), 2);
+ gfx.fillRect(cx - roadPx, sy, 2 * roadPx, 2);
+ if (alt === 0) { gfx.fillStyle(dashCol); gfx.fillRect(cx - 2, sy, 4, 2); }
+ } else {
+ // Building walls (fill to screen edges)
+ const segL = Math.floor((worldZ + BLDG_SEED_L) / BUILDING_SEG_LEN);
+ const segR = Math.floor((worldZ + BLDG_SEED_R) / BUILDING_SEG_LEN);
+ const bL = BUILDING_PALETTE[((segL % BUILDING_PALETTE.length) + BUILDING_PALETTE.length) % BUILDING_PALETTE.length];
+ const bR = BUILDING_PALETTE[((segR % BUILDING_PALETTE.length) + BUILDING_PALETTE.length) % BUILDING_PALETTE.length];
+
+ gfx.fillStyle(bL.fill);
+ gfx.fillRect(0, sy, cx - sidePx, 2);
+ gfx.fillStyle(bR.fill);
+ gfx.fillRect(cx + sidePx, sy, W - (cx + sidePx), 2);
+
+ // Sidewalk between building and kerb
+ gfx.fillStyle(sidewCol);
+ gfx.fillRect(cx - sidePx, sy, sidePx - kerbPx, 2);
+ gfx.fillRect(cx + kerbPx, sy, sidePx - kerbPx, 2);
+
+ // Kerb
+ gfx.fillStyle(alt ? kerbColA : kerbColB);
+ gfx.fillRect(cx - kerbPx, sy, kerbPx - roadPx, 2);
+ gfx.fillRect(cx + roadPx, sy, kerbPx - roadPx, 2);
+
+ // Road
+ gfx.fillStyle(alt ? roadColA : roadColB);
+ gfx.fillRect(cx - roadPx, sy, 2 * roadPx, 2);
+ if (alt === 0) { gfx.fillStyle(dashCol); gfx.fillRect(cx - 2, sy, 4, 2); }
+ }
+ }
- // sidewalk
- gfx.fillStyle(sidewCol);
- gfx.fillRect(cx - sidePx, sy, sidePx - kerbPx, 2);
- gfx.fillRect(cx + kerbPx, sy, sidePx - kerbPx, 2);
+ // ── Above-horizon building facades ───────────────────────────────────────
+ // Render several upcoming building segments as tall quads on each side.
+ this._drawBuildingFacades(gfx, W, H, horizY, iZones, sinP, cosP);
+ }
- // kerb (striped alternating white/grey)
- gfx.fillStyle(alt ? kerbColA : kerbColB);
- gfx.fillRect(cx - kerbPx, sy, kerbPx - roadPx, 2);
- gfx.fillRect(cx + roadPx, sy, kerbPx - roadPx, 2);
+ _drawBuildingFacades(gfx, W, H, horizY, iZones, sinP, cosP) {
+ const maxDz = DRAW_SEGS * SEG_LEN;
+ const nSegs = Math.ceil(maxDz / BUILDING_SEG_LEN) + 1;
+ const startZ = Math.floor(this.camZ / BUILDING_SEG_LEN) * BUILDING_SEG_LEN;
+
+ for (let s = 0; s < nSegs; s++) {
+ const segZNear = startZ + s * BUILDING_SEG_LEN;
+ const segZFar = segZNear + BUILDING_SEG_LEN;
+
+ // skip segments behind camera
+ if (segZFar <= this.camZ) continue;
+ if (segZNear - this.camZ > maxDz) break;
+
+ for (let side = -1; side <= 1; side += 2) {
+ const seedOff = side === -1 ? BLDG_SEED_L : BLDG_SEED_R;
+ const segIdx = Math.floor((segZNear + seedOff) / BUILDING_SEG_LEN);
+ const bldg = BUILDING_PALETTE[((segIdx % BUILDING_PALETTE.length) + BUILDING_PALETTE.length) % BUILDING_PALETTE.length];
+
+ // In intersection gap? Skip building facade
+ const midZ = (segZNear + segZFar) / 2;
+ if (iZones.some(iz => Math.abs(midZ - iz) < INTERSECT_HALF_LEN)) continue;
+
+ // Project near and far edges of this building segment at the sidewalk edge
+ const wx = side * SIDEW_HALF_W;
+
+ const pNearB = this._projPivot(wx, 0, segZNear, W, H, horizY, sinP, cosP);
+ const pFarB = this._projPivot(wx, 0, segZFar, W, H, horizY, sinP, cosP);
+ const pNearT = this._projPivot(wx, BUILDING_TOP_Y, segZNear, W, H, horizY, sinP, cosP);
+ const pFarT = this._projPivot(wx, BUILDING_TOP_Y, segZFar, W, H, horizY, sinP, cosP);
+
+ if (!pNearB.visible && !pFarB.visible) continue;
+
+ // Building quad: near-top → far-top → far-bottom → near-bottom
+ // Then filled to screen left/right edge
+ const xNearBottom = pNearB.x;
+ const xFarBottom = pFarB.visible ? pFarB.x : (side < 0 ? 0 : W);
+ const xNearTop = pNearT.visible ? pNearT.x : (side < 0 ? 0 : W);
+ const xFarTop = pFarT.visible ? pFarT.x : (side < 0 ? 0 : W);
+ const yNearBottom = pNearB.visible ? Math.min(pNearB.y, H) : horizY;
+ const yFarBottom = pFarB.visible ? Math.min(pFarB.y, H) : horizY;
+ const yNearTop = pNearT.visible ? pNearT.y : -H;
+ const yFarTop = pFarT.visible ? pFarT.y : -H;
+
+ gfx.fillStyle(bldg.fill);
+
+ // Draw facade as stacked horizontal slices for correct fill
+ // Use polygon approach: 4 corners of the facade face
+ const topY = Math.min(yNearTop, yFarTop, 0);
+ const bottomY = Math.max(yNearBottom, yFarBottom, horizY);
+
+ if (bottomY <= topY) continue;
+
+ // Interpolate x-left/right at each screen row between horizY and bottomY
+ // Near edge: at y approaching horizY, x → horizon vanishing point; at yNearBottom x = xNearBottom
+ // Far edge: similar but for far Z
+ // Simpler: just draw a filled trapezoid using fillPoints
+ if (side < 0) {
+ // Left side: facade from sidewalk edge to screen left
+ // Near wall x = xNearBottom (at bottom) to xNearTop (at top)
+ // Fill screen left (x=0) to wall face
+ const pts = [
+ { x: 0, y: topY },
+ { x: xFarTop, y: topY },
+ { x: xFarBottom, y: horizY },
+ { x: xNearBottom, y: yNearBottom },
+ { x: 0, y: yNearBottom },
+ ];
+ gfx.fillPoints(pts.map(p => new Phaser.Geom.Point(p.x, p.y)), true);
+ } else {
+ // Right side
+ const pts = [
+ { x: xFarTop, y: topY },
+ { x: W, y: topY },
+ { x: W, y: yNearBottom },
+ { x: xNearBottom, y: yNearBottom },
+ { x: xFarBottom, y: horizY },
+ ];
+ gfx.fillPoints(pts.map(p => new Phaser.Geom.Point(p.x, p.y)), true);
+ }
- // road
- gfx.fillStyle(alt ? roadColA : roadColB);
- gfx.fillRect(cx - roadPx, sy, 2 * roadPx, 2);
+ // Windows — draw a grid on the facade for the nearest segment
+ if (pNearB.visible && pNearB.scale > 0.015) {
+ this._drawWindows(gfx, bldg.win, side, xNearBottom, yNearBottom, pNearB.scale, H);
+ }
+ }
+ }
+ }
- // center dashes
- if (alt === 0) {
- gfx.fillStyle(dashCol);
- gfx.fillRect(cx - 2, sy, 4, 2);
+ _drawWindows(gfx, winCol, side, wallX, wallBottomY, scale, H) {
+ const cols = 4, rows = 6;
+ const winW = Math.max(2, scale * 220 * (H / 2));
+ const winH = Math.max(2, scale * 260 * (H / 2));
+ const gapX = winW * 1.8, gapY = winH * 1.9;
+ const panelW = cols * gapX;
+ const panelH = rows * gapY;
+ const startX = side < 0 ? wallX - panelW - winW : wallX + winW;
+ const startY = wallBottomY - panelH - winH;
+
+ gfx.fillStyle(winCol, 0.75);
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ const wx = startX + c * gapX;
+ const wy = startY + r * gapY;
+ if (Math.random() > 0.25) {
+ gfx.fillRect(wx, wy, winW, winH);
+ }
}
}
}
- // ── World-object update & projection ──────────────────────────────────────
+ _projPivot(wx, wy, wz, W, H, horizY, sinP, cosP) {
+ // Apply pivot rotation to world x (swings nearer objects more)
+ const dz = wz - this.camZ;
+ if (dz <= 0) return { visible: false, x: W / 2, y: horizY, scale: 0 };
+ const scale = CAM_DEPTH / dz;
+ const sway = sinP * (H * 0.35) * (1 / (1 + dz / 4000));
+ const cx = W / 2 + sway;
+ const scaleX = cosP;
+ const sx = cx + scale * wx * (W / 2) * scaleX;
+ const sy = horizY + scale * (CAM_Y_WORLD - wy) * (H / 2);
+ return { x: sx, y: sy, scale, visible: true };
+ }
+
+ // ── World-object update ────────────────────────────────────────────────────
_proj(wx, wy, wz) {
return project(
this.scale.width, this.scale.height,
this.camZ, CAM_Y_WORLD, CAM_DEPTH, this.scale.height * HORIZON_FRAC,
- wx, wy, wz, this.turnShift
+ wx, wy, wz, 0
);
}
_updateWorld(W, H, horizY, dt, time) {
- const bldgKeys = ['bldg0', 'bldg1', 'bldg2', 'bldg3'];
- const maxDz = DRAW_SEGS * SEG_LEN;
-
- // Buildings
- this.bldgs.forEach(b => {
- const dz = b.z - this.camZ;
- if (dz < SEG_LEN) {
- b.z = this.camZ + Phaser.Math.Between(8000, 20000);
- b.key = Phaser.Math.RND.pick(bldgKeys);
- b.sp.setTexture(b.key);
- }
- if (dz > maxDz) { b.sp.setVisible(false); return; }
- const { x, y, scale, visible } = this._proj(b.side * BLDG_X_W, 0, b.z);
- if (!visible) { b.sp.setVisible(false); return; }
- const texH = this._texH(b.key);
- const sc = scale * BLDG_H_W * (H / 2) / texH;
- b.sp.setVisible(sc > 0.05).setPosition(x, y - texH * sc * 0.5).setScale(sc).setDepth(3 + 1 / (dz + 1));
- });
+ const maxDz = DRAW_SEGS * SEG_LEN;
// Pedestrians
this.peds.forEach(p => {
@@ -406,7 +532,7 @@ class FireTruckScene extends Phaser.Scene {
const { x, y, scale, visible } = this._proj(p.side * PED_X_W + wobble * 200, 0, p.z);
if (!visible) { p.sp.setVisible(false); return; }
const sc = scale * PED_H_W * (H / 2) / 36;
- p.sp.setVisible(sc > 0.04).setPosition(x, y - 18 * sc).setScale(sc).setDepth(4 + 1 / (dz + 1));
+ p.sp.setVisible(sc > 0.04).setPosition(x, y - 18 * sc).setScale(sc).setDepth(6 + 1 / (dz + 1));
});
// Traffic lights
@@ -423,40 +549,43 @@ class FireTruckScene extends Phaser.Scene {
const { x, y, scale, visible } = this._proj(tl.side * TLIGHT_X_W, 0, tl.z);
if (!visible) { tl.sp.setVisible(false); return; }
const sc = scale * TLIGHT_H_W * (H / 2) / 100;
- tl.sp.setVisible(sc > 0.04).setPosition(x, y - 50 * sc).setScale(sc).setDepth(4 + 1 / (dz + 1));
+ tl.sp.setVisible(sc > 0.04).setPosition(x, y - 50 * sc).setScale(sc).setDepth(6 + 1 / (dz + 1));
});
- // Fire (at final destination)
+ // Fire (on building wall at final destination)
if (this.route.length > 0) {
const dest = this.route[this.route.length - 1];
const dz = dest.z - this.camZ;
if (dz > 0 && dz < maxDz) {
- const { x, y, scale } = this._proj(0, 0, dest.z);
- const sc = scale * FIRE_H_W * (H / 2) / 60;
- const flicker = 1 + 0.06 * Math.sin(Date.now() / 150);
- this.fireSprite.setVisible(sc > 0.05)
- .setPosition(x, y - 30 * sc)
+ const fireWx = this.fireSide * (SIDEW_HALF_W - 300);
+ const { x, y, scale } = this._proj(fireWx, FIRE_H_W * 1.5, dest.z);
+ const sc = scale * FIRE_H_W * (H / 2) / 60;
+ const flicker = 1 + 0.07 * Math.sin(Date.now() / 140);
+ const glowSc = sc * flicker * 2.2;
+ this.fireGlow.setVisible(sc > 0.04)
+ .setPosition(x, y)
+ .setDisplaySize(60 * glowSc, 60 * glowSc);
+ this.fireSprite.setVisible(sc > 0.04)
+ .setPosition(x, y - 20 * sc)
.setScale(sc * flicker)
- .setDepth(5);
+ .setDepth(7);
} else {
+ this.fireGlow.setVisible(false);
this.fireSprite.setVisible(false);
}
}
}
- _texH(key) {
- return { bldg0: 120, bldg1: 160, bldg2: 100, bldg3: 140 }[key] || 120;
- }
-
// ── Intersection logic ─────────────────────────────────────────────────────
_checkIntersection(W, H) {
if (this.routeIdx >= this.route.length) return;
const next = this.route[this.routeIdx];
const dist = next.z - this.camZ;
+ const isLast = this.routeIdx === this.route.length - 1;
if (dist <= ARRIVE_DIST) {
- if (this.routeIdx === this.route.length - 1) {
+ if (isLast) {
this._onArrived();
} else {
this._hideArrow();
@@ -464,26 +593,49 @@ class FireTruckScene extends Phaser.Scene {
this.state = 'driving';
}
} else if (dist < PROMPT_DIST) {
- this._showArrow(next.dir, W, H);
+ this._showArrow(next, W, H);
} else {
if (this.state === 'awaiting') this._hideArrow();
}
}
- _showArrow(dir, W, H) {
- if (this.state === 'awaiting') return; // already showing
+ _showArrow(entry, W, H) {
+ if (this.state === 'awaiting') return;
this.state = 'awaiting';
- const key = dir === 'left' ? 'arrow_left' : dir === 'right' ? 'arrow_right' : 'arrow_up';
- const label = dir === 'left' ? '← Turn Left' : dir === 'right' ? 'Turn Right →' : '↑ Straight';
- this.arrowBg.setVisible(true).setPosition(W / 2, H * 0.3);
- this.arrowSprite.setTexture(key).setVisible(true).setPosition(W / 2, H * 0.27);
- this.arrowText.setText(label).setVisible(true).setPosition(W / 2, H * 0.41);
+
+ const isT = entry.kind === 't';
+
+ if (isT) {
+ // Show both arrows; highlight the correct one
+ this.arrowBg.setVisible(true).setPosition(W / 2, H * 0.3).setSize(160, 130);
+ this.arrowMain.setVisible(false);
+ this.arrowLeft.setVisible(true).setPosition(W / 2 - 44, H * 0.27)
+ .setAlpha(entry.dir === 'left' ? 1.0 : 0.3);
+ this.arrowRight.setVisible(true).setPosition(W / 2 + 44, H * 0.27)
+ .setAlpha(entry.dir === 'right' ? 1.0 : 0.3);
+ this.arrowText.setText('T-intersection!').setVisible(true).setPosition(W / 2, H * 0.41);
+ if (this.instrText.active) {
+ this.instrText.setText('Left or right — no straight!');
+ this.time.delayedCall(2000, () => { if (this.instrText.active) this.instrText.setText(''); });
+ }
+ } else {
+ // 4-way: single arrow
+ const key = entry.dir === 'left' ? 'arrow_left' : entry.dir === 'right' ? 'arrow_right' : 'arrow_up';
+ const label = entry.dir === 'left' ? '← Turn Left' : entry.dir === 'right' ? 'Turn Right →' : '↑ Straight';
+ this.arrowBg.setVisible(true).setPosition(W / 2, H * 0.3).setSize(120, 120);
+ this.arrowLeft.setVisible(false);
+ this.arrowRight.setVisible(false);
+ this.arrowMain.setTexture(key).setVisible(true).setPosition(W / 2, H * 0.27);
+ this.arrowText.setText(label).setVisible(true).setPosition(W / 2, H * 0.41);
+ }
}
_hideArrow() {
this.state = 'driving';
this.arrowBg.setVisible(false);
- this.arrowSprite.setVisible(false);
+ this.arrowMain.setVisible(false);
+ this.arrowLeft.setVisible(false);
+ this.arrowRight.setVisible(false);
this.arrowText.setVisible(false);
}
@@ -497,6 +649,12 @@ class FireTruckScene extends Phaser.Scene {
if (k === 's' || k === 'S') { this.toggleSiren(); return; }
if (k === 'b' || k === 'B') { this.ringBell(); return; }
+ // Spacebar sprays water at the fire
+ if (this.state === 'arrived' && (k === ' ' || k === 'Spacebar')) {
+ this._sprayAndAdvance();
+ return;
+ }
+
if (this.state !== 'awaiting') return;
const next = this.route[this.routeIdx];
@@ -508,6 +666,12 @@ class FireTruckScene extends Phaser.Scene {
if (k === 'ArrowUp') pressed = 'straight';
if (!pressed) return;
+ // T-intersection: straight is always wrong
+ if (next.kind === 't' && pressed === 'straight') {
+ this._onWrongTurn();
+ return;
+ }
+
if (pressed === next.dir) {
this._onCorrectTurn(next.dir);
} else {
@@ -520,16 +684,39 @@ class FireTruckScene extends Phaser.Scene {
this.state = 'turning';
this.playTurnSound();
- const shift = dir === 'left' ? 140 : dir === 'right' ? -140 : 0;
+ // Pivot angle: negative = swing left, positive = swing right
+ const targetAngle = dir === 'left' ? -Math.PI / 2 : dir === 'right' ? Math.PI / 2 : 0;
+
+ if (targetAngle === 0) {
+ // Straight: brief forward surge, no pivot needed
+ this.routeIdx++;
+ this.state = 'driving';
+ return;
+ }
+
+ // Phase 1: swing into the turn
this.tweens.add({
targets: this,
- turnShift: shift,
- duration: 280,
- ease: 'Quad.InOut',
+ turnAngle: targetAngle,
+ duration: TURN_DURATION_MS * 0.6,
+ ease: 'Quad.In',
onComplete: () => {
- this.turnShift = 0;
- this.routeIdx++;
- this.state = 'driving';
+ // Snap: advance past intersection, reset pivot
+ const next = this.route[this.routeIdx];
+ if (next) this.camZ = next.z + ARRIVE_DIST + 100;
+ this.turnAngle = -targetAngle * 0.45; // come from opposite side
+ // Phase 2: settle back to straight
+ this.tweens.add({
+ targets: this,
+ turnAngle: 0,
+ duration: TURN_DURATION_MS * 0.4,
+ ease: 'Quad.Out',
+ onComplete: () => {
+ this.turnAngle = 0;
+ this.routeIdx++;
+ this.state = 'driving';
+ },
+ });
},
});
}
@@ -546,13 +733,20 @@ class FireTruckScene extends Phaser.Scene {
_onArrived() {
this.state = 'arrived';
this._hideArrow();
- this.instrText.setText('You made it! Putting out the fire!');
+ this.instrText.setText('🔥 Press SPACE to spray water!');
+ this.speak('Fire! Press space to spray water!');
+ }
+
+ _sprayAndAdvance() {
+ // Prevent double-firing
+ this.state = 'spraying';
+ this.instrText.setText('Putting out the fire!');
this.playSuccess();
this.playSuccessJingle();
this._burstConfetti();
this._sprayWater();
- this.time.delayedCall(3000, () => {
+ this.time.delayedCall(2500, () => {
this.round++;
this.newRound();
});
@@ -561,13 +755,9 @@ class FireTruckScene extends Phaser.Scene {
_burstConfetti() {
const { width: W, height: H } = this.scale;
const em = this.add.particles(W / 2, H * 0.4, 'sq', {
- speed: { min: 120, max: 380 },
- angle: { min: 0, max: 360 },
- scale: { start: 1.1, end: 0 },
- gravityY: 450,
- lifespan: 950,
- tint: TINTS,
- emitting: false,
+ speed: { min: 120, max: 380 }, angle: { min: 0, max: 360 },
+ scale: { start: 1.1, end: 0 }, gravityY: 450,
+ lifespan: 950, tint: TINTS, emitting: false,
});
em.explode(55);
this.time.delayedCall(1200, () => { if (em.active) em.destroy(); });
@@ -575,19 +765,20 @@ class FireTruckScene extends Phaser.Scene {
_sprayWater() {
const { width: W, height: H } = this.scale;
+ // Spray toward the fire side
+ const fireScreenX = this.fireSprite.visible ? this.fireSprite.x : W / 2;
+ const angleCenter = fireScreenX < W / 2 ? 220 : 320;
const em = this.add.particles(W / 2, H * 0.35, 'sq', {
- speed: { min: 60, max: 200 },
- angle: { min: 250, max: 290 },
- scale: { start: 0.9, end: 0 },
- gravityY: 130,
- lifespan: 700,
- tint: [0x1D7CF2, 0x55AAFF, 0xAADDFF],
- frequency: 35,
- quantity: 3,
+ speed: { min: 80, max: 240 },
+ angle: { min: angleCenter - 25, max: angleCenter + 25 },
+ scale: { start: 0.9, end: 0 }, gravityY: 130,
+ lifespan: 700, tint: [0x1D7CF2, 0x55AAFF, 0xAADDFF],
+ frequency: 30, quantity: 4,
});
- this.time.delayedCall(2000, () => {
+ this.time.delayedCall(1800, () => {
em.stop();
this.fireSprite.setVisible(false);
+ this.fireGlow.setVisible(false);
this.time.delayedCall(400, () => { if (em.active) em.destroy(); });
});
}
@@ -597,11 +788,16 @@ class FireTruckScene extends Phaser.Scene {
newRound() {
this.camZ = 0;
this.routeIdx = 0;
- this.turnShift = 0;
+ this.turnAngle = 0;
this.state = 'driving';
this.route = buildRoute(ROUTE_LEN, INTERSECTION_GAP, Phaser.Math.RND);
+ // Pick fire side from the last route entry direction
+ const last = this.route[this.route.length - 1];
+ this.fireSide = last.dir === 'left' ? -1 : 1;
+
this.fireSprite.setVisible(false).setScale(0);
+ this.fireGlow.setVisible(false);
this._hideArrow();
this.instrText.setText('Follow the arrows to the fire!');
this.roundText.setText('Round ' + this.round);
@@ -618,9 +814,10 @@ class FireTruckScene extends Phaser.Scene {
this.groundRect.setPosition(W / 2, H * HORIZON_FRAC).setSize(W, H);
this._sizeToScreen(W, H);
- const horizY = H * HORIZON_FRAC;
this.arrowBg.setPosition(W / 2, H * 0.3);
- this.arrowSprite.setPosition(W / 2, H * 0.27);
+ this.arrowMain.setPosition(W / 2, H * 0.27);
+ this.arrowLeft.setPosition(W / 2 - 44, H * 0.27);
+ this.arrowRight.setPosition(W / 2 + 44, H * 0.27);
this.arrowText.setPosition(W / 2, H * 0.41);
this.instrText.setPosition(W / 2, H * 0.07);
}
@@ -630,7 +827,6 @@ class FireTruckScene extends Phaser.Scene {
toggleLights() {
this.lightsOn = !this.lightsOn;
document.getElementById('btn-lights').classList.toggle('active', this.lightsOn);
-
if (this.lightsOn) {
this.lightBarL.setVisible(true);
this.lightBarR.setVisible(true);
@@ -661,13 +857,10 @@ class FireTruckScene extends Phaser.Scene {
const ctx = this.getAudioCtx();
this.sirenGain = ctx.createGain(); this.sirenGain.gain.value = 0.3;
this.sirenGain.connect(ctx.destination);
-
this.sirenOsc1 = ctx.createOscillator(); this.sirenOsc1.type = 'sawtooth';
this.sirenOsc1.frequency.value = 770; this.sirenOsc1.connect(this.sirenGain); this.sirenOsc1.start();
-
this.sirenOsc2 = ctx.createOscillator(); this.sirenOsc2.type = 'sawtooth';
this.sirenOsc2.frequency.value = 570; this.sirenOsc2.connect(this.sirenGain); this.sirenOsc2.start();
-
let hi = true;
this.sirenInterval = setInterval(() => {
if (!this.sirenOsc1) return;
@@ -695,7 +888,7 @@ class FireTruckScene extends Phaser.Scene {
playBell() {
if (this.sfxMuted) return;
try {
- const ctx = this.getAudioCtx();
+ const ctx = this.getAudioCtx();
[1046, 1318, 1568].forEach((freq, i) => {
const osc = ctx.createOscillator(); const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine';
diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js
@@ -2,21 +2,6 @@
// Browser: exposed as globals. Node: CommonJS exports.
// ── Projection ────────────────────────────────────────────────────────────────
-//
-// Standard Outrun-style perspective projection.
-//
-// W, H – canvas dimensions (px)
-// camZ – camera Z position in world units
-// camYWorld – camera height above ground in world units (e.g. 1500)
-// camDepth – 1/tan(fov/2) where fov≈100° ⟹ ~0.84
-// horizonY – screen Y of the vanishing line (px); typically H * HORIZON_FRAC
-// wx, wy, wz – world-space position of the point to project
-// turnShift – extra horizontal pixel offset (used during turn animation)
-//
-// Returns { x, y, scale, visible }
-// x, y – screen pixel position
-// scale – CAM_DEPTH / dz, used to size sprites
-// visible – false when dz ≤ 0 (behind camera)
function project(W, H, camZ, camYWorld, camDepth, horizonY, wx, wy, wz, turnShift) {
const dz = wz - camZ;
@@ -25,27 +10,28 @@ function project(W, H, camZ, camYWorld, camDepth, horizonY, wx, wy, wz, turnShif
}
const scale = camDepth / dz;
const sx = W / 2 + scale * wx * (W / 2) + (turnShift || 0);
- // Ground-level points (wy=0) project BELOW horizonY; offset shrinks as dz→∞
const sy = horizonY + scale * (camYWorld - wy) * (H / 2);
return { x: sx, y: sy, scale, visible: true };
}
// ── Route generator ───────────────────────────────────────────────────────────
//
-// routeLen – number of intersections (last one = fire destination)
-// gap – world-unit spacing between intersections
-// rng – optional object with .pick(array) method; falls back to Math.random
-//
-// Returns an array of { z, dir } objects with strictly-increasing Z values.
+// Returns array of { z, dir, kind } where:
+// kind – 'four' (L/R/straight allowed) or 't' (only L/R allowed)
+// dir – correct direction at this intersection
function buildRoute(routeLen, gap, rng) {
- const dirs = ['left', 'right', 'straight'];
- const route = [];
+ const lrDirs = ['left', 'right'];
+ const allDirs = ['left', 'right', 'straight'];
+ const route = [];
for (let i = 0; i < routeLen; i++) {
- const dir = rng
- ? rng.pick(dirs)
- : dirs[Math.floor(Math.random() * dirs.length)];
- route.push({ z: (i + 1) * gap, dir });
+ const isLast = i === routeLen - 1;
+ // Last intersection is always T (fire is on a corner building)
+ const kind = isLast || (rng ? rng.pick([true, false, false]) : Math.random() < 0.4)
+ ? 't' : 'four';
+ const dirs = kind === 't' ? lrDirs : allDirs;
+ const dir = rng ? rng.pick(dirs) : dirs[Math.floor(Math.random() * dirs.length)];
+ route.push({ z: (i + 1) * gap, dir, kind });
}
return route;
}
diff --git a/tests/unit/route.test.js b/tests/unit/route.test.js
@@ -43,11 +43,12 @@ describe('buildRoute()', () => {
assert.deepEqual(a, b);
});
- it('each entry has exactly z and dir properties', () => {
+ it('each entry has z, dir, and kind properties', () => {
const r = buildRoute(3, 4000, fixedRng);
r.forEach((seg, i) => {
const keys = Object.keys(seg).sort();
- assert.deepEqual(keys, ['dir', 'z'], `r[${i}] keys: ${keys}`);
+ assert.deepEqual(keys, ['dir', 'kind', 'z'], `r[${i}] keys: ${keys}`);
+ assert.ok(['four', 't'].includes(seg.kind), `r[${i}] kind invalid: ${seg.kind}`);
});
});
});