Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 2f7b2e183e feat(blokus): animate AI piece placement from portrait to board
Introduce a visual transition where AI pieces fly from their opponent
portrait to the board upon placement. Human player placements remain
instant with a flash effect. Implemented animateAIPiece to handle the
tweening and temporary graphics rendering.
2026-06-02 14:25:13 -06:00
Brian Fertig 5c88ab7986 feat(blokus): add player and opponent portraits to score panels
Integrate portrait avatars into the Blokus score panels by increasing
panel height and adjusting the layout. Added portrait graphics with
colored strokes and repositioned text elements to accommodate the new
visual components.
2026-06-02 14:17:40 -06:00
Brian Fertig aa920fddfb ui: reposition control buttons in BlokusGame
Adjust button coordinates in buildControls() to update the control tray layout.
Pass, Rotate, and Flip buttons are moved lower and centered relative to the tray,
while New and Leave buttons are positioned at the bottom right.
2026-06-02 14:05:41 -06:00
1 changed files with 104 additions and 23 deletions

View File

@ -5,6 +5,7 @@ import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { import {
SIZE, CELL, BOARD_PX, GRID_X, GRID_Y, cellToWorld, worldToCell, SIZE, CELL, BOARD_PX, GRID_X, GRID_Y, cellToWorld, worldToCell,
COLOR_PALETTE, cornersFor, ALL_PIECE_IDS, PIECE_SIZE, ORIENTATIONS, COLOR_PALETTE, cornersFor, ALL_PIECE_IDS, PIECE_SIZE, ORIENTATIONS,
@ -44,6 +45,7 @@ export default class BlokusGame extends Phaser.Scene {
this.hoverMove = null; this.hoverMove = null;
this.trayObjs = []; this.trayObjs = [];
this.scoreObjs = []; this.scoreObjs = [];
this.opponentPortraits = [];
} }
create() { create() {
@ -255,18 +257,31 @@ export default class BlokusGame extends Phaser.Scene {
// ── Score panels + controls ────────────────────────────────────────────────── // ── Score panels + controls ──────────────────────────────────────────────────
buildScorePanels() { buildScorePanels() {
this.scorePanels = []; this.scorePanels = [];
const panelH = 192; // 3× original height
const portraitR = 60;
const portraitY = -panelH / 2 + portraitR + 14; // centered in upper space
for (let seat = 0; seat < this.gs.players.length; seat++) { for (let seat = 0; seat < this.gs.players.length; seat++) {
const y = 146 + seat * 74; const y = 221 + seat * (panelH + 10);
const container = this.add.container(SIDE_X, y).setDepth(DEPTH.hud); const container = this.add.container(SIDE_X, y).setDepth(DEPTH.hud);
const bg = this.add.graphics(); const bg = this.add.graphics();
container.add(bg); container.add(bg);
const swatch = this.add.graphics(); const swatch = this.add.graphics();
container.add(swatch); container.add(swatch);
const name = this.add.text(-120, -18, '', { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex }).setOrigin(0, 0.5); const portraitStroke = this.add.graphics().setDepth(DEPTH.hud + 1);
const detail = this.add.text(-120, 14, '', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0, 0.5); container.add(portraitStroke);
const score = this.add.text(150, 0, '', { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.accentHex }).setOrigin(1, 0.5); const name = this.add.text(-120, 60, '', { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex }).setOrigin(0, 0.5);
const detail = this.add.text(-120, 85, '', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const score = this.add.text(150, 60, '', { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.accentHex }).setOrigin(1, 0.5);
container.add([name, detail, score]); container.add([name, detail, score]);
this.scorePanels.push({ container, bg, swatch, name, detail, score }); this.scorePanels.push({ container, bg, swatch, portraitStroke, name, detail, score, panelH });
// Create portrait
if (seat === 0) {
createPlayerPortrait(this, SIDE_X, y + portraitY, portraitR, DEPTH.hud + 2, 'BlokusGame');
} else {
const opp = this.opponents[seat - 1];
this.opponentPortraits[seat - 1] = createOpponentPortrait(this, opp, SIDE_X, y + portraitY, portraitR, DEPTH.hud + 2);
}
} }
} }
@ -283,12 +298,17 @@ export default class BlokusGame extends Phaser.Scene {
const isTurn = !this.gameOver && this.gs.current === seat; const isTurn = !this.gameOver && this.gs.current === seat;
panel.bg.clear(); panel.bg.clear();
panel.bg.fillStyle(COLORS.panel, isTurn ? 0.95 : 0.6); panel.bg.fillStyle(COLORS.panel, isTurn ? 0.95 : 0.6);
panel.bg.fillRoundedRect(-180, -32, 360, 64, 10); panel.bg.fillRoundedRect(-180, -panel.panelH / 2, 360, panel.panelH, 10);
panel.bg.lineStyle(isTurn ? 3 : 1, isTurn ? COLORS.accent : col.hex, isTurn ? 1 : 0.5); panel.bg.lineStyle(isTurn ? 3 : 1, isTurn ? COLORS.accent : col.hex, isTurn ? 1 : 0.5);
panel.bg.strokeRoundedRect(-180, -32, 360, 64, 10); panel.bg.strokeRoundedRect(-180, -panel.panelH / 2, 360, panel.panelH, 10);
panel.swatch.clear(); panel.swatch.clear();
panel.swatch.fillStyle(col.hex, 1); panel.swatch.fillStyle(col.hex, 1);
panel.swatch.fillRoundedRect(-172, -16, 28, 32, 5); panel.swatch.fillRoundedRect(-172, panel.panelH / 2 - 44, 28, 32, 5);
// Portrait stroke — colored circle around the portrait
const portraitY = -panel.panelH / 2 + 60 + 14;
panel.portraitStroke.clear();
panel.portraitStroke.lineStyle(4, col.hex, 1);
panel.portraitStroke.strokeCircle(0, portraitY, 63);
panel.name.setText(this.playerName(seat)); panel.name.setText(this.playerName(seat));
panel.detail.setText(p.out ? 'done' : `${p.remaining.size} pieces left`); panel.detail.setText(p.out ? 'done' : `${p.remaining.size} pieces left`);
panel.score.setText(`${scoreFor(p)}`); panel.score.setText(`${scoreFor(p)}`);
@ -296,11 +316,15 @@ export default class BlokusGame extends Phaser.Scene {
} }
buildControls() { buildControls() {
this.rotateBtn = new Button(this, SIDE_X - 95, 610, '↻ Rotate', () => this.rotateSelection(), { width: 170, height: 48, fontSize: 20 }).setDepth(DEPTH.button); const trayCenter = TRAY_X + (TRAY_COLS * TRAY_SLOT_W) / 2;
this.flipBtn = new Button(this, SIDE_X + 95, 610, '↔ Flip', () => this.flipSelection(), { width: 170, height: 48, fontSize: 20 }).setDepth(DEPTH.button); this.passBtn = new Button(this, trayCenter, 898, 'Pass', () => this.humanPass(), { variant: 'ghost', width: 360, height: 48, fontSize: 20 }).setDepth(DEPTH.button);
this.passBtn = new Button(this, SIDE_X, 674, 'Pass', () => this.humanPass(), { variant: 'ghost', width: 360, height: 48, fontSize: 20 }).setDepth(DEPTH.button); this.rotateBtn = new Button(this, trayCenter - 95, 838, '↻ Rotate', () => this.rotateSelection(), { width: 170, height: 48, fontSize: 20 }).setDepth(DEPTH.button);
new Button(this, SIDE_X - 95, 750, 'New', () => this.scene.restart(), { variant: 'ghost', width: 170, height: 44, fontSize: 18 }).setDepth(DEPTH.button); this.flipBtn = new Button(this, trayCenter + 95, 838, '↔ Flip', () => this.flipSelection(), { width: 170, height: 48, fontSize: 20 }).setDepth(DEPTH.button);
new Button(this, SIDE_X + 95, 750, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 170, height: 44, fontSize: 18 }).setDepth(DEPTH.button); const rightPad = 20;
const gap = 20;
const leaveX = GAME_WIDTH - rightPad - 85;
new Button(this, leaveX - 170 - gap, 1030, 'New', () => this.scene.restart(), { variant: 'ghost', width: 170, height: 44, fontSize: 18 }).setDepth(DEPTH.button);
new Button(this, leaveX, 1030, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 170, height: 44, fontSize: 18 }).setDepth(DEPTH.button);
} }
buildStatus() { buildStatus() {
@ -368,7 +392,7 @@ export default class BlokusGame extends Phaser.Scene {
if (this.gameOver) return; if (this.gameOver) return;
const mv = chooseMove(this.gs, seat, this.skillForSeat(seat)); const mv = chooseMove(this.gs, seat, this.skillForSeat(seat));
if (!mv) { this.gs = passTurn(this.gs, seat); this.animating = false; this.driveTurn(); return; } if (!mv) { this.gs = passTurn(this.gs, seat); this.animating = false; this.driveTurn(); return; }
this.placePiece(seat, mv, () => { this.animating = false; this.driveTurn(); }); this.placePiece(seat, mv, () => { this.animating = false; this.driveTurn(); }, true);
} }
humanPass() { humanPass() {
@ -378,23 +402,80 @@ export default class BlokusGame extends Phaser.Scene {
this.driveTurn(); this.driveTurn();
} }
placePiece(seat, move, done) { placePiece(seat, move, done, animateFromPortrait = false) {
const next = applyPlacement(this.gs, seat, move.pieceId, move.oriIdx, move.anchorR, move.anchorC); const next = applyPlacement(this.gs, seat, move.pieceId, move.oriIdx, move.anchorR, move.anchorC);
if (next === this.gs) { done(); return; } // illegal — should not happen if (next === this.gs) { done(); return; } // illegal — should not happen
this.gs = next; this.gs = next;
playSound(this, SFX.CARD_PLACE); playSound(this, SFX.CARD_PLACE);
if (animateFromPortrait && seat > 0) {
this.animateAIPiece(seat, move, done);
} else {
this.renderPieces(); this.renderPieces();
this.renderTray(); this.renderTray();
this.renderScores(); this.renderScores();
// Brief pop on the freshly placed cells. // Brief pop on the freshly placed cells.
const cells = placementCells(move.pieceId, move.oriIdx, move.anchorR, move.anchorC); const cells = placementCells(move.pieceId, move.oriIdx, move.anchorR, move.anchorC);
const flash = this.add.graphics().setDepth(DEPTH.ghost); const flash = this.add.graphics().setDepth(DEPTH.ghost);
for (const [r, c] of cells) this.fillCell(flash, r, c, 0xffffff, 0.5); for (const [r, c] of cells) this.fillCell(flash, r, c, 0xffffff, 0.5);
this.tweens.add({ targets: flash, alpha: 0, duration: 280, ease: 'Quad.easeOut', onComplete: () => flash.destroy() }); this.tweens.add({ targets: flash, alpha: 0, duration: 280, ease: 'Quad.easeOut', onComplete: () => flash.destroy() });
this.time.delayedCall(180, done); this.time.delayedCall(180, done);
} }
}
animateAIPiece(seat, move, done) {
const cells = placementCells(move.pieceId, move.oriIdx, move.anchorR, move.anchorC);
const col = COLOR_PALETTE[seat];
// Calculate board center of the piece
let avgR = 0, avgC = 0;
for (const [r, c] of cells) { avgR += r; avgC += c; }
avgR /= cells.length;
avgC /= cells.length;
const destX = GRID_X + avgC * CELL;
const destY = GRID_Y + avgR * CELL;
// Portrait position for this opponent
const panelH = 192;
const portraitYOffset = -panelH / 2 + 60 + 14;
const panelY = 221 + (seat - 1) * (panelH + 10);
const startX = SIDE_X;
const startY = panelY + portraitYOffset + 200;
// Create flying piece graphics — draw centered at (0,0) so we can tween position
const flyGfx = this.add.graphics().setDepth(DEPTH.piece + 1);
flyGfx.fillStyle(col.hex, 1);
flyGfx.lineStyle(2, col.dark, 1);
for (const [r, c] of cells) {
const cx = (c - avgC) * CELL;
const cy = (r - avgR) * CELL;
flyGfx.fillRoundedRect(cx - CELL / 2 + 2, cy - CELL / 2 + 2, CELL - 4, CELL - 4, 6);
flyGfx.strokeRoundedRect(cx - CELL / 2 + 2, cy - CELL / 2 + 2, CELL - 4, CELL - 4, 6);
}
flyGfx.x = startX;
flyGfx.y = startY;
// Animate from portrait to board
const duration = 400;
this.tweens.add({
targets: flyGfx,
x: destX,
y: destY,
duration,
ease: 'Quad.easeInOut',
onComplete: () => {
flyGfx.destroy();
this.renderPieces();
this.renderTray();
this.renderScores();
// Brief pop on the freshly placed cells.
const flash = this.add.graphics().setDepth(DEPTH.ghost);
for (const [r, c] of cells) this.fillCell(flash, r, c, 0xffffff, 0.5);
this.tweens.add({ targets: flash, alpha: 0, duration: 280, ease: 'Quad.easeOut', onComplete: () => flash.destroy() });
this.time.delayedCall(180, done);
},
});
}
updateButtons() { updateButtons() {
const human = this.isHumanTurn(); const human = this.isHumanTurn();