Initial Commit

This commit is contained in:
Brian Fertig 2026-08-18 20:23:33 -06:00
commit 34960a2321
33 changed files with 270070 additions and 0 deletions

89
README.md Normal file
View File

@ -0,0 +1,89 @@
# Monsterplex
A Trials-style physics driving game starring a school bus, crossed with
Snuggle Truck: land badly enough and the g-force throws your passengers off.
Built with **Phaser 4**, plain **ES6 modules**, and **no package manager**.
## Running it
Browsers block ES module imports over `file://`, so serve the folder with any
static file server. No install needed - this ships with Python 3:
```sh
python3 -m http.server 8000
```
Then open `http://localhost:8000/`.
## Controls
- **Arrow Up / W** - throttle
- **Arrow Down / S** - brake / reverse
- **Arrow Left/Right / A/D** - lean the bus left/right
Land smoothly or the g-force throws a kid off the bus. Lose all of them and
the level fails.
## Project layout
```
index.html entry point, import map -> vendor/phaser.esm.js
vendor/phaser.esm.js vendored local copy of Phaser 4 (see below)
src/main.js Phaser.Game config + scene list
src/config.js every tunable constant (physics, g-force threshold, camera, ...)
src/scenes/ Boot -> Preload -> Intro -> MainMenu -> LevelSelect -> Play -> LevelComplete/LevelFailed
src/entities/ Bus, Kid, Terrain
src/systems/ GForceMonitor, KidManager, InputController, CameraRig
src/data/levels/ the 3 hand-authored levels
src/util/ asset manifest, placeholder-texture generator, localStorage progress, small UI helper
assets/ where real art goes (see manifest below) - currently empty
```
## Adding real art
No art assets exist yet - `src/util/assetManifest.js` lists every image the
game expects, and `PreloadScene` falls back to a synthesized colored
placeholder (via `src/util/placeholderTextures.js`) for anything missing.
**Drop a real PNG at the exact path below and it's picked up automatically -
no code changes needed.**
| key | path | size | notes |
|---|---|---|---|
| `bus_chassis` | `assets/sprites/bus_chassis.png` | 170x64 | scaled to this size regardless of source resolution |
| `bus_wheel` | `assets/sprites/bus_wheel.png` | 52x52 | used for both wheels |
| `kid_idle` | `assets/sprites/kid_idle.png` | 28x28 | passenger while aboard |
| `kid_ejected` | `assets/sprites/kid_ejected.png` | 28x28 | passenger after being thrown off |
| `bg_far` | `assets/backgrounds/bg_far.png` | 256x540 | tiled, slowest parallax layer |
| `bg_mid` | `assets/backgrounds/bg_mid.png` | 256x540 | tiled, mid parallax layer |
| `bg_near` | `assets/backgrounds/bg_near.png` | 256x540 | tiled, fastest parallax layer |
| `icon_kid` | `assets/ui/icon_kid.png` | 24x24 | HUD "kids aboard" icon |
| `favicon` | `assets/favicon.png` | 32x32 | browser tab icon |
## Tuning the feel
Everything worth tweaking lives in `src/config.js`, notably:
- `GFORCE.ejectThresholdG` - how hard a landing has to be before a kid is
thrown off. Not physically derived - tune by playtesting.
- `GFORCE.gracePeriodMs` / `ejectCooldownMs` - ignore-window after level
start/restart, and minimum time between two ejections so one bad landing
doesn't empty the whole bus at once.
- `BUS.*` - suspension stiffness/damping, wheel friction, throttle/lean torque
and their clamps.
Set `DEBUG = true` in `config.js` to show a live g-force readout and Matter's
physics debug overlay in `PlayScene`.
## Phaser 4 loading
Phaser is vendored locally at `vendor/phaser.esm.js` (downloaded once from
jsDelivr's CDN build) and wired up via an import map in `index.html`, so
every source file just does `import Phaser from 'phaser'` like a normal npm
project would - the only place that knows about the vendored file is that one
import map line. This keeps the project working fully offline with no
runtime dependency on a third-party CDN staying up.
To upgrade Phaser later: download a newer `phaser.esm.js` build over
`vendor/phaser.esm.js` and re-verify `Bus.js` against the Matter physics
Factory API in the new file (grep for `class Factory` under the Matter
section).

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

42
index.html Normal file
View File

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Monsterplex</title>
<link rel="icon" href="assets/favicon.png">
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #0b0e14;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
#game-container {
line-height: 0;
}
canvas {
max-width: 100vw;
max-height: 100vh;
}
</style>
<script type="importmap">
{
"imports": {
"phaser": "./vendor/phaser.esm.js"
}
}
</script>
</head>
<body>
<div id="game-container"></div>
<script type="module" src="./src/main.js"></script>
</body>
</html>

100
sprites.md Normal file
View File

