diff --git a/README.md b/README.md index daee8eb..c89d172 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/backgrounds/campaign01_bg.png b/assets/backgrounds/campaign01_bg.png new file mode 100644 index 0000000..5923324 Binary files /dev/null and b/assets/backgrounds/campaign01_bg.png differ diff --git a/assets/backgrounds/campaign02_bg.png b/assets/backgrounds/campaign02_bg.png new file mode 100644 index 0000000..01c594c Binary files /dev/null and b/assets/backgrounds/campaign02_bg.png differ diff --git a/assets/ui/map_campaign_tag.png b/assets/ui/map_campaign_tag.png new file mode 100644 index 0000000..1156a6a Binary files /dev/null and b/assets/ui/map_campaign_tag.png differ diff --git a/assets/ui/map_finish.png b/assets/ui/map_finish.png new file mode 100644 index 0000000..77ef608 Binary files /dev/null and b/assets/ui/map_finish.png differ diff --git a/assets/ui/map_node.png b/assets/ui/map_node.png new file mode 100644 index 0000000..b6bbbba Binary files /dev/null and b/assets/ui/map_node.png differ diff --git a/assets/ui/map_node_current.png b/assets/ui/map_node_current.png new file mode 100644 index 0000000..a5abec5 Binary files /dev/null and b/assets/ui/map_node_current.png differ diff --git a/assets/ui/map_node_locked.png b/assets/ui/map_node_locked.png new file mode 100644 index 0000000..671a000 Binary files /dev/null and b/assets/ui/map_node_locked.png differ diff --git a/sprites.md b/sprites.md index aedfda8..1564256 100644 --- a/sprites.md +++ b/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 `" 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 diff --git a/src/data/levels/index.js b/src/data/levels/index.js index 5af15ed..79ca342 100644 --- a/src/data/levels/index.js +++ b/src/data/levels/index.js @@ -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; diff --git a/src/data/levels/positioning.js b/src/data/levels/positioning.js new file mode 100644 index 0000000..8833f4b --- /dev/null +++ b/src/data/levels/positioning.js @@ -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; +} diff --git a/src/main.js b/src/main.js index 0b5b662..8929db4 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], }); diff --git a/src/scenes/LevelCompleteScene.js b/src/scenes/LevelCompleteScene.js deleted file mode 100644 index c2d6456..0000000 --- a/src/scenes/LevelCompleteScene.js +++ /dev/null @@ -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'); - }); - } -} diff --git a/src/scenes/LevelFailedScene.js b/src/scenes/LevelFailedScene.js index f446208..56446ca 100644 --- a/src/scenes/LevelFailedScene.js +++ b/src/scenes/LevelFailedScene.js @@ -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); + } } diff --git a/src/scenes/LevelScoreScene.js b/src/scenes/LevelScoreScene.js index 6fb3d1a..bd227e8 100644 --- a/src/scenes/LevelScoreScene.js +++ b/src/scenes/LevelScoreScene.js @@ -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); diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 7266c0a..2177941 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -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; + } + } } } diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index 1ec875c..fb46f33 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -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(); diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js index 6baf0d2..f2682eb 100644 --- a/src/util/assetManifest.js +++ b/src/util/assetManifest.js @@ -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 }, ]; diff --git a/src/util/mapArt.js b/src/util/mapArt.js new file mode 100644 index 0000000..58673f8 --- /dev/null +++ b/src/util/mapArt.js @@ -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 }; +}