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:
parent
762792f9f5
commit
b7821c3bd8
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.
|
|
@ -84,7 +84,10 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
|
|
||||||
this.humanGridGfx = null;
|
this.humanGridGfx = null;
|
||||||
this.aiGridGfx = null;
|
this.aiGridGfx = null;
|
||||||
this.traySlots = []; // [{ container, pieceId }]
|
this._pulseGfx = null;
|
||||||
|
this._ghostAnimContainer = null;
|
||||||
|
this._ghostAnim = null;
|
||||||
|
this.traySlots = []; // [{ container, pieceId }]
|
||||||
this.opponentPortrait = null;
|
this.opponentPortrait = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +127,43 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
this._startCountdown();
|
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 ────────────────────────────────────────────────────────
|
// ── Scene construction ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_buildBackground() {
|
_buildBackground() {
|
||||||
|
|
@ -165,6 +205,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
_buildGridGraphics() {
|
_buildGridGraphics() {
|
||||||
this.humanGridGfx = this.add.graphics().setDepth(D.grid);
|
this.humanGridGfx = this.add.graphics().setDepth(D.grid);
|
||||||
this.aiGridGfx = this.add.graphics().setDepth(D.grid);
|
this.aiGridGfx = this.add.graphics().setDepth(D.grid);
|
||||||
|
this._pulseGfx = this.add.graphics().setDepth(D.grid + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildLabels() {
|
_buildLabels() {
|
||||||
|
|
@ -358,6 +399,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
this.selectedPiece = { pieceId, oriIdx: 0 };
|
this.selectedPiece = { pieceId, oriIdx: 0 };
|
||||||
this.ghostCells = null;
|
this.ghostCells = null;
|
||||||
|
playSound(this, SFX.UI_PICK);
|
||||||
this._renderTray();
|
this._renderTray();
|
||||||
this._renderHumanGrid();
|
this._renderHumanGrid();
|
||||||
}
|
}
|
||||||
|
|
@ -373,16 +415,130 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
|
|
||||||
_rotate() {
|
_rotate() {
|
||||||
if (!this.selectedPiece || this.gameOver) return;
|
if (!this.selectedPiece || this.gameOver) return;
|
||||||
this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
|
playSound(this, SFX.UI_ACTIVATE);
|
||||||
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
|
this._cancelGhostAnim();
|
||||||
this._renderHumanGrid();
|
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() {
|
_flip() {
|
||||||
if (!this.selectedPiece || this.gameOver) return;
|
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);
|
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();
|
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) {
|
_updateGhostAt(r, c) {
|
||||||
|
|
@ -398,6 +554,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
const c = Math.floor((ptr.worldX - HGX) / CELL);
|
const c = Math.floor((ptr.worldX - HGX) / CELL);
|
||||||
this._lastHoverR = r;
|
this._lastHoverR = r;
|
||||||
this._lastHoverC = c;
|
this._lastHoverC = c;
|
||||||
|
if (this._ghostAnimContainer) return; // let animation finish
|
||||||
this._updateGhostAt(r, c);
|
this._updateGhostAt(r, c);
|
||||||
this._renderHumanGrid();
|
this._renderHumanGrid();
|
||||||
}
|
}
|
||||||
|
|
@ -413,6 +570,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
|
|
||||||
// Click on a placed piece → lift it back to the tray
|
// Click on a placed piece → lift it back to the tray
|
||||||
if (cellVal && cellVal !== 'blocked') {
|
if (cellVal && cellVal !== 'blocked') {
|
||||||
|
this._cancelGhostAnim();
|
||||||
this._liftPiece(cellVal);
|
this._liftPiece(cellVal);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -426,7 +584,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
const { pieceId } = this.selectedPiece;
|
const { pieceId } = this.selectedPiece;
|
||||||
this.humanBoard = placePiece(this.humanBoard, this.ghostCells, pieceId);
|
this.humanBoard = placePiece(this.humanBoard, this.ghostCells, pieceId);
|
||||||
this.placedPieces.add(pieceId);
|
this.placedPieces.add(pieceId);
|
||||||
playSound(this, SFX.CARD_PLACE);
|
playSound(this, SFX.UI_PLACE);
|
||||||
this._clearSelection();
|
this._clearSelection();
|
||||||
this._renderHumanGrid();
|
this._renderHumanGrid();
|
||||||
this._renderTray();
|
this._renderTray();
|
||||||
|
|
@ -557,6 +715,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
this.gameOver = true;
|
this.gameOver = true;
|
||||||
this._stopAI();
|
this._stopAI();
|
||||||
this.opponentPortrait?.playEmotion('upset');
|
this.opponentPortrait?.playEmotion('upset');
|
||||||
|
playSound(this, SFX.UI_CHIME);
|
||||||
playSound(this, SFX.VICTORY_SHORT);
|
playSound(this, SFX.VICTORY_SHORT);
|
||||||
this._recordResult('win');
|
this._recordResult('win');
|
||||||
this._showOverlay(true);
|
this._showOverlay(true);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import {
|
import {
|
||||||
ROWS, PIECES, PIECE_COLORS, ORIENTATIONS,
|
ROWS, PIECES, PIECE_COLORS, ORIENTATIONS,
|
||||||
|
|
@ -74,14 +75,22 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
// Rendering
|
// Rendering
|
||||||
this._screenContainer = null;
|
this._screenContainer = null;
|
||||||
this._maskGfx = null;
|
this._maskGfx = null;
|
||||||
this._boardGfx = null;
|
this._boardGfx = null;
|
||||||
this._traySlots = [];
|
this._pulseGfx = null;
|
||||||
|
this._ghostAnimContainer = null;
|
||||||
|
this._ghostAnim = null;
|
||||||
|
this._traySlots = [];
|
||||||
|
|
||||||
// Input handler refs for cleanup
|
// Input handler refs for cleanup
|
||||||
this._inputHandlers = [];
|
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._bank = this.cache.json.get('katamino');
|
||||||
|
|
||||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0f0d0a).setDepth(D.bg);
|
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();
|
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 ──────────────────────────────────────────────────────
|
// ── Screen management ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
_clearScreen() {
|
_clearScreen() {
|
||||||
|
|
@ -102,6 +149,14 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
for (const { ev, fn } of this._inputHandlers) this.input.off(ev, fn);
|
for (const { ev, fn } of this._inputHandlers) this.input.off(ev, fn);
|
||||||
this._inputHandlers = [];
|
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
|
// Reset play state
|
||||||
this._board = null;
|
this._board = null;
|
||||||
this._cols = null;
|
this._cols = null;
|
||||||
|
|
@ -111,6 +166,7 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
this._lastHoverR = -1;
|
this._lastHoverR = -1;
|
||||||
this._lastHoverC = -1;
|
this._lastHoverC = -1;
|
||||||
this._boardGfx = null;
|
this._boardGfx = null;
|
||||||
|
this._pulseGfx = null;
|
||||||
this._traySlots = [];
|
this._traySlots = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,12 +428,12 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
sc.add([backBtn, titleTxt]);
|
sc.add([backBtn, titleTxt]);
|
||||||
|
|
||||||
// Reset and Leave buttons
|
// Reset and Leave buttons (lower right)
|
||||||
const resetBtn = new Button(this, GAME_WIDTH - 310, 46, 'Reset',
|
const resetBtn = new Button(this, GAME_WIDTH - 310, GAME_HEIGHT - 40, 'Reset',
|
||||||
() => this._resetBoard(),
|
() => this._resetBoard(),
|
||||||
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
|
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
|
||||||
).setDepth(D.ui + 1);
|
).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'),
|
() => this.scene.start('GameMenu'),
|
||||||
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
|
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
|
||||||
).setDepth(D.ui + 1);
|
).setDepth(D.ui + 1);
|
||||||
|
|
@ -387,6 +443,10 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
this._boardGfx = this.add.graphics().setDepth(D.board);
|
this._boardGfx = this.add.graphics().setDepth(D.board);
|
||||||
sc.add(this._boardGfx);
|
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)
|
// Board input zone (use setOrigin(0) so x,y = top-left)
|
||||||
const zone = this.add.zone(this._boardX, BOARD_Y, boardW, ROWS * CELL)
|
const zone = this.add.zone(this._boardX, BOARD_Y, boardW, ROWS * CELL)
|
||||||
.setOrigin(0)
|
.setOrigin(0)
|
||||||
|
|
@ -533,6 +593,7 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
if (this._selectedPiece?.pieceId === pieceId) { this._clearSelection(); return; }
|
if (this._selectedPiece?.pieceId === pieceId) { this._clearSelection(); return; }
|
||||||
this._selectedPiece = { pieceId, oriIdx: 0 };
|
this._selectedPiece = { pieceId, oriIdx: 0 };
|
||||||
this._ghostCells = null;
|
this._ghostCells = null;
|
||||||
|
playSound(this, SFX.UI_PICK);
|
||||||
this._renderTray();
|
this._renderTray();
|
||||||
this._renderBoard();
|
this._renderBoard();
|
||||||
}
|
}
|
||||||
|
|
@ -548,16 +609,130 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
|
|
||||||
_rotateSelected() {
|
_rotateSelected() {
|
||||||
if (this._screen !== 'play' || !this._selectedPiece) return;
|
if (this._screen !== 'play' || !this._selectedPiece) return;
|
||||||
this._selectedPiece.oriIdx = rotateOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
|
playSound(this, SFX.UI_ACTIVATE);
|
||||||
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
|
this._cancelGhostAnim();
|
||||||
this._renderBoard();
|
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() {
|
_flipSelected() {
|
||||||
if (this._screen !== 'play' || !this._selectedPiece) return;
|
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);
|
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();
|
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) {
|
_updateGhostAt(r, c) {
|
||||||
|
|
@ -573,6 +748,7 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
const c = Math.floor((ptr.worldX - this._boardX) / CELL);
|
const c = Math.floor((ptr.worldX - this._boardX) / CELL);
|
||||||
this._lastHoverR = r;
|
this._lastHoverR = r;
|
||||||
this._lastHoverC = c;
|
this._lastHoverC = c;
|
||||||
|
if (this._ghostAnimContainer) return; // let animation finish
|
||||||
this._updateGhostAt(r, c);
|
this._updateGhostAt(r, c);
|
||||||
this._renderBoard();
|
this._renderBoard();
|
||||||
}
|
}
|
||||||
|
|
@ -584,7 +760,7 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
if (r < 0 || r >= ROWS || c < 0 || c >= this._cols) return;
|
if (r < 0 || r >= ROWS || c < 0 || c >= this._cols) return;
|
||||||
|
|
||||||
const cellVal = this._board[r * this._cols + c];
|
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 (!this._selectedPiece || !this._ghostCells) return;
|
||||||
if (!canPlace(this._board, this._cols, this._ghostCells)) {
|
if (!canPlace(this._board, this._cols, this._ghostCells)) {
|
||||||
|
|
@ -595,12 +771,15 @@ export default class KataminoGame extends Phaser.Scene {
|
||||||
const { pieceId } = this._selectedPiece;
|
const { pieceId } = this._selectedPiece;
|
||||||
this._board = placePiece(this._board, this._cols, this._ghostCells, pieceId);
|
this._board = placePiece(this._board, this._cols, this._ghostCells, pieceId);
|
||||||
this._placedPieces.add(pieceId);
|
this._placedPieces.add(pieceId);
|
||||||
playSound(this, SFX.CARD_PLACE);
|
playSound(this, SFX.UI_PLACE);
|
||||||
this._clearSelection();
|
this._clearSelection();
|
||||||
this._renderBoard();
|
this._renderBoard();
|
||||||
this._renderTray();
|
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) {
|
_liftPiece(pieceId) {
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,11 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.audio('sfx-woosh', '/assets/fx/woosh.mp3');
|
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.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-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 });
|
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ export const SFX = {
|
||||||
SQUASH: 'sfx-squash',
|
SQUASH: 'sfx-squash',
|
||||||
WOOSH: 'sfx-woosh',
|
WOOSH: 'sfx-woosh',
|
||||||
JELLO_MONSTERS: 'sfx-jello-monsters',
|
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) {
|
export function playSound(scene, key) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue