Expand to 10 levels with 2-lane serpentine map layout

- Add levels 7-10 and move all 10 into campaign01 (campaign02 left empty
  as scaffolding)
- Replace the old S-curve auto-layout with a 2-lane circuit: top lane
  runs right→left, bottom lane left→right, finish flag in the
  bottom-right corner. Comfortable up to ~12 levels per campaign.
- Re-export node/flag sprites as tight 512x512 badges (badge IS the
  file) instead of centered-on-960x540 frames; update mapArt.js tables
  (BADGE_FILE_FRAC, BADGE_HIT_FRAC, NODE_ART_FRAMES) and asset manifest
  accordingly
- Size the finish-flag nudge to stay on-screen when a custom `positions`
  layout ends at an edge (flips direction if out of bounds)
- Guard against empty campaigns: skip path/flag rendering when there are
  no points, and bail early in _playFirst()
- Shrink node pill font slightly so the longest tag fits under tightly
  spaced nodes
- Update README.md and sprites.md to reflect the new sprite convention,
  lane geometry, and level count
This commit is contained in:
Brian Fertig 2026-08-22 08:56:41 -06:00
parent 5e49c055a2
commit d6630dce3a
11 changed files with 1196 additions and 110 deletions

View File

@ -34,7 +34,7 @@ src/config.js every tunable constant (physics, g-force threshold, camer
src/scenes/ Boot -> Preload -> Intro -> MainMenu -> LevelSelect (map) -> Play -> LevelScore -> LevelSelect / LevelFailed src/scenes/ Boot -> Preload -> Intro -> MainMenu -> LevelSelect (map) -> Play -> LevelScore -> LevelSelect / LevelFailed
src/entities/ Bus, Kid, Terrain src/entities/ Bus, Kid, Terrain
src/systems/ GForceMonitor, KidManager, InputController, CameraRig src/systems/ GForceMonitor, KidManager, InputController, CameraRig
src/data/levels/ the 3 hand-authored levels src/data/levels/ the hand-authored levels (10, split across the two campaigns)
src/util/ asset manifest, placeholder-texture generator, localStorage progress, small UI helper src/util/ asset manifest, placeholder-texture generator, localStorage progress, small UI helper
assets/ where real art goes (see manifest below) - currently empty assets/ where real art goes (see manifest below) - currently empty
``` ```

View File

@ -84,46 +84,54 @@ road ribbon snaking between level nodes, a finish flag past the last node,
and a banner plate behind the campaign title. There is one of each of these and a banner plate behind the campaign title. There is one of each of these
per campaign, plus the shared node/flag/plate sprites. per campaign, plus the shared node/flag/plate sprites.
**Important convention for node/flag/plate sprites:** they are drawn centered **Important convention for these sprites:** each is displayed at a fixed
on a **960x540 transparent canvas** (1920x1080 at 2x) and displayed full-frame display size with origin (0.5, 0.5) at its map position, so **keep the
at a position, so *where you draw the art on that canvas is where it appears subject centered in its file** or it will show up shifted. The node badges
in the game*. Keep the subject centered in the frame, or it will show up and the flag ship as tight 512x512 images (the badge/flag IS the file);
shifted. `map_campaign_tag` is a full-screen 1920x1080 frame with the plate centered
near the top. Sizes below are the display sizes the game uses (design
units); the source file's own resolution only affects crispness.
| key | path | subject size (design units) | notes | | key | path | subject size (design units) | notes |
|---|---|---|---| |---|---|---|---|
| `campaign01_bg` | `assets/backgrounds/campaign01_bg.png` | full frame 960x540 | Campaign 1 "Sunny Suburbs" background | | `campaign01_bg` | `assets/backgrounds/campaign01_bg.png` | full frame 960x540 | Campaign 1 "Sunny Suburbs" background |
| `campaign02_bg` | `assets/backgrounds/campaign02_bg.png` | full frame 960x540 | Campaign 2 "Dusk Junction" background | | `campaign02_bg` | `assets/backgrounds/campaign02_bg.png` | full frame 960x540 | Campaign 2 "Dusk Junction" background |
| `map_node` | `assets/ui/map_node.png` | ~450x450 badge centered | completed level node (number badge) | | `map_node` | `assets/ui/map_node.png` | ~58x58 badge (512x512 file) | completed level node |
| `map_node_current` | `assets/ui/map_node_current.png` | **512x512 badge, no padding frame** (exception to the centered-frame rule) | the next level to play ("start here" node) | | `map_node_current` | `assets/ui/map_node_current.png` | ~71x71 badge (512x512 file) | the next level to play ("start here" node) |
| `map_node_locked` | `assets/ui/map_node_locked.png` | ~450x450 badge centered | locked level node | | `map_node_locked` | `assets/ui/map_node_locked.png` | ~58x58 badge (512x512 file) | locked level node |
| `map_finish` | `assets/ui/map_finish.png` | ~440x560 centered | flag past the last node | | `map_finish` | `assets/ui/map_finish.png` | ~345x345 (512x512 file) | flag past the last node |
| `map_campaign_tag` | `assets/ui/map_campaign_tag.png` | ~1440x300 plate centered near top | banner behind the campaign title text (title is drawn on top at y≈82/540) | | `map_campaign_tag` | `assets/ui/map_campaign_tag.png` | ~1440x300 plate centered near top | banner behind the campaign title text (title is drawn on top at y≈82/540) |
Per-campaign notes: Per-campaign notes:
- **`campaign0X_bg.png`** - full 960x540 (or 2x) scene, no transparency needed. - **`campaign0X_bg.png`** - full 960x540 (or 2x) scene, no transparency needed.
The top ~150 units are behind the title plate, so keep that area fairly The top ~150 units are behind the title plate, so keep that area fairly
quiet. Leave the bottom 60% open road/grass for the path to sit on. quiet. Node positions are auto-placed in a **2-lane serpentine** that sits
The node positions live in `src/data/levels/index.js` (`positions`, or in the band **y ≈ 268-412** (design units) across the full map width
auto-layout via `src/data/levels/positioning.js`) - check where your (x ≈ 90-870) - both lanes - so keep that horizontal strip fairly open and
nodes will land before you paint, and keep that corridor readable. readable, and leave the bottom ~60 units clear for the `< Menu` / hint
chrome. The exact lane y-values live in `src/data/levels/positioning.js`
(`LANE_TOP`/`LANE_BOTTOM`) and per-campaign overrides in
`src/data/levels/index.js` (`positions`) - check where your nodes will
land before you paint.
- **Node states** share one silhouette (same size/placement) in three moods: - **Node states** share one silhouette (same size/placement) in three moods:
`map_node` (cleared, e.g. blue with the level number), `map_node_current` `map_node` (cleared), `map_node_current` (the one to play next - this one
(the one to play next, e.g. gold - this one gets a gentle idle pulse), gets a gentle idle pulse), `map_node_locked` (greyed out, e.g. a padlock).
`map_node_locked` (greyed out, e.g. a padlock). If you want the badge to All three ship as 512x512 badge images with no padding frame; their
be a different size than ~450 units, also adjust `BADGE_FRAC_OF_FRAME` display size and hit area are driven by `NODE_ART_FRAMES` +
in `src/util/mapArt.js`. Note `map_node_current` currently ships as a `BADGE_FILE_FRAC` per-key in `src/util/mapArt.js`, so a different frame
tight 512x512 image (no 960x540 padding frame); its size/hit area are size there is fine as long as those tables are updated. If you want the
driven by `NODE_ART_FRAMES` + `BADGE_FILE_FRAC` per-key in `mapArt.js`, badge to be a different display size than ~58-71 units, adjust
so a different frame size there is fine as long as those tables are `BADGE_FRAC_OF_FRAME` / the per-state `BADGE_FILE_FRAC` entries in
updated. `src/util/mapArt.js` (the click area follows the art, not these knobs).
- **`map_finish.png`** - the flag sits just past the last node, on the road, - **`map_finish.png`** - the flag sits just past the last node, so a base/mound
so a base/mound that grounds it looks best; don't rely on the bottom of that grounds it looks best. Currently ships as a tight 512x512 image (flag
the 960x540 canvas being the ground line. Currently ships as a tight ink is ~60% of the width, full height); the scene displays it at 0.18x the
512x512 image (flag ink is ~60% of the width, full height); the scene game screen, so keep the flag roughly centered in a square frame if you
displays it at 0.28x the game screen, so keep the flag roughly centered re-export it. The auto-layout circuit ends at the bottom-right, so the
in a square frame if you re-export it. flag lands in the bottom-right corner past the final node (with custom
`positions` the nudge flips to stay on-screen when the last node is at an
edge).
- **`map_campaign_tag.png`** - a plate/banner with empty center space; the - **`map_campaign_tag.png`** - a plate/banner with empty center space; the
game writes `"<name> x/y cleared"` on top of it in dark ink (#1a1f29), game writes `"<name> x/y cleared"` on top of it in dark ink (#1a1f29),
so the middle should be a light, uncluttered fill. so the middle should be a light, uncluttered fill.

View File

@ -4,6 +4,10 @@ import level03 from './level03.js';
import level04 from './level04.js'; import level04 from './level04.js';
import level05 from './level05.js'; import level05 from './level05.js';
import level06 from './level06.js'; import level06 from './level06.js';
import level07 from './level07.js';
import level08 from './level08.js';
import level09 from './level09.js';
import level10 from './level10.js';
// Campaigns group levels into themed "maps" for the level select screen. // Campaigns group levels into themed "maps" for the level select screen.
// Each campaign gets its own background art (bgKey) and theme, so adding a // Each campaign gets its own background art (bgKey) and theme, so adding a
@ -11,24 +15,27 @@ import level06 from './level06.js';
// row here. Node positions are in 960x540 design units (NOT multiplied by // row here. Node positions are in 960x540 design units (NOT multiplied by
// WORLD_SCALE - the scene scales them), and the path is rendered as a line // WORLD_SCALE - the scene scales them), and the path is rendered as a line
// through them in order. `positions` may be omitted to auto-place the // through them in order. `positions` may be omitted to auto-place the
// campaign's levels along a gentle S-curve. // campaign's levels: a 2-lane circuit (top lane right->left, bottom lane
// left->right, finish flag bottom-right) that comfortably fits up to ~12
// levels (see positioning.js). Give explicit `positions` only when you want
// a custom layout - the auto-layout keeps every node in the open band of
// the background art (y roughly 268-412) and clear of the title plate and
// bottom chrome.
export const CAMPAIGNS = [ export const CAMPAIGNS = [
{ {
id: 'campaign01', id: 'campaign01',
name: 'Evergreen Lake', name: 'Evergreen Lake',
bgKey: 'campaign01_bg', bgKey: 'campaign01_bg',
levels: [level01, level02, level03], levels: [level01, level02, level03, level04, level05, level06, level07, level08, level09, level10],
positions: [
{ x: 160, y: 392 },
{ x: 400, y: 330 },
{ x: 640, y: 380 },
],
}, },
{ {
id: 'campaign02', id: 'campaign02',
name: 'Underprivileged Community', name: 'Underprivileged Community',
bgKey: 'campaign02_bg', bgKey: 'campaign02_bg',
levels: [level04, level05, level06], // Its old levels (level04-06) moved into campaign01 while it grows toward
// 10; add campaign02's own levels here as they're built. An empty
// campaign renders as a bare map with "0/0 cleared" - fine as scaffolding.
levels: [],
}, },
]; ];

250
src/data/levels/level07.js Normal file
View File

@ -0,0 +1,250 @@
// Scaffolding level - terrain is a copy of level06.
// Build out the real layout in editor.html, then replace this file.
export default {
id: "level07",
name: "Rush Hour",
description: "Kids get home before dinner!",
kidsAboard: 3,
timeLimit: 41,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 840 },
{ x: 1140, y: 840 },
{ x: 1210, y: 840 },
{ x: 1280, y: 840 },
{ x: 1350, y: 840 },
{ x: 1420, y: 840 },
{ x: 1490, y: 840 },
{ x: 1560, y: 840 },
{ x: 1630, y: 840 },
{ x: 1700, y: 840 },
{ x: 1770, y: 840 },
{ x: 1840, y: 840 },
{ x: 1910, y: 840 },
{ x: 1980, y: 840 },
{ x: 2050, y: 840 },
{ x: 2120, y: 840 },
{ x: 2190, y: 840 },
{ x: 2260, y: 840 },
{ x: 2330, y: 840 },
{ x: 2400, y: 840 },
{ x: 2470, y: 840 },
{ x: 2540, y: 840 },
{ x: 2610, y: 840 },
{ x: 2680, y: 840 },
{ x: 2750, y: 840 },
{ x: 2820, y: 840 },
{ x: 2890, y: 840 },
{ x: 2960, y: 840 },
{ x: 3030, y: 840 },
{ x: 3100, y: 840 },
{ x: 3170, y: 840 },
{ x: 3240, y: 840 },
{ x: 3310, y: 840 },
{ x: 3380, y: 840 },
{ x: 3450, y: 840 },
{ x: 3520, y: 840 },
{ x: 3590, y: 840 },
{ x: 3660, y: 840 },
{ x: 3730, y: 840 },
{ x: 3800, y: 840 },
{ x: 3870, y: 842 },
{ x: 3940, y: 846 },
{ x: 4010, y: 850 },
{ x: 4080, y: 858 },
{ x: 4150, y: 868 },
{ x: 4220, y: 878 },
{ x: 4290, y: 890 },
{ x: 4360, y: 904 },
{ x: 4430, y: 916 },
{ x: 4500, y: 930 },
{ x: 4570, y: 944 },
{ x: 4640, y: 956 },
{ x: 4710, y: 970 },
{ x: 4780, y: 982 },
{ x: 4850, y: 992 },
{ x: 4920, y: 1002 },
{ x: 4990, y: 1010 },
{ x: 5060, y: 1014 },
{ x: 5130, y: 1018 },
{ x: 5200, y: 1020 },
{ x: 5270, y: 1020 },
{ x: 5340, y: 1020 },
{ x: 5410, y: 1020 },
{ x: 5480, y: 1020 },
{ x: 5550, y: 1020 },
{ x: 5620, y: 1020 },
{ x: 5690, y: 1020 },
{ x: 5760, y: 1020 },
{ x: 5830, y: 1020 },
{ x: 5900, y: 1020 },
{ x: 5970, y: 1020 },
{ x: 6040, y: 1020 },
{ x: 6110, y: 1020 },
{ x: 6180, y: 1020 },
{ x: 6250, y: 1020 },
{ x: 6320, y: 1020 },
{ x: 6390, y: 1020 },
{ x: 6460, y: 1020 },
{ x: 6530, y: 1020 },
{ x: 6600, y: 1020 },
{ x: 6670, y: 1018 },
{ x: 6740, y: 1014 },
{ x: 6810, y: 1010 },
{ x: 6880, y: 1002 },
{ x: 6950, y: 992 },
{ x: 7020, y: 982 },
{ x: 7090, y: 970 },
{ x: 7160, y: 956 },
{ x: 7230, y: 944 },
{ x: 7300, y: 930 },
{ x: 7370, y: 916 },
{ x: 7440, y: 904 },
{ x: 7510, y: 890 },
{ x: 7580, y: 878 },
{ x: 7650, y: 868 },
{ x: 7720, y: 858 },
{ x: 7790, y: 850 },
{ x: 7860, y: 846 },
{ x: 7930, y: 842 },
{ x: 8000, y: 840 },
{ x: 8070, y: 840 },
{ x: 8140, y: 840 },
{ x: 8210, y: 840 },
{ x: 8280, y: 840 },
{ x: 8350, y: 840 },
{ x: 8420, y: 840 },
{ x: 8490, y: 840 },
{ x: 8560, y: 840 },
{ x: 8630, y: 840 },
{ x: 8700, y: 840 },
{ x: 8770, y: 840 },
{ x: 8840, y: 840 },
{ x: 8910, y: 840 },
{ x: 8980, y: 840 },
{ x: 9050, y: 840 },
{ x: 9120, y: 840 },
{ x: 9190, y: 840 },
{ x: 9260, y: 840 },
{ x: 9330, y: 840 },
{ x: 9400, y: 840 },
{ x: 9470, y: 840 },
{ x: 9540, y: 840 },
{ x: 9610, y: 840 },
{ x: 9680, y: 840 },
{ x: 9750, y: 840 },
{ x: 9820, y: 840 },
{ x: 9890, y: 840 },
{ x: 9960, y: 840 },
{ x: 10030, y: 840 },
{ x: 10100, y: 840 },
{ x: 10170, y: 840 },
{ x: 10240, y: 840 },
{ x: 10310, y: 840 },
{ x: 10380, y: 840 },
{ x: 10450, y: 840 },
{ x: 10520, y: 840 },
{ x: 10590, y: 840 },
{ x: 10660, y: 840 },
{ x: 10730, y: 840 },
{ x: 10800, y: 840 },
{ x: 10870, y: 840 },
{ x: 10940, y: 840 },
{ x: 11010, y: 840 },
{ x: 11080, y: 840 },
{ x: 11150, y: 840 },
{ x: 11220, y: 840 },
{ x: 11290, y: 840 },
{ x: 11360, y: 840 },
{ x: 11430, y: 840 },
{ x: 11500, y: 840 },
{ x: 11570, y: 840 },
{ x: 11640, y: 840 },
{ x: 11710, y: 840 },
{ x: 11780, y: 840 },
{ x: 11808, y: 840 },
{ x: 11878, y: 836 },
{ x: 11948, y: 822 },
{ x: 12018, y: 800 },
{ x: 12088, y: 768 },
{ x: 12158, y: 728 },
{ x: 12200, y: 700 },
{ x: 12270, y: 700 },
] },
{ points: [
{ x: 13704, y: 1180 },
{ x: 13774, y: 1180 },
{ x: 13844, y: 1180 },
{ x: 13914, y: 1180 },
{ x: 13984, y: 1180 },
{ x: 14054, y: 1180 },
{ x: 14080, y: 1180 },
{ x: 14150, y: 1180 },
{ x: 14220, y: 1180 },
{ x: 14290, y: 1180 },
{ x: 14360, y: 1180 },
{ x: 14430, y: 1180 },
{ x: 14500, y: 1180 },
{ x: 14570, y: 1180 },
{ x: 14640, y: 1180 },
{ x: 14710, y: 1180 },
{ x: 14780, y: 1180 },
{ x: 14850, y: 1180 },
{ x: 14920, y: 1180 },
{ x: 14990, y: 1180 },
{ x: 15060, y: 1180 },
{ x: 15130, y: 1180 },
{ x: 15200, y: 1180 },
{ x: 15270, y: 1180 },
{ x: 15340, y: 1180 },
{ x: 15410, y: 1180 },
{ x: 15480, y: 1180 },
{ x: 15550, y: 1180 },
{ x: 15620, y: 1180 },
{ x: 15690, y: 1180 },
{ x: 15760, y: 1180 },
{ x: 15830, y: 1180 },
{ x: 15900, y: 1180 },
{ x: 15970, y: 1180 },
{ x: 16040, y: 1180 },
{ x: 16110, y: 1180 },
{ x: 16180, y: 1180 },
{ x: 16250, y: 1180 },
{ x: 16320, y: 1180 },
{ x: 16390, y: 1180 },
{ x: 16460, y: 1180 },
{ x: 16530, y: 1180 },
{ x: 16600, y: 1180 },
{ x: 16670, y: 1180 },
{ x: 16740, y: 1180 },
{ x: 16810, y: 1180 },
{ x: 16880, y: 1180 },
] },
],
obstacles: [],
goal: { x: 16640, y: 940, width: 280, height: 640 },
cameraBounds: { x: -600, y: -100, width: 18280, height: 2080 },
};

