feat: add bonus items (CTC box / guitar / cash) with firework burst on collection
Introduces a new "bonus item" system alongside the existing smashables: - **Gameplay**: Floating collectible prizes (CTC box, guitar, cash) that the bus passes through with zero physical response. On first contact, the item pops (scale-up + fade) and spawns a radial burst of tinted firework particles that arc down under gravity, settle on terrain, and fade out. Each collected item scores 100 points on the end-of-level tally. - **Config**: `BONUS_ITEM` in `config.js` holds all tuning knobs (particle count, speed range, spin, lifetime, per-type tint palette, pop scale/duration). `SCORE.perBonusItem` added. - **Entity**: New `BonusItemManager` class owns static sensor bodies for each bonus item, detects bus collisions, and orchestrates the pop + firework burst animation. Particles are real Matter bodies exempted from bus/kid collision so they can land on terrain without affecting gameplay. - **Editor**: "Smashables" section renamed to "Placeables" with a kind toggle (Smashables vs Bonus items). Bonus items are float-only with no gravity option. Preview map, click-to-place, item list, and export all support both kinds. `splitPlaceables()` in `levelBuilder.js` splits the unified editor list into the two level arrays (`items` / `bonusItems`). - **Levels**: Levels 2–6 updated with bonus item placements (and some additional smashable items). - **Assets**: New `bonus-items.png` spritesheet (9 frames: 3 bonus items × [item + 2 firework objects]) registered in the asset manifest. - **Score screen**: `_revealBonus()` method in `LevelScoreScene` displays collected bonus count and points, skipped when zero.
This commit is contained in:
parent
8e5398e898
commit
b087d91f4f
56
README.md
56
README.md
|
|
@ -54,6 +54,7 @@ no code changes needed.**
|
||||||
| `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) |
|
| `smash_items` | `assets/sprites/smash-items.png` | 64x64 x 6 frames | 1x6 spritesheet: intact/smashed TV, chair, cone (see Smashables below) |
|
||||||
|
| `bonus_items` | `assets/sprites/bonus-items.png` | 64x64 x 9 frames | 1x9 spritesheet: CTC box + 2 firework objects, guitar + 2, cash + 2 (see Bonus Items 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 |
|
||||||
|
|
@ -105,10 +106,12 @@ it lands and stays. `SMASH_ITEM` in `config.js` holds the launch physics.
|
||||||
Smashed items also tally as +`SCORE.perSmashItem` (50) each on the
|
Smashed items also tally as +`SCORE.perSmashItem` (50) each on the
|
||||||
end-of-level score screen.
|
end-of-level score screen.
|
||||||
|
|
||||||
The **level editor** (`editor.html`) has a "Smashables" section: pick a type
|
The **level editor** (`editor.html`) has a "Placeables" section with a kind
|
||||||
+ mode, then click anywhere on the preview map to drop one there; click an
|
toggle: **Smashables** (TV / chair / cone, float or gravity mode) and
|
||||||
existing item (on the map or in the list) to remove it. Exporting the level
|
**Bonus items** (CTC box / guitar / cash, float-only). Pick the kind + type,
|
||||||
writes the `items` array into the level file automatically.
|
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
|
||||||
|
both the `items` and `bonusItems` arrays into the level file automatically.
|
||||||
|
|
||||||
The editor can also **load an existing level** (the "Load an existing level"
|
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
|
section) - either pick a bundled level from the dropdown (all levels from
|
||||||
|
|
@ -116,9 +119,48 @@ section) - either pick a bundled level from the dropdown (all levels from
|
||||||
terrain is preserved exactly: since it wasn't built from sections, the
|
terrain is preserved exactly: since it wasn't built from sections, the
|
||||||
sections palette / sequence / size controls lock (dimmed and inert) and the
|
sections palette / sequence / size controls lock (dimmed and inert) and the
|
||||||
preview + export render the loaded level's own terrain. Details (id / name /
|
preview + export render the loaded level's own terrain. Details (id / name /
|
||||||
description / kids) and smashables stay editable, so you can tweak them and
|
description / kids) and both placeable types stay editable, so you can tweak
|
||||||
re-export. "Start a new level" clears the import and unlocks the section
|
them and re-export. "Start a new level" clears the import and unlocks the
|
||||||
controls again.
|
section controls again.
|
||||||
|
|
||||||
|
## Bonus Items (CTC box / guitar / cash)
|
||||||
|
|
||||||
|
`assets/sprites/bonus-items.png` is a 1x9 spritesheet (CTC box, 2 firework
|
||||||
|
frames, guitar, 2 firework frames, cash, 2 firework frames - 64x64 per
|
||||||
|
frame), loaded as the `bonus_items` texture. Any level can carry an optional
|
||||||
|
`bonusItems` array (see the editor's export for the exact shape):
|
||||||
|
|
||||||
|
```js
|
||||||
|
bonusItems: [
|
||||||
|
{ type: 'ctc', x: 3000, y: 500 },
|
||||||
|
{ type: 'cash', x: 5000, y: 450 },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Bonus items are **always float** - they're static sensors resting exactly at
|
||||||
|
`(x, y)`, never dynamic, never on the ground. The editor enforces this: the
|
||||||
|
bonus-item form has no gravity option, only a type selector.
|
||||||
|
|
||||||
|
The first time the bus (chassis or either wheel) touches an uncollected
|
||||||
|
bonus item it **pops**: the item sprite scales up and fades out over ~200 ms,
|
||||||
|
and a radial burst of the item's two firework-object frames (14 particles,
|
||||||
|
alternating frames and tints) launches outward from the pop point at
|
||||||
|
`BONUS_ITEM.minSpeed`–`maxSpeed` (Matter px/step, same convention as
|
||||||
|
`SMASH_ITEM.launchSpeed`), arcing down under the world's real gravity, then
|
||||||
|
settling on the terrain and fading out after `BONUS_ITEM.lifeMs` (1.3 s).
|
||||||
|
|
||||||
|
Each firework particle is a real Matter body (so gravity and terrain
|
||||||
|
response apply), but its collision mask explicitly excludes the bus and the
|
||||||
|
kids (same trick as `SmashItemManager._smash`) so it can never bump the bus
|
||||||
|
or flick a kid out of the compartment. It does still collide with terrain
|
||||||
|
so it lands where it lands. Particles are tinted per the bonus type's own
|
||||||
|
color scheme (`BONUS_ITEM.colors`): CTC = warm toast-brown/cinnamon, guitar
|
||||||
|
= bright/deep green, cash = muted sage/olive-grey.
|
||||||
|
|
||||||
|
Collected bonus items tally as `SCORE.perBonusItem` (100) each on the
|
||||||
|
end-of-level score screen, revealed right after the smashed-props line.
|
||||||
|
`BONUS_ITEM` in `config.js` holds all the tuning knobs (particle count,
|
||||||
|
speed range, spin, lifetime, tint palette, pop scale/duration).
|
||||||
|
|
||||||
## Phaser 4 loading
|
## Phaser 4 loading
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 62 KiB |
|
|
@ -326,8 +326,8 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Smashables</h2>
|
<h2>Placeables</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>
|
<p class="hint">Pick the placeable <b>kind</b> (Smashables or Bonus items) + its type, then <b>click anywhere on the Preview map below</b> to drop it there. Click a placed item (on the map or in the list) to delete it. Bonus items are float-only; smashables can float or fall to the ground.</p>
|
||||||
<div id="items-form"></div>
|
<div id="items-form"></div>
|
||||||
<div id="items-list" style="margin-top:10px;display:flex;flex-direction:column;gap:6px;"></div>
|
<div id="items-list" style="margin-top:10px;display:flex;flex-direction:column;gap:6px;"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,9 @@ export const SCORE = {
|
||||||
// Per smashable item (TV/chair/cone, see SMASH_ITEM) knocked off its
|
// 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.
|
// perch by the bus - revealed on the tally between the kids and the clock.
|
||||||
perSmashItem: 50,
|
perSmashItem: 50,
|
||||||
|
// Per bonus item (CTC box / guitar / cash - see BONUS_ITEM) the bus plows
|
||||||
|
// through - revealed on the tally right after the smashed-props line.
|
||||||
|
perBonusItem: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Smashable items (assets/sprites/smash-items.png - 6 frames of 64x64, one
|
// Smashable items (assets/sprites/smash-items.png - 6 frames of 64x64, one
|
||||||
|
|
@ -249,6 +252,62 @@ export const SMASH_ITEM = {
|
||||||
floatHeight: 90 * WORLD_SCALE,
|
floatHeight: 90 * WORLD_SCALE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bonus items (assets/sprites/bonus-items.png - 9 frames of 64x64: a bonus
|
||||||
|
// item followed by its two firework-object frames - see
|
||||||
|
// src/entities/BonusItemManager.js for the type/frame/color table). Placed
|
||||||
|
// per-level via the editor's "Placeable kind" switch ("Bonus items") and
|
||||||
|
// exported into the level's `bonusItems` array. ALWAYS float-only - the
|
||||||
|
// editor has no gravity option for them: they're mid-air collectible-looking
|
||||||
|
// prizes, never ground props.
|
||||||
|
//
|
||||||
|
// A bonus item is a SENSOR: the bus can pass through it with zero physical
|
||||||
|
// response (no bounce, no spin, no impulse - unlike smashables, which are
|
||||||
|
// real bodies that fly off). The first time the bus touches it, it pops and
|
||||||
|
// plays a firework show: a radial burst of its two firework-object sprites
|
||||||
|
// (tinted to its own color scheme, see BONUS_ITEM.colors) that blast
|
||||||
|
// outward and arc down on the world's gravity, then fade away.
|
||||||
|
export const BONUS_ITEM = {
|
||||||
|
// Hitbox. bonus-items.png art roughly fills its 64x64 cell (a bit of
|
||||||
|
// transparent padding on the edges). Same displaySize/4 hitbox convention
|
||||||
|
// as SMASH_ITEM.radius - the two managers' physics sizing stay consistent.
|
||||||
|
// Sensor bodies, so exactness only matters for "how close can the bus get
|
||||||
|
// before the pop fires".
|
||||||
|
itemDisplaySize: 64 * WORLD_SCALE, // the bonus item sprite itself, shown square at this size; hitbox radius is itemDisplaySize / 4 (see BonusItemManager._buildItem)
|
||||||
|
|
||||||
|
// Bonus item pop (the item sprite itself, at the moment of the hit): a
|
||||||
|
// quick grow+fade so the burst below reads as the aftermath, not a
|
||||||
|
// separate event. See BonusItemManager._explode for the full show.
|
||||||
|
popDurationMs: 220,
|
||||||
|
popScale: 1.35,
|
||||||
|
|
||||||
|
// Firework particles (see BonusItemManager._explode). Speeds are Matter
|
||||||
|
// units (px per 60Hz physics step, same convention as
|
||||||
|
// SMASH_ITEM.launchSpeed - the world's gravity in main.js,
|
||||||
|
// 0.675 * WORLD_SCALE = 1.35 px/step^2, is what pulls them back down
|
||||||
|
// into the arc). Calibrated like SMASH_ITEM.launchSpeed: with gravity
|
||||||
|
// 1.35, a ~14-16 px/step radial burst hangs for ~0.7-1.0s and drifts
|
||||||
|
// ~150-200px outward before falling - a satisfying "firework ball" around
|
||||||
|
// the pop point.
|
||||||
|
particleCount: 14, // burst size: the item's two firework-object frames alternate around the ring
|
||||||
|
minSpeed: 14,
|
||||||
|
maxSpeed: 16,
|
||||||
|
spinRange: 0.35, // random angular velocity (radians per physics step) on each particle, +/- this
|
||||||
|
lifeMs: 1300, // how long a particle stays visible; the fade-out is the last 40% of this
|
||||||
|
particleDisplaySize: 40 * WORLD_SCALE, // each firework particle is shown square at this size (80 world px, half the bonus item's own 128)
|
||||||
|
|
||||||
|
// Tint per bonus type, matched to each item's art: the firework sprites
|
||||||
|
// (which are drawn in their own colors) get a PHASER.MULTIPLY tint of
|
||||||
|
// these so the burst reads as an echo of the item's own color scheme
|
||||||
|
// (multiply darkens/complements rather than overriding - a white pixel
|
||||||
|
// becomes the tint color, saturated pixels get their own hue pushed into
|
||||||
|
// it). Particles alternate between two tints (main + accent).
|
||||||
|
colors: {
|
||||||
|
ctc: { main: 0xe0a040, accent: 0x703818 }, // warm toast-brown / cinnamon
|
||||||
|
guitar: { main: 0x50c050, accent: 0x105018 }, // bright green / deep forest
|
||||||
|
cash: { main: 0xaab888, accent: 0x404030 }, // muted sage / olive-grey (cash's own scheme is near-white grey-green)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const CAMERA = {
|
export const CAMERA = {
|
||||||
lerpX: 0.1,
|
lerpX: 0.1,
|
||||||
lerpY: 0.1,
|
lerpY: 0.1,
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,10 @@ export default {
|
||||||
] },
|
] },
|
||||||
],
|
],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
|
bonusItems: [
|
||||||
|
{ type: "guitar", x: 11404, y: -134 },
|
||||||
|
{ type: "ctc", x: 7746, y: -1017 },
|
||||||
|
],
|
||||||
goal: { x: 16440, y: 472, width: 280, height: 640 },
|
goal: { x: 16440, y: 472, width: 280, height: 640 },
|
||||||
cameraBounds: { x: -600, y: -1542, width: 18080, height: 3182 },
|
cameraBounds: { x: -600, y: -1542, width: 18080, height: 3182 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,12 @@ export default {
|
||||||
] },
|
] },
|
||||||
],
|
],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
|
items: [
|
||||||
|
{ type: "chair", mode: "gravity", x: 14663, y: 804 },
|
||||||
|
],
|
||||||
|
bonusItems: [
|
||||||
|
{ type: "cash", x: 12479, y: 79 },
|
||||||
|
],
|
||||||
goal: { x: 15640, y: 702, width: 280, height: 640 },
|
goal: { x: 15640, y: 702, width: 280, height: 640 },
|
||||||
cameraBounds: { x: -600, y: -554, width: 17280, height: 2496 },
|
cameraBounds: { x: -600, y: -554, width: 17280, height: 2496 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,17 @@ export default {
|
||||||
] },
|
] },
|
||||||
],
|
],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
|
items: [
|
||||||
|
{ type: "tv", mode: "gravity", x: 18896, y: 2583 },
|
||||||
|
{ type: "chair", mode: "gravity", x: 19129, y: 2575 },
|
||||||
|
{ type: "chair", mode: "gravity", x: 19388, y: 2567 },
|
||||||
|
],
|
||||||
|
bonusItems: [
|
||||||
|
{ type: "ctc", x: 4788, y: 1225 },
|
||||||
|
{ type: "ctc", x: 6938, y: 1142 },
|
||||||
|
{ type: "ctc", x: 9771, y: 1050 },
|
||||||
|
{ type: "ctc", x: 12671, y: 942 },
|
||||||
|
],
|
||||||
goal: { x: 21040, y: 2570, width: 280, height: 640 },
|
goal: { x: 21040, y: 2570, width: 280, height: 640 },
|
||||||
cameraBounds: { x: -600, y: 0, width: 22680, height: 3610 },
|
cameraBounds: { x: -600, y: 0, width: 22680, height: 3610 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,20 @@ export default {
|
||||||
] },
|
] },
|
||||||
],
|
],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
|
items: [
|
||||||
|
{ type: "tv", mode: "gravity", x: 5171, y: 2792 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 6813, y: 3892 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 7054, y: 3900 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 7279, y: 3933 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 7496, y: 4000 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 7696, y: 4067 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 7538, y: 3758 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 7329, y: 3675 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 7163, y: 3683 },
|
||||||
|
],
|
||||||
|
bonusItems: [
|
||||||
|
{ type: "guitar", x: 13179, y: 5783 },
|
||||||
|
],
|
||||||
goal: { x: 17080, y: 5849, width: 280, height: 640 },
|
goal: { x: 17080, y: 5849, width: 280, height: 640 },
|
||||||
cameraBounds: { x: -600, y: 0, width: 18720, height: 7068 },
|
cameraBounds: { x: -600, y: 0, width: 18720, height: 7068 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,15 @@ export default {
|
||||||
] },
|
] },
|
||||||
],
|
],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
|
items: [
|
||||||
|
{ type: "tv", mode: "gravity", x: 5863, y: 825 },
|
||||||
|
{ type: "cone", mode: "gravity", x: 15729, y: 992 },
|
||||||
|
],
|
||||||
|
bonusItems: [
|
||||||
|
{ type: "cash", x: 13796, y: 1000 },
|
||||||
|
{ type: "cash", x: 14413, y: 992 },
|
||||||
|
{ type: "cash", x: 15021, y: 958 },
|
||||||
|
],
|
||||||
goal: { x: 16640, y: 940, width: 280, height: 640 },
|
goal: { x: 16640, y: 940, width: 280, height: 640 },
|
||||||
cameraBounds: { x: -600, y: -100, width: 18280, height: 2080 },
|
cameraBounds: { x: -600, y: -100, width: 18280, height: 2080 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ 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
|
// Smashable item placements (editor "Placeables" section, kind 'smashable')
|
||||||
// object per item, same field order as levelBuilder.js emits them.
|
// - one literal object per item, same field order as levelBuilder.js emits them.
|
||||||
function itemsLiteral(items) {
|
function itemsLiteral(items) {
|
||||||
if (!items || items.length === 0) return '[]';
|
if (!items || items.length === 0) return '[]';
|
||||||
const lines = items.map(
|
const lines = items.map(
|
||||||
|
|
@ -30,6 +30,16 @@ function itemsLiteral(items) {
|
||||||
return `[\n${lines.join('\n')}\n ]`;
|
return `[\n${lines.join('\n')}\n ]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bonus item placements (editor "Placeables" section, kind 'bonus') -
|
||||||
|
// float-only, so no `mode` field (see BonusItemManager in the game).
|
||||||
|
function bonusItemsLiteral(items) {
|
||||||
|
if (!items || items.length === 0) return '[]';
|
||||||
|
const lines = items.map(
|
||||||
|
(it) => ` { type: ${JSON.stringify(it.type)}, 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/.',
|
||||||
|
|
@ -43,9 +53,10 @@ 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
|
// Only emitted when the level actually has them - keeps hand-authored
|
||||||
// levels' exports byte-identical to before this feature existed.
|
// levels' exports byte-identical to before these features existed.
|
||||||
...(levelData.items && levelData.items.length > 0 ? [` items: ${itemsLiteral(levelData.items)},`] : []),
|
...(levelData.items && levelData.items.length > 0 ? [` items: ${itemsLiteral(levelData.items)},`] : []),
|
||||||
|
...(levelData.bonusItems && levelData.bonusItems.length > 0 ? [` bonusItems: ${bonusItemsLiteral(levelData.bonusItems)},`] : []),
|
||||||
` goal: ${rectLiteral(levelData.goal)},`,
|
` goal: ${rectLiteral(levelData.goal)},`,
|
||||||
` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`,
|
` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`,
|
||||||
'};',
|
'};',
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,28 @@ function appendPoints(accumulator, points) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits the editor's unified placement list into the two level arrays the
|
||||||
|
// game expects: smashables (kind 'smashable' / default) -> `items` (each
|
||||||
|
// with its placement `mode`), and bonus items (kind 'bonus', float-only) ->
|
||||||
|
// `bonusItems`. x/y are rounded to world ints. An array is omitted entirely
|
||||||
|
// when empty, so old hand-authored levels (which have neither key) keep
|
||||||
|
// working: both managers read `level.X || []`. Returns a spread-ready object.
|
||||||
|
export function splitPlaceables(items) {
|
||||||
|
const smashables = [];
|
||||||
|
const bonus = [];
|
||||||
|
for (const it of items || []) {
|
||||||
|
if (it.kind === 'bonus') {
|
||||||
|
bonus.push({ type: it.type, x: Math.round(it.x), y: Math.round(it.y) });
|
||||||
|
} else {
|
||||||
|
smashables.push({ type: it.type, mode: it.mode || 'float', x: Math.round(it.x), y: Math.round(it.y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = {};
|
||||||
|
if (smashables.length > 0) out.items = smashables;
|
||||||
|
if (bonus.length > 0) out.bonusItems = bonus;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// sections: ordered array of { type, width, vScale, smooth } - type is an
|
// sections: ordered array of { type, width, vScale, smooth } - type is an
|
||||||
// id from sections.js's SECTION_TYPES, width/vScale independently stretch
|
// id from sections.js's SECTION_TYPES, width/vScale independently stretch
|
||||||
// or contract that section horizontally/vertically (see sections.js's
|
// or contract that section horizontally/vertically (see sections.js's
|
||||||
|
|
@ -248,16 +270,10 @@ export function buildLevelData(sections, metadata, items) {
|
||||||
startAngle: 0,
|
startAngle: 0,
|
||||||
terrain: terrainSegments,
|
terrain: terrainSegments,
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
// Smashable items (editor "Smashables" section) - only in the level
|
// Placeables (editor "Placeables" section) - split into the two level
|
||||||
// data when at least one was placed, so old hand-authored levels
|
// arrays the game expects (see splitPlaceables): smashables -> `items`,
|
||||||
// (which have no `items` key at all) keep working: SmashItemManager
|
// bonus items -> `bonusItems`. Each omitted when empty.
|
||||||
// reads `level.items || []`.
|
...splitPlaceables(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),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
SECTION_VSCALE_MIN,
|
SECTION_VSCALE_MIN,
|
||||||
SECTION_VSCALE_MAX,
|
SECTION_VSCALE_MAX,
|
||||||
} from './sections.js';
|
} from './sections.js';
|
||||||
import { buildLevelData } from './levelBuilder.js';
|
import { buildLevelData, splitPlaceables } 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';
|
import { LEVELS } from '../data/levels/index.js';
|
||||||
|
|
@ -27,13 +27,20 @@ const state = {
|
||||||
// the level's exact terrain instead of one the sequence generates. Details +
|
// the level's exact terrain instead of one the sequence generates. Details +
|
||||||
// items remain editable, and the level can be re-exported.
|
// items remain editable, and the level can be re-exported.
|
||||||
imported: null,
|
imported: null,
|
||||||
// Smashable props placed on the course (see the "Smashables" section below
|
// Smashable props + bonus items placed on the course. Each entry has a
|
||||||
// and SmashItemManager in the game). x/y are in the SAME scaled world units
|
// `kind`: 'smashable' (TV/chair/cone - can float or fall, smash off on hit)
|
||||||
// the preview canvas plots (and that the exported level uses); refIndex is
|
// or 'bonus' (CTC/guitar/cash - always float, pop into fireworks on hit).
|
||||||
// the track section the item was added under (rebased on reordering).
|
// x/y are in the SAME scaled world units the preview canvas plots (and that
|
||||||
|
// the exported level uses); refIndex is the track section it was added
|
||||||
|
// under (rebased on reordering).
|
||||||
items: [],
|
items: [],
|
||||||
|
// Which kind the Placeables section is currently placing (the toggle).
|
||||||
|
kind: 'smashable',
|
||||||
|
// Smashable selection (kind 'smashable'): type + placement mode.
|
||||||
itemsType: 'tv',
|
itemsType: 'tv',
|
||||||
itemsMode: 'float',
|
itemsMode: 'float',
|
||||||
|
// Bonus selection (kind 'bonus'): type only - bonus items are float-only.
|
||||||
|
bonusType: 'ctc',
|
||||||
};
|
};
|
||||||
|
|
||||||
const paletteEl = document.getElementById('palette');
|
const paletteEl = document.getElementById('palette');
|
||||||
|
|
@ -111,7 +118,13 @@ function setImported(level, source) {
|
||||||
obstacles: level.obstacles,
|
obstacles: level.obstacles,
|
||||||
},
|
},
|
||||||
terrain: level.terrain,
|
terrain: level.terrain,
|
||||||
items: (level.items || []).map((it) => ({ ...it })),
|
// Smashables and bonus items are two separate level arrays, but the
|
||||||
|
// editor works with ONE list (with a `kind` per entry) so the preview
|
||||||
|
// map / list / click-to-place code is shared - see state.items.
|
||||||
|
items: [
|
||||||
|
...(level.items || []).map((it) => ({ ...it, kind: 'smashable' })),
|
||||||
|
...(level.bonusItems || []).map((it) => ({ ...it, kind: 'bonus' })),
|
||||||
|
],
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
// Mirror the imported fields into the shared metadata so edits to name/id/
|
// Mirror the imported fields into the shared metadata so edits to name/id/
|
||||||
|
|
@ -190,6 +203,21 @@ ITEM_SPRITE.src = 'assets/sprites/smash-items.png';
|
||||||
const ITEM_SPRITE_FRAMES = { tv: 0, chair: 2, cone: 4 };
|
const ITEM_SPRITE_FRAMES = { tv: 0, chair: 2, cone: 4 };
|
||||||
const ITEM_SPRITE_CELL = 64;
|
const ITEM_SPRITE_CELL = 64;
|
||||||
|
|
||||||
|
// Bonus items ("Placeables" toggle -> "Bonus items"): float-only collectible
|
||||||
|
// prizes - CTC box / guitar / cash. See BonusItemManager.js in the game for
|
||||||
|
// the type/frame/color table; frames here are the BONUS frame of each pair
|
||||||
|
// (0 CTC, 3 guitar, 6 cash).
|
||||||
|
const BONUS_TYPES = [
|
||||||
|
{ id: 'ctc', label: 'CTC Box' },
|
||||||
|
{ id: 'guitar', label: 'Guitar' },
|
||||||
|
{ id: 'cash', label: 'Cash' },
|
||||||
|
];
|
||||||
|
const BONUS_COLORS = { ctc: '#e0a040', guitar: '#4b9a45', cash: '#b3ad9c' };
|
||||||
|
const BONUS_ICONS = { ctc: 'CT', guitar: 'GT', cash: '$' };
|
||||||
|
const BONUS_SPRITE = new Image();
|
||||||
|
BONUS_SPRITE.src = 'assets/sprites/bonus-items.png';
|
||||||
|
const BONUS_SPRITE_FRAMES = { ctc: 0, guitar: 3, cash: 6 };
|
||||||
|
|
||||||
// Samples the terrain surface Y (highest ground) at a given world X - used
|
// 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
|
// 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
|
// preview. Same "first segment whose x-span contains X" logic the physics
|
||||||
|
|
@ -220,57 +248,121 @@ function buildItemsForm() {
|
||||||
fieldset.style.gap = '16px';
|
fieldset.style.gap = '16px';
|
||||||
itemsFormEl.appendChild(fieldset);
|
itemsFormEl.appendChild(fieldset);
|
||||||
|
|
||||||
const typeSelect = document.createElement('select');
|
// --- Placeable kind toggle: smashables (TV/chair/cone) vs bonus items
|
||||||
typeSelect.id = 'items-type';
|
// (CTC/guitar/cash). Only the relevant type+mode controls show below,
|
||||||
for (const t of ITEM_TYPES) {
|
// and clicks on the preview place whichever kind is selected.
|
||||||
const opt = document.createElement('option');
|
const kindRadios = [];
|
||||||
opt.value = t.id;
|
for (const kind of ['smashable', 'bonus']) {
|
||||||
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');
|
const radio = document.createElement('input');
|
||||||
radio.type = 'radio';
|
radio.type = 'radio';
|
||||||
radio.name = 'items-mode';
|
radio.name = 'placeable-kind';
|
||||||
radio.value = mode;
|
radio.value = kind;
|
||||||
radio.id = `items-mode-${mode}`;
|
radio.id = `placeable-kind-${kind}`;
|
||||||
if (mode === state.itemsMode) radio.checked = true;
|
if (kind === state.kind) radio.checked = true;
|
||||||
radio.addEventListener('change', () => {
|
radio.addEventListener('change', () => {
|
||||||
state.itemsMode = mode;
|
state.kind = kind;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
const label = document.createElement('label');
|
const label = document.createElement('label');
|
||||||
label.className = 'checkbox-row';
|
label.className = 'checkbox-row';
|
||||||
label.append(radio, mode === 'float' ? 'Float (stays where placed)' : 'Gravity (falls to the ground)');
|
label.append(radio, kind === 'smashable' ? 'Smashables (TV / Chair / Cone)' : 'Bonus items (CTC / Guitar / Cash)');
|
||||||
modeRadios.push(label);
|
kindRadios.push(label);
|
||||||
|
}
|
||||||
|
const kindWrap = document.createElement('div');
|
||||||
|
kindWrap.className = 'size-field';
|
||||||
|
kindWrap.append('Placeable kind', ...kindRadios);
|
||||||
|
fieldset.appendChild(kindWrap);
|
||||||
|
|
||||||
|
if (state.kind === 'smashable') {
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
const typeSelect = document.createElement('select');
|
||||||
|
typeSelect.id = 'bonus-type';
|
||||||
|
for (const t of BONUS_TYPES) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.id;
|
||||||
|
opt.textContent = t.label;
|
||||||
|
if (t.id === state.bonusType) opt.selected = true;
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
typeSelect.addEventListener('change', () => {
|
||||||
|
state.bonusType = typeSelect.value;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
const typeWrap = document.createElement('label');
|
||||||
|
typeWrap.className = 'size-field';
|
||||||
|
typeWrap.append('Item type', typeSelect);
|
||||||
|
fieldset.appendChild(typeWrap);
|
||||||
|
|
||||||
|
const floatNote = document.createElement('div');
|
||||||
|
floatNote.className = 'size-field';
|
||||||
|
floatNote.style.color = '#9aa7bb';
|
||||||
|
floatNote.style.fontSize = '12px';
|
||||||
|
floatNote.textContent = 'Placement mode: always Float (bonus items can only float)';
|
||||||
|
fieldset.appendChild(floatNote);
|
||||||
}
|
}
|
||||||
const modeWrap = document.createElement('div');
|
|
||||||
modeWrap.className = 'size-field';
|
|
||||||
modeWrap.append('Placement mode', ...modeRadios);
|
|
||||||
fieldset.appendChild(modeWrap);
|
|
||||||
|
|
||||||
const hint = document.createElement('p');
|
const hint = document.createElement('p');
|
||||||
hint.style.margin = '8px 0 0';
|
hint.style.margin = '8px 0 0';
|
||||||
hint.style.color = '#9aa7bb';
|
hint.style.color = '#9aa7bb';
|
||||||
hint.style.fontSize = '12px';
|
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.';
|
hint.textContent = state.kind === 'smashable'
|
||||||
|
? '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.'
|
||||||
|
: 'Pick a type, then click anywhere on the Preview map above to place the bonus item there (it floats exactly where placed). Click a placed item in the list to delete it.';
|
||||||
itemsFormEl.appendChild(hint);
|
itemsFormEl.appendChild(hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a click on the preview map should place right now, based on the
|
||||||
|
// "Placeable kind" toggle (see buildItemsForm).
|
||||||
|
function placementSpec() {
|
||||||
|
if (state.kind === 'bonus') {
|
||||||
|
return { kind: 'bonus', type: state.bonusType };
|
||||||
|
}
|
||||||
|
return { kind: 'smashable', type: state.itemsType, mode: state.itemsMode };
|
||||||
|
}
|
||||||
|
|
||||||
function itemLabel(item, index) {
|
function itemLabel(item, index) {
|
||||||
const t = ITEM_TYPES.find((x) => x.id === item.type);
|
const t = (item.kind === 'bonus' ? BONUS_TYPES : 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}]`;
|
const modePart = item.kind === 'bonus' ? 'bonus/float' : item.mode;
|
||||||
|
return `${index + 1}. ${t ? t.label : item.type} @ (${Math.round(item.x)}, ${Math.round(item.y)}) [${modePart}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildItemsList() {
|
function buildItemsList() {
|
||||||
|
|
@ -288,7 +380,8 @@ function buildItemsList() {
|
||||||
el.className = 'item-row';
|
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;';
|
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');
|
const dot = document.createElement('span');
|
||||||
dot.style.cssText = `width:10px;height:10px;border-radius:50%;background:${ITEM_COLORS[item.type] || '#888'};flex:none;`;
|
const color = item.kind === 'bonus' ? BONUS_COLORS[item.type] : ITEM_COLORS[item.type];
|
||||||
|
dot.style.cssText = `width:10px;height:10px;border-radius:50%;background:${color || '#888'};flex:none;`;
|
||||||
const text = document.createElement('span');
|
const text = document.createElement('span');
|
||||||
text.textContent = itemLabel(item, index);
|
text.textContent = itemLabel(item, index);
|
||||||
const del = document.createElement('span');
|
const del = document.createElement('span');
|
||||||
|
|
@ -603,17 +696,21 @@ function drawPreview(levelData) {
|
||||||
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
|
// Placed placeables (smashables + bonus items): the real sprite frame at
|
||||||
// (same space as terrain/start/goal, so the click-to-place mapping below
|
// the placement point (same space as terrain/start/goal, so the
|
||||||
// is exact). Gravity items also get a dashed drop line down to the ground
|
// click-to-place mapping below is exact). Smashable gravity items also get
|
||||||
// under that X - where they'll actually settle - with a small arrowhead.
|
// a dashed drop line down to the ground under that X - where they'll
|
||||||
|
// actually settle - with a small arrowhead. Bonus items are always float.
|
||||||
const itemDrawScale = 26; // px on the preview map, per 64px source cell
|
const itemDrawScale = 26; // px on the preview map, per 64px source cell
|
||||||
for (const item of state.items) {
|
for (const item of state.items) {
|
||||||
const ix = screenX(item.x);
|
const ix = screenX(item.x);
|
||||||
const iy = screenY(item.y);
|
const iy = screenY(item.y);
|
||||||
const color = ITEM_COLORS[item.type] || '#888';
|
const isBonus = item.kind === 'bonus';
|
||||||
|
const color = isBonus ? (BONUS_COLORS[item.type] || '#888') : (ITEM_COLORS[item.type] || '#888');
|
||||||
|
|
||||||
if (item.mode === 'gravity') {
|
// Smashables in 'gravity' mode: the drop line (bonus items never do this -
|
||||||
|
// they always float exactly where placed).
|
||||||
|
if (!isBonus && item.mode === 'gravity') {
|
||||||
const groundY = terrainSurfaceY(levelData, item.x);
|
const groundY = terrainSurfaceY(levelData, item.x);
|
||||||
if (groundY !== Infinity) {
|
if (groundY !== Infinity) {
|
||||||
const gy = screenY(groundY);
|
const gy = screenY(groundY);
|
||||||
|
|
@ -638,20 +735,24 @@ function drawPreview(levelData) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const frame = ITEM_SPRITE_FRAMES[item.type];
|
const frameTable = isBonus ? BONUS_SPRITE_FRAMES : ITEM_SPRITE_FRAMES;
|
||||||
if (ITEM_SPRITE.complete && ITEM_SPRITE.naturalWidth > 0 && frame !== undefined) {
|
const spriteTable = isBonus ? BONUS_SPRITE : ITEM_SPRITE;
|
||||||
|
const frame = frameTable[item.type];
|
||||||
|
if (spriteTable.complete && spriteTable.naturalWidth > 0 && frame !== undefined) {
|
||||||
const half = itemDrawScale / 2;
|
const half = itemDrawScale / 2;
|
||||||
// float items get a light outline glow so they read as "held in place";
|
// 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.
|
// gravity items rest on (or fall to) the ground, drawn as-is.
|
||||||
if (item.mode === 'float') {
|
if (item.mode !== 'gravity') {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
|
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.strokeRect(ix - half - 1, iy - half - 1, itemDrawScale + 2, itemDrawScale + 2);
|
ctx.strokeRect(ix - half - 1, iy - half - 1, itemDrawScale + 2, itemDrawScale + 2);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
// Both spritesheets share the same 64px cell size, so ITEM_SPRITE_CELL
|
||||||
|
// works for bonus frames too (no separate cell constant needed).
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
ITEM_SPRITE,
|
spriteTable,
|
||||||
frame * ITEM_SPRITE_CELL, 0, ITEM_SPRITE_CELL, ITEM_SPRITE_CELL,
|
frame * ITEM_SPRITE_CELL, 0, ITEM_SPRITE_CELL, ITEM_SPRITE_CELL,
|
||||||
ix - half, iy - half, itemDrawScale, itemDrawScale,
|
ix - half, iy - half, itemDrawScale, itemDrawScale,
|
||||||
);
|
);
|
||||||
|
|
@ -663,7 +764,8 @@ function drawPreview(levelData) {
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.fillStyle = '#0b0e14';
|
ctx.fillStyle = '#0b0e14';
|
||||||
ctx.font = 'bold 9px monospace';
|
ctx.font = 'bold 9px monospace';
|
||||||
ctx.fillText(ITEM_ICONS[item.type] || '??', ix - 4, iy + 3);
|
const letters = isBonus ? BONUS_ICONS : ITEM_ICONS;
|
||||||
|
ctx.fillText(letters[item.type] || '??', ix - 4, iy + 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -712,13 +814,16 @@ previewCanvas.addEventListener('click', (e) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise: place a new item at this world position.
|
// Otherwise: place a new item at this world position, using whatever the
|
||||||
|
// "Placeable kind" toggle is set to (see placementSpec).
|
||||||
const worldX = (sx - PADDING) / H_PX_PER_UNIT + previewBounds.minX;
|
const worldX = (sx - PADDING) / H_PX_PER_UNIT + previewBounds.minX;
|
||||||
const worldY = (sy - PADDING) / H_PX_PER_UNIT + previewBounds.minY;
|
const worldY = (sy - PADDING) / H_PX_PER_UNIT + previewBounds.minY;
|
||||||
|
|
||||||
|
const spec = placementSpec();
|
||||||
const item = {
|
const item = {
|
||||||
type: state.itemsType,
|
kind: spec.kind,
|
||||||
mode: state.itemsMode,
|
type: spec.type,
|
||||||
|
...(spec.kind === 'smashable' ? { mode: spec.mode } : {}),
|
||||||
x: Math.round(worldX),
|
x: Math.round(worldX),
|
||||||
y: Math.round(worldY),
|
y: Math.round(worldY),
|
||||||
refIndex: state.selectedIndex !== null ? state.selectedIndex : 0,
|
refIndex: state.selectedIndex !== null ? state.selectedIndex : 0,
|
||||||
|
|
@ -801,10 +906,16 @@ function render() {
|
||||||
updateTrackControls();
|
updateTrackControls();
|
||||||
updateSizePanel();
|
updateSizePanel();
|
||||||
buildItemsList();
|
buildItemsList();
|
||||||
|
// The Placeables form depends on state.kind (which type/mode controls show
|
||||||
|
// for the currently-selected placeable kind), so rebuild it on every
|
||||||
|
// render - the kind toggle's own change handler calls render() too.
|
||||||
|
buildItemsForm();
|
||||||
|
|
||||||
if (isImported()) {
|
if (isImported()) {
|
||||||
// Imported level: render its own exact terrain (plus the editable goal,
|
// Imported level: render its own exact terrain (plus the editable goal,
|
||||||
// start, items) instead of regenerating from the (locked) sequence.
|
// start, and placeables) instead of regenerating from the (locked)
|
||||||
|
// sequence. Placeables get split into the two level arrays the game
|
||||||
|
// expects (items / bonusItems) the same way buildLevelData does.
|
||||||
const imp = state.imported;
|
const imp = state.imported;
|
||||||
currentLevelData = {
|
currentLevelData = {
|
||||||
id: state.metadata.id,
|
id: state.metadata.id,
|
||||||
|
|
@ -816,9 +927,7 @@ function render() {
|
||||||
startAngle: imp.metadata.startAngle,
|
startAngle: imp.metadata.startAngle,
|
||||||
terrain: imp.terrain,
|
terrain: imp.terrain,
|
||||||
obstacles: imp.metadata.obstacles || [],
|
obstacles: imp.metadata.obstacles || [],
|
||||||
...(state.items.length > 0 ? { items: state.items.map((it) => ({
|
...splitPlaceables(state.items),
|
||||||
type: it.type, mode: it.mode, x: Math.round(it.x), y: Math.round(it.y),
|
|
||||||
})) } : {}),
|
|
||||||
goal: imp.metadata.goal,
|
goal: imp.metadata.goal,
|
||||||
cameraBounds: imp.metadata.cameraBounds,
|
cameraBounds: imp.metadata.cameraBounds,
|
||||||
};
|
};
|
||||||
|
|
@ -855,7 +964,8 @@ function initSizeControls() {
|
||||||
|
|
||||||
buildPalette();
|
buildPalette();
|
||||||
buildMetadataForm();
|
buildMetadataForm();
|
||||||
buildItemsForm();
|
// buildItemsForm() runs on every render() (it depends on state.kind), and
|
||||||
|
// render() is called right below - no need to build it once here.
|
||||||
buildImportSelect();
|
buildImportSelect();
|
||||||
initSizeControls();
|
initSizeControls();
|
||||||
render();
|
render();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { BONUS_ITEM } from '../config.js';
|
||||||
|
|
||||||
|
// bonus-items.png is 9 frames of 64x64, in order:
|
||||||
|
// 0 CTC box, 1 CTC firework object 1, 2 CTC firework object 2,
|
||||||
|
// 3 Guitar, 4 Guitar firework object 1, 5 Guitar firework object 2,
|
||||||
|
// 6 Cash, 7 Cash firework object 1, 8 Cash firework object 2.
|
||||||
|
// Each bonus item is followed by its two firework-object frames, which are
|
||||||
|
// the sprites the explosion burst is made of (see _explode below).
|
||||||
|
const BONUS_TYPES = {
|
||||||
|
ctc: { frame: 0, fireworks: [1, 2], label: 'CTC Box' },
|
||||||
|
guitar: { frame: 3, fireworks: [4, 5], label: 'Guitar' },
|
||||||
|
cash: { frame: 6, fireworks: [7, 8], label: 'Cash' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class BonusItemManager {
|
||||||
|
/**
|
||||||
|
* @param scene PlayScene
|
||||||
|
* @param bus the level's Bus
|
||||||
|
* @param level the level data object (bonus items come from `level.bonusItems`)
|
||||||
|
*
|
||||||
|
* Owns the level's floating bonus items: one static SENSOR image per item.
|
||||||
|
* A sensor body has zero physical response (Matter builds the pair with
|
||||||
|
* friction/restitution/inverse-mass all zero - verified against the
|
||||||
|
* vendored build's Pair.create), so the bus passes straight through with
|
||||||
|
* no bounce, spin or impulse of any kind. The first time a bus body
|
||||||
|
* touches an uncollected item it pops (quick scale-up + fade) and spawns
|
||||||
|
* a radial firework burst: the item's two firework-object frames as small
|
||||||
|
* tinted sprites that fly outward, arc down on the world's real gravity,
|
||||||
|
* settle on the terrain, then fade out and are removed.
|
||||||
|
*
|
||||||
|
* Bonus items are FLOAT-only by design (the editor offers no gravity mode
|
||||||
|
* for them) - they're always static, always at the exact placement point.
|
||||||
|
*/
|
||||||
|
constructor(scene, bus, level) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.bus = bus;
|
||||||
|
this.items = [];
|
||||||
|
|
||||||
|
for (const spec of level.bonusItems || []) {
|
||||||
|
const item = this._buildItem(spec);
|
||||||
|
if (item) this.items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same bus-body set as SmashItemManager: chassis + both wheels are what
|
||||||
|
// can count as "the bus hitting the item".
|
||||||
|
this._busBodies = [
|
||||||
|
bus.chassis.body,
|
||||||
|
bus.wheelRear.body,
|
||||||
|
bus.wheelFront.body,
|
||||||
|
];
|
||||||
|
|
||||||
|
scene.matter.world.on('collisionstart', (event) => this._onCollisionStart(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many bonus items the bus collected this run - LevelScoreScene
|
||||||
|
// tallies these as SCORE.perBonusItem each (see PlayScene._onWin).
|
||||||
|
get collectedCount() {
|
||||||
|
return this.items.filter((item) => item.collected).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildItem(spec) {
|
||||||
|
const type = BONUS_TYPES[spec.type];
|
||||||
|
if (!type) return null; // unknown/misspelled type in a level file - skip
|
||||||
|
|
||||||
|
const scene = this.scene;
|
||||||
|
|
||||||
|
const image = scene.matter.add.image(spec.x, spec.y, 'bonus_items', type.frame, {
|
||||||
|
// SENSOR + STATIC: no physical response to anything (the bus is the
|
||||||
|
// only body we care about touching it, and a sensor pair can't push
|
||||||
|
// it regardless), and it holds its placement point forever - the
|
||||||
|
// "always floats" requirement.
|
||||||
|
isSensor: true,
|
||||||
|
isStatic: true,
|
||||||
|
// Circle hitbox. Same convention as SMASH_ITEM.radius (= that
|
||||||
|
// displaySize / 4, the game's own documented mapping from a 64x64-frame
|
||||||
|
// display size to the shape.radius value): keep the two managers' physics
|
||||||
|
// sizing consistent so a bonus item and a smashable of the same display
|
||||||
|
// size have the same physical footprint.
|
||||||
|
shape: { type: 'circle', radius: BONUS_ITEM.itemDisplaySize / 4 },
|
||||||
|
// It's a SENSOR, so no pair it's in ever produces a physical response
|
||||||
|
// (Matter builds sensor pairs with zero friction/restitution/inverse
|
||||||
|
// mass - the bus passes straight through it, no bounce/spin/impulse).
|
||||||
|
// The collision is still *detected* (we get a collisionstart event, which
|
||||||
|
// is what triggers the pop) - the bus's default all-bits mask already
|
||||||
|
// picks it up; setting the category here just keeps the item's "who am
|
||||||
|
// I for" explicit. (Kids/terrain pairs are harmless either way.)
|
||||||
|
collisionFilter: { category: this.bus.busCategory },
|
||||||
|
});
|
||||||
|
image.setDisplaySize(BONUS_ITEM.itemDisplaySize, BONUS_ITEM.itemDisplaySize);
|
||||||
|
image.setDepth(1); // same layer as kids/smashables - over the ground, under the bus
|
||||||
|
|
||||||
|
return { spec, type, image, collected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same event shape as SmashItemManager: the matter `collisionStart` event
|
||||||
|
// re-emitted by the Phaser Matter plugin carries `pairs` (the top-level
|
||||||
|
// bodyA/bodyB args are undefined - see that manager's comment).
|
||||||
|
_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.collected) 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._explode(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pop: the item itself scales up and fades away quickly, then its
|
||||||
|
// firework burst launches - a full ring of its two firework-object
|
||||||
|
// frames, alternating frames (and tints) around the circle, all at once.
|
||||||
|
_explode(item) {
|
||||||
|
item.collected = true;
|
||||||
|
const x = item.image.x;
|
||||||
|
const y = item.image.y;
|
||||||
|
const { scene } = this;
|
||||||
|
|
||||||
|
// The bonus item sprite: quick "pop" (grow + fade) then it's gone -
|
||||||
|
// the firework sprites below ARE the aftermath.
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: item.image,
|
||||||
|
scale: BONUS_ITEM.popScale,
|
||||||
|
alpha: 0,
|
||||||
|
duration: BONUS_ITEM.popDurationMs,
|
||||||
|
ease: 'Quad.easeOut',
|
||||||
|
onComplete: () => item.image.destroy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tint palette for this item's color scheme (see BONUS_ITEM.colors).
|
||||||
|
const palette = BONUS_ITEM.colors[item.spec.type] || BONUS_ITEM.colors.ctc;
|
||||||
|
const [mainTint, accentTint] = [palette.main, palette.accent];
|
||||||
|
|
||||||
|
const count = BONUS_ITEM.particleCount;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Alternate the two firework-object frames around the ring (and the
|
||||||
|
// two tints with them) so the burst reads as the item's own palette
|
||||||
|
// with texture, not a single flat color.
|
||||||
|
const frame = item.type.fireworks[i % item.type.fireworks.length];
|
||||||
|
const tint = (i % 2 === 0) ? mainTint : accentTint;
|
||||||
|
|
||||||
|
// Evenly spaced around the circle with a little jitter so two bursts
|
||||||
|
// (or the same item type used twice in a level) don't look copy-pasted.
|
||||||
|
const angle = (i / count) * Math.PI * 2 + (Math.random() - 0.5) * (Math.PI / count) * 0.8;
|
||||||
|
const speed = BONUS_ITEM.minSpeed + Math.random() * (BONUS_ITEM.maxSpeed - BONUS_ITEM.minSpeed);
|
||||||
|
|
||||||
|
// REAL (non-sensor) bodies, but exempted from the bus AND the kids via
|
||||||
|
// the mask (same trick as SmashItemManager._smash) - so the fireworks
|
||||||
|
// can rest on the terrain (and land where they land, the arc-down the
|
||||||
|
// art calls for) without ever being able to bump the bus or flick a
|
||||||
|
// kid. They're also exempt from each other's impact on gameplay: they
|
||||||
|
// only ever physically interact with terrain.
|
||||||
|
const particle = scene.matter.add.image(x, y, 'bonus_items', frame, {
|
||||||
|
// Circle hitbox, same displaySize/4 convention as the item's own
|
||||||
|
// hitbox above (and SMASH_ITEM.radius) - keeps every circle body in
|
||||||
|
// the game on one consistent sizing rule.
|
||||||
|
shape: { type: 'circle', radius: BONUS_ITEM.particleDisplaySize / 4 },
|
||||||
|
density: 0.001,
|
||||||
|
friction: 0.4,
|
||||||
|
frictionAir: 0.008, // a little air drag - the arcs feel "fireworky", not ballistic-dry
|
||||||
|
restitution: 0.25, // small bounce when they settle on the ground
|
||||||
|
collisionFilter: { mask: 0xffffffff & ~this.bus.busCategory & ~this.bus.kidCategory },
|
||||||
|
});
|
||||||
|
particle.setDisplaySize(BONUS_ITEM.particleDisplaySize, BONUS_ITEM.particleDisplaySize);
|
||||||
|
particle.setDepth(5); // above the bus, under the HUD - this is the show, let it read on top
|
||||||
|
particle.setTint(tint);
|
||||||
|
|
||||||
|
// Launch outward. setVelocity is the Phaser Matter component (px per
|
||||||
|
// 60Hz physics step, matching every other velocity in this codebase -
|
||||||
|
// see SMASH_ITEM.launchSpeed). The world's gravity (main.js:
|
||||||
|
// 0.675 * WORLD_SCALE) then bends each one into its arc down.
|
||||||
|
const vx = Math.cos(angle) * speed;
|
||||||
|
const vy = Math.sin(angle) * speed;
|
||||||
|
particle.setVelocity(vx, vy);
|
||||||
|
|
||||||
|
// A little tumble while they're in the air (radians per physics
|
||||||
|
// step, same convention as SMASH_ITEM.launchSpin).
|
||||||
|
particle.setAngularVelocity((Math.random() * 2 - 1) * BONUS_ITEM.spinRange);
|
||||||
|
|
||||||
|
// Lifespan: hold full opacity for the first 60%, fade for the last
|
||||||
|
// 40%, then remove the sprite (and its body). Scene-scoped timers
|
||||||
|
// are cleared on scene shutdown, so a level-end mid-burst can't leak.
|
||||||
|
const life = BONUS_ITEM.lifeMs;
|
||||||
|
scene.time.delayedCall(life * 0.6, () => {
|
||||||
|
scene.tweens.add({ targets: particle, alpha: 0, duration: life * 0.4, ease: 'Sine.easeIn' });
|
||||||
|
});
|
||||||
|
scene.time.delayedCall(life, () => particle.destroy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
// Nothing to do: the Matter world's shutdown removes the bodies and its
|
||||||
|
// registered listeners (and nulls scene.matter before this runs), and
|
||||||
|
// the scene's ClockTimer is destroyed with it - matching
|
||||||
|
// SmashItemManager.destroy().
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ export default class LevelScoreScene extends Phaser.Scene {
|
||||||
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.smashedItems = data.smashedItems || 0;
|
||||||
|
this.bonusItems = data.bonusItems || 0;
|
||||||
this.timeRemainingSeconds = data.timeRemainingSeconds;
|
this.timeRemainingSeconds = data.timeRemainingSeconds;
|
||||||
this.freezeKey = data.freezeKey;
|
this.freezeKey = data.freezeKey;
|
||||||
this.runningScore = 0;
|
this.runningScore = 0;
|
||||||
|
|
@ -108,6 +109,9 @@ export default class LevelScoreScene extends Phaser.Scene {
|
||||||
await this._revealSmashed();
|
await this._revealSmashed();
|
||||||
await this._wait(300);
|
await this._wait(300);
|
||||||
|
|
||||||
|
await this._revealBonus();
|
||||||
|
await this._wait(300);
|
||||||
|
|
||||||
await this._tallyTimeRemaining();
|
await this._tallyTimeRemaining();
|
||||||
await this._wait(400);
|
await this._wait(400);
|
||||||
|
|
||||||
|
|
@ -282,6 +286,57 @@ export default class LevelScoreScene extends Phaser.Scene {
|
||||||
sprite.destroy();
|
sprite.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The level's collected bonus items tally in as a single bonus line, the
|
||||||
|
// same way smashed props do (see _revealSmashed): one representative bonus
|
||||||
|
// icon (cash - the archetypal "bonus") plus "+100 x N" slams into the
|
||||||
|
// score. Skipped entirely when the level had no bonus items / none were
|
||||||
|
// collected, so bonus-less levels keep their exact old tally flow.
|
||||||
|
async _revealBonus() {
|
||||||
|
if (this.bonusItems <= 0) return;
|
||||||
|
|
||||||
|
this._playSound('score_count');
|
||||||
|
|
||||||
|
const x = GAME_WIDTH / 2;
|
||||||
|
const sprite = this.add.image(x, KID_ROW_Y, 'bonus_items', 6) // cash = the archetypal "bonus" 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, `Bonus x${this.bonusItems} +${this.bonusItems * SCORE.perBonusItem}`, {
|
||||||
|
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.bonusItems * SCORE.perBonusItem;
|
||||||
|
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', () => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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 SmashItemManager from '../entities/SmashItemManager.js';
|
||||||
|
import BonusItemManager from '../entities/BonusItemManager.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';
|
||||||
|
|
@ -48,6 +49,11 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
// off (see SmashItemManager). No items in a level = no-op.
|
// off (see SmashItemManager). No items in a level = no-op.
|
||||||
this.smashItems = new SmashItemManager(this, this.bus, this.level);
|
this.smashItems = new SmashItemManager(this, this.bus, this.level);
|
||||||
|
|
||||||
|
// Floating bonus items (CTC box / guitar / cash) - the bus passes
|
||||||
|
// through them with zero physical effect; the first touch pops them
|
||||||
|
// into a firework burst (see BonusItemManager). None in a level = no-op.
|
||||||
|
this.bonusItems = new BonusItemManager(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.
|
||||||
|
|
@ -301,6 +307,7 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
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 smashedItems = this.smashItems.smashedCount;
|
||||||
|
const bonusItems = this.bonusItems.collectedCount;
|
||||||
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
|
||||||
|
|
@ -318,6 +325,7 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
total,
|
total,
|
||||||
kidResults,
|
kidResults,
|
||||||
smashedItems,
|
smashedItems,
|
||||||
|
bonusItems,
|
||||||
timeRemainingSeconds,
|
timeRemainingSeconds,
|
||||||
freezeKey,
|
freezeKey,
|
||||||
});
|
});
|
||||||
|
|
@ -338,6 +346,7 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
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.smashItems) this.smashItems.destroy();
|
||||||
|
if (this.bonusItems) this.bonusItems.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();
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ export const ASSET_MANIFEST = [
|
||||||
// frameWidth/frameHeight are raw SOURCE pixels (one 64x64 cell each),
|
// frameWidth/frameHeight are raw SOURCE pixels (one 64x64 cell each),
|
||||||
// same convention as the kid sheets above.
|
// 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: 'smash_items', path: 'assets/sprites/smash-items.png', width: 64 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, frameWidth: 64, frameHeight: 64 },
|
||||||
|
// 9 frames of 64x64 bonus items (CTC box + 2 firework frames, guitar + 2,
|
||||||
|
// cash + 2 - see BONUS_ITEM in config.js and BonusItemManager.js).
|
||||||
|
{ key: 'bonus_items', path: 'assets/sprites/bonus-items.png', width: 64 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0xe0a040, 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