fertig-classic-games/src/games/tetrisattack/TetrisAttackScreens.js

421 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}