250
src/data/levels/level08.js Normal file
View File

@ -0,0 +1,250 @@
// Scaffolding level - terrain is a copy of level06.
// Build out the real layout in editor.html, then replace this file.
export default {
id: "level08",
name: "Pothole Panic",
description: "Roads not built for a bus... or its cargo.",
kidsAboard: 3,
timeLimit: 41,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 840 },
{ x: 1140, y: 840 },
{ x: 1210, y: 840 },
{ x: 1280, y: 840 },
{ x: 1350, y: 840 },
{ x: 1420, y: 840 },
{ x: 1490, y: 840 },
{ x: 1560, y: 840 },
{ x: 1630, y: 840 },
{ x: 1700, y: 840 },
{ x: 1770, y: 840 },
{ x: 1840, y: 840 },
{ x: 1910, y: 840 },
{ x: 1980, y: 840 },
{ x: 2050, y: 840 },
{ x: 2120, y: 840 },
{ x: 2190, y: 840 },
{ x: 2260, y: 840 },
{ x: 2330, y: 840 },
{ x: 2400, y: 840 },
{ x: 2470, y: 840 },
{ x: 2540, y: 840 },
{ x: 2610, y: 840 },
{ x: 2680, y: 840 },
{ x: 2750, y: 840 },
{ x: 2820, y: 840 },
{ x: 2890, y: 840 },
{ x: 2960, y: 840 },
{ x: 3030, y: 840 },
{ x: 3100, y: 840 },
{ x: 3170, y: 840 },
{ x: 3240, y: 840 },
{ x: 3310, y: 840 },
{ x: 3380, y: 840 },
{ x: 3450, y: 840 },
{ x: 3520, y: 840 },
{ x: 3590, y: 840 },
{ x: 3660, y: 840 },
{ x: 3730, y: 840 },
{ x: 3800, y: 840 },
{ x: 3870, y: 842 },
{ x: 3940, y: 846 },
{ x: 4010, y: 850 },
{ x: 4080, y: 858 },
{ x: 4150, y: 868 },
{ x: 4220, y: 878 },
{ x: 4290, y: 890 },
{ x: 4360, y: 904 },
{ x: 4430, y: 916 },
{ x: 4500, y: 930 },
{ x: 4570, y: 944 },
{ x: 4640, y: 956 },
{ x: 4710, y: 970 },
{ x: 4780, y: 982 },
{ x: 4850, y: 992 },
{ x: 4920, y: 1002 },
{ x: 4990, y: 1010 },
{ x: 5060, y: 1014 },
{ x: 5130, y: 1018 },
{ x: 5200, y: 1020 },
{ x: 5270, y: 1020 },
{ x: 5340, y: 1020 },
{ x: 5410, y: 1020 },
{ x: 5480, y: 1020 },
{ x: 5550, y: 1020 },
{ x: 5620, y: 1020 },
{ x: 5690, y: 1020 },
{ x: 5760, y: 1020 },
{ x: 5830, y: 1020 },
{ x: 5900, y: 1020 },
{ x: 5970, y: 1020 },
{ x: 6040, y: 1020 },
{ x: 6110, y: 1020 },
{ x: 6180, y: 1020 },
{ x: 6250, y: 1020 },
{ x: 6320, y: 1020 },
{ x: 6390, y: 1020 },
{ x: 6460, y: 1020 },
{ x: 6530, y: 1020 },
{ x: 6600, y: 1020 },
{ x: 6670, y: 1018 },
{ x: 6740, y: 1014 },
{ x: 6810, y: 1010 },
{ x: 6880, y: 1002 },
{ x: 6950, y: 992 },
{ x: 7020, y: 982 },
{ x: 7090, y: 970 },
{ x: 7160, y: 956 },
{ x: 7230, y: 944 },
{ x: 7300, y: 930 },
{ x: 7370, y: 916 },
{ x: 7440, y: 904 },
{ x: 7510, y: 890 },
{ x: 7580, y: 878 },
{ x: 7650, y: 868 },
{ x: 7720, y: 858 },
{ x: 7790, y: 850 },
{ x: 7860, y: 846 },
{ x: 7930, y: 842 },
{ x: 8000, y: 840 },
{ x: 8070, y: 840 },
{ x: 8140, y: 840 },
{ x: 8210, y: 840 },
{ x: 8280, y: 840 },
{ x: 8350, y: 840 },
{ x: 8420, y: 840 },
{ x: 8490, y: 840 },
{ x: 8560, y: 840 },
{ x: 8630, y: 840 },
{ x: 8700, y: 840 },
{ x: 8770, y: 840 },
{ x: 8840, y: 840 },
{ x: 8910, y: 840 },
{ x: 8980, y: 840 },
{ x: 9050, y: 840 },
{ x: 9120, y: 840 },
{ x: 9190, y: 840 },
{ x: 9260, y: 840 },
{ x: 9330, y: 840 },
{ x: 9400, y: 840 },
{ x: 9470, y: 840 },
{ x: 9540, y: 840 },
{ x: 9610, y: 840 },
{ x: 9680, y: 840 },
{ x: 9750, y: 840 },
{ x: 9820, y: 840 },
{ x: 9890, y: 840 },
{ x: 9960, y: 840 },
{ x: 10030, y: 840 },
{ x: 10100, y: 840 },
{ x: 10170, y: 840 },
{ x: 10240, y: 840 },
{ x: 10310, y: 840 },
{ x: 10380, y: 840 },
{ x: 10450, y: 840 },
{ x: 10520, y: 840 },
{ x: 10590, y: 840 },
{ x: 10660, y: 840 },
{ x: 10730, y: 840 },
{ x: 10800, y: 840 },
{ x: 10870, y: 840 },
{ x: 10940, y: 840 },
{ x: 11010, y: 840 },
{ x: 11080, y: 840 },
{ x: 11150, y: 840 },
{ x: 11220, y: 840 },
{ x: 11290, y: 840 },
{ x: 11360, y: 840 },
{ x: 11430, y: 840 },
{ x: 11500, y: 840 },
{ x: 11570, y: 840 },
{ x: 11640, y: 840 },
{ x: 11710, y: 840 },
{ x: 11780, y: 840 },
{ x: 11808, y: 840 },
{ x: 11878, y: 836 },
{ x: 11948, y: 822 },
{ x: 12018, y: 800 },
{ x: 12088, y: 768 },
{ x: 12158, y: 728 },
{ x: 12200, y: 700 },
{ x: 12270, y: 700 },
] },
{ points: [
{ x: 13704, y: 1180 },
{ x: 13774, y: 1180 },
{ x: 13844, y: 1180 },
{ x: 13914, y: 1180 },
{ x: 13984, y: 1180 },
{ x: 14054, y: 1180 },
{ x: 14080, y: 1180 },
{ x: 14150, y: 1180 },
{ x: 14220, y: 1180 },
{ x: 14290, y: 1180 },
{ x: 14360, y: 1180 },
{ x: 14430, y: 1180 },
{ x: 14500, y: 1180 },
{ x: 14570, y: 1180 },
{ x: 14640, y: 1180 },
{ x: 14710, y: 1180 },
{ x: 14780, y: 1180 },
{ x: 14850, y: 1180 },
{ x: 14920, y: 1180 },
{ x: 14990, y: 1180 },
{ x: 15060, y: 1180 },
{ x: 15130, y: 1180 },
{ x: 15200, y: 1180 },
{ x: 15270, y: 1180 },
{ x: 15340, y: 1180 },
{ x: 15410, y: 1180 },
{ x: 15480, y: 1180 },
{ x: 15550, y: 1180 },
{ x: 15620, y: 1180 },
{ x: 15690, y: 1180 },
{ x: 15760, y: 1180 },
{ x: 15830, y: 1180 },
{ x: 15900, y: 1180 },
{ x: 15970, y: 1180 },
{ x: 16040, y: 1180 },
{ x: 16110, y: 1180 },
{ x: 16180, y: 1180 },
{ x: 16250, y: 1180 },
{ x: 16320, y: 1180 },
{ x: 16390, y: 1180 },
{ x: 16460, y: 1180 },
{ x: 16530, y: 1180 },
{ x: 16600, y: 1180 },
{ x: 16670, y: 1180 },
{ x: 16740, y: 1180 },
{ x: 16810, y: 1180 },
{ x: 16880, y: 1180 },
] },
],
obstacles: [],
goal: { x: 16640, y: 940, width: 280, height: 640 },
cameraBounds: { x: -600, y: -100, width: 18280, height: 2080 },
};

250
src/data/levels/level09.js Normal file
View File

@ -0,0 +1,250 @@
// Scaffolding level - terrain is a copy of level06.
// Build out the real layout in editor.html, then replace this file.
export default {
id: "level09",
name: "The Switchback",
description: "Left, right, left, right. Stay calm.",
kidsAboard: 3,
timeLimit: 41,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 840 },
{ x: 1140, y: 840 },
{ x: 1210, y: 840 },
{ x: 1280, y: 840 },
{ x: 1350, y: 840 },
{ x: 1420, y: 840 },
{ x: 1490, y: 840 },
{ x: 1560, y: 840 },
{ x: 1630, y: 840 },
{ x: 1700, y: 840 },
{ x: 1770, y: 840 },
{ x: 1840, y: 840 },
{ x: 1910, y: 840 },
{ x: 1980, y: 840 },
{ x: 2050, y: 840 },
{ x: 2120, y: 840 },
{ x: 2190, y: 840 },
{ x: 2260, y: 840 },
{ x: 2330, y: 840 },
{ x: 2400, y: 840 },
{ x: 2470, y: 840 },
{ x: 2540, y: 840 },
{ x: 2610, y: 840 },
{ x: 2680, y: 840 },
{ x: 2750, y: 840 },
{ x: 2820, y: 840 },
{ x: 2890, y: 840 },
{ x: 2960, y: 840 },
{ x: 3030, y: 840 },
{ x: 3100, y: 840 },
{ x: 3170, y: 840 },
{ x: 3240, y: 840 },
{ x: 3310, y: 840 },
{ x: 3380, y: 840 },
{ x: 3450, y: 840 },
{ x: 3520, y: 840 },
{ x: 3590, y: 840 },
{ x: 3660, y: 840 },
{ x: 3730, y: 840 },
{ x: 3800, y: 840 },
{ x: 3870, y: 842 },
{ x: 3940, y: 846 },
{ x: 4010, y: 850 },
{ x: 4080, y: 858 },
{ x: 4150, y: 868 },
{ x: 4220, y: 878 },
{ x: 4290, y: 890 },
{ x: 4360, y: 904 },
{ x: 4430, y: 916 },
{ x: 4500, y: 930 },
{ x: 4570, y: 944 },
{ x: 4640, y: 956 },
{ x: 4710, y: 970 },
{ x: 4780, y: 982 },
{ x: 4850, y: 992 },
{ x: 4920, y: 1002 },
{ x: 4990, y: 1010 },
{ x: 5060, y: 1014 },
{ x: 5130, y: 1018 },
{ x: 5200, y: 1020 },
{ x: 5270, y: 1020 },
{ x: 5340, y: 1020 },
{ x: 5410, y: 1020 },
{ x: 5480, y: 1020 },
{ x: 5550, y: 1020 },
{ x: 5620, y: 1020 },
{ x: 5690, y: 1020 },
{ x: 5760, y: 1020 },
{ x: 5830, y: 1020 },
{ x: 5900, y: 1020 },
{ x: 5970, y: 1020 },
{ x: 6040, y: 1020 },
{ x: 6110, y: 1020 },
{ x: 6180, y: 1020 },
{ x: 6250, y: 1020 },
{ x: 6320, y: 1020 },
{ x: 6390, y: 1020 },
{ x: 6460, y: 1020 },
{ x: 6530, y: 1020 },
{ x: 6600, y: 1020 },
{ x: 6670, y: 1018 },
{ x: 6740, y: 1014 },
{ x: 6810, y: 1010 },
{ x: 6880, y: 1002 },
{ x: 6950, y: 992 },
{ x: 7020, y: 982 },
{ x: 7090, y: 970 },
{ x: 7160, y: 956 },
{ x: 7230, y: 944 },
{ x: 7300, y: 930 },
{ x: 7370, y: 916 },
{ x: 7440, y: 904 },
{ x: 7510, y: 890 },
{ x: 7580, y: 878 },
{ x: 7650, y: 868 },
{ x: 7720, y: 858 },
{ x: 7790, y: 850 },
{ x: 7860, y: 846 },
{ x: 7930, y: 842 },
{ x: 8000, y: 840 },
{ x: 8070, y: 840 },
{ x: 8140, y: 840 },
{ x: 8210, y: 840 },
{ x: 8280, y: 840 },
{ x: 8350, y: 840 },
{ x: 8420, y: 840 },
{ x: 8490, y: 840 },
{ x: 8560, y: 840 },
{ x: 8630, y: 840 },
{ x: 8700, y: 840 },
{ x: 8770, y: 840 },
{ x: 8840, y: 840 },
{ x: 8910, y: 840 },
{ x: 8980, y: 840 },
{ x: 9050, y: 840 },
{ x: 9120, y: 840 },
{ x: 9190, y: 840 },
{ x: 9260, y: 840 },
{ x: 9330, y: 840 },
{ x: 9400, y: 840 },
{ x: 9470, y: 840 },
{ x: 9540, y: 840 },
{ x: 9610, y: 840 },
{ x: 9680, y: 840 },
{ x: 9750, y: 840 },
{ x: 9820, y: 840 },
{ x: 9890, y: 840 },
{ x: 9960, y: 840 },
{ x: 10030, y: 840 },
{ x: 10100, y: 840 },
{ x: 10170, y: 840 },
{ x: 10240, y: 840 },
{ x: 10310, y: 840 },
{ x: 10380, y: 840 },
{ x: 10450, y: 840 },
{ x: 10520, y: 840 },
{ x: 10590, y: 840 },
{ x: 10660, y: 840 },
{ x: 10730, y: 840 },
{ x: 10800, y: 840 },
{ x: 10870, y: 840 },
{ x: 10940, y: 840 },
{ x: 11010, y: 840 },
{ x: 11080, y: 840 },
{ x: 11150, y: 840 },
{ x: 11220, y: 840 },
{ x: 11290, y: 840 },
{ x: 11360, y: 840 },
{ x: 11430, y: 840 },
{ x: 11500, y: 840 },
{ x: 11570, y: 840 },
{ x: 11640, y: 840 },
{ x: 11710, y: 840 },
{ x: 11780, y: 840 },
{ x: 11808, y: 840 },
{ x: 11878, y: 836 },
{ x: 11948, y: 822 },
{ x: 12018, y: 800 },
{ x: 12088, y: 768 },
{ x: 12158, y: 728 },
{ x: 12200, y: 700 },
{ x: 12270, y: 700 },
] },
{ points: [
{ x: 13704, y: 1180 },
{ x: 13774, y: 1180 },
{ x: 13844, y: 1180 },
{ x: 13914, y: 1180 },
{ x: 13984, y: 1180 },
{ x: 14054, y: 1180 },
{ x: 14080, y: 1180 },
{ x: 14150, y: 1180 },
{ x: 14220, y: 1180 },
{ x: 14290, y: 1180 },
{ x: 14360, y: 1180 },
{ x: 14430, y: 1180 },
{ x: 14500, y: 1180 },
{ x: 14570, y: 1180 },
{ x: 14640, y: 1180 },
{ x: 14710, y: 1180 },
{ x: 14780, y: 1180 },
{ x: 14850, y: 1180 },
{ x: 14920, y: 1180 },
{ x: 14990, y: 1180 },
{ x: 15060, y: 1180 },
{ x: 15130, y: 1180 },
{ x: 15200, y: 1180 },
{ x: 15270, y: 1180 },
{ x: 15340, y: 1180 },
{ x: 15410, y: 1180 },
{ x: 15480, y: 1180 },
{ x: 15550, y: 1180 },
{ x: 15620, y: 1180 },
{ x: 15690, y: 1180 },
{ x: 15760, y: 1180 },
{ x: 15830, y: 1180 },
{ x: 15900, y: 1180 },
{ x: 15970, y: 1180 },
{ x: 16040, y: 1180 },
{ x: 16110, y: 1180 },
{ x: 16180, y: 1180 },
{ x: 16250, y: 1180 },
{ x: 16320, y: 1180 },
{ x: 16390, y: 1180 },
{ x: 16460, y: 1180 },
{ x: 16530, y: 1180 },
{ x: 16600, y: 1180 },
{ x: 16670, y: 1180 },
{ x: 16740, y: 1180 },
{ x: 16810, y: 1180 },
{ x: 16880, y: 1180 },
] },
],
obstacles: [],
goal: { x: 16640, y: 940, width: 280, height: 640 },
cameraBounds: { x: -600, y: -100, width: 18280, height: 2080 },
};

250
src/data/levels/level10.js Normal file
View File

@ -0,0 +1,250 @@
// Scaffolding level - terrain is a copy of level06.
// Build out the real layout in editor.html, then replace this file.
export default {
id: "level10",
name: "Lakeside Finale",
description: "One last lap of Evergreen Lake. Make it count!",
kidsAboard: 3,
timeLimit: 41,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 840 },
{ x: 1140, y: 840 },
{ x: 1210, y: 840 },
{ x: 1280, y: 840 },
{ x: 1350, y: 840 },
{ x: 1420, y: 840 },
{ x: 1490, y: 840 },
{ x: 1560, y: 840 },
{ x: 1630, y: 840 },
{ x: 1700, y: 840 },
{ x: 1770, y: 840 },
{ x: 1840, y: 840 },
{ x: 1910, y: 840 },
{ x: 1980, y: 840 },
{ x: 2050, y: 840 },
{ x: 2120, y: 840 },
{ x: 2190, y: 840 },
{ x: 2260, y: 840 },
{ x: 2330, y: 840 },
{ x: 2400, y: 840 },
{ x: 2470, y: 840 },
{ x: 2540, y: 840 },
{ x: 2610, y: 840 },
{ x: 2680, y: 840 },
{ x: 2750, y: 840 },
{ x: 2820, y: 840 },
{ x: 2890, y: 840 },
{ x: 2960, y: 840 },
{ x: 3030, y: 840 },
{ x: 3100, y: 840 },
{ x: 3170, y: 840 },
{ x: 3240, y: 840 },
{ x: 3310, y: 840 },
{ x: 3380, y: 840 },
{ x: 3450, y: 840 },
{ x: 3520, y: 840 },
{ x: 3590, y: 840 },
{ x: 3660, y: 840 },
{ x: 3730, y: 840 },
{ x: 3800, y: 840 },
{ x: 3870, y: 842 },
{ x: 3940, y: 846 },
{ x: 4010, y: 850 },
{ x: 4080, y: 858 },
{ x: 4150, y: 868 },
{ x: 4220, y: 878 },
{ x: 4290, y: 890 },
{ x: 4360, y: 904 },
{ x: 4430, y: 916 },
{ x: 4500, y: 930 },
{ x: 4570, y: 944 },
{ x: 4640, y: 956 },
{ x: 4710, y: 970 },
{ x: 4780, y: 982 },
{ x: 4850, y: 992 },
{ x: 4920, y: 1002 },
{ x: 4990, y: 1010 },
{ x: 5060, y: 1014 },
{ x: 5130, y: 1018 },
{ x: 5200, y: 1020 },
{ x: 5270, y: 1020 },
{ x: 5340, y: 1020 },
{ x: 5410, y: 1020 },
{ x: 5480, y: 1020 },
{ x: 5550, y: 1020 },
{ x: 5620, y: 1020 },
{ x: 5690, y: 1020 },
{ x: 5760, y: 1020 },
{ x: 5830, y: 1020 },
{ x: 5900, y: 1020 },
{ x: 5970, y: 1020 },
{ x: 6040, y: 1020 },
{ x: 6110, y: 1020 },
{ x: 6180, y: 1020 },
{ x: 6250, y: 1020 },
{ x: 6320, y: 1020 },
{ x: 6390, y: 1020 },
{ x: 6460, y: 1020 },
{ x: 6530, y: 1020 },
{ x: 6600, y: 1020 },
{ x: 6670, y: 1018 },
{ x: 6740, y: 1014 },
{ x: 6810, y: 1010 },
{ x: 6880, y: 1002 },
{ x: 6950, y: 992 },
{ x: 7020, y: 982 },
{ x: 7090, y: 970 },
{ x: 7160, y: 956 },
{ x: 7230, y: 944 },
{ x: 7300, y: 930 },
{ x: 7370, y: 916 },
{ x: 7440, y: 904 },
{ x: 7510, y: 890 },
{ x: 7580, y: 878 },
{ x: 7650, y: 868 },
{ x: 7720, y: 858 },
{ x: 7790, y: 850 },
{ x: 7860, y: 846 },
{ x: 7930, y: 842 },
{ x: 8000, y: 840 },
{ x: 8070, y: 840 },
{ x: 8140, y: 840 },
{ x: 8210, y: 840 },
{ x: 8280, y: 840 },
{ x: 8350, y: 840 },
{ x: 8420, y: 840 },
{ x: 8490, y: 840 },
{ x: 8560, y: 840 },
{ x: 8630, y: 840 },
{ x: 8700, y: 840 },
{ x: 8770, y: 840 },
{ x: 8840, y: 840 },
{ x: 8910, y: 840 },
{ x: 8980, y: 840 },
{ x: 9050, y: 840 },
{ x: 9120, y: 840 },
{ x: 9190, y: 840 },
{ x: 9260, y: 840 },
{ x: 9330, y: 840 },
{ x: 9400, y: 840 },
{ x: 9470, y: 840 },
{ x: 9540, y: 840 },
{ x: 9610, y: 840 },
{ x: 9680, y: 840 },
{ x: 9750, y: 840 },
{ x: 9820, y: 840 },
{ x: 9890, y: 840 },
{ x: 9960, y: 840 },
{ x: 10030, y: 840 },
{ x: 10100, y: 840 },
{ x: 10170, y: 840 },
{ x: 10240, y: 840 },
{ x: 10310, y: 840 },
{ x: 10380, y: 840 },
{ x: 10450, y: 840 },
{ x: 10520, y: 840 },
{ x: 10590, y: 840 },
{ x: 10660, y: 840 },
{ x: 10730, y: 840 },
{ x: 10800, y: 840 },
{ x: 10870, y: 840 },
{ x: 10940, y: 840 },
{ x: 11010, y: 840 },
{ x: 11080, y: 840 },
{ x: 11150, y: 840 },
{ x: 11220, y: 840 },
{ x: 11290, y: 840 },
{ x: 11360, y: 840 },
{ x: 11430, y: 840 },
{ x: 11500, y: 840 },
{ x: 11570, y: 840 },
{ x: 11640, y: 840 },
{ x: 11710, y: 840 },
{ x: 11780, y: 840 },
{ x: 11808, y: 840 },
{ x: 11878, y: 836 },
{ x: 11948, y: 822 },
{ x: 12018, y: 800 },
{ x: 12088, y: 768 },
{ x: 12158, y: 728 },
{ x: 12200, y: 700 },
{ x: 12270, y: 700 },
] },
{ points: [
{ x: 13704, y: 1180 },
{ x: 13774, y: 1180 },
{ x: 13844, y: 1180 },
{ x: 13914, y: 1180 },
{ x: 13984, y: 1180 },
{ x: 14054, y: 1180 },
{ x: 14080, y: 1180 },
{ x: 14150, y: 1180 },
{ x: 14220, y: 1180 },
{ x: 14290, y: 1180 },
{ x: 14360, y: 1180 },
{ x: 14430, y: 1180 },
{ x: 14500, y: 1180 },
{ x: 14570, y: 1180 },
{ x: 14640, y: 1180 },
{ x: 14710, y: 1180 },
{ x: 14780, y: 1180 },
{ x: 14850, y: 1180 },
{ x: 14920, y: 1180 },
{ x: 14990, y: 1180 },
{ x: 15060, y: 1180 },
{ x: 15130, y: 1180 },
{ x: 15200, y: 1180 },
{ x: 15270, y: 1180 },
{ x: 15340, y: 1180 },
{ x: 15410, y: 1180 },
{ x: 15480, y: 1180 },
{ x: 15550, y: 1180 },
{ x: 15620, y: 1180 },
{ x: 15690, y: 1180 },
{ x: 15760, y: 1180 },
{ x: 15830, y: 1180 },
{ x: 15900, y: 1180 },
{ x: 15970, y: 1180 },
{ x: 16040, y: 1180 },
{ x: 16110, y: 1180 },
{ x: 16180, y: 1180 },
{ x: 16250, y: 1180 },
{ x: 16320, y: 1180 },
{ x: 16390, y: 1180 },
{ x: 16460, y: 1180 },
{ x: 16530, y: 1180 },
{ x: 16600, y: 1180 },
{ x: 16670, y: 1180 },
{ x: 16740, y: 1180 },
{ x: 16810, y: 1180 },
{ x: 16880, y: 1180 },
] },
],
obstacles: [],
goal: { x: 16640, y: 940, width: 280, height: 640 },
cameraBounds: { x: -600, y: -100, width: 18280, height: 2080 },
};

