diff --git a/README.md b/README.md index 23f94cd..0c17abf 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ no code changes needed.** | `kid_idle` | `assets/sprites/kid_idle.png` | 28x28 | passenger while aboard | | `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) | +| `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_mid` | `assets/backgrounds/bg_mid.png` | 256x540 | tiled, mid 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 end-of-level score screen. -The **level editor** (`editor.html`) has a "Smashables" section: pick a type -+ mode, then click anywhere on the preview map to drop one there; click an -existing item (on the map or in the list) to remove it. Exporting the level -writes the `items` array into the level file automatically. +The **level editor** (`editor.html`) has a "Placeables" section with a kind +toggle: **Smashables** (TV / chair / cone, float or gravity mode) and +**Bonus items** (CTC box / guitar / cash, float-only). Pick the kind + type, +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" 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 sections palette / sequence / size controls lock (dimmed and inert) and the preview + export render the loaded level's own terrain. Details (id / name / -description / kids) and smashables stay editable, so you can tweak them and -re-export. "Start a new level" clears the import and unlocks the section -controls again. +description / kids) and both placeable types stay editable, so you can tweak +them and re-export. "Start a new level" clears the import and unlocks the +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 diff --git a/assets/images/bonus-items b/assets/images/bonus-items new file mode 100644 index 0000000..ac22c8a Binary files /dev/null and b/assets/images/bonus-items differ diff --git a/assets/sprites/bonus-items.png b/assets/sprites/bonus-items.png index 4991c66..ab4e8ba 100644 Binary files a/assets/sprites/bonus-items.png and b/assets/sprites/bonus-items.png differ diff --git a/editor.html b/editor.html index a1eac1f..aaffc0f 100644 --- a/editor.html +++ b/editor.html @@ -326,8 +326,8 @@
-

Smashables

-

Pick a type + placement mode, then click anywhere on the Preview map below to drop the item there. Click a placed item (on the map or in the list) to delete it.

+

Placeables

+

Pick the placeable kind (Smashables or Bonus items) + its type, then click anywhere on the Preview map below 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.

diff --git a/src/config.js b/src/config.js index dcec53c..4f78967 100644 --- a/src/config.js +++ b/src/config.js @@ -198,6 +198,9 @@ export const SCORE = { // Per smashable item (TV/chair/cone, see SMASH_ITEM) knocked off its // perch by the bus - revealed on the tally between the kids and the clock. perSmashItem: 50, + // 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 @@ -249,6 +252,62 @@ export const SMASH_ITEM = { 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 = { lerpX: 0.1, lerpY: 0.1, diff --git a/src/data/levels/level02.js b/src/data/levels/level02.js index 3b1b4dd..c5eaa8e 100644 --- a/src/data/levels/level02.js +++ b/src/data/levels/level02.js @@ -255,6 +255,10 @@ export default { ] }, ], obstacles: [], + bonusItems: [ + { type: "guitar", x: 11404, y: -134 }, + { type: "ctc", x: 7746, y: -1017 }, + ], goal: { x: 16440, y: 472, width: 280, height: 640 }, cameraBounds: { x: -600, y: -1542, width: 18080, height: 3182 }, }; diff --git a/src/data/levels/level03.js b/src/data/levels/level03.js index 2f63de1..f44e7d6 100644 --- a/src/data/levels/level03.js +++ b/src/data/levels/level03.js @@ -236,6 +236,12 @@ export default { ] }, ], 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 }, cameraBounds: { x: -600, y: -554, width: 17280, height: 2496 }, }; diff --git a/src/data/levels/level04.js b/src/data/levels/level04.js index 8a2fd16..2afa3a4 100644 --- a/src/data/levels/level04.js +++ b/src/data/levels/level04.js @@ -306,6 +306,17 @@ export default { ] }, ], 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 }, cameraBounds: { x: -600, y: 0, width: 22680, height: 3610 }, }; diff --git a/src/data/levels/level05.js b/src/data/levels/level05.js index 243e5be..c8edd88 100644 --- a/src/data/levels/level05.js +++ b/src/data/levels/level05.js @@ -263,6 +263,20 @@ export default { ] }, ], 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 }, cameraBounds: { x: -600, y: 0, width: 18720, height: 7068 }, }; diff --git a/src/data/levels/level06.js b/src/data/levels/level06.js index 9fc6029..77a450e 100644 --- a/src/data/levels/level06.js +++ b/src/data/levels/level06.js @@ -244,6 +244,15 @@ export default { ] }, ], 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 }, cameraBounds: { x: -600, y: -100, width: 18280, height: 2080 }, }; diff --git a/src/editor/exportLevel.js b/src/editor/exportLevel.js index eb922d0..82b1105 100644 --- a/src/editor/exportLevel.js +++ b/src/editor/exportLevel.js @@ -20,8 +20,8 @@ function rectLiteral(rect) { return `{ x: ${rect.x}, y: ${rect.y}, width: ${rect.width}, height: ${rect.height} }`; } -// Smashable item placements (editor "Smashables" section) - one literal -// object per item, same field order as levelBuilder.js emits them. +// Smashable item placements (editor "Placeables" section, kind 'smashable') +// - one literal object per item, same field order as levelBuilder.js emits them. function itemsLiteral(items) { if (!items || items.length === 0) return '[]'; const lines = items.map( @@ -30,6 +30,16 @@ function itemsLiteral(items) { 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) { const lines = [ '// Generated by editor.html - review before dropping into src/data/levels/.', @@ -43,9 +53,10 @@ export function exportLevelToSource(levelData) { ` startAngle: ${levelData.startAngle},`, ` terrain: ${terrainLiteral(levelData.terrain)},`, ' obstacles: [],', - // Only emitted when the level actually has items - keeps hand-authored - // levels' exports byte-identical to before this feature existed. + // Only emitted when the level actually has them - keeps hand-authored + // levels' exports byte-identical to before these features existed. ...(levelData.items && levelData.items.length > 0 ? [` items: ${itemsLiteral(levelData.items)},`] : []), + ...(levelData.bonusItems && levelData.bonusItems.length > 0 ? [` bonusItems: ${bonusItemsLiteral(levelData.bonusItems)},`] : []), ` goal: ${rectLiteral(levelData.goal)},`, ` cameraBounds: ${rectLiteral(levelData.cameraBounds)},`, '};', diff --git a/src/editor/levelBuilder.js b/src/editor/levelBuilder.js index a13ea05..7a8611d 100644 --- a/src/editor/levelBuilder.js +++ b/src/editor/levelBuilder.js @@ -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 // id from sections.js's SECTION_TYPES, width/vScale independently stretch // or contract that section horizontally/vertically (see sections.js's @@ -248,16 +270,10 @@ export function buildLevelData(sections, metadata, items) { startAngle: 0, terrain: terrainSegments, obstacles: [], - // Smashable items (editor "Smashables" section) - only in the level - // data when at least one was placed, so old hand-authored levels - // (which have no `items` key at all) keep working: SmashItemManager - // reads `level.items || []`. - ...(items && items.length > 0 ? { items: items.map((it) => ({ - type: it.type, - mode: it.mode, - x: Math.round(it.x), - y: Math.round(it.y), - })) } : {}), + // Placeables (editor "Placeables" section) - split into the two level + // arrays the game expects (see splitPlaceables): smashables -> `items`, + // bonus items -> `bonusItems`. Each omitted when empty. + ...splitPlaceables(items), goal: { x: goalX, y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE), diff --git a/src/editor/main.js b/src/editor/main.js index f74eeb8..e30768e 100644 --- a/src/editor/main.js +++ b/src/editor/main.js @@ -6,7 +6,7 @@ import { SECTION_VSCALE_MIN, SECTION_VSCALE_MAX, } from './sections.js'; -import { buildLevelData } from './levelBuilder.js'; +import { buildLevelData, splitPlaceables } from './levelBuilder.js'; import { exportLevelToSource } from './exportLevel.js'; import { BUS } from '../config.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 + // items remain editable, and the level can be re-exported. imported: null, - // Smashable props placed on the course (see the "Smashables" section below - // and SmashItemManager in the game). x/y are in the SAME scaled world units - // the preview canvas plots (and that the exported level uses); refIndex is - // the track section the item was added under (rebased on reordering). + // Smashable props + bonus items placed on the course. Each entry has a + // `kind`: 'smashable' (TV/chair/cone - can float or fall, smash off on hit) + // or 'bonus' (CTC/guitar/cash - always float, pop into fireworks on hit). + // 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: [], + // Which kind the Placeables section is currently placing (the toggle). + kind: 'smashable', + // Smashable selection (kind 'smashable'): type + placement mode. itemsType: 'tv', itemsMode: 'float', + // Bonus selection (kind 'bonus'): type only - bonus items are float-only. + bonusType: 'ctc', }; const paletteEl = document.getElementById('palette'); @@ -111,7 +118,13 @@ function setImported(level, source) { obstacles: level.obstacles, }, 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, }; // 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_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 // 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 @@ -220,57 +248,121 @@ function buildItemsForm() { fieldset.style.gap = '16px'; itemsFormEl.appendChild(fieldset); - const typeSelect = document.createElement('select'); - typeSelect.id = 'items-type'; - for (const t of ITEM_TYPES) { - const opt = document.createElement('option'); - opt.value = t.id; - opt.textContent = t.label; - if (t.id === state.itemsType) opt.selected = true; - typeSelect.appendChild(opt); - } - typeSelect.addEventListener('change', () => { - state.itemsType = typeSelect.value; - render(); - }); - const typeWrap = document.createElement('label'); - typeWrap.className = 'size-field'; - typeWrap.append('Item type', typeSelect); - fieldset.appendChild(typeWrap); - - const modeRadios = []; - for (const mode of ['float', 'gravity']) { + // --- Placeable kind toggle: smashables (TV/chair/cone) vs bonus items + // (CTC/guitar/cash). Only the relevant type+mode controls show below, + // and clicks on the preview place whichever kind is selected. + const kindRadios = []; + for (const kind of ['smashable', 'bonus']) { 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.name = 'placeable-kind'; + radio.value = kind; + radio.id = `placeable-kind-${kind}`; + if (kind === state.kind) radio.checked = true; radio.addEventListener('change', () => { - state.itemsMode = mode; + state.kind = kind; 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); + label.append(radio, kind === 'smashable' ? 'Smashables (TV / Chair / Cone)' : 'Bonus items (CTC / Guitar / Cash)'); + 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'); hint.style.margin = '8px 0 0'; hint.style.color = '#9aa7bb'; hint.style.fontSize = '12px'; - hint.textContent = 'Pick a type + mode, then click anywhere on the Preview map above to place the item there. Click a placed item in the list to delete it.'; + 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); } +// 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) { - const t = ITEM_TYPES.find((x) => x.id === item.type); - return `${index + 1}. ${t ? t.label : item.type} @ (${Math.round(item.x)}, ${Math.round(item.y)}) [${item.mode}]`; + const t = (item.kind === 'bonus' ? BONUS_TYPES : ITEM_TYPES).find((x) => x.id === item.type); + 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() { @@ -288,7 +380,8 @@ function buildItemsList() { el.className = 'item-row'; el.style.cssText = 'display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 10px;background:#1a2236;border:1px solid #2c374f;border-radius:4px;'; const dot = document.createElement('span'); - dot.style.cssText = `width:10px;height:10px;border-radius:50%;background:${ITEM_COLORS[item.type] || '#888'};flex:none;`; + const 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'); text.textContent = itemLabel(item, index); const del = document.createElement('span'); @@ -603,17 +696,21 @@ function drawPreview(levelData) { ctx.font = '12px monospace'; ctx.fillText('GOAL', gx - 16, gyTop - 6); - // Placed smashable items: the real sprite frame at the placement point - // (same space as terrain/start/goal, so the click-to-place mapping below - // is exact). Gravity items also get a dashed drop line down to the ground - // under that X - where they'll actually settle - with a small arrowhead. + // Placed placeables (smashables + bonus items): the real sprite frame at + // the placement point (same space as terrain/start/goal, so the + // click-to-place mapping below is exact). Smashable gravity items also get + // 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 for (const item of state.items) { const ix = screenX(item.x); 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); if (groundY !== Infinity) { const gy = screenY(groundY); @@ -638,20 +735,24 @@ function drawPreview(levelData) { } } - const frame = ITEM_SPRITE_FRAMES[item.type]; - if (ITEM_SPRITE.complete && ITEM_SPRITE.naturalWidth > 0 && frame !== undefined) { + const frameTable = isBonus ? BONUS_SPRITE_FRAMES : ITEM_SPRITE_FRAMES; + const spriteTable = isBonus ? BONUS_SPRITE : ITEM_SPRITE; + const frame = frameTable[item.type]; + if (spriteTable.complete && spriteTable.naturalWidth > 0 && frame !== undefined) { const half = itemDrawScale / 2; // float items get a light outline glow so they read as "held in place"; // gravity items rest on (or fall to) the ground, drawn as-is. - if (item.mode === 'float') { + if (item.mode !== 'gravity') { ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.strokeRect(ix - half - 1, iy - half - 1, itemDrawScale + 2, itemDrawScale + 2); ctx.restore(); } + // Both spritesheets share the same 64px cell size, so ITEM_SPRITE_CELL + // works for bonus frames too (no separate cell constant needed). ctx.drawImage( - ITEM_SPRITE, + spriteTable, frame * ITEM_SPRITE_CELL, 0, ITEM_SPRITE_CELL, ITEM_SPRITE_CELL, ix - half, iy - half, itemDrawScale, itemDrawScale, ); @@ -663,7 +764,8 @@ function drawPreview(levelData) { ctx.fill(); ctx.fillStyle = '#0b0e14'; 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 worldY = (sy - PADDING) / H_PX_PER_UNIT + previewBounds.minY; + const spec = placementSpec(); const item = { - type: state.itemsType, - mode: state.itemsMode, + kind: spec.kind, + type: spec.type, + ...(spec.kind === 'smashable' ? { mode: spec.mode } : {}), x: Math.round(worldX), y: Math.round(worldY), refIndex: state.selectedIndex !== null ? state.selectedIndex : 0, @@ -801,10 +906,16 @@ function render() { updateTrackControls(); updateSizePanel(); 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()) { // 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; currentLevelData = { id: state.metadata.id, @@ -816,9 +927,7 @@ function render() { startAngle: imp.metadata.startAngle, terrain: imp.terrain, obstacles: imp.metadata.obstacles || [], - ...(state.items.length > 0 ? { items: state.items.map((it) => ({ - type: it.type, mode: it.mode, x: Math.round(it.x), y: Math.round(it.y), - })) } : {}), + ...splitPlaceables(state.items), goal: imp.metadata.goal, cameraBounds: imp.metadata.cameraBounds, }; @@ -855,7 +964,8 @@ function initSizeControls() { buildPalette(); 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(); initSizeControls(); render(); diff --git a/src/entities/BonusItemManager.js b/src/entities/BonusItemManager.js new file mode 100644 index 0000000..7786aed --- /dev/null +++ b/src/entities/BonusItemManager.js @@ -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(). + } +} diff --git a/src/scenes/LevelScoreScene.js b/src/scenes/LevelScoreScene.js index 6b2b038..1a479bf 100644 --- a/src/scenes/LevelScoreScene.js +++ b/src/scenes/LevelScoreScene.js @@ -26,6 +26,7 @@ export default class LevelScoreScene extends Phaser.Scene { this.total = data.total; this.kidResults = data.kidResults; // boolean[] per seat - true = made it this.smashedItems = data.smashedItems || 0; + this.bonusItems = data.bonusItems || 0; this.timeRemainingSeconds = data.timeRemainingSeconds; this.freezeKey = data.freezeKey; this.runningScore = 0; @@ -108,6 +109,9 @@ export default class LevelScoreScene extends Phaser.Scene { await this._revealSmashed(); await this._wait(300); + await this._revealBonus(); + await this._wait(300); + await this._tallyTimeRemaining(); await this._wait(400); @@ -282,6 +286,57 @@ export default class LevelScoreScene extends Phaser.Scene { 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() { const y = GAME_HEIGHT * 0.86; const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => { diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index 4b5b7f9..d3f6007 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -8,6 +8,7 @@ import InputController from '../systems/InputController.js'; import GForceMonitor from '../systems/GForceMonitor.js'; import KidManager from '../systems/KidManager.js'; import SmashItemManager from '../entities/SmashItemManager.js'; +import BonusItemManager from '../entities/BonusItemManager.js'; import CameraRig from '../systems/CameraRig.js'; import EngineSound from '../systems/EngineSound.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. 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 // (floor + walls) as simple rectangles tracking the bus, plus per-kid // 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 kidResults = this.kidManager.kids.map((kid) => kid.state === 'aboard'); const smashedItems = this.smashItems.smashedCount; + const bonusItems = this.bonusItems.collectedCount; const timeRemainingSeconds = Math.max(0, Math.ceil(this._timeRemaining)); // Renderer.snapshot() only resolves after the NEXT frame renders, but @@ -318,6 +325,7 @@ export default class PlayScene extends Phaser.Scene { total, kidResults, smashedItems, + bonusItems, timeRemainingSeconds, freezeKey, }); @@ -338,6 +346,7 @@ export default class PlayScene extends Phaser.Scene { if (this.gForceMonitor) this.gForceMonitor.destroy(); if (this.kidManager) this.kidManager.destroy(); if (this.smashItems) this.smashItems.destroy(); + if (this.bonusItems) this.bonusItems.destroy(); if (this.engineSound) this.engineSound.destroy(); if (this.voiceSound) this.voiceSound.stop(); if (this.bus) this.bus.destroy(); diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js index c6a6744..b67caa7 100644 --- a/src/util/assetManifest.js +++ b/src/util/assetManifest.js @@ -19,6 +19,9 @@ export const ASSET_MANIFEST = [ // frameWidth/frameHeight are raw SOURCE pixels (one 64x64 cell each), // same convention as the kid sheets above. { key: 'smash_items', path: 'assets/sprites/smash-items.png', width: 64 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, frameWidth: 64, frameHeight: 64 }, + // 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_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 },