Compare commits

..

No commits in common. "2e4b67db8928104ab166189261edf4107ff41260" and "5e49c055a2af5d8889d54377174146dacf2c1f91" have entirely different histories.

17 changed files with 163 additions and 1472 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/entities/ Bus, Kid, Terrain
src/systems/ GForceMonitor, KidManager, InputController, CameraRig
src/data/levels/ the hand-authored levels (10, split across the two campaigns)
src/data/levels/ the 3 hand-authored levels
src/util/ asset manifest, placeholder-texture generator, localStorage progress, small UI helper
assets/ where real art goes (see manifest below) - currently empty
```

View File

@ -84,61 +84,46 @@ 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 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.
**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.
| 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` | ~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_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_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. 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.
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.
- **Node states** share one silhouette (same size/placement) in three moods:
`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).
- **Cleared nodes show the player's best score** under the badge, as a small
gold pill (the game's `SCORE_PILL_STYLE` in `src/scenes/LevelSelectScene.js`)
with the number rolling up from 0 on entrance. The pill is drawn by the
game, not part of the badge art - the `map_node` badge should stay a plain
cleared marker (no digits baked in). The score itself comes from
`bestScore` in `src/util/progress.js` (localStorage), recorded by the
tally scene after each run.
- **`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_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_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),
so the middle should be a light, uncluttered fill.

View File

@ -4,10 +4,6 @@ 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
@ -15,27 +11,24 @@ import level10 from './level10.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: 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.
// campaign's levels along a gentle S-curve.
export const CAMPAIGNS = [
{
id: 'campaign01',
name: 'Evergreen Lake',
bgKey: 'campaign01_bg',
levels: [level01, level02, level03, level04, level05, level06, level07, level08, level09, level10],
levels: [level01, level02, level03],
positions: [
{ x: 160, y: 392 },
{ x: 400, y: 330 },
{ x: 640, y: 380 },
],
},
{
id: 'campaign02',
name: 'Underprivileged Community',
bgKey: 'campaign02_bg',
// 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: [],
levels: [level04, level05, level06],
},
];

View File

@ -1,250 +0,0 @@
// 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 },
};

View File

@ -1,250 +0,0 @@
// 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 },
};

View File

@ -1,250 +0,0 @@
// 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 },
};

View File

@ -1,250 +0,0 @@
// 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,60 +1,25 @@
// 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_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
const MAP_MARGIN_X = 110;
const MAP_TOP = 250;
const MAP_BOTTOM = 470;
// 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.
// Auto-places a campaign's levels along a gentle S-curve, left to right.
// Used when a campaign in index.js omits explicit `positions`.
export function autoLayoutPoints(count) {
if (count <= 0) return [];
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;
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;
const points = [];
// 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 });
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) });
}
return points;
}

View File

