Add compartment debug overlay for kid ejection troubleshooting

Introduce `COMPARTMENT_DEBUG` config flag and a new `compartmentDebug.js` utility that renders the invisible bus compartment (floor, left/right walls, and open-top boundary) as colored rectangles tracking the chassis each frame. Includes per-kid position markers color-coded by state (aboard/ejected) and a live HUD readout showing local coordinates and ejection timing to diagnose why kids eject unexpectedly.
This commit is contained in:
Brian Fertig 2026-08-22 11:47:11 -06:00
parent 2e4b67db89
commit 499d82d4d4
3 changed files with 109 additions and 1 deletions

View File

@ -9,6 +9,12 @@ export const GAME_HEIGHT = 540 * WORLD_SCALE;
// Set true to show live g-force / debug readouts during PlayScene.
export const DEBUG = false;
// Set true to render the invisible kid compartment (floor + left/right
// walls) as simple colored rectangles that track the chassis, plus a live
// per-kid local-position/state readout in the PlayScene HUD. Troubleshooting
// aid for ejection behavior - off during normal play.
export const COMPARTMENT_DEBUG = true;
export const STORAGE_KEY = 'monsterplex.progress.v1';
// Matter runs Phaser's default fixed 60Hz step.

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE } from '../config.js';
import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE, COMPARTMENT_DEBUG } from '../config.js';
import { getLevelById } from '../data/levels/index.js';
import Bus from '../entities/Bus.js';
import Terrain from '../entities/Terrain.js';
@ -13,6 +13,7 @@ import { stopMenuMusic } from '../util/music.js';
import { playLevelVoiceLine } from '../util/voiceLine.js';
import { formatTime } from '../util/formatTime.js';
import { createPill } from '../util/pill.js';
import buildCompartmentDebug from '../util/compartmentDebug.js';
export default class PlayScene extends Phaser.Scene {
constructor() {
@ -41,6 +42,11 @@ export default class PlayScene extends Phaser.Scene {
this.kidManager = new KidManager(this, this.bus, this.level.kidsAboard);
// Troubleshooting overlay: renders the invisible compartment fixtures
// (floor + walls) as simple rectangles tracking the bus, plus per-kid
// state readout. Off by default - toggle COMPARTMENT_DEBUG in config.js.
this.compartmentDebug = COMPARTMENT_DEBUG ? buildCompartmentDebug(this, this.bus, this.kidManager) : null;
this.inputController = new InputController(this);
this.engineSound = new EngineSound(this, this.bus);
@ -320,6 +326,7 @@ export default class PlayScene extends Phaser.Scene {
// Matter.World#shutdown() already calls removeAllListeners() on itself
// internally (verified against the vendored source), so this was always
// redundant, not just now-unsafe.
if (this.compartmentDebug) this.compartmentDebug.destroy();
if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy();
if (this.engineSound) this.engineSound.destroy();

View File

@ -0,0 +1,95 @@
import Phaser from 'phaser';
import { BUS, KID, WORLD_SCALE } from '../config.js';
// Debug-only visualisation of the invisible kid compartment (see
// Bus._buildCompartment): one simple colored rectangle per fixture
// (floor + left wall + right wall) tracking the chassis every frame, plus
// the open-top line and one marker per kid showing its state. Used for
// troubleshooting ejection behavior (does the kid visually leave through
// the open top before KidManager latches it? where does it sit relative to
// the floor/walls while aboard?).
//
// Rectangles are plain Phaser.GameObjects.Rectangle (rendering only - they
// are NOT physics bodies, deliberately, so they can't interfere with the
// very collision behavior being observed).
export default function buildCompartmentDebug(scene, bus, kidManager) {
const depth = 5;
const mkRect = (w, h, color, alpha) =>
scene.add.rectangle(0, 0, w, h, color, alpha).setOrigin(0.5).setDepth(depth);
const floor = mkRect(BUS.compartmentHalfWidth * 2, BUS.compartmentFloorThickness, 0x00e676, 0.75);
const leftWall = mkRect(BUS.compartmentWallThickness, (BUS.compartmentFloorY - BUS.compartmentTopY) + BUS.compartmentFloorThickness, 0x40c4ff, 0.75);
const rightWall = mkRect(BUS.compartmentWallThickness, (BUS.compartmentFloorY - BUS.compartmentTopY) + BUS.compartmentFloorThickness, 0xff5252, 0.75);
// The open-top boundary (BUS.compartmentTopY) is where the aboard/ejected
// transition happens - drawn as a thin dashed-style line so "crossing
// this line" is visible in gameplay.
const topLine = mkRect(BUS.compartmentHalfWidth * 2 + BUS.compartmentWallThickness * 2, 2 * WORLD_SCALE, 0xffeb3b, 0.9);
// One marker per kid: small rectangle at the kid's position, color-coded
// by state (green = aboard, red = ejected).
const kidMarkers = kidManager.kids.map((kid) => mkRect(KID.radius * 2 + 4 * WORLD_SCALE, KID.radius * 2 + 4 * WORLD_SCALE, 0xffffff, 0.0));
// HUD readout, same style as the other pills.
const label = scene.add.text(24 * WORLD_SCALE, 110 * WORLD_SCALE, '', {
fontFamily: 'monospace',
fontSize: `${13 * WORLD_SCALE}px`,
color: '#1a1f29',
}).setScrollFactor(0).setDepth(10);
const shapes = [floor, leftWall, rightWall, topLine];
function update() {
const chassis = bus.chassis;
const cos = Math.cos(chassis.rotation);
const sin = Math.sin(chassis.rotation);
const toWorld = (localX, localY) => ({
x: chassis.x + localX * cos - localY * sin,
y: chassis.y + localX * sin + localY * cos,
});
const f = toWorld(0, BUS.compartmentFloorY);
floor.setPosition(f.x, f.y);
floor.setAngle(chassis.rotation);
const wallHeight = (BUS.compartmentFloorY - BUS.compartmentTopY) + BUS.compartmentFloorThickness;
const wallCenterY = (BUS.compartmentFloorY + BUS.compartmentTopY) / 2;
const l = toWorld(-BUS.compartmentHalfWidth, wallCenterY);
leftWall.setPosition(l.x, l.y);
leftWall.setAngle(chassis.rotation);
const r = toWorld(BUS.compartmentHalfWidth, wallCenterY);
rightWall.setPosition(r.x, r.y);
rightWall.setAngle(chassis.rotation);
const t = toWorld(0, BUS.compartmentTopY);
topLine.setPosition(t.x, t.y);
topLine.setAngle(chassis.rotation);
let lines = [`floor: x${-BUS.compartmentHalfWidth}..${BUS.compartmentHalfWidth} y=${BUS.compartmentFloorY}`];
lines.push(`walls: x=${-BUS.compartmentHalfWidth} / x=${BUS.compartmentHalfWidth} top(open): y=${BUS.compartmentTopY}`);
for (let i = 0; i < kidManager.kids.length; i++) {
const kid = kidManager.kids[i];
const dx = kid.image.x - chassis.x;
const dy = kid.image.y - chassis.y;
const localX = dx * cos + dy * sin;
const localY = -dx * sin + dy * cos;
const marker = kidMarkers[i];
marker.setPosition(kid.image.x, kid.image.y);
marker.setFillStyle(kid.state === 'aboard' ? 0x00e676 : 0xff5252, 0.9);
marker.setSize(KID.radius * 2 + 4 * WORLD_SCALE, KID.radius * 2 + 4 * WORLD_SCALE);
lines.push(`kid${i}: ${kid.state} local=(${localX.toFixed(0)}, ${localY.toFixed(0)})${kid.state === 'ejected' && kid.ejectedAt !== null ? ` outFor=${((scene.time.now - kid.ejectedAt) / 1000).toFixed(1)}s` : ''}`);
}
label.setText(lines.join('\n'), { align: 'left' });
}
scene.events.on('update', update);
scene.events.once('shutdown', () => {
scene.events.off('update', update);
for (const s of shapes) s.destroy();
for (const m of kidMarkers) m.destroy();
label.destroy();
});
return { shapes, kidMarkers, label, destroy: () => { shapes.concat(kidMarkers).concat([label]).forEach((s) => s.destroy()); } };
}