Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig f88b38a49c Fix kid teleportation bug by adding horizontal span guard to compartment clamp
The previous clamp logic only bounded localY (vertical) after a radius check, which couldn't distinguish between:
1. A kid that tunneled through the floor/wall (should be clamped back)
2. A kid resting on nearby ground below the bus chassis (should NOT be clamped)

Since the bus floats ~chassis-height above ground, an outside kid sitting on the ground would have localY below the floor line and get yanked up into the bus every tick until it exited compartmentClampRadius.

Added a horizontal span check that only clamps kids whose |localX| is within the actual compartment extent (half-width + wall thickness + kid radius). This ensures:
- Kids that tunneled through floor/walls are still corrected (they're inside the span when they cross it)
- Kids outside the bus's horizontal footprint are left alone, preventing the "ejected kid teleported to bus" bug

Also disabled COMPARTMENT_DEBUG mode in config.js.
2026-08-22 12:15:52 -06:00
Brian Fertig 499d82d4d4 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.
2026-08-22 11:47:11 -06:00
4 changed files with 139 additions and 7 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. // Set true to show live g-force / debug readouts during PlayScene.
export const DEBUG = false; 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 = false;
export const STORAGE_KEY = 'monsterplex.progress.v1'; export const STORAGE_KEY = 'monsterplex.progress.v1';
// Matter runs Phaser's default fixed 60Hz step. // Matter runs Phaser's default fixed 60Hz step.

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser'; 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 { 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';
@ -13,6 +13,7 @@ import { stopMenuMusic } from '../util/music.js';
import { playLevelVoiceLine } from '../util/voiceLine.js'; import { playLevelVoiceLine } from '../util/voiceLine.js';
import { formatTime } from '../util/formatTime.js'; import { formatTime } from '../util/formatTime.js';
import { createPill } from '../util/pill.js'; import { createPill } from '../util/pill.js';
import buildCompartmentDebug from '../util/compartmentDebug.js';
export default class PlayScene extends Phaser.Scene { export default class PlayScene extends Phaser.Scene {
constructor() { constructor() {
@ -41,6 +42,11 @@ export default class PlayScene extends Phaser.Scene {
this.kidManager = new KidManager(this, this.bus, this.level.kidsAboard); 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.inputController = new InputController(this);
this.engineSound = new EngineSound(this, this.bus); 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 // Matter.World#shutdown() already calls removeAllListeners() on itself
// internally (verified against the vendored source), so this was always // internally (verified against the vendored source), so this was always
// redundant, not just now-unsafe. // redundant, not just now-unsafe.
if (this.compartmentDebug) this.compartmentDebug.destroy();
if (this.gForceMonitor) this.gForceMonitor.destroy(); if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy(); if (this.kidManager) this.kidManager.destroy();
if (this.engineSound) this.engineSound.destroy(); if (this.engineSound) this.engineSound.destroy();

View File

@ -162,21 +162,45 @@ export default class KidManager {
// technique as Bus.js's wheel clamp). Never clamps the top - that's the // technique as Bus.js's wheel clamp). Never clamps the top - that's the
// one side deliberately left open (the compartment has no ceiling, see // one side deliberately left open (the compartment has no ceiling, see
// Bus.js/_buildCompartment and config.js). // Bus.js/_buildCompartment and config.js).
//
// Only a kid actually within the compartment's horizontal span is
// clamped at all - the radius pre-check below (distance to the chassis
// center) alone can't tell "just tunneled through the floor" apart from
// "resting on the ground somewhere else in the level at roughly the
// bus's height": the chassis floats ~a chassis-height above the ground,
// so a kid sitting on nearby ground ends up with localY BELOW the floor
// line and would be yanked up into the bus every tick until the bus
// drove out of compartmentClampRadius (the "ejected kid teleported to
// the bus" bug). A kid that tunneled through the floor/wall is, by
// definition, inside the span when it crossed it, so bounding BOTH axes
// keeps the tunnel guard working while leaving outside kids alone.
_clampToCompartment(kid, chassis, cos, sin) { _clampToCompartment(kid, chassis, cos, sin) {
const dx = kid.image.x - chassis.x; const dx = kid.image.x - chassis.x;
const dy = kid.image.y - chassis.y; const dy = kid.image.y - chassis.y;
// A kid genuinely outside compartmentClampRadius was never inside the // A kid genuinely outside compartmentClampRadius was never inside the
// compartment this tick - skip it entirely, rather than let the below // compartment this tick - a cheap early-out that also bounds the
// checks (which only bound ONE axis each) treat "far away in the other // span check below (a local-frame coordinate alone can't tell "far
// axis" as a boundary violation. Without this, a kid resting anywhere // away in the other axis" apart from "at the boundary", so the radius
// else in the level could get yanked toward wherever the bus currently // check is still needed even with the span bound).
// is, since local-frame coordinates alone don't distinguish "just
// tunneled through" from "elsewhere entirely."
if (dx * dx + dy * dy > BUS.compartmentClampRadius * BUS.compartmentClampRadius) return; if (dx * dx + dy * dy > BUS.compartmentClampRadius * BUS.compartmentClampRadius) return;
let localX = dx * cos + dy * sin; let localX = dx * cos + dy * sin;
let localY = -dx * sin + dy * cos; let localY = -dx * sin + dy * cos;
// The actual span guard: a kid is only clamped while its circle can
// still overlap a compartment fixture - the floor reaches
// +/-compartmentHalfWidth, the walls out to
// +/-compartmentHalfWidth + wallThickness/2 - expanded by the kid's
// radius, beyond which it overlaps neither and so has nothing to
// tunnel back out of. A kid that tunneled through the floor/wall is,
// by definition, inside that extent when it crossed it, so the tunnel
// guard keeps working while a kid resting on the ground outside the
// bus (localY below the floor line but |localX| past this) is left
// alone instead of yanked up into the bus every tick.
const spanExtent = BUS.compartmentHalfWidth + BUS.compartmentWallThickness / 2 + KID.radius;
if (Math.abs(localX) > spanExtent) return;
let hitFloor = false; let hitFloor = false;
let hitLeftWall = false; let hitLeftWall = false;
let hitRightWall = false; let hitRightWall = false;

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()); } };
}