@ -0,0 +1,100 @@
# Adding real art
Nothing in the code needs to change to swap in real art. `src/util/assetManifest.js`
lists every image the game expects, with an exact `path`. `PreloadScene` tries to
load each one; if the file doesn't exist (or fails to load), it falls back to a
synthesized colored placeholder shape instead. **Drop a real PNG at the exact path
below, refresh the page, and it's used automatically.**
## Where files go
All paths are relative to the project root (`/home/brianfertig/git/monsterplex/`).
The folders already exist and are currently empty.
| key | path | size used in-game | shape | notes |
|---|---|---|---|---|
| `bus_chassis` | `assets/sprites/bus_chassis.png` | 340 x 128 | rectangle | see "Bus" below |
| `bus_wheel` | `assets/sprites/bus_wheel.png` | 104 x 104 | circle | one image, used for both wheels |
| `kid_idle` | `assets/sprites/kid_idle.png` | 56 x 56 | circle | passenger while aboard the bus |
| `kid_ejected` | `assets/sprites/kid_ejected.png` | 56 x 56 | circle | passenger after being thrown off |
| `bg_far` | `assets/backgrounds/bg_far.png` | tiled, 512 x 1080 native | - | slowest-scrolling parallax layer (sky/distant) |
| `bg_mid` | `assets/backgrounds/bg_mid.png` | tiled, 512 x 1080 native | - | mid-speed parallax layer |
| `bg_near` | `assets/backgrounds/bg_near.png` | tiled, 512 x 1080 native | - | fastest-scrolling parallax layer (closest) |
| `icon_kid` | `assets/ui/icon_kid.png` | 48 x 48 | - | small HUD "kids aboard" icon, top-left |
| `favicon` | `assets/favicon.png` | 32 x 32 | - | browser tab icon - see caveat below |
"Size used in-game" is the size the game actually displays the image at
(`setDisplaySize`), **not** a requirement on the source file's resolution - see
"Resolution" below.
These numbers are the game's current 1920x1080 resolution (`WORLD_SCALE = 2`
in `src/config.js`) - if that changes again, every size in this table changes
with it (same ratios, just re-derive from that one constant).
## Bus
- `bus_chassis.png` and `bus_wheel.png`, transparent background (PNG alpha).
- **The bus always faces right** (nose/front toward positive x - the direction
it drives at level start) and there's no left/right flip when reversing, so
draw the chassis facing right.
- The chassis's rectangle *is* the physics hitbox (with rounded corners baked
in by the physics engine, not the art). Keep the bus's silhouette roughly
filling a 340x128 box so the visual matches where it actually collides.
- `bus_wheel.png` is used for **both** wheels (front and rear) - same image,
no separate left/right variant needed. It's a real physics body, so it
visually spins as the bus drives; a plain hubcap/circle reads better at
speed than fine detail.
- If you want a bus with different proportions (longer, taller, etc.) than
340x128, tell me the size you want art at - that number is also the physics
body size in `src/config.js` (`BUS.chassisWidth`/`chassisHeight`), so it
needs a matching code change, not just a differently-shaped image.
## Kids
- `kid_idle.png` (aboard) and `kid_ejected.png` (mid-air after being thrown
off) - two separate images, swapped automatically when a kid is ejected.
- Physics shape is a circle, but the *image* itself renders as a full square
at 56x56 - draw the character centered in a square canvas with transparent
padding around them rather than filling every corner, or they'll look like
they're poking out past their own collision circle.
- `kid_ejected` is a good place to show some "yikes" energy (arms out,
startled face, etc.) since it only appears for the ~2 seconds after a kid
gets thrown clear.
## Parallax backgrounds
- `bg_far` / `bg_mid` / `bg_near` are rendered as horizontally **tiling**
strips (`TileSprite`), not single stretched images - whatever you supply
repeats sideways as the camera scrolls.
- Make the **left and right edges match** so the seam is invisible when it
tiles (or design something patternable, like scattered clouds/hills on a
transparent background).
- far/mid/near are layered back-to-front and scroll at different speeds for
depth (far moves slowest, near moves fastest) - a plain sky for far, hills
or treeline silhouettes for mid, and closer foreground detail for near
works well.
- Native resolution isn't locked to 512x1080 the way the bus/kid sprites are -
a wider or taller source image just changes how often the tile repeats, so
use whatever gives a clean seamless loop.
## Favicon
- `favicon.png` is the one asset **without** a placeholder fallback for its
actual purpose - the browser tab icon comes from a plain `<link>` tag in
`index.html`, not from Phaser's loader, so until a real file exists at
`assets/favicon.png` the browser just shows its default icon (nothing
broken, just nothing shown).
## Resolution
Supply art at the "size used in-game" from the table above, or a clean
multiple of it (2x/3x) for a crisper look on high-DPI screens - everything
gets scaled to the table's size regardless of the source file's actual pixel
dimensions, so it won't come in stretched or huge, but a very different
aspect ratio than the target box will get squashed/stretched to fit.
## Testing
Just refresh the browser tab after dropping files into `assets/` - no build
step, no server restart needed (unless the static server itself isn't
running yet, in which case see `README.md`).

114
src/config.js Normal file
View File

@ -0,0 +1,114 @@
// Every spatial (pixel) constant in this file and in src/data/levels/*.js is
// multiplied by WORLD_SCALE, so the whole game - canvas, physics bodies,
// terrain, UI - resizes together. Change this one number to rescale it again.
export const WORLD_SCALE = 2; // 960x540 base design -> 1920x1080
export const GAME_WIDTH = 960 * WORLD_SCALE;
export const GAME_HEIGHT = 540 * WORLD_SCALE;
// Set true to show live g-force / debug readouts during PlayScene.
export const DEBUG = false;
export const STORAGE_KEY = 'monsterplex.progress.v1';
// Matter runs Phaser's default fixed 60Hz step.
export const PHYSICS_TIMESTEP_SECONDS = 1 / 60;
export const BUS = {
chassisWidth: 170 * WORLD_SCALE,
chassisHeight: 64 * WORLD_SCALE,
// Measured directly from assets/sprites/bus_chassis.png's baked-in wheel
// wells (it's a real photo bus - long rear overhang, short front hood, so
// the axles are NOT symmetric around the chassis center). Offsets are from
// the chassis center; re-measure these if bus_chassis.png is replaced.
wheelRadius: 17 * WORLD_SCALE,
rearWheelOffsetX: -35 * WORLD_SCALE,
frontWheelOffsetX: 64 * WORLD_SCALE,
// Where the wheel's CENTER rests at equilibrium - this is the measured
// wheel-well position itself, not an attachment point the wheel then
// hangs further below (that was the placeholder-art version's model,
// before there was real art dictating exactly where the wheel belongs).
wheelRestOffsetY: 22 * WORLD_SCALE,
// Horizontal spread between the two suspension links each wheel hangs
// from (a "wishbone") - a single point-to-point constraint can't resist
// sideways drift at all (it's radially symmetric), so each wheel is held
// by two constraints spread by this much, which resists side-to-side
// wander while still allowing the wheel to compress/extend vertically.
axleSpread: 6 * WORLD_SCALE,
maxSeats: 5,
// Density/friction/stiffness/damping are dimensionless ratios, not pixel
// distances - they don't scale with WORLD_SCALE.
chassisDensity: 0.0015,
chassisFriction: 0.4,
chassisFrictionAir: 0.01,
wheelDensity: 0.004,
wheelFriction: 0.9,
wheelFrictionStatic: 1.4,
// Lowered a lot from the original 0.02 - that was quietly eating most of
// the wheel's spin-up each second (it's a per-tick multiplicative decay,
// so 0.02 was burning off roughly a third of angular velocity per second)
// and was the main reason the bus felt sluggish rather than Trials-fast.
wheelFrictionAir: 0.008,
// Some bounce on landing, not just suspension absorbing everything.
wheelRestitution: 0.3,
// Softer and less damped than before - more travel, and lets it actually
// oscillate (bounce) instead of settling immediately - "more shocks."
suspensionStiffness: 0.45,
suspensionDamping: 0.08,
// How far the wheel can move up/down from wheelRestOffsetY. This will
// visibly pop the wheel outside its wheel well on a hard landing - that's
// the tradeoff for real suspension travel/bounce instead of a stiff ride.
suspensionTravel: 12 * WORLD_SCALE,
// Angular velocities/torque steps are rotational (radians), not linear
// pixel distances, so they're left unscaled - wheel radius already scaled,
// so linear surface speed (angularVelocity * radius) scales automatically.
// Pushed way up from the original values for Trials/Snuggle-Truck-style
// speed - the old numbers made the bus crawl.
maxWheelAngularVelocity: 5,
driveTorque: 0.6,
brakeTorque: 0.4,
maxBrakeAngularVelocity: 3.5,
// Bumped up to stay responsive at the new higher speed - sluggish lean
// control would feel wrong on a bus that's now moving quickly.
maxLeanAngularVelocity: 0.13,
leanTorqueStep: 0.018,
};
export const GFORCE = {
// Scaled with WORLD_SCALE so "meters" keeps the same real-world meaning
// relative to the bus, and ejectThresholdG doesn't need re-tuning.
pixelsPerMeter: 50 * WORLD_SCALE,
ejectThresholdG: 6, // primary tuning knob - not physically derived, tune by playtesting
gracePeriodMs: 500,
ejectImpulseMagnitude: 0.03 * WORLD_SCALE,
// A single hard landing can stay above threshold for several consecutive
// physics ticks; without a cooldown that would eject several kids from one
// impact instead of one. Debounces ejections to one per impact.
ejectCooldownMs: 400,
};
export const KID = {
radius: 14 * WORLD_SCALE,
density: 0.001,
friction: 0.6,
seatStiffness: 0.22,
seatDamping: 0.1,
seatLength: 4 * WORLD_SCALE,
settleTimeMs: 2000,
};
export const CAMERA = {
lerpX: 0.1,
lerpY: 0.1,
deadzoneWidth: 220 * WORLD_SCALE,
deadzoneHeight: 140 * WORLD_SCALE,
};
export const TERRAIN_DEPTH = 400 * WORLD_SCALE;

