fertig-classic-games/public/src/games/blockfighter/BlockFighterGame.js

819 lines
34 KiB
JavaScript

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import {
WIDTH, VISIBLE_ROWS, HIDDEN_ROWS, KIND, SPEED_GRAVITY_MS,
createMatch, spawnPiece, stepDown, hardDrop, moveLeft, moveRight,
rotateCW, rotateCCW, getGhostCells, pieceCells, peekPiece,
} from './BlockFighterLogic.js';
import { createAI, planPlacement, nextAction } from './BlockFighterAI.js';
const CELL = 52;
const BOARD_W = CELL * WIDTH; // 312
const BOARD_H = CELL * VISIBLE_ROWS; // 624
const BOARD_TOP = 240;
const BOARD_LEFT = [360, GAME_WIDTH - 360 - BOARD_W]; // P1 left, AI right
const FELT = 0x101626;
const FRAME = 0x0a1020;
const CELLBG = 0x182238;
const GRIDLN = 0x243352;
const GEM_COLORS = [0xe04444, 0x2ecc71, 0x3f8efc, 0xf1c40f];
const GEM_HEX = ['#e04444', '#2ecc71', '#3f8efc', '#f1c40f'];
const D = { felt: -2, frame: -1, grid: 0, cells: 5, piece: 8, fx: 12, ui: 30, overlay: 60, overlayUI: 62 };
const REPLAY_DELAY = { place: 70, settle: 150, clear: 230, diamond: 260, counter: 140, garbage: 200, attack: 60, spawn: 0, lose: 0 };
export default class BlockFighterGame extends Phaser.Scene {
constructor() { super('BlockFighterGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'blockfighter', name: 'Block Fighter' };
this.bank = [];
this.roster = [];
this.levelsCompleted = 0;
this.canPersist = true;
this.view = 'select';
this.portraits = []; // active Portrait handles (DOM-backed, not in layer)
this.match = null;
this.overlayUp = false;
}
async create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
const raw = this.cache.json.get('blockfighter');
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
try {
const res = await fetch('/data/opponents.json');
const json = await res.json();
this.roster = json.opponents ?? [];
} catch (_) { this.roster = []; }
try {
const res = await api.get('/puzzles/blockfighter/progress');
this.levelsCompleted = res?.levelsCompleted ?? 0;
} catch (_) {
this.canPersist = false;
this.levelsCompleted = 0;
}
this.makeTextures();
this.layer = this.add.container(0, 0);
this.showLevelSelect();
}
opponentFor(levelDef) {
const opp = this.roster.find((o) => o.id === levelDef.opponentId);
if (opp) return opp;
console.warn(`blockfighter: opponent '${levelDef.opponentId}' not in roster; using stub`);
return { id: levelDef.opponentId, spriteIndex: 0, name: levelDef.opponentId, bio: '', speech: {} };
}
// ── Generated gem textures ──────────────────────────────────────────────────
makeTextures() {
if (this.textures.exists('bf-gem-0')) return;
for (let c = 0; c < 4; c++) {
const color = GEM_COLORS[c];
let g = this.make.graphics({ add: false });
g.fillStyle(color, 1);
g.fillRoundedRect(2, 2, CELL - 4, CELL - 4, 10);
g.fillStyle(0xffffff, 0.32);
g.fillRoundedRect(7, 6, CELL - 14, 14, 6);
g.lineStyle(2, 0x000000, 0.35);
g.strokeRoundedRect(2, 2, CELL - 4, CELL - 4, 10);
g.generateTexture(`bf-gem-${c}`, CELL, CELL);
g.destroy();
g = this.make.graphics({ add: false });
g.fillStyle(0x000000, 0.25);
g.fillCircle(CELL / 2, CELL / 2, CELL / 2 - 2);
g.fillStyle(color, 1);
g.fillCircle(CELL / 2, CELL / 2, CELL / 2 - 5);
g.fillStyle(0xffffff, 0.85);
g.fillCircle(CELL / 2, CELL / 2, 7);
g.lineStyle(3, 0xffffff, 0.5);
g.strokeCircle(CELL / 2, CELL / 2, CELL / 2 - 10);
g.generateTexture(`bf-crash-${c}`, CELL, CELL);
g.destroy();
g = this.make.graphics({ add: false });
g.fillStyle(color, 0.45);
g.fillRoundedRect(3, 3, CELL - 6, CELL - 6, 8);
g.lineStyle(3, color, 0.95);
g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 8);
g.generateTexture(`bf-counter-${c}`, CELL, CELL);
g.destroy();
}
const g = this.make.graphics({ add: false });
g.fillStyle(0xffffff, 1);
const m = CELL / 2;
g.fillPoints([
{ x: m, y: 3 }, { x: CELL - 4, y: m }, { x: m, y: CELL - 3 }, { x: 4, y: m },
], true);
g.fillStyle(0xb8e8ff, 0.85);
g.fillPoints([
{ x: m, y: 12 }, { x: CELL - 13, y: m }, { x: m, y: CELL - 12 }, { x: 13, y: m },
], true);
g.generateTexture('bf-diamond', CELL, CELL);
g.destroy();
}
// ── View management ─────────────────────────────────────────────────────────
clearLayer() {
for (const p of this.portraits) { try { p.destroy(); } catch (_) {} }
this.portraits = [];
this.input.keyboard.off('keydown', this.onKeyDown, this);
this.layer.removeAll(true);
this.boardObjs = null;
}
// ── Level select ────────────────────────────────────────────────────────────
showLevelSelect() {
this.view = 'select';
this.overlayUp = false;
this.match = null;
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 84, 'BLOCK FIGHTER', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Match gems, drop crash gems, and bury your rival in counter gems. Beat each fighter to unlock the next.', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([title, sub]);
if (!this.bank.length) {
const msg = this.add.text(cx, 520, 'No levels found in /data/blockfighter.json', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
}).setOrigin(0.5);
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' });
this.layer.add([msg, back]);
return;
}
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
const prog = this.add.text(cx, 182, `Defeated ${this.levelsCompleted} / ${this.bank.length}`, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add(prog);
const COLS = 10;
const SIZE = 128;
const GAP = 16;
const gridW = COLS * SIZE + (COLS - 1) * GAP;
const left = cx - gridW / 2 + SIZE / 2;
const top = 300;
this.bank.forEach((lv, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = left + col * (SIZE + GAP);
const y = top + row * (SIZE + GAP + 36);
const level = lv.level;
const cleared = level <= this.levelsCompleted;
const playable = level <= nextLevel;
const opp = this.opponentFor(lv);
const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b;
const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744;
const tile = this.add.rectangle(x, y, SIZE, SIZE + 28, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
const num = this.add.text(x, y - SIZE / 2 + 22, String(level), {
fontFamily: 'Righteous', fontSize: '26px',
color: playable || cleared ? COLORS.textHex : '#54606b',
}).setOrigin(0.5);
const objs = [tile, num];
if (this.textures.exists('opponents')) {
const face = this.add.image(x, y + 6, 'opponents', opp.spriteIndex ?? 0).setDisplaySize(76, 76);
if (!playable && !cleared) { face.setTint(0x333a44); face.setAlpha(0.7); }
objs.push(face);
}
const tag = this.add.text(x, y + SIZE / 2 + 2, cleared ? `${opp.name}` : playable ? opp.name : 'locked', {
fontFamily: '"Julius Sans One"', fontSize: '15px',
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
}).setOrigin(0.5);
objs.push(tag);
this.layer.add(objs);
if (playable) {
tile.setInteractive({ useHandCursor: true });
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
tile.on('pointerup', () => this.showIntro(level));
}
});
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel),
{ width: 280, height: 58, fontSize: 24 });
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(),
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
this.layer.add([resume, back, reset]);
if (!this.canPersist) {
const note = this.add.text(cx, GAME_HEIGHT - 28, 'Sign in to save your progress across devices.', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add(note);
}
}
confirmResetProgress() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
const title = this.add.text(cx, cy - 92, 'Reset Progress?', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const msg = this.add.text(cx, cy - 14,
'This clears every fighter you have beaten and\nstarts you back at Level 1. This cannot be undone.', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6,
}).setOrigin(0.5).setDepth(D.overlayUI);
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => {
api.post('/puzzles/blockfighter/reset').catch(() => {});
this.levelsCompleted = 0;
this.showLevelSelect();
}, { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }).setDepth(D.overlayUI);
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
this.layer.add([dim, panel, title, msg, yes, no]);
}
// ── Pre-battle intro ────────────────────────────────────────────────────────
showIntro(level) {
const lv = this.bank.find((l) => l.level === level);
if (!lv) return;
this.view = 'intro';
this.clearLayer();
const cx = GAME_WIDTH / 2;
const opp = this.opponentFor(lv);
const title = this.add.text(cx, 110, `LEVEL ${level}`, {
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex,
}).setOrigin(0.5);
const vs = this.add.text(cx, 560, 'VS', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.mutedHex,
}).setOrigin(0.5).setAlpha(0.6);
this.layer.add([title, vs]);
this.portraits.push(createOpponentPortrait(this, opp, cx, 360, 150, D.ui, { playIntro: true }));
const name = this.add.text(cx, 552 + 80, opp.name, {
fontFamily: 'Righteous', fontSize: '54px', color: COLORS.textHex,
}).setOrigin(0.5);
const bio = this.add.text(cx, 552 + 140, opp.bio ?? '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const tagline = this.add.text(cx, 552 + 188, lv.tagline ?? '', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex, fontStyle: 'italic',
}).setOrigin(0.5);
const stars = '★'.repeat(Math.ceil(lv.skill / 2)) + '☆'.repeat(5 - Math.ceil(lv.skill / 2));
const bolts = '⚡'.repeat(lv.speed);
const statText = this.add.text(cx, 552 + 248, `Skill ${stars} Speed ${bolts}`, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add([name, bio, tagline, statText]);
const fight = new Button(this, cx - 130, GAME_HEIGHT - 110, 'FIGHT!', () => this.startBattle(level),
{ width: 240, height: 66, fontSize: 30 });
const back = new Button(this, cx + 140, GAME_HEIGHT - 110, 'Back', () => this.showLevelSelect(),
{ variant: 'ghost', width: 200, height: 66, fontSize: 24 });
this.layer.add([fight, back]);
}
// ── Battle setup ────────────────────────────────────────────────────────────
startBattle(level) {
const lv = this.bank.find((l) => l.level === level);
if (!lv) return;
this.view = 'battle';
this.overlayUp = false;
this.level = level;
this.levelDef = lv;
this.opponent = this.opponentFor(lv);
this.clearLayer();
const seed = (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
this.match = createMatch({ seed, dropPatterns: [lv.dropPattern, lv.dropPattern] });
this.ai = createAI({ skill: lv.skill, speed: lv.speed, seed: seed ^ 0x9e3779b9 });
this.gravityMs = SPEED_GRAVITY_MS[Math.max(1, Math.min(5, lv.speed))];
this.gravTimer = [0, 0];
this.replayQueue = [[], []];
this.replayTimer = [0, 0];
this.matchEnded = false;
this.drawBattleChrome();
this.input.keyboard.on('keydown', this.onKeyDown, this);
for (const i of [0, 1]) this.beginSpawn(i);
}
drawBattleChrome() {
const cx = GAME_WIDTH / 2;
const hud = this.add.text(cx, 56, `Level ${this.level} — vs ${this.opponent.name}`, {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add(hud);
this.boardObjs = [];
this.pieceImgs = [];
this.ghostImgs = [];
this.garbageTexts = [];
this.sentTexts = [];
this.previewImgs = [];
for (const i of [0, 1]) {
const left = BOARD_LEFT[i];
const g = this.add.graphics().setDepth(D.frame);
g.fillStyle(FRAME, 1);
g.fillRoundedRect(left - 16, BOARD_TOP - 16, BOARD_W + 32, BOARD_H + 32, 14);
g.fillStyle(CELLBG, 1);
g.fillRect(left, BOARD_TOP, BOARD_W, BOARD_H);
this.layer.add(g);
const grid = this.add.graphics().setDepth(D.grid);
grid.lineStyle(1, GRIDLN, 0.8);
for (let c = 0; c <= WIDTH; c++) grid.lineBetween(left + c * CELL, BOARD_TOP, left + c * CELL, BOARD_TOP + BOARD_H);
for (let r = 0; r <= VISIBLE_ROWS; r++) grid.lineBetween(left, BOARD_TOP + r * CELL, left + BOARD_W, BOARD_TOP + r * CELL);
// danger marker over the spawn column
grid.fillStyle(0xe04444, 0.5);
grid.fillTriangle(
left + 3 * CELL + CELL / 2 - 12, BOARD_TOP - 16,
left + 3 * CELL + CELL / 2 + 12, BOARD_TOP - 16,
left + 3 * CELL + CELL / 2, BOARD_TOP - 4,
);
this.layer.add(grid);
const label = this.add.text(left + BOARD_W / 2, BOARD_TOP + BOARD_H + 36, i === 0 ? 'YOU' : this.opponent.name.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '24px', color: i === 0 ? COLORS.accentHex ?? '#5bc0de' : COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add(label);
// pending garbage indicator + sent counter above the board
const gt = this.add.text(left + BOARD_W / 2, BOARD_TOP - 44, '', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.ui);
this.garbageTexts.push(gt);
const st = this.add.text(left + (i === 0 ? -16 : BOARD_W + 16), BOARD_TOP - 44, 'Sent: 0', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(i === 0 ? 1 : 0, 0.5).setDepth(D.ui);
this.sentTexts.push(st);
this.layer.add([gt, st]);
// next-piece preview in the centre gutter
const px = i === 0 ? BOARD_LEFT[0] + BOARD_W + 90 : BOARD_LEFT[1] - 90;
const pg = this.add.graphics().setDepth(D.frame);
pg.fillStyle(FRAME, 1);
pg.fillRoundedRect(px - 44, 290 - 70, 88, 150, 10);
const pl = this.add.text(px, 290 - 92, 'NEXT', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add([pg, pl]);
this.previewImgs.push([
this.add.image(px, 290 - 28, 'bf-gem-0').setDepth(D.ui).setVisible(false),
this.add.image(px, 290 + 28, 'bf-gem-0').setDepth(D.ui).setVisible(false),
]);
this.layer.add(this.previewImgs[i]);
this.boardObjs.push([]); // cell images, rebuilt per render
this.pieceImgs.push([
this.add.image(0, 0, 'bf-gem-0').setDepth(D.piece).setVisible(false),
this.add.image(0, 0, 'bf-gem-0').setDepth(D.piece).setVisible(false),
]);
this.layer.add(this.pieceImgs[i]);
this.ghostImgs.push([
this.add.rectangle(0, 0, CELL - 8, CELL - 8).setStrokeStyle(2, 0xffffff, 0.35).setDepth(D.grid + 1).setVisible(false),
this.add.rectangle(0, 0, CELL - 8, CELL - 8).setStrokeStyle(2, 0xffffff, 0.35).setDepth(D.grid + 1).setVisible(false),
]);
this.layer.add(this.ghostImgs[i]);
}
// portraits
this.portraits.push(createPlayerPortrait(this, 165, 380, 84, D.ui, 'BlockFighterGame'));
this.oppPortrait = createOpponentPortrait(this, this.opponent, GAME_WIDTH - 165, 380, 84, D.ui, { playIntro: false });
this.portraits.push(this.oppPortrait);
// chain callout
this.calloutText = this.add.text(GAME_WIDTH / 2, 620, '', {
fontFamily: 'Righteous', fontSize: '54px', color: COLORS.goldHex, stroke: '#000000', strokeThickness: 6,
}).setOrigin(0.5).setDepth(D.fx).setAlpha(0);
this.layer.add(this.calloutText);
const quit = new Button(this, 140, GAME_HEIGHT - 60, 'Levels', () => this.showLevelSelect(),
{ variant: 'ghost', width: 180, height: 52, fontSize: 22 });
this.layer.add(quit);
this.drawTouchControls();
}
drawTouchControls() {
const y = GAME_HEIGHT - 110;
const cx = GAME_WIDTH / 2;
const defs = [
{ x: cx - 290, glyph: '◀', action: 'left', repeat: true },
{ x: cx - 174, glyph: '▶', action: 'right', repeat: true },
{ x: cx - 58, glyph: '⟲', action: 'rotateCCW', repeat: false },
{ x: cx + 58, glyph: '⟳', action: 'rotateCW', repeat: false },
{ x: cx + 174, glyph: '▼', action: 'softDrop', repeat: true },
{ x: cx + 290, glyph: '⤓', action: 'hardDrop', repeat: false },
];
for (const def of defs) {
const g = this.add.graphics().setDepth(D.ui);
g.fillStyle(COLORS.panel, 0.9);
g.fillCircle(def.x, y, 46);
g.lineStyle(2, COLORS.gold, 0.7);
g.strokeCircle(def.x, y, 46);
const t = this.add.text(def.x, y, def.glyph, {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui + 1);
const zone = this.add.zone(def.x, y, 96, 96).setInteractive({ useHandCursor: true }).setDepth(D.ui + 2);
let repeatEvt = null;
const stop = () => { if (repeatEvt) { repeatEvt.remove(); repeatEvt = null; } };
zone.on('pointerdown', () => {
this.playerAction(def.action);
if (def.repeat) {
stop();
repeatEvt = this.time.addEvent({ delay: 120, loop: true, callback: () => this.playerAction(def.action) });
}
});
zone.on('pointerup', stop);
zone.on('pointerout', stop);
this.layer.add([g, t, zone]);
}
}
// ── Input ───────────────────────────────────────────────────────────────────
onKeyDown(event) {
if (this.view !== 'battle') return;
switch (event.code) {
case 'ArrowLeft': case 'KeyA': this.playerAction('left'); break;
case 'ArrowRight': case 'KeyD': this.playerAction('right'); break;
case 'ArrowDown': case 'KeyS': this.playerAction('softDrop'); break;
case 'ArrowUp': case 'KeyX': if (!event.repeat) this.playerAction('rotateCW'); break;
case 'KeyZ': if (!event.repeat) this.playerAction('rotateCCW'); break;
case 'Space': if (!event.repeat) this.playerAction('hardDrop'); event.preventDefault(); break;
default: break;
}
}
playerAction(action) { this.applyAction(0, action); }
applyAction(idx, action) {
const m = this.match;
if (!m || m.over || this.overlayUp || this.view !== 'battle') return;
if (this.replayQueue[idx].length) return; // board busy animating
const p = m.players[idx];
if (!p.piece) return;
let locked = false;
let events = null;
switch (action) {
case 'left': if (moveLeft(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break;
case 'right': if (moveRight(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break;
case 'rotateCW': if (rotateCW(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break;
case 'rotateCCW': if (rotateCCW(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break;
case 'softDrop': {
const r = stepDown(m, idx);
locked = r.locked; events = r.events;
this.gravTimer[idx] = 0;
break;
}
case 'hardDrop': {
events = hardDrop(m, idx);
locked = true;
break;
}
default: break;
}
if (locked) this.onLocked(idx, events);
else this.updatePieceSprites(idx);
}
// ── Spawn / lock / replay flow ──────────────────────────────────────────────
beginSpawn(idx) {
const events = spawnPiece(this.match, idx);
this.enqueue(idx, events);
if (this.match.players[idx].piece && idx === 1) {
planPlacement(this.ai, this.match, 1);
}
}
onLocked(idx, events) {
playSound(this, SFX.CARD_PLACE);
this.updatePieceSprites(idx); // hides the piece (now null)
this.enqueue(idx, events);
}
enqueue(idx, events) {
if (events?.length) this.replayQueue[idx].push(...events);
this.updateMeters();
}
// process one queued event; returns the delay before the next one
processEvent(idx, e) {
const left = BOARD_LEFT[idx];
switch (e.type) {
case 'spawn':
this.updatePieceSprites(idx);
this.updatePreviews();
break;
case 'place':
case 'settle':
case 'counter':
this.renderBoard(idx, e);
break;
case 'garbage':
this.renderBoard(idx, e);
if (e.cells?.length) playSound(this, SFX.DICE_ROLL);
break;
case 'clear':
case 'diamond': {
this.renderBoard(idx, e);
playSound(this, SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW);
for (const cell of e.cells ?? []) {
if (cell.r < HIDDEN_ROWS) continue;
const fx = this.add.rectangle(
left + cell.c * CELL + CELL / 2,
BOARD_TOP + (cell.r - HIDDEN_ROWS) * CELL + CELL / 2,
CELL - 4, CELL - 4, 0xffffff, 0.9,
).setDepth(D.fx);
this.layer.add(fx);
this.tweens.add({ targets: fx, alpha: 0, scale: 1.4, duration: 260, onComplete: () => fx.destroy() });
}
if (e.type === 'clear' && e.chain >= 2) {
this.showCallout(`${e.chain} CHAIN!`);
if (idx === 0) this.oppPortrait?.playEmotion('upset');
else this.oppPortrait?.playEmotion('happy');
}
break;
}
case 'attack': {
if (e.sent > 0) {
this.showCallout(`${idx === 0 ? 'YOU' : this.opponent.name} +${e.sent}`, idx === 0 ? '#9be7b4' : '#ff8a8a');
if (e.sent >= 6) this.oppPortrait?.playEmotion(idx === 0 ? 'upset' : 'happy');
}
break;
}
case 'lose':
this.renderBoard(idx, e);
this.endMatch();
break;
default:
this.renderBoard(idx, e);
break;
}
this.updateMeters();
return REPLAY_DELAY[e.type] ?? 120;
}
showCallout(text, color = COLORS.goldHex) {
this.calloutText.setText(text).setColor(color).setAlpha(1).setScale(0.7);
this.tweens.add({ targets: this.calloutText, scale: 1, duration: 140, ease: 'Back.easeOut' });
this.tweens.add({ targets: this.calloutText, alpha: 0, delay: 850, duration: 300 });
}
// ── Rendering ───────────────────────────────────────────────────────────────
renderBoard(idx, snap) {
for (const o of this.boardObjs[idx]) o.destroy();
this.boardObjs[idx] = [];
const left = BOARD_LEFT[idx];
const inPower = new Set();
for (const g of snap.powerGems ?? []) {
for (let r = g.y; r < g.y + g.h; r++) for (let c = g.x; c < g.x + g.w; c++) inPower.add(r * WIDTH + c);
const vr = Math.max(g.y, HIDDEN_ROWS);
const vh = g.y + g.h - vr;
if (vh <= 0) continue;
const gx = left + g.x * CELL;
const gy = BOARD_TOP + (vr - HIDDEN_ROWS) * CELL;
const pg = this.add.graphics().setDepth(D.cells);
pg.fillStyle(GEM_COLORS[g.color], 1);
pg.fillRoundedRect(gx + 2, gy + 2, g.w * CELL - 4, vh * CELL - 4, 12);
pg.fillStyle(0xffffff, 0.28);
pg.fillRoundedRect(gx + 8, gy + 6, g.w * CELL - 16, 16, 8);
pg.lineStyle(3, 0xffffff, 0.55);
pg.strokeRoundedRect(gx + 4, gy + 4, g.w * CELL - 8, vh * CELL - 8, 10);
pg.lineStyle(2, 0x000000, 0.35);
pg.strokeRoundedRect(gx + 2, gy + 2, g.w * CELL - 4, vh * CELL - 4, 12);
this.layer.add(pg);
this.boardObjs[idx].push(pg);
}
for (let r = HIDDEN_ROWS; r < snap.board.length; r++) {
for (let c = 0; c < WIDTH; c++) {
const cell = snap.board[r][c];
if (!cell || inPower.has(r * WIDTH + c)) continue;
const x = left + c * CELL + CELL / 2;
const y = BOARD_TOP + (r - HIDDEN_ROWS) * CELL + CELL / 2;
const img = this.add.image(x, y, this.textureFor(cell)).setDepth(D.cells);
this.layer.add(img);
this.boardObjs[idx].push(img);
if (cell.kind === KIND.COUNTER) {
const t = this.add.text(x, y, String(cell.count), {
fontFamily: 'Righteous', fontSize: '26px', color: '#ffffff', stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5).setDepth(D.cells + 1);
this.layer.add(t);
this.boardObjs[idx].push(t);
}
}
}
}
textureFor(cell) {
if (cell.kind === KIND.DIAMOND) return 'bf-diamond';
if (cell.kind === KIND.CRASH) return `bf-crash-${cell.color}`;
if (cell.kind === KIND.COUNTER) return `bf-counter-${cell.color}`;
return `bf-gem-${cell.color}`;
}
updatePieceSprites(idx) {
const p = this.match.players[idx];
const imgs = this.pieceImgs[idx];
const ghosts = this.ghostImgs[idx];
if (!p.piece) {
imgs.forEach((img) => img.setVisible(false));
ghosts.forEach((g) => g.setVisible(false));
return;
}
const left = BOARD_LEFT[idx];
const cells = pieceCells(p.piece);
cells.forEach(({ r, c, half }, i) => {
imgs[i]
.setTexture(this.textureFor(p.piece[half]))
.setPosition(left + c * CELL + CELL / 2, BOARD_TOP + (r - HIDDEN_ROWS) * CELL + CELL / 2)
.setVisible(true)
.setAlpha(r < HIDDEN_ROWS ? 0.45 : 1);
});
getGhostCells(p).forEach(({ r, c }, i) => {
ghosts[i]
.setPosition(left + c * CELL + CELL / 2, BOARD_TOP + (r - HIDDEN_ROWS) * CELL + CELL / 2)
.setVisible(r >= HIDDEN_ROWS);
});
}
updatePreviews() {
if (!this.match) return;
for (const i of [0, 1]) {
const p = this.match.players[i];
const next = peekPiece(this.match, p.pieceIndex);
this.previewImgs[i][0].setTexture(this.textureFor(next.b)).setVisible(true);
this.previewImgs[i][1].setTexture(this.textureFor(next.a)).setVisible(true);
}
}
updateMeters() {
if (!this.match || !this.garbageTexts) return;
for (const i of [0, 1]) {
const pending = this.match.players[i].pendingGarbage;
this.garbageTexts[i].setText(pending > 0 ? `${pending} incoming` : '');
this.sentTexts[i].setText(`Sent: ${this.match.players[i].garbageSent}`);
}
}
// ── Main loop ───────────────────────────────────────────────────────────────
update(time, delta) {
if (this.view !== 'battle' || !this.match || this.overlayUp) return;
const m = this.match;
for (const i of [0, 1]) {
// replay queued engine events (board is frozen while animating)
if (this.replayQueue[i].length) {
this.replayTimer[i] -= delta;
if (this.replayTimer[i] <= 0) {
const e = this.replayQueue[i].shift();
this.replayTimer[i] = this.processEvent(i, e);
}
continue;
}
if (m.over) continue;
const p = m.players[i];
if (!p.piece) {
if (!p.lost) this.beginSpawn(i);
continue;
}
// AI inputs
if (i === 1) {
const act = nextAction(this.ai, m, 1, time);
if (act) {
this.applyAction(1, act);
if (!m.players[1].piece) continue; // locked via soft/hard drop
}
}
// gravity
this.gravTimer[i] += delta;
if (this.gravTimer[i] >= this.gravityMs) {
this.gravTimer[i] = 0;
const r = stepDown(m, i);
if (r.locked) this.onLocked(i, r.events);
else this.updatePieceSprites(i);
}
}
}
// ── End of match ────────────────────────────────────────────────────────────
endMatch() {
if (this.matchEnded) return;
this.matchEnded = true;
const won = this.match.winner === 0;
const p = this.match.players[0];
this.oppPortrait?.playEmotion(won ? 'upset' : 'happy');
playSound(this, won ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE);
api.post('/history/single-player', {
slug: 'blockfighter',
score: p.garbageSent,
opponentScores: [this.match.players[1].garbageSent],
result: won ? 'win' : 'loss',
}).catch(() => {});
if (won) {
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
api.post('/puzzles/blockfighter/complete', { level: this.level })
.then((res) => {
if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted);
})
.catch(() => {});
}
this.time.delayedCall(900, () => this.showEndModal(won));
}
showEndModal(won) {
if (this.view !== 'battle' || !this.match) return; // user already left the battle
this.overlayUp = true;
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 340, cy - 210, 680, 420, 20);
panel.lineStyle(3, won ? COLORS.accent : COLORS.danger, 1);
panel.strokeRoundedRect(cx - 340, cy - 210, 680, 420, 20);
this.layer.add([dim, panel]);
const p = this.match.players[0];
const title = this.add.text(cx, cy - 140, won ? 'VICTORY!' : 'DEFEATED', {
fontFamily: 'Righteous', fontSize: '64px', color: won ? COLORS.goldHex : COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const stat = this.add.text(cx, cy - 55,
won
? `You beat ${this.opponent.name}!\nGarbage sent: ${p.garbageSent} Best chain: ${p.bestChain}`
: `${this.opponent.name} buried you in gems.\nGarbage sent: ${p.garbageSent} Best chain: ${p.bestChain}`, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', lineSpacing: 8,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, stat]);
const btns = [];
if (won) {
const hasNext = this.level < this.bank.length;
if (hasNext) {
btns.push(new Button(this, cx, cy + 50, `Next Fight (${this.level + 1})`, () => this.showIntro(this.level + 1),
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
} else {
btns.push(this.add.text(cx, cy + 45, 'You beat every fighter. Champion!', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
}
btns.push(new Button(this, cx - 110, cy + 135, 'Rematch', () => this.startBattle(this.level),
{ width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI));
} else {
btns.push(new Button(this, cx - 110, cy + 80, 'Retry', () => this.startBattle(this.level),
{ width: 200, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
}
btns.push(new Button(this, cx + 120, won ? cy + 135 : cy + 80, 'Levels', () => this.showLevelSelect(),
{ width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI));
this.layer.add(btns);
}
}