feat(bookwork): add playfield backgrounds, tile multipliers, and enhanced refill animations
- Add customizable playfield backgrounds with localStorage persistence - Display multiplier labels on gold (1.5x) and diamond (2x) tiles - Replace simple fade-in refill with Bejeweled-style falling tile animations - Swap SUBMIT and CLEAR button positions for better UX - Add new opponents (Gerome, Fireball, Natasha) with voice lines and convo files - Reduce conversation interval from 60-300s to 20-200s - Enable DEBUG_CONVOS for development
This commit is contained in:
parent
6a4d42f8b7
commit
7e82de37c6
Binary file not shown.
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 270 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -205,7 +205,8 @@
|
|||
"voice": "SlowAltered",
|
||||
"convo": [
|
||||
"kona",
|
||||
"michael"
|
||||
"michael",
|
||||
"gerome"
|
||||
],
|
||||
"speech": {
|
||||
"intro": [
|
||||
|
|
@ -236,6 +237,12 @@
|
|||
"spriteIndex": 7,
|
||||
"name": "Gerome",
|
||||
"bio": "I'm here for the thrill of extreme victory!",
|
||||
"voice": "Mass Effect - Commander Shepard",
|
||||
"convo": [
|
||||
"kage",
|
||||
"natasha",
|
||||
"steve"
|
||||
],
|
||||
"speech": {
|
||||
"intro": [
|
||||
"gerome-intro-01",
|
||||
|
|
@ -266,6 +273,11 @@
|
|||
"name": "Fireball",
|
||||
"bio": "Come on let's play! I promise not to use my x-ray eyes.",
|
||||
"voice": "Zero - Borderlands 3",
|
||||
"convo": [
|
||||
"zanthor",
|
||||
"balam",
|
||||
"dv-8-2303"
|
||||
],
|
||||
"speech": {
|
||||
"intro": [
|
||||
"fireball-intro-01",
|
||||
|
|
@ -347,6 +359,7 @@
|
|||
"spriteIndex": 11,
|
||||
"name": "Natasha",
|
||||
"bio": "Perhaps if I win, you will spill the secrets you've been keeping!",
|
||||
"voice": "Street Fighter 6 - Marisa",
|
||||
"speech": {
|
||||
"intro": [
|
||||
"natasha-intro-01",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.view = 'select';
|
||||
this.portraits = [];
|
||||
this.match = null;
|
||||
this.playfield = null;
|
||||
this.playfieldTiles = [];
|
||||
this.bgFill = null;
|
||||
this.bgTex = null;
|
||||
// Battle state
|
||||
this.grid = null;
|
||||
this.tileObjs = null;
|
||||
|
|
@ -96,7 +100,17 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
this.makeTextures();
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0f0a05).setDepth(D.bg);
|
||||
|
||||
// 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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -107,6 +121,22 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
return { id: levelDef.opponentId, spriteIndex: 0, name: levelDef.opponentId, bio: '', speech: {} };
|
||||
}
|
||||
|
||||
applyPlayfieldBg(pf) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Textures ────────────────────────────────────────────────────────────────
|
||||
|
||||
makeTextures() {
|
||||
|
|
@ -238,6 +268,68 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
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); });
|
||||
});
|
||||
}
|
||||
|
||||
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'),
|
||||
|
|
@ -422,9 +514,9 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.layer.add([this.wordText, this.statusText]);
|
||||
|
||||
// Buttons
|
||||
this.submitBtn = new Button(this, cx - 140, GRID_Y + GRID_W + 140, 'SUBMIT', () => this.submitWord(),
|
||||
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 + 110, GRID_Y + GRID_W + 140, 'CLEAR', () => { this.clearSelection(); playSound(this, SFX.UI_PICK); },
|
||||
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]);
|
||||
|
||||
|
|
@ -444,13 +536,20 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
const x = GRID_X + c * CELL + CELL / 2;
|
||||
const y = GRID_Y + r * CELL + CELL / 2;
|
||||
|
||||
const bg = this.add.image(x, y, `bw-tile-${this.grid[r][c].type}`).setDepth(D.tiles);
|
||||
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[this.grid[r][c].type]?.letter ?? '#2a1a0a',
|
||||
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 });
|
||||
|
|
@ -458,8 +557,8 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
zone.on('pointerover', () => { if (this.turnPhase === 'player') bg.setAlpha(0.85); });
|
||||
zone.on('pointerout', () => { bg.setAlpha(1); });
|
||||
|
||||
this.tileObjs[r].push({ bg, sel, txt, zone });
|
||||
this.layer.add([bg, sel, txt, zone]);
|
||||
this.tileObjs[r].push({ bg, sel, txt, mult, zone });
|
||||
this.layer.add([bg, sel, txt, mult, zone]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -470,6 +569,10 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
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() {
|
||||
|
|
@ -656,7 +759,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
|
||||
async animateWord(word, cells, damage) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2 - 70;
|
||||
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;
|
||||
|
|
@ -686,6 +789,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
// 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;
|
||||
|
|
@ -831,24 +935,96 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
async animateRefill(usedCells) {
|
||||
// Briefly hide used tile objects (they now have new content) then fade in
|
||||
// Group used rows by column
|
||||
const usedRowsByCol = {};
|
||||
for (const { r, c } of usedCells) {
|
||||
const obj = this.tileObjs[r][c];
|
||||
obj.bg.setAlpha(0); obj.txt.setAlpha(0);
|
||||
obj.bg.setScale(1); obj.txt.setScale(1);
|
||||
if (!usedRowsByCol[c]) usedRowsByCol[c] = [];
|
||||
usedRowsByCol[c].push(r);
|
||||
}
|
||||
// Also animate tiles that shifted down
|
||||
const usedCols = [...new Set(usedCells.map(({ c }) => c))];
|
||||
for (const c of usedCols) {
|
||||
for (let r = 0; r < GRID_SIZE; r++) {
|
||||
const obj = this.tileObjs[r][c];
|
||||
this.tweens.add({
|
||||
targets: [obj.bg, obj.txt],
|
||||
alpha: 1, duration: 180, delay: r * 30, ease: 'Cubic.easeOut',
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
await this.delay(280);
|
||||
}
|
||||
|
||||
async opponentAttack() {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import { enqueue as enqueueSpeech } from './SpeechQueue.js';
|
|||
// This rides on the shared Portrait factory, so it applies to every game with
|
||||
// no per-game wiring.
|
||||
|
||||
const DEBUG_CONVOS = false;
|
||||
const DEBUG_CONVOS = true;
|
||||
|
||||
const MIN_MS = 60_000;
|
||||
const MAX_MS = 300_000;
|
||||
const MIN_MS = 20_000;
|
||||
const MAX_MS = 200_000;
|
||||
const ORANGE_RGB = [255, 122, 0];
|
||||
|
||||
function rand(min, max) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue