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.
This commit is contained in:
Brian Fertig 2026-06-02 14:25:13 -06:00
parent 5c88ab7986
commit 2f7b2e183e
1 changed files with 67 additions and 10 deletions

View File

@ -392,7 +392,7 @@ export default class BlokusGame extends Phaser.Scene {
if (this.gameOver) return;
const mv = chooseMove(this.gs, seat, this.skillForSeat(seat));
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() {
@ -402,22 +402,79 @@ export default class BlokusGame extends Phaser.Scene {
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);
if (next === this.gs) { done(); return; } // illegal — should not happen
this.gs = next;
playSound(this, SFX.CARD_PLACE);
this.renderPieces();
this.renderTray();
this.renderScores();
// Brief pop on the freshly placed cells.
if (animateFromPortrait && seat > 0) {
this.animateAIPiece(seat, move, done);
} else {
this.renderPieces();
this.renderTray();
this.renderScores();
// Brief pop on the freshly placed cells.
const cells = placementCells(move.pieceId, move.oriIdx, move.anchorR, move.anchorC);
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);
}
}
animateAIPiece(seat, move, done) {
const cells = placementCells(move.pieceId, move.oriIdx, move.anchorR, move.anchorC);
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() });
const col = COLOR_PALETTE[seat];
this.time.delayedCall(180, done);
// 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() {