15
src/data/levels/index.js Normal file
View File

@ -0,0 +1,15 @@
import level01 from './level01.js';
import level02 from './level02.js';
import level03 from './level03.js';
export const LEVELS = [level01, level02, level03];
export function getLevelById(id) {
return LEVELS.find((level) => level.id === id);
}
export function getNextLevelId(id) {
const index = LEVELS.findIndex((level) => level.id === id);
if (index === -1 || index === LEVELS.length - 1) return null;
return LEVELS[index + 1].id;
}

View File

@ -0,0 +1,20 @@
import { generateWave } from './terrainHelpers.js';
import { WORLD_SCALE } from '../../config.js';
const END_X = 3600 * WORLD_SCALE;
const BASE_Y = 420 * WORLD_SCALE;
const points = generateWave(-200 * WORLD_SCALE, END_X, 80 * WORLD_SCALE, BASE_Y, 30 * WORLD_SCALE, 260 * WORLD_SCALE);
export default {
id: 'level01',
name: 'First Hills',
description: 'Gentle rolling hills - learn to drive and lean.',
kidsAboard: 5,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
startAngle: 0,
terrain: [{ points }],
obstacles: [],
goal: { x: END_X - 120 * WORLD_SCALE, y: BASE_Y - 120 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 320 * WORLD_SCALE },
cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE },
};

View File

@ -0,0 +1,24 @@
import { generateWave, withRamp } from './terrainHelpers.js';
import { WORLD_SCALE } from '../../config.js';
const RUNWAY_END_X = 1850 * WORLD_SCALE;
const LANDING_START_X = 2200 * WORLD_SCALE;
const END_X = 3900 * WORLD_SCALE;
const runway = generateWave(-200 * WORLD_SCALE, RUNWAY_END_X, 60 * WORLD_SCALE, 420 * WORLD_SCALE, 10 * WORLD_SCALE, 500 * WORLD_SCALE);
withRamp(runway, 1500 * WORLD_SCALE, RUNWAY_END_X, -170 * WORLD_SCALE); // launch ramp climbing up to the jump-off edge
const landing = generateWave(LANDING_START_X, END_X, 80 * WORLD_SCALE, 460 * WORLD_SCALE, 15 * WORLD_SCALE, 400 * WORLD_SCALE);
export default {
id: 'level02',
name: 'Big Air',
description: 'Clear the gap and land level, or the kids pay for it.',
kidsAboard: 3,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
startAngle: 0,
terrain: [{ points: runway }, { points: landing }],
obstacles: [],
goal: { x: END_X - 120 * WORLD_SCALE, y: 340 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 320 * WORLD_SCALE },
cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE },
};

View File

@ -0,0 +1,41 @@
import { WORLD_SCALE } from '../../config.js';
// Hand-tuned profile: flat approach -> steep valley drop -> steep climb out ->
// a long stretch of tight, fast bumps that punish sloppy lean control.
function buildPoints() {
const step = 60 * WORLD_SCALE;
const points = [];
for (let x = -200 * WORLD_SCALE; x <= 3900 * WORLD_SCALE; x += step) {
let y;
if (x < 1200 * WORLD_SCALE) {
y = 420 * WORLD_SCALE + Math.sin(x / (500 * WORLD_SCALE)) * 10 * WORLD_SCALE;
} else if (x < 1500 * WORLD_SCALE) {
y = 420 * WORLD_SCALE + 220 * WORLD_SCALE * ((x - 1200 * WORLD_SCALE) / (300 * WORLD_SCALE));
} else if (x < 1650 * WORLD_SCALE) {
y = 640 * WORLD_SCALE;
} else if (x < 1950 * WORLD_SCALE) {
y = 640 * WORLD_SCALE - 260 * WORLD_SCALE * ((x - 1650 * WORLD_SCALE) / (300 * WORLD_SCALE));
} else if (x < 3650 * WORLD_SCALE) {
y = 380 * WORLD_SCALE + Math.sin(x / (150 * WORLD_SCALE)) * 45 * WORLD_SCALE;
} else {
y = 380 * WORLD_SCALE;
}
points.push({ x, y: Math.round(y) });
}
return points;
}
const END_X = 3900 * WORLD_SCALE;
export default {
id: 'level03',
name: 'Tight Squeeze',
description: 'A steep valley and a long run of sharp bumps - lean early, lean often.',
kidsAboard: 4,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
startAngle: 0,
terrain: [{ points: buildPoints() }],
obstacles: [],
goal: { x: END_X - 120 * WORLD_SCALE, y: 260 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 400 * WORLD_SCALE },
cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE },
};

View File

@ -0,0 +1,31 @@
// Generates a hand-tunable wavy ground polyline: a human picks the
// amplitude/frequency knobs per level, this just avoids typing out every point.
export function generateWave(startX, endX, step, baseY, amplitude, frequency, phase = 0) {
const points = [];
for (let x = startX; x <= endX; x += step) {
const y = baseY + Math.sin(x / frequency + phase) * amplitude;
points.push({ x, y: Math.round(y) });
}
return points;
}
// Splices a straight ramp of the given rise (positive = downhill to the right)
// between two x positions into an existing point array, in place.
export function withRamp(points, fromX, toX, rise) {
const from = points.find((p) => p.x >= fromX) || points[points.length - 1];
const to = points.find((p) => p.x >= toX) || points[points.length - 1];
const fromIndex = points.indexOf(from);
const toIndex = points.indexOf(to);
if (toIndex <= fromIndex) return points;
const baseY = from.y;
for (let i = fromIndex; i <= toIndex; i++) {
const t = (points[i].x - from.x) / (to.x - from.x);
points[i].y = Math.round(baseY + rise * t);
}
// flatten everything after the ramp to the new height
const newY = points[toIndex].y;
for (let i = toIndex + 1; i < points.length; i++) {
points[i].y = newY;
}
return points;
}

138
src/entities/Bus.js Normal file
View File

