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
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 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));
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 {
display: flex;
flex-direction: column;
@ -230,34 +270,52 @@
</section>
<section>
<h2>Sections</h2>
<div id="palette"></div>
<h2>Load an existing level</h2>
<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 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>
<div id="track"></div>
<div id="track-controls">
<div id="track" data-section-locked></div>
<div id="track-controls" data-section-locked>
<button id="move-left-btn">&#9664; Move Left</button>
<button id="move-right-btn">Move Right &#9654;</button>
<button id="remove-btn">&#10005; Remove</button>
</div>
<div id="size-panel" hidden>
<div class="size-field">
<div class="size-field" data-section-locked>
<label for="width-range">Width (horizontal)</label>
<div class="size-field-row">
<input type="range" id="width-range" min="250" max="2100" step="10">
<input type="number" id="width-number" min="250" max="2100" step="10">
</div>
</div>
<div class="size-field">
<div class="size-field" data-section-locked>
<label for="vscale-range">Vertical Scale</label>
<div class="size-field-row">
<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">
</div>
</div>
<div class="size-field">
<div class="size-field" data-section-locked>
<label for="smooth-checkbox">Entry</label>
<label class="checkbox-row">
<input type="checkbox" id="smooth-checkbox">

View File

@ -9,6 +9,7 @@ import {
import { buildLevelData } from './levelBuilder.js';
import { exportLevelToSource } from './exportLevel.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
// 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 },
sequence: [{ type: 'flat', width: SECTION_WIDTH, vScale: 1, smooth: false }],
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
// 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
@ -48,12 +55,108 @@ const downloadBtn = document.getElementById('download-btn');
const itemsFormEl = document.getElementById('items-form');
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;
// 'flat' and 'gap' have no rise/amplitude/depth for vScale to multiply -
// disable that control rather than leave it silently do nothing.
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() {
paletteEl.innerHTML = '';
for (const { id, label } of SECTION_TYPES) {
@ -625,6 +728,52 @@ previewCanvas.addEventListener('click', (e) => {
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 --------------------------------------------------------------
let currentLevelData = null;
@ -646,12 +795,36 @@ downloadBtn.addEventListener('click', () => {
// --- Render loop -----------------------------------------------------------
function render() {
setSectionsLocked(isImported());
clearLevelBtn.disabled = !isImported();
buildTrack();
updateTrackControls();
updateSizePanel();
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);
}
if (!currentLevelData) {
previewScrollEl.innerHTML = '<div id="empty-state">Add a section to see the preview.</div>';
@ -683,5 +856,6 @@ function initSizeControls() {
buildPalette();
buildMetadataForm();
buildItemsForm();
buildImportSelect();
initSizeControls();
render();