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.
This commit is contained in:
Brian Fertig 2026-08-21 14:43:32 -06:00
parent 6229f9529b
commit a2d267bc49
1 changed files with 45 additions and 19 deletions

View File

@ -9,6 +9,12 @@ import { nodeDisplaySize, scalePos, BADGE_FILE_FRAC, BADGE_HIT_RADIUS_MULT } fro
const NODE_HOVER = 1.12; const NODE_HOVER = 1.12;
const NODE_IDLE_PULSE = 1.05; 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 = { const TITLE_STYLE = {
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: `${30 * WORLD_SCALE}px`, fontSize: `${30 * WORLD_SCALE}px`,
@ -38,17 +44,23 @@ export default class LevelSelectScene extends Phaser.Scene {
this._transitioning = false; this._transitioning = false;
this._pulseTween = null; 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 this._titlePlate = this.add
.image(GAME_WIDTH / 2, 82 * WORLD_SCALE, 'map_campaign_tag') .image(GAME_WIDTH / 2, 82 * WORLD_SCALE, 'map_campaign_tag')
.setOrigin(0.5) .setOrigin(0.5)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT); .setDisplaySize(GAME_WIDTH, GAME_HEIGHT)
this._titleText = this.add.text(GAME_WIDTH / 2, 82 * WORLD_SCALE, '', TITLE_STYLE).setOrigin(0.5); .setDepth(DEPTH.plate);
this._pager = this.add.graphics(); 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 const back = this.add
.text(44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }) .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', () => { back.on('pointerdown', () => {
this.scene.stop('LevelSelect'); this.scene.stop('LevelSelect');
this.scene.start('MainMenu'); this.scene.start('MainMenu');
@ -56,7 +68,8 @@ export default class LevelSelectScene extends Phaser.Scene {
this.add this.add
.text(GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE) .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); this._buildCampaign(true);
@ -93,11 +106,11 @@ export default class LevelSelectScene extends Phaser.Scene {
let current = completed.indexOf(false); let current = completed.indexOf(false);
if (current === 0) current = campaignUnlocked ? 0 : -1; 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); this._groups.bg.push(bg);
const spline = sampleSpline(points, 18); 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, 84 * WORLD_SCALE, 0x1a1f29, 1);
this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1); this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1);
this._traceDashes(path, spline); 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') .image(last.x + Math.cos(ang) * 95 * WORLD_SCALE, last.y + Math.sin(ang) * 95 * WORLD_SCALE, 'map_finish')
.setOrigin(0.5) .setOrigin(0.5)
.setDisplaySize(GAME_WIDTH * 0.55, GAME_HEIGHT * 0.55) .setDisplaySize(GAME_WIDTH * 0.55, GAME_HEIGHT * 0.55)
.setDepth(1); .setDepth(DEPTH.flag);
this._groups.path.push(flag); this._groups.path.push(flag);
// Nodes + labels. // Nodes + labels.
@ -120,7 +133,7 @@ export default class LevelSelectScene extends Phaser.Scene {
const p = points[i]; const p = points[i];
const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked'; 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 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 // The badge is a small centered circle inside a big mostly-transparent
// texture frame, so the interactive area must be a circle around the // 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!'; if (state === 'done') label = result ? `${result.kidsSaved}/${result.kidsTotal} kids saved` : 'cleared!';
else if (state === 'current') label = level.name; else if (state === 'current') label = level.name;
if (label) { 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._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.tweens.add({ targets: [...this._groups.bg, ...this._groups.path], alpha: 1, duration: 200, ease: 'Quad.easeOut' });
this._groups.nodes.forEach((node, i) => { 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({ this.tweens.add({
targets: node, targets: node,
alpha: 1, alpha: 1,
scaleX: 1, displayWidth: dw,
scaleY: 1, displayHeight: dh,
delay: 80 + i * 70, delay: 80 + i * 70,
duration: 260, duration: 260,
ease: 'Back.easeOut', 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(); if (this._pulseTween) this._pulseTween.stop();
const node = this._groups.nodes[index]; const node = this._groups.nodes[index];
if (!node) return; if (!node) return;
const { width: dw, height: dh } = nodeDisplaySize(); 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({ this._pulseTween = this.tweens.add({
targets: node, targets: node,
displayWidth: dw * NODE_IDLE_PULSE, displayWidth: { from: dw, to: dw * NODE_IDLE_PULSE },
displayHeight: dh * NODE_IDLE_PULSE, displayHeight: { from: dh, to: dh * NODE_IDLE_PULSE },
duration: 900, duration: 900,
yoyo: true, yoyo: true,
repeat: -1, repeat: -1,
ease: 'Sine.easeInOut', ease: 'Sine.easeInOut',
delay,
}); });
} }
@ -270,7 +296,7 @@ export default class LevelSelectScene extends Phaser.Scene {
padding: { x: 12 * WORLD_SCALE, y: 6 * WORLD_SCALE }, padding: { x: 12 * WORLD_SCALE, y: 6 * WORLD_SCALE },
}) })
.setOrigin(0.5) .setOrigin(0.5)
.setDepth(10) .setDepth(DEPTH.tooltip)
.setAlpha(0); .setAlpha(0);
this.tweens.add({ targets: this._tip, alpha: 1, duration: 100 }); this.tweens.add({ targets: this._tip, alpha: 1, duration: 100 });
} }