@ -0,0 +1,138 @@
import Phaser from 'phaser';
import { BUS } from '../config.js';
// Builds the bus as a chassis + two wheel Matter Image bodies, each wheel
// held by a "wishbone" - two suspension constraints instead of one - rather
// than Phaser's built-in Factory.car() - car()'s axle constraints are rigid
// pins (stiffness 1, length 0, verified against the shipped Phaser 4 build)
// with no vertical give, which is too harsh for a bus that needs real
// suspension travel to absorb bumps.
export default class Bus {
constructor(scene, x, y, angle = 0) {
this.scene = scene;
const matter = scene.matter;
this.group = matter.world.nextGroup(true);
// The anchor sits suspensionTravel above where the wheel actually rests,
// so the spring pulling the wheel out to its full constraint length is
// what lands it exactly on wheelRestOffsetY (anchorY + travel = restY),
// while still leaving suspensionTravel of compress/droop room.
const anchorY = BUS.wheelRestOffsetY - BUS.suspensionTravel;
// The wheel doesn't hang straight down from a single point (that point
// is split into two, spread by axleSpread), so the constraint's target
// length is the hypotenuse to the spread anchor, not just the vertical
// drop - otherwise the wheel would spawn pulled taut at an angle instead
// of hanging level, snapping into place on the first physics step.
const constraintLength = Math.hypot(BUS.axleSpread, BUS.suspensionTravel);
this.chassis = matter.add.image(x, y, 'bus_chassis', null, {
shape: { type: 'rectangle', width: BUS.chassisWidth, height: BUS.chassisHeight },
chamfer: { radius: BUS.chassisHeight * 0.35 },
density: BUS.chassisDensity,
friction: BUS.chassisFriction,
frictionAir: BUS.chassisFrictionAir,
collisionFilter: { group: this.group },
});
this.chassis.setDisplaySize(BUS.chassisWidth, BUS.chassisHeight);
this.chassis.setAngle(Phaser.Math.RadToDeg(angle));
const wheelY = y + BUS.wheelRestOffsetY;
this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY);
this.wheelFront = this._createWheel(x + BUS.frontWheelOffsetX, wheelY);
this.wheelRearLinks = this._attachWishbone(this.wheelRear, BUS.rearWheelOffsetX, anchorY, constraintLength);
this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength);
this.seatOffsets = this._buildSeatOffsets();
}
// A single point-to-point constraint is radially symmetric - it can only
// resist the wheel drifting away in general, not sideways specifically.
// Two constraints anchored on either side of the true axle position (both
// to the same wheel center) form a narrow wishbone: moving the wheel
// sideways stretches one link and slackens the other, which the springs
// resist, while moving straight down lengthens both links symmetrically
// and is barely resisted - so the wheel is free to bob vertically but
// held from wandering left/right under its axle.
_attachWishbone(wheel, offsetX, anchorY, length) {
const matter = this.scene.matter;
const options = (anchorX) => ({
pointA: { x: anchorX, y: anchorY },
pointB: { x: 0, y: 0 },
damping: BUS.suspensionDamping,
});
return [
matter.add.constraint(this.chassis, wheel, length, BUS.suspensionStiffness, options(offsetX - BUS.axleSpread)),
matter.add.constraint(this.chassis, wheel, length, BUS.suspensionStiffness, options(offsetX + BUS.axleSpread)),
];
}
_createWheel(x, y) {
const wheel = this.scene.matter.add.image(x, y, 'bus_wheel', null, {
shape: { type: 'circle', radius: BUS.wheelRadius },
density: BUS.wheelDensity,
friction: BUS.wheelFriction,
frictionStatic: BUS.wheelFrictionStatic,
frictionAir: BUS.wheelFrictionAir,
restitution: BUS.wheelRestitution,
collisionFilter: { group: this.group },
});
wheel.setDisplaySize(BUS.wheelRadius * 2, BUS.wheelRadius * 2);
return wheel;
}
_buildSeatOffsets() {
const offsets = [];
const margin = BUS.chassisWidth * 0.32;
for (let i = 0; i < BUS.maxSeats; i++) {
const t = BUS.maxSeats === 1 ? 0 : (i / (BUS.maxSeats - 1)) * 2 - 1;
offsets.push({ x: t * margin, y: -BUS.chassisHeight * 0.1 });
}
return offsets;
}
// intent: { throttle: -1|0|1, leanLeft: bool, leanRight: bool }
applyIntent(intent) {
const wheelBody = this.wheelRear.body;
if (intent.throttle > 0) {
const next = Phaser.Math.Clamp(
wheelBody.angularVelocity + BUS.driveTorque,
-BUS.maxWheelAngularVelocity,
BUS.maxWheelAngularVelocity
);
this.wheelRear.setAngularVelocity(next);
} else if (intent.throttle < 0) {
const next = Phaser.Math.Clamp(
wheelBody.angularVelocity - BUS.brakeTorque,
-BUS.maxBrakeAngularVelocity,
BUS.maxBrakeAngularVelocity
);
this.wheelRear.setAngularVelocity(next);
}
const chassisBody = this.chassis.body;
if (intent.leanLeft && !intent.leanRight) {
const next = Phaser.Math.Clamp(
chassisBody.angularVelocity - BUS.leanTorqueStep,
-BUS.maxLeanAngularVelocity,
BUS.maxLeanAngularVelocity
);
this.chassis.setAngularVelocity(next);
} else if (intent.leanRight && !intent.leanLeft) {
const next = Phaser.Math.Clamp(
chassisBody.angularVelocity + BUS.leanTorqueStep,
-BUS.maxLeanAngularVelocity,
BUS.maxLeanAngularVelocity
);
this.chassis.setAngularVelocity(next);
}
}
getSeatWorldPosition(seatIndex) {
const seat = this.seatOffsets[seatIndex];
return { x: this.chassis.x + seat.x, y: this.chassis.y + seat.y };
}
}

69
src/entities/Kid.js Normal file
View File

@ -0,0 +1,69 @@
import Phaser from 'phaser';
import { GFORCE, KID } from '../config.js';
export default class Kid {
constructor(scene, bus, seatIndex) {
this.scene = scene;
this.bus = bus;
this.seatIndex = seatIndex;
this.state = 'aboard'; // aboard | ejected | settled
const seat = bus.seatOffsets[seatIndex];
const worldPos = bus.getSeatWorldPosition(seatIndex);
this.image = scene.matter.add.image(worldPos.x, worldPos.y, 'kid_idle', null, {
shape: { type: 'circle', radius: KID.radius },
density: KID.density,
friction: KID.friction,
collisionFilter: { group: bus.group },
});
this.image.setDisplaySize(KID.radius * 2, KID.radius * 2);
this.seatConstraint = scene.matter.add.constraint(bus.chassis, this.image, KID.seatLength, KID.seatStiffness, {
pointA: seat,
pointB: { x: 0, y: 0 },
damping: KID.seatDamping,
});
this._settleAt = 0;
}
eject(chassisVelocity, gForce) {
if (this.state !== 'aboard') return;
this.state = 'ejected';
this.scene.matter.world.removeConstraint(this.seatConstraint, true);
this.seatConstraint = null;
// Neutral group re-enables normal category/mask collision (was sharing
// the bus's non-colliding group while seated).
this.image.body.collisionFilter.group = 0;
this.image.setTexture('kid_ejected');
const speed = Math.hypot(chassisVelocity.x, chassisVelocity.y) || 1;
const dirX = chassisVelocity.x / speed;
const dirY = chassisVelocity.y / speed;
const severity = Phaser.Math.Clamp(gForce / GFORCE.ejectThresholdG, 1, 3);
this.image.applyForce({
x: dirX * GFORCE.ejectImpulseMagnitude * severity + (Math.random() - 0.5) * GFORCE.ejectImpulseMagnitude * 0.5,
y: dirY * GFORCE.ejectImpulseMagnitude * severity - GFORCE.ejectImpulseMagnitude * 0.4,
});
this._settleAt = this.scene.time.now + KID.settleTimeMs;
}
update(time) {
if (this.state === 'ejected' && time >= this._settleAt) {
this.state = 'settled';
}
}
destroy() {
if (this.seatConstraint) {
this.scene.matter.world.removeConstraint(this.seatConstraint, true);
this.seatConstraint = null;
}
this.image.destroy();
}
}

