// Tetris Attack — front-end screens: mode menu, puzzle select, the Stage Clear // intro cutscene, results overlays, and the character host portrait. These build // into scene.overlayObjs (cleared via scene.clearOverlay()) and delegate all // actions back to methods on the scene. Kept as a one-way dependency (this file // never imports the gameplay scene) so there's no import cycle. import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { playSound, SFX } from '../../ui/Sounds.js'; export const FONT = 'm6x11'; const OVL = 60, OVL_UI = 62; // Character↔portrait fallback: opponents.png frame per round (see sprites.md). const POSE_OFFSET = { neutral: 0, happy: 1, worried: 2 }; // Character dialog is authored as an array per friend (data/tetrisattack.json → // stageClear.rounds) so they say something different each visit. A bare string // still works, and an empty/missing entry falls back to `fallback`. export function pickLine(value, fallback = '') { if (Array.isArray(value)) return value.length ? value[Math.floor(Math.random() * value.length)] : fallback; return value || fallback; } function dim(scene, alpha = 0.72) { const r = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x05070f, alpha).setDepth(OVL); scene.overlayObjs.push(r); return r; } // Title art behind the front-end screens (assets/images/tetrisattack/main-menu.png, // declared in data/assetManifest.js). Falls back to the plain dim if it's absent. // The veil on top is light — just enough for the menu text to stay readable. function menuBackdrop(scene, veilAlpha = 0.35) { if (!scene.textures.exists('tetrisattack-menu-bg')) return dim(scene, 0.9); const img = scene.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'tetrisattack-menu-bg') .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(OVL - 1); scene.overlayObjs.push(img); return dim(scene, veilAlpha); } function textButton(scene, x, y, label, action, opts = {}) { const w = opts.width ?? 300, h = opts.height ?? 66; const bg = scene.add.rectangle(x, y, w, h, 0x243458, 1).setStrokeStyle(3, 0x5a7cbf).setDepth(OVL_UI); const t = scene.add.text(x, y, label, { fontFamily: FONT, fontSize: `${opts.fontSize ?? 30}px`, color: '#e6eeff' }).setOrigin(0.5).setDepth(OVL_UI); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x304670)); bg.on('pointerout', () => bg.setFillStyle(0x243458)); bg.on('pointerdown', () => { playSound(scene, SFX.UI_PICK); action(); }); scene.overlayObjs.push(bg, t); return { bg, t }; } // ── Mode menu ──────────────────────────────────────────────────────────────── export function showMenu(scene) { scene.clearOverlay(); // The title art carries the name — no lettering drawn over it. menuBackdrop(scene, 0.28); const cx = GAME_WIDTH / 2; const modes = [ { key: 'endless', name: scene.config?.endless?.name ?? 'Endless', desc: scene.config?.endless?.description ?? '', action: () => scene.startEndless() }, { key: 'stageclear', name: scene.config?.stageClear?.name ?? 'Stage Clear', desc: scene.config?.stageClear?.description ?? '', action: () => scene.startStageClear() }, { key: 'puzzle', name: scene.config?.puzzle?.name ?? 'Puzzle', desc: scene.config?.puzzle?.description ?? '', action: () => scene.startPuzzleMode() }, ]; const cardW = 460, cardH = 200, gap = 40; const totalW = cardW * 3 + gap * 2; let x = cx - totalW / 2 + cardW / 2; for (const m of modes) { const y = 500; const bg = scene.add.rectangle(x, y, cardW, cardH, 0x1a2440, 1).setStrokeStyle(4, 0x4a6ea8).setDepth(OVL_UI); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setStrokeStyle(4, 0xffe66e)); bg.on('pointerout', () => bg.setStrokeStyle(4, 0x4a6ea8)); bg.on('pointerdown', () => { playSound(scene, SFX.UI_ACTIVATE); m.action(); }); scene.overlayObjs.push(bg); scene.overlayObjs.push(scene.add.text(x, y - 60, m.name.toUpperCase(), { fontFamily: FONT, fontSize: '40px', color: '#ffe66e' }).setOrigin(0.5).setDepth(OVL_UI)); scene.overlayObjs.push(scene.add.text(x, y + 20, m.desc, { fontFamily: FONT, fontSize: '22px', color: '#c3d2ea', align: 'center', wordWrap: { width: cardW - 50 }, }).setOrigin(0.5).setDepth(OVL_UI)); x += cardW + gap; } textButton(scene, cx, GAME_HEIGHT - 110, 'LEAVE', () => scene.scene.start('GameMenu'), { width: 260 }); } // ── Puzzle select ──────────────────────────────────────────────────────────── export function showPuzzleSelect(scene) { scene.clearOverlay(); scene.teardownGameplay(); dim(scene, 0.9); const cx = GAME_WIDTH / 2; scene.overlayObjs.push(scene.add.text(cx, 110, 'PUZZLE MODE', { fontFamily: FONT, fontSize: '72px', color: '#ffe66e', }).setOrigin(0.5).setDepth(OVL_UI)); scene.overlayObjs.push(scene.add.text(cx, 180, 'Clear every panel in the fewest swaps', { fontFamily: FONT, fontSize: '26px', color: '#7de6ff', }).setOrigin(0.5).setDepth(OVL_UI)); const puzzles = scene.puzzles ?? []; const cols = 8; const size = 120, gap = 22; const startX = cx - ((Math.min(cols, puzzles.length) * (size + gap) - gap)) / 2 + size / 2; const startY = 300; const solvedKey = 'tetrisattack-puzzle-solved'; const solved = new Set((localStorage.getItem(solvedKey) ?? '').split(',').filter(Boolean)); puzzles.forEach((p, i) => { const r = Math.floor(i / cols), c = i % cols; const x = startX + c * (size + gap); const y = startY + r * (size + gap); const done = solved.has(String(p.id)); const bg = scene.add.rectangle(x, y, size, size, done ? 0x24503a : 0x243458, 1) .setStrokeStyle(3, done ? 0x5ecf8e : 0x5a7cbf).setDepth(OVL_UI); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setStrokeStyle(3, 0xffe66e)); bg.on('pointerout', () => bg.setStrokeStyle(3, done ? 0x5ecf8e : 0x5a7cbf)); bg.on('pointerdown', () => { playSound(scene, SFX.UI_PICK); scene.beginStagePuzzleFromSelect(i); }); scene.overlayObjs.push(bg); scene.overlayObjs.push(scene.add.text(x, y - 16, String(i + 1), { fontFamily: FONT, fontSize: '40px', color: '#ffffff' }).setOrigin(0.5).setDepth(OVL_UI)); scene.overlayObjs.push(scene.add.text(x, y + 30, `${p.maxMoves} swaps`, { fontFamily: FONT, fontSize: '18px', color: '#c3d2ea' }).setOrigin(0.5).setDepth(OVL_UI)); }); textButton(scene, cx, GAME_HEIGHT - 100, 'BACK', () => scene.showMenu(), { width: 240 }); } // ── Stage Clear round select ───────────────────────────────────────────────── // The SNES "NEXT STAGE" overview: one card per character, each showing their // five stage squares (filled once cleared) and the round's difficulty level. // Characters unlock in order — you may restart any round you have reached, but // always from its first stage. export function showStageSelect(scene) { scene.clearOverlay(); scene.teardownGameplay(); menuBackdrop(scene, 0.62); const cx = GAME_WIDTH / 2; const rounds = scene.rounds; const perRound = scene.stagesPerRound; const progress = scene.loadStageProgress(); scene.overlayObjs.push(scene.add.text(cx, 110, 'STAGE CLEAR', { fontFamily: FONT, fontSize: '72px', color: '#ffe66e', }).setOrigin(0.5).setDepth(OVL_UI)); scene.overlayObjs.push(scene.add.text(cx, 180, 'Pick a friend to help — six rounds of five stages', { fontFamily: FONT, fontSize: '26px', color: '#7de6ff', }).setOrigin(0.5).setDepth(OVL_UI)); const cardW = 700, cardH = 200, gapX = 60, gapY = 26; const startX = cx - (cardW + gapX / 2) + cardW / 2; const startY = 330; rounds.forEach((round, i) => { const x = startX + (i % 2) * (cardW + gapX); const y = startY + Math.floor(i / 2) * (cardH + gapY); const unlocked = scene.isRoundUnlocked(i); const done = progress[round.characterId] ?? 0; const bg = scene.add.rectangle(x, y, cardW, cardH, unlocked ? 0x1a2440 : 0x12161f, 1) .setStrokeStyle(4, unlocked ? 0x9a5ee8 : 0x2e3644).setDepth(OVL_UI); scene.overlayObjs.push(bg); // portrait thumb on the left of the card const px = x - cardW / 2 + 110; const frame = scene.add.rectangle(px, y, 170, 170, 0x0e1526, 1) .setStrokeStyle(3, unlocked ? 0x4a6ea8 : 0x2e3644).setDepth(OVL_UI); scene.overlayObjs.push(frame); const img = buildPortraitImage(scene, round, i, 'neutral', px, y, 0.45); img.setDepth(OVL_UI + 1); if (!unlocked) img.setTint(0x333a48); scene.overlayObjs.push(img); const tx = px + 300; scene.overlayObjs.push(scene.add.text(tx, y - 66, `ROUND ${i + 1}`, { fontFamily: FONT, fontSize: '38px', color: unlocked ? '#7de6ff' : '#4d5768', }).setOrigin(0.5).setDepth(OVL_UI)); // the five stage squares, filled for stages already cleared const sq = 34, sgap = 14; let sx = tx - ((perRound * (sq + sgap) - sgap) / 2) + sq / 2; for (let n = 1; n <= perRound; n++) { const cleared = done >= n; scene.overlayObjs.push(scene.add.rectangle(sx, y + 4, sq, sq, cleared ? 0xffe66e : 0x0e1526, 1) .setStrokeStyle(3, unlocked ? 0x9fb0c8 : 0x39414f).setDepth(OVL_UI)); scene.overlayObjs.push(scene.add.text(sx, y - 30, String(n), { fontFamily: FONT, fontSize: '20px', color: unlocked ? '#9fb0c8' : '#4d5768', }).setOrigin(0.5).setDepth(OVL_UI)); sx += sq + sgap; } scene.overlayObjs.push(scene.add.text(tx, y + 62, unlocked ? `LEVEL - ${round.speedLevel}` : 'LOCKED', { fontFamily: FONT, fontSize: '30px', color: unlocked ? '#e07be0' : '#4d5768', }).setOrigin(0.5).setDepth(OVL_UI)); if (unlocked) { bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setStrokeStyle(4, 0xffe66e)); bg.on('pointerout', () => bg.setStrokeStyle(4, 0x9a5ee8)); bg.on('pointerdown', () => { playSound(scene, SFX.UI_ACTIVATE); scene.beginRound(i); }); } }); textButton(scene, cx, GAME_HEIGHT - 60, 'BACK', () => scene.showMenu(), { width: 240, height: 58 }); } // ── Stage Clear intro cutscene ──────────────────────────────────────────────── export function showStageIntro(scene, round, index, onContinue) { scene.clearOverlay(); scene.teardownGameplay(); const veil = dim(scene, 0.92); const cx = GAME_WIDTH / 2; const total = (scene.config?.stageClear?.rounds ?? []).length; // Backdrop: one of this friend's own stage backgrounds, picked at random. The // art loads lazily (see scene.ensureRoundAssets) so it may still be in // flight when the cutscene opens — scene.onBackgroundsLoaded re-tries then. let bgImage = null; const applyBg = () => { if (bgImage) return; const key = scene.introBackgroundKey?.(round); if (!key) return; bgImage = scene.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key).setDepth(OVL - 1); scene.overlayObjs.push(bgImage); veil.setAlpha(0.5); // lift the dim so the art reads behind the cutscene }; applyBg(); scene.onBackgroundsLoaded = applyBg; scene.overlayObjs.push(scene.add.text(cx, 120, `ROUND ${index + 1} OF ${total}`, { fontFamily: FONT, fontSize: '40px', color: '#7de6ff', }).setOrigin(0.5).setDepth(OVL_UI)); scene.overlayObjs.push(scene.add.text(cx, 172, `${scene.stagesPerRound} STAGES`, { fontFamily: FONT, fontSize: '28px', color: '#9fb0c8', }).setOrigin(0.5).setDepth(OVL_UI)); // portrait slides in from the left const portrait = buildPortraitImage(scene, round, index, 'neutral', 480, GAME_HEIGHT / 2 + 40, 2.0); portrait.setDepth(OVL_UI); portrait.x = -300; scene.overlayObjs.push(portrait); scene.tweens.add({ targets: portrait, x: 480, duration: 650, ease: 'Back.easeOut' }); scene.overlayObjs.push(scene.add.text(480, GAME_HEIGHT / 2 + 300, round.name.toUpperCase(), { fontFamily: FONT, fontSize: '64px', color: '#ffe66e', stroke: '#3a2a00', strokeThickness: 6, }).setOrigin(0.5).setDepth(OVL_UI)); // speech bubble const bx = 1130, by = GAME_HEIGHT / 2 - 20; const bubble = scene.add.graphics().setDepth(OVL_UI); bubble.fillStyle(0xffffff, 1); bubble.lineStyle(5, 0x2a3a5a, 1); bubble.fillRoundedRect(bx - 380, by - 150, 760, 300, 24); bubble.strokeRoundedRect(bx - 380, by - 150, 760, 300, 24); bubble.fillTriangle(bx - 380, by - 20, bx - 440, by + 10, bx - 380, by + 40); bubble.setAlpha(0); scene.overlayObjs.push(bubble); // The friend says every one of their introLines in order, one per keypress; // the last one starts the stage. (A bare string still works as a one-liner.) const script = introScript(round); let lineIndex = 0; const line = scene.add.text(bx, by, `"${script[0]}"`, { fontFamily: FONT, fontSize: '34px', color: '#1a2036', align: 'center', wordWrap: { width: 680 }, }).setOrigin(0.5).setDepth(OVL_UI).setAlpha(0); scene.overlayObjs.push(line); scene.tweens.add({ targets: [bubble, line], alpha: 1, delay: 500, duration: 400 }); // one dot per remaining line, so the player can see the conversation's length const dots = script.length > 1 ? scene.add.graphics().setDepth(OVL_UI) : null; if (dots) scene.overlayObjs.push(dots); const drawDots = () => { if (!dots) return; const gap = 30, y = by + 186; let dx = bx - ((script.length - 1) * gap) / 2; dots.clear(); for (let i = 0; i < script.length; i++) { dots.fillStyle(0xffffff, i === lineIndex ? 1 : 0.3); dots.fillCircle(dx, y, i === lineIndex ? 8 : 6); dx += gap; } }; drawDots(); const promptLabel = () => (lineIndex < script.length - 1 ? 'PRESS ANY KEY OR CLICK TO CONTINUE' : 'PRESS ANY KEY OR CLICK TO HELP'); const prompt = scene.add.text(cx, GAME_HEIGHT - 90, promptLabel(), { fontFamily: FONT, fontSize: '30px', color: '#ffffff', }).setOrigin(0.5).setDepth(OVL_UI); scene.overlayObjs.push(prompt); scene.tweens.add({ targets: prompt, alpha: 0.25, duration: 600, yoyo: true, repeat: -1 }); let done = false; // input gate: each line has to settle before the next press counts, so one // eager keypress can't blow through the whole conversation let ready = false; const arm = (ms) => { ready = false; scene.time.delayedCall(ms, () => { ready = true; }); }; const finish = () => { if (done) return; done = true; scene.onBackgroundsLoaded = null; scene.input.keyboard.off('keydown', advance); scene.input.off('pointerdown', advance); playSound(scene, SFX.UI_ACTIVATE); scene.clearOverlay(); onContinue(); }; const advance = () => { if (done || !ready) return; if (lineIndex >= script.length - 1) { finish(); return; } lineIndex++; playSound(scene, SFX.UI_PICK); // blink the bubble text over to the next line so it reads as a new beat scene.tweens.add({ targets: line, alpha: 0, duration: 110, onComplete: () => { line.setText(`"${script[lineIndex]}"`); scene.tweens.add({ targets: line, alpha: 1, duration: 160 }); }, }); drawDots(); prompt.setText(promptLabel()); arm(260); }; scene.input.keyboard.on('keydown', advance); scene.input.on('pointerdown', advance); // small delay so the first line reads before an accidental keypress skips it arm(350); } // A friend's intro dialog, normalised to a non-empty array of lines in order. function introScript(round) { const raw = round.introLines ?? round.intro; const list = (Array.isArray(raw) ? raw : [raw]).filter((l) => typeof l === 'string' && l.trim()); return list.length ? list : ['Glad you made it — help me clear these!']; } // ── Result overlay ──────────────────────────────────────────────────────────── export function showResult(scene, { title, lines = [], buttons = [], round = null }) { scene.clearOverlay(); dim(scene, 0.78); const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const panel = scene.add.rectangle(cx, cy, 900, 560, 0x141c30, 0.98).setStrokeStyle(5, 0x5a7cbf).setDepth(OVL_UI); scene.overlayObjs.push(panel); scene.overlayObjs.push(scene.add.text(cx, cy - 200, title, { fontFamily: FONT, fontSize: '58px', color: '#ffe66e', align: 'center', wordWrap: { width: 820 }, }).setOrigin(0.5).setDepth(OVL_UI)); if (round) { const p = buildPortraitImage(scene, round, scene.roundIndex, scene._resultPose ?? 'neutral', cx, cy - 60, 0.85); p.setDepth(OVL_UI); scene.overlayObjs.push(p); } const firstLineY = round ? cy + 90 : cy - 70; lines.forEach((l, i) => { scene.overlayObjs.push(scene.add.text(cx, firstLineY + i * 48, l, { fontFamily: FONT, fontSize: '32px', color: '#dbe6ff', align: 'center', wordWrap: { width: 800 }, }).setOrigin(0.5).setDepth(OVL_UI)); }); const bw = 300, gap = 40; const totalW = buttons.length * bw + (buttons.length - 1) * gap; let bx = cx - totalW / 2 + bw / 2; for (const b of buttons) { textButton(scene, bx, cy + 210, b.label, () => { scene.clearOverlay(); b.action(); }, { width: bw }); bx += bw + gap; } } // ── Host portrait (in-game HUD) ─────────────────────────────────────────────── export function createHostPortrait(scene, x, y, round, index = scene.roundIndex) { // half-transparent fill so the stage background reads through the portrait box const frameBg = scene.add.rectangle(x, y, 300, 380, 0x0e1526, 0.5).setStrokeStyle(5, 0x4a6ea8).setDepth(20); const img = buildPortraitImage(scene, round, index, 'neutral', x, y, 1.0); img.setDepth(21); const apply = (pose) => { if (scene.textures.exists('tetrisattack-characters')) { img.setFrame(index * 3 + (POSE_OFFSET[pose] ?? 0)); } else { // opponents fallback has no poses — pulse instead const tint = pose === 'happy' ? 0xd0ffe0 : pose === 'worried' ? 0xffd0d0 : 0xffffff; img.setTint(tint); scene.tweens.add({ targets: img, scale: img.scaleX * 1.06, duration: 120, yoyo: true }); } }; // basePose is the pose the host rests in (driven by the danger flag); a held // pose is a momentary reaction that reverts to it once holdMs elapses. let basePose = 'neutral'; let holdTimer = null; const clearHold = () => { if (holdTimer) { holdTimer.remove(false); holdTimer = null; } }; return { sprite: img, setPose(pose, holdMs = 0) { clearHold(); if (holdMs > 0) { apply(pose); holdTimer = scene.time.delayedCall(holdMs, () => { holdTimer = null; apply(basePose); }); } else { basePose = pose; apply(pose); } }, destroy() { clearHold(); frameBg.destroy(); img.destroy(); }, }; } // Build a portrait image: character spritesheet frame if painted, else the // opponent's round portrait from the shared opponents.png sheet. function buildPortraitImage(scene, round, index, pose, x, y, scaleBoost = 1) { let img; if (scene.textures.exists('tetrisattack-characters')) { img = scene.add.image(x, y, 'tetrisattack-characters', index * 3 + (POSE_OFFSET[pose] ?? 0)); const s = Math.min(280 / img.width, 360 / img.height) * scaleBoost; img.setScale(s); } else { // opponents.png is 300×300 frames; crop to a portrait-ish square img = scene.add.image(x, y, 'opponents', round.spriteIndex ?? 0); const s = (280 / 300) * scaleBoost; img.setScale(s); } return img; }