Refactor level select into campaign map with auto-layout
Replace the flat level-grid level select screen with a campaign-based map view. Levels are grouped into themed campaigns (e.g. "Sunny Suburbs", "Dusk Junction") each with its own background art and node positions rendered along a Catmull-Rom spline ribbon. Key changes: - Introduce CAMPAIGNS array in src/data/levels/index.js with helpers for campaign/level lookup. - Add src/data/levels/positioning.js for auto-layout S-curve generation and Catmull-Rom spline sampling. - Rewrite LevelSelectScene.js to render per-campaign maps with animated node entrance, idle pulse on the current level, hover states, tool-tips for locked levels, and ← → / P keyboard navigation. - Remove the now-unused LevelCompleteScene; scores now record progress and return directly to the map via LevelScoreScene. - Add map assets (campaign backgrounds, node/flag/tag sprites) and manifest entries with a documented 960x540 centered-canvas convention. - Add src/util/mapArt.js for shared map geometry constants and scaling.
|
|
@ -31,7 +31,7 @@ index.html entry point, import map -> vendor/phaser.esm.js
|
|||
vendor/phaser.esm.js vendored local copy of Phaser 4 (see below)
|
||||
src/main.js Phaser.Game config + scene list
|
||||
src/config.js every tunable constant (physics, g-force threshold, camera, ...)
|
||||
src/scenes/ Boot -> Preload -> Intro -> MainMenu -> LevelSelect -> Play -> LevelComplete/LevelFailed
|
||||
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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
44
sprites.md
|
|
@ -77,6 +77,50 @@ with it (same ratios, just re-derive from that one constant).
|
|||
a wider or taller source image just changes how often the tile repeats, so
|
||||
use whatever gives a clean seamless loop.
|
||||
|
||||
## Level select map
|
||||
|
||||
The level select screen is a map per campaign: a themed background with a
|
||||
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.
|
||||
|
||||
| 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` | ~450x450 badge centered | 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. 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, 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`.
|
||||
- **`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.
|
||||
- **`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.
|
||||
|
||||
## Favicon
|
||||
|
||||
- `favicon.png` is the one asset **without** a placeholder fallback for its
|
||||
|
|
|
|||
|
|
@ -5,12 +5,47 @@ import level04 from './level04.js';
|
|||
import level05 from './level05.js';
|
||||
import level06 from './level06.js';
|
||||
|
||||
export const LEVELS = [level01, level02, level03, level04, level05, level06];
|
||||
// Campaigns group levels into themed "maps" for the level select screen.
|
||||
// Each campaign gets its own background art (bgKey) and theme, so adding a
|
||||
// new campaign is: create its levels, add a bg image + manifest entry, add a
|
||||
// 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.
|
||||
export const CAMPAIGNS = [
|
||||
{
|
||||
id: 'campaign01',
|
||||
name: 'Sunny Suburbs',
|
||||
bgKey: 'campaign01_bg',
|
||||
levels: [level01, level02, level03],
|
||||
positions: [
|
||||
{ x: 160, y: 392 },
|
||||
{ x: 400, y: 330 },
|
||||
{ x: 640, y: 380 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'campaign02',
|
||||
name: 'Dusk Junction',
|
||||
bgKey: 'campaign02_bg',
|
||||
levels: [level04, level05, level06],
|
||||
},
|
||||
];
|
||||
|
||||
export const LEVELS = CAMPAIGNS.flatMap((c) => c.levels);
|
||||
|
||||
export function getLevelById(id) {
|
||||
return LEVELS.find((level) => level.id === id);
|
||||
}
|
||||
|
||||
export function getCampaignById(id) {
|
||||
return CAMPAIGNS.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
export function getCampaignOfLevel(levelId) {
|
||||
return CAMPAIGNS.find((c) => c.levels.some((l) => l.id === levelId)) || null;
|
||||
}
|
||||
|
||||
export function getNextLevelId(id) {
|
||||
const index = LEVELS.findIndex((level) => level.id === id);
|
||||
if (index === -1 || index === LEVELS.length - 1) return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
// Layout math for campaign map node positions. All coordinates are in 960x540
|
||||
// design units (the scene multiplies by WORLD_SCALE when drawing).
|
||||
|
||||
const MAP_MARGIN_X = 110;
|
||||
const MAP_TOP = 250;
|
||||
const MAP_BOTTOM = 470;
|
||||
|
||||
// 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: 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 = [];
|
||||
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;
|
||||
}
|
||||
|
||||
// Catmull-Rom spline sampled into a polyline. `points` must have >= 2 entries;
|
||||
// the first/last are duplicated so the curve passes exactly through every
|
||||
// node (standard end-anchor trick).
|
||||
export function sampleSpline(points, samplesPerSegment = 14) {
|
||||
if (points.length < 2) return [];
|
||||
const pts = [points[0], ...points, points[points.length - 1]];
|
||||
const out = [];
|
||||
for (let i = 0; i < pts.length - 3; i++) {
|
||||
const [p0, p1, p2, p3] = [pts[i], pts[i + 1], pts[i + 2], pts[i + 3]];
|
||||
for (let s = 0; s < samplesPerSegment; s++) {
|
||||
const t = s / samplesPerSegment;
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
const x =
|
||||
0.5 *
|
||||
(2 * p1.x +
|
||||
(-p0.x + p2.x) * t +
|
||||
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
|
||||
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
|
||||
const y =
|
||||
0.5 *
|
||||
(2 * p1.y +
|
||||
(-p0.y + p2.y) * t +
|
||||
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
|
||||
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
|
||||
out.push({ x, y });
|
||||
}
|
||||
}
|
||||
out.push(points[points.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import MainMenuScene from './scenes/MainMenuScene.js';
|
|||
import LevelSelectScene from './scenes/LevelSelectScene.js';
|
||||
import PlayScene from './scenes/PlayScene.js';
|
||||
import LevelScoreScene from './scenes/LevelScoreScene.js';
|
||||
import LevelCompleteScene from './scenes/LevelCompleteScene.js';
|
||||
import LevelFailedScene from './scenes/LevelFailedScene.js';
|
||||
|
||||
window.__PHASER_GAME__ = new Phaser.Game({
|
||||
|
|
@ -55,7 +54,6 @@ window.__PHASER_GAME__ = new Phaser.Game({
|
|||
LevelSelectScene,
|
||||
PlayScene,
|
||||
LevelScoreScene,
|
||||
LevelCompleteScene,
|
||||
LevelFailedScene,
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||
import { getLevelById, getNextLevelId } from '../data/levels/index.js';
|
||||
import { markLevelComplete } from '../util/progress.js';
|
||||
import { createButton } from '../util/ui.js';
|
||||
|
||||
export default class LevelCompleteScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('LevelComplete');
|
||||
}
|
||||
|
||||
init(data) {
|
||||
this.levelId = data.levelId;
|
||||
this.kidsSaved = data.kidsSaved;
|
||||
this.total = data.total;
|
||||
}
|
||||
|
||||
create() {
|
||||
markLevelComplete(this.levelId, this.kidsSaved, this.total);
|
||||
|
||||
this.cameras.main.setBackgroundColor('#8fc7e8');
|
||||
|
||||
this.add.text(GAME_WIDTH / 2, 110 * WORLD_SCALE, 'LEVEL COMPLETE!', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${36 * WORLD_SCALE}px`,
|
||||
color: '#1a1f29',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const perfect = this.kidsSaved === this.total;
|
||||
this.add.text(GAME_WIDTH / 2, 170 * WORLD_SCALE, `${this.kidsSaved} / ${this.total} kids made it${perfect ? ' - perfect run!' : ''}`, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${18 * WORLD_SCALE}px`,
|
||||
color: perfect ? '#2c8f3c' : '#1a1f29',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const nextId = getNextLevelId(this.levelId);
|
||||
const y = GAME_HEIGHT / 2 + 20 * WORLD_SCALE;
|
||||
|
||||
createButton(this, GAME_WIDTH / 2, y - 70 * WORLD_SCALE, 'RETRY', () => {
|
||||
this.scene.start('Play', { levelId: this.levelId });
|
||||
});
|
||||
|
||||
if (nextId) {
|
||||
const nextLevel = getLevelById(nextId);
|
||||
createButton(this, GAME_WIDTH / 2, y, `NEXT: ${nextLevel.name.toUpperCase()}`, () => {
|
||||
this.scene.start('Play', { levelId: nextId });
|
||||
}, { width: 280 * WORLD_SCALE });
|
||||
} else {
|
||||
this.add.text(GAME_WIDTH / 2, y, 'That was the last level - nice driving!', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${14 * WORLD_SCALE}px`,
|
||||
color: '#1a1f29',
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
createButton(this, GAME_WIDTH / 2, y + 70 * WORLD_SCALE, 'LEVEL SELECT', () => {
|
||||
this.scene.start('LevelSelect');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -36,12 +36,18 @@ export default class LevelFailedScene extends Phaser.Scene {
|
|||
|
||||
const y = GAME_HEIGHT / 2 + 40 * WORLD_SCALE;
|
||||
|
||||
createButton(this, GAME_WIDTH / 2, y - 40 * WORLD_SCALE, 'RETRY', () => {
|
||||
this.scene.start('Play', { levelId: this.levelId });
|
||||
// Primary action: try again.
|
||||
createButton(this, GAME_WIDTH / 2, y, 'RETRY', () => {
|
||||
this._to('Play', { levelId: this.levelId });
|
||||
});
|
||||
|
||||
createButton(this, GAME_WIDTH / 2, y + 40 * WORLD_SCALE, 'LEVEL SELECT', () => {
|
||||
this.scene.start('LevelSelect');
|
||||
createButton(this, GAME_WIDTH / 2, y + 70 * WORLD_SCALE, 'LEVEL SELECT', () => {
|
||||
this._to('LevelSelect');
|
||||
});
|
||||
}
|
||||
|
||||
_to(key, data) {
|
||||
this.scene.stop('LevelFailed');
|
||||
this.scene.start(key, data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Phaser from 'phaser';
|
||||
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';
|
||||
|
||||
|
|
@ -11,8 +12,8 @@ const SCORE_Y = GAME_HEIGHT * 0.36;
|
|||
// Plays out entirely over a single frozen snapshot of PlayScene at the
|
||||
// instant the bus crossed the finish line (see PlayScene._onWin) - this
|
||||
// scene owns no physics or camera follow of its own, just the tally
|
||||
// choreography on top of that still frame, before handing off to
|
||||
// LevelCompleteScene for the retry/next-level choice.
|
||||
// choreography on top of that still frame. CONTINUE records the result
|
||||
// and returns the player to the level select map.
|
||||
export default class LevelScoreScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('LevelScore');
|
||||
|
|
@ -232,7 +233,10 @@ 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();
|
||||
this.scene.start('LevelComplete', { levelId: this.levelId, kidsSaved: this.kidsSaved, total: this.total });
|
||||
// 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');
|
||||
});
|
||||
|
||||
bg.setDepth(10);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,28 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||
import { LEVELS } from '../data/levels/index.js';
|
||||
import { getLevelResult } from '../util/progress.js';
|
||||
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';
|
||||
|
||||
const NODE_HOVER = 1.12;
|
||||
const NODE_IDLE_PULSE = 1.05;
|
||||
|
||||
const TITLE_STYLE = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${30 * WORLD_SCALE}px`,
|
||||
color: '#1a1f29',
|
||||
fontStyle: 'bold',
|
||||
};
|
||||
|
||||
const PILL_STYLE = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${14 * WORLD_SCALE}px`,
|
||||
color: '#1a1f29',
|
||||
backgroundColor: '#ffffffaa',
|
||||
padding: { x: 10 * WORLD_SCALE, y: 5 * WORLD_SCALE },
|
||||
};
|
||||
|
||||
export default class LevelSelectScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -13,70 +33,323 @@ export default class LevelSelectScene extends Phaser.Scene {
|
|||
this.cameras.main.setBackgroundColor('#8fc7e8');
|
||||
playMenuMusic(this);
|
||||
|
||||
this.add.image(0, 0, 'bg_menu').setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
this.campaignIndex = 0;
|
||||
this._groups = { bg: [], path: [], nodes: [], tags: [] };
|
||||
this._transitioning = false;
|
||||
this._pulseTween = null;
|
||||
|
||||
this.add.text(GAME_WIDTH / 2, 60 * WORLD_SCALE, 'SELECT LEVEL', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${32 * WORLD_SCALE}px`,
|
||||
color: '#1a1f29',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
// Chrome (persistent across campaigns).
|
||||
this._titlePlate = this.add
|
||||
.image(GAME_WIDTH / 2, 82 * WORLD_SCALE, 'map_campaign_tag')
|
||||
.setOrigin(0.5)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
this._titleText = this.add.text(GAME_WIDTH / 2, 82 * WORLD_SCALE, '', TITLE_STYLE).setOrigin(0.5);
|
||||
this._pager = this.add.graphics();
|
||||
|
||||
const cardWidth = 260 * WORLD_SCALE;
|
||||
const cardHeight = 300 * WORLD_SCALE;
|
||||
const gap = 30 * WORLD_SCALE;
|
||||
const totalWidth = LEVELS.length * cardWidth + (LEVELS.length - 1) * gap;
|
||||
const startX = GAME_WIDTH / 2 - totalWidth / 2 + cardWidth / 2;
|
||||
const y = GAME_HEIGHT / 2 + 30 * WORLD_SCALE;
|
||||
|
||||
LEVELS.forEach((level, i) => {
|
||||
const x = startX + i * (cardWidth + gap);
|
||||
this._createCard(x, y, cardWidth, cardHeight, level);
|
||||
const back = this.add
|
||||
.text(44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` })
|
||||
.setInteractive({ useHandCursor: true });
|
||||
back.on('pointerdown', () => {
|
||||
this.scene.stop('LevelSelect');
|
||||
this.scene.start('MainMenu');
|
||||
});
|
||||
|
||||
// backgroundColor pill (same convention as PlayScene's HUD text) - this
|
||||
// corner sits right over bg_menu.png's dark treeline.
|
||||
const back = this.add.text(40 * WORLD_SCALE, GAME_HEIGHT - 40 * WORLD_SCALE, '< Menu', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${16 * WORLD_SCALE}px`,
|
||||
color: '#1a1f29',
|
||||
backgroundColor: '#ffffffaa',
|
||||
}).setInteractive({ useHandCursor: true });
|
||||
back.on('pointerdown', () => this.scene.start('MainMenu'));
|
||||
this.add
|
||||
.text(GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this._buildCampaign(true);
|
||||
|
||||
const kb = this.input.keyboard;
|
||||
kb.on('keydown-LEFT', () => this._shiftCampaign(-1));
|
||||
kb.on('keydown-RIGHT', () => this._shiftCampaign(1));
|
||||
kb.on('keydown-P', () => this._playFirst());
|
||||
kb.on('keydown-ESC', () => {
|
||||
this.scene.stop('LevelSelect');
|
||||
this.scene.start('MainMenu');
|
||||
});
|
||||
}
|
||||
|
||||
_createCard(x, y, width, height, level) {
|
||||
const result = getLevelResult(level.id);
|
||||
shutdown() {
|
||||
if (this._pulseTween) this._pulseTween.stop();
|
||||
}
|
||||
|
||||
const bg = this.add.rectangle(x, y, width, height, 0x2255aa).setStrokeStyle(2 * WORLD_SCALE, 0x1a1f29);
|
||||
bg.setInteractive({ useHandCursor: true });
|
||||
bg.on('pointerover', () => bg.setFillStyle(0x2f6ac0));
|
||||
bg.on('pointerout', () => bg.setFillStyle(0x2255aa));
|
||||
bg.on('pointerdown', () => this.scene.start('Play', { levelId: level.id }));
|
||||
// ------------------------------------------------------------------ build
|
||||
|
||||
this.add.text(x, y - height / 2 + 34 * WORLD_SCALE, level.name, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${20 * WORLD_SCALE}px`,
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
_buildCampaign(instant = false) {
|
||||
for (const key of Object.keys(this._groups)) {
|
||||
for (const obj of this._groups[key]) obj.destroy();
|
||||
this._groups[key] = [];
|
||||
}
|
||||
|
||||
this.add.text(x, y, level.description, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${13 * WORLD_SCALE}px`,
|
||||
color: '#e8eef7',
|
||||
align: 'center',
|
||||
wordWrap: { width: width - 30 * WORLD_SCALE },
|
||||
}).setOrigin(0.5);
|
||||
const campaign = CAMPAIGNS[this.campaignIndex];
|
||||
const levels = campaign.levels;
|
||||
const points = (campaign.positions || autoLayoutPoints(levels.length)).map(scalePos);
|
||||
const completed = levels.map((l) => isLevelComplete(l.id));
|
||||
// A campaign's first level is playable as soon as the PREVIOUS campaign
|
||||
// is fully cleared (within the first campaign, it's open from the start);
|
||||
// after that, levels unlock one at a time within the campaign.
|
||||
const campaignUnlocked = this.campaignIndex === 0 || CAMPAIGNS[this.campaignIndex - 1].levels.every((l) => isLevelComplete(l.id));
|
||||
let current = completed.indexOf(false);
|
||||
if (current === 0) current = campaignUnlocked ? 0 : -1;
|
||||
|
||||
const statusText = result
|
||||
? `Best: ${result.kidsSaved}/${result.kidsTotal} kids saved`
|
||||
: 'Not completed';
|
||||
const bg = this.add.image(0, 0, campaign.bgKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
this._groups.bg.push(bg);
|
||||
|
||||
this.add.text(x, y + height / 2 - 30 * WORLD_SCALE, statusText, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${13 * WORLD_SCALE}px`,
|
||||
color: result ? '#f2c14e' : '#c8d3e0',
|
||||
}).setOrigin(0.5);
|
||||
const spline = sampleSpline(points, 18);
|
||||
const path = this.add.graphics();
|
||||
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.55, GAME_HEIGHT * 0.55)
|
||||
.setDepth(1);
|
||||
this._groups.path.push(flag);
|
||||
|
||||
// Nodes + labels.
|
||||
const { width: dw, height: dh } = nodeDisplaySize();
|
||||
levels.forEach((level, i) => {
|
||||
const p = points[i];
|
||||
const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked';
|
||||
const key = state === 'current' ? 'map_node_current' : state === 'locked' ? 'map_node_locked' : 'map_node';
|
||||
const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(dw, dh).setDepth(2);
|
||||
|
||||
// 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 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) / 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;
|
||||
|
||||
if (state === 'locked') {
|
||||
node
|
||||
.setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest })
|
||||
.on(
|
||||
'pointerover',
|
||||
() =>
|
||||
this._tooltip(
|
||||
p.x,
|
||||
p.y - dh * 0.75,
|
||||
i === 0 ? 'Clear the previous campaign to unlock' : 'Finish the previous level to unlock'
|
||||
)
|
||||
)
|
||||
.on('pointerout', () => this._hideTooltip());
|
||||
} else {
|
||||
node
|
||||
.setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest })
|
||||
.on('pointerover', () => this._tweenNodeSize(node, dw * NODE_HOVER, dh * NODE_HOVER))
|
||||
.on('pointerout', () => this._tweenNodeSize(node, dw, dh))
|
||||
.on('pointerdown', () => {
|
||||
this._tweenNodeSize(node, dw * 0.92, dh * 0.92, 70);
|
||||
this.time.delayedCall(80, () => {
|
||||
this.scene.stop('LevelSelect');
|
||||
this.scene.start('Play', { levelId: level.id });
|
||||
});
|
||||
});
|
||||
}
|
||||
this._groups.nodes.push(node);
|
||||
|
||||
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 + dh * 0.68, label, PILL_STYLE).setOrigin(0.5).setDepth(3));
|
||||
}
|
||||
});
|
||||
|
||||
const done = completed.filter(Boolean).length;
|
||||
this._titleText.setText(`${campaign.name} ${done}/${levels.length} cleared`);
|
||||
this._drawPager();
|
||||
|
||||
if (!instant) {
|
||||
// Entrance: background/road fade in, nodes pop in one after another.
|
||||
[...this._groups.bg, ...this._groups.path].forEach((o) => o.setAlpha(0));
|
||||
this.tweens.add({ targets: [...this._groups.bg, ...this._groups.path], alpha: 1, duration: 200, ease: 'Quad.easeOut' });
|
||||
this._groups.nodes.forEach((node, i) => {
|
||||
node.setScale(0.5).setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: node,
|
||||
alpha: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
delay: 80 + i * 70,
|
||||
duration: 260,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
});
|
||||
if (current !== -1) this._startPulse(current);
|
||||
}
|
||||
}
|
||||
|
||||
_startPulse(index) {
|
||||
if (this._pulseTween) this._pulseTween.stop();
|
||||
const node = this._groups.nodes[index];
|
||||
if (!node) return;
|
||||
const { width: dw, height: dh } = nodeDisplaySize();
|
||||
this._pulseTween = this.tweens.add({
|
||||
targets: node,
|
||||
displayWidth: dw * NODE_IDLE_PULSE,
|
||||
displayHeight: dh * NODE_IDLE_PULSE,
|
||||
duration: 900,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- actions
|
||||
|
||||
_shiftCampaign(dir) {
|
||||
if (this._transitioning) return;
|
||||
const next = (this.campaignIndex + dir + CAMPAIGNS.length) % CAMPAIGNS.length;
|
||||
if (next === this.campaignIndex) return;
|
||||
this._transitioning = true;
|
||||
this.campaignIndex = next;
|
||||
|
||||
const all = [...this._groups.bg, ...this._groups.path, ...this._groups.nodes, ...this._groups.tags];
|
||||
this.tweens.add({
|
||||
targets: all,
|
||||
alpha: 0,
|
||||
y: (o) => o.y + 24 * WORLD_SCALE * dir,
|
||||
duration: 140,
|
||||
ease: 'Quad.easeIn',
|
||||
onComplete: () => {
|
||||
this._buildCampaign(false);
|
||||
this._transitioning = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_playFirst() {
|
||||
const campaign = CAMPAIGNS[this.campaignIndex];
|
||||
// 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));
|
||||
if (!campaignUnlocked) return;
|
||||
const firstOpen = campaign.levels.find((l) => !isLevelComplete(l.id)) || campaign.levels[0];
|
||||
this.scene.stop('LevelSelect');
|
||||
this.scene.start('Play', { levelId: firstOpen.id });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- helpers
|
||||
|
||||
_tweenNodeSize(node, w, h, duration = 130) {
|
||||
if (this._pulseTween && this._pulseTween.targets === node) this._pulseTween.stop();
|
||||
this.tweens.killTweensOf(node);
|
||||
this.tweens.add({ targets: node, displayWidth: w, displayHeight: h, duration, ease: 'Quad.easeOut' });
|
||||
}
|
||||
|
||||
_tooltip(x, y, msg) {
|
||||
this._hideTooltip();
|
||||
this._tip = this.add
|
||||
.text(x, y, msg, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${14 * WORLD_SCALE}px`,
|
||||
color: '#f4f0e6',
|
||||
backgroundColor: '#1a1f29',
|
||||
padding: { x: 12 * WORLD_SCALE, y: 6 * WORLD_SCALE },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(10)
|
||||
.setAlpha(0);
|
||||
this.tweens.add({ targets: this._tip, alpha: 1, duration: 100 });
|
||||
}
|
||||
|
||||
_hideTooltip() {
|
||||
if (this._tip) {
|
||||
this._tip.destroy();
|
||||
this._tip = null;
|
||||
}
|
||||
}
|
||||
|
||||
_drawPager() {
|
||||
const g = this._pager;
|
||||
g.clear();
|
||||
const n = CAMPAIGNS.length;
|
||||
const r = 8 * WORLD_SCALE;
|
||||
const gap = 32 * WORLD_SCALE;
|
||||
const x0 = GAME_WIDTH / 2 - ((n - 1) * gap) / 2;
|
||||
const y = 34 * WORLD_SCALE;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = x0 + i * gap;
|
||||
if (i === this.campaignIndex) {
|
||||
g.lineStyle(4 * WORLD_SCALE, 0x1a1f29, 1);
|
||||
g.strokeCircle(x, y, r + 5 * WORLD_SCALE);
|
||||
g.fillStyle(0xf2c14e, 1);
|
||||
} else {
|
||||
g.fillStyle(0x1a1f29, 0.4);
|
||||
}
|
||||
g.fillCircle(x, y, r);
|
||||
}
|
||||
}
|
||||
|
||||
// Path ribbon: strokes along the spline, with circles at each point for
|
||||
// rounded caps/joins (Phaser's Graphics has no lineCap/lineJoin).
|
||||
_traceRibbon(g, spline, width, color, alpha) {
|
||||
g.lineStyle(width, color, alpha);
|
||||
g.beginPath();
|
||||
g.moveTo(spline[0].x, spline[0].y);
|
||||
for (let i = 1; i < spline.length; i++) g.lineTo(spline[i].x, spline[i].y);
|
||||
g.strokePath();
|
||||
// Circles at each sample point give the ribbon rounded ends and smooth joins.
|
||||
const r = width / 2;
|
||||
for (const p of spline) {
|
||||
g.fillStyle(color, alpha);
|
||||
g.fillCircle(p.x, p.y, r);
|
||||
}
|
||||
}
|
||||
|
||||
_traceDashes(g, spline) {
|
||||
const dash = 40 * WORLD_SCALE;
|
||||
const gap = 30 * WORLD_SCALE;
|
||||
g.lineStyle(8 * WORLD_SCALE, 0x2255aa, 0.8);
|
||||
let inDash = true;
|
||||
let remaining = dash;
|
||||
for (let i = 1; i < spline.length; i++) {
|
||||
const a = spline[i - 1];
|
||||
const b = spline[i];
|
||||
const segLen = Math.hypot(b.x - a.x, b.y - a.y) || 1;
|
||||
const ux = (b.x - a.x) / segLen;
|
||||
const uy = (b.y - a.y) / segLen;
|
||||
let t = 0;
|
||||
while (t < segLen) {
|
||||
const step = Math.min(remaining, segLen - t);
|
||||
if (inDash) {
|
||||
g.beginPath();
|
||||
g.moveTo(a.x + ux * t, a.y + uy * t);
|
||||
g.lineTo(a.x + ux * (t + step), a.y + uy * (t + step));
|
||||
g.strokePath();
|
||||
g.fillStyle(0x2255aa, 0.8);
|
||||
g.fillCircle(a.x + ux * t, a.y + uy * t, 4 * WORLD_SCALE);
|
||||
g.fillCircle(a.x + ux * (t + step), a.y + uy * (t + step), 4 * WORLD_SCALE);
|
||||
}
|
||||
remaining -= step;
|
||||
if (remaining <= 0) {
|
||||
inDash = !inDash;
|
||||
remaining = inDash ? dash : gap;
|
||||
}
|
||||
t += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,8 +278,8 @@ export default class PlayScene extends Phaser.Scene {
|
|||
// Freezes the exact moment of crossing the line - pausing physics before
|
||||
// anything else means the snapshot taken below can't catch so much as one
|
||||
// more tick of drift - then hands off to LevelScoreScene, which plays the
|
||||
// score-tally sequence over that frozen frame before continuing on to
|
||||
// LevelCompleteScene's retry/next-level choices.
|
||||
// score-tally sequence over that frozen frame before returning the player
|
||||
// to the level select map.
|
||||
_onWin() {
|
||||
this._levelEnded = true;
|
||||
this.matter.world.pause();
|
||||
|
|
|
|||
|
|
@ -22,7 +22,19 @@ export const ASSET_MANIFEST = [
|
|||
// see IntroScene._buildLogo.
|
||||
{ key: 'bg_main', path: 'assets/backgrounds/bg_main.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x2255aa },
|
||||
{ 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 and LevelSelectScene.
|
||||
// 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.
|
||||
{ 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 },
|
||||
{ key: 'map_node_current', path: 'assets/ui/map_node_current.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, 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_finish', path: 'assets/ui/map_finish.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, 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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
// Shared map geometry for the LevelSelect map nodes + path ribbon.
|
||||
//
|
||||
// Placeholder art for the map UI is generated on 960x540 canvases with the
|
||||
// sprite centered (the generator in /tmp, documented in sprites.md). The code
|
||||
// below displays those textures full-frame at a node's position and reads the
|
||||
// badge size back from the frame size, so it works unchanged when real art
|
||||
// replaces the placeholders - as long as the same "sprite centered on a
|
||||
// 960x540 canvas" convention is kept (or the texture frame size is kept
|
||||
// close to the badge's own aspect).
|
||||
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.
|
||||
export const BADGE_FRAC_OF_FRAME = 0.47;
|
||||
|
||||
// Fraction of the texture file's width spanned by the badge's ink. The node
|
||||
// art is a ~450px badge centered in a 1920x1080 file (the 2x export of the
|
||||
// 960x540 design canvas - see sprites.md); measured on the shipped
|
||||
// map_node*.png, ink spans ~459-466 of the 1920 file width. Re-measure if
|
||||
// the art changes.
|
||||
export const BADGE_FILE_FRAC = 0.24;
|
||||
|
||||
// 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.
|
||||
export const PATH_WIDTH = 480;
|
||||
export const PATH_HEIGHT = 80;
|
||||
|
||||
// A texture frame displayed full-screen, anchored at (x, y) in design units.
|
||||
export function frameAnchorDisplayWidth() {
|
||||
return GAME_WIDTH;
|
||||
}
|
||||
|
||||
export function nodeDisplaySize() {
|
||||
const s = GAME_WIDTH * BADGE_FRAC_OF_FRAME;
|
||||
return { width: s, height: s * (540 / 960) };
|
||||
}
|
||||
|
||||
export function scalePos(p) {
|
||||
return { x: p.x * WORLD_SCALE, y: p.y * WORLD_SCALE };
|
||||
}
|
||||