diff --git a/README.md b/README.md index c89d172..3b118f8 100644 --- a/README.md +++ b/README.md @@ -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/entities/ Bus, Kid, Terrain 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 assets/ where real art goes (see manifest below) - currently empty ``` diff --git a/sprites.md b/sprites.md index b9e70f1..4e107c3 100644 --- a/sprites.md +++ b/sprites.md @@ -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 per campaign, plus the shared node/flag/plate sprites. -**Important convention for node/flag/plate sprites:** they are drawn centered -on a **960x540 transparent canvas** (1920x1080 at 2x) and displayed full-frame -at a position, so *where you draw the art on that canvas is where it appears -in the game*. Keep the subject centered in the frame, or it will show up -shifted. +**Important convention for these sprites:** each is displayed at a fixed +display size with origin (0.5, 0.5) at its map position, so **keep the +subject centered in its file** or it will show up shifted. The node badges +and the flag ship as tight 512x512 images (the badge/flag IS the file); +`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 | |---|---|---|---| | `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 | -| `map_node` | `assets/ui/map_node.png` | ~450x450 badge centered | completed level node (number badge) | -| `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_locked` | `assets/ui/map_node_locked.png` | ~450x450 badge centered | locked level node | -| `map_finish` | `assets/ui/map_finish.png` | ~440x560 centered | flag past the last node | +| `map_node` | `assets/ui/map_node.png` | ~58x58 badge (512x512 file) | completed level 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` | ~58x58 badge (512x512 file) | locked level 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) | Per-campaign notes: - **`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 - quiet. Leave the bottom 60% open road/grass for the path to sit on. - The node positions live in `src/data/levels/index.js` (`positions`, or - auto-layout via `src/data/levels/positioning.js`) - check where your - nodes will land before you paint, and keep that corridor readable. + quiet. Node positions are auto-placed in a **2-lane serpentine** that sits + in the band **y ≈ 268-412** (design units) across the full map width + (x ≈ 90-870) - both lanes - so keep that horizontal strip fairly open and + 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: - `map_node` (cleared, e.g. blue with the level number), `map_node_current` - (the one to play next, e.g. gold - this one gets a gentle idle pulse), - `map_node_locked` (greyed out, e.g. a padlock). If you want the badge to - be a different size than ~450 units, also adjust `BADGE_FRAC_OF_FRAME` - in `src/util/mapArt.js`. Note `map_node_current` currently ships as a - tight 512x512 image (no 960x540 padding frame); its size/hit area are - driven by `NODE_ART_FRAMES` + `BADGE_FILE_FRAC` per-key in `mapArt.js`, - so a different frame size there is fine as long as those tables are - updated. -- **`map_finish.png`** - the flag sits just past the last node, on the road, - so a base/mound that grounds it looks best; don't rely on the bottom of - the 960x540 canvas being the ground line. Currently ships as a tight - 512x512 image (flag ink is ~60% of the width, full height); the scene - displays it at 0.28x the game screen, so keep the flag roughly centered - in a square frame if you re-export it. + `map_node` (cleared), `map_node_current` (the one to play next - this one + gets a gentle idle pulse), `map_node_locked` (greyed out, e.g. a padlock). + All three ship as 512x512 badge images with no padding frame; their + display size and hit area are driven by `NODE_ART_FRAMES` + + `BADGE_FILE_FRAC` per-key in `src/util/mapArt.js`, so a different frame + size there is fine as long as those tables are updated. If you want the + badge to be a different display size than ~58-71 units, adjust + `BADGE_FRAC_OF_FRAME` / the per-state `BADGE_FILE_FRAC` entries in + `src/util/mapArt.js` (the click area follows the art, not these knobs). +- **`map_finish.png`** - the flag sits just past the last node, so a base/mound + that grounds it looks best. Currently ships as a tight 512x512 image (flag + ink is ~60% of the width, full height); the scene displays it at 0.18x the + game screen, so keep the flag roughly centered in a square frame if you + re-export it. The auto-layout circuit ends at the bottom-right, so the + 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 game writes `" x/y cleared"` on top of it in dark ink (#1a1f29), so the middle should be a light, uncluttered fill. diff --git a/src/data/levels/index.js b/src/data/levels/index.js index 3b130dc..5198098 100644 --- a/src/data/levels/index.js +++ b/src/data/levels/index.js @@ -4,6 +4,10 @@ import level03 from './level03.js'; import level04 from './level04.js'; import level05 from './level05.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. // 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 // 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 -// 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 = [ { id: 'campaign01', name: 'Evergreen Lake', bgKey: 'campaign01_bg', - levels: [level01, level02, level03], - positions: [ - { x: 160, y: 392 }, - { x: 400, y: 330 }, - { x: 640, y: 380 }, - ], + levels: [level01, level02, level03, level04, level05, level06, level07, level08, level09, level10], }, { id: 'campaign02', name: 'Underprivileged Community', 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: [], }, ]; diff --git a/src/data/levels/level07.js b/src/data/levels/level07.js new file mode 100644 index 0000000..31f7d03 --- /dev/null +++ b/src/data/levels/level07.js @@ -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 }, +}; diff --git a/src/data/levels/level08.js b/src/data/levels/level08.js new file mode 100644 index 0000000..d03cbfb --- /dev/null +++ b/src/data/levels/level08.js @@ -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 }, +}; diff --git a/src/data/levels/level09.js b/src/data/levels/level09.js new file mode 100644 index 0000000..b4e7057 --- /dev/null +++ b/src/data/levels/level09.js @@ -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 }, +}; diff --git a/src/data/levels/level10.js b/src/data/levels/level10.js new file mode 100644 index 0000000..cbb367c --- /dev/null +++ b/src/data/levels/level10.js @@ -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 }, +}; diff --git a/src/data/levels/positioning.js b/src/data/levels/positioning.js index 8833f4b..630400c 100644 --- a/src/data/levels/positioning.js +++ b/src/data/levels/positioning.js @@ -1,25 +1,60 @@ // Layout math for campaign map node positions. All coordinates are in 960x540 // 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_TOP = 250; -const MAP_BOTTOM = 470; +const MAP_WIDTH = 960; +const LANE_TOP = 268; +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. -// Used when a campaign in index.js omits explicit `positions`. +// Auto-places a campaign's levels along the 2-lane circuit. Handles any +// count >= 1. For a bespoke look, give explicit `positions` in index.js - +// those are used verbatim. export function autoLayoutPoints(count) { if (count <= 0) return []; - if (count === 1) return [{ x: 480, y: 360 }]; - const usableWidth = 960 - MAP_MARGIN_X * 2; - const midY = (MAP_TOP + MAP_BOTTOM) / 2; - const amplitude = (MAP_BOTTOM - MAP_TOP) / 2 - 20; + if (count === 1) return [{ x: 480, y: Math.round((LANE_TOP + LANE_BOTTOM) / 2) }]; + // Two levels: one per lane, offset toward each other so the connector is + // a diagonal (not a vertical pair, which would be closer than the badges + // 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 = []; - for (let i = 0; i < count; i++) { - const t = i / (count - 1); - // ease x slightly so endpoints breathe at the map edges - const x = MAP_MARGIN_X + usableWidth * t; - const y = midY + Math.sin(t * Math.PI * 1.5) * amplitude; - points.push({ x: Math.round(x), y: Math.round(y) }); + // Lane 1: right -> left. + for (let k = 0; k < firstRow; k++) { + const t = firstRow === 1 ? 0 : k / (firstRow - 1); + points.push({ x: Math.round(LANE1_X0 - LANE_SPAN * t), y: LANE_TOP }); + } + // 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; } diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 27897ef..8488202 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -4,7 +4,7 @@ import { CAMPAIGNS } from '../data/levels/index.js'; import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js'; import { isLevelComplete, getLevelResult } from '../util/progress.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_IDLE_PULSE = 1.05; @@ -24,10 +24,13 @@ const TITLE_STYLE = { const PILL_STYLE = { 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', 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 { @@ -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); this._groups.bg.push(bg); - const spline = sampleSpline(points, 18); - const path = this.add.graphics().setDepth(DEPTH.path); - this._traceRibbon(path, spline, 84 * WORLD_SCALE, 0x1a1f29, 1); - this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1); - this._traceDashes(path, spline); - this._groups.path.push(path); + if (points.length >= 2) { + const spline = sampleSpline(points, 18); + const path = this.add.graphics().setDepth(DEPTH.path); + this._traceRibbon(path, spline, 84 * WORLD_SCALE, 0x1a1f29, 1); + this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1); + this._traceDashes(path, spline); + this._groups.path.push(path); + } // Finish flag just past the last node, nudged along the path direction. - const last = points[points.length - 1]; - const prev = points[Math.max(0, points.length - 2)] || { x: last.x - 100, y: last.y }; - const ang = Math.atan2(last.y - prev.y, last.x - prev.x); - const flag = this.add - .image(last.x + Math.cos(ang) * 95 * WORLD_SCALE, last.y + Math.sin(ang) * 95 * WORLD_SCALE, 'map_finish') - .setOrigin(0.5) - .setDisplaySize(GAME_WIDTH * 0.18, GAME_HEIGHT * 0.18) - .setDepth(DEPTH.flag); - this._groups.path.push(flag); + // (The auto-layout circuit ends at the bottom-right, so this nudge lands + // in the corner; if an explicit `positions` layout ends at an edge and the + // nudge would push the flag off-screen, flip it so it stays on.) + if (points.length) { + const last = points[points.length - 1]; + const prev = points[Math.max(0, points.length - 2)] || { x: last.x - 100, y: last.y }; + const ang = Math.atan2(last.y - prev.y, last.x - prev.x); + const nudge = 95 * WORLD_SCALE; + 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 - // 512x512 badge while the others are 960x540 frames (see mapArt.js). + // Nodes + labels. All node textures are 512x512 badges; per-key display + // sizes come from mapArt.js (map_node_current displays larger than the + // others). levels.forEach((level, i) => { const p = points[i]; 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 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 - // texture frame, so the interactive area must be a circle around the - // badge itself - otherwise the whole overlapping frames would be - // clickable. + // The badge fills its texture file, so the interactive area is a + // circle at the badge's full diameter (BADGE_HIT_FRAC = 1.0, + the + // BADGE_HIT_RADIUS_MULT allowance), not the whole texture frame. // // Phaser hit-tests in LOCAL space: the pointer is inverse-transformed by // the object's position/scale (InputPlugin.checkGameObjects), then // displayOrigin is added (InputManager.pointWithinInteractiveObject). // Both steps are expressed in UNSCALED source-frame pixels, so the badge // center (the node's world position) lands at (displayOriginX, - // displayOriginY) - the frame's center for these origin-0.5 textures - - // and radii must be given in frame pixels too (world radius = radius * - // scale). The hover/entrance tweens only change the scale, so the hit - // circle stays glued to the visible badge at all times. - const hitRadius = ((node.width * BADGE_FILE_FRAC[key]) / 2) * BADGE_HIT_RADIUS_MULT; + // displayOriginY) - the center of these origin-0.5 textures - and radii + // must be given in frame pixels too (world radius = radius * scale). + // The hover/entrance tweens only change the scale, so the hit circle + // stays glued to the visible badge at all times. + 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 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; 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({ targets: all, alpha: 0, @@ -268,6 +288,7 @@ export default class LevelSelectScene extends Phaser.Scene { _playFirst() { 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 // 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)); diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js index 204c23e..bbff0b0 100644 --- a/src/util/assetManifest.js +++ b/src/util/assetManifest.js @@ -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 }, // 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 }, - // LevelSelect map art. The node/path images are 960x540 canvases with the - // sprite centered (see makeMap* in the placeholder generator) so the code - // can just display them full-frame at a node's (x,y) - real art should do - // the same, or use a transparent canvas with the art centered. + // LevelSelect map art. The node and flag sprites are tight 512x512 badges + // (the badge IS the file) displayed at the sizes computed by nodeDisplaySize() + // in mapArt.js; the width/height below only feed the placeholder fallback. { 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 }, - // 960x540 canvas, badge centered (see the LEVEL SELECT section of sprites.md). - { 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', path: 'assets/ui/map_node.png', width: 512, height: 512, kind: 'circle', color: 0x2255aa }, { 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_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 }, diff --git a/src/util/mapArt.js b/src/util/mapArt.js index 20754a5..8a57a4a 100644 --- a/src/util/mapArt.js +++ b/src/util/mapArt.js @@ -1,29 +1,50 @@ // Shared map geometry for the LevelSelect map nodes + path ribbon. // -// Most map UI art is generated on 960x540 canvases with the sprite centered -// (documented in sprites.md). The node textures follow that convention EXCEPT -// map_node_current, which ships as a 512x512 badge image with no padding frame -// (see NODE_ART_FRAMES below). +// The node textures (and map_finish) are tight 512x512 badge images - the +// badge IS the file, no padding frame. Their display size and hit area are +// derived from the tables below, so the code stays correct if a file is +// 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'; -// How much of a game screen wide each node's texture FRAME is displayed at. -// The frame is mostly transparent padding around the badge, so this is NOT -// the badge-to-frame ratio (see BADGE_FILE_FRAC) - it only sets display size. +// Overall size knob: node badge display size = GAME_WIDTH * BADGE_FRAC_OF_FRAME +// * BADGE_FILE_FRAC[key]. With the current table that's ~58 design units for +// 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; -// Fraction of the texture file's width spanned by the badge's ink, PER NODE -// STATE. map_node/map_node_locked are 1920x1080 frames with the ~450px badge -// centered, so ink spans ~0.24 of the file width (measured on the shipped -// art). map_node_current is a 512x512 image that IS the badge, so its ink -// spans the whole file. Re-measure an entry if that file's art changes. +// Per-state share of the target badge size. The shipped art fills the file, +// so these are pure size choices (NOT measurements): map_node_current is +// deliberately the largest - it's the level to play next and gets the idle +// pulse - with the cleared and locked badges a bit smaller. +// +// 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 = { - map_node: 0.24, - map_node_current: 0.30, - map_node_locked: 0.24, + map_node: 0.128, + map_node_current: 0.155, + map_node_locked: 0.128, }; -// Hit-circle radius as a multiple of the VISIBLE badge's radius - slightly -// generous so clicks near the edge feel natural without reaching the road. +// Hit-circle radius = (node.width * BADGE_HIT_FRAC[key] / 2) * +// 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; // Road ribbon display size, in design units. @@ -35,9 +56,7 @@ export function frameAnchorDisplayWidth() { return GAME_WIDTH; } -// Source pixel dimensions of each node texture. The old 960x540 frames were -// 2x exports; the current map_node_current is a tight 512x512 badge. Used to -// normalize display size + hit radius regardless of frame size. +// Source pixel dimensions of each node texture (all currently 512x512). export const NODE_ART_FRAMES = { map_node: { w: 512, h: 512 }, map_node_current: { w: 512, h: 512 },