diff --git a/README.md b/README.md
index 3b118f8..e7cbcf6 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@ no code changes needed.**
| `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 |
+| `smash_items` | `assets/sprites/smash-items.png` | 64x64 x 6 frames | 1x6 spritesheet: intact/smashed TV, chair, cone (see Smashables below) |
| `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 |
@@ -70,10 +71,45 @@ Everything worth tweaking lives in `src/config.js`, notably:
doesn't empty the whole bus at once.
- `BUS.*` - suspension stiffness/damping, wheel friction, throttle/lean torque
and their clamps.
+- `SMASH_ITEM.*` - smashable prop physics (launch speed/angle/spin, density,
+ etc. - see the block in config.js for the full list). `SMASH_ITEM.launchSpeed`
+ and `SMASH_ITEM.launchAngle` are the two knobs that matter most for "how far
+ and how high the item flies when the bus hits it."
Set `DEBUG = true` in `config.js` to show a live g-force readout and Matter's
physics debug overlay in `PlayScene`.
+## Smashables (TV / chair / cone)
+
+`assets/sprites/smash-items.png` is a 1x6 spritesheet (TV, smashed TV, chair,
+smashed chair, cone, smashed cone), loaded as the `smash_items` texture
+(64x64 per frame). Any level can carry an optional `items` array
+(see the editor's export for the exact shape):
+
+```js
+items: [
+ { type: 'tv', mode: 'float', x: 2500, y: 660 },
+ { type: 'chair', mode: 'gravity', x: 3500, y: 760 },
+]
+```
+
+- `mode: 'float'` - the item rests exactly at `(x, y)` (a backdrop prop,
+ static body, never moves until smashed).
+- `mode: 'gravity'` - the item spawns at `(x, y)` but is a dynamic body, so
+ it drops and settles onto the ground under that X at level start.
+
+The first time the bus (chassis or either wheel) touches an intact item it
+swaps to its smashed frame and launches up-and-forward relative to the bus's
+travel direction, tumbling, on the world's normal gravity. It lands wherever
+it lands and stays. `SMASH_ITEM` in `config.js` holds the launch physics.
+Smashed items also tally as +`SCORE.perSmashItem` (50) each on the
+end-of-level score screen.
+
+The **level editor** (`editor.html`) has a "Smashables" section: pick a type
++ mode, then click anywhere on the preview map to drop one there; click an
+existing item (on the map or in the list) to remove it. Exporting the level
+writes the `items` array into the level file automatically.
+
## Phaser 4 loading
Phaser is vendored locally at `vendor/phaser.esm.js` (downloaded once from
diff --git a/assets/sprites/bonus-items.png b/assets/sprites/bonus-items.png
new file mode 100644
index 0000000..4991c66
Binary files /dev/null and b/assets/sprites/bonus-items.png differ
diff --git a/assets/sprites/bonus-items.psd b/assets/sprites/bonus-items.psd
new file mode 100644
index 0000000..4995052
Binary files /dev/null and b/assets/sprites/bonus-items.psd differ
diff --git a/assets/sprites/smash-items.png b/assets/sprites/smash-items.png
new file mode 100644
index 0000000..2a2301b
Binary files /dev/null and b/assets/sprites/smash-items.png differ
diff --git a/assets/sprites/smash-items.psd b/assets/sprites/smash-items.psd
new file mode 100644
index 0000000..fdb0a00
Binary files /dev/null and b/assets/sprites/smash-items.psd differ
diff --git a/editor.html b/editor.html
index 3e0142a..f88a0a6 100644
--- a/editor.html
+++ b/editor.html
@@ -151,6 +151,20 @@
opacity: 0.35;
cursor: not-allowed;
}
+ .hint {
+ color: #9aa7bb;
+ font-size: 12px;
+ margin: 0 0 12px;
+ }
+ #items-form select {
+ background: #0b0e14;
+ border: 1px solid #2c374f;
+ color: #e8eef7;
+ border-radius: 4px;
+ padding: 6px 8px;
+ font-family: monospace;
+ font-size: 13px;
+ }
#metadata-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -182,6 +196,7 @@
}
#preview-canvas {
display: block;
+ cursor: crosshair;
}
#export-textarea {
width: 100%;
@@ -252,6 +267,13 @@
+
+
Smashables
+
Pick a type + placement mode, then click anywhere on the Preview map below to drop the item there. Click a placed item (on the map or in the list) to delete it.
+
+
+
+
Preview
diff --git a/src/config.js b/src/config.js
index 0f7cf65..cecd3cd 100644
--- a/src/config.js
+++ b/src/config.js
@@ -195,6 +195,49 @@ export const SCORE = {
perKidSaved: 100,
perKidLost: -50,
perSecondRemaining: 10,
+ // Per smashable item (TV/chair/cone, see SMASH_ITEM) knocked off its
+ // perch by the bus - revealed on the tally between the kids and the clock.
+ perSmashItem: 50,
+};
+
+// Smashable items (assets/sprites/smash-items.png - 6 frames of 64x64, one
+// intact + one smashed variant per prop, see src/entities/SmashItemManager.js
+// for the type/frame table). Placed per-level via the editor ("Smashables"
+// section) and exported into the level's `items` array.
+//
+// At rest an item sits in the background like a prop: either floating
+// exactly where the editor placed it (mode 'float') or resting on the
+// ground (mode 'gravity'). The first time the bus touches it it swaps to
+// its smashed frame and launches up-and-forward into the air on the
+// world's REAL gravity (the full value in main.js - unlike ejected kids,
+// which fly under a reduced fraction, see KID.ejectedGravityScale),
+// tumbling as it goes, then lands and stays where it lands.
+export const SMASH_ITEM = {
+ radius: 22 * WORLD_SCALE, // circle hitbox (~70% of the 64px frame's display half)
+ displaySize: 64 * WORLD_SCALE, // one 64x64 frame, shown square
+ density: 0.001,
+ friction: 0.5,
+ frictionAir: 0.005, // a little air drag while airborne
+ restitution: 0.2, // small bounce on landing
+ // Launch applied at the moment of the hit: straight up-and-forward
+ // relative to the bus's travel direction, so the item always sails off
+ // ahead and above the bus regardless of which way it's going.
+ // Speeds are in Matter units (px per 60Hz physics step, same convention as
+ // every other velocity in this codebase - see KID.restSpeedThreshold etc.
+ // The bus itself cruises at roughly single-digit to low-double-digit
+ // px/step, so a launch in the ~25 range reads as "sent flying").
+ // Calibrated by simulating Matter's own integration under the world's
+ // 1.35 px/step^2 gravity: speed 26 at 0.7 rad above horizontal gives ~1.4s
+ // hang, ~320px up (a quarter of the screen), ~1300px forward - the bus
+ // covers most of that same distance in the same time, so the item lands
+ // just ahead of the bus, still on screen.
+ launchSpeed: 26,
+ launchAngle: 0.7,
+ launchSpin: 0.16, // radians of angular velocity per physics step (tumble)
+ // In 'float' mode the editor's placement Y IS the item's resting spot -
+ // this is how high (above the ground point under the placement X) a float
+ // item rests. Gravity-mode items ignore it (they drop to the ground).
+ floatHeight: 90 * WORLD_SCALE,
};
export const CAMERA = {
diff --git a/src/editor/exportLevel.js b/src/editor/exportLevel.js
index c74f238..eb922d0 100644
--- a/src/editor/exportLevel.js
+++ b/src/editor/exportLevel.js
@@ -20,6 +20,16 @@ function rectLiteral(rect) {
return `{ x: ${rect.x}, y: ${rect.y}, width: ${rect.width}, height: ${rect.height} }`;
}
+// Smashable item placements (editor "Smashables" section) - one literal
+// object per item, same field order as levelBuilder.js emits them.
+function itemsLiteral(items) {
+ if (!items || items.length === 0) return '[]';
+ const lines = items.map(
+ (it) => ` { type: ${JSON.stringify(it.type)}, mode: ${JSON.stringify(it.mode)}, x: ${it.x}, y: ${it.y} },`
+ );
+ return `[\n${lines.join('\n')}\n ]`;
+}
+
export function exportLevelToSource(levelData) {
const lines = [
'// Generated by editor.html - review before dropping into src/data/levels/.',
@@ -33,6 +43,9 @@ export function exportLevelToSource(levelData) {
` startAngle: ${levelData.startAngle},`,
` terrain: ${terrainLiteral(levelData.terrain)},`,
' obstacles: [],',
+ // Only emitted when the level actually has items - keeps hand-authored
+ // levels' exports byte-identical to before this feature existed.
+ ...(levelData.items && levelData.items.length > 0 ? [` items: ${itemsLiteral(levelData.items)},`] : []),
` goal: ${rectLiteral(levelData.goal)},`,
` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`,
'};',
diff --git a/src/editor/levelBuilder.js b/src/editor/levelBuilder.js
index d2b1707..a13ea05 100644
--- a/src/editor/levelBuilder.js
+++ b/src/editor/levelBuilder.js
@@ -156,9 +156,12 @@ function appendPoints(accumulator, points) {
// the previous section's (see smoothEntry above) - off by default, since a
// "Sharp Incline" or a jump's ramp is often supposed to look abrupt.
// metadata: { id, name, description, kidsAboard }.
+// items: optional array of smashable prop placements { type, mode, x, y }
+// (see SmashItemManager in the game) - passed through verbatim into the
+// level's `items` array; null/absent = no items.
// Returns a full level data object matching level01.js's shape, or null if
// sections is empty.
-export function buildLevelData(sections, metadata) {
+export function buildLevelData(sections, metadata, items) {
if (!sections || sections.length === 0) return null;
let baseX = -TERRAIN_LEAD_IN;
@@ -245,6 +248,16 @@ export function buildLevelData(sections, metadata) {
startAngle: 0,
terrain: terrainSegments,
obstacles: [],
+ // Smashable items (editor "Smashables" section) - only in the level
+ // data when at least one was placed, so old hand-authored levels
+ // (which have no `items` key at all) keep working: SmashItemManager
+ // reads `level.items || []`.
+ ...(items && items.length > 0 ? { items: items.map((it) => ({
+ type: it.type,
+ mode: it.mode,
+ x: Math.round(it.x),
+ y: Math.round(it.y),
+ })) } : {}),
goal: {
x: goalX,
y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE),
diff --git a/src/editor/main.js b/src/editor/main.js
index 228c4b0..039084f 100644
--- a/src/editor/main.js
+++ b/src/editor/main.js
@@ -20,6 +20,13 @@ const state = {
metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 },
sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }],
selectedIndex: null,
+ // Smashable props placed on the course (see the "Smashables" section below
+ // and SmashItemManager in the game). x/y are in the SAME scaled world units
+ // the preview canvas plots (and that the exported level uses); refIndex is
+ // the track section the item was added under (rebased on reordering).
+ items: [],
+ itemsType: 'tv',
+ itemsMode: 'float',
};
const paletteEl = document.getElementById('palette');
@@ -38,6 +45,8 @@ const previewScrollEl = document.getElementById('preview-scroll');
const previewCanvas = document.getElementById('preview-canvas');
const exportTextarea = document.getElementById('export-textarea');
const downloadBtn = document.getElementById('download-btn');
+const itemsFormEl = document.getElementById('items-form');
+const itemsListEl = document.getElementById('items-list');
const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId;
@@ -59,6 +68,147 @@ function buildPalette() {
}
}
+// --- Smashable items ---------------------------------------------------
+
+const ITEM_TYPES = [
+ { id: 'tv', label: 'TV' },
+ { id: 'chair', label: 'Chair' },
+ { id: 'cone', label: 'Cone' },
+];
+const ITEM_COLORS = { tv: '#4a90d9', chair: '#d9a04a', cone: '#d96a4a' };
+const ITEM_ICONS = { tv: 'TV', chair: 'CH', cone: 'CO' };
+// The real spritesheet, loaded directly as an for the preview map
+// (frames: 0 TV, 1 smashed TV, 2 chair, 3 smashed chair, 4 cone, 5 smashed
+// cone - 64x64 cells, same order as ITEM_TYPES pairs above). The circle
+// fallback in drawPreview covers the brief moment before it finishes loading
+// and any file:// context where the image is blocked.
+const ITEM_SPRITE = new Image();
+ITEM_SPRITE.src = 'assets/sprites/smash-items.png';
+const ITEM_SPRITE_FRAMES = { tv: 0, chair: 2, cone: 4 };
+const ITEM_SPRITE_CELL = 64;
+
+// Samples the terrain surface Y (highest ground) at a given world X - used
+// to draw the "falls to here" drop line for gravity-mode items in the
+// preview. Same "first segment whose x-span contains X" logic the physics
+// engine effectively applies when the item settles.
+function terrainSurfaceY(levelData, x) {
+ let best = Infinity;
+ for (const segment of levelData.terrain) {
+ const pts = segment.points;
+ for (let i = 0; i < pts.length - 1; i++) {
+ const a = pts[i];
+ const b = pts[i + 1];
+ const lo = Math.min(a.x, b.x);
+ const hi = Math.max(a.x, b.x);
+ if (x < lo || x > hi) continue;
+ const t = (b.x === a.x) ? 0 : (x - a.x) / (b.x - a.x);
+ best = Math.min(best, a.y + (b.y - a.y) * t);
+ }
+ }
+ return best;
+}
+
+function buildItemsForm() {
+ itemsFormEl.innerHTML = '';
+
+ const fieldset = document.createElement('div');
+ fieldset.style.display = 'flex';
+ fieldset.style.flexWrap = 'wrap';
+ fieldset.style.gap = '16px';
+ itemsFormEl.appendChild(fieldset);
+
+ const typeSelect = document.createElement('select');
+ typeSelect.id = 'items-type';
+ for (const t of ITEM_TYPES) {
+ const opt = document.createElement('option');
+ opt.value = t.id;
+ opt.textContent = t.label;
+ if (t.id === state.itemsType) opt.selected = true;
+ typeSelect.appendChild(opt);
+ }
+ typeSelect.addEventListener('change', () => {
+ state.itemsType = typeSelect.value;
+ render();
+ });
+ const typeWrap = document.createElement('label');
+ typeWrap.className = 'size-field';
+ typeWrap.append('Item type', typeSelect);
+ fieldset.appendChild(typeWrap);
+
+ const modeRadios = [];
+ for (const mode of ['float', 'gravity']) {
+ const radio = document.createElement('input');
+ radio.type = 'radio';
+ radio.name = 'items-mode';
+ radio.value = mode;
+ radio.id = `items-mode-${mode}`;
+ if (mode === state.itemsMode) radio.checked = true;
+ radio.addEventListener('change', () => {
+ state.itemsMode = mode;
+ render();
+ });
+ const label = document.createElement('label');
+ label.className = 'checkbox-row';
+ label.append(radio, mode === 'float' ? 'Float (stays where placed)' : 'Gravity (falls to the ground)');
+ modeRadios.push(label);
+ }
+ const modeWrap = document.createElement('div');
+ modeWrap.className = 'size-field';
+ modeWrap.append('Placement mode', ...modeRadios);
+ fieldset.appendChild(modeWrap);
+
+ const hint = document.createElement('p');
+ hint.style.margin = '8px 0 0';
+ hint.style.color = '#9aa7bb';
+ hint.style.fontSize = '12px';
+ hint.textContent = 'Pick a type + mode, then click anywhere on the Preview map above to place the item there. Click a placed item in the list to delete it.';
+ itemsFormEl.appendChild(hint);
+}
+
+function itemLabel(item, index) {
+ const t = ITEM_TYPES.find((x) => x.id === item.type);
+ return `${index + 1}. ${t ? t.label : item.type} @ (${Math.round(item.x)}, ${Math.round(item.y)}) [${item.mode}]`;
+}
+
+function buildItemsList() {
+ itemsListEl.innerHTML = '';
+ if (state.items.length === 0) {
+ const empty = document.createElement('div');
+ empty.style.color = '#9aa7bb';
+ empty.style.fontSize = '12px';
+ empty.textContent = 'No items placed yet.';
+ itemsListEl.appendChild(empty);
+ return;
+ }
+ state.items.forEach((item, index) => {
+ const el = document.createElement('div');
+ el.className = 'item-row';
+ el.style.cssText = 'display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 10px;background:#1a2236;border:1px solid #2c374f;border-radius:4px;';
+ const dot = document.createElement('span');
+ dot.style.cssText = `width:10px;height:10px;border-radius:50%;background:${ITEM_COLORS[item.type] || '#888'};flex:none;`;
+ const text = document.createElement('span');
+ text.textContent = itemLabel(item, index);
+ const del = document.createElement('span');
+ del.textContent = '\u2715';
+ del.style.cssText = 'margin-left:auto;cursor:pointer;color:#c0392b;';
+ el.title = 'Click to delete';
+ el.addEventListener('click', () => {
+ state.items.splice(index, 1);
+ render();
+ });
+ el.append(dot, text, del);
+ itemsListEl.appendChild(el);
+ });
+}
+
+// Rebase item refIndex references after sequence reordering - track item
+// indexes shift when sections move, so keep items attached to their section.
+function rebaseItemRefs(oldToNew) {
+ for (const item of state.items) {
+ if (oldToNew.has(item.refIndex)) item.refIndex = oldToNew.get(item.refIndex);
+ }
+}
+
function buildMetadataForm() {
metadataFormEl.innerHTML = '';
@@ -199,16 +349,24 @@ smoothCheckboxEl.addEventListener('change', () => {
moveLeftBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i <= 1) return;
+ const oldToNew = new Map();
+ oldToNew.set(i, i - 1);
+ oldToNew.set(i - 1, i);
[state.sequence[i - 1], state.sequence[i]] = [state.sequence[i], state.sequence[i - 1]];
state.selectedIndex = i - 1;
+ rebaseItemRefs(oldToNew);
render();
});
moveRightBtn.addEventListener('click', () => {
const i = state.selectedIndex;
if (i === null || i === 0 || i === state.sequence.length - 1) return;
+ const oldToNew = new Map();
+ oldToNew.set(i, i + 1);
+ oldToNew.set(i + 1, i);
[state.sequence[i + 1], state.sequence[i]] = [state.sequence[i], state.sequence[i + 1]];
state.selectedIndex = i + 1;
+ rebaseItemRefs(oldToNew);
render();
});
@@ -217,6 +375,13 @@ removeBtn.addEventListener('click', () => {
if (i === null || i === 0) return;
state.sequence.splice(i, 1);
state.selectedIndex = null;
+ // Items referenced removed/shifted sections keep their position (they were
+ // placed in world space anyway) but their refIndex is now meaningless -
+ // clamp it to a valid index for display purposes only.
+ for (const item of state.items) {
+ if (item.refIndex >= state.sequence.length) item.refIndex = state.sequence.length - 1;
+ else if (item.refIndex > i) item.refIndex -= 1;
+ }
render();
});
@@ -317,6 +482,10 @@ function drawPreview(levelData) {
ctx.font = 'bold 12px monospace';
ctx.fillText('S', sx - 4, sy + 4);
+ // Cache the preview bounds for the click handler below (it inverts the
+ // same scale to map screen px back to world units).
+ previewBounds = { minX, minY };
+
const g = levelData.goal;
const gx = screenX(g.x);
const gyTop = screenY(g.y - g.height / 2);
@@ -330,8 +499,132 @@ function drawPreview(levelData) {
ctx.fillStyle = '#f2c14e';
ctx.font = '12px monospace';
ctx.fillText('GOAL', gx - 16, gyTop - 6);
+
+ // Placed smashable items: the real sprite frame at the placement point
+ // (same space as terrain/start/goal, so the click-to-place mapping below
+ // is exact). Gravity items also get a dashed drop line down to the ground
+ // under that X - where they'll actually settle - with a small arrowhead.
+ const itemDrawScale = 26; // px on the preview map, per 64px source cell
+ for (const item of state.items) {
+ const ix = screenX(item.x);
+ const iy = screenY(item.y);
+ const color = ITEM_COLORS[item.type] || '#888';
+
+ if (item.mode === 'gravity') {
+ const groundY = terrainSurfaceY(levelData, item.x);
+ if (groundY !== Infinity) {
+ const gy = screenY(groundY);
+ ctx.save();
+ ctx.setLineDash([4, 4]);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(ix, iy + itemDrawScale / 2);
+ ctx.lineTo(ix, gy - 4);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ // arrowhead
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.moveTo(ix, gy);
+ ctx.lineTo(ix - 4, gy - 7);
+ ctx.lineTo(ix + 4, gy - 7);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+
+ const frame = ITEM_SPRITE_FRAMES[item.type];
+ if (ITEM_SPRITE.complete && ITEM_SPRITE.naturalWidth > 0 && frame !== undefined) {
+ const half = itemDrawScale / 2;
+ // float items get a light outline glow so they read as "held in place";
+ // gravity items rest on (or fall to) the ground, drawn as-is.
+ if (item.mode === 'float') {
+ ctx.save();
+ ctx.strokeStyle = 'rgba(255,255,255,0.85)';
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(ix - half - 1, iy - half - 1, itemDrawScale + 2, itemDrawScale + 2);
+ ctx.restore();
+ }
+ ctx.drawImage(
+ ITEM_SPRITE,
+ frame * ITEM_SPRITE_CELL, 0, ITEM_SPRITE_CELL, ITEM_SPRITE_CELL,
+ ix - half, iy - half, itemDrawScale, itemDrawScale,
+ );
+ } else {
+ // Sprite not loaded (yet) - colored circle + letter, as before.
+ ctx.beginPath();
+ ctx.arc(ix, iy, 8, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.fillStyle = '#0b0e14';
+ ctx.font = 'bold 9px monospace';
+ ctx.fillText(ITEM_ICONS[item.type] || '??', ix - 4, iy + 3);
+ }
+ }
+
+ // If an item was just added (by the click handler that runs right after
+ // this draw), give it a one-frame highlight ring so the placement is
+ // visible even when it overlaps terrain.
+ if (state._lastPlacedItem) {
+ const it = state._lastPlacedItem;
+ const ix = screenX(it.x);
+ const iy = screenY(it.y);
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(ix, iy, 12, 0, Math.PI * 2);
+ ctx.stroke();
+ state._lastPlacedItem = null;
+ }
}
+// --- Click-to-place items on the preview canvas -------------------------
+
+// The preview canvas is the "map" the user clicks on. A click anywhere
+// (except on an existing item, which deletes it) adds a new smashable item
+// at that world position using the currently selected type + mode from the
+// "Smashables" form above. The screen<->world mapping is the same
+// H_PX_PER_UNIT scale drawPreview used, so a click at screen (sx, sy) maps
+// to world ((sx - PADDING)/H_PX_PER_UNIT + minX, (sy - PADDING)/H_PX_PER_UNIT + minY).
+let previewBounds = null; // set by drawPreview each render
+
+previewCanvas.addEventListener('click', (e) => {
+ if (!currentLevelData || !previewBounds) return;
+
+ const rect = previewCanvas.getBoundingClientRect();
+ const sx = (e.clientX - rect.left) * (previewCanvas.width / rect.width);
+ const sy = (e.clientY - rect.top) * (previewCanvas.height / rect.height);
+
+ // Delete? If the click is within ~12px of an existing item, remove it.
+ for (let i = state.items.length - 1; i >= 0; i--) {
+ const it = state.items[i];
+ const ix = (it.x - previewBounds.minX) * H_PX_PER_UNIT + PADDING;
+ const iy = (it.y - previewBounds.minY) * H_PX_PER_UNIT + PADDING;
+ if (Math.hypot(sx - ix, sy - iy) <= 12) {
+ state.items.splice(i, 1);
+ render();
+ return;
+ }
+ }
+
+ // Otherwise: place a new item at this world position.
+ const worldX = (sx - PADDING) / H_PX_PER_UNIT + previewBounds.minX;
+ const worldY = (sy - PADDING) / H_PX_PER_UNIT + previewBounds.minY;
+
+ const item = {
+ type: state.itemsType,
+ mode: state.itemsMode,
+ x: Math.round(worldX),
+ y: Math.round(worldY),
+ refIndex: state.selectedIndex !== null ? state.selectedIndex : 0,
+ };
+ state.items.push(item);
+ state._lastPlacedItem = item;
+ render();
+});
+
// --- Export --------------------------------------------------------------
let currentLevelData = null;
@@ -356,8 +649,9 @@ function render() {
buildTrack();
updateTrackControls();
updateSizePanel();
+ buildItemsList();
- currentLevelData = buildLevelData(state.sequence, state.metadata);
+ currentLevelData = buildLevelData(state.sequence, state.metadata, state.items);
if (!currentLevelData) {
previewScrollEl.innerHTML = '
Add a section to see the preview.
';
@@ -388,5 +682,6 @@ function initSizeControls() {
buildPalette();
buildMetadataForm();
+buildItemsForm();
initSizeControls();
render();
diff --git a/src/entities/SmashItemManager.js b/src/entities/SmashItemManager.js
new file mode 100644
index 0000000..44c7ce6
--- /dev/null
+++ b/src/entities/SmashItemManager.js
@@ -0,0 +1,157 @@
+import { SMASH_ITEM } from '../config.js';
+
+// smash-items.png is 6 frames of 64x64, in order:
+// 0 intact TV, 1 smashed TV, 2 intact chair, 3 smashed chair,
+// 4 intact cone, 5 smashed cone.
+const ITEM_TYPES = {
+ tv: { baseFrame: 0, smashedFrame: 1, label: 'TV' },
+ chair: { baseFrame: 2, smashedFrame: 3, label: 'Chair' },
+ cone: { baseFrame: 4, smashedFrame: 5, label: 'Cone' },
+};
+
+export default class SmashItemManager {
+ /**
+ * @param scene PlayScene
+ * @param bus the level's Bus
+ * @param level the level data object (items come from `level.items`)
+ *
+ * Owns the level's smashable props: builds one image+body per item,
+ * watches for the bus touching an intact item, and flies each one off
+ * with a smash (swap to its smashed frame + up-and-forward impulse on the
+ * world's real gravity). Float items stay exactly where placed; gravity
+ * items settle onto the ground at level start.
+ */
+ constructor(scene, bus, level) {
+ this.scene = scene;
+ this.bus = bus;
+ this.items = [];
+
+ for (const spec of level.items || []) {
+ const item = this._buildItem(spec);
+ if (item) this.items.push(item);
+ }
+
+ // The bus's bodies that can count as "the bus hitting an item": chassis
+ // + both wheels (the wheel usually does the touching). The compartment
+ // fixtures are intentionally NOT here - their collisionFilter mask is
+ // kidCategory-only, so an item can never actually collide with them
+ // (and neither can it with the kids, whose bodies are on a separate
+ // category the items don't mask against).
+ this._busBodies = [
+ bus.chassis.body,
+ bus.wheelRear.body,
+ bus.wheelFront.body,
+ ];
+
+ scene.matter.world.on('collisionstart', (event) => this._onCollisionStart(event));
+ }
+
+ get smashedCount() {
+ return this.items.filter((item) => item.smashed).length;
+ }
+
+ _buildItem(spec) {
+ const type = ITEM_TYPES[spec.type];
+ if (!type) return null; // unknown/misspelled type in a level file - skip
+
+ const scene = this.scene;
+ const isFloat = spec.mode === 'float';
+
+ // Both modes spawn at the editor's exact placement (spec.x, spec.y).
+ // Float items are static, so they simply stay there. Gravity items are
+ // dynamic, so the first physics step lets them drop onto the ground
+ // under that X and settle - the "falls to the ground" behavior.
+ const image = scene.matter.add.image(spec.x, spec.y, 'smash_items', type.baseFrame, {
+ shape: { type: 'circle', radius: SMASH_ITEM.radius },
+ density: SMASH_ITEM.density,
+ friction: SMASH_ITEM.friction,
+ frictionAir: SMASH_ITEM.frictionAir,
+ restitution: SMASH_ITEM.restitution,
+ });
+ image.setDisplaySize(SMASH_ITEM.displaySize, SMASH_ITEM.displaySize);
+ image.setDepth(1); // same layer as the kids - over the ground, under the bus
+ image.setAngle(0);
+
+ // Intact items are STATIC (a solid prop, infinite mass in the engine's
+ // bookkeeping). A gravity item is left dynamic (it still "rests" where
+ // the editor put it, just supported by the terrain); a float item is
+ // made static here so it holds its placed height forever. When smashed,
+ // setStatic(false) restores the body's real density/mass so the launch
+ // moves it. (setStatic is a Phaser Matter component on the body wrapper,
+ // not on the raw Matter body - see Bus.js / the vendored build.)
+ if (isFloat) image.setStatic(true);
+
+ return { spec, type, image, smashed: false };
+ }
+
+ // Phaser's Matter plugin re-emits matter-js's `collisionStart` as
+ // `collisionstart` with the raw event (see World.js in the vendored build):
+ // the event carries `pairs`, each with `.bodyA`/`.bodyB` - there is no
+ // top-level event.bodyA (PlayScene._onCollisionStart's
+ // `bodyA`/`bodyB` params are actually undefined at runtime; it still works
+ // because its handler is a no-op for this event shape - don't copy that
+ // pattern).
+ _onCollisionStart(event) {
+ const pairs = (event && event.pairs) || [];
+ for (const pair of pairs) {
+ const bodyA = pair.bodyA;
+ const bodyB = pair.bodyB;
+ for (const item of this.items) {
+ if (item.smashed) continue;
+ if (item.image.body !== bodyA && item.image.body !== bodyB) continue;
+ const other = item.image.body === bodyA ? bodyB : bodyA;
+ if (!this._busBodies.includes(other)) continue;
+
+ this._smash(item);
+ this.scene.events.emit('item-smashed', { index: this.items.indexOf(item) });
+ // Each item smashes at most once, so no `break` needed past the
+ // per-pair scan; this loop is O(pairs * items) per physics step,
+ // both tiny for any level we'd plausibly author.
+ }
+ }
+ }
+
+ // The smash: swap to the smashed frame, make the body dynamic (so the
+ // launch actually moves it), and give it the up-and-forward impulse.
+ _smash(item) {
+ const image = item.image;
+ const dirX = Math.sign(this.bus.chassis.body.velocity.x) >= 0 ? 1 : -1;
+
+ // setStatic(false) (Phaser component, same as in _buildItem) restores
+ // the body's saved real mass/inertia - matter-js stashed them when the
+ // body was made static - so the velocity math below is meaningful. For
+ // items that were never static (gravity items) this is a no-op.
+ image.setStatic(false);
+
+ image.setFrame(item.type.smashedFrame);
+
+ // Launch up-and-forward relative to where the bus is HEADED, so the item
+ // always sails off ahead of it - even on a backward hit.
+ // `launchAngle` is a positive magnitude (radians above horizontal):
+ // velocity.x = dirX * cos(angle) * speed (forward, in bus's direction)
+ // velocity.y = -sin(angle) * speed (always upward = negative Y)
+ // This works for both leftward and rightward bus motion.
+ // image.setVelocity / setAngularVelocity are the Phaser Matter components
+ // (the raw body has no such methods - see Components.Velocity in the
+ // vendored build); they delegate to the same Body.setVelocity that the
+ // rest of this codebase uses via KidManager / Bus.
+ const angle = Math.abs(SMASH_ITEM.launchAngle);
+ const speed = SMASH_ITEM.launchSpeed;
+ image.setVelocity(
+ dirX * Math.cos(angle) * speed,
+ -Math.sin(angle) * speed,
+ );
+
+ // A visible tumble while airborne. matter-js stores angular velocity in
+ // radians PER PHYSICS STEP (not per second) - hence the small value.
+ image.setAngularVelocity(SMASH_ITEM.launchSpin * dirX);
+
+ item.smashed = true;
+ }
+
+ destroy() {
+ // Nothing to do: the Matter world's shutdown removes the bodies and its
+ // registered listeners (and nulls scene.matter before this runs), matching
+ // the empty destroy() in Bus.js.
+ }
+}
diff --git a/src/scenes/LevelScoreScene.js b/src/scenes/LevelScoreScene.js
index be77d86..6b2b038 100644
--- a/src/scenes/LevelScoreScene.js
+++ b/src/scenes/LevelScoreScene.js
@@ -25,6 +25,7 @@ export default class LevelScoreScene extends Phaser.Scene {
this.kidsSaved = data.kidsSaved;
this.total = data.total;
this.kidResults = data.kidResults; // boolean[] per seat - true = made it
+ this.smashedItems = data.smashedItems || 0;
this.timeRemainingSeconds = data.timeRemainingSeconds;
this.freezeKey = data.freezeKey;
this.runningScore = 0;
@@ -104,6 +105,9 @@ export default class LevelScoreScene extends Phaser.Scene {
await this._slamKidsIntoScore();
await this._wait(300);
+ await this._revealSmashed();
+ await this._wait(300);
+
await this._tallyTimeRemaining();
await this._wait(400);
@@ -227,6 +231,57 @@ export default class LevelScoreScene extends Phaser.Scene {
}
}
+ // The level's smashed props tally in as a single bonus line (one row, not
+ // one per item - the count can be large and the row is just flavor anyway):
+ // a smashed-cone icon plus "+50 x N" slams into the score, like the kids.
+ // Skipped entirely when the level had no items / none got smashed, so
+ // itemless levels keep their exact old tally flow.
+ async _revealSmashed() {
+ if (this.smashedItems <= 0) return;
+
+ this._playSound('score_count');
+
+ const x = GAME_WIDTH / 2;
+ const sprite = this.add.image(x, KID_ROW_Y, 'smash_items', 5) // smashed cone = the archetypal "smashed" icon
+ .setDisplaySize(KID_SPRITE_SIZE, KID_SPRITE_SIZE)
+ .setScale(0)
+ .setDepth(10);
+
+ const label = this.add.text(x, KID_ROW_Y + KID_SPRITE_SIZE * 0.7, `Smashed x${this.smashedItems} +${this.smashedItems * SCORE.perSmashItem}`, {
+ fontFamily: 'monospace',
+ fontSize: `${18 * WORLD_SCALE}px`,
+ fontStyle: 'bold',
+ color: '#f2c14e',
+ stroke: '#1a1f29',
+ strokeThickness: 4 * WORLD_SCALE,
+ }).setOrigin(0.5).setAlpha(0).setDepth(10);
+
+ await Promise.all([
+ this._tween({ targets: sprite, scale: 1, duration: 300, ease: 'Back.easeOut' }),
+ this.tweens.add({ targets: label, alpha: 1, duration: 200 }),
+ ]);
+ await this._wait(180);
+
+ await this._tween({
+ targets: label,
+ x: this.scoreValueText.x,
+ y: this.scoreValueText.y,
+ scale: 0.4,
+ alpha: 0,
+ duration: 260,
+ ease: 'Cubic.easeIn',
+ });
+ label.destroy();
+
+ this.runningScore += this.smashedItems * SCORE.perSmashItem;
+ this.scoreValueText.setText(String(this.runningScore));
+ this._punch(this.scoreValueText);
+ this._playSound('score_count');
+ this.cameras.main.shake(70, 0.004);
+
+ sprite.destroy();
+ }
+
_showContinueButton() {
const y = GAME_HEIGHT * 0.86;
const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => {
diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js
index be86fa1..4b5b7f9 100644
--- a/src/scenes/PlayScene.js
+++ b/src/scenes/PlayScene.js
@@ -7,6 +7,7 @@ import FinishLine from '../entities/FinishLine.js';
import InputController from '../systems/InputController.js';
import GForceMonitor from '../systems/GForceMonitor.js';
import KidManager from '../systems/KidManager.js';
+import SmashItemManager from '../entities/SmashItemManager.js';
import CameraRig from '../systems/CameraRig.js';
import EngineSound from '../systems/EngineSound.js';
import { stopMenuMusic } from '../util/music.js';
@@ -42,6 +43,11 @@ export default class PlayScene extends Phaser.Scene {
this.kidManager = new KidManager(this, this.bus, this.level.kidsAboard);
+ // Smashable props (TV/chair/cone) placed in the editor's "Smashables"
+ // section - they watch for the bus touching an intact item and fly it
+ // off (see SmashItemManager). No items in a level = no-op.
+ this.smashItems = new SmashItemManager(this, this.bus, this.level);
+
// 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.
@@ -294,6 +300,7 @@ export default class PlayScene extends Phaser.Scene {
const kidsSaved = this.kidManager.kidsAboardCount;
const total = this.kidManager.total;
const kidResults = this.kidManager.kids.map((kid) => kid.state === 'aboard');
+ const smashedItems = this.smashItems.smashedCount;
const timeRemainingSeconds = Math.max(0, Math.ceil(this._timeRemaining));
// Renderer.snapshot() only resolves after the NEXT frame renders, but
@@ -310,6 +317,7 @@ export default class PlayScene extends Phaser.Scene {
kidsSaved,
total,
kidResults,
+ smashedItems,
timeRemainingSeconds,
freezeKey,
});
@@ -329,6 +337,7 @@ export default class PlayScene extends Phaser.Scene {
if (this.compartmentDebug) this.compartmentDebug.destroy();
if (this.gForceMonitor) this.gForceMonitor.destroy();
if (this.kidManager) this.kidManager.destroy();
+ if (this.smashItems) this.smashItems.destroy();
if (this.engineSound) this.engineSound.destroy();
if (this.voiceSound) this.voiceSound.stop();
if (this.bus) this.bus.destroy();
diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js
index bbff0b0..c6a6744 100644
--- a/src/util/assetManifest.js
+++ b/src/util/assetManifest.js
@@ -14,6 +14,11 @@ export const ASSET_MANIFEST = [
// as before - only used for the single-frame placeholder fallback.
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e, frameWidth: 56, frameHeight: 56 },
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b, frameWidth: 56, frameHeight: 56 },
+ // 6 frames of 64x64 smashable props (intact/smashed TV, chair, cone) -
+ // see SMASH_ITEM in config.js and src/entities/SmashItemManager.js.
+ // frameWidth/frameHeight are raw SOURCE pixels (one 64x64 cell each),
+ // same convention as the kid sheets above.
+ { key: 'smash_items', path: 'assets/sprites/smash-items.png', width: 64 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, frameWidth: 64, frameHeight: 64 },
{ 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 },