75
src/entities/Terrain.js Normal file
View File

@ -0,0 +1,75 @@
import { TERRAIN_DEPTH, WORLD_SCALE } from '../config.js';
// 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.
export default class Terrain {
constructor(scene, levelData) {
this.scene = scene;
this.bodies = [];
this.graphics = scene.add.graphics();
for (const segment of levelData.terrain) {
this._buildSegment(segment.points);
this._drawSegment(segment.points);
}
if (levelData.obstacles) {
for (const obstacle of levelData.obstacles) {
this._buildObstacle(obstacle);
}
}
}
_buildSegment(points) {
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
if (length < 1) continue;
const angle = Math.atan2(dy, dx);
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2 + (TERRAIN_DEPTH / 2) * Math.cos(angle);
const body = this.scene.matter.add.rectangle(midX, midY, length, TERRAIN_DEPTH, {
isStatic: true,
angle,
friction: 0.95,
label: 'terrain',
});
this.bodies.push(body);
}
}
_buildObstacle(obstacle) {
if (obstacle.type !== 'ramp') return;
const body = this.scene.matter.add.rectangle(obstacle.x, obstacle.y, obstacle.width, obstacle.height, {
isStatic: true,
angle: obstacle.angle || 0,
friction: 0.95,
label: 'terrain',
});
this.bodies.push(body);
}
_drawSegment(points) {
const g = this.graphics;
g.fillStyle(0x3c8f3c, 1);
g.beginPath();
g.moveTo(points[0].x, points[0].y + TERRAIN_DEPTH);
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);
g.closePath();
g.fillPath();
g.lineStyle(4 * WORLD_SCALE, 0x2c6e2c, 1);
g.beginPath();
g.moveTo(points[0].x, points[0].y);
for (const p of points) g.lineTo(p.x, p.y);
g.strokePath();
}
}

44
src/main.js Normal file
View File

@ -0,0 +1,44 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE } from './config.js';
import BootScene from './scenes/BootScene.js';
import PreloadScene from './scenes/PreloadScene.js';
import IntroScene from './scenes/IntroScene.js';
import MainMenuScene from './scenes/MainMenuScene.js';
import LevelSelectScene from './scenes/LevelSelectScene.js';
import PlayScene from './scenes/PlayScene.js';
import LevelCompleteScene from './scenes/LevelCompleteScene.js';
import LevelFailedScene from './scenes/LevelFailedScene.js';
window.__PHASER_GAME__ = new Phaser.Game({
type: Phaser.AUTO,
parent: 'game-container',
width: GAME_WIDTH,
height: GAME_HEIGHT,
backgroundColor: '#8fc7e8',
physics: {
default: 'matter',
matter: {
// Gravity is a constant absolute px/s^2 regardless of body size (it's
// mass-proportional force, so acceleration = force/mass is scale-
// independent) - has to scale with WORLD_SCALE or a 2x-bigger world
// falls proportionally slower (everything free-falls for longer to
// cover the now-doubled pixel distances).
gravity: { x: 0, y: 1 * WORLD_SCALE },
debug: DEBUG,
},
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [
BootScene,
PreloadScene,
IntroScene,
MainMenuScene,
LevelSelectScene,
PlayScene,
LevelCompleteScene,
LevelFailedScene,
],
});

11
src/scenes/BootScene.js Normal file
View File

@ -0,0 +1,11 @@
import Phaser from 'phaser';
export default class BootScene extends Phaser.Scene {
constructor() {
super('Boot');
}
create() {
this.scene.start('Preload');
}
}

44
src/scenes/IntroScene.js Normal file
View File

@ -0,0 +1,44 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
export default class IntroScene extends Phaser.Scene {
constructor() {
super('Intro');
}
create() {
this.cameras.main.setBackgroundColor('#8fc7e8');
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 60 * WORLD_SCALE, 'MONSTERPLEX', {
fontFamily: 'monospace',
fontSize: `${52 * WORLD_SCALE}px`,
color: '#1a1f29',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'a school bus survival ride', {
fontFamily: 'monospace',
fontSize: `${18 * WORLD_SCALE}px`,
color: '#2255aa',
}).setOrigin(0.5);
const prompt = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 90 * WORLD_SCALE, 'Press any key or tap to continue', {
fontFamily: 'monospace',
fontSize: `${16 * WORLD_SCALE}px`,
color: '#1a1f29',
}).setOrigin(0.5);
this.tweens.add({
targets: prompt,
alpha: { from: 1, to: 0.2 },
duration: 700,
yoyo: true,
repeat: -1,
});
this._advance = () => this.scene.start('MainMenu');
this.input.keyboard.once('keydown', this._advance);
this.input.once('pointerdown', this._advance);
this.time.delayedCall(6000, this._advance);
}
}

View File

@ -0,0 +1,61 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { getLevelById, getNextLevelId } from '../data/levels/index.js';
import { markLevelComplete } from '../util/progress.js';
import { createButton } from '../util/ui.js';
export default class LevelCompleteScene extends Phaser.Scene {
constructor() {
super('LevelComplete');
}
init(data) {
this.levelId = data.levelId;
this.kidsSaved = data.kidsSaved;
this.total = data.total;
}
create() {
markLevelComplete(this.levelId, this.kidsSaved, this.total);
this.cameras.main.setBackgroundColor('#8fc7e8');
this.add.text(GAME_WIDTH / 2, 110 * WORLD_SCALE, 'LEVEL COMPLETE!', {
fontFamily: 'monospace',
fontSize: `${36 * WORLD_SCALE}px`,
color: '#1a1f29',
fontStyle: 'bold',
}).setOrigin(0.5);
const perfect = this.kidsSaved === this.total;
this.add.text(GAME_WIDTH / 2, 170 * WORLD_SCALE, `${this.kidsSaved} / ${this.total} kids made it${perfect ? ' - perfect run!' : ''}`, {
fontFamily: 'monospace',
fontSize: `${18 * WORLD_SCALE}px`,
color: perfect ? '#2c8f3c' : '#1a1f29',
}).setOrigin(0.5);
const nextId = getNextLevelId(this.levelId);
const y = GAME_HEIGHT / 2 + 20 * WORLD_SCALE;
createButton(this, GAME_WIDTH / 2, y - 70 * WORLD_SCALE, 'RETRY', () => {
this.scene.start('Play', { levelId: this.levelId });
});
if (nextId) {
const nextLevel = getLevelById(nextId);
createButton(this, GAME_WIDTH / 2, y, `NEXT: ${nextLevel.name.toUpperCase()}`, () => {
this.scene.start('Play', { levelId: nextId });
}, { width: 280 * WORLD_SCALE });
} else {
this.add.text(GAME_WIDTH / 2, y, 'That was the last level - nice driving!', {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
}).setOrigin(0.5);
}
createButton(this, GAME_WIDTH / 2, y + 70 * WORLD_SCALE, 'LEVEL SELECT', () => {
this.scene.start('LevelSelect');
});
}
}

