421 lines
19 KiB
JavaScript
421 lines
19 KiB
JavaScript
// 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;
|
||
}
|