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:
Brian Fertig 2026-08-22 19:00:55 -06:00
parent 8e5398e898
commit b087d91f4f
17 changed files with 633 additions and 83 deletions

View File

@ -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

BIN
assets/images/bonus-items Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -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>

View File

@ -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,

View File

@ -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 },
}; };

View File

@ -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 },
}; };

View File

@ -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 },
}; };

View File

@ -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 },
}; };

View File

@ -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 },
}; };

View File

@ -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)},`,
'};', '};',

View File

@ -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),

View File

@ -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();

View File

@ -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().
}
}

View File

@ -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', () => {

View File

@ -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();

View File

@ -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 },