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
|
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
|
||||||
|
|
|
||||||
74
editor.html
74
editor.html
|
|
@ -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>✕ 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">◀ Move Left</button>
|
<button id="move-left-btn">◀ Move Left</button>
|
||||||
<button id="move-right-btn">Move Right ▶</button>
|
<button id="move-right-btn">Move Right ▶</button>
|
||||||
<button id="remove-btn">✕ Remove</button>
|
<button id="remove-btn">✕ 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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
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) {
|
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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue