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