View File

@ -0,0 +1,45 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { createButton } from '../util/ui.js';
const REASON_TEXT = {
'all-kids-lost': 'Every kid got thrown off the bus!',
};
export default class LevelFailedScene extends Phaser.Scene {
constructor() {
super('LevelFailed');
}
init(data) {
this.levelId = data.levelId;
this.reason = data.reason;
}
create() {
this.cameras.main.setBackgroundColor('#c96a5a');
this.add.text(GAME_WIDTH / 2, 130 * WORLD_SCALE, 'LEVEL FAILED', {
fontFamily: 'monospace',
fontSize: `${36 * WORLD_SCALE}px`,
color: '#1a1f29',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(GAME_WIDTH / 2, 190 * WORLD_SCALE, REASON_TEXT[this.reason] || 'The bus didn\'t make it.', {
fontFamily: 'monospace',
fontSize: `${16 * WORLD_SCALE}px`,
color: '#1a1f29',
}).setOrigin(0.5);
const y = GAME_HEIGHT / 2 + 40 * WORLD_SCALE;
createButton(this, GAME_WIDTH / 2, y - 40 * WORLD_SCALE, 'RETRY', () => {
this.scene.start('Play', { levelId: this.levelId });
});
createButton(this, GAME_WIDTH / 2, y + 40 * WORLD_SCALE, 'LEVEL SELECT', () => {
this.scene.start('LevelSelect');
});
}
}

View File

@ -0,0 +1,75 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { LEVELS } from '../data/levels/index.js';
import { getLevelResult } from '../util/progress.js';
export default class LevelSelectScene extends Phaser.Scene {
constructor() {
super('LevelSelect');
}
create() {
this.cameras.main.setBackgroundColor('#8fc7e8');
this.add.text(GAME_WIDTH / 2, 60 * WORLD_SCALE, 'SELECT LEVEL', {
fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`,
color: '#1a1f29',
fontStyle: 'bold',
}).setOrigin(0.5);
const cardWidth = 260 * WORLD_SCALE;
const cardHeight = 300 * WORLD_SCALE;
const gap = 30 * WORLD_SCALE;
const totalWidth = LEVELS.length * cardWidth + (LEVELS.length - 1) * gap;
const startX = GAME_WIDTH / 2 - totalWidth / 2 + cardWidth / 2;
const y = GAME_HEIGHT / 2 + 30 * WORLD_SCALE;
LEVELS.forEach((level, i) => {
const x = startX + i * (cardWidth + gap);
this._createCard(x, y, cardWidth, cardHeight, level);
});
const back = this.add.text(40 * WORLD_SCALE, GAME_HEIGHT - 40 * WORLD_SCALE, '< Menu', {
fontFamily: 'monospace',
fontSize: `${16 * WORLD_SCALE}px`,
color: '#1a1f29',
}).setInteractive({ useHandCursor: true });
back.on('pointerdown', () => this.scene.start('MainMenu'));
}
_createCard(x, y, width, height, level) {
const result = getLevelResult(level.id);
const bg = this.add.rectangle(x, y, width, height, 0x2255aa).setStrokeStyle(2 * WORLD_SCALE, 0x1a1f29);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x2f6ac0));
bg.on('pointerout', () => bg.setFillStyle(0x2255aa));
bg.on('pointerdown', () => this.scene.start('Play', { levelId: level.id }));
this.add.text(x, y - height / 2 + 34 * WORLD_SCALE, level.name, {
fontFamily: 'monospace',
fontSize: `${20 * WORLD_SCALE}px`,
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(x, y, level.description, {
fontFamily: 'monospace',
fontSize: `${13 * WORLD_SCALE}px`,
color: '#e8eef7',
align: 'center',
wordWrap: { width: width - 30 * WORLD_SCALE },
}).setOrigin(0.5);
const statusText = result
? `Best: ${result.kidsSaved}/${result.kidsTotal} kids saved`
: 'Not completed';
this.add.text(x, y + height / 2 - 30 * WORLD_SCALE, statusText, {
fontFamily: 'monospace',
fontSize: `${13 * WORLD_SCALE}px`,
color: result ? '#f2c14e' : '#c8d3e0',
}).setOrigin(0.5);
}
}

View File

@ -0,0 +1,31 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { createButton } from '../util/ui.js';
export default class MainMenuScene extends Phaser.Scene {
constructor() {
super('MainMenu');
}
create() {
this.cameras.main.setBackgroundColor('#8fc7e8');
this.add.text(GAME_WIDTH / 2, 90 * WORLD_SCALE, 'MONSTERPLEX', {
fontFamily: 'monospace',
fontSize: `${40 * WORLD_SCALE}px`,
color: '#1a1f29',
fontStyle: 'bold',
}).setOrigin(0.5);
createButton(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 - 20 * WORLD_SCALE, 'PLAY', () => {
this.scene.start('LevelSelect');
});
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 60 * WORLD_SCALE, 'Arrows/WASD to drive and lean.\nDon\'t let the g-force throw the kids out!', {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
align: 'center',
}).setOrigin(0.5);
}
}

162
src/scenes/PlayScene.js Normal file
View File

@ -0,0 +1,162 @@
import Phaser from 'phaser';
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 InputController from '../systems/InputController.js';
import GForceMonitor from '../systems/GForceMonitor.js';
import KidManager from '../systems/KidManager.js';
import CameraRig from '../systems/CameraRig.js';
export default class PlayScene extends Phaser.Scene {
constructor() {
super('Play');
}
init(data) {
this.levelId = data.levelId;
this.level = getLevelById(this.levelId);
this._levelEnded = false;
}
create() {
this.cameras.main.setBackgroundColor('#8fc7e8');
this._buildParallax();
this.terrain = new Terrain(this, this.level);
this.bus = new Bus(this, this.level.startPosition.x, this.level.startPosition.y, this.level.startAngle || 0);
this.kidManager = new KidManager(this, this.bus, this.level.kidsAboard);
this.inputController = new InputController(this);
this.gForceMonitor = new GForceMonitor(this, this.bus.chassis.body);
// Measured via headless testing: the spawn-to-ground drop settles by
// ~4.5s with the current spawn height/bus mass. Re-measure this if
// startPosition, WORLD_SCALE, or the bus's mass/suspension change again -
// too short and the landing itself trips the g-force ejection check.
this.gForceMonitor.startGracePeriod(5000);
this.cameraRig = new CameraRig(this, this.bus.chassis, this.level.cameraBounds);
this._buildGoal();
this._buildHud();
this.events.on('kid-ejected', this._onKidEjected, this);
this.events.on('level-failed', this._onLevelFailed, this);
this.matter.world.on('collisionstart', this._onCollisionStart, this);
this.events.once('shutdown', this._cleanup, this);
}
update(time, delta) {
if (this._levelEnded) return;
const intent = this.inputController.getIntent();
this.bus.applyIntent(intent);
this.kidManager.update(time);
if (DEBUG && this.debugText) {
const g = this.gForceMonitor.lastGForce || 0;
this.debugText.setText(`g-force: ${g.toFixed(2)}`);
}
}
_buildParallax() {
const bounds = this.level.cameraBounds;
const width = bounds.width + 1000 * WORLD_SCALE;
const far = this.add.tileSprite(bounds.x, 0, width, GAME_HEIGHT, 'bg_far').setOrigin(0, 0);
far.setScrollFactor(0.15, 0);
const mid = this.add.tileSprite(bounds.x, GAME_HEIGHT * 0.35, width, GAME_HEIGHT * 0.65, 'bg_mid').setOrigin(0, 0);
mid.setScrollFactor(0.45, 0);
const near = this.add.tileSprite(bounds.x, GAME_HEIGHT * 0.55, width, GAME_HEIGHT * 0.45, 'bg_near').setOrigin(0, 0);
near.setScrollFactor(0.75, 0);
}
_buildGoal() {
const goal = this.level.goal;
this.goalBody = this.matter.add.rectangle(goal.x, goal.y, goal.width, goal.height, {
isStatic: true,
isSensor: true,
label: 'goal',
});
}
_buildHud() {
this.hudIcons = [];
const startX = 24 * WORLD_SCALE;
const y = 24 * WORLD_SCALE;
const spacing = 30 * WORLD_SCALE;
const iconSize = 24 * WORLD_SCALE;
for (let i = 0; i < this.level.kidsAboard; i++) {
const icon = this.add.image(startX + i * spacing, y, 'icon_kid').setDisplaySize(iconSize, iconSize).setScrollFactor(0).setDepth(10);
this.hudIcons.push(icon);
}
this.hudText = this.add.text(startX, y + 24 * WORLD_SCALE, `Kids aboard: ${this.level.kidsAboard}/${this.level.kidsAboard}`, {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
backgroundColor: '#ffffffaa',
}).setScrollFactor(0).setDepth(10);
if (DEBUG) {
this.debugText = this.add.text(startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
backgroundColor: '#ffffffaa',
}).setScrollFactor(0).setDepth(10);
}
}
_onKidEjected({ kidsAboard, total }) {
const ejectedCount = total - kidsAboard;
for (let i = 0; i < this.hudIcons.length; i++) {
this.hudIcons[i].setAlpha(i < ejectedCount ? 0.2 : 1);
}
this.hudText.setText(`Kids aboard: ${kidsAboard}/${total}`);
}
_onLevelFailed({ reason }) {
if (this._levelEnded) return;
this._levelEnded = true;
this.scene.start('LevelFailed', { levelId: this.levelId, reason });
}
_onCollisionStart(event, bodyA, bodyB) {
if (this._levelEnded) return;
if (this._isGoalHit(bodyA, bodyB)) {
this._onWin();
}
}
_isGoalHit(bodyA, bodyB) {
const busBodies = [this.bus.chassis.body, this.bus.wheelRear.body, this.bus.wheelFront.body];
const isGoal = (b) => b.label === 'goal';
const isBus = (b) => busBodies.includes(b);
return (isGoal(bodyA) && isBus(bodyB)) || (isGoal(bodyB) && isBus(bodyA));
}
_onWin() {
this._levelEnded = true;
const kidsSaved = this.kidManager.kidsAboardCount;
const total = this.kidManager.total;
this.scene.start('LevelComplete', { levelId: this.levelId, kidsSaved, total });
}
_cleanup() {
this.events.off('kid-ejected', this._onKidEjected, this);
this.events.off('level-failed', this._onLevelFailed, this);
this.matter.world.off('collisionstart', this._onCollisionStart, this);
if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy();
}
}

View File

@ -0,0 +1,53 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { ASSET_MANIFEST } from '../util/assetManifest.js';
import { generatePlaceholder } from '../util/placeholderTextures.js';
export default class PreloadScene extends Phaser.Scene {
constructor() {
super('Preload');
}
preload() {
this._drawLoadingBar();
this._failedKeys = new Set();
this.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
this._failedKeys.add(file.key);
});
for (const entry of ASSET_MANIFEST) {
this.load.image(entry.key, entry.path);
}
}
create() {
for (const entry of ASSET_MANIFEST) {
if (this._failedKeys.has(entry.key) || !this.textures.exists(entry.key)) {
generatePlaceholder(this, entry);
}
}
this.scene.start('Intro');
}
_drawLoadingBar() {
const barWidth = 320 * WORLD_SCALE;
const barHeight = 18 * WORLD_SCALE;
const pad = 4 * WORLD_SCALE;
const x = GAME_WIDTH / 2 - barWidth / 2;
const y = GAME_HEIGHT / 2 - barHeight / 2;
const box = this.add.graphics();
box.fillStyle(0x1a1f29, 1);
box.fillRect(x - pad, y - pad, barWidth + pad * 2, barHeight + pad * 2);
const bar = this.add.graphics();
this.load.on(Phaser.Loader.Events.PROGRESS, (value) => {
bar.clear();
bar.fillStyle(0xf2c14e, 1);
bar.fillRect(x, y, barWidth * value, barHeight);
});
}
}

15
src/systems/CameraRig.js Normal file
View File

@ -0,0 +1,15 @@
import { CAMERA } from '../config.js';
export default class CameraRig {
constructor(scene, target, bounds) {
this.scene = scene;
const cam = scene.cameras.main;
if (bounds) {
cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
}
cam.setDeadzone(CAMERA.deadzoneWidth, CAMERA.deadzoneHeight);
cam.startFollow(target, true, CAMERA.lerpX, CAMERA.lerpY);
}
}

View File

@ -0,0 +1,51 @@
import { GFORCE, PHYSICS_TIMESTEP_SECONDS } from '../config.js';
// Pure detector: samples the chassis's linear velocity every fixed physics
// step, estimates acceleration via finite difference, and emits
// 'gforce-exceeded' on the scene when it crosses the tunable threshold.
// Ejection logic lives elsewhere (KidManager) so this stays a single-purpose
// sensor.
export default class GForceMonitor {
constructor(scene, chassisBody) {
this.scene = scene;
this.chassisBody = chassisBody;
this.previousVelocity = { x: 0, y: 0 };
this.graceUntil = 0;
this.enabled = true;
this._onAfterUpdate = this._onAfterUpdate.bind(this);
scene.matter.world.on('afterupdate', this._onAfterUpdate);
}
startGracePeriod(durationMs = GFORCE.gracePeriodMs) {
this.graceUntil = this.scene.time.now + durationMs;
const v = this.chassisBody.velocity;
this.previousVelocity = { x: v.x, y: v.y };
}
_onAfterUpdate() {
if (!this.enabled) return;
const v = this.chassisBody.velocity;
const dvx = v.x - this.previousVelocity.x;
const dvy = v.y - this.previousVelocity.y;
const deltaV = Math.hypot(dvx, dvy);
const accelPxPerSec2 = deltaV / PHYSICS_TIMESTEP_SECONDS;
const accelMPerSec2 = accelPxPerSec2 / GFORCE.pixelsPerMeter;
const gForce = accelMPerSec2 / 9.8;
this.previousVelocity = { x: v.x, y: v.y };
this.lastGForce = gForce;
if (this.scene.time.now < this.graceUntil) return;
if (gForce > GFORCE.ejectThresholdG) {
this.scene.events.emit('gforce-exceeded', { gForce });
}
}
destroy() {
this.scene.matter.world.off('afterupdate', this._onAfterUpdate);
}
}

View File

@ -0,0 +1,29 @@
import Phaser from 'phaser';
// Polls keyboard input into a small, stable intent shape. Bus/PlayScene only
// ever depend on getIntent()'s return shape, so a TouchInputController with
// the same shape could be added later without touching either of them.
export default class InputController {
constructor(scene) {
const KC = Phaser.Input.Keyboard.KeyCodes;
const kb = scene.input.keyboard;
this.up = [kb.addKey(KC.UP), kb.addKey(KC.W)];
this.down = [kb.addKey(KC.DOWN), kb.addKey(KC.S)];
this.left = [kb.addKey(KC.LEFT), kb.addKey(KC.A)];
this.right = [kb.addKey(KC.RIGHT), kb.addKey(KC.D)];
}
_anyDown(keys) {
return keys.some((key) => key.isDown);
}
getIntent() {
const throttle = this._anyDown(this.up) ? 1 : this._anyDown(this.down) ? -1 : 0;
return {
throttle,
leanLeft: this._anyDown(this.left),
leanRight: this._anyDown(this.right),
};
}
}

54
src/systems/KidManager.js Normal file
View File

@ -0,0 +1,54 @@
import Phaser from 'phaser';
import { GFORCE } from '../config.js';
import Kid from '../entities/Kid.js';
export default class KidManager {
constructor(scene, bus, kidsAboard) {
this.scene = scene;
this.bus = bus;
this.total = kidsAboard;
this.kids = [];
this._lastEjectTime = -Infinity;
for (let i = 0; i < kidsAboard; i++) {
this.kids.push(new Kid(scene, bus, i));
}
this._onGForceExceeded = this._onGForceExceeded.bind(this);
scene.events.on('gforce-exceeded', this._onGForceExceeded);
}
get kidsAboardCount() {
return this.kids.filter((kid) => kid.state === 'aboard').length;
}
_onGForceExceeded({ gForce }) {
const now = this.scene.time.now;
if (now - this._lastEjectTime < GFORCE.ejectCooldownMs) return;
const aboard = this.kids.filter((kid) => kid.state === 'aboard');
if (aboard.length === 0) return;
this._lastEjectTime = now;
const kid = Phaser.Utils.Array.GetRandom(aboard);
kid.eject(this.bus.chassis.body.velocity, gForce);
this.scene.events.emit('kid-ejected', {
kidsAboard: this.kidsAboardCount,
total: this.total,
});
if (this.kidsAboardCount === 0) {
this.scene.events.emit('level-failed', { reason: 'all-kids-lost' });
}
}
update(time) {
for (const kid of this.kids) kid.update(time);
}
destroy() {
this.scene.events.off('gforce-exceeded', this._onGForceExceeded);
for (const kid of this.kids) kid.destroy();
}
}

16
src/util/assetManifest.js Normal file
View File

@ -0,0 +1,16 @@
import { WORLD_SCALE } from '../config.js';
// Single source of truth for every image the game expects.
// Drop a real file at `path` and it will be used automatically -
// nothing else in the codebase needs to change (see placeholderTextures.js).
export const ASSET_MANIFEST = [
{ key: 'bus_chassis', path: 'assets/sprites/bus_chassis.png', width: 170 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x2255aa },
{ key: 'bus_wheel', path: 'assets/sprites/bus_wheel.png', width: 34 * WORLD_SCALE, height: 34 * WORLD_SCALE, kind: 'circle', color: 0x222222 },
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e },
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b },
{ key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true },
{ key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true },
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },
{ key: 'icon_kid', path: 'assets/ui/icon_kid.png', width: 24 * WORLD_SCALE, height: 24 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e },
{ key: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa },
];

View File

@ -0,0 +1,31 @@
import { WORLD_SCALE } from '../config.js';
// Synthesizes a simple colored texture for any manifest entry whose real
// image file failed to load, registered under that entry's own texture key.
// Once a real PNG exists at the manifest path, the loader succeeds and this
// is never invoked for that key - no other code needs to change.
export function generatePlaceholder(scene, entry) {
const { key, width, height, kind, color, tileable } = entry;
const g = scene.make.graphics({ x: 0, y: 0 }, false);
const border = 2 * WORLD_SCALE;
g.fillStyle(color, 1);
if (kind === 'circle') {
const r = Math.min(width, height) / 2;
g.fillCircle(width / 2, height / 2, r - 1);
g.lineStyle(border, 0x000000, 0.35);
g.strokeCircle(width / 2, height / 2, r - 1);
} else {
g.fillRect(0, 0, width, height);
// Tiled textures (parallax backgrounds) skip the border - it would tile
// into a visible grid seam.
if (!tileable) {
g.lineStyle(border, 0x000000, 0.35);
g.strokeRect(border / 2, border / 2, width - border, height - border);
}
}
g.generateTexture(key, width, height);
g.destroy();
}

31
src/util/progress.js Normal file
View File

@ -0,0 +1,31 @@
import { STORAGE_KEY } from '../config.js';
function readAll() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch (e) {
return {};
}
}
export function isLevelComplete(levelId) {
return Boolean(readAll()[levelId]);
}
export function markLevelComplete(levelId, kidsSaved, kidsTotal) {
const all = readAll();
const existing = all[levelId];
if (!existing || kidsSaved > existing.kidsSaved) {
all[levelId] = { kidsSaved, kidsTotal };
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch (e) {
// localStorage unavailable (e.g. private browsing) - progress just won't persist
}
}
export function getLevelResult(levelId) {
return readAll()[levelId] || null;
}

21
src/util/ui.js Normal file
View File

@ -0,0 +1,21 @@
import { WORLD_SCALE } from '../config.js';
export function createButton(scene, x, y, label, onClick, options = {}) {
const width = options.width || 220 * WORLD_SCALE;
const height = options.height || 56 * WORLD_SCALE;
const fontSize = options.fontSize || `${20 * WORLD_SCALE}px`;
const bg = scene.add.rectangle(x, y, width, height, 0x2255aa).setStrokeStyle(2 * WORLD_SCALE, 0x1a1f29);
const text = scene.add.text(x, y, label, {
fontFamily: 'monospace',
fontSize,
color: '#ffffff',
}).setOrigin(0.5);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x2f6ac0));
bg.on('pointerout', () => bg.setFillStyle(0x2255aa));
bg.on('pointerdown', onClick);
return { bg, text };
}

4
start_web.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
# Start a simple HTTP server on port 8000
python3 -m http.server 3000

268534
vendor/phaser.esm.js vendored Normal file

File diff suppressed because it is too large Load Diff