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 },