From a2d267bc49c09dd1637fb88c7b242e4f505c56d7 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 21 Aug 2026 14:43:32 -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. --- src/scenes/LevelSelectScene.js | 64 ++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 2177941..382346b 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -9,6 +9,12 @@ import { nodeDisplaySize, scalePos, BADGE_FILE_FRAC, BADGE_HIT_RADIUS_MULT } fro const NODE_HOVER = 1.12; const NODE_IDLE_PULSE = 1.05; +// Render order, lowest to highest. The per-campaign group (bg -> tags) is +// rebuilt on every campaign switch; the persistent chrome must sit ABOVE it, +// because chrome is created *before* _buildCampaign and Phaser's stable +// same-depth sort would otherwise let the opaque campaign background cover it. +const DEPTH = { bg: 0, path: 1, flag: 2, node: 3, tag: 4, plate: 5, chrome: 6, tooltip: 10 }; + const TITLE_STYLE = { fontFamily: 'monospace', fontSize: `${30 * WORLD_SCALE}px`, @@ -38,17 +44,23 @@ export default class LevelSelectScene extends Phaser.Scene { this._transitioning = false; this._pulseTween = null; - // Chrome (persistent across campaigns). + // Chrome (persistent across campaigns) - see DEPTH for why these need + // explicit depths above the per-campaign group. 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(); + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT) + .setDepth(DEPTH.plate); + this._titleText = this.add + .text(GAME_WIDTH / 2, 82 * WORLD_SCALE, '', TITLE_STYLE) + .setOrigin(0.5) + .setDepth(DEPTH.chrome); + this._pager = this.add.graphics().setDepth(DEPTH.chrome); const back = this.add .text(44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }) - .setInteractive({ useHandCursor: true }); + .setInteractive({ useHandCursor: true }) + .setDepth(DEPTH.chrome); back.on('pointerdown', () => { this.scene.stop('LevelSelect'); this.scene.start('MainMenu'); @@ -56,7 +68,8 @@ export default class LevelSelectScene extends Phaser.Scene { this.add .text(GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE) - .setOrigin(0.5); + .setOrigin(0.5) + .setDepth(DEPTH.chrome); this._buildCampaign(true); @@ -93,11 +106,11 @@ export default class LevelSelectScene extends Phaser.Scene { let current = completed.indexOf(false); if (current === 0) current = campaignUnlocked ? 0 : -1; - const bg = this.add.image(0, 0, campaign.bgKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT); + const bg = this.add.image(0, 0, campaign.bgKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg); this._groups.bg.push(bg); const spline = sampleSpline(points, 18); - const path = this.add.graphics(); + const path = this.add.graphics().setDepth(DEPTH.path); this._traceRibbon(path, spline, 84 * WORLD_SCALE, 0x1a1f29, 1); this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1); this._traceDashes(path, spline); @@ -111,7 +124,7 @@ export default class LevelSelectScene extends Phaser.Scene { .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); + .setDepth(DEPTH.flag); this._groups.path.push(flag); // Nodes + labels. @@ -120,7 +133,7 @@ export default class LevelSelectScene extends Phaser.Scene { 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); + const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(dw, dh).setDepth(DEPTH.node); // The badge is a small centered circle inside a big mostly-transparent // texture frame, so the interactive area must be a circle around the @@ -173,7 +186,7 @@ export default class LevelSelectScene extends Phaser.Scene { 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)); + this._groups.tags.push(this.add.text(p.x, p.y + dh * 0.68, label, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag)); } }); @@ -186,34 +199,47 @@ export default class LevelSelectScene extends Phaser.Scene { [...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); + // Pop in to the resting DISPLAY size - never scale 1: the texture is a + // big mostly-transparent frame with the badge centered (the shipped 2x + // art is 1920x1080), so full scale renders it enormous. Animating + // display sizes keeps this correct for any frame size, like every other + // node tween in this scene. + node.setDisplaySize(dw * 0.5, dh * 0.5).setAlpha(0); this.tweens.add({ targets: node, alpha: 1, - scaleX: 1, - scaleY: 1, + displayWidth: dw, + displayHeight: dh, delay: 80 + i * 70, duration: 260, ease: 'Back.easeOut', }); }); - if (current !== -1) this._startPulse(current); + } + if (current !== -1) { + // Start the idle pulse only once that node's entrance pop has landed so + // it takes over exactly at resting size (immediately for instant builds). + this._startPulse(current, instant ? 0 : 80 + current * 70 + 260); } } - _startPulse(index) { + _startPulse(index, delay = 0) { if (this._pulseTween) this._pulseTween.stop(); const node = this._groups.nodes[index]; if (!node) return; const { width: dw, height: dh } = nodeDisplaySize(); + // Explicit from/to pins the oscillation to the resting size, independent + // of whatever mid-animation scale the node happens to have when this is + // added. this._pulseTween = this.tweens.add({ targets: node, - displayWidth: dw * NODE_IDLE_PULSE, - displayHeight: dh * NODE_IDLE_PULSE, + displayWidth: { from: dw, to: dw * NODE_IDLE_PULSE }, + displayHeight: { from: dh, to: dh * NODE_IDLE_PULSE }, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + delay, }); } @@ -270,7 +296,7 @@ export default class LevelSelectScene extends Phaser.Scene { padding: { x: 12 * WORLD_SCALE, y: 6 * WORLD_SCALE }, }) .setOrigin(0.5) - .setDepth(10) + .setDepth(DEPTH.tooltip) .setAlpha(0); this.tweens.add({ targets: this._tip, alpha: 1, duration: 100 }); }