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:
parent
d6e998027b
commit
f4b89a25df
10
README.md
10
README.md
|
|
@ -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
|
||||
|
|
|
|||
74
editor.html
74
editor.html
|
|
@ -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>✕ 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">◀ Move Left</button>
|
||||
<button id="move-right-btn">Move Right ▶</button>
|
||||
<button id="remove-btn">✕ 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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
currentLevelData = buildLevelData(state.sequence, state.metadata, state.items);
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue