From 6229f9529b0c465f5c8bdea53628f259945b4849 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 21 Aug 2026 12:41:49 -0600 Subject: [PATCH] Refactor level select into campaign map with auto-layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- assets/backgrounds/campaign01_bg.png | Bin 0 -> 22627 bytes assets/backgrounds/campaign02_bg.png | Bin 0 -> 13947 bytes assets/ui/map_campaign_tag.png | Bin 0 -> 10228 bytes assets/ui/map_finish.png | Bin 0 -> 11678 bytes assets/ui/map_node.png | Bin 0 -> 16972 bytes assets/ui/map_node_current.png | Bin 0 -> 14889 bytes assets/ui/map_node_locked.png | Bin 0 -> 15108 bytes sprites.md | 44 +++ src/data/levels/index.js | 37 ++- src/data/levels/positioning.js | 57 ++++ src/main.js | 2 - src/scenes/LevelCompleteScene.js | 61 ----- src/scenes/LevelFailedScene.js | 14 +- src/scenes/LevelScoreScene.js | 10 +- src/scenes/LevelSelectScene.js | 385 +++++++++++++++++++++++---- src/scenes/PlayScene.js | 4 +- src/util/assetManifest.js | 14 +- src/util/mapArt.js | 44 +++ 19 files changed, 543 insertions(+), 131 deletions(-) create mode 100644 assets/backgrounds/campaign01_bg.png create mode 100644 assets/backgrounds/campaign02_bg.png create mode 100644 assets/ui/map_campaign_tag.png create mode 100644 assets/ui/map_finish.png create mode 100644 assets/ui/map_node.png create mode 100644 assets/ui/map_node_current.png create mode 100644 assets/ui/map_node_locked.png create mode 100644 src/data/levels/positioning.js delete mode 100644 src/scenes/LevelCompleteScene.js create mode 100644 src/util/mapArt.js 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 0000000000000000000000000000000000000000..5923324fc584e6d212eadc6811db07d3430b413d GIT binary patch literal 22627 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 OY@2V|Zjxcd4EkSfwe$i2 literal 0 HcmV?d00001 diff --git a/assets/backgrounds/campaign02_bg.png b/assets/backgrounds/campaign02_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..01c594cf3ea85a482a2aa50fde651c9f36b22d86 GIT binary patch literal 13947 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 literal 0 HcmV?d00001 diff --git a/assets/ui/map_finish.png b/assets/ui/map_finish.png new file mode 100644 index 0000000000000000000000000000000000000000..77ef608e97bc2a88d1760836a9a3cfae9877501a GIT binary patch literal 11678 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 VWJ<$4_>bUl$!BS2QD9CD{Xa$kw{`#k literal 0 HcmV?d00001 diff --git a/assets/ui/map_node_locked.png b/assets/ui/map_node_locked.png new file mode 100644 index 0000000000000000000000000000000000000000..671a000c2fe4d3739df73b6e0cb1a2df0f6d0b7a GIT binary patch literal 15108 zcmeHtdpOkF_y0RdL#lBrI�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 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 }; +}