Added Block Fighter game
This commit is contained in:
parent
c5971d8eb1
commit
bf47c50dfa
Binary file not shown.
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
|
@ -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"] }
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -65,6 +65,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
|
|||
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
||||
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
|
||||
import ShiftGame from './games/shift/ShiftGame.js';
|
||||
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -143,6 +144,7 @@ const config = {
|
|||
HexsweeperGame,
|
||||
PuddingMonstersGame,
|
||||
ShiftGame,
|
||||
BlockFighterGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('rushhour', '/data/rushhour.json');
|
||||
this.load.json('puddingmonsters', '/data/puddingmonsters.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-sink', '/assets/fx/water-sink.mp3');
|
||||
|
|
|
|||
|
|
@ -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: '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: 'blockfighter', name: 'Block Fighter', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 56 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue