feat: add piece rotation/flip animations and UI sound feedback

Enhance Genius Square and Katamino with:
- Smooth piece rotation and flip animations (scale + rotate tweens)
- Pulsing outline glow on ghost piece preview
- New UI sound effects: pick, activate, flip, place, chime
- Music player support in both games
- Win chime with delayed overlay for better feedback
- Move Reset/Leave buttons to bottom in Katamino
This commit is contained in:
Brian Fertig 2026-06-20 09:31:04 -06:00
parent 762792f9f5
commit b7821c3bd8
11 changed files with 367 additions and 19 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

View File

@ -84,7 +84,10 @@ export default class GeniusSquareGame extends Phaser.Scene {
this.humanGridGfx = null;
this.aiGridGfx = null;
this.traySlots = []; // [{ container, pieceId }]
this._pulseGfx = null;
this._ghostAnimContainer = null;
this._ghostAnim = null;
this.traySlots = []; // [{ container, pieceId }]
this.opponentPortrait = null;
}
@ -124,6 +127,43 @@ export default class GeniusSquareGame extends Phaser.Scene {
this._startCountdown();
}
update(time) {
if (!this._pulseGfx || !this.ghostCells || !this.selectedPiece) {
this._pulseGfx?.clear();
return;
}
const cells = this.ghostCells;
const pieceId = this.selectedPiece.pieceId;
const alpha = 0.35 + 0.65 * Math.abs(Math.sin(time * 0.004));
const allValid = cells.every(([r, c]) =>
r >= 0 && r < 6 && c >= 0 && c < 6 && this.humanBoard[r * 6 + c] === null
);
const color = allValid ? (PIECE_COLORS[pieceId] ?? 0xffffff) : 0xff4444;
const ghostSet = new Set(cells.map(([r, c]) => `${r},${c}`));
const OFF = 4;
this._pulseGfx.clear();
this._pulseGfx.lineStyle(5, color, alpha);
for (const [r, c] of cells) {
const x0 = HGX + c * CELL, y0 = HGY + r * CELL;
const x1 = x0 + CELL, y1 = y0 + CELL;
if (!ghostSet.has(`${r - 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y0 - OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r + 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y1 + OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c - 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x0 - OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c + 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x1 + OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
}
}
// ── Scene construction ────────────────────────────────────────────────────────
_buildBackground() {
@ -165,6 +205,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
_buildGridGraphics() {
this.humanGridGfx = this.add.graphics().setDepth(D.grid);
this.aiGridGfx = this.add.graphics().setDepth(D.grid);
this._pulseGfx = this.add.graphics().setDepth(D.grid + 1);
}
_buildLabels() {
@ -358,6 +399,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
}
this.selectedPiece = { pieceId, oriIdx: 0 };
this.ghostCells = null;
playSound(this, SFX.UI_PICK);
this._renderTray();
this._renderHumanGrid();
}
@ -373,16 +415,130 @@ export default class GeniusSquareGame extends Phaser.Scene {
_rotate() {
if (!this.selectedPiece || this.gameOver) return;
this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
playSound(this, SFX.UI_ACTIVATE);
this._cancelGhostAnim();
if (!this.ghostCells) {
this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
return;
}
this._startGhostAnim('rotate');
}
_flip() {
if (!this.selectedPiece || this.gameOver) return;
this.selectedPiece.oriIdx = flipOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
playSound(this, SFX.UI_FLIP);
this._cancelGhostAnim();
if (!this.ghostCells) {
this.selectedPiece.oriIdx = flipOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
return;
}
this._startGhostAnim('flip');
}
_cancelGhostAnim() {
if (!this._ghostAnimContainer) return;
this.tweens.killTweensOf(this._ghostAnimContainer);
this._ghostAnimContainer.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
}
_startGhostAnim(type) {
const cells = this.ghostCells;
const pieceId = this.selectedPiece.pieceId;
const hoverR = this._lastHoverR;
const hoverC = this._lastHoverC;
const n = cells.length;
const cr = cells.reduce((s, [r]) => s + r, 0) / n;
const cc = cells.reduce((s, [, c]) => s + c, 0) / n;
const gfx = this.add.graphics();
this._fillGhostLocal(gfx, cells, cr, cc);
const pivotX = HGX + (cc + 0.5) * CELL;
const pivotY = HGY + (cr + 0.5) * CELL;
const container = this.add.container(pivotX, pivotY, [gfx]).setDepth(D.piece + 1);
this._ghostAnimContainer = container;
// Suppress normal ghost & pulse while animating
this.ghostCells = null;
this._pulseGfx?.clear();
this._renderHumanGrid();
const HALF = 80;
const finish = () => {
container.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
};
const phase2 = () => {
const oriCells = ORIENTATIONS[this.selectedPiece.pieceId][this.selectedPiece.oriIdx];
const [pinDr, pinDc] = _centerPin(oriCells);
const newCells = (hoverR >= 0 && hoverC >= 0)
? absoluteCells(oriCells, hoverR - pinDr, hoverC - pinDc)
: null;
if (!newCells) { finish(); return; }
const nn = newCells.length;
const ncr = newCells.reduce((s, [r]) => s + r, 0) / nn;
const ncc = newCells.reduce((s, [, c]) => s + c, 0) / nn;
gfx.clear();
this._fillGhostLocal(gfx, newCells, ncr, ncc);
container.setPosition(HGX + (ncc + 0.5) * CELL, HGY + (ncr + 0.5) * CELL);
if (type === 'rotate') container.angle = -90;
this._ghostAnim = this.tweens.add({
targets: container,
angle: 0, scaleX: 1, scaleY: 1,
duration: HALF,
ease: 'Power2.easeOut',
onComplete: finish,
});
};
const midpoint = () => {
if (type === 'rotate') {
this.selectedPiece.oriIdx = rotateOri(pieceId, this.selectedPiece.oriIdx);
} else {
this.selectedPiece.oriIdx = flipOri(pieceId, this.selectedPiece.oriIdx);
}
phase2();
};
const phase1Props = type === 'rotate'
? { angle: 90, scaleX: 0, scaleY: 0 }
: { scaleX: 0 };
this._ghostAnim = this.tweens.add({
targets: container,
...phase1Props,
duration: HALF,
ease: 'Power2.easeIn',
onComplete: midpoint,
});
}
_fillGhostLocal(gfx, cells, cr, cc) {
const pieceId = this.selectedPiece?.pieceId;
const color = PIECE_COLORS[pieceId] ?? 0xffffff;
const allValid = cells.every(([r, c]) =>
r >= 0 && r < 6 && c >= 0 && c < 6 && this.humanBoard[r * 6 + c] === null
);
gfx.fillStyle(color, allValid ? 0.55 : 0.2);
for (const [r, c] of cells) {
const lx = (c - cc) * CELL - CELL / 2;
const ly = (r - cr) * CELL - CELL / 2;
gfx.fillRoundedRect(lx + 3, ly + 3, CELL - 6, CELL - 6, 7);
}
}
_updateGhostAt(r, c) {
@ -398,6 +554,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
const c = Math.floor((ptr.worldX - HGX) / CELL);
this._lastHoverR = r;
this._lastHoverC = c;
if (this._ghostAnimContainer) return; // let animation finish
this._updateGhostAt(r, c);
this._renderHumanGrid();
}
@ -413,6 +570,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
// Click on a placed piece → lift it back to the tray
if (cellVal && cellVal !== 'blocked') {
this._cancelGhostAnim();
this._liftPiece(cellVal);
return;
}
@ -426,7 +584,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
const { pieceId } = this.selectedPiece;
this.humanBoard = placePiece(this.humanBoard, this.ghostCells, pieceId);
this.placedPieces.add(pieceId);
playSound(this, SFX.CARD_PLACE);
playSound(this, SFX.UI_PLACE);
this._clearSelection();
this._renderHumanGrid();
this._renderTray();
@ -557,6 +715,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
this.gameOver = true;
this._stopAI();
this.opponentPortrait?.playEmotion('upset');
playSound(this, SFX.UI_CHIME);
playSound(this, SFX.VICTORY_SHORT);
this._recordResult('win');
this._showOverlay(true);

View File

@ -1,6 +1,7 @@
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 {
ROWS, PIECES, PIECE_COLORS, ORIENTATIONS,
@ -74,14 +75,22 @@ export default class KataminoGame extends Phaser.Scene {
// Rendering
this._screenContainer = null;
this._maskGfx = null;
this._boardGfx = null;
this._traySlots = [];
this._boardGfx = null;
this._pulseGfx = null;
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._traySlots = [];
// Input handler refs for cleanup
this._inputHandlers = [];
}
create() {
async create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this._bank = this.cache.json.get('katamino');
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0f0d0a).setDepth(D.bg);
@ -94,6 +103,44 @@ export default class KataminoGame extends Phaser.Scene {
this._showPentaSelect();
}
update(time) {
if (!this._pulseGfx || this._screen !== 'play' || !this._ghostCells || !this._selectedPiece) {
this._pulseGfx?.clear();
return;
}
const cells = this._ghostCells;
const pieceId = this._selectedPiece.pieceId;
const alpha = 0.35 + 0.65 * Math.abs(Math.sin(time * 0.004));
const allValid = cells.every(([r, c]) =>
r >= 0 && r < ROWS && c >= 0 && c < this._cols && this._board[r * this._cols + c] === null
);
const color = allValid ? (PIECE_COLORS[pieceId] ?? 0xffffff) : 0xff4444;
const ghostSet = new Set(cells.map(([r, c]) => `${r},${c}`));
const OFF = 4;
const bx = this._boardX;
this._pulseGfx.clear();
this._pulseGfx.lineStyle(5, color, alpha);
for (const [r, c] of cells) {
const x0 = bx + c * CELL, y0 = BOARD_Y + r * CELL;
const x1 = x0 + CELL, y1 = y0 + CELL;
if (!ghostSet.has(`${r - 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y0 - OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r + 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y1 + OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c - 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x0 - OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c + 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x1 + OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
}
}
// ── Screen management ──────────────────────────────────────────────────────
_clearScreen() {
@ -102,6 +149,14 @@ export default class KataminoGame extends Phaser.Scene {
for (const { ev, fn } of this._inputHandlers) this.input.off(ev, fn);
this._inputHandlers = [];
// Kill any in-progress piece animation
if (this._ghostAnimContainer) {
this.tweens.killTweensOf(this._ghostAnimContainer);
this._ghostAnimContainer.destroy(true);
this._ghostAnimContainer = null;
}
this._ghostAnim = null;
// Reset play state
this._board = null;
this._cols = null;
@ -111,6 +166,7 @@ export default class KataminoGame extends Phaser.Scene {
this._lastHoverR = -1;
this._lastHoverC = -1;
this._boardGfx = null;
this._pulseGfx = null;
this._traySlots = [];
}
@ -372,12 +428,12 @@ export default class KataminoGame extends Phaser.Scene {
}).setOrigin(0.5);
sc.add([backBtn, titleTxt]);
// Reset and Leave buttons
const resetBtn = new Button(this, GAME_WIDTH - 310, 46, 'Reset',
// Reset and Leave buttons (lower right)
const resetBtn = new Button(this, GAME_WIDTH - 310, GAME_HEIGHT - 40, 'Reset',
() => this._resetBoard(),
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
).setDepth(D.ui + 1);
const leaveBtn = new Button(this, GAME_WIDTH - 150, 46, 'Leave',
const leaveBtn = new Button(this, GAME_WIDTH - 150, GAME_HEIGHT - 40, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
).setDepth(D.ui + 1);
@ -387,6 +443,10 @@ export default class KataminoGame extends Phaser.Scene {
this._boardGfx = this.add.graphics().setDepth(D.board);
sc.add(this._boardGfx);
// Pulse outline (above board fill, below tray)
this._pulseGfx = this.add.graphics().setDepth(D.board + 1);
sc.add(this._pulseGfx);
// Board input zone (use setOrigin(0) so x,y = top-left)
const zone = this.add.zone(this._boardX, BOARD_Y, boardW, ROWS * CELL)
.setOrigin(0)
@ -533,6 +593,7 @@ export default class KataminoGame extends Phaser.Scene {
if (this._selectedPiece?.pieceId === pieceId) { this._clearSelection(); return; }
this._selectedPiece = { pieceId, oriIdx: 0 };
this._ghostCells = null;
playSound(this, SFX.UI_PICK);
this._renderTray();
this._renderBoard();
}
@ -548,16 +609,130 @@ export default class KataminoGame extends Phaser.Scene {
_rotateSelected() {
if (this._screen !== 'play' || !this._selectedPiece) return;
this._selectedPiece.oriIdx = rotateOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
playSound(this, SFX.UI_ACTIVATE);
this._cancelGhostAnim();
if (!this._ghostCells) {
this._selectedPiece.oriIdx = rotateOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
return;
}
this._startGhostAnim('rotate');
}
_flipSelected() {
if (this._screen !== 'play' || !this._selectedPiece) return;
this._selectedPiece.oriIdx = flipOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
playSound(this, SFX.UI_FLIP);
this._cancelGhostAnim();
if (!this._ghostCells) {
this._selectedPiece.oriIdx = flipOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
return;
}
this._startGhostAnim('flip');
}
_cancelGhostAnim() {
if (!this._ghostAnimContainer) return;
this.tweens.killTweensOf(this._ghostAnimContainer);
this._ghostAnimContainer.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
}
_startGhostAnim(type) {
const cells = this._ghostCells;
const pieceId = this._selectedPiece.pieceId;
const hoverR = this._lastHoverR;
const hoverC = this._lastHoverC;
const n = cells.length;
const cr = cells.reduce((s, [r]) => s + r, 0) / n;
const cc = cells.reduce((s, [, c]) => s + c, 0) / n;
const gfx = this.add.graphics();
this._fillGhostLocal(gfx, cells, cr, cc);
const pivotX = this._boardX + (cc + 0.5) * CELL;
const pivotY = BOARD_Y + (cr + 0.5) * CELL;
const container = this.add.container(pivotX, pivotY, [gfx]).setDepth(D.piece + 1);
this._ghostAnimContainer = container;
// Suppress normal ghost & pulse while animating
this._ghostCells = null;
this._pulseGfx?.clear();
this._renderBoard();
const HALF = 80;
const finish = () => {
container.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
};
const phase2 = () => {
const oriCells = ORIENTATIONS[this._selectedPiece.pieceId][this._selectedPiece.oriIdx];
const [pinDr, pinDc] = _centerPin(oriCells);
const newCells = (hoverR >= 0 && hoverC >= 0)
? absoluteCells(oriCells, hoverR - pinDr, hoverC - pinDc)
: null;
if (!newCells) { finish(); return; }
const nn = newCells.length;
const ncr = newCells.reduce((s, [r]) => s + r, 0) / nn;
const ncc = newCells.reduce((s, [, c]) => s + c, 0) / nn;
gfx.clear();
this._fillGhostLocal(gfx, newCells, ncr, ncc);
container.setPosition(this._boardX + (ncc + 0.5) * CELL, BOARD_Y + (ncr + 0.5) * CELL);
if (type === 'rotate') container.angle = -90;
this._ghostAnim = this.tweens.add({
targets: container,
angle: 0, scaleX: 1, scaleY: 1,
duration: HALF,
ease: 'Power2.easeOut',
onComplete: finish,
});
};
const midpoint = () => {
if (type === 'rotate') {
this._selectedPiece.oriIdx = rotateOri(pieceId, this._selectedPiece.oriIdx);
} else {
this._selectedPiece.oriIdx = flipOri(pieceId, this._selectedPiece.oriIdx);
}
phase2();
};
const phase1Props = type === 'rotate'
? { angle: 90, scaleX: 0, scaleY: 0 }
: { scaleX: 0 };
this._ghostAnim = this.tweens.add({
targets: container,
...phase1Props,
duration: HALF,
ease: 'Power2.easeIn',
onComplete: midpoint,
});
}
_fillGhostLocal(gfx, cells, cr, cc) {
const pieceId = this._selectedPiece?.pieceId;
const color = PIECE_COLORS[pieceId] ?? 0xffffff;
const allValid = cells.every(([r, c]) =>
r >= 0 && r < ROWS && c >= 0 && c < this._cols && this._board[r * this._cols + c] === null
);
gfx.fillStyle(color, allValid ? 0.55 : 0.2);
for (const [r, c] of cells) {
const lx = (c - cc) * CELL - CELL / 2;
const ly = (r - cr) * CELL - CELL / 2;
gfx.fillRoundedRect(lx + 3, ly + 3, CELL - 6, CELL - 6, 9);
}
}
_updateGhostAt(r, c) {
@ -573,6 +748,7 @@ export default class KataminoGame extends Phaser.Scene {
const c = Math.floor((ptr.worldX - this._boardX) / CELL);
this._lastHoverR = r;
this._lastHoverC = c;
if (this._ghostAnimContainer) return; // let animation finish
this._updateGhostAt(r, c);
this._renderBoard();
}
@ -584,7 +760,7 @@ export default class KataminoGame extends Phaser.Scene {
if (r < 0 || r >= ROWS || c < 0 || c >= this._cols) return;
const cellVal = this._board[r * this._cols + c];
if (cellVal !== null) { this._liftPiece(cellVal); return; }
if (cellVal !== null) { this._cancelGhostAnim(); this._liftPiece(cellVal); return; }
if (!this._selectedPiece || !this._ghostCells) return;
if (!canPlace(this._board, this._cols, this._ghostCells)) {
@ -595,12 +771,15 @@ export default class KataminoGame extends Phaser.Scene {
const { pieceId } = this._selectedPiece;
this._board = placePiece(this._board, this._cols, this._ghostCells, pieceId);
this._placedPieces.add(pieceId);
playSound(this, SFX.CARD_PLACE);
playSound(this, SFX.UI_PLACE);
this._clearSelection();
this._renderBoard();
this._renderTray();
if (isSolved(this._board)) this._showWinOverlay();
if (isSolved(this._board)) {
playSound(this, SFX.UI_CHIME);
this.time.delayedCall(1000, () => this._showWinOverlay());
}
}
_liftPiece(pieceId) {

View File

@ -125,6 +125,11 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-woosh', '/assets/fx/woosh.mp3');
this.load.spritesheet('jello-items', '/assets/images/jello-items.png', { frameWidth: 132, frameHeight: 132 });
this.load.audio('sfx-jello-monsters','/assets/fx/jello-monsters.mp3');
this.load.audio('sfx-ui-pick', '/assets/fx/ui-pick.mp3');
this.load.audio('sfx-ui-activate', '/assets/fx/ui-activate.mp3');
this.load.audio('sfx-ui-flip', '/assets/fx/ui-flip.mp3');
this.load.audio('sfx-ui-place', '/assets/fx/ui-place.mp3');
this.load.audio('sfx-ui-chime', '/assets/fx/ui-chime.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -48,6 +48,11 @@ export const SFX = {
SQUASH: 'sfx-squash',
WOOSH: 'sfx-woosh',
JELLO_MONSTERS: 'sfx-jello-monsters',
UI_PICK: 'sfx-ui-pick',
UI_ACTIVATE: 'sfx-ui-activate',
UI_FLIP: 'sfx-ui-flip',
UI_PLACE: 'sfx-ui-place',
UI_CHIME: 'sfx-ui-chime',
};
export function playSound(scene, key) {