Added Block Fighter game

This commit is contained in:
Brian Fertig 2026-06-11 10:03:22 -06:00
parent c5971d8eb1
commit bf47c50dfa
12 changed files with 2071 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

View File

@ -0,0 +1,44 @@
{
"levels": [
{ "level": 1, "opponentId": "ethel", "skill": 1, "speed": 1, "tagline": "A gentle warm-up over tea.",
"dropPattern": ["RRRRRR", "GGGGGG", "BBBBBB", "YYYYYY"] },
{ "level": 2, "opponentId": "kona", "skill": 1, "speed": 1, "tagline": "Good puppy. Surprisingly good at gems.",
"dropPattern": ["YYYYYY", "BBBBBB", "GGGGGG", "RRRRRR"] },
{ "level": 3, "opponentId": "bernie", "skill": 2, "speed": 1, "tagline": "All fun and games... until the gems drop.",
"dropPattern": ["RRRGGG", "RRRGGG", "BBBYYY", "BBBYYY"] },
{ "level": 4, "opponentId": "brad", "skill": 2, "speed": 1, "tagline": "Less salmon, more smashing.",
"dropPattern": ["GGGYYY", "GGGYYY", "RRRBBB", "RRRBBB"] },
{ "level": 5, "opponentId": "jerry", "skill": 3, "speed": 2, "tagline": "Y'all ready for a real scrap?",
"dropPattern": ["RRGGBB", "RRGGBB", "YYRRGG", "YYRRGG"] },
{ "level": 6, "opponentId": "jeff", "skill": 3, "speed": 2, "tagline": "Plays slow. Wins fast. You've been warned.",
"dropPattern": ["BBYYRR", "BBYYRR", "GGBBYY", "GGBBYY"] },
{ "level": 7, "opponentId": "mario", "skill": 4, "speed": 2, "tagline": "Welcome to the maze of falling gems!",
"dropPattern": ["RRGGYY", "BBRRGG", "YYBBRR", "GGYYBB"] },
{ "level": 8, "opponentId": "juliet", "skill": 4, "speed": 2, "tagline": "A summer day, a storm of gems.",
"dropPattern": ["GGBBRR", "YYGGBB", "RRYYGG", "BBRRYY"] },
{ "level": 9, "opponentId": "michael", "skill": 5, "speed": 3, "tagline": "Easy vibes, heavy chains, mon.",
"dropPattern": ["RGGBBY", "YRGGBB", "BYRGGB", "BBYRGG"] },
{ "level": 10, "opponentId": "croc", "skill": 5, "speed": 3, "tagline": "The party's over when your board fills up.",
"dropPattern": ["GBYRGB", "BYRGBY", "YRGBYR", "RGBYRG"] },
{ "level": 11, "opponentId": "gerome", "skill": 6, "speed": 3, "tagline": "Extreme victory or nothing!",
"dropPattern": ["RYRYRY", "GBGBGB", "YRYRYR", "BGBGBG"] },
{ "level": 12, "opponentId": "beth", "skill": 6, "speed": 3, "tagline": "These parts have rules, stranger.",
"dropPattern": ["BRBRBR", "YGYGYG", "RBRBRB", "GYGYGY"] },
{ "level": 13, "opponentId": "steve", "skill": 7, "speed": 4, "tagline": "Stupid Earth gems. Prepare to lose.",
"dropPattern": ["RGBYRG", "BYRGBY", "GBYRGB", "YRGBYR"] },
{ "level": 14, "opponentId": "fireball", "skill": 7, "speed": 4, "tagline": "No x-ray eyes. Just flawless drops.",
"dropPattern": ["RRYYRR", "YYRRYY", "GGBBGG", "BBGGBB"] },
{ "level": 15, "opponentId": "natasha", "skill": 8, "speed": 4, "tagline": "Your secrets fall with your gems.",
"dropPattern": ["RGYBGR", "BYGRYB", "GRBYRG", "YBRGBY"] },
{ "level": 16, "opponentId": "victor", "skill": 8, "speed": 4, "tagline": "Every drop calculated. Centuries ago.",
"dropPattern": ["RGBYBG", "YBGRGB", "GYRBRY", "BRYGYR"] },
{ "level": 17, "opponentId": "balam", "skill": 9, "speed": 5, "tagline": "Mystical powers meet falling blocks.",
"dropPattern": ["GYBRYG", "RBYGBR", "YGRBGY", "BRGYRB"] },
{ "level": 18, "opponentId": "cybro", "skill": 9, "speed": 5, "tagline": "The future has already beaten you.",
"dropPattern": ["RBGYRB", "GYRBGY", "BRYGBR", "YGBRYG"] },
{ "level": 19, "opponentId": "zanthor", "skill": 10, "speed": 5, "tagline": "Alacazam! Your board is doomed!",
"dropPattern": ["RYGBRY", "GBRYGB", "YRBGYR", "BGYRBG"] },
{ "level": 20, "opponentId": "blackwind", "skill": 10, "speed": 5, "tagline": "The final showdown on the high seas!",
"dropPattern": ["RGBYRG", "YBRGYB", "GRYBGR", "BYGRBY"] }
]
}

View File

@ -0,0 +1,185 @@
// Block Fighter AI — placement search over the headless engine.
// Skill 1-10 controls lookahead, blunder rate, evaluation richness, and how
// fast the AI physically executes its plan (one input per movePeriodMs).
import {
WIDTH, HEIGHT, SPAWN_COL, KIND,
legalPlacements, simulatePlacement, makeRng, peekPiece,
} from './BlockFighterLogic.js';
// skill → knobs (interpolated linearly between anchor rows)
const SKILL_ANCHORS = [
{ skill: 1, lookahead: 1, blunder: 0.45, movePeriodMs: 620, profile: 'basic' },
{ skill: 3, lookahead: 1, blunder: 0.25, movePeriodMs: 460, profile: 'cluster' },
{ skill: 5, lookahead: 1, blunder: 0.10, movePeriodMs: 330, profile: 'full' },
{ skill: 7, lookahead: 2, blunder: 0.05, movePeriodMs: 230, profile: 'full' },
{ skill: 9, lookahead: 2, blunder: 0.01, movePeriodMs: 150, profile: 'aggressive' },
{ skill: 10, lookahead: 2, blunder: 0.00, movePeriodMs: 110, profile: 'aggressive' },
];
function knobsFor(skill) {
const s = Math.max(1, Math.min(10, skill));
let lo = SKILL_ANCHORS[0], hi = SKILL_ANCHORS[SKILL_ANCHORS.length - 1];
for (let i = 0; i < SKILL_ANCHORS.length - 1; i++) {
if (s >= SKILL_ANCHORS[i].skill && s <= SKILL_ANCHORS[i + 1].skill) {
lo = SKILL_ANCHORS[i]; hi = SKILL_ANCHORS[i + 1];
break;
}
}
const t = hi.skill === lo.skill ? 0 : (s - lo.skill) / (hi.skill - lo.skill);
return {
lookahead: t < 0.5 ? lo.lookahead : hi.lookahead,
blunder: lo.blunder + (hi.blunder - lo.blunder) * t,
movePeriodMs: Math.round(lo.movePeriodMs + (hi.movePeriodMs - lo.movePeriodMs) * t),
profile: t < 0.5 ? lo.profile : hi.profile,
};
}
const EVAL_WEIGHTS = {
basic: { attack: 40, cleared: 6, cluster: 0, crash: 0, power: 0, height: 1.0, spawnCol: 2, counters: 0, bumpy: 0 },
cluster: { attack: 40, cleared: 6, cluster: 2.5, crash: 4, power: 0, height: 1.0, spawnCol: 2, counters: 0.5, bumpy: 0.5 },
full: { attack: 45, cleared: 5, cluster: 3, crash: 6, power: 5, height: 1.1, spawnCol: 3, counters: 1, bumpy: 1 },
aggressive: { attack: 60, cleared: 4, cluster: 3.5, crash: 8, power: 7, height: 1.2, spawnCol: 4, counters: 1.5, bumpy: 1 },
};
export function createAI({ skill = 5, speed = 1, seed = 1 } = {}) {
const knobs = knobsFor(skill);
// higher level speed also quickens the AI's hands a bit
knobs.movePeriodMs = Math.round(knobs.movePeriodMs * (1 - 0.08 * (speed - 1)));
return {
skill,
knobs,
rng: makeRng(seed),
plan: null, // { col, orient }
nextActionAt: 0,
useSoftDrop: skill >= 4,
useHardDrop: skill >= 7,
};
}
// ── Board evaluation ─────────────────────────────────────────────────────────
function evaluateBoard(player, w) {
const { board } = player;
let score = 0;
const heights = new Array(WIDTH).fill(0);
for (let c = 0; c < WIDTH; c++) {
for (let r = 0; r < HEIGHT; r++) {
if (board[r][c]) { heights[c] = HEIGHT - r; break; }
}
}
for (let c = 0; c < WIDTH; c++) {
const weight = c === SPAWN_COL ? w.spawnCol : w.height;
score -= weight * heights[c] * heights[c] * 0.25;
}
for (let c = 0; c < WIDTH - 1; c++) score -= w.bumpy * Math.abs(heights[c] - heights[c + 1]);
let cluster = 0, counters = 0, crashPotential = 0;
for (let r = 0; r < HEIGHT; r++) {
for (let c = 0; c < WIDTH; c++) {
const cell = board[r][c];
if (!cell) continue;
if (cell.kind === KIND.COUNTER) { counters += 1; continue; }
const right = c + 1 < WIDTH ? board[r][c + 1] : null;
const down = r + 1 < HEIGHT ? board[r + 1][c] : null;
if (right && right.kind !== KIND.COUNTER && right.color === cell.color) cluster += 1;
if (down && down.kind !== KIND.COUNTER && down.color === cell.color) cluster += 1;
if (cell.kind === KIND.CRASH) {
// size of the same-color group this crash gem touches
let touching = 0;
for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
const n = board[r + dr]?.[c + dc];
if (n && n.color === cell.color && n.kind !== KIND.COUNTER) touching += 1;
}
crashPotential += touching;
}
}
}
score += w.cluster * cluster + w.crash * crashPotential - w.counters * counters;
let power = 0;
for (const gem of player.powerGems.values()) power += Math.pow(gem.w * gem.h, 1.3);
score += w.power * power;
return score;
}
function scorePlacement(result, w) {
if (!result) return -Infinity;
if (result.died) return -1e9;
return w.attack * result.attack + w.cleared * result.cleared + evaluateBoard(result.player, w);
}
// ── Planning ─────────────────────────────────────────────────────────────────
export function planPlacement(ai, match, pIdx) {
const player = match.players[pIdx];
if (!player.piece) return null;
const w = EVAL_WEIGHTS[ai.knobs.profile];
const options = [];
for (const { col, orient } of legalPlacements(player)) {
const result = simulatePlacement(match, pIdx, col, orient);
if (!result) continue;
let score = scorePlacement(result, w);
if (ai.knobs.lookahead >= 2 && score > -1e8) {
// probe the next shared piece on the resulting board
const nextProto = peekPiece(match, player.pieceIndex);
if (nextProto) {
const ghost = {
players: pIdx === 0
? [result.player, match.players[1]]
: [match.players[0], result.player],
over: false, winner: null, headless: true,
};
result.player.piece = { a: { ...nextProto.a }, b: { ...nextProto.b }, row: 1, col: SPAWN_COL, orient: 0 };
let best2 = -Infinity;
for (const p2 of legalPlacements(result.player)) {
const r2 = simulatePlacement(ghost, pIdx, p2.col, p2.orient);
if (!r2) continue;
const s2 = scorePlacement(r2, w);
if (s2 > best2) best2 = s2;
}
result.player.piece = null;
if (best2 > -Infinity) score = score * 0.6 + best2 * 0.4;
}
}
options.push({ col, orient, score });
}
if (!options.length) { ai.plan = null; return null; }
options.sort((a, b) => b.score - a.score);
let pick = options[0];
if (ai.rng() < ai.knobs.blunder) {
pick = options[Math.floor(ai.rng() * Math.min(options.length, 5))];
}
ai.plan = { col: pick.col, orient: pick.orient };
ai.nextActionAt = 0;
return ai.plan;
}
// ── Execution: one input per movePeriodMs ────────────────────────────────────
export function nextAction(ai, match, pIdx, nowMs) {
const player = match.players[pIdx];
if (!player.piece || !ai.plan) return null;
if (nowMs < ai.nextActionAt) return null;
const piece = player.piece;
let action = null;
if (piece.orient !== ai.plan.orient) {
const cwDist = (ai.plan.orient - piece.orient + 4) % 4;
action = cwDist <= 2 ? 'rotateCW' : 'rotateCCW';
} else if (piece.col > ai.plan.col) {
action = 'left';
} else if (piece.col < ai.plan.col) {
action = 'right';
} else if (ai.useHardDrop) {
action = 'hardDrop';
} else if (ai.useSoftDrop) {
action = 'softDrop';
}
if (action) ai.nextActionAt = nowMs + ai.knobs.movePeriodMs;
return action;
}

View File

@ -0,0 +1,818 @@
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);
}
}

View File

@ -0,0 +1,634 @@
// Block Fighter — pure game engine (no Phaser, no DOM, no timers).
// A Super Puzzle Fighter II Turbo style versus engine. The scene (or a headless
// script) drives all timing; every transition returns an ordered event list,
// each event carrying a board snapshot, which the renderer replays as animation.
//
// Power-gem fusion here is a deterministic approximation of the arcade's
// folklore rules: greedy largest-rectangle fusion, then full-row/column
// extension, then same-seam merging. Fixtures in verifyBlockFighter.js pin it.
export const WIDTH = 6;
export const VISIBLE_ROWS = 12;
export const HIDDEN_ROWS = 2;
export const HEIGHT = VISIBLE_ROWS + HIDDEN_ROWS; // row 0 = top (hidden)
export const NUM_COLORS = 4; // 0=red 1=green 2=blue 3=yellow
export const SPAWN_COL = 3;
export const KIND = { GEM: 'gem', CRASH: 'crash', COUNTER: 'counter', DIAMOND: 'diamond' };
export const CRASH_RATE = 0.28;
export const DIAMOND_EVERY = 25;
export const COUNTER_START = 5;
export const MAX_GARBAGE_PER_DROP = 24;
export const CHAIN_MULT = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32];
export const POWER_BONUS = 0.5; // extra attack per cleared power-gem cell
export const ATTACK_DISCOUNT = 2; // cells "free" per clear step before attack
export const DIAMOND_DAMAGE_DIV = 2;
export const SPEED_GRAVITY_MS = [null, 900, 750, 600, 475, 350]; // index = level speed 1-5
export const COLOR_LETTERS = 'RGBY';
export const DEFAULT_DROP_PATTERN = ['RRGGYY', 'BBRRGG', 'YYBBRR', 'GGYYBB'];
// ── Seeded RNG (mulberry32, matches genRushHour.js) ─────────────────────────
export function makeRng(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function parseDropPattern(rows) {
const src = Array.isArray(rows) && rows.length === 4 ? rows : DEFAULT_DROP_PATTERN;
return src.map((row) =>
[...String(row).padEnd(WIDTH, 'R').slice(0, WIDTH)].map((ch) => {
const c = COLOR_LETTERS.indexOf(ch.toUpperCase());
return c >= 0 ? c : 0;
})
);
}
// ── Match / player construction ──────────────────────────────────────────────
function emptyBoard() {
return Array.from({ length: HEIGHT }, () => Array(WIDTH).fill(null));
}
function createPlayer(dropPattern) {
return {
board: emptyBoard(),
powerGems: new Map(), // id -> { id, color, x, y, w, h }
nextPowerId: 1,
piece: null, // { a, b, row, col, orient }; orient: 0 b-above, 1 b-right, 2 b-below, 3 b-left
pieceIndex: 0, // next index into match.pieceSeq
pendingGarbage: 0,
dropPattern: parseDropPattern(dropPattern),
patternRow: 0,
garbageSent: 0,
garbageReceived: 0,
bestChain: 0,
lastResolve: { attack: 0, cleared: 0, chain: 0 },
lost: false,
};
}
export function createMatch({ seed = 1, dropPatterns = [null, null] } = {}) {
return {
rng: makeRng(seed),
pieceSeq: [], // shared by both players for fairness
players: [createPlayer(dropPatterns[0]), createPlayer(dropPatterns[1])],
over: false,
winner: null,
};
}
function getSeqPiece(match, index) {
while (match.pieceSeq.length <= index) {
const i = match.pieceSeq.length;
const half = () => ({
color: Math.floor(match.rng() * NUM_COLORS),
kind: match.rng() < CRASH_RATE ? KIND.CRASH : KIND.GEM,
});
const a = half();
const b = half();
if ((i + 1) % DIAMOND_EVERY === 0) { a.kind = KIND.DIAMOND; a.color = null; }
match.pieceSeq.push({ a, b });
}
return match.pieceSeq[index];
}
// Peek at (and lazily generate) the shared sequence — used for next-piece
// previews and AI lookahead.
export function peekPiece(match, index) {
return getSeqPiece(match, index);
}
// ── Snapshots (for renderer playback) ────────────────────────────────────────
function cloneCell(cell) { return cell ? { ...cell } : null; }
function cloneBoard(board) { return board.map((row) => row.map(cloneCell)); }
export function snapshot(player) {
return {
board: cloneBoard(player.board),
powerGems: [...player.powerGems.values()].map((g) => ({ ...g })),
};
}
function evt(player, type, extra = {}) {
return { type, ...extra, ...snapshot(player) };
}
// ── Piece geometry ───────────────────────────────────────────────────────────
const ORIENT_DELTA = [[-1, 0], [0, 1], [1, 0], [0, -1]]; // b relative to a
export function pieceCells(piece) {
const [dr, dc] = ORIENT_DELTA[piece.orient];
return [
{ r: piece.row, c: piece.col, half: 'a' },
{ r: piece.row + dr, c: piece.col + dc, half: 'b' },
];
}
function cellFree(board, r, c) {
return r >= 0 && r < HEIGHT && c >= 0 && c < WIDTH && !board[r][c];
}
function pieceFits(board, piece) {
return pieceCells(piece).every(({ r, c }) => cellFree(board, r, c));
}
// ── Spawning & garbage drops ─────────────────────────────────────────────────
export function spawnPiece(match, pIdx) {
const player = match.players[pIdx];
const events = [];
if (match.over || player.lost) return events;
if (player.pendingGarbage > 0) {
const dropped = dropGarbage(player);
events.push(evt(player, 'garbage', { cells: dropped, remaining: player.pendingGarbage }));
}
const proto = getSeqPiece(match, player.pieceIndex);
player.pieceIndex += 1;
const piece = { a: { ...proto.a }, b: { ...proto.b }, row: 1, col: SPAWN_COL, orient: 0 };
if (!pieceFits(player.board, piece)) {
player.lost = true;
match.over = true;
match.winner = 1 - pIdx;
events.push(evt(player, 'lose'));
return events;
}
player.piece = piece;
events.push(evt(player, 'spawn', { piece: { ...piece, a: { ...piece.a }, b: { ...piece.b } } }));
return events;
}
function topOfStack(board, col) {
for (let r = 0; r < HEIGHT; r++) if (board[r][col]) return r;
return HEIGHT;
}
function dropGarbage(player) {
const n = Math.min(player.pendingGarbage, MAX_GARBAGE_PER_DROP);
player.pendingGarbage -= n;
player.garbageReceived += n;
const placed = [];
for (let i = 0; i < n; i++) {
const col = i % WIDTH;
const rowOffset = Math.floor(i / WIDTH);
const color = player.dropPattern[(player.patternRow + rowOffset) % 4][col];
const r = topOfStack(player.board, col) - 1;
if (r < 0) continue; // column already full; the loss check happens at spawn
player.board[r][col] = { color, kind: KIND.COUNTER, count: COUNTER_START };
placed.push({ r, c: col, color });
}
player.patternRow = (player.patternRow + Math.ceil(n / WIDTH)) % 4;
return placed;
}
// ── Player input ─────────────────────────────────────────────────────────────
function tryShift(player, dc) {
if (!player.piece) return false;
const moved = { ...player.piece, col: player.piece.col + dc };
if (!pieceFits(player.board, moved)) return false;
player.piece = moved;
return true;
}
export function moveLeft(match, pIdx) { return tryShift(match.players[pIdx], -1); }
export function moveRight(match, pIdx) { return tryShift(match.players[pIdx], 1); }
const ROTATE_KICKS = [[0, 0], [0, -1], [0, 1], [-1, 0]];
function tryRotate(player, dir) {
if (!player.piece) return false;
const orient = (player.piece.orient + dir + 4) % 4;
for (const [dr, dc] of ROTATE_KICKS) {
const rotated = { ...player.piece, orient, row: player.piece.row + dr, col: player.piece.col + dc };
if (pieceFits(player.board, rotated)) { player.piece = rotated; return true; }
}
return false;
}
export function rotateCW(match, pIdx) { return tryRotate(match.players[pIdx], 1); }
export function rotateCCW(match, pIdx) { return tryRotate(match.players[pIdx], -1); }
export function stepDown(match, pIdx) {
const player = match.players[pIdx];
if (!player.piece || player.lost) return { locked: false, events: [] };
const moved = { ...player.piece, row: player.piece.row + 1 };
if (pieceFits(player.board, moved)) {
player.piece = moved;
return { locked: false, events: [] };
}
return { locked: true, events: lockPiece(match, pIdx) };
}
export function hardDrop(match, pIdx) {
const player = match.players[pIdx];
if (!player.piece || player.lost) return [];
let step = stepDown(match, pIdx);
while (!step.locked) step = stepDown(match, pIdx);
return step.events;
}
export function getGhostCells(player) {
if (!player.piece) return [];
let probe = { ...player.piece };
while (true) {
const next = { ...probe, row: probe.row + 1 };
if (!pieceFits(player.board, next)) break;
probe = next;
}
return pieceCells(probe);
}
// ── Lock + resolution pipeline ───────────────────────────────────────────────
function restingRow(board, col) {
return topOfStack(board, col) - 1; // -1 means column full
}
function lockPiece(match, pIdx) {
const player = match.players[pIdx];
const opponent = match.players[1 - pIdx];
const piece = player.piece;
player.piece = null;
const events = [];
const live = !match.headless; // headless sims (AI search) skip snapshot events
const push = (type, extra) => { if (live) events.push(evt(player, type, extra)); };
player.lastResolve = { attack: 0, cleared: 0, chain: 0 };
// Place: vertical pairs stack in-column; horizontal halves settle independently.
const placedCells = [];
const place = (half, r, c) => {
if (r < 0) return;
player.board[r][c] = { ...half };
placedCells.push({ r, c });
};
let diamondPos = null;
if (piece.orient === 0 || piece.orient === 2) {
const bottomHalf = piece.orient === 0 ? piece.a : piece.b;
const topHalf = piece.orient === 0 ? piece.b : piece.a;
const rBottom = restingRow(player.board, piece.col);
place(bottomHalf, rBottom, piece.col);
place(topHalf, rBottom - 1, piece.col);
if (bottomHalf.kind === KIND.DIAMOND) diamondPos = { r: rBottom, c: piece.col };
if (topHalf.kind === KIND.DIAMOND) diamondPos = { r: rBottom - 1, c: piece.col };
} else {
const cells = pieceCells(piece);
for (const { c, half } of cells) {
const r = restingRow(player.board, c);
place(piece[half], r, c);
if (piece[half].kind === KIND.DIAMOND) diamondPos = { r, c };
}
}
push('place', { cells: placedCells });
let attack = 0;
// Diamond: wipe every cell of the color directly beneath it.
if (diamondPos && player.board[diamondPos.r]?.[diamondPos.c]?.kind === KIND.DIAMOND) {
const below = player.board[diamondPos.r + 1]?.[diamondPos.c];
const wiped = [];
if (below && below.color != null) {
for (let r = 0; r < HEIGHT; r++) {
for (let c = 0; c < WIDTH; c++) {
const cell = player.board[r][c];
if (cell && cell.color === below.color) {
if (cell.powerId != null) player.powerGems.delete(cell.powerId);
player.board[r][c] = null;
wiped.push({ r, c, color: cell.color, kind: cell.kind });
}
}
}
attack += Math.floor(wiped.length / DIAMOND_DAMAGE_DIV);
player.lastResolve.cleared += wiped.length;
}
player.board[diamondPos.r][diamondPos.c] = null;
push('diamond', { cells: wiped });
}
attack += runCascade(player, live ? events : null);
// Counter tick: one decrement per lock; matured counters become plain gems
// and may themselves enable fusions/crashes, so re-resolve if any matured.
const matured = [];
for (let r = 0; r < HEIGHT; r++) {
for (let c = 0; c < WIDTH; c++) {
const cell = player.board[r][c];
if (cell?.kind === KIND.COUNTER) {
cell.count -= 1;
if (cell.count <= 0) {
player.board[r][c] = { color: cell.color, kind: KIND.GEM };
matured.push({ r, c, color: cell.color });
}
}
}
}
if (matured.length) {
push('counter', { matured });
attack += runCascade(player, live ? events : null);
}
// Damage: offset our own pending garbage first, remainder goes to opponent.
player.lastResolve.attack = attack;
if (attack > 0) {
const offset = Math.min(attack, player.pendingGarbage);
player.pendingGarbage -= offset;
const sent = attack - offset;
opponent.pendingGarbage += sent;
player.garbageSent += sent;
push('attack', { amount: attack, offset, sent });
}
return events;
}
function runCascade(player, events) {
let attack = 0;
let chain = 0;
while (true) {
const fell = settle(player);
const fused = fuseAll(player);
if ((fell || fused) && events) events.push(evt(player, 'settle'));
const { cleared, powerCells } = crashClear(player);
if (!cleared.length) break;
chain += 1;
player.bestChain = Math.max(player.bestChain, chain);
player.lastResolve.cleared += cleared.length;
player.lastResolve.chain = Math.max(player.lastResolve.chain, chain);
const mult = CHAIN_MULT[Math.min(chain - 1, CHAIN_MULT.length - 1)];
attack += Math.max(0, Math.ceil((cleared.length - ATTACK_DISCOUNT + powerCells * POWER_BONUS) * mult));
if (events) events.push(evt(player, 'clear', { cells: cleared, chain }));
}
return attack;
}
// ── Gravity (power gems fall rigidly) ────────────────────────────────────────
function settle(player) {
const { board, powerGems } = player;
let movedAny = false;
let movedThisPass = true;
while (movedThisPass) {
movedThisPass = false;
for (let r = HEIGHT - 2; r >= 0; r--) {
for (let c = 0; c < WIDTH; c++) {
const cell = board[r][c];
if (!cell || cell.powerId != null) continue;
if (!board[r + 1][c]) {
board[r + 1][c] = cell;
board[r][c] = null;
movedThisPass = true;
}
}
}
for (const gem of powerGems.values()) {
let canFall = gem.y + gem.h < HEIGHT;
for (let c = gem.x; canFall && c < gem.x + gem.w; c++) {
if (board[gem.y + gem.h][c]) canFall = false;
}
if (canFall) {
for (let c = gem.x; c < gem.x + gem.w; c++) {
board[gem.y + gem.h][c] = board[gem.y + gem.h - 1][c];
for (let r = gem.y + gem.h - 1; r > gem.y; r--) board[r][c] = board[r - 1][c];
board[gem.y][c] = null;
}
gem.y += 1;
movedThisPass = true;
}
}
if (movedThisPass) movedAny = true;
}
return movedAny;
}
// ── Power-gem fusion ─────────────────────────────────────────────────────────
function isFusable(cell, color) {
return cell && cell.kind === KIND.GEM && cell.powerId == null && cell.color === color;
}
function hasAnyTwoByTwo(board) {
for (let r = 0; r < HEIGHT - 1; r++) {
for (let c = 0; c < WIDTH - 1; c++) {
const cell = board[r][c];
if (!cell || cell.kind !== KIND.GEM || cell.powerId != null) continue;
if (isFusable(board[r][c + 1], cell.color) &&
isFusable(board[r + 1][c], cell.color) &&
isFusable(board[r + 1][c + 1], cell.color)) return true;
}
}
return false;
}
function findBestRect(board) {
let best = null;
for (let r = 0; r < HEIGHT - 1; r++) {
for (let c = 0; c < WIDTH - 1; c++) {
const cell = board[r][c];
if (!cell || cell.kind !== KIND.GEM || cell.powerId != null) continue;
const color = cell.color;
let maxW = 0;
while (c + maxW < WIDTH && isFusable(board[r][c + maxW], color)) maxW++;
for (let w = 2; w <= maxW; w++) {
let h = 1;
outer: while (r + h < HEIGHT) {
for (let cc = c; cc < c + w; cc++) {
if (!isFusable(board[r + h][cc], color)) break outer;
}
h++;
}
if (h >= 2 && (!best || w * h > best.w * best.h)) best = { x: c, y: r, w, h, color };
}
}
}
return best;
}
function applyPowerId(board, gem) {
for (let r = gem.y; r < gem.y + gem.h; r++) {
for (let c = gem.x; c < gem.x + gem.w; c++) board[r][c].powerId = gem.id;
}
}
function fuseAll(player) {
const { board, powerGems } = player;
let changed = false;
// New 2x2+ rectangles, largest-first.
if (hasAnyTwoByTwo(board)) {
let rect;
while ((rect = findBestRect(board))) {
const gem = { id: player.nextPowerId++, color: rect.color, x: rect.x, y: rect.y, w: rect.w, h: rect.h };
powerGems.set(gem.id, gem);
applyPowerId(board, gem);
changed = true;
}
}
// Row/column extension and same-seam merging.
let again = true;
while (again) {
again = false;
for (const gem of powerGems.values()) {
for (const dy of [-1, gem.h]) { // row above / below
const r = gem.y + (dy === -1 ? -1 : gem.h);
if (r < 0 || r >= HEIGHT) continue;
let full = true;
for (let c = gem.x; c < gem.x + gem.w; c++) if (!isFusable(board[r][c], gem.color)) { full = false; break; }
if (full) {
if (dy === -1) gem.y -= 1;
gem.h += 1;
applyPowerId(board, gem);
again = true; changed = true;
}
}
for (const dx of [-1, gem.w]) { // column left / right
const c = gem.x + (dx === -1 ? -1 : gem.w);
if (c < 0 || c >= WIDTH) continue;
let full = true;
for (let r = gem.y; r < gem.y + gem.h; r++) if (!isFusable(board[r][c], gem.color)) { full = false; break; }
if (full) {
if (dx === -1) gem.x -= 1;
gem.w += 1;
applyPowerId(board, gem);
again = true; changed = true;
}
}
}
// Merge pairs sharing a full seam.
const gems = [...powerGems.values()];
for (let i = 0; i < gems.length && !again; i++) {
for (let j = i + 1; j < gems.length && !again; j++) {
const a = gems[i], b = gems[j];
if (a.color !== b.color) continue;
const vStack = a.x === b.x && a.w === b.w && (a.y + a.h === b.y || b.y + b.h === a.y);
const hStack = a.y === b.y && a.h === b.h && (a.x + a.w === b.x || b.x + b.w === a.x);
if (vStack || hStack) {
a.x = Math.min(a.x, b.x); a.y = Math.min(a.y, b.y);
if (vStack) a.h += b.h; else a.w += b.w;
powerGems.delete(b.id);
applyPowerId(board, a);
again = true; changed = true;
}
}
}
}
return changed;
}
// ── Crash clearing ───────────────────────────────────────────────────────────
const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]];
function crashClear(player) {
const { board, powerGems } = player;
const toClear = new Set();
const visited = new Set();
for (let r = 0; r < HEIGHT; r++) {
for (let c = 0; c < WIDTH; c++) {
const cell = board[r][c];
if (!cell || cell.kind !== KIND.CRASH) continue;
const startKey = r * WIDTH + c;
if (visited.has(startKey)) continue;
const group = [];
const stack = [[r, c]];
visited.add(startKey);
while (stack.length) {
const [gr, gc] = stack.pop();
group.push([gr, gc]);
for (const [dr, dc] of ORTH) {
const nr = gr + dr, nc = gc + dc;
if (nr < 0 || nr >= HEIGHT || nc < 0 || nc >= WIDTH) continue;
const key = nr * WIDTH + nc;
if (visited.has(key)) continue;
const ncell = board[nr][nc];
if (ncell && ncell.color === cell.color && (ncell.kind === KIND.GEM || ncell.kind === KIND.CRASH)) {
visited.add(key);
stack.push([nr, nc]);
}
}
}
if (group.length >= 2) group.forEach(([gr, gc]) => toClear.add(gr * WIDTH + gc));
}
}
if (!toClear.size) return { cleared: [], powerCells: 0 };
// Counter gems adjacent to a cleared cell of their own color also clear.
for (const key of [...toClear]) {
const r = Math.floor(key / WIDTH), c = key % WIDTH;
const color = board[r][c].color;
for (const [dr, dc] of ORTH) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= HEIGHT || nc < 0 || nc >= WIDTH) continue;
const ncell = board[nr][nc];
if (ncell?.kind === KIND.COUNTER && ncell.color === color) toClear.add(nr * WIDTH + nc);
}
}
const cleared = [];
let powerCells = 0;
for (const key of toClear) {
const r = Math.floor(key / WIDTH), c = key % WIDTH;
const cell = board[r][c];
if (cell.powerId != null) { powerCells += 1; powerGems.delete(cell.powerId); }
cleared.push({ r, c, color: cell.color, kind: cell.kind });
board[r][c] = null;
}
// Drop powerId from any cells whose gem was destroyed (partial overlap can't
// happen — a power gem is monochrome and connected, so it clears whole — but
// guard against stale ids anyway).
for (let r = 0; r < HEIGHT; r++) {
for (let c = 0; c < WIDTH; c++) {
const cell = board[r][c];
if (cell?.powerId != null && !powerGems.has(cell.powerId)) delete cell.powerId;
}
}
return { cleared, powerCells };
}
// ── AI support ───────────────────────────────────────────────────────────────
export function legalPlacements(player) {
const placements = [];
for (let orient = 0; orient < 4; orient++) {
const [, dc] = ORIENT_DELTA[orient];
for (let col = 0; col < WIDTH; col++) {
const bCol = col + dc;
if (bCol < 0 || bCol >= WIDTH) continue;
placements.push({ col, orient });
}
}
return placements;
}
function clonePlayer(player) {
return {
...player,
board: cloneBoard(player.board),
powerGems: new Map([...player.powerGems.values()].map((g) => [g.id, { ...g }])),
piece: player.piece ? { ...player.piece, a: { ...player.piece.a }, b: { ...player.piece.b } } : null,
dropPattern: player.dropPattern,
};
}
// Simulate hard-dropping the current piece at (col, orient). Returns metrics
// without mutating the real match, or null if the placement is impossible.
export function simulatePlacement(match, pIdx, col, orient) {
const real = match.players[pIdx];
if (!real.piece) return null;
const player = clonePlayer(real);
const opponent = clonePlayer(match.players[1 - pIdx]);
const sim = { players: pIdx === 0 ? [player, opponent] : [opponent, player], over: false, winner: null, headless: true };
const target = { ...player.piece, row: 1, col, orient };
if (!pieceFits(player.board, target)) return null;
player.piece = target;
hardDrop(sim, pIdx);
const spawnBlocked = !cellFree(player.board, 1, SPAWN_COL) || !cellFree(player.board, 0, SPAWN_COL);
return {
player,
attack: player.lastResolve.attack,
cleared: player.lastResolve.cleared,
chain: player.lastResolve.chain,
died: spawnBlocked,
};
}

View File

@ -0,0 +1,51 @@
# Block Fighter
Go head-to-head against a rival in a falling-gem battle. Win by making your
opponent's board overflow — the fight is lost when a board's **spawn column**
(marked by the red arrow) fills to the top.
## The Basics
- Pairs of gems fall onto your board. Move them with **← →** (or A/D), rotate
with **↑ / X / Z**, drop faster with **↓** (or S), and slam them down with
**Space**. On a touchscreen, use the buttons along the bottom.
- Gems come in four colors. They stack where they land — horizontal pairs split
and each half falls to its own column.
## Crash Gems
The round, glowing orbs are **Crash Gems**. When a Crash Gem touches any gem of
its own color, it destroys the entire connected group of that color. This is
your only way to clear gems — plan your stacks so one Crash Gem wipes out a
big cluster.
## Power Gems
Build a solid rectangle of one color (2×2 or bigger) and it fuses into a giant
**Power Gem**. Power Gems count extra when destroyed, so building big before
you crash sends a much bigger attack.
## Chains
When gems are destroyed, everything above falls. If that drop brings another
Crash Gem into contact with its color, you get a **chain** — and every link in
the chain multiplies your attack.
## Counter Gems
Attacks land on the enemy board as **Counter Gems** — numbered blocks that
can't be crashed right away. Each number ticks down once per piece you drop;
at zero they become normal gems. You can also destroy a Counter Gem early by
clearing gems of its color right next to it. If you attack while garbage is
queued against you, your attack cancels it out first.
## The Diamond
Every 25th piece carries a **Diamond**. It destroys every gem of whatever
color it lands on — a great panic button, but it sends a weaker attack.
## The Ladder
Each level is a single battle against one fighter. Beat them to unlock the
next — opponents get smarter and faster as you climb. Lose, and you can retry
as many times as you like.

View File

@ -65,6 +65,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js'; import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js'; import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
import ShiftGame from './games/shift/ShiftGame.js'; import ShiftGame from './games/shift/ShiftGame.js';
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -143,6 +144,7 @@ const config = {
HexsweeperGame, HexsweeperGame,
PuddingMonstersGame, PuddingMonstersGame,
ShiftGame, ShiftGame,
BlockFighterGame,
], ],
}; };

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame' }; const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -62,6 +62,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('rushhour', '/data/rushhour.json'); this.load.json('rushhour', '/data/rushhour.json');
this.load.json('puddingmonsters', '/data/puddingmonsters.json'); this.load.json('puddingmonsters', '/data/puddingmonsters.json');
this.load.json('shift-artwork', '/data/shift-artwork.json'); this.load.json('shift-artwork', '/data/shift-artwork.json');
this.load.json('blockfighter', '/data/blockfighter.json');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');

View File

@ -80,3 +80,4 @@ registerGame({ slug: 'rushhour', name: 'Rush Hour', category:
registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 }); registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 });
registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 }); registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 });
registerGame({ slug: 'shift', name: 'Shift', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 55 }); registerGame({ slug: 'shift', name: 'Shift', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 55 });
registerGame({ slug: 'blockfighter', name: 'Block Fighter', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 56 });

View File

@ -0,0 +1,334 @@
// Headless verification for Block Fighter.
// node server/scripts/verifyBlockFighter.js [--quick]
// Exits non-zero on any failure.
//
// 1. Fixture tests: exact engine behavior on hand-built boards.
// 2. AI-vs-AI self-play with invariant checks after every lock.
// 3. Skill differentiation matrix (higher skill should win more).
// 4. Level bank lint (public/data/blockfighter.json vs opponents.json).
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
WIDTH, HEIGHT, SPAWN_COL, KIND, COUNTER_START, SPEED_GRAVITY_MS,
createMatch, spawnPiece, stepDown, hardDrop,
moveLeft, moveRight, rotateCW, rotateCCW,
} from '../../public/src/games/blockfighter/BlockFighterLogic.js';
import { createAI, planPlacement, nextAction } from '../../public/src/games/blockfighter/BlockFighterAI.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const QUICK = process.argv.includes('--quick');
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? `${detail}` : ''}`); }
}
// ── Fixture helpers ──────────────────────────────────────────────────────────
const gem = (color) => ({ color, kind: KIND.GEM });
const crash = (color) => ({ color, kind: KIND.CRASH });
const counter = (color, count = COUNTER_START) => ({ color, kind: KIND.COUNTER, count });
function freshMatch() { return createMatch({ seed: 42 }); }
function put(player, r, c, cell) { player.board[r][c] = cell; }
// Give the player a specific piece and hard-drop it.
function dropPiece(match, pIdx, a, b, col, orient = 0) {
const player = match.players[pIdx];
player.piece = { a, b, row: 1, col, orient };
return hardDrop(match, pIdx);
}
function cellAt(player, r, c) { return player.board[r][c]; }
function countCells(player, pred) {
let n = 0;
for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) {
if (player.board[r][c] && pred(player.board[r][c])) n++;
}
return n;
}
const BOTTOM = HEIGHT - 1;
// ── 1. Fixtures ──────────────────────────────────────────────────────────────
console.log('Fixtures:');
{
// Crash gem clears its connected same-color group.
const m = freshMatch();
const p = m.players[0];
put(p, BOTTOM, 0, gem(0));
put(p, BOTTOM - 1, 0, gem(0));
dropPiece(m, 0, crash(0), gem(1), 0);
check('crash clears connected group', countCells(p, (c) => c.color === 0) === 0);
check('non-matching half survives', countCells(p, (c) => c.color === 1) === 1);
check('cleared cells settle', cellAt(p, BOTTOM, 0)?.color === 1);
}
{
// Lone crash gem stays put.
const m = freshMatch();
const p = m.players[0];
dropPiece(m, 0, crash(0), gem(1), 0);
check('lone crash gem stays', countCells(p, (c) => c.kind === KIND.CRASH) === 1);
}
{
// 2x2 fusion into a power gem; then column extension to 2x3.
const m = freshMatch();
const p = m.players[0];
dropPiece(m, 0, gem(2), gem(2), 0);
dropPiece(m, 0, gem(2), gem(2), 1);
check('2x2 fuses into power gem', p.powerGems.size === 1);
const g0 = [...p.powerGems.values()][0];
check('power gem is 2x2', g0 && g0.w === 2 && g0.h === 2);
dropPiece(m, 0, gem(2), gem(2), 2);
const g1 = [...p.powerGems.values()][0];
check('power gem extends to 3 wide', p.powerGems.size === 1 && g1.w === 3 && g1.h === 2,
JSON.stringify([...p.powerGems.values()]));
}
{
// Rigid power-gem gravity: gem bridges a hole and falls as a unit.
const m = freshMatch();
const p = m.players[0];
// pillar in col 0 only; power gem sits on rows 10-11 across cols 0-1, hole below col 1
put(p, BOTTOM, 0, gem(3));
put(p, BOTTOM - 1, 0, gem(3));
const pg = { id: 99, color: 1, x: 0, y: BOTTOM - 3, w: 2, h: 2 };
p.powerGems.set(pg.id, pg);
for (let r = pg.y; r < pg.y + pg.h; r++) for (let c = pg.x; c < pg.x + pg.w; c++) {
put(p, r, c, { color: 1, kind: KIND.GEM, powerId: 99 });
}
// drop something far away to trigger a resolution pass
dropPiece(m, 0, gem(0), gem(2), 5);
const g = p.powerGems.get(99);
check('power gem rests on support, bridging the hole', g && g.y === BOTTOM - 3 && !cellAt(p, BOTTOM, 1),
JSON.stringify(g));
// remove the pillar support and resolve again: gem should drop as a unit
put(p, BOTTOM, 0, null);
put(p, BOTTOM - 1, 0, null);
dropPiece(m, 0, gem(0), gem(2), 5);
const g2 = p.powerGems.get(99);
check('power gem falls rigidly when support clears', g2 && g2.y === BOTTOM - 1,
JSON.stringify(g2));
}
{
// Counter gems: tick per lock, mature into normal gems at 0.
const m = freshMatch();
const p = m.players[0];
put(p, BOTTOM, 0, counter(2, 2));
dropPiece(m, 0, gem(0), gem(1), 5);
check('counter ticks down on lock', cellAt(p, BOTTOM, 0)?.count === 1);
dropPiece(m, 0, gem(0), gem(1), 4);
check('counter matures into gem', cellAt(p, BOTTOM, 0)?.kind === KIND.GEM);
}
{
// Counter destroyed when an adjacent same-color group clears.
const m = freshMatch();
const p = m.players[0];
put(p, BOTTOM, 0, gem(0));
put(p, BOTTOM, 1, counter(0));
dropPiece(m, 0, crash(0), gem(1), 0);
check('adjacent same-color counter cleared', countCells(p, (c) => c.kind === KIND.COUNTER) === 0);
}
{
// Diamond wipes the color beneath it; vanishes on bare floor.
const m = freshMatch();
const p = m.players[0];
put(p, BOTTOM, 0, gem(3));
put(p, BOTTOM, 3, gem(3));
put(p, BOTTOM, 5, counter(3));
put(p, BOTTOM, 1, gem(2));
dropPiece(m, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 0); // diamond (bottom half) lands on yellow
check('diamond wipes all of that color (incl. counters)', countCells(p, (c) => c.color === 3) === 0);
check('other colors survive diamond', countCells(p, (c) => c.color === 2) === 2);
check('diamond itself is gone', countCells(p, (c) => c.kind === KIND.DIAMOND) === 0);
const m2 = freshMatch();
dropPiece(m2, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 2);
check('diamond on bare floor vanishes', countCells(m2.players[0], (c) => c.kind === KIND.DIAMOND) === 0);
}
{
// Attack is offset by own pending garbage before reaching the opponent.
const m = freshMatch();
const p = m.players[0];
p.pendingGarbage = 100;
put(p, BOTTOM, 0, gem(0));
put(p, BOTTOM - 1, 0, gem(0));
put(p, BOTTOM, 1, gem(0));
put(p, BOTTOM - 1, 1, gem(0));
dropPiece(m, 0, crash(0), gem(1), 2, 3); // crash next to the 2x2... orient 3: b left
check('clear happened for offset test', p.lastResolve.cleared >= 5, `cleared=${p.lastResolve.cleared}`);
check('attack offsets own pending garbage', p.pendingGarbage < 100 && m.players[1].pendingGarbage === 0,
`pending=${p.pendingGarbage}, opp=${m.players[1].pendingGarbage}`);
}
{
// Unoffset attack lands on the opponent; garbage drops as counters.
const m = freshMatch();
const p = m.players[0];
for (let r = 0; r < 4; r++) for (let c = 0; c < 2; c++) put(p, BOTTOM - r, c, gem(1));
dropPiece(m, 0, crash(1), gem(0), 2, 3);
const sent = m.players[1].pendingGarbage;
check('attack reaches opponent', sent > 0, `sent=${sent}`);
spawnPiece(m, 1);
const counters = countCells(m.players[1], (c) => c.kind === KIND.COUNTER);
check('garbage drops as counter gems on spawn', counters === Math.min(sent, 24),
`counters=${counters} sent=${sent}`);
check('counter colors follow the drop pattern',
m.players[1].board.flat().filter(Boolean).every((c) => c.kind !== KIND.COUNTER || c.color != null));
}
{
// Chain: red crash dropped in col 1 clears the reds; the green crash above
// them falls beside the green gem and triggers a second clear.
const m = freshMatch();
const p = m.players[0];
put(p, BOTTOM, 0, gem(0));
put(p, BOTTOM - 1, 0, gem(0));
put(p, BOTTOM - 2, 0, crash(1));
put(p, BOTTOM, 1, gem(1));
dropPiece(m, 0, crash(0), gem(2), 1); // lands beside the red pair
check('chain of 2 detected', p.lastResolve.chain === 2, `chain=${p.lastResolve.chain}`);
check('chain cleared everything green', countCells(p, (c) => c.color === 1) === 0);
}
{
// Spawn-blocked loss in the spawn column.
const m = freshMatch();
const p = m.players[0];
for (let r = 0; r < HEIGHT; r++) put(p, r, SPAWN_COL, gem(r % 4));
spawnPiece(m, 0);
check('blocked spawn loses the match', p.lost && m.over && m.winner === 1);
}
// ── 2 & 3. Self-play with invariants + skill matrix ─────────────────────────
function checkInvariants(match, tag) {
for (const p of match.players) {
for (let r = 0; r < HEIGHT - 1; r++) {
for (let c = 0; c < WIDTH; c++) {
const cell = p.board[r][c];
if (cell && !p.board[r + 1][c] && cell.powerId == null) {
throw new Error(`${tag}: floating cell at ${r},${c}`);
}
}
}
for (const g of p.powerGems.values()) {
if (g.w < 2 || g.h < 2) throw new Error(`${tag}: degenerate power gem ${JSON.stringify(g)}`);
for (let r = g.y; r < g.y + g.h; r++) for (let c = g.x; c < g.x + g.w; c++) {
const cell = p.board[r]?.[c];
if (!cell || cell.powerId !== g.id || cell.color !== g.color) {
throw new Error(`${tag}: power gem cell mismatch at ${r},${c}: ${JSON.stringify(g)}`);
}
}
}
for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) {
const cell = p.board[r][c];
if (cell?.powerId != null && !p.powerGems.has(cell.powerId)) {
throw new Error(`${tag}: orphaned powerId at ${r},${c}`);
}
}
if (p.pendingGarbage < 0) throw new Error(`${tag}: negative pendingGarbage`);
}
}
const TICK_MS = 25;
function playMatch(skillA, skillB, seed, speed = 3, pieceCap = 1200) {
const match = createMatch({ seed });
const ais = [
createAI({ skill: skillA, speed, seed: seed * 2 + 1 }),
createAI({ skill: skillB, speed, seed: seed * 3 + 7 }),
];
const gravityMs = SPEED_GRAVITY_MS[speed];
const gravTimer = [0, 0];
let now = 0;
let pieces = 0;
for (const i of [0, 1]) {
spawnPiece(match, i);
if (match.players[i].piece) planPlacement(ais[i], match, i);
}
while (!match.over && pieces < pieceCap) {
now += TICK_MS;
for (const i of [0, 1]) {
if (match.over) break;
const p = match.players[i];
if (!p.piece) continue;
let locked = false;
const act = nextAction(ais[i], match, i, now);
if (act === 'left') moveLeft(match, i);
else if (act === 'right') moveRight(match, i);
else if (act === 'rotateCW') rotateCW(match, i);
else if (act === 'rotateCCW') rotateCCW(match, i);
else if (act === 'softDrop') locked = stepDown(match, i).locked;
else if (act === 'hardDrop') { hardDrop(match, i); locked = true; }
gravTimer[i] += TICK_MS;
if (!locked && p.piece && gravTimer[i] >= gravityMs) {
gravTimer[i] = 0;
locked = stepDown(match, i).locked;
}
if (locked) {
pieces += 1;
checkInvariants(match, `match(seed=${seed},${skillA}v${skillB})`);
if (!match.over) {
spawnPiece(match, i);
if (match.players[i].piece) planPlacement(ais[i], match, i);
}
}
}
}
return { winner: match.over ? match.winner : null, pieces };
}
console.log('\nSelf-play invariants:');
{
const games = QUICK ? 10 : 40;
let decided = 0, totalPieces = 0;
for (let s = 1; s <= games; s++) {
const { winner, pieces } = playMatch(5, 5, s * 101);
if (winner !== null) decided += 1;
totalPieces += pieces;
}
check(`self-play runs clean (${games} games)`, true);
check('most games reach a decision', decided >= games * 0.8, `${decided}/${games}`);
console.log(` avg pieces/game: ${(totalPieces / games).toFixed(1)}`);
}
console.log('\nSkill differentiation:');
{
const games = QUICK ? 8 : 24;
const pairs = [[2, 8], [3, 6], [5, 6], [1, 10]];
for (const [lo, hi] of pairs) {
let hiWins = 0, decided = 0;
for (let s = 1; s <= games; s++) {
// alternate sides to cancel any side bias
const flip = s % 2 === 1;
const { winner } = playMatch(flip ? lo : hi, flip ? hi : lo, s * 977 + lo * 13 + hi);
if (winner === null) continue;
decided += 1;
if ((flip && winner === 1) || (!flip && winner === 0)) hiWins += 1;
}
const rate = decided ? hiWins / decided : 0;
const need = hi - lo >= 5 ? 0.7 : 0.5;
check(`skill ${hi} beats ${lo} (${(rate * 100).toFixed(0)}% of ${decided})`, rate >= need);
}
}
// ── 4. Level bank lint ───────────────────────────────────────────────────────
console.log('\nLevel bank:');
{
const bank = JSON.parse(readFileSync(join(__dirname, '../../public/data/blockfighter.json'), 'utf8'));
const roster = JSON.parse(readFileSync(join(__dirname, '../../public/data/opponents.json'), 'utf8'));
const ids = new Set((roster.opponents ?? roster).map((o) => o.id));
const levels = bank.levels ?? [];
check('bank has levels', levels.length > 0);
let ok = true;
levels.forEach((lv, i) => {
if (lv.level !== i + 1) ok = false;
if (!(lv.skill >= 1 && lv.skill <= 10)) ok = false;
if (!(lv.speed >= 1 && lv.speed <= 5)) ok = false;
if (lv.dropPattern && !(Array.isArray(lv.dropPattern) && lv.dropPattern.length === 4
&& lv.dropPattern.every((row) => /^[RGBY]{6}$/.test(row)))) ok = false;
if (!ids.has(lv.opponentId)) console.warn(` warn: level ${lv.level} opponent '${lv.opponentId}' not in roster`);
});
check('levels contiguous with valid skill/speed/dropPattern', ok);
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.');
process.exit(failures ? 1 : 0);