import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { CAMPAIGNS } from '../data/levels/index.js'; import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js'; import { isLevelComplete, getBestScore } from '../util/progress.js'; import { playMenuTrack } from '../util/music.js'; import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js'; import { createPill, pillInteractive } from '../util/pill.js'; 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`, color: '#1a1f29', fontStyle: 'bold', }; const PILL_STYLE = { fontFamily: 'monospace', // 13 (not 14) so the level-name tag under the current node stays compact // even for campaigns with many levels; the best-score pills use their own // SCORE_PILL_STYLE below. fontSize: `${13 * WORLD_SCALE}px`, color: '#1a1f29', }; // Labels that share PILL_STYLE are pills (see util/pill.js) - createPill // supplies the rounded white backing + black stroke and the guaranteed // text/edge padding; only the font side lives here. // The "best score" pill that rolls up under cleared nodes - gold, to match // the gold score numerals in the score tally scene. const SCORE_PILL_STYLE = { fontFamily: 'monospace', fontSize: `${14 * WORLD_SCALE}px`, fontStyle: 'bold', color: '#1a1f29', backgroundColor: '#f2c14e', stroke: '#ffffff', strokeThickness: 2 * WORLD_SCALE, padding: { x: 10 * WORLD_SCALE, y: 5 * WORLD_SCALE }, }; export default class LevelSelectScene extends Phaser.Scene { constructor() { super('LevelSelect'); } create() { this.cameras.main.setBackgroundColor('#8fc7e8'); // Menu music (main title or the campaign's own theme) is started by // _buildCampaign -> playMenuTrack, which knows which campaign is being // shown; it handles first-time start, campaign-to-campaign swaps, and // re-entry from the main menu alike. this.campaignIndex = 0; this._groups = { bg: [], path: [], nodes: [], tags: [] }; this._transitioning = false; this._pulseTween = null; // 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) .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 = createPill(this, 44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }, 0, 0); pillInteractive(back, { useHandCursor: true }); back.setDepth(DEPTH.chrome); back.on('pointerdown', () => { this.scene.stop('LevelSelect'); this.scene.start('MainMenu'); }); createPill(this, GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE) .setDepth(DEPTH.chrome); 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'); }); } shutdown() { if (this._pulseTween) this._pulseTween.stop(); } // ------------------------------------------------------------------ build _buildCampaign(instant = false) { for (const key of Object.keys(this._groups)) { for (const obj of this._groups[key]) obj.destroy(); this._groups[key] = []; } const campaign = CAMPAIGNS[this.campaignIndex]; const levels = campaign.levels; // Menu music follows the campaign: this campaign's theme (musicKey) or // the main title for campaigns without one. Handles first entry, campaign // switches (from _shiftCampaign), and re-entry after a level (activeKey // was cleared by stopMenuMusic, so the track restarts from the top). playMenuTrack(this, campaign.musicKey); 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 bg = this.add.image(0, 0, campaign.bgKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg); this._groups.bg.push(bg); if (points.length >= 2) { const spline = sampleSpline(points, 18); 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); this._groups.path.push(path); } // Finish flag just past the last node, nudged along the path direction. // (The auto-layout circuit ends at the bottom-right, so this nudge lands // in the corner; if an explicit `positions` layout ends at an edge and the // nudge would push the flag off-screen, flip it so it stays on.) if (points.length) { 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 nudge = 95 * WORLD_SCALE; let fx = last.x + Math.cos(ang) * nudge; let fy = last.y + Math.sin(ang) * nudge; const inBounds = (x, y) => x > 60 * WORLD_SCALE && x < GAME_WIDTH - 60 * WORLD_SCALE && y > 160 * WORLD_SCALE && y < GAME_HEIGHT - 60 * WORLD_SCALE; if (!inBounds(fx, fy)) { fx = last.x - Math.cos(ang) * nudge; fy = last.y - Math.sin(ang) * nudge; } const flag = this.add .image(fx, fy, 'map_finish') .setOrigin(0.5) .setDisplaySize(GAME_WIDTH * 0.18, GAME_HEIGHT * 0.18) .setDepth(DEPTH.flag); this._groups.path.push(flag); } // Nodes + labels. All node textures are 512x512 badges; per-key display // sizes come from mapArt.js (map_node_current displays larger than the // others). 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 { width: nw, height: nh } = nodeDisplaySize(key); const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(nw, nh).setDepth(DEPTH.node); // The badge fills its texture file, so the interactive area is a // circle at the badge's full diameter (BADGE_HIT_FRAC = 1.0, + the // BADGE_HIT_RADIUS_MULT allowance), not the whole texture frame. // // 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 center of 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_HIT_FRAC[key]) / 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 - nh * 0.75, i === 0 ? 'Clear the previous campaign to unlock' : 'Finish the previous level to unlock' ) ) .on('pointerout', () => this._hideTooltip()); } else { // Hover name tag for CLEARED nodes: a small level-name pill that fades // in above the badge while the pointer is over it (the 'current' // node already wears its name pill permanently, so no tag there). // Created before setInteractive so it sits ahead of the node in the // display list; depth DEPTH.tooltip (10) puts it over node and tag. const nameTag = state === 'done' ? createPill(this, p.x, p.y, level.name, PILL_STYLE).setDepth(DEPTH.tooltip).setAlpha(0) : null; if (nameTag) this._groups.tags.push(nameTag); // rebuilt/destroyed with the campaign group node .setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest }) .on('pointerover', () => { this._tweenNodeSize(node, nw * NODE_HOVER, nh * NODE_HOVER); if (nameTag) this._showNameTag(nameTag, p.x, p.y - nh * 0.78); }) .on('pointerout', () => { this._tweenNodeSize(node, nw, nh); if (nameTag) this._hideNameTag(nameTag); }) .on('pointerdown', () => { this._tweenNodeSize(node, nw * 0.92, nh * 0.92, 70); this.time.delayedCall(80, () => { this.scene.stop('LevelSelect'); this.scene.start('Play', { levelId: level.id }); }); }); } this._groups.nodes.push(node); if (state === 'done') { const best = getBestScore(level.id); if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70); } else if (state === 'current') { this._groups.tags.push(createPill(this, p.x, p.y + nh * 0.68, level.name, PILL_STYLE).setDepth(DEPTH.tag)); } }); 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) => { // Pop in to the resting DISPLAY size - never scale 1 (see the per-key // sizes in mapArt.js); animating display sizes keeps this correct for // any texture frame size, like every other node tween in this scene. const { width: dw, height: dh } = nodeDisplaySize(node.texture.key); node.setDisplaySize(dw * 0.5, dh * 0.5).setAlpha(0); this.tweens.add({ targets: node, alpha: 1, displayWidth: dw, displayHeight: dh, delay: 80 + i * 70, duration: 260, ease: 'Back.easeOut', }); }); } 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); } } // Cleared nodes wear a small gold "best score" pill under the badge (gold // matches the gold score numerals in the tally scene). On entrance the // number rolls up from 0 to the player's best - an odometer that reads as // the map remembering the run. Duration scales with the score's magnitude // (capped) so big scores get the longer, more satisfying count. _scoreRollup(p, nh, best, delay) { const pill = this.add .text(p.x, p.y + nh * 0.68, '★ 0', SCORE_PILL_STYLE) .setOrigin(0.5) .setDepth(DEPTH.tag) .setAlpha(0) .setScale(0.4); this._groups.tags.push(pill); this.tweens.add({ targets: pill, alpha: 1, scale: 1, delay, duration: 220, ease: 'Back.easeOut', onComplete: () => { const counter = { v: 0 }; this.tweens.add({ targets: counter, v: best, duration: Math.min(1500, 500 + Math.sqrt(best) * 40), ease: 'Cubic.easeOut', onUpdate: () => { // The pill is destroyed by _buildCampaign when the campaign // switches, but this tween targets a plain object (the counter) // and outlives the pill - guard the setText (crashes on the // destroyed Text's nulled texture frame). if (pill.isDestroyed) return; pill.setText(`★ ${Math.round(counter.v).toLocaleString()}`); }, }); }, }); } _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(node.texture.key); // 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: { 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, }); } // ----------------------------------------------------------------- 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]; // `all` is never empty (the background is always built), so onComplete is // guaranteed to fire - empty campaigns transition fine through this path. 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]; if (!campaign.levels.length) return; // campaign with no levels yet - nothing to play // 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(); // Tooltip is dark, not one of the white pills (util/pill.js is // white-fill-only), so it keeps its own plain backgroundColor. 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(DEPTH.tooltip) .setAlpha(0); this.tweens.add({ targets: this._tip, alpha: 1, duration: 100 }); } _hideTooltip() { if (this._tip) { this._tip.destroy(); this._tip = null; } } // Hover name tag for cleared nodes: fade in (a little pop) while hovered, // fade out when the pointer leaves. Non-blocking - the click's // pointerdown/scene transition below are never gated on this tween, so an // overlapping node's pointerover can't interrupt a still-running fade-in. _showNameTag(tag, x, y) { tag.setPosition(x, y); tag.setAlpha(0); this.tweens.killTweensOf(tag); this.tweens.add({ targets: tag, alpha: 1, duration: 100, ease: 'Quad.easeOut' }); } _hideNameTag(tag) { if (!tag.active) return; this.tweens.killTweensOf(tag); this.tweens.add({ targets: tag, alpha: 0, duration: 100, ease: 'Quad.easeIn' }); } _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; } } } }