Add smashable props (TV/chair/cone) with physics-based launch on bus collision

- New SmashItemManager entity that spawns items from level data, detects bus contact, swaps to smashed frame, and applies up-and-forward impulse with tumble
- Add `SMASH_ITEM` config block for launch speed/angle/spin, density, friction, restitution, and float height
- Extend asset manifest and spritesheet (6 frames: intact/smashed TV, chair, cone)
- Level editor gains "Smashables" section to place/delete items via click on preview map; exports `items` array in level data
- Level editor can now import bundled or file-based levels, preserving exact terrain while locking section editing; metadata and items remain editable for re-export
- Score scene reveals smashed item count as a bonus line (+50 each) between kids and time tally
- PlayScene wires SmashItemManager lifecycle and passes smashedCount to score scene
- README documents smashables feature, editor workflow, and import behavior
This commit is contained in:
Brian Fertig 2026-08-22 14:56:02 -06:00
parent d6e998027b
commit f4b89a25df
3 changed files with 251 additions and 9 deletions

View File

@ -110,6 +110,16 @@ The **level editor** (`editor.html`) has a "Smashables" section: pick a type
existing item (on the map or in the list) to remove it. Exporting the level existing item (on the map or in the list) to remove it. Exporting the level
writes the `items` array into the level file automatically. writes the `items` array into the level file automatically.
The editor can also **load an existing level** (the "Load an existing level"
section) - either pick a bundled level from the dropdown (all levels from
`src/data/levels/index.js`) or load a `levelNN.js` file from disk. Its
terrain is preserved exactly: since it wasn't built from sections, the
sections palette / sequence / size controls lock (dimmed and inert) and the
preview + export render the loaded level's own terrain. Details (id / name /
description / kids) and smashables stay editable, so you can tweak them and
re-export. "Start a new level" clears the import and unlocks the section
controls again.
## Phaser 4 loading ## Phaser 4 loading
Phaser is vendored locally at `vendor/phaser.esm.js` (downloaded once from Phaser is vendored locally at `vendor/phaser.esm.js` (downloaded once from

View File

@ -170,6 +170,46 @@
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px; gap: 10px;
} }
.is-locked {
opacity: 0.45;
pointer-events: none;
}
.import-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 16px;
}
.import-row label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #9aa7bb;
}
.import-row select, .import-row input[type="file"] {
background: #0b0e14;
border: 1px solid #2c374f;
color: #e8eef7;
border-radius: 4px;
padding: 6px 8px;
font-family: monospace;
font-size: 13px;
}
#clear-level-btn {
background: #2255aa;
color: #fff;
border: 1px solid #1a1f29;
border-radius: 4px;
padding: 8px 12px;
font-family: monospace;
font-size: 13px;
cursor: pointer;
}
#clear-level-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
#metadata-form label { #metadata-form label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -230,34 +270,52 @@
</section> </section>
<section> <section>
<h2>Sections</h2> <h2>Load an existing level</h2>
<div id="palette"></div> <p class="hint">Import a bundled level or a <code>levelNN.js</code> file. Its terrain is preserved exactly (section editing is locked, since it wasn't built from sections), and you can adjust its details and smashables, then re-export it.</p>
<div class="import-row">
<label>
<span>Bundled level</span>
<select id="import-level-select"></select>
</label>
<label>
<span>Or load a file</span>
<input type="file" id="import-file" accept=".js,.txt">
</label>
<button id="clear-level-btn" disabled>&#10005; Start a new level</button>
</div>
<div id="import-status" class="hint" hidden></div>
</section> </section>
<section> <section id="palette-section">
<h2>Sections</h2>
<p class="hint" data-section-locked>Add sections to build a new track.</p>
<div id="palette" data-section-locked></div>
</section>
<section id="sequence-section">
<h2>Sequence</h2> <h2>Sequence</h2>
<div id="track"></div> <div id="track" data-section-locked></div>
<div id="track-controls"> <div id="track-controls" data-section-locked>
<button id="move-left-btn">&#9664; Move Left</button> <button id="move-left-btn">&#9664; Move Left</button>
<button id="move-right-btn">Move Right &#9654;</button> <button id="move-right-btn">Move Right &#9654;</button>
<button id="remove-btn">&#10005; Remove</button> <button id="remove-btn">&#10005; Remove</button>
</div> </div>
<div id="size-panel" hidden> <div id="size-panel" hidden>
<div class="size-field"> <div class="size-field" data-section-locked>
<label for="width-range">Width (horizontal)</label> <label for="width-range">Width (horizontal)</label>
<div class="size-field-row"> <div class="size-field-row">
<input type="range" id="width-range" min="250" max="2100" step="10"> <input type="range" id="width-range" min="250" max="2100" step="10">
<input type="number" id="width-number" min="250" max="2100" step="10"> <input type="number" id="width-number" min="250" max="2100" step="10">
</div> </div>
</div> </div>
<div class="size-field"> <div class="size-field" data-section-locked>
<label for="vscale-range">Vertical Scale</label> <label for="vscale-range">Vertical Scale</label>
<div class="size-field-row"> <div class="size-field-row">
<input type="range" id="vscale-range" min="0.3" max="2.5" step="0.05"> <input type="range" id="vscale-range" min="0.3" max="2.5" step="0.05">
<input type="number" id="vscale-number" min="0.3" max="2.5" step="0.05"> <input type="number" id="vscale-number" min="0.3" max="2.5" step="0.05">
</div> </div>
</div> </div>
<div class="size-field"> <div class="size-field" data-section-locked>
<label for="smooth-checkbox">Entry</label> <label for="smooth-checkbox">Entry</label>
<label class="checkbox-row"> <label class="checkbox-row">
<input type="checkbox" id="smooth-checkbox"> <input type="checkbox" id="smooth-checkbox">

View File

@ -9,6 +9,7 @@ import {
import { buildLevelData } from './levelBuilder.js'; import { buildLevelData } from './levelBuilder.js';
import { exportLevelToSource } from './exportLevel.js'; import { exportLevelToSource } from './exportLevel.js';
import { BUS } from '../config.js'; import { BUS } from '../config.js';
import { LEVELS } from '../data/levels/index.js';
// The leading section is always 'flat' and locked at index 0, so a jump // The leading section is always 'flat' and locked at index 0, so a jump
// section can never end up as the very first thing the bus drives onto - // section can never end up as the very first thing the bus drives onto -
@ -20,6 +21,12 @@ const state = {
metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 }, metadata: { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 },
sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }], sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }],
selectedIndex: null, selectedIndex: null,
// Loaded from a bundled level or a file? When true the sections palette / track /
// size controls are locked (the terrain isn't built from sections, so rebuilding it
// from an arbitrary sequence would lose the original shape), and the preview shows
// the level's exact terrain instead of one the sequence generates. Details +
// items remain editable, and the level can be re-exported.
imported: null,
// Smashable props placed on the course (see the "Smashables" section below // 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 // 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 preview canvas plots (and that the exported level uses); refIndex is
@ -48,12 +55,108 @@ const downloadBtn = document.getElementById('download-btn');
const itemsFormEl = document.getElementById('items-form'); const itemsFormEl = document.getElementById('items-form');
const itemsListEl = document.getElementById('items-list'); const itemsListEl = document.getElementById('items-list');
// --- Load an existing level --------------------------------------------
const importSelectEl = document.getElementById('import-level-select');
const importFileEl = document.getElementById('import-file');
const clearLevelBtn = document.getElementById('clear-level-btn');
const importStatusEl = document.getElementById('import-status');
const paletteSectionEl = document.getElementById('palette-section');
const sequenceSectionEl = document.getElementById('sequence-section');
const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId; const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId;
// 'flat' and 'gap' have no rise/amplitude/depth for vScale to multiply - // 'flat' and 'gap' have no rise/amplitude/depth for vScale to multiply -
// disable that control rather than leave it silently do nothing. // disable that control rather than leave it silently do nothing.
const FLAT_LIKE_TYPES = new Set(['flat', 'gap']); const FLAT_LIKE_TYPES = new Set(['flat', 'gap']);
// --- Load an existing level ------------------------------------------------
//
// An imported level is the game's source-of-truth for its terrain (its exact
// `terrain` array), so the preview renders that instead of what `state.sequence`
// would generate, and section editing is locked. Metadata + items + goal/start
// stay editable, and re-export emits the loaded level's fields verbatim.
function buildImportSelect() {
importSelectEl.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = 'Choose a level\u2026';
importSelectEl.appendChild(ph);
for (const level of LEVELS) {
const opt = document.createElement('option');
opt.value = level.id;
opt.textContent = `${level.id} \u2014 ${level.name || ''}`;
importSelectEl.appendChild(opt);
}
}
function setImported(level, source) {
if (!level || !Array.isArray(level.terrain) || level.terrain.length === 0) {
importStatusEl.hidden = false;
importStatusEl.style.color = '#c0392b';
importStatusEl.textContent = 'Could not import \u2014 the level has no terrain.';
return;
}
state.imported = {
metadata: {
id: level.id || 'levelCustom',
name: level.name || '',
description: level.description || '',
kidsAboard: level.kidsAboard || 1,
timeLimit: level.timeLimit,
startPosition: level.startPosition,
startAngle: level.startAngle,
goal: level.goal,
cameraBounds: level.cameraBounds,
obstacles: level.obstacles,
},
terrain: level.terrain,
items: (level.items || []).map((it) => ({ ...it })),
source,
};
// Mirror the imported fields into the shared metadata so edits to name/id/
// kids in the Level Details form are reflected in the re-export.
state.metadata = {
id: state.imported.metadata.id,
name: state.imported.metadata.name,
description: state.imported.metadata.description,
kidsAboard: state.imported.metadata.kidsAboard,
timeLimit: state.imported.metadata.timeLimit,
};
state.items = state.imported.items.map((it) => ({ ...it }));
buildMetadataForm(); // reflect the imported id/name/description/kids in the form
importStatusEl.hidden = false;
importStatusEl.style.color = '#2c8f3c';
importStatusEl.textContent = `Loaded ${state.imported.metadata.id} \u2014 \"${state.imported.metadata.name}\" from ${source}.`;
render();
}
function clearImported() {
state.imported = null;
// "Start a new level" = full reset to the blank-canvas defaults, so the
// imported level's items/metadata don't bleed into the fresh level.
state.items = [];
state.selectedIndex = null;
state.sequence = [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }];
state.metadata = { id: 'levelCustom', name: 'New Level', description: '', kidsAboard: 3 };
importStatusEl.hidden = true;
importStatusEl.textContent = '';
importSelectEl.value = '';
if (importFileEl) importFileEl.value = '';
buildMetadataForm();
render();
}
function isImported() {
return state.imported !== null;
}
function setSectionsLocked(locked) {
for (const el of document.querySelectorAll('[data-section-locked]')) {
el.classList.toggle('is-locked', locked);
}
}
function buildPalette() { function buildPalette() {
paletteEl.innerHTML = ''; paletteEl.innerHTML = '';
for (const { id, label } of SECTION_TYPES) { for (const { id, label } of SECTION_TYPES) {
@ -625,6 +728,52 @@ previewCanvas.addEventListener('click', (e) => {
render(); render();
}); });
// --- Import wiring -------------------------------------------------------
importSelectEl.addEventListener('change', () => {
const id = importSelectEl.value;
if (!id) return;
const level = LEVELS.find((l) => l.id === id);
if (!level) {
importStatusEl.hidden = false;
importStatusEl.style.color = '#c0392b';
importStatusEl.textContent = `No level with id "${id}".`;
return;
}
setImported(level, 'bundled levels');
});
importFileEl.addEventListener('change', async () => {
const file = importFileEl.files && importFileEl.files[0];
if (!file) return;
try {
const text = await file.text();
// Level files are ES modules (`export default {...}`). Parse the actual
// source as a module (not `new Function('return ...')`, which a leading
// `//` comment would swallow into the return) via a blob URL, then read
// its default export.
const blob = new Blob([text], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
let level;
try {
const mod = await import(url);
level = mod.default;
} finally {
URL.revokeObjectURL(url);
}
if (!level || typeof level !== 'object') throw new Error('not a level object');
setImported(level, file.name);
} catch (err) {
importStatusEl.hidden = false;
importStatusEl.style.color = '#c0392b';
importStatusEl.textContent = `Could not read ${file.name}: ${err.message}`;
}
});
clearLevelBtn.addEventListener('click', () => {
clearImported();
});
// --- Export -------------------------------------------------------------- // --- Export --------------------------------------------------------------
let currentLevelData = null; let currentLevelData = null;
@ -646,12 +795,36 @@ downloadBtn.addEventListener('click', () => {
// --- Render loop ----------------------------------------------------------- // --- Render loop -----------------------------------------------------------
function render() { function render() {
setSectionsLocked(isImported());
clearLevelBtn.disabled = !isImported();
buildTrack(); buildTrack();
updateTrackControls(); updateTrackControls();
updateSizePanel(); updateSizePanel();
buildItemsList(); buildItemsList();
if (isImported()) {
// Imported level: render its own exact terrain (plus the editable goal,
// start, items) instead of regenerating from the (locked) sequence.
const imp = state.imported;
currentLevelData = {
id: state.metadata.id,
name: state.metadata.name,
description: state.metadata.description,
kidsAboard: state.metadata.kidsAboard,
timeLimit: state.metadata.timeLimit,
startPosition: imp.metadata.startPosition,
startAngle: imp.metadata.startAngle,
terrain: imp.terrain,
obstacles: imp.metadata.obstacles || [],
...(state.items.length > 0 ? { items: state.items.map((it) => ({
type: it.type, mode: it.mode, x: Math.round(it.x), y: Math.round(it.y),
})) } : {}),
goal: imp.metadata.goal,
cameraBounds: imp.metadata.cameraBounds,
};
} else {
currentLevelData = buildLevelData(state.sequence, state.metadata, state.items); currentLevelData = buildLevelData(state.sequence, state.metadata, state.items);
}
if (!currentLevelData) { if (!currentLevelData) {
previewScrollEl.innerHTML = '<div id="empty-state">Add a section to see the preview.</div>'; previewScrollEl.innerHTML = '<div id="empty-state">Add a section to see the preview.</div>';
@ -683,5 +856,6 @@ function initSizeControls() {
buildPalette(); buildPalette();
buildMetadataForm(); buildMetadataForm();
buildItemsForm(); buildItemsForm();
buildImportSelect();
initSizeControls(); initSizeControls();
render(); render();