@ -3,7 +3,6 @@ import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE, SCORE } from '../config.js';
import { markLevelComplete } from '../util/progress.js';
import { createButton } from '../util/ui.js';
import { formatTime } from '../util/formatTime.js';
import { createPill } from '../util/pill.js';
const KID_ROW_Y = GAME_HEIGHT * 0.62;
const KID_SPACING = 92 * WORLD_SCALE;
@ -44,19 +43,21 @@ export default class LevelScoreScene extends Phaser.Scene {
// so this reads as that same clock continuing, not a new element.
const startX = 24 * WORLD_SCALE;
const hudY = 24 * WORLD_SCALE;
this.timeText = createPill(this, GAME_WIDTH - startX, hudY, formatTime(this.timeRemainingSeconds), {
this.timeText = this.add.text(GAME_WIDTH - startX, hudY, formatTime(this.timeRemainingSeconds), {
fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`,
fontStyle: 'bold',
color: '#1a1f29',
}, 1, 0).setDepth(10);
backgroundColor: '#ffffffaa',
}).setOrigin(1, 0).setDepth(10);
this.scoreLabel = createPill(this, GAME_WIDTH / 2, GAME_HEIGHT * 0.2, 'Score:', {
this.scoreLabel = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT * 0.2, 'Score:', {
fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`,
fontStyle: 'bold',
color: '#1a1f29',
}).setScale(0).setDepth(10);
backgroundColor: '#ffffffaa',
}).setOrigin(0.5).setScale(0).setDepth(10);
// Both start alpha 0 - they sit at Phaser's default (0,0) position until
// _flyInScore places them off-screen and tweens them in, and origin
@ -71,12 +72,13 @@ export default class LevelScoreScene extends Phaser.Scene {
strokeThickness: 6 * WORLD_SCALE,
}).setOrigin(0.5, 0.5).setAlpha(0).setDepth(10);
this.solidFinishText = createPill(this, 0, 0, 'Solid Finish', {
this.solidFinishText = this.add.text(0, 0, 'Solid Finish', {
fontFamily: 'monospace',
fontSize: `${20 * WORLD_SCALE}px`,
fontStyle: 'bold',
color: '#2c8f3c',
}, 0, 0.5).setAlpha(0).setDepth(10);
backgroundColor: '#ffffffaa',
}).setOrigin(0, 0.5).setAlpha(0).setDepth(10);
this.kidSprites = [];
this.kidLabels = [];
@ -231,9 +233,8 @@ export default class LevelScoreScene extends Phaser.Scene {
const y = GAME_HEIGHT * 0.86;
const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => {
this._stopCrowdCheer();
// Record the result (including this run's final score, so the level
// select map can show the player's best) and head back to the map.
markLevelComplete(this.levelId, this.kidsSaved, this.total, this.runningScore);
// Record the result and head straight back to the map.
markLevelComplete(this.levelId, this.kidsSaved, this.total);
this.scene.stop('LevelScore');
this.scene.start('LevelSelect');
});

View File

@ -2,10 +2,9 @@ import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { CAMPAIGNS } from '../data/levels/index.js';
import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js';
import { isLevelComplete, getBestScore } from '../util/progress.js';
import { isLevelComplete, getLevelResult } from '../util/progress.js';
import { playMenuMusic } from '../util/music.js';
import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js';
import { createPill, pillInteractive } from '../util/pill.js';
import { nodeDisplaySize, scalePos, BADGE_FILE_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js';
const NODE_HOVER = 1.12;
const NODE_IDLE_PULSE = 1.05;
@ -24,28 +23,10 @@ const TITLE_STYLE = {
};
const PILL_STYLE = {
fontFamily: 'monospace',
// 13 (not 14) so the level-name tag under the current node stays compact
// even for campaigns with many levels; the best-score pills use their own
// SCORE_PILL_STYLE below.
fontSize: `${13 * WORLD_SCALE}px`,
color: '#1a1f29',
};
// Labels that share PILL_STYLE are pills (see util/pill.js) - createPill
// supplies the rounded white backing + black stroke and the guaranteed
// text/edge padding; only the font side lives here.
// The "best score" pill that rolls up under cleared nodes - gold, to match
// the gold score numerals in the score tally scene.
const SCORE_PILL_STYLE = {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
fontStyle: 'bold',
color: '#1a1f29',
backgroundColor: '#f2c14e',
stroke: '#ffffff',
strokeThickness: 2 * WORLD_SCALE,
backgroundColor: '#ffffffaa',
padding: { x: 10 * WORLD_SCALE, y: 5 * WORLD_SCALE },
};
@ -76,15 +57,18 @@ export default class LevelSelectScene extends Phaser.Scene {
.setDepth(DEPTH.chrome);
this._pager = this.add.graphics().setDepth(DEPTH.chrome);
const back = createPill(this, 44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }, 0, 0);
pillInteractive(back, { useHandCursor: true });
back.setDepth(DEPTH.chrome);
const back = this.add
.text(44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` })
.setInteractive({ useHandCursor: true })
.setDepth(DEPTH.chrome);
back.on('pointerdown', () => {
this.scene.stop('LevelSelect');
this.scene.start('MainMenu');
});
createPill(this, GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE)
this.add
.text(GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE)
.setOrigin(0.5)
.setDepth(DEPTH.chrome);
this._buildCampaign(true);
@ -125,42 +109,26 @@ 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);
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);
}
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.
// (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);
}
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);
// Nodes + labels. All node textures are 512x512 badges; per-key display
// sizes come from mapArt.js (map_node_current displays larger than the
// others).
// Nodes + labels. Per-key display sizes: map_node_current ships as a tight
// 512x512 badge while the others are 960x540 frames (see mapArt.js).
levels.forEach((level, i) => {
const p = points[i];
const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked';
@ -168,20 +136,21 @@ 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 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.
// 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.
//
// 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 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;
// 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;
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;
@ -213,11 +182,12 @@ export default class LevelSelectScene extends Phaser.Scene {
}
this._groups.nodes.push(node);
if (state === 'done') {
const best = getBestScore(level.id);
if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70);
} else if (state === 'current') {
this._groups.tags.push(createPill(this, p.x, p.y + nh * 0.68, level.name, PILL_STYLE).setDepth(DEPTH.tag));
const result = getLevelResult(level.id);
let label = '';
if (state === 'done') label = result ? `${result.kidsSaved}/${result.kidsTotal} kids saved` : 'cleared!';
else if (state === 'current') label = level.name;
if (label) {
this._groups.tags.push(this.add.text(p.x, p.y + nh * 0.68, label, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag));
}
});
@ -253,39 +223,6 @@ export default class LevelSelectScene extends Phaser.Scene {
}
}
// Cleared nodes wear a small gold "best score" pill under the badge (gold
// matches the gold score numerals in the tally scene). On entrance the
// number rolls up from 0 to the player's best - an odometer that reads as
// the map remembering the run. Duration scales with the score's magnitude
// (capped) so big scores get the longer, more satisfying count.
_scoreRollup(p, nh, best, delay) {
const pill = this.add
.text(p.x, p.y + nh * 0.68, '★ 0', SCORE_PILL_STYLE)
.setOrigin(0.5)
.setDepth(DEPTH.tag)
.setAlpha(0)
.setScale(0.4);
this._groups.tags.push(pill);
this.tweens.add({
targets: pill,
alpha: 1,
scale: 1,
delay,
duration: 220,
ease: 'Back.easeOut',
onComplete: () => {
const counter = { v: 0 };
this.tweens.add({
targets: counter,
v: best,
duration: Math.min(1500, 500 + Math.sqrt(best) * 40),
ease: 'Cubic.easeOut',
onUpdate: () => pill.setText(`${Math.round(counter.v).toLocaleString()}`),
});
},
});
}
_startPulse(index, delay = 0) {
if (this._pulseTween) this._pulseTween.stop();
const node = this._groups.nodes[index];
@ -316,8 +253,6 @@ 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,
@ -333,7 +268,6 @@ 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));
@ -353,8 +287,6 @@ export default class LevelSelectScene extends Phaser.Scene {
_tooltip(x, y, msg) {
this._hideTooltip();
// Tooltip is dark, not one of the white pills (util/pill.js is
// white-fill-only), so it keeps its own plain backgroundColor.
this._tip = this.add
.text(x, y, msg, {
fontFamily: 'monospace',

View File

@ -1,7 +1,6 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { createButton } from '../util/ui.js';
import { createPill } from '../util/pill.js';
import { playMenuMusic } from '../util/music.js';
export default class MainMenuScene extends Phaser.Scene {
@ -27,14 +26,15 @@ export default class MainMenuScene extends Phaser.Scene {
this.scene.start('LevelSelect');
});
// Pill (see util/pill.js) - the treeline in bg_menu.png sits right
// behind this text and would otherwise swallow the dark-on-dark-green
// portion of it.
createPill(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 60 * WORLD_SCALE, 'Arrows/WASD to drive and lean.\nDon\'t let the g-force throw the kids out!', {
// backgroundColor pill (same convention as PlayScene's HUD text) - the
// treeline in bg_menu.png sits right behind this text and would
// otherwise swallow the dark-on-dark-green portion of it.
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 60 * WORLD_SCALE, 'Arrows/WASD to drive and lean.\nDon\'t let the g-force throw the kids out!', {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
align: 'center',
});
backgroundColor: '#ffffffaa',
}).setOrigin(0.5);
}
}

View File

@ -12,7 +12,6 @@ import EngineSound from '../systems/EngineSound.js';
import { stopMenuMusic } from '../util/music.js';
import { playLevelVoiceLine } from '../util/voiceLine.js';
import { formatTime } from '../util/formatTime.js';
import { createPill } from '../util/pill.js';
export default class PlayScene extends Phaser.Scene {
constructor() {
@ -215,25 +214,28 @@ export default class PlayScene extends Phaser.Scene {
this.hudIcons.push(icon);
}
this.hudText = createPill(this, startX, y + 24 * WORLD_SCALE, `Kids aboard: ${this.level.kidsAboard}/${this.level.kidsAboard}`, {
this.hudText = this.add.text(startX, y + 24 * WORLD_SCALE, `Kids aboard: ${this.level.kidsAboard}/${this.level.kidsAboard}`, {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
}, 0, 0).setScrollFactor(0).setDepth(10);
backgroundColor: '#ffffffaa',
}).setScrollFactor(0).setDepth(10);
this.timerText = createPill(this, GAME_WIDTH - startX, y, formatTime(this._timeRemaining), {
this.timerText = this.add.text(GAME_WIDTH - startX, y, formatTime(this._timeRemaining), {
fontFamily: 'monospace',
fontSize: `${32 * WORLD_SCALE}px`,
fontStyle: 'bold',
color: '#1a1f29',
}, 1, 0).setScrollFactor(0).setDepth(10);
backgroundColor: '#ffffffaa',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(10);
if (DEBUG) {
this.debugText = createPill(this, startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', {
this.debugText = this.add.text(startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', {
fontFamily: 'monospace',
fontSize: `${14 * WORLD_SCALE}px`,
color: '#1a1f29',
}, 0, 0).setScrollFactor(0).setDepth(10);
backgroundColor: '#ffffffaa',
}).setScrollFactor(0).setDepth(10);
}
}

View File

@ -24,14 +24,18 @@ 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 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.
// 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.
{ 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: 'map_node', path: 'assets/ui/map_node.png', width: 512, height: 512, kind: 'circle', color: 0x2255aa },
// 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_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: 512, height: 512, kind: 'circle', color: 0x606876 },
{ 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_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 },

View File

@ -1,50 +1,29 @@
// Shared map geometry for the LevelSelect map nodes + path ribbon.
//
// 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).
// 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).
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
// 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.
// 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.
export const BADGE_FRAC_OF_FRAME = 0.47;
// 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.
// 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.
export const BADGE_FILE_FRAC = {
map_node: 0.128,
map_node_current: 0.155,
map_node_locked: 0.128,
map_node: 0.24,
map_node_current: 0.30,
map_node_locked: 0.24,
};
// 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.
// 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.
export const BADGE_HIT_RADIUS_MULT = 1.1;
// Road ribbon display size, in design units.
@ -56,7 +35,9 @@ export function frameAnchorDisplayWidth() {
return GAME_WIDTH;
}
// Source pixel dimensions of each node texture (all currently 512x512).
// 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.
export const NODE_ART_FRAMES = {
map_node: { w: 512, h: 512 },
map_node_current: { w: 512, h: 512 },

View File

@ -1,126 +0,0 @@
import Phaser from 'phaser';
import { WORLD_SCALE } from '../config.js';
// Rounded "pill" backing for the dark-on-white text labels used across the
// UI. Phaser's Text backgroundColor is always a plain rectangle, so this
// helper replaces it: the text keeps its own style (font, size, color) but
// draws with NO background, and a graphics pill - semi-transparent white,
// rounded corners, black stroke - is sized to the text and sits just behind
// it in a Container. The container is one object with one depth/alpha/tween
// identity, so it can replace a bare Text in place; the text's padding
// (floored at PILL_PADDING) keeps breathing room between glyphs and the
// pill edges.
//
// Usage: const pill = createPill(scene, x, y, 'Label', { fontSize: ... })
// The container is origin-0.5 by default, so existing .setOrigin(0.5) call
// sites keep working. For labels that used a corner origin (the HUD timer
// at (1, 0), "Solid Finish" at (0, 0.5)), pass originX/originY - the
// backing then aligns so that corner of the pill sits on the container
// position, and the container's transform/positioning semantics are
// unchanged.
export const PILL_FILL = 0xffffff;
export const PILL_FILL_ALPHA = 0.667; // same '#ffffffaa' the text backgrounds used
export const PILL_STROKE = 0x000000;
export const PILL_STROKE_WIDTH = 2 * WORLD_SCALE;
export const PILL_RADIUS = 8 * WORLD_SCALE;
export const PILL_PADDING = 6 * WORLD_SCALE;
// Sizes the backing pill to the text and positions both so the pill's
// origin corner (0/0.5/1 on each axis) lands on the container position -
// mirroring what setOrigin(ox, oy) on a bare Text would do. The text and
// pill share one bounding box, so the corner aligns for both.
function fitPill(c) {
const back = c.list[0];
const text = c.list[1];
const w = Math.max(text.width, 1);
const h = Math.max(text.height, 1);
const cx = (0.5 - c.pillOriginX) * w;
const cy = (0.5 - c.pillOriginY) * h;
// Track the pill box as the container's own size - displayOriginX/Y
// (used by the input system to normalize hit-test points) are derived
// from width/2, so without this an enabled hit area would be off by
// half its width/height.
c.setSize(w, h);
text.x = cx;
text.y = cy;
back.clear();
back.fillStyle(PILL_FILL, PILL_FILL_ALPHA);
back.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, PILL_RADIUS);
back.lineStyle(PILL_STROKE_WIDTH, PILL_STROKE, 1);
back.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, PILL_RADIUS);
// Keep an input hit area (if enabled via pillInteractive) matched to the
// pill box. Container hit-test points are normalized by adding
// displayOrigin (always width/2, height/2 for a Container), so the rect
// lives in that normalized space - (cx, cy) here rather than the raw
// local corner.
if (c.input && c.input.hitArea) {
syncHitArea(c);
}
}
// Every Text re-render path (setText, setStyle, setPadding, ...) funnels
// through updateText, but this Phaser build emits no event for it - so
// wrap it and re-fit the backing after each pass.
function observeText(c, text) {
const originalUpdateText = text.updateText.bind(text);
text.updateText = function () {
const r = originalUpdateText();
fitPill(c);
return r;
};
}
export function createPill(scene, x, y, textContent, style, originX = 0.5, originY = 0.5) {
// The text's own background is what we're replacing - drop it here (and
// keep it off any later setStyle too), else both would draw.
delete style.backgroundColor;
const textStyle = { ...style };
const padding = textStyle.padding ? { ...textStyle.padding } : {};
padding.left = Math.max(padding.left || 0, PILL_PADDING);
padding.right = Math.max(padding.right || 0, PILL_PADDING);
padding.top = Math.max(padding.top || 0, PILL_PADDING);
padding.bottom = Math.max(padding.bottom || 0, PILL_PADDING);
textStyle.padding = padding;
const textObj = scene.add.text(0, 0, textContent, textStyle).setOrigin(0.5);
const back = scene.add.graphics();
const c = scene.add.container(x, y, [back, textObj]);
c.pillOriginX = originX;
c.pillOriginY = originY;
observeText(c, textObj);
fitPill(c);
// Scene code treats the pill like the Text it replaces - delegate the
// Text methods it calls. setText goes through the observed updateText,
// so the backing re-fits automatically.
c.text = textObj;
c.setText = (value) => { textObj.setText(value); return c; };
c.setColor = (color) => { textObj.setColor(color); return c; };
return c;
}
// Makes a pill clickable. A bare setInteractive() on a Container derives its
// hit area from a texture frame it doesn't have (a 0x0 rectangle -
// effectively unclickable), so this supplies an explicit rectangle that
// fitPill keeps sized to the pill box on every text update. Extra config
// (useHandCursor, draggable, ...) is forwarded as-is.
export function pillInteractive(c, config = {}) {
// Explicit rect + Rectangle.Contains - same semantics as the default
// texture-frame hit area, but sized to the pill box (a Container has no
// texture frame of its own, so the default would be 0x0 / unclickable).
const hitArea = new Phaser.Geom.Rectangle(0, 0, 1, 1);
c.setInteractive({ hitArea, hitAreaCallback: Phaser.Geom.Rectangle.Contains, ...config });
syncHitArea(c);
return c;
}
function syncHitArea(c) {
if (!c.input || !c.input.hitArea) return;
const text = c.list[1];
const w = Math.max(text.width, 1);
const h = Math.max(text.height, 1);
const cx = (0.5 - c.pillOriginX) * w;
const cy = (0.5 - c.pillOriginY) * h;
Object.assign(c.input.hitArea, { x: cx, y: cy, width: w, height: h });
}

View File

@ -9,7 +9,16 @@ function readAll() {
}
}
function writeAll(all) {
export function isLevelComplete(levelId) {
return Boolean(readAll()[levelId]);
}
export function markLevelComplete(levelId, kidsSaved, kidsTotal) {
const all = readAll();
const existing = all[levelId];
if (!existing || kidsSaved > existing.kidsSaved) {
all[levelId] = { kidsSaved, kidsTotal };
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch (e) {
@ -17,33 +26,6 @@ function writeAll(all) {
}
}
export function isLevelComplete(levelId) {
return Boolean(readAll()[levelId]);
}
// Records a completed run. kidsSaved/kidsTotal are kept (they're how
// "isLevelComplete" and any legacy saves work), and bestScore is the
// player's highest final score - the level select map shows it under each
// cleared node. A run only "counts" if it improves the best on either
// axis, but whichever axis improved wins; both are kept at their max.
export function markLevelComplete(levelId, kidsSaved, kidsTotal, score = null) {
const all = readAll();
const existing = all[levelId] || {};
all[levelId] = {
kidsSaved: Math.max(existing.kidsSaved || 0, kidsSaved),
kidsTotal,
bestScore: Math.max(existing.bestScore || 0, score || 0),
};
writeAll(all);
}
export function getLevelResult(levelId) {
return readAll()[levelId] || null;
}
// The player's best score for a level, or null if none has been recorded
// yet (e.g. a legacy save from before best scores existed).
export function getBestScore(levelId) {
const result = readAll()[levelId];
return result && typeof result.bestScore === 'number' && result.bestScore > 0 ? result.bestScore : null;
}

View File

@ -1,48 +1,20 @@
import Phaser from 'phaser';
import { WORLD_SCALE } from '../config.js';
import { PILL_RADIUS } from './pill.js';
// Rounded-rect blue button - same look as the old flat Rectangle button,
// just with rounded corners matching the pills (PILL_RADIUS). Graphics
// instead of a Rectangle because only Graphics can round corners; the
// hover highlight re-draws the fill, keeping the dark outline.
const FILL = 0x2255aa;
const HOVER_FILL = 0x2f6ac0;
const STROKE = 0x1a1f29;
const STROKE_WIDTH = 2 * WORLD_SCALE;
function drawBg(g, width, height, fill) {
g.clear();
g.fillStyle(fill, 1);
g.fillRoundedRect(-width / 2, -height / 2, width, height, PILL_RADIUS);
g.lineStyle(STROKE_WIDTH, STROKE, 1);
g.strokeRoundedRect(-width / 2, -height / 2, width, height, PILL_RADIUS);
}
export function createButton(scene, x, y, label, onClick, options = {}) {
const width = options.width || 220 * WORLD_SCALE;
const height = options.height || 56 * WORLD_SCALE;
const fontSize = options.fontSize || `${20 * WORLD_SCALE}px`;
const bg = scene.add.graphics().setPosition(x, y);
drawBg(bg, width, height, FILL);
const bg = scene.add.rectangle(x, y, width, height, 0x2255aa).setStrokeStyle(2 * WORLD_SCALE, 0x1a1f29);
const text = scene.add.text(x, y, label, {
fontFamily: 'monospace',
fontSize,
color: '#ffffff',
}).setOrigin(0.5);
// Explicit hit rect - a default setInteractive() on a Graphics would use
// its full 512x512 texture frame, making clicks near other buttons land
// on the wrong one. Graphics hit areas live in its local drawing space
// (displayOrigin 0,0), so the rect mirrors the drawn rounded rect.
bg.setInteractive({
hitArea: new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height),
hitAreaCallback: Phaser.Geom.Rectangle.Contains,
useHandCursor: true,
});
bg.on('pointerover', () => drawBg(bg, width, height, HOVER_FILL));
bg.on('pointerout', () => drawBg(bg, width, height, FILL));
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x2f6ac0));
bg.on('pointerout', () => bg.setFillStyle(0x2255aa));
bg.on('pointerdown', onClick);
return { bg, text };