feat: enhance Monopoly dice animations and player panel layout

- Add realistic dice throw animation with quadratic bezier arcs
- Implement perspective scaling and spin during dice animation
- Add impact bounce effect when dice land
- Randomize dice landing positions and angles for natural appearance
- Increase player portrait size and adjust panel layout for better readability
- Improve cash display font size and spacing in player panels
- Track dice positions and angles for consistent rendering between turns
This commit is contained in:
Brian Fertig 2026-06-07 21:14:11 -06:00
parent 4fbc868305
commit ae2f3246dc
1 changed files with 125 additions and 39 deletions

View File

@ -30,7 +30,7 @@ const RP_X = BL + BS + 50; // 920
const RP_W = GAME_WIDTH - RP_X - 20; // ~980 const RP_W = GAME_WIDTH - RP_X - 20; // ~980
// Depth // Depth
const DEPTH = { bg:0, board:5, band:6, text:7, houses:10, pawns:15, ui:25, popup:50, banner:90 }; const DEPTH = { bg:0, board:5, band:6, text:7, houses:10, pawns:15, ui:25, dice:40, popup:50, banner:90 };
// Center deck offset (must match drawCenterDecks constant) // Center deck offset (must match drawCenterDecks constant)
const DECK_D = 130; const DECK_D = 130;
@ -68,8 +68,10 @@ export default class MonopolyGame extends Phaser.Scene {
this.dyn = []; this.dyn = [];
this.portraits = []; this.portraits = [];
this.pawns = {}; // seat → image/circle this.pawns = {}; // seat → image/circle
this.dieGfx = []; // [die1Graphics, die2Graphics] this.dieGfx = []; // [die1Graphics, die2Graphics]
this.dieVals = [1,1]; this.dieVals = [1,1];
this.dicePositions = []; // [{cx,cy,angle}×2] — updated on each throw landing
this.diceAnimating = false;
this.cardPopup = null; // popup container this.cardPopup = null; // popup container
this.bidInput = 0; // human bid amount for auction this.bidInput = 0; // human bid amount for auction
// Property purchase modal (managed outside dyn) // Property purchase modal (managed outside dyn)
@ -401,9 +403,13 @@ export default class MonopolyGame extends Phaser.Scene {
const dx = RP_X + RP_W/2 - 55; const dx = RP_X + RP_W/2 - 55;
const dy = BT + this.playerPanelTotalH() + 30; const dy = BT + this.playerPanelTotalH() + 30;
this.diceY = dy; this.diceY = dy;
this.dicePositions = [
{ cx: dx, cy: dy, angle: 0 },
{ cx: dx + 84, cy: dy, angle: 0 },
];
this.dieGfx = [ this.dieGfx = [
this.add.graphics().setDepth(DEPTH.ui), this.add.graphics().setDepth(DEPTH.dice),
this.add.graphics().setDepth(DEPTH.ui), this.add.graphics().setDepth(DEPTH.dice),
]; ];
this.drawDie(0, dx, dy, 1); this.drawDie(0, dx, dy, 1);
this.drawDie(1, dx + 84, dy, 1); this.drawDie(1, dx + 84, dy, 1);
@ -415,27 +421,25 @@ export default class MonopolyGame extends Phaser.Scene {
return rows * 190 + (rows - 1) * 12 + 20; return rows * 190 + (rows - 1) * 12 + 20;
} }
drawDie(idx, cx, cy, value) { drawDie(idx, cx, cy, value, angle = 0) {
const g = this.dieGfx[idx]; const g = this.dieGfx[idx];
const size = 66; const size = 66;
const half = size / 2; const half = size / 2;
g.clear(); g.clear();
g.setPosition(cx, cy);
g.setAngle(angle);
g.fillStyle(0xFFF8E7, 1); g.fillStyle(0xFFF8E7, 1);
g.fillRoundedRect(cx - half, cy - half, size, size, 10); g.fillRoundedRect(-half, -half, size, size, 10);
g.lineStyle(2, 0x4A3728, 1); g.lineStyle(2, 0x4A3728, 1);
g.strokeRoundedRect(cx - half, cy - half, size, size, 10); g.strokeRoundedRect(-half, -half, size, size, 10);
// Pips // Pips drawn in local space (centered at origin)
g.fillStyle(0x1a1208, 1); g.fillStyle(0x1a1208, 1);
const pipR = 5; const pipR = 5;
const step = 18; const step = 18;
const pips = PIPS[value] ?? PIPS[1]; const pips = PIPS[value] ?? PIPS[1];
for (const [px, py] of pips) { for (const [px, py] of pips) {
g.fillCircle(cx + px * step, cy + py * step, pipR); g.fillCircle(px * step, py * step, pipR);
} }
this.dieGfx[idx] = g;
// Store die positions for later re-draw
if (!this.diePositions) this.diePositions = [];
this.diePositions[idx] = { cx, cy };
} }
// ── Portraits ────────────────────────────────────────────────────────────── // ── Portraits ──────────────────────────────────────────────────────────────
@ -443,7 +447,7 @@ export default class MonopolyGame extends Phaser.Scene {
const n = this.gs.playerCount; const n = this.gs.playerCount;
for (let seat = 0; seat < n; seat++) { for (let seat = 0; seat < n; seat++) {
const { px, py } = this.panelPos(seat); const { px, py } = this.panelPos(seat);
const portraitR = 28; const portraitR = 40;
if (seat === this.humanSeat) { if (seat === this.humanSeat) {
this.portraits[seat] = createPlayerPortrait(this, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1, 'MonopolyGame'); this.portraits[seat] = createPlayerPortrait(this, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1, 'MonopolyGame');
} else { } else {
@ -636,37 +640,37 @@ export default class MonopolyGame extends Phaser.Scene {
// Name // Name
const nameColor = isCurrent ? COLORS.goldHex : COLORS.textHex; const nameColor = isCurrent ? COLORS.goldHex : COLORS.textHex;
this.reg(this.add.text(px + 72, py + 14, p.name, { this.reg(this.add.text(px + 96, py + 14, p.name, {
fontFamily:'Righteous', fontSize:'17px', color: nameColor, fontFamily:'Righteous', fontSize:'17px', color: nameColor,
}).setOrigin(0, 0).setDepth(DEPTH.ui+1)); }).setOrigin(0, 0).setDepth(DEPTH.ui+1));
// Cash // Cash
this.reg(this.add.text(px + 72, py + 36, `$${p.cash.toLocaleString()}`, { this.reg(this.add.text(px + 96, py + 36, `$${p.cash.toLocaleString()}`, {
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f', fontFamily:'"Julius Sans One"', fontSize:'26px', color:'#7fb87f',
}).setOrigin(0, 0).setDepth(DEPTH.ui+1)); }).setOrigin(0, 0).setDepth(DEPTH.ui+1));
// Net worth // Net worth
const nw = netWorth(this.gs, seat); const nw = netWorth(this.gs, seat);
this.reg(this.add.text(px + 72, py + 56, `Net: $${nw.toLocaleString()}`, { this.reg(this.add.text(px + 96, py + 72, `Net: $${nw.toLocaleString()}`, {
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex, fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex,
}).setOrigin(0, 0).setDepth(DEPTH.ui+1)); }).setOrigin(0, 0).setDepth(DEPTH.ui+1));
// Jail indicator // Jail indicator
if (p.jailed) { if (p.jailed) {
this.reg(this.add.text(px + 72, py + 74, '🔒 In Jail', { this.reg(this.add.text(px + 96, py + 90, '🔒 In Jail', {
fontFamily:'"Julius Sans One"', fontSize:'12px', color:COLORS.dangerHex, fontFamily:'"Julius Sans One"', fontSize:'12px', color:COLORS.dangerHex,
}).setOrigin(0, 0).setDepth(DEPTH.ui+1)); }).setOrigin(0, 0).setDepth(DEPTH.ui+1));
} }
// GOOJF card indicator // GOOJF card indicator
if (p.getOutOfJailFree > 0) { if (p.getOutOfJailFree > 0) {
this.reg(this.add.text(px + 72, py + (p.jailed ? 90 : 74), `🎴 ×${p.getOutOfJailFree}`, { this.reg(this.add.text(px + 96, py + (p.jailed ? 106 : 90), `🎴 ×${p.getOutOfJailFree}`, {
fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#aaccaa', fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#aaccaa',
}).setOrigin(0, 0).setDepth(DEPTH.ui+1)); }).setOrigin(0, 0).setDepth(DEPTH.ui+1));
} }
// Property color swatches // Property color swatches
let sx = px + 72, sy = py + panelH - 26; let sx = px + 96, sy = py + panelH - 26;
for (const [group, idxArr] of Object.entries(GROUPS)) { for (const [group, idxArr] of Object.entries(GROUPS)) {
const owned = idxArr.filter(i => this.gs.board[i]?.owner === seat).length; const owned = idxArr.filter(i => this.gs.board[i]?.owner === seat).length;
if (owned === 0) continue; if (owned === 0) continue;
@ -692,11 +696,11 @@ export default class MonopolyGame extends Phaser.Scene {
const gs = this.gs; const gs = this.gs;
if (gs.phase === 'gameover') return; if (gs.phase === 'gameover') return;
// Dice values display (update) // Dice values display — use stored landing positions/angles; skip during throw animation
const diceX = RP_X + RP_W/2 - 55; if (gs.diceRoll && !this.diceAnimating) {
if (gs.diceRoll) { const [dp0, dp1] = this.dicePositions;
this.drawDie(0, diceX, this.diceY, gs.diceRoll[0]); this.drawDie(0, dp0.cx, dp0.cy, gs.diceRoll[0], dp0.angle);
this.drawDie(1, diceX + 84, this.diceY, gs.diceRoll[1]); this.drawDie(1, dp1.cx, dp1.cy, gs.diceRoll[1], dp1.angle);
} }
// Buttons only for human's turn // Buttons only for human's turn
@ -1538,19 +1542,101 @@ export default class MonopolyGame extends Phaser.Scene {
// ── Animations ───────────────────────────────────────────────────────────── // ── Animations ─────────────────────────────────────────────────────────────
animateDice(d1, d2) { animateDice(d1, d2) {
return new Promise(resolve => { return new Promise(resolve => {
let count = 0; const defaultX = RP_X + RP_W / 2 - 55;
const total = 12; const defaultY = this.diceY;
const diceX = RP_X + RP_W/2 - 55;
const ev = this.time.addEvent({ // Landing positions — scattered near default spots, random angle
delay: 70, const land0 = {
repeat: total - 1, x: defaultX + Phaser.Math.Between(-20, 20),
y: defaultY + Phaser.Math.Between(-14, 14),
angle: Phaser.Math.Between(-30, 30),
};
const land1 = {
x: defaultX + 84 + Phaser.Math.Between(-20, 20),
y: defaultY + Phaser.Math.Between(-14, 14),
angle: Phaser.Math.Between(-30, 30),
};
// Throw origin — below the landing area (like a hand tossing upward)
const throwX = RP_X + RP_W / 2;
const throwY = GAME_HEIGHT - 40;
// Arch control points — above the landing area so dice overshoot upward then fall back down
const ctrl0 = { x: defaultX - 60, y: defaultY - 380 };
const ctrl1 = { x: defaultX + 80, y: defaultY - 350 };
// Total spin per die: 23 full rotations ending at the landing angle
const dir0 = Math.random() < 0.5 ? 1 : -1;
const dir1 = Math.random() < 0.5 ? 1 : -1;
const spin0 = dir0 * (Phaser.Math.Between(2, 3) * 360 + land0.angle * dir0);
const spin1 = dir1 * (Phaser.Math.Between(2, 3) * 360 + land1.angle * dir1);
const THROW_MS = 820;
let face0 = Phaser.Math.Between(1, 6);
let face1 = Phaser.Math.Between(1, 6);
// Randomize pip faces during flight
const faceTimer = this.time.addEvent({
delay: 90,
repeat: Math.ceil(THROW_MS / 90),
callback: () => { callback: () => {
count++; face0 = Phaser.Math.Between(1, 6);
const r1 = count < total ? Math.floor(Math.random()*6)+1 : d1; face1 = Phaser.Math.Between(1, 6);
const r2 = count < total ? Math.floor(Math.random()*6)+1 : d2; },
this.drawDie(0, diceX, this.diceY, r1); });
this.drawDie(1, diceX + 84, this.diceY, r2);
if (count >= total) resolve(); this.diceAnimating = true;
const proxy = { t: 0 };
this.tweens.add({
targets: proxy,
t: 1,
duration: THROW_MS,
ease: 'Sine.easeIn',
onUpdate: () => {
const t = proxy.t;
const inv = 1 - t;
// Quadratic bezier: start → control → land
const x0 = inv*inv*throwX + 2*inv*t*ctrl0.x + t*t*land0.x;
const y0 = inv*inv*throwY + 2*inv*t*ctrl0.y + t*t*land0.y;
const x1 = inv*inv*throwX + 2*inv*t*ctrl1.x + t*t*land1.x;
const y1 = inv*inv*throwY + 2*inv*t*ctrl1.y + t*t*land1.y;
// Scale up as dice approach (perspective/depth effect)
const scale = 0.45 + t * 0.55;
this.dieGfx[0].setScale(scale);
this.dieGfx[1].setScale(scale);
this.drawDie(0, x0, y0, face0, spin0 * t);
this.drawDie(1, x1, y1, face1, spin1 * t);
},
onComplete: () => {
faceTimer.remove();
// Snap to final values at landing positions
this.dieGfx[0].setScale(1);
this.dieGfx[1].setScale(1);
this.drawDie(0, land0.x, land0.y, d1, land0.angle);
this.drawDie(1, land1.x, land1.y, d2, land1.angle);
// Store landing state for subsequent renders
this.dicePositions[0] = { cx: land0.x, cy: land0.y, angle: land0.angle };
this.dicePositions[1] = { cx: land1.x, cy: land1.y, angle: land1.angle };
// Impact bounce: scale 1 → 1.18 → 1
this.dieGfx[0].setScale(1.18);
this.dieGfx[1].setScale(1.18);
this.tweens.add({
targets: [this.dieGfx[0], this.dieGfx[1]],
scaleX: 1, scaleY: 1,
duration: 200,
ease: 'Back.easeOut',
onComplete: () => {
this.diceAnimating = false;
resolve();
},
});
}, },
}); });
}); });