diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 6821381..b075b70 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 8d21dc0..ad59c92 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/assets/speech/convo/fireball-balam.mp3 b/public/assets/speech/convo/fireball-balam.mp3 new file mode 100644 index 0000000..44a02e5 Binary files /dev/null and b/public/assets/speech/convo/fireball-balam.mp3 differ diff --git a/public/assets/speech/convo/fireball-dv-8-2303.mp3 b/public/assets/speech/convo/fireball-dv-8-2303.mp3 new file mode 100644 index 0000000..b3abcba Binary files /dev/null and b/public/assets/speech/convo/fireball-dv-8-2303.mp3 differ diff --git a/public/assets/speech/convo/fireball-zanthar.mp3 b/public/assets/speech/convo/fireball-zanthar.mp3 new file mode 100644 index 0000000..7e8a871 Binary files /dev/null and b/public/assets/speech/convo/fireball-zanthar.mp3 differ diff --git a/public/assets/speech/convo/gerome-kage.mp3 b/public/assets/speech/convo/gerome-kage.mp3 new file mode 100644 index 0000000..98915fc Binary files /dev/null and b/public/assets/speech/convo/gerome-kage.mp3 differ diff --git a/public/assets/speech/convo/gerome-natasha.mp3 b/public/assets/speech/convo/gerome-natasha.mp3 new file mode 100644 index 0000000..26d907d Binary files /dev/null and b/public/assets/speech/convo/gerome-natasha.mp3 differ diff --git a/public/assets/speech/convo/gerome-steve.mp3 b/public/assets/speech/convo/gerome-steve.mp3 new file mode 100644 index 0000000..b373ee1 Binary files /dev/null and b/public/assets/speech/convo/gerome-steve.mp3 differ diff --git a/public/assets/speech/convo/jeff-gerome.mp3 b/public/assets/speech/convo/jeff-gerome.mp3 new file mode 100644 index 0000000..e6e14f8 Binary files /dev/null and b/public/assets/speech/convo/jeff-gerome.mp3 differ diff --git a/public/data/opponents.json b/public/data/opponents.json index 5168ca5..53d9757 100644 --- a/public/data/opponents.json +++ b/public/data/opponents.json @@ -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", diff --git a/public/src/games/bookwork/BookworkGame.js b/public/src/games/bookwork/BookworkGame.js index 39abf9d..701e68e 100644 --- a/public/src/games/bookwork/BookworkGame.js +++ b/public/src/games/bookwork/BookworkGame.js @@ -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() { diff --git a/public/src/ui/ConvoManager.js b/public/src/ui/ConvoManager.js index 054e911..b3a1f44 100644 --- a/public/src/ui/ConvoManager.js +++ b/public/src/ui/ConvoManager.js @@ -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) {