View File

@ -1,25 +1,60 @@
// Layout math for campaign map node positions. All coordinates are in 960x540 // Layout math for campaign map node positions. All coordinates are in 960x540
// design units (the scene multiplies by WORLD_SCALE when drawing). // design units (the scene multiplies by WORLD_SCALE when drawing).
//
// Node badges render at ~58-71 design units in diameter (see nodeDisplaySize
// in util/mapArt.js). The free band of the map (below the title plate, whose
// ink ends at y≈158, and above the bottom chrome at y≈490) is ~330 units
// tall, which leaves room for two lanes of nodes with the ribbon's loop
// between them:
//
// * lane 1 (top, y=268): runs RIGHT -> LEFT, ending short of the left edge
// * lane 2 (bottom, y=412): runs LEFT -> RIGHT, ending short of the right
// edge so the finish flag fits in the bottom-right corner past it
//
// ...which reads as one continuous road: start top-right, across the top,
// down the left side, across the bottom, flag at the bottom-right. Both
// lanes use ~660 of the 780 usable width; the 120-unit ends are what make
// the left connector and the right flag fit without touching a node.
// Comfortable for up to 12 levels (13 starts getting tight - give explicit
// `positions` for exotic counts).
const MAP_MARGIN_X = 110; const MAP_WIDTH = 960;
const MAP_TOP = 250; const LANE_TOP = 268;
const MAP_BOTTOM = 470; const LANE_BOTTOM = 412;
// Usable span of each lane (keeps a 120-unit end zone free, see above).
const LANE_SPAN = 660;
// Where each lane starts. Lane 1 starts at the top-right, lane 2 at the
// bottom-left.
const LANE1_X0 = 870; // lane 1 runs from here LEFTWARDS
const LANE2_X0 = 90; // lane 2 runs from here RIGHTWARDS
// Auto-places a campaign's levels along a gentle S-curve, left to right. // Auto-places a campaign's levels along the 2-lane circuit. Handles any
// Used when a campaign in index.js omits explicit `positions`. // count >= 1. For a bespoke look, give explicit `positions` in index.js -
// those are used verbatim.
export function autoLayoutPoints(count) { export function autoLayoutPoints(count) {
if (count <= 0) return []; if (count <= 0) return [];
if (count === 1) return [{ x: 480, y: 360 }]; if (count === 1) return [{ x: 480, y: Math.round((LANE_TOP + LANE_BOTTOM) / 2) }];
const usableWidth = 960 - MAP_MARGIN_X * 2; // Two levels: one per lane, offset toward each other so the connector is
const midY = (MAP_TOP + MAP_BOTTOM) / 2; // a diagonal (not a vertical pair, which would be closer than the badges
const amplitude = (MAP_BOTTOM - MAP_TOP) / 2 - 20; // are wide).
if (count === 2) {
return [
{ x: (LANE1_X0 + LANE2_X0) / 2 - 60, y: LANE_TOP },
{ x: (LANE1_X0 + LANE2_X0) / 2 + 60, y: LANE_BOTTOM },
];
}
const firstRow = Math.ceil(count / 2);
const secondRow = count - firstRow;
const points = []; const points = [];
for (let i = 0; i < count; i++) { // Lane 1: right -> left.
const t = i / (count - 1); for (let k = 0; k < firstRow; k++) {
// ease x slightly so endpoints breathe at the map edges const t = firstRow === 1 ? 0 : k / (firstRow - 1);
const x = MAP_MARGIN_X + usableWidth * t; points.push({ x: Math.round(LANE1_X0 - LANE_SPAN * t), y: LANE_TOP });
const y = midY + Math.sin(t * Math.PI * 1.5) * amplitude; }
points.push({ x: Math.round(x), y: Math.round(y) }); // Lane 2: left -> right.
for (let k = 0; k < secondRow; k++) {
const t = secondRow === 1 ? 0 : k / (secondRow - 1);
points.push({ x: Math.round(LANE2_X0 + LANE_SPAN * t), y: LANE_BOTTOM });
} }
return points; return points;
} }

View File

@ -4,7 +4,7 @@ import { CAMPAIGNS } from '../data/levels/index.js';
import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js'; import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js';
import { isLevelComplete, getLevelResult } from '../util/progress.js'; import { isLevelComplete, getLevelResult } from '../util/progress.js';
import { playMenuMusic } from '../util/music.js'; import { playMenuMusic } from '../util/music.js';
import { nodeDisplaySize, scalePos, BADGE_FILE_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js'; import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js';
const NODE_HOVER = 1.12; const NODE_HOVER = 1.12;
const NODE_IDLE_PULSE = 1.05; const NODE_IDLE_PULSE = 1.05;
@ -24,10 +24,13 @@ const TITLE_STYLE = {
const PILL_STYLE = { const PILL_STYLE = {
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`, // 13 (not 14) so the longest tag - "10/10 kids saved" on a cleared node -
// fits comfortably under ~100-unit-spaced nodes once a campaign grows past
// a handful of levels.
fontSize: `${13 * WORLD_SCALE}px`,
color: '#1a1f29', color: '#1a1f29',
backgroundColor: '#ffffffaa', backgroundColor: '#ffffffaa',
padding: { x: 10 * WORLD_SCALE, y: 5 * WORLD_SCALE }, padding: { x: 9 * WORLD_SCALE, y: 5 * WORLD_SCALE },
}; };
export default class LevelSelectScene extends Phaser.Scene { export default class LevelSelectScene extends Phaser.Scene {
@ -109,26 +112,42 @@ export default class LevelSelectScene extends Phaser.Scene {
const bg = this.add.image(0, 0, campaign.bgKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg); const bg = this.add.image(0, 0, campaign.bgKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
this._groups.bg.push(bg); this._groups.bg.push(bg);
const spline = sampleSpline(points, 18); if (points.length >= 2) {
const path = this.add.graphics().setDepth(DEPTH.path); const spline = sampleSpline(points, 18);
this._traceRibbon(path, spline, 84 * WORLD_SCALE, 0x1a1f29, 1); const path = this.add.graphics().setDepth(DEPTH.path);
this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1); this._traceRibbon(path, spline, 84 * WORLD_SCALE, 0x1a1f29, 1);
this._traceDashes(path, spline); this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1);
this._groups.path.push(path); this._traceDashes(path, spline);
this._groups.path.push(path);
}
// Finish flag just past the last node, nudged along the path direction. // Finish flag just past the last node, nudged along the path direction.
const last = points[points.length - 1]; // (The auto-layout circuit ends at the bottom-right, so this nudge lands
const prev = points[Math.max(0, points.length - 2)] || { x: last.x - 100, y: last.y }; // in the corner; if an explicit `positions` layout ends at an edge and the
const ang = Math.atan2(last.y - prev.y, last.x - prev.x); // nudge would push the flag off-screen, flip it so it stays on.)
const flag = this.add if (points.length) {
.image(last.x + Math.cos(ang) * 95 * WORLD_SCALE, last.y + Math.sin(ang) * 95 * WORLD_SCALE, 'map_finish') const last = points[points.length - 1];
.setOrigin(0.5) const prev = points[Math.max(0, points.length - 2)] || { x: last.x - 100, y: last.y };
.setDisplaySize(GAME_WIDTH * 0.18, GAME_HEIGHT * 0.18) const ang = Math.atan2(last.y - prev.y, last.x - prev.x);
.setDepth(DEPTH.flag); const nudge = 95 * WORLD_SCALE;
this._groups.path.push(flag); let fx = last.x + Math.cos(ang) * nudge;
let fy = last.y + Math.sin(ang) * nudge;
const inBounds = (x, y) => x > 60 * WORLD_SCALE && x < GAME_WIDTH - 60 * WORLD_SCALE && y > 160 * WORLD_SCALE && y < GAME_HEIGHT - 60 * WORLD_SCALE;
if (!inBounds(fx, fy)) {
fx = last.x - Math.cos(ang) * nudge;
fy = last.y - Math.sin(ang) * nudge;
}
const flag = this.add
.image(fx, fy, 'map_finish')
.setOrigin(0.5)
.setDisplaySize(GAME_WIDTH * 0.18, GAME_HEIGHT * 0.18)
.setDepth(DEPTH.flag);
this._groups.path.push(flag);
}
// Nodes + labels. Per-key display sizes: map_node_current ships as a tight // Nodes + labels. All node textures are 512x512 badges; per-key display
// 512x512 badge while the others are 960x540 frames (see mapArt.js). // sizes come from mapArt.js (map_node_current displays larger than the
// others).
levels.forEach((level, i) => { levels.forEach((level, i) => {
const p = points[i]; const p = points[i];
const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked'; const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked';
@ -136,21 +155,20 @@ export default class LevelSelectScene extends Phaser.Scene {
const { width: nw, height: nh } = nodeDisplaySize(key); const { width: nw, height: nh } = nodeDisplaySize(key);
const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(nw, nh).setDepth(DEPTH.node); const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(nw, nh).setDepth(DEPTH.node);
// The badge is a small centered circle inside a big mostly-transparent // The badge fills its texture file, so the interactive area is a
// texture frame, so the interactive area must be a circle around the // circle at the badge's full diameter (BADGE_HIT_FRAC = 1.0, + the
// badge itself - otherwise the whole overlapping frames would be // BADGE_HIT_RADIUS_MULT allowance), not the whole texture frame.
// clickable.
// //
// Phaser hit-tests in LOCAL space: the pointer is inverse-transformed by // Phaser hit-tests in LOCAL space: the pointer is inverse-transformed by
// the object's position/scale (InputPlugin.checkGameObjects), then // the object's position/scale (InputPlugin.checkGameObjects), then
// displayOrigin is added (InputManager.pointWithinInteractiveObject). // displayOrigin is added (InputManager.pointWithinInteractiveObject).
// Both steps are expressed in UNSCALED source-frame pixels, so the badge // Both steps are expressed in UNSCALED source-frame pixels, so the badge
// center (the node's world position) lands at (displayOriginX, // center (the node's world position) lands at (displayOriginX,
// displayOriginY) - the frame's center for these origin-0.5 textures - // displayOriginY) - the center of these origin-0.5 textures - and radii
// and radii must be given in frame pixels too (world radius = radius * // must be given in frame pixels too (world radius = radius * scale).
// scale). The hover/entrance tweens only change the scale, so the hit // The hover/entrance tweens only change the scale, so the hit circle
// circle stays glued to the visible badge at all times. // stays glued to the visible badge at all times.
const hitRadius = ((node.width * BADGE_FILE_FRAC[key]) / 2) * BADGE_HIT_RADIUS_MULT; const hitRadius = ((node.width * BADGE_HIT_FRAC[key]) / 2) * BADGE_HIT_RADIUS_MULT;
const hitArea = new Phaser.Geom.Circle(node.displayOriginX, node.displayOriginY, hitRadius); const hitArea = new Phaser.Geom.Circle(node.displayOriginX, node.displayOriginY, hitRadius);
const hitTest = (shape, x, y) => (x - shape.x) * (x - shape.x) + (y - shape.y) * (y - shape.y) <= shape.radius * shape.radius; const hitTest = (shape, x, y) => (x - shape.x) * (x - shape.x) + (y - shape.y) * (y - shape.y) <= shape.radius * shape.radius;
@ -253,6 +271,8 @@ export default class LevelSelectScene extends Phaser.Scene {
this.campaignIndex = next; this.campaignIndex = next;
const all = [...this._groups.bg, ...this._groups.path, ...this._groups.nodes, ...this._groups.tags]; const all = [...this._groups.bg, ...this._groups.path, ...this._groups.nodes, ...this._groups.tags];
// `all` is never empty (the background is always built), so onComplete is
// guaranteed to fire - empty campaigns transition fine through this path.
this.tweens.add({ this.tweens.add({
targets: all, targets: all,
alpha: 0, alpha: 0,
@ -268,6 +288,7 @@ export default class LevelSelectScene extends Phaser.Scene {
_playFirst() { _playFirst() {
const campaign = CAMPAIGNS[this.campaignIndex]; const campaign = CAMPAIGNS[this.campaignIndex];
if (!campaign.levels.length) return; // campaign with no levels yet - nothing to play
// Mirror the map's unlock rule: the campaign must be open (previous // Mirror the map's unlock rule: the campaign must be open (previous
// campaign cleared) before any of its levels can be played. // campaign cleared) before any of its levels can be played.
const campaignUnlocked = this.campaignIndex === 0 || CAMPAIGNS[this.campaignIndex - 1].levels.every((l) => isLevelComplete(l.id)); const campaignUnlocked = this.campaignIndex === 0 || CAMPAIGNS[this.campaignIndex - 1].levels.every((l) => isLevelComplete(l.id));

View File

@ -24,18 +24,14 @@ export const ASSET_MANIFEST = [
{ key: 'logo', path: 'assets/backgrounds/logo.png', width: 320 * WORLD_SCALE, height: 173 * WORLD_SCALE, kind: 'rect', color: 0xe040a0 }, { key: 'logo', path: 'assets/backgrounds/logo.png', width: 320 * WORLD_SCALE, height: 173 * WORLD_SCALE, kind: 'rect', color: 0xe040a0 },
// Shared full-bleed background for MainMenuScene. // Shared full-bleed background for MainMenuScene.
{ key: 'bg_menu', path: 'assets/backgrounds/bg_menu.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8 }, { key: 'bg_menu', path: 'assets/backgrounds/bg_menu.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8 },
// LevelSelect map art. The node/path images are 960x540 canvases with the // LevelSelect map art. The node and flag sprites are tight 512x512 badges
// sprite centered (see makeMap* in the placeholder generator) so the code // (the badge IS the file) displayed at the sizes computed by nodeDisplaySize()
// can just display them full-frame at a node's (x,y) - real art should do // in mapArt.js; the width/height below only feed the placeholder fallback.
// the same, or use a transparent canvas with the art centered.
{ key: 'campaign01_bg', path: 'assets/backgrounds/campaign01_bg.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8 }, { key: 'campaign01_bg', path: 'assets/backgrounds/campaign01_bg.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8 },
{ key: 'campaign02_bg', path: 'assets/backgrounds/campaign02_bg.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x3a346e }, { key: 'campaign02_bg', path: 'assets/backgrounds/campaign02_bg.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x3a346e },
// 960x540 canvas, badge centered (see the LEVEL SELECT section of sprites.md). { key: 'map_node', path: 'assets/ui/map_node.png', width: 512, height: 512, kind: 'circle', color: 0x2255aa },
{ key: 'map_node', path: 'assets/ui/map_node.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'circle', color: 0x2255aa },
// 512x512 badge art (NOT the 960x540 centered-frame convention of the other
// node sprites) - see NODE_ART_FRAMES + nodeDisplaySize() in mapArt.js.
{ key: 'map_node_current', path: 'assets/ui/map_node_current.png', width: 512, height: 512, kind: 'circle', color: 0xdea834 }, { key: 'map_node_current', path: 'assets/ui/map_node_current.png', width: 512, height: 512, kind: 'circle', color: 0xdea834 },
{ key: 'map_node_locked', path: 'assets/ui/map_node_locked.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'circle', color: 0x606876 }, { key: 'map_node_locked', path: 'assets/ui/map_node_locked.png', width: 512, height: 512, kind: 'circle', color: 0x606876 },
{ key: 'map_finish', path: 'assets/ui/map_finish.png', width: 512, height: 512, kind: 'rect', color: 0xde5046 }, { key: 'map_finish', path: 'assets/ui/map_finish.png', width: 512, height: 512, kind: 'rect', color: 0xde5046 },
{ key: 'map_campaign_tag', path: 'assets/ui/map_campaign_tag.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0xf4f0e6 }, { key: 'map_campaign_tag', path: 'assets/ui/map_campaign_tag.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0xf4f0e6 },
{ key: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa }, { key: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa },

View File

@ -1,29 +1,50 @@
// Shared map geometry for the LevelSelect map nodes + path ribbon. // Shared map geometry for the LevelSelect map nodes + path ribbon.
// //
// Most map UI art is generated on 960x540 canvases with the sprite centered // The node textures (and map_finish) are tight 512x512 badge images - the
// (documented in sprites.md). The node textures follow that convention EXCEPT // badge IS the file, no padding frame. Their display size and hit area are
// map_node_current, which ships as a 512x512 badge image with no padding frame // derived from the tables below, so the code stays correct if a file is
// (see NODE_ART_FRAMES below). // ever re-exported at a different size (NODE_ART_FRAMES is the place to
// change then).
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
// How much of a game screen wide each node's texture FRAME is displayed at. // Overall size knob: node badge display size = GAME_WIDTH * BADGE_FRAC_OF_FRAME
// The frame is mostly transparent padding around the badge, so this is NOT // * BADGE_FILE_FRAC[key]. With the current table that's ~58 design units for
// the badge-to-frame ratio (see BADGE_FILE_FRAC) - it only sets display size. // the regular badges (map_node/locked) and ~71 for map_node_current
// (~116/~142 screen pixels at WORLD_SCALE = 2) - the same on-map scale the
// nodes had before the art was re-exported as tight badges. Raise this (or
// the per-state fractions below) to make every node badge bigger on the map.
export const BADGE_FRAC_OF_FRAME = 0.47; export const BADGE_FRAC_OF_FRAME = 0.47;
// Fraction of the texture file's width spanned by the badge's ink, PER NODE // Per-state share of the target badge size. The shipped art fills the file,
// STATE. map_node/map_node_locked are 1920x1080 frames with the ~450px badge // so these are pure size choices (NOT measurements): map_node_current is
// centered, so ink spans ~0.24 of the file width (measured on the shipped // deliberately the largest - it's the level to play next and gets the idle
// art). map_node_current is a 512x512 image that IS the badge, so its ink // pulse - with the cleared and locked badges a bit smaller.
// spans the whole file. Re-measure an entry if that file's art changes. //
// NOTE: BADGE_FILE_FRAC only affects DISPLAY size, never the click area.
// The hit circle is sized from the art itself (BADGE_HIT_FRAC = 1.0 = full
// badge, times BADGE_HIT_RADIUS_MULT), and since all three states ship
// full-frame ink every node's click area is the same regardless of these
// display fractions.
export const BADGE_FILE_FRAC = { export const BADGE_FILE_FRAC = {
map_node: 0.24, map_node: 0.128,
map_node_current: 0.30, map_node_current: 0.155,
map_node_locked: 0.24, map_node_locked: 0.128,
}; };
// Hit-circle radius as a multiple of the VISIBLE badge's radius - slightly // Hit-circle radius = (node.width * BADGE_HIT_FRAC[key] / 2) *
// generous so clicks near the edge feel natural without reaching the road. // BADGE_HIT_RADIUS_MULT, in source-frame pixels. The badge fills its file,
// so 1.0 gives a click area exactly the badge - slightly generous via
// BADGE_HIT_RADIUS_MULT so clicks near the edge feel natural without
// reaching the road. (BADGE_FILE_FRAC no longer feeds the hit area - it's
// display size only.)
export const BADGE_HIT_FRAC = {
map_node: 1.0,
map_node_current: 1.0,
map_node_locked: 1.0,
};
// Slightly generous so clicks near the edge feel natural without reaching
// the road.
export const BADGE_HIT_RADIUS_MULT = 1.1; export const BADGE_HIT_RADIUS_MULT = 1.1;
// Road ribbon display size, in design units. // Road ribbon display size, in design units.
@ -35,9 +56,7 @@ export function frameAnchorDisplayWidth() {
return GAME_WIDTH; return GAME_WIDTH;
} }
// Source pixel dimensions of each node texture. The old 960x540 frames were // Source pixel dimensions of each node texture (all currently 512x512).
// 2x exports; the current map_node_current is a tight 512x512 badge. Used to
// normalize display size + hit radius regardless of frame size.
export const NODE_ART_FRAMES = { export const NODE_ART_FRAMES = {
map_node: { w: 512, h: 512 }, map_node: { w: 512, h: 512 },
map_node_current: { w: 512, h: 512 }, map_node_current: { w: 512, h: 512 },