1385 lines
54 KiB
JavaScript
1385 lines
54 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 {
|
||
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 3–15); 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));
|
||
}
|
||
}
|