fertig-classic-games/src/games/bookwork/BookworkGame.js

1385 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, specialTileChances,
} from './BookworkLogic.js';
import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
const CELL = 96;
const GRID_W = CELL * GRID_SIZE;
const GRID_X = (GAME_WIDTH - GRID_W) / 2; // 720
const GRID_Y = 210;
const PLAYER_CX = 290;
const OPP_CX = GAME_WIDTH - 290; // 1630
const TILE_COLS = {
normal: { bg: 0xf0eada, border: 0xbfb59e, letter: '#2a1a0a', shine: 0xffffff },
gold: { bg: 0xd4af37, border: 0xf0d060, letter: '#1a0e00', shine: 0xfffff0 },
diamond: { bg: 0x1d6fa8, border: 0x55aadd, letter: '#e8f4ff', shine: 0xaaddff },
fire: { bg: 0xb83222, border: 0xe05030, letter: '#fff0e8', shine: 0xff9977 },
poison: { bg: 0x5d2580, border: 0x9944c8, letter: '#f0e8ff', shine: 0xcc88ff },
};
const D = {
bg: -2, board: 0, tiles: 5, selLine: 8,
selHighlight: 10, letters: 15, ui: 20,
fx: 30, overlay: 60, overlayUI: 62,
};
export default class BookworkGame extends Phaser.Scene {
constructor() { super('BookworkGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' };
this.config = { playerBaseHp: 100, milestones: [], levels: [] };
this.bank = [];
this.roster = [];
this.levelsCompleted = 0;
this.canPersist = true;
this.view = 'select';
this.portraits = [];
this.match = null;
this.playfield = null;
this.playfieldTiles = [];
this.selectedColorScheme = null;
this._colorDropdownDomEl = null;
this.bgFill = null;
this.bgTex = null;
// Battle state
this.grid = null;
this.wordSet = null; // steering dictionary (ENABLE words 315); null → unsteered
this.steerOpts = {};
this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 };
this.tileObjs = null;
this.selection = [];
this.selGraphics = null;
this.playerHp = 100;
this.playerMaxHp = 100;
this.oppHp = 0;
this.oppMaxHp = 0;
this.turnPhase = 'idle';
this.potionUsed = false;
this.potionUnlocked = false;
this.playerHpText = null;
this.oppHpText = null;
this.playerHpBar = null;
this.oppHpBar = null;
this.wordText = null;
this.statusText = null;
this.submitBtn = null;
this.potionBtn = null;
this.poisonWarning = null;
}
async create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) {}
const raw = this.cache.json.get('bookwork');
if (raw) this.config = raw;
this.steerOpts = this.config.steer ?? {};
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level);
// Load the steering dictionary (same ENABLE list the word validator uses).
// Non-fatal: if it fails, boards fall back to plain weighted-random.
try {
const res = await fetch('data/wordlists/enable1.txt');
if (res.ok) this.wordSet = parseWordList(await res.text());
} catch (_) { this.wordSet = null; }
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/bookwork/progress');
this.levelsCompleted = res?.levelsCompleted ?? 0;
} catch (_) {
this.canPersist = false;
this.levelsCompleted = 0;
}
this.makeTextures();
// Load saved playfield preference
const pfData = this.cache.json.get('playfields') ?? {};
const pfItems = pfData.playfields ?? [];
const savedId = localStorage.getItem('bookwork-playfield');
this.playfield = pfItems.find(p => p.id === savedId) ?? pfItems.find(p => p.id === pfData.default) ?? pfItems[0] ?? null;
// Load saved color scheme for colored playfield
const schemes = this.cache.json.get('colored-playfields')?.schemes ?? [];
const savedSchemeId = localStorage.getItem('bookwork-colorscheme');
this.selectedColorScheme = schemes.find(s => s.id === savedSchemeId) ?? schemes[Math.floor(Math.random() * schemes.length)] ?? null;
const bgCx = GAME_WIDTH / 2, bgCy = GAME_HEIGHT / 2;
this.bgFill = this.add.rectangle(bgCx, bgCy, GAME_WIDTH, GAME_HEIGHT, 0x0f0a05).setDepth(D.bg);
this.applyPlayfieldBg(this.playfield);
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;
return { id: levelDef.opponentId, spriteIndex: 0, name: levelDef.opponentId, bio: '', speech: {} };
}
applyPlayfieldBg(pf) {
if (pf?.type === 'colored' && this.selectedColorScheme) {
const texKey = `playfield-colored-${this.selectedColorScheme.id}`;
this.generateColoredTexture(this.selectedColorScheme, texKey);
if (this.bgTex) {
this.bgTex.setTexture(texKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setVisible(true);
} else {
this.bgTex = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, texKey)
.setDepth(D.bg).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
}
this.bgFill.setFillStyle(0x0f0a05);
} else if (pf?.key && this.textures.exists(pf.key)) {
if (this.bgTex) {
this.bgTex.setTexture(pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setVisible(true);
} else {
this.bgTex = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDepth(D.bg).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
}
this.bgFill.setFillStyle(0x0f0a05);
} else {
if (this.bgTex) this.bgTex.setVisible(false);
const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x0f0a05;
this.bgFill.setFillStyle(color);
}
}
generateColoredTexture(scheme, texKey) {
const W = GAME_WIDTH, H = GAME_HEIGHT;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = scheme.base;
ctx.fillRect(0, 0, W, H);
const n = scheme.accents.length;
const D2 = Math.sqrt(W * W + H * H);
const px = -H / D2, py = W / D2;
scheme.accents.forEach((hex, i) => {
const t = (i + 1) / (n + 1);
const gx = W * t, gy = H * (1 - t);
const spread = D2 * 0.55;
const grad = ctx.createLinearGradient(gx - px * spread, gy - py * spread, gx + px * spread, gy + py * spread);
grad.addColorStop(0, hex + '00');
grad.addColorStop(0.5, hex + '80');
grad.addColorStop(1, hex + '00');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
});
if (this.textures.exists(texKey)) this.textures.remove(texKey);
this.textures.addCanvas(texKey, canvas);
}
_buildColorSchemeDropdown(x, y) {
const schemes = this.cache.json.get('colored-playfields')?.schemes ?? [];
if (!schemes.length) return;
const wrapper = document.createElement('div');
wrapper.style.cssText = 'text-align:center;';
const label = document.createElement('span');
label.textContent = 'Color Scheme:';
label.style.cssText = 'font-family:"Julius Sans One",sans-serif;font-size:15px;color:#9e9080;margin-right:10px;vertical-align:middle;';
const select = document.createElement('select');
select.style.cssText = 'background:#1e1a12;color:#f2ead8;border:2px solid #9e9080;border-radius:6px;padding:6px 14px;font-family:"Julius Sans One",sans-serif;font-size:15px;cursor:pointer;outline:none;vertical-align:middle;';
select.addEventListener('mouseover', () => { select.style.borderColor = '#c8a84b'; });
select.addEventListener('mouseout', () => { select.style.borderColor = '#9e9080'; });
schemes.forEach(scheme => {
const opt = document.createElement('option');
opt.value = scheme.id;
opt.textContent = scheme.name;
select.appendChild(opt);
});
select.value = this.selectedColorScheme?.id ?? schemes[0].id;
select.addEventListener('change', (e) => {
const scheme = schemes.find(s => s.id === e.target.value) ?? schemes[0];
this.selectedColorScheme = scheme;
localStorage.setItem('bookwork-colorscheme', scheme.id);
if (this.playfield?.type === 'colored') this.applyPlayfieldBg(this.playfield);
});
wrapper.appendChild(label);
wrapper.appendChild(select);
this._colorDropdownDomEl = this.add.dom(x, y, wrapper);
}
// ── Textures ────────────────────────────────────────────────────────────────
makeTextures() {
if (this.textures.exists('bw-tile-normal')) return;
for (const [type, col] of Object.entries(TILE_COLS)) {
const g = this.make.graphics({ add: false });
g.fillStyle(col.bg, 1);
g.fillRoundedRect(3, 3, CELL - 6, CELL - 6, 12);
g.fillStyle(col.shine, 0.25);
g.fillRoundedRect(7, 5, CELL - 14, 16, 6);
g.lineStyle(2, col.border, 1);
g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 12);
g.generateTexture(`bw-tile-${type}`, CELL, CELL);
g.destroy();
}
// Selection highlight overlay
const sel = this.make.graphics({ add: false });
sel.lineStyle(4, 0xfff5a0, 1);
sel.strokeRoundedRect(2, 2, CELL - 4, CELL - 4, 12);
sel.fillStyle(0xfff5a0, 0.15);
sel.fillRoundedRect(2, 2, CELL - 4, CELL - 4, 12);
sel.generateTexture('bw-sel', CELL, CELL);
sel.destroy();
// HP bar backgrounds
const hpBg = this.make.graphics({ add: false });
hpBg.fillStyle(0x1a1208, 1);
hpBg.fillRoundedRect(0, 0, 220, 22, 6);
hpBg.generateTexture('bw-hpbg', 220, 22);
hpBg.destroy();
// Firework spark particle
const fw = this.make.graphics({ add: false });
fw.fillStyle(0xffffff, 1);
fw.fillCircle(5, 5, 5);
fw.generateTexture('bw-fw-spark', 10, 10);
fw.destroy();
}
// ── View management ─────────────────────────────────────────────────────────
clearLayer() {
if (this._colorDropdownDomEl) { this._colorDropdownDomEl.destroy(); this._colorDropdownDomEl = null; }
for (const p of this.portraits) { try { p.destroy(); } catch (_) {} }
this.portraits = [];
this.turnPhase = 'idle';
this.selection = [];
this.grid = null;
this.tileObjs = null;
this.selGraphics = null;
this.layer.removeAll(true);
}
// ── Level select ─────────────────────────────────────────────────────────────
showLevelSelect() {
this.view = 'select';
this.match = null;
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 84, 'BOOKWORM', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', {
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/bookwork.json', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex,
}).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, 180, `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 = 305;
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 ? 0x1f4a2a : playable ? 0x1e3040 : 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', () => { playSound(this, SFX.UI_PICK); this.showIntro(level); });
}
});
// Playfield picker — per-tile Containers at (x, pfY) with children at local (0,0),
// matching OpponentSelectScene pattern so input + rendering work inside this.layer.
this.playfieldTiles = [];
const pfItems = this.cache.json.get('playfields')?.playfields ?? [];
const pfLabel = this.add.text(cx, 672, 'Playfield', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add(pfLabel);
if (pfItems.length > 0) {
const PW = 190, PH = 90, PGAP = 16;
const totalPfW = pfItems.length * PW + (pfItems.length - 1) * PGAP;
const pfLeft = cx - totalPfW / 2 + PW / 2;
const pfY = 760;
const thumbW = PW - 16, thumbH = PH - 30;
pfItems.forEach((pf, i) => {
const isSel = this.playfield?.id === pf.id;
// Each tile is its own Container — children use local (0, 0)
const tc = this.add.container(pfLeft + i * (PW + PGAP), pfY);
this.layer.add(tc);
const bg = this.add.rectangle(0, 0, PW, PH, COLORS.panel)
.setStrokeStyle(isSel ? 4 : 2, isSel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const overlay = this.add.rectangle(0, 0, PW, PH, COLORS.accent, isSel ? 0.22 : 0);
let thumb;
if (pf.key && this.textures.exists(pf.key)) {
thumb = this.add.image(0, -8, pf.key).setDisplaySize(thumbW, thumbH).setOrigin(0.5);
} else {
const fb = pf.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x1a1208;
thumb = this.add.rectangle(0, -8, thumbW, thumbH, fb);
}
const nameText = this.add.text(0, PH / 2 - 11, pf.name, {
fontFamily: '"Julius Sans One"', fontSize: '13px',
color: isSel ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5);
tc.add([bg, overlay, thumb, nameText]);
this.playfieldTiles.push({ pf, bg, overlay, nameText });
bg.on('pointerup', () => {
this.playfield = pf;
localStorage.setItem('bookwork-playfield', pf.id);
this.applyPlayfieldBg(pf);
if (this._colorDropdownDomEl) {
this._colorDropdownDomEl.node.style.display = pf.type === 'colored' ? 'block' : 'none';
}
this.playfieldTiles.forEach(t => {
const s = t.pf.id === pf.id;
t.bg.setStrokeStyle(s ? 4 : 2, s ? COLORS.accent : COLORS.muted);
t.overlay.setFillStyle(COLORS.accent, s ? 0.22 : 0);
t.nameText.setColor(s ? COLORS.goldHex : COLORS.textHex);
});
});
bg.on('pointerover', () => { if (this.playfield?.id !== pf.id) bg.setStrokeStyle(2, COLORS.text); });
bg.on('pointerout', () => { if (this.playfield?.id !== pf.id) bg.setStrokeStyle(2, COLORS.muted); });
});
// Color scheme dropdown positioned below the "Colored" tile
const coloredIdx = pfItems.findIndex(p => p.type === 'colored');
if (coloredIdx >= 0) {
const dropX = pfLeft + coloredIdx * (PW + PGAP);
const dropY = pfY + PH / 2 + 30;
this._buildColorSchemeDropdown(dropX, dropY);
if (this.playfield?.type !== 'colored' && this._colorDropdownDomEl) {
this._colorDropdownDomEl.node.style.display = 'none';
}
}
}
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, 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 t1 = this.add.text(cx, cy - 92, 'Reset Progress?', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const t2 = this.add.text(cx, cy - 14, 'This clears every opponent you have beaten\nand starts you back at Level 1.', {
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/bookwork/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, t1, t2, yes, no]);
}
// ── 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);
this.layer.add(title);
this.portraits.push(createOpponentPortrait(this, opp, cx, 360, 150, D.ui, { playIntro: true }));
const name = this.add.text(cx, 550, opp.name, { fontFamily: 'Righteous', fontSize: '54px', color: COLORS.textHex }).setOrigin(0.5);
const bio = this.add.text(cx, 614, opp.bio ?? '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5);
const tagline = this.add.text(cx, 658, lv.tagline ?? '', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex, fontStyle: 'italic',
}).setOrigin(0.5);
const stars = '★'.repeat(lv.skill) + '☆'.repeat(5 - lv.skill);
const stats = this.add.text(cx, 710, `Skill ${stars} HP ${lv.hp}`, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5);
const maxHp = computeMaxHp(this.config, this.levelsCompleted);
const potionNote = isPotionUnlocked(this.config, this.levelsCompleted) ? ' • Potion ready!' : '';
const you = this.add.text(cx, 756, `Your HP: ${maxHp}${potionNote}`, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
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([name, bio, tagline, stats, you, fight, back]);
}
// ── Battle ───────────────────────────────────────────────────────────────────
startBattle(level) {
const lv = this.bank.find((l) => l.level === level);
if (!lv) return;
this.view = 'battle';
this.level = level;
this.levelDef = lv;
this.opponent = this.opponentFor(lv);
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
this.clearLayer();
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted);
this.playerHp = this.playerMaxHp;
this.oppMaxHp = lv.hp;
this.oppHp = lv.hp;
this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
this.potionUsed = false;
this.turnPhase = 'player';
this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid();
this.selection = [];
this.drawBattleUI();
this.buildTileGrid();
playSound(this, SFX.UI_ACTIVATE);
}
drawBattleUI() {
const cx = GAME_WIDTH / 2;
const lv = this.levelDef;
const opp = this.opponent;
// Header
const hdr = this.add.text(cx, 56, `Level ${this.level} — vs ${opp.name}`, {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add(hdr);
// Dark panels behind portraits
const mkPanel = (x) => {
const g = this.add.graphics().setDepth(D.board);
g.fillStyle(COLORS.panel, 0.9);
g.fillRoundedRect(x - 175, 105, 350, 490, 14);
return g;
};
this.layer.add([mkPanel(PLAYER_CX), mkPanel(OPP_CX)]);
// Portrait: player (left)
this.portraits.push(createPlayerPortrait(this, PLAYER_CX, 240, 76, D.ui));
// Portrait: opponent (right)
this.portraits.push(createOpponentPortrait(this, opp, OPP_CX, 240, 76, D.ui, { playIntro: false }));
// Player label + HP
this.add.text(PLAYER_CX, 340, 'YOU', { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(D.ui);
this.playerHpBar = this.add.graphics().setDepth(D.ui);
this.playerHpText = this.add.text(PLAYER_CX, 403, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.ui);
// Opponent label + HP
this.add.text(OPP_CX, 340, opp.name.toUpperCase(), { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(D.ui);
this.oppHpBar = this.add.graphics().setDepth(D.ui);
this.oppHpText = this.add.text(OPP_CX, 403, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.ui);
this.updateHpBars();
// Potion button (player panel)
if (this.potionUnlocked) {
this.potionBtn = new Button(this, PLAYER_CX, 480, '💊 Potion (+20 HP)', () => this.usePotion(),
{ width: 240, height: 46, fontSize: 18 }).setDepth(D.ui);
this.layer.add(this.potionBtn);
}
// Poison warning (hidden by default)
this.poisonWarning = this.add.text(PLAYER_CX, 530, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#dd99ff',
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add(this.poisonWarning);
// Grid frame
const gf = this.add.graphics().setDepth(D.board);
gf.fillStyle(COLORS.panel, 1);
gf.fillRoundedRect(GRID_X - 12, GRID_Y - 12, GRID_W + 24, GRID_W + 24, 14);
gf.lineStyle(2, COLORS.accent, 0.5);
gf.strokeRoundedRect(GRID_X - 12, GRID_Y - 12, GRID_W + 24, GRID_W + 24, 14);
this.layer.add(gf);
// Selection line graphics (drawn on top of tiles)
this.selGraphics = this.add.graphics().setDepth(D.selLine);
this.layer.add(this.selGraphics);
// Word display panel
const wp = this.add.graphics().setDepth(D.board);
wp.fillStyle(COLORS.panel, 0.9);
wp.fillRoundedRect(cx - 280, GRID_Y + GRID_W + 16, 560, 90, 12);
this.layer.add(wp);
this.wordText = this.add.text(cx, GRID_Y + GRID_W + 44, '', {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.statusText = this.add.text(cx, GRID_Y + GRID_W + 82, 'Select adjacent letters to form a word (3+ letters)', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add([this.wordText, this.statusText]);
// Buttons
this.submitBtn = new Button(this, cx + 110, GRID_Y + GRID_W + 140, 'SUBMIT', () => this.submitWord(),
{ width: 220, height: 52, fontSize: 24 });
const clearBtn = new Button(this, cx - 140, GRID_Y + GRID_W + 140, 'CLEAR', () => { this.clearSelection(); playSound(this, SFX.UI_PICK); },
{ variant: 'ghost', width: 160, height: 52, fontSize: 24 });
this.layer.add([this.submitBtn, clearBtn]);
// Back to levels
const back = new Button(this, 90, 56, '← Levels', () => this.showLevelSelect(),
{ variant: 'ghost', width: 180, height: 44, fontSize: 18 });
this.layer.add(back);
}
// ── Tile grid ────────────────────────────────────────────────────────────────
buildTileGrid() {
this.tileObjs = [];
for (let r = 0; r < GRID_SIZE; r++) {
this.tileObjs.push([]);
for (let c = 0; c < GRID_SIZE; c++) {
const x = GRID_X + c * CELL + CELL / 2;
const y = GRID_Y + r * CELL + CELL / 2;
const type0 = this.grid[r][c].type;
const bg = this.add.image(x, y, `bw-tile-${type0}`).setDepth(D.tiles);
const sel = this.add.image(x, y, 'bw-sel').setDepth(D.selHighlight).setAlpha(0);
const txt = this.add.text(x, y, this.grid[r][c].letter, {
fontFamily: 'Righteous', fontSize: '40px',
color: TILE_COLS[type0]?.letter ?? '#2a1a0a',
}).setOrigin(0.5).setDepth(D.letters);
const multLabel = type0 === 'gold' ? '1.5x' : type0 === 'diamond' ? '2x' : '';
const multColor = type0 === 'gold' ? '#1a0e00' : '#e8f4ff';
const mult = this.add.text(x + CELL / 2 - 6, y + CELL / 2 - 5, multLabel, {
fontFamily: 'Righteous', fontSize: '16px', color: multColor,
}).setOrigin(1, 1).setDepth(D.letters).setAlpha(multLabel ? 1 : 0);
// Capture r/c for click handler
const zone = this.add.rectangle(x, y, CELL - 4, CELL - 4, 0x000000, 0)
.setDepth(D.letters + 1).setInteractive({ useHandCursor: true });
zone.on('pointerup', () => { if (this.turnPhase === 'player') this.handleTileClick(r, c); });
zone.on('pointerover', () => { if (this.turnPhase === 'player') bg.setAlpha(0.85); });
zone.on('pointerout', () => { bg.setAlpha(1); });
this.tileObjs[r].push({ bg, sel, txt, mult, zone });
this.layer.add([bg, sel, txt, mult, zone]);
}
}
}
redrawTile(r, c) {
const cell = this.grid[r][c];
const obj = this.tileObjs[r][c];
obj.bg.setTexture(`bw-tile-${cell.type}`);
obj.txt.setText(cell.letter);
obj.txt.setColor(TILE_COLS[cell.type]?.letter ?? '#2a1a0a');
const multLabel = cell.type === 'gold' ? '1.5x' : cell.type === 'diamond' ? '2x' : '';
obj.mult.setText(multLabel);
obj.mult.setAlpha(multLabel ? 1 : 0);
obj.mult.setColor(cell.type === 'gold' ? '#1a0e00' : '#e8f4ff');
}
redrawAllTiles() {
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) this.redrawTile(r, c);
}
}
// ── Selection ────────────────────────────────────────────────────────────────
handleTileClick(r, c) {
const last = this.selection[this.selection.length - 1];
// Backtrack: clicking the last selected tile
if (last && last.r === r && last.c === c) {
this.selection.pop();
playSound(this, SFX.UI_PICK);
this.updateSelectionGraphics();
this.updateWordDisplay();
return;
}
// Already in selection (not last) — ignore
if (this.selection.some((s) => s.r === r && s.c === c)) return;
// Must be adjacent to the last tile
if (last && !isAdjacent(last, { r, c })) {
// Non-adjacent: clear and restart from this tile
this.clearSelection();
}
this.selection.push({ r, c });
playSound(this, SFX.PIECE_CLICK);
this.updateSelectionGraphics();
this.updateWordDisplay();
}
updateSelectionGraphics() {
// Update sel highlights
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
const inSel = this.selection.some((s) => s.r === r && s.c === c);
this.tileObjs[r][c].sel.setAlpha(inSel ? 1 : 0);
}
}
// Draw connecting lines between selected tiles
this.selGraphics.clear();
if (this.selection.length < 2) return;
this.selGraphics.lineStyle(5, 0xfff5a0, 0.7);
const first = this.selection[0];
const fx = GRID_X + first.c * CELL + CELL / 2;
const fy = GRID_Y + first.r * CELL + CELL / 2;
this.selGraphics.beginPath();
this.selGraphics.moveTo(fx, fy);
for (let i = 1; i < this.selection.length; i++) {
const s = this.selection[i];
this.selGraphics.lineTo(GRID_X + s.c * CELL + CELL / 2, GRID_Y + s.r * CELL + CELL / 2);
}
this.selGraphics.strokePath();
}
clearSelection() {
this.selection = [];
this.updateSelectionGraphics();
this.updateWordDisplay();
}
updateWordDisplay() {
const word = wordFromCells(this.grid, this.selection);
if (!word) {
this.wordText.setText('');
this.statusText.setText('Select adjacent letters to form a word (3+ letters)').setColor(COLORS.mutedHex);
return;
}
this.wordText.setText(word);
if (word.length >= 3) {
const dmg = computeDamage(this.selection, this.grid);
const selfDmg = computeSelfDamage(this.selection, this.grid);
const hasGold = this.selection.some(({ r, c }) => this.grid[r][c].type === 'gold');
const hasDiamond = this.selection.some(({ r, c }) => this.grid[r][c].type === 'diamond');
const hasFire = this.selection.some(({ r, c }) => this.grid[r][c].type === 'fire');
let tip = `Damage: ${dmg}`;
if (hasGold) tip += ' ✦ Gold tile';
if (hasDiamond) tip += ' ◆ Diamond tile';
if (hasFire && selfDmg > 0) tip += ` 🔥 Fire: -${selfDmg} HP!`;
this.statusText.setText(tip).setColor(selfDmg > 0 ? '#ff8866' : COLORS.goldHex);
} else {
this.statusText.setText('Need 3+ letters').setColor(COLORS.mutedHex);
}
}
// ── Word submission ──────────────────────────────────────────────────────────
async submitWord() {
if (this.turnPhase !== 'player' || this.selection.length < 3) return;
const word = wordFromCells(this.grid, this.selection);
const cells = this.selection.slice();
this.turnPhase = 'resolving';
let valid = false;
try {
const res = await api.post('/words/scrabble/validate', { words: [word] });
valid = res?.valid ?? false;
} catch (_) {
this.turnPhase = 'player';
return;
}
if (!valid) {
this.shakeWordDisplay();
this.statusText.setText(`"${word}" is not a valid word`).setColor(COLORS.dangerHex);
this.selection = cells; // restore selection
this.turnPhase = 'player';
return;
}
await this.resolveTurn(word, cells);
}
async resolveTurn(word, cells) {
const dmg = computeDamage(cells, this.grid);
const selfDmg = computeSelfDamage(cells, this.grid);
// Clear selection visuals before animation starts
this.selection = [];
this.selGraphics?.clear();
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) this.tileObjs[r][c].sel.setAlpha(0);
}
// Full word animation: fly letters → fireworks → +Damage → opponent HP
await this.animateWord(word, cells, dmg);
// Self-damage from fire tiles (word < 5 letters)
if (selfDmg > 0) {
this.playerHp = Math.max(0, this.playerHp - selfDmg);
this.floatDamage(PLAYER_CX, 200, `-${selfDmg}`, '#ff6633');
playSound(this, SFX.SWORD_HIT);
this.updateHpBars();
await this.delay(350);
}
this.statusText.setText(`${word}${dmg} damage!`).setColor('#aaffaa');
await this.delay(200);
if (this.oppHp <= 0) { await this.onVictory(); return; }
// Opponent attacks
await this.opponentAttack();
if (this.playerHp <= 0) { await this.onDefeat(); return; }
// Poison tick (after opponent turn)
const poisonCount = countPoisonTiles(this.grid);
if (poisonCount > 0) {
this.playerHp = Math.max(0, this.playerHp - poisonCount);
this.floatDamage(PLAYER_CX, 200, `-${poisonCount} poison`, '#cc88ff');
this.updateHpBars();
await this.delay(350);
if (this.playerHp <= 0) { await this.onDefeat(); return; }
}
// Refill grid
this.grid = this.wordSet
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
await this.animateRefill(cells);
this.redrawAllTiles();
const pc = countPoisonTiles(this.grid);
this.poisonWarning.setText(pc > 0 ? `${pc} poison tile${pc > 1 ? 's' : ''} active` : '');
this.wordText.setText('');
this.statusText.setText('Select adjacent letters to form a word (3+ letters)').setColor(COLORS.mutedHex);
this.turnPhase = 'player';
}
// ── Word animation sequence ──────────────────────────────────────────────────
// Phase 1: each letter flies from its tile to its word position at center (0.5s each)
// Phase 2: fireworks burst around the word for 1.5s
// Phase 3: word transforms to "+X Damage" in green (0.4s fade)
// Phase 4: "+X Damage" flies to opponent portrait while shrinking (0.7s)
// Phase 5: opponent HP flashes and reduces
async animateWord(word, cells, damage) {
const cx = GAME_WIDTH / 2;
const cy = GRID_Y - 60; // between header and tile grid
const FONT_END = 72;
const FONT_START = 36;
const SCALE_END = FONT_END / FONT_START;
// --- Phase 1: measure each character width at target size ---
const charWidths = word.split('').map((ch) => {
const t = this.add.text(-3000, -3000, ch, { fontFamily: 'Righteous', fontSize: `${FONT_END}px` });
const w = t.width;
t.destroy();
return w;
});
const GAP = 3;
const totalW = charWidths.reduce((s, w) => s + w, 0) + (word.length - 1) * GAP;
let xCur = cx - totalW / 2;
const destPos = charWidths.map((w) => {
const pos = { x: xCur + w / 2, y: cy };
xCur += w + GAP;
return pos;
});
const flyTexts = [];
for (let i = 0; i < cells.length; i++) {
const { r, c } = cells[i];
const tileType = this.grid[r][c].type;
// Hide the source tile
this.tileObjs[r][c].bg.setAlpha(0);
this.tileObjs[r][c].txt.setAlpha(0);
this.tileObjs[r][c].mult.setAlpha(0);
// Start the flying letter at the tile's screen position
const tileX = GRID_X + c * CELL + CELL / 2;
const tileY = GRID_Y + r * CELL + CELL / 2;
const flyLetter = this.add.text(tileX, tileY, word[i], {
fontFamily: 'Righteous',
fontSize: `${FONT_START}px`,
color: '#ffffff',
stroke: '#1a0a00',
strokeThickness: 4,
}).setOrigin(0.5).setDepth(D.fx).setScale(1);
this.layer.add(flyLetter);
flyTexts.push(flyLetter);
playSound(this, i % 2 === 0 ? SFX.SCIFI_PLINK : SFX.SCIFI_PLONK);
// Tween to its position in the word at center, growing to full size
await new Promise((resolve) => {
this.tweens.add({
targets: flyLetter,
x: destPos[i].x,
y: destPos[i].y,
scaleX: SCALE_END,
scaleY: SCALE_END,
duration: 500,
ease: 'Cubic.easeInOut',
onComplete: resolve,
});
});
}
// --- Phase 2: fireworks for 1.5 seconds ---
const FW_COLORS = [0xff4444, 0xffff44, 0x44ff88, 0x44aaff, 0xff88ff, 0xffcc44];
const margin = 70;
const fwPositions = [
{ x: cx - totalW / 2 - margin, y: cy - 55 },
{ x: cx + totalW / 2 + margin, y: cy - 55 },
{ x: cx, y: cy - 110 },
{ x: cx - totalW / 2 - margin / 2, y: cy + 65 },
{ x: cx + totalW / 2 + margin / 2, y: cy + 65 },
];
fwPositions.forEach(({ x, y }, i) => {
this.time.delayedCall(i * 280, () => {
playSound(this, SFX.FIREWORK);
this.spawnFirework(x, y, FW_COLORS[i % FW_COLORS.length]);
});
});
await this.delay(1500);
// --- Phase 3: transform word → "+X Damage" in green ---
await new Promise((resolve) => {
this.tweens.add({
targets: flyTexts,
alpha: 0,
scaleX: 1.3,
scaleY: 1.3,
duration: 250,
ease: 'Cubic.easeOut',
onComplete: resolve,
});
});
for (const t of flyTexts) t.destroy();
const dmgText = this.add.text(cx, cy, `+${damage} Damage`, {
fontFamily: 'Righteous',
fontSize: '78px',
color: '#44ff88',
stroke: '#0a2010',
strokeThickness: 6,
}).setOrigin(0.5).setDepth(D.fx).setAlpha(0).setScale(0.7);
this.layer.add(dmgText);
await new Promise((resolve) => {
this.tweens.add({
targets: dmgText,
alpha: 1,
scaleX: 1,
scaleY: 1,
duration: 400,
ease: 'Back.easeOut',
onComplete: resolve,
});
});
await this.delay(400);
// --- Phase 4: fly "+X Damage" to opponent portrait ---
const OPP_PORTRAIT_Y = 240;
await new Promise((resolve) => {
this.tweens.add({
targets: dmgText,
x: OPP_CX,
y: OPP_PORTRAIT_Y,
scaleX: 0.3,
scaleY: 0.3,
alpha: 0.6,
duration: 700,
ease: 'Cubic.easeIn',
onComplete: resolve,
});
});
dmgText.destroy();
// --- Phase 5: opponent HP flashes and reduces ---
this.oppHp = Math.max(0, this.oppHp - damage);
playSound(this, SFX.SWORD_SLICE);
const flash = this.add.rectangle(OPP_CX, 310, 360, 430, 0xff2222, 0.45).setDepth(D.fx);
this.layer.add(flash);
await new Promise((resolve) => {
this.tweens.add({
targets: flash,
alpha: 0,
duration: 550,
ease: 'Cubic.easeOut',
onComplete: () => { flash.destroy(); resolve(); },
});
});
this.updateHpBars();
await this.delay(200);
}
spawnFirework(x, y, tint) {
try {
const em = this.add.particles(x, y, 'bw-fw-spark', {
speed: { min: 140, max: 380 },
angle: { min: 0, max: 360 },
lifespan: { min: 500, max: 950 },
scale: { start: 1.6, end: 0 },
alpha: { start: 1, end: 0 },
tint,
blendMode: 'ADD',
quantity: 24,
emitting: false,
}).setDepth(D.fx + 5);
em.explode(24);
this.time.delayedCall(1100, () => { try { em.destroy(); } catch (_) {} });
} catch (_) { /* particles optional */ }
}
async animateRefill(usedCells) {
// Group used rows by column
const usedRowsByCol = {};
for (const { r, c } of usedCells) {
if (!usedRowsByCol[c]) usedRowsByCol[c] = [];
usedRowsByCol[c].push(r);
}
const affectedCols = Object.keys(usedRowsByCol).map(Number);
// Mirror Bejeweled's pattern: compute falls (existing tiles that shift down) and
// refills (brand-new tiles that drop in from above).
// clearAndRefill puts nonUsed[GRID_SIZE-1-r] at new row r, where nonUsed is the
// list of surviving old row indices collected bottom-to-top.
const falls = []; // { fromR, toR, c } — real tileObj slides from fromR to toR
const refills = []; // { r, c, n } — temp object drops n rows from above
for (const c of affectedCols) {
const usedSet = new Set(usedRowsByCol[c]);
const n = usedSet.size;
const nonUsed = [];
for (let r = GRID_SIZE - 1; r >= 0; r--) {
if (!usedSet.has(r)) nonUsed.push(r);
}
for (let r_new = 0; r_new < GRID_SIZE; r_new++) {
if (r_new < n) {
refills.push({ r: r_new, c, n });
} else {
const r_old = nonUsed[GRID_SIZE - 1 - r_new];
if (r_old !== r_new) falls.push({ fromR: r_old, toR: r_new, c });
}
}
}
// Falls: slide the real tileObj (already visible at fromR) down to toR.
// Destination slots are guaranteed to be either alpha=0 (used tiles) or
// themselves sliding away — so nothing needs to be hidden beforehand.
let maxDuration = 0;
for (const { fromR, toR, c } of falls) {
const obj = this.tileObjs[fromR][c];
const finalY = GRID_Y + toR * CELL + CELL / 2;
const duration = 110 + 58 * (toR - fromR);
maxDuration = Math.max(maxDuration, duration);
this.tweens.add({ targets: [obj.bg, obj.txt, obj.mult], y: finalY, duration, ease: 'Bounce.easeOut' });
}
// Refills: create temporary visuals above the grid and tween them into place.
// All new tiles in a column fall the same n rows, stacked above in order.
const tempObjs = [];
for (const { r, c, n } of refills) {
const cell = this.grid[r][c];
const x = GRID_X + c * CELL + CELL / 2;
const startY = GRID_Y - (n - r) * CELL;
const finalY = GRID_Y + r * CELL + CELL / 2;
const duration = 110 + 58 * n;
maxDuration = Math.max(maxDuration, duration);
const tempBg = this.add.image(x, startY, `bw-tile-${cell.type}`).setDepth(D.tiles);
const tempTxt = this.add.text(x, startY, cell.letter, {
fontFamily: 'Righteous', fontSize: '40px',
color: TILE_COLS[cell.type]?.letter ?? '#2a1a0a',
}).setOrigin(0.5).setDepth(D.letters);
this.layer.add([tempBg, tempTxt]);
tempObjs.push(tempBg, tempTxt);
this.tweens.add({ targets: [tempBg, tempTxt], y: finalY, duration, ease: 'Bounce.easeOut' });
}
// Sound when the longest animation lands
if (falls.length > 0 || refills.length > 0) {
this.time.delayedCall(maxDuration + 20, () => playSound(this, SFX.GEM_DROP));
}
await this.delay(maxDuration + 80);
// Cleanup: destroy temp objects, snap slid tileObj y values back to their correct
// grid positions, and restore alpha on all affected tiles.
// redrawTile() does not restore bg/txt alpha, so destination slots (which were
// alpha=0 from the fly animation) must be shown here before redrawAllTiles() runs.
for (const t of tempObjs) { try { t.destroy(); } catch (_) {} }
for (const { fromR, c } of falls) {
const obj = this.tileObjs[fromR][c];
const snapY = GRID_Y + fromR * CELL + CELL / 2;
obj.bg.y = snapY;
obj.txt.y = snapY;
obj.mult.y = snapY + CELL / 2 - 5;
}
for (const c of affectedCols) {
for (let r = 0; r < GRID_SIZE; r++) {
this.tileObjs[r][c].bg.setAlpha(1);
this.tileObjs[r][c].txt.setAlpha(1);
}
}
}
async opponentAttack() {
const dmg = getAttackDamage(this.levelDef);
const special = getSpecialTile(this.levelDef);
this.statusText.setText(`${this.opponent.name} attacks!`).setColor(COLORS.dangerHex);
playSound(this, SFX.SWORD_HIT);
this.cameras.main.shake(180, 0.006);
await this.delay(300);
// Damage text starts at opponent portrait and flies to player portrait
const dmgText = this.add.text(OPP_CX, 490, `+${dmg} Damage`, {
fontFamily: 'Righteous',
fontSize: '52px',
color: '#ff4444',
stroke: '#200000',
strokeThickness: 5,
}).setOrigin(0.5).setDepth(D.fx).setAlpha(0).setScale(0.5);
this.layer.add(dmgText);
// Pop in at opponent portrait
await new Promise((resolve) => {
this.tweens.add({
targets: dmgText,
alpha: 1,
scaleX: 1,
scaleY: 1,
duration: 280,
ease: 'Back.easeOut',
onComplete: resolve,
});
});
await this.delay(150);
// Fly across to player portrait, shrinking as it arrives
await new Promise((resolve) => {
this.tweens.add({
targets: dmgText,
x: PLAYER_CX,
y: 490,
scaleX: 0.3,
scaleY: 0.3,
alpha: 0.6,
duration: 700,
ease: 'Cubic.easeIn',
onComplete: resolve,
});
});
dmgText.destroy();
// Apply damage, flash player panel, lower HP bar
this.playerHp = Math.max(0, this.playerHp - dmg);
const flash = this.add.rectangle(PLAYER_CX, 310, 360, 430, 0xff2222, 0.45).setDepth(D.fx);
this.layer.add(flash);
playSound(this, SFX.SWORD_SLICE);
await new Promise((resolve) => {
this.tweens.add({
targets: flash,
alpha: 0,
duration: 550,
ease: 'Cubic.easeOut',
onComplete: () => { flash.destroy(); resolve(); },
});
});
this.updateHpBars();
await this.delay(200);
if (special) {
this.grid = dropSpecialTile(this.grid, special);
this.statusText.setText(`${this.opponent.name} dropped a ${special} tile!`)
.setColor(special === 'fire' ? '#ff8844' : '#cc88ff');
playSound(this, SFX.GEM_DROP);
this.time.delayedCall(120, () => this.redrawAllTiles());
await this.delay(400);
}
}
// ── HP bars ─────────────────────────────────────────────────────────────────
updateHpBars() {
this.drawHpBar(this.playerHpBar, PLAYER_CX, 365, this.playerHp, this.playerMaxHp);
this.playerHpText.setText(`${this.playerHp} / ${this.playerMaxHp}`);
this.drawHpBar(this.oppHpBar, OPP_CX, 365, this.oppHp, this.oppMaxHp);
this.oppHpText.setText(`${this.oppHp} / ${this.oppMaxHp}`);
}
drawHpBar(g, cx, y, current, max) {
const W = 220, H = 22;
const pct = Math.max(0, current / max);
const filled = Math.round(W * pct);
const barColor = pct > 0.5 ? 0x2ecc71 : pct > 0.25 ? 0xf1c40f : 0xe04444;
g.clear();
g.fillStyle(0x1a1208, 1);
g.fillRoundedRect(cx - W / 2, y, W, H, 6);
if (filled > 0) {
g.fillStyle(barColor, 1);
g.fillRoundedRect(cx - W / 2, y, filled, H, 6);
}
g.lineStyle(2, 0x3a3020, 1);
g.strokeRoundedRect(cx - W / 2, y, W, H, 6);
}
async tweenHpBar(target) {
// Simple immediate redraw + short wait for visual feedback
this.updateHpBars();
await this.delay(180);
}
// ── Potion ───────────────────────────────────────────────────────────────────
usePotion() {
if (this.potionUsed || this.turnPhase !== 'player') return;
this.potionUsed = true;
this.playerHp = Math.min(this.playerMaxHp, this.playerHp + 20);
this.updateHpBars();
this.floatDamage(PLAYER_CX, 200, '+20 HP', '#66ffaa');
playSound(this, SFX.UI_CHIME);
if (this.potionBtn) {
this.potionBtn.setText('💊 Used');
this.potionBtn.setInteractive(false);
this.potionBtn.setAlpha(0.4);
}
}
// ── Victory / Defeat ─────────────────────────────────────────────────────────
async onVictory() {
playSound(this, SFX.VICTORY_SHORT);
this.cameras.main.flash(400, 255, 220, 80, false);
// Persist progress
if (this.canPersist && this.level === this.levelsCompleted + 1) {
try {
const res = await api.post('/puzzles/bookwork/complete', { level: this.level });
this.levelsCompleted = res?.levelsCompleted ?? this.levelsCompleted;
} catch (_) {}
} else if (this.level > this.levelsCompleted) {
this.levelsCompleted = this.level;
}
// Check milestones unlocked by this completion
const newMilestones = (this.config.milestones ?? []).filter(
(m) => m.afterLevel === this.level,
);
this.showResultOverlay(true, newMilestones);
}
async onDefeat() {
playSound(this, SFX.CASINO_LOSE);
this.cameras.main.shake(400, 0.014);
await this.delay(450);
this.showResultOverlay(false, []);
}
showResultOverlay(won, newMilestones) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setDepth(D.overlay).setInteractive();
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.97);
panel.fillRoundedRect(cx - 380, cy - 220, 760, won ? 440 + newMilestones.length * 36 : 340, 24);
panel.lineStyle(3, won ? COLORS.gold : COLORS.danger, 1);
panel.strokeRoundedRect(cx - 380, cy - 220, 760, won ? 440 + newMilestones.length * 36 : 340, 24);
const headline = won ? `Victory!` : `Defeated`;
const color = won ? COLORS.goldHex : COLORS.dangerHex;
const t1 = this.add.text(cx, cy - 150, headline, {
fontFamily: 'Righteous', fontSize: '72px', color,
}).setOrigin(0.5).setDepth(D.overlayUI);
const sub = won
? `You defeated ${this.opponent.name}!`
: `${this.opponent.name} was too powerful…`;
const t2 = this.add.text(cx, cy - 56, sub, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
let yOff = cy + 4;
if (won && newMilestones.length) {
const mLabel = this.add.text(cx, yOff, '— Milestone Unlocked —', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
yOff += 34;
for (const m of newMilestones) {
const parts = [];
if (m.maxHpBonus) parts.push(`+${m.maxHpBonus} Max HP`);
if (m.unlock === 'potion') parts.push('Potion Unlocked');
const ml = this.add.text(cx, yOff, parts.join(' • '), {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ccffcc',
}).setOrigin(0.5).setDepth(D.overlayUI);
yOff += 30;
this.layer.add([ml]);
}
yOff += 14;
this.layer.add(mLabel);
}
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
const primaryLabel = won
? (this.level < this.bank.length ? `Next: Level ${this.level + 1}` : 'All Levels Complete!')
: 'Try Again';
const primaryAction = won
? () => (this.level < this.bank.length ? this.showIntro(this.level + 1) : this.showLevelSelect())
: () => this.startBattle(this.level);
const btnPrimary = new Button(this, cx - 130, yOff + 60, primaryLabel, primaryAction,
{ width: 250, height: 60, fontSize: 24 }).setDepth(D.overlayUI);
const btnLevels = new Button(this, cx + 145, yOff + 60, 'Levels', () => this.showLevelSelect(),
{ variant: 'ghost', width: 200, height: 60, fontSize: 24 }).setDepth(D.overlayUI);
this.layer.add([dim, panel, t1, t2, btnPrimary, btnLevels]);
this.turnPhase = 'over';
}
// ── Helpers ──────────────────────────────────────────────────────────────────
floatDamage(x, y, text, color) {
const t = this.add.text(x, y, text, {
fontFamily: 'Righteous', fontSize: '38px', color,
stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5).setDepth(D.fx);
this.layer.add(t);
this.tweens.add({
targets: t, y: y - 90, alpha: 0, duration: 900,
ease: 'Cubic.easeOut', onComplete: () => t.destroy(),
});
}
shakeWordDisplay() {
playSound(this, SFX.MASTERMIND_DENIED);
const origX = this.wordText.x;
this.tweens.add({
targets: this.wordText,
x: origX + 12, duration: 60, yoyo: true, repeat: 3,
onComplete: () => { this.wordText.x = origX; },
});
}
delay(ms) {
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
}
}