Compare commits
4 Commits
f88b38a49c
...
cdc26001fd
| Author | SHA1 | Date |
|---|---|---|
|
|
cdc26001fd | |
|
|
d9b6a754d8 | |
|
|
f4b89a25df | |
|
|
d6e998027b |
46
README.md
46
README.md
|
|
@ -53,6 +53,7 @@ no code changes needed.**
|
||||||
| `bus_wheel` | `assets/sprites/bus_wheel.png` | 52x52 | used for both wheels |
|
| `bus_wheel` | `assets/sprites/bus_wheel.png` | 52x52 | used for both wheels |
|
||||||
| `kid_idle` | `assets/sprites/kid_idle.png` | 28x28 | passenger while aboard |
|
| `kid_idle` | `assets/sprites/kid_idle.png` | 28x28 | passenger while aboard |
|
||||||
| `kid_ejected` | `assets/sprites/kid_ejected.png` | 28x28 | passenger after being thrown off |
|
| `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_far` | `assets/backgrounds/bg_far.png` | 256x540 | tiled, slowest parallax layer |
|
||||||
| `bg_mid` | `assets/backgrounds/bg_mid.png` | 256x540 | tiled, mid 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 |
|
| `bg_near` | `assets/backgrounds/bg_near.png` | 256x540 | tiled, fastest parallax layer |
|
||||||
|
|
@ -70,10 +71,55 @@ Everything worth tweaking lives in `src/config.js`, notably:
|
||||||
doesn't empty the whole bus at once.
|
doesn't empty the whole bus at once.
|
||||||
- `BUS.*` - suspension stiffness/damping, wheel friction, throttle/lean torque
|
- `BUS.*` - suspension stiffness/damping, wheel friction, throttle/lean torque
|
||||||
and their clamps.
|
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
|
Set `DEBUG = true` in `config.js` to show a live g-force readout and Matter's
|
||||||
physics debug overlay in `PlayScene`.
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
96
editor.html
96
editor.html
|
|
@ -151,11 +151,65 @@
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
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 {
|
#metadata-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
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;
|
||||||
|
|
@ -182,6 +236,7 @@
|
||||||
}
|
}
|
||||||
#preview-canvas {
|
#preview-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
#export-textarea {
|
#export-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -215,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">
|
||||||
|
|
@ -252,6 +325,13 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Smashables</h2>
|
||||||
|
<p class="hint">Pick a type + placement mode, then <b>click anywhere on the Preview map below</b> to drop the item there. Click a placed item (on the map or in the list) to delete it.</p>
|
||||||
|
<div id="items-form"></div>
|
||||||
|
<div id="items-list" style="margin-top:10px;display:flex;flex-direction:column;gap:6px;"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Preview</h2>
|
<h2>Preview</h2>
|
||||||
<div id="preview-scroll"><canvas id="preview-canvas"></canvas></div>
|
<div id="preview-scroll"><canvas id="preview-canvas"></canvas></div>
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,58 @@ export const SCORE = {
|
||||||
perKidSaved: 100,
|
perKidSaved: 100,
|
||||||
perKidLost: -50,
|
perKidLost: -50,
|
||||||
perSecondRemaining: 10,
|
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 = {
|
||||||
|
// Circle hitbox. The spritesheet art sits in the LOWER half of each 64x64
|
||||||
|
// cell and fills down to the very bottom of that cell, so an item's VISUAL
|
||||||
|
// bottom edge is exactly displayHalf (= SMASH_ITEM.displaySize / 2 = 64
|
||||||
|
// world px) below its centre. In this build a Matter circle's on-screen
|
||||||
|
// radius is 2x the value passed to shape.radius (32 -> 64 world px, the
|
||||||
|
// game's own convention), so we pass displaySize/4 to make the PHYSICAL
|
||||||
|
// bottom coincide with the VISUAL bottom. That is what stops a gravity item
|
||||||
|
// from hovering above the ground - its art actually reaches the terrain
|
||||||
|
// (and any other body it falls onto) instead of floating a gap above it.
|
||||||
|
radius: 16 * WORLD_SCALE, // = SMASH_ITEM.displaySize / 4 (body radius -> 64)
|
||||||
|
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 = {
|
export const CAMERA = {
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,12 @@ export default {
|
||||||
] },
|
] },
|
||||||
],
|
],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
|
items: [
|
||||||
|
{ type: "tv", mode: "gravity", x: 5754, y: 444 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 2479, y: 369 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 2721, y: 219 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 2521, y: 136 },
|
||||||
|
],
|
||||||
goal: { x: 25420, y: 406, width: 280, height: 640 },
|
goal: { x: 25420, y: 406, width: 280, height: 640 },
|
||||||
cameraBounds: { x: -600, y: -522, width: 27060, height: 2414 },
|
cameraBounds: { x: -600, y: -522, width: 27060, height: 2414 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ function rectLiteral(rect) {
|
||||||
return `{ x: ${rect.x}, y: ${rect.y}, width: ${rect.width}, height: ${rect.height} }`;
|
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) {
|
export function exportLevelToSource(levelData) {
|
||||||
const lines = [
|
const lines = [
|
||||||
'// Generated by editor.html - review before dropping into src/data/levels/.',
|
'// Generated by editor.html - review before dropping into src/data/levels/.',
|
||||||
|
|
@ -33,6 +43,9 @@ export function exportLevelToSource(levelData) {
|
||||||
` startAngle: ${levelData.startAngle},`,
|
` startAngle: ${levelData.startAngle},`,
|
||||||
` terrain: ${terrainLiteral(levelData.terrain)},`,
|
` terrain: ${terrainLiteral(levelData.terrain)},`,
|
||||||
' obstacles: [],',
|
' 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)},`,
|
` goal: ${rectLiteral(levelData.goal)},`,
|
||||||
` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`,
|
` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`,
|
||||||
'};',
|
'};',
|
||||||
|
|
|
||||||
|
|
@ -156,9 +156,12 @@ function appendPoints(accumulator, points) {
|
||||||
// the previous section's (see smoothEntry above) - off by default, since a
|
// 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.
|
// "Sharp Incline" or a jump's ramp is often supposed to look abrupt.
|
||||||
// metadata: { id, name, description, kidsAboard }.
|
// 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
|
// Returns a full level data object matching level01.js's shape, or null if
|
||||||
// sections is empty.
|
// sections is empty.
|
||||||
export function buildLevelData(sections, metadata) {
|
export function buildLevelData(sections, metadata, items) {
|
||||||
if (!sections || sections.length === 0) return null;
|
if (!sections || sections.length === 0) return null;
|
||||||
|
|
||||||
let baseX = -TERRAIN_LEAD_IN;
|
let baseX = -TERRAIN_LEAD_IN;
|
||||||
|
|
@ -245,6 +248,16 @@ export function buildLevelData(sections, metadata) {
|
||||||
startAngle: 0,
|
startAngle: 0,
|
||||||
terrain: terrainSegments,
|
terrain: terrainSegments,
|
||||||
obstacles: [],
|
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: {
|
goal: {
|
||||||
x: goalX,
|
x: goalX,
|
||||||
y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE),
|
y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE),
|
||||||
|
|
|
||||||
|
|
@ -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,19 @@ 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
|
||||||
|
// 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');
|
const paletteEl = document.getElementById('palette');
|
||||||
|
|
@ -38,6 +52,16 @@ const previewScrollEl = document.getElementById('preview-scroll');
|
||||||
const previewCanvas = document.getElementById('preview-canvas');
|
const previewCanvas = document.getElementById('preview-canvas');
|
||||||
const exportTextarea = document.getElementById('export-textarea');
|
const exportTextarea = document.getElementById('export-textarea');
|
||||||
const downloadBtn = document.getElementById('download-btn');
|
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;
|
const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId;
|
||||||
|
|
||||||
|
|
@ -45,6 +69,94 @@ const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label |
|
||||||
// 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) {
|
||||||
|
|
@ -59,6 +171,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 <img> 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() {
|
function buildMetadataForm() {
|
||||||
metadataFormEl.innerHTML = '';
|
metadataFormEl.innerHTML = '';
|
||||||
|
|
||||||
|
|
@ -199,16 +452,24 @@ smoothCheckboxEl.addEventListener('change', () => {
|
||||||
moveLeftBtn.addEventListener('click', () => {
|
moveLeftBtn.addEventListener('click', () => {
|
||||||
const i = state.selectedIndex;
|
const i = state.selectedIndex;
|
||||||
if (i === null || i <= 1) return;
|
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.sequence[i - 1], state.sequence[i]] = [state.sequence[i], state.sequence[i - 1]];
|
||||||
state.selectedIndex = i - 1;
|
state.selectedIndex = i - 1;
|
||||||
|
rebaseItemRefs(oldToNew);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
moveRightBtn.addEventListener('click', () => {
|
moveRightBtn.addEventListener('click', () => {
|
||||||
const i = state.selectedIndex;
|
const i = state.selectedIndex;
|
||||||
if (i === null || i === 0 || i === state.sequence.length - 1) return;
|
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.sequence[i + 1], state.sequence[i]] = [state.sequence[i], state.sequence[i + 1]];
|
||||||
state.selectedIndex = i + 1;
|
state.selectedIndex = i + 1;
|
||||||
|
rebaseItemRefs(oldToNew);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -217,6 +478,13 @@ removeBtn.addEventListener('click', () => {
|
||||||
if (i === null || i === 0) return;
|
if (i === null || i === 0) return;
|
||||||
state.sequence.splice(i, 1);
|
state.sequence.splice(i, 1);
|
||||||
state.selectedIndex = null;
|
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();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -317,6 +585,10 @@ function drawPreview(levelData) {
|
||||||
ctx.font = 'bold 12px monospace';
|
ctx.font = 'bold 12px monospace';
|
||||||
ctx.fillText('S', sx - 4, sy + 4);
|
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 g = levelData.goal;
|
||||||
const gx = screenX(g.x);
|
const gx = screenX(g.x);
|
||||||
const gyTop = screenY(g.y - g.height / 2);
|
const gyTop = screenY(g.y - g.height / 2);
|
||||||
|
|
@ -330,8 +602,178 @@ function drawPreview(levelData) {
|
||||||
ctx.fillStyle = '#f2c14e';
|
ctx.fillStyle = '#f2c14e';
|
||||||
ctx.font = '12px monospace';
|
ctx.font = '12px monospace';
|
||||||
ctx.fillText('GOAL', gx - 16, gyTop - 6);
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
|
@ -353,11 +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();
|
||||||
|
|
||||||
currentLevelData = buildLevelData(state.sequence, state.metadata);
|
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>';
|
||||||
|
|
@ -388,5 +855,7 @@ function initSizeControls() {
|
||||||
|
|
||||||
buildPalette();
|
buildPalette();
|
||||||
buildMetadataForm();
|
buildMetadataForm();
|
||||||
|
buildItemsForm();
|
||||||
|
buildImportSelect();
|
||||||
initSizeControls();
|
initSizeControls();
|
||||||
render();
|
render();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
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), give it the up-and-forward impulse, and clear
|
||||||
|
// the bus out of its collision mask so the bus drives straight through it.
|
||||||
|
_smash(item) {
|
||||||
|
const image = item.image;
|
||||||
|
const dirX = Math.sign(this.bus.chassis.body.velocity.x) >= 0 ? 1 : -1;
|
||||||
|
|
||||||
|
// From this moment on the smashed item is no longer solid to the bus.
|
||||||
|
// Clear the bus's category bit out of the item's collision mask so the
|
||||||
|
// broadphase skips every item<->bus pair (Detector.canCollide only
|
||||||
|
// considers a pair when each side's mask includes the other's category -
|
||||||
|
// see the vendored build). Set on the raw matter body directly: that's the
|
||||||
|
// exact field canCollide reads, and matter-js recomputes pairs every step,
|
||||||
|
// so this takes effect on the very next physics step. The item keeps its
|
||||||
|
// mask against everything ELSE (terrain, other items, kids), so it still
|
||||||
|
// lands and rests where it flies - only the bus is exempted. This is what
|
||||||
|
// makes the bus "drive through" a smashed prop instead of bumping it.
|
||||||
|
image.body.collisionFilter.mask = 0xffffffff & ~this.bus.busCategory;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ export default class LevelScoreScene extends Phaser.Scene {
|
||||||
this.kidsSaved = data.kidsSaved;
|
this.kidsSaved = data.kidsSaved;
|
||||||
this.total = data.total;
|
this.total = data.total;
|
||||||
this.kidResults = data.kidResults; // boolean[] per seat - true = made it
|
this.kidResults = data.kidResults; // boolean[] per seat - true = made it
|
||||||
|
this.smashedItems = data.smashedItems || 0;
|
||||||
this.timeRemainingSeconds = data.timeRemainingSeconds;
|
this.timeRemainingSeconds = data.timeRemainingSeconds;
|
||||||
this.freezeKey = data.freezeKey;
|
this.freezeKey = data.freezeKey;
|
||||||
this.runningScore = 0;
|
this.runningScore = 0;
|
||||||
|
|
@ -104,6 +105,9 @@ export default class LevelScoreScene extends Phaser.Scene {
|
||||||
await this._slamKidsIntoScore();
|
await this._slamKidsIntoScore();
|
||||||
await this._wait(300);
|
await this._wait(300);
|
||||||
|
|
||||||
|
await this._revealSmashed();
|
||||||
|
await this._wait(300);
|
||||||
|
|
||||||
await this._tallyTimeRemaining();
|
await this._tallyTimeRemaining();
|
||||||
await this._wait(400);
|
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() {
|
_showContinueButton() {
|
||||||
const y = GAME_HEIGHT * 0.86;
|
const y = GAME_HEIGHT * 0.86;
|
||||||
const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => {
|
const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import FinishLine from '../entities/FinishLine.js';
|
||||||
import InputController from '../systems/InputController.js';
|
import InputController from '../systems/InputController.js';
|
||||||
import GForceMonitor from '../systems/GForceMonitor.js';
|
import GForceMonitor from '../systems/GForceMonitor.js';
|
||||||
import KidManager from '../systems/KidManager.js';
|
import KidManager from '../systems/KidManager.js';
|
||||||
|
import SmashItemManager from '../entities/SmashItemManager.js';
|
||||||
import CameraRig from '../systems/CameraRig.js';
|
import CameraRig from '../systems/CameraRig.js';
|
||||||
import EngineSound from '../systems/EngineSound.js';
|
import EngineSound from '../systems/EngineSound.js';
|
||||||
import { stopMenuMusic } from '../util/music.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);
|
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
|
// Troubleshooting overlay: renders the invisible compartment fixtures
|
||||||
// (floor + walls) as simple rectangles tracking the bus, plus per-kid
|
// (floor + walls) as simple rectangles tracking the bus, plus per-kid
|
||||||
// state readout. Off by default - toggle COMPARTMENT_DEBUG in config.js.
|
// 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 kidsSaved = this.kidManager.kidsAboardCount;
|
||||||
const total = this.kidManager.total;
|
const total = this.kidManager.total;
|
||||||
const kidResults = this.kidManager.kids.map((kid) => kid.state === 'aboard');
|
const kidResults = this.kidManager.kids.map((kid) => kid.state === 'aboard');
|
||||||
|
const smashedItems = this.smashItems.smashedCount;
|
||||||
const timeRemainingSeconds = Math.max(0, Math.ceil(this._timeRemaining));
|
const timeRemainingSeconds = Math.max(0, Math.ceil(this._timeRemaining));
|
||||||
|
|
||||||
// Renderer.snapshot() only resolves after the NEXT frame renders, but
|
// Renderer.snapshot() only resolves after the NEXT frame renders, but
|
||||||
|
|
@ -310,6 +317,7 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
kidsSaved,
|
kidsSaved,
|
||||||
total,
|
total,
|
||||||
kidResults,
|
kidResults,
|
||||||
|
smashedItems,
|
||||||
timeRemainingSeconds,
|
timeRemainingSeconds,
|
||||||
freezeKey,
|
freezeKey,
|
||||||
});
|
});
|
||||||
|
|
@ -329,6 +337,7 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
if (this.compartmentDebug) this.compartmentDebug.destroy();
|
if (this.compartmentDebug) this.compartmentDebug.destroy();
|
||||||
if (this.gForceMonitor) this.gForceMonitor.destroy();
|
if (this.gForceMonitor) this.gForceMonitor.destroy();
|
||||||
if (this.kidManager) this.kidManager.destroy();
|
if (this.kidManager) this.kidManager.destroy();
|
||||||
|
if (this.smashItems) this.smashItems.destroy();
|
||||||
if (this.engineSound) this.engineSound.destroy();
|
if (this.engineSound) this.engineSound.destroy();
|
||||||
if (this.voiceSound) this.voiceSound.stop();
|
if (this.voiceSound) this.voiceSound.stop();
|
||||||
if (this.bus) this.bus.destroy();
|
if (this.bus) this.bus.destroy();
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ export const ASSET_MANIFEST = [
|
||||||
// as before - only used for the single-frame placeholder fallback.
|
// 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_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 },
|
{ 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_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_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 },
|
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue