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