feat(monopoly): add card deck visuals and rent/card animations

- Draw Chance and Community Chest card decks on the board center
- Add animated card draw with flip effect from deck position
- Add animated rent payment with money flying between players
- Integrate new Monopoly sound effects (purchase, expense, paid)
- Extract applyRent() to MonopolyLogic for cleaner state handling
- Update monopoly-cards spritesheet with new card art
This commit is contained in:
Brian Fertig 2026-06-07 16:32:12 -06:00
parent 0317d1c14f
commit 359740f4f7
10 changed files with 330 additions and 10 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -15,7 +15,7 @@ import {
createInitialState, rollDice, resolveSpace, buyProperty, declineProperty, createInitialState, rollDice, resolveSpace, buyProperty, declineProperty,
placeBid, passAuction, buildHouse, buildHotel, sellHouse, sellHotel, placeBid, passAuction, buildHouse, buildHotel, sellHouse, sellHotel,
mortgageProperty, unmortgageProperty, payJailFine, useJailCard, mortgageProperty, unmortgageProperty, payJailFine, useJailCard,
applyCardEffect, endTurn, checkGameOver, calculateRent, applyCardEffect, applyRent, endTurn, checkGameOver, calculateRent,
canBuildHouse, canBuildHotel, ownsGroup, netWorth, canBuildHouse, canBuildHotel, ownsGroup, netWorth,
} from './MonopolyLogic.js'; } from './MonopolyLogic.js';
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay } from './MonopolyAI.js'; import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay } from './MonopolyAI.js';
@ -32,6 +32,9 @@ 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, popup:50, banner:90 };
// Center deck offset (must match drawCenterDecks constant)
const DECK_D = 130;
// Property purchase modal // Property purchase modal
const MODAL_W = 340; const MODAL_W = 340;
const MODAL_H = 500; const MODAL_H = 500;
@ -76,6 +79,8 @@ export default class MonopolyGame extends Phaser.Scene {
this.modalOverlay = null; this.modalOverlay = null;
this.modalSpaceIdx = null; this.modalSpaceIdx = null;
this.modalOrigin = null; this.modalOrigin = null;
// Card draw animation flag — suppresses static popup until animation finishes
this.cardAnimPlayed = false;
} }
create() { create() {
@ -154,6 +159,61 @@ export default class MonopolyGame extends Phaser.Scene {
// Draw all 40 spaces // Draw all 40 spaces
for (let i = 0; i < 40; i++) this.drawBoardSpace(g, i); for (let i = 0; i < 40; i++) this.drawBoardSpace(g, i);
this.drawCenterDecks();
}
drawCenterDecks() {
const cx = BL + BS / 2; // 450
const cy = BT + BS / 2; // 540
// Chance: lower-left, orange; Community Chest: upper-right, blue
this._drawCardDeck(cx - DECK_D, cy + DECK_D, 88, 126, -0.14, 0xE77A2C, 'Chance');
this._drawCardDeck(cx + DECK_D, cy - DECK_D, 88, 126, 0.12, 0x1565C0, 'Community\nChest');
}
_drawCardDeck(x, y, w, h, rot, color, label) {
// Darken the base color for shadow card layers
const dr = (((color >> 16) & 0xFF) * 0.60) | 0;
const dg = (((color >> 8) & 0xFF) * 0.60) | 0;
const db = ((color & 0xFF) * 0.60) | 0;
const dark = (dr << 16) | (dg << 8) | db;
const container = this.add.container(x, y).setDepth(DEPTH.text).setRotation(rot);
// Shadow card layers — offset downward-right to simulate deck thickness
for (let i = 3; i >= 1; i--) {
const sg = this.add.graphics();
sg.fillStyle(dark, 1);
sg.lineStyle(1, 0x1a1208, 0.6);
sg.fillRoundedRect(-w / 2 + i * 2, -h / 2 + i * 2, w, h, 5);
sg.strokeRoundedRect(-w / 2 + i * 2, -h / 2 + i * 2, w, h, 5);
container.add(sg);
}
// Top card body
const bg = this.add.graphics();
bg.fillStyle(color, 1);
bg.lineStyle(2, 0x1a1208, 1);
bg.fillRoundedRect(-w / 2, -h / 2, w, h, 5);
bg.strokeRoundedRect(-w / 2, -h / 2, w, h, 5);
container.add(bg);
// Inner cream border
const bdr = this.add.graphics();
bdr.lineStyle(1.5, 0xFFF8E7, 0.85);
bdr.strokeRoundedRect(-w / 2 + 6, -h / 2 + 6, w - 12, h - 12, 3);
container.add(bdr);
// Label
const txt = this.add.text(0, 0, label, {
fontFamily: 'Righteous',
fontSize: '11px',
color: '#FFFFFF',
align: 'center',
stroke: '#00000055',
strokeThickness: 1,
}).setOrigin(0.5);
container.add(txt);
} }
drawBoardSpace(g, idx) { drawBoardSpace(g, idx) {
@ -415,7 +475,7 @@ export default class MonopolyGame extends Phaser.Scene {
this.positionPawns(); this.positionPawns();
this.drawPlayerPanels(); this.drawPlayerPanels();
this.drawActionBar(); this.drawActionBar();
if (this.gs.pendingCard) this.drawCardPopup(); if (this.gs.pendingCard && this.cardAnimPlayed) this.drawCardPopup();
if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel(); if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel();
if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons(); if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons();
// DOM video portraits always render above canvas — hide them during any overlay // DOM video portraits always render above canvas — hide them during any overlay
@ -699,15 +759,222 @@ export default class MonopolyGame extends Phaser.Scene {
} }
} }
if (phase === 'card' && gs.pendingCard && gs.current === this.humanSeat) { // Card OK button is drawn inside drawCardPopup(), overlaid on the card
mkBtn('OK', () => this.onDismissCard());
}
if (phase === 'jailChoice') { if (phase === 'jailChoice') {
// Jail handling is in preroll above // Jail handling is in preroll above
} }
} }
// ── Rent Payment Animation ─────────────────────────────────────────────────
async animateRent() {
const { payer, receiver, amount } = this.gs.pendingRent;
const depth = DEPTH.banner - 1; // 89 — above all game elements
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
playSound(this, SFX.MONOPOLY_EXPENSE);
// Phase 1: Banner + amount appear centered
const bannerTxt = this.add.text(cx, cy - 60, 'PAY RENT', {
fontFamily: 'Righteous', fontSize: '80px', color: '#FFFFFF',
stroke: '#1a1208', strokeThickness: 5,
}).setOrigin(0.5).setDepth(depth);
const amtTxt = this.add.text(cx, cy + 40, `$${amount.toLocaleString()}`, {
fontFamily: 'Righteous', fontSize: '56px', color: '#FFD700',
stroke: '#1a1208', strokeThickness: 4,
}).setOrigin(0.5).setDepth(depth);
await this.delay(1000);
// Phase 2: Amount flies to payer's panel (750 ms), turns red, adds minus
const { px: ppx, py: ppy, panelW: ppw } = this.panelPos(payer);
const { px: rpx, py: rpy, panelW: rpw } = this.panelPos(receiver);
const payerX = ppx + ppw / 2, payerY = ppy + 44;
const recvX = rpx + rpw / 2, recvY = rpy + 44;
amtTxt.setText(`-$${amount.toLocaleString()}`);
amtTxt.setColor('#FF4444');
// Fade banner out simultaneously
this.tweens.add({ targets: bannerTxt, alpha: 0, duration: 750, ease: 'Linear' });
await new Promise(resolve => {
this.tweens.add({
targets: amtTxt,
x: payerX, y: payerY,
scaleX: 0.5, scaleY: 0.5,
duration: 750, ease: 'Cubic.easeIn',
onComplete: resolve,
});
});
await this.delay(250);
// Phase 3: Amount arches to receiver's panel (1200 ms), turns green, adds plus
amtTxt.setText(`+$${amount.toLocaleString()}`);
amtTxt.setColor('#44FF88');
const sx = amtTxt.x, sy = amtTxt.y;
const midX = (sx + recvX) / 2;
const midY = Math.min(sy, recvY) - 220; // arch above both panels
const proxy = { t: 0 };
await new Promise(resolve => {
this.tweens.add({
targets: proxy, t: 1,
duration: 1200, ease: 'Sine.easeInOut',
onUpdate: () => {
const t = proxy.t, u = 1 - t;
amtTxt.x = u*u*sx + 2*u*t*midX + t*t*recvX;
amtTxt.y = u*u*sy + 2*u*t*midY + t*t*recvY;
},
onComplete: resolve,
});
});
playSound(this, SFX.MONOPOLY_PAID);
await this.delay(300);
bannerTxt.destroy();
amtTxt.destroy();
}
// ── Card Draw Animation ────────────────────────────────────────────────────
async animateCardDraw() {
const { cardType, text } = this.gs.pendingCard;
const isChance = cardType === 'chance';
const BOARD_CX = BL + BS / 2;
const BOARD_CY = BT + BS / 2;
const cardColor = isChance ? 0xE77A2C : 0x1565C0;
// Deck position — must match drawCenterDecks()
const deckX = isChance ? BOARD_CX - DECK_D : BOARD_CX + DECK_D;
const deckY = isChance ? BOARD_CY + DECK_D : BOARD_CY - DECK_D;
const deckRot = isChance ? -0.14 : 0.12;
// Full popup card size; container scales up from deck visual width
const CW = 360, CH = 480;
const startScale = 88 / CW; // ≈ 0.244
// Dim overlay (not in dyn — destroyed at end of animation)
const overlay = this.add.rectangle(
GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0
).setDepth(DEPTH.popup - 2);
this.tweens.add({ targets: overlay, alpha: 0.6, duration: 500, ease: 'Linear' });
// Container starts at deck position, scaled and rotated to match deck
const container = this.add.container(deckX, deckY)
.setDepth(DEPTH.popup - 1)
.setScale(startScale)
.setRotation(deckRot);
// ── Back face (matches the face-down deck appearance) ─────────────────
const backGfx = this.add.graphics();
backGfx.fillStyle(cardColor, 1);
backGfx.lineStyle(3, 0xFFF8E7, 1);
backGfx.fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
backGfx.strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
backGfx.lineStyle(2, 0xFFF8E7, 0.75);
backGfx.strokeRoundedRect(-CW / 2 + 14, -CH / 2 + 14, CW - 28, CH - 28, 10);
container.add(backGfx);
const backLabel = this.add.text(0, 0, isChance ? 'Chance' : 'Community\nChest', {
fontFamily: 'Righteous', fontSize: '36px', color: '#FFF8E7', align: 'center',
}).setOrigin(0.5);
container.add(backLabel);
// ── Front face (matches drawCardPopup content, hidden until flip) ─────
const frontObjs = [];
const frontBg = this.add.graphics();
frontBg.fillStyle(cardColor, 1);
frontBg.lineStyle(4, 0xFFF8E7, 1);
frontBg.fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
frontBg.strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
frontBg.setVisible(false);
container.add(frontBg);
frontObjs.push(frontBg);
if (this.hasCards) {
const frame = isChance ? CARD_FRAME.chance : CARD_FRAME.community_chest;
const art = this.add.image(0, -CH / 2 + 120, 'monopoly-cards', frame)
.setDisplaySize(CW - 20, 220).setVisible(false);
container.add(art);
frontObjs.push(art);
} else {
const fallBg = this.add.graphics();
fallBg.fillStyle(0xffffff, 0.15);
fallBg.fillRoundedRect(-CW / 2 + 10, -CH / 2 + 10, CW - 20, 210, 12);
fallBg.setVisible(false);
container.add(fallBg);
frontObjs.push(fallBg);
const icon = this.add.text(0, -CH / 2 + 110, isChance ? '?' : '📦', {
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
}).setOrigin(0.5).setVisible(false);
container.add(icon);
frontObjs.push(icon);
}
const frontTitle = this.add.text(0, -CH / 2 + 30, isChance ? 'CHANCE' : 'COMMUNITY CHEST', {
fontFamily: 'Righteous', fontSize: '18px', color: '#FFF8E7',
}).setOrigin(0.5).setVisible(false);
container.add(frontTitle);
frontObjs.push(frontTitle);
const frontBody = this.add.text(0, -CH / 2 + 250, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#FFF8E7',
align: 'center', wordWrap: { width: CW - 30 },
}).setOrigin(0.5, 0).setVisible(false);
container.add(frontBody);
frontObjs.push(frontBody);
// Phase 1: fly from deck to center, grow, straighten (700 ms)
await new Promise(resolve => {
this.tweens.add({
targets: container,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scaleX: 1, scaleY: 1,
rotation: 0,
duration: 700,
ease: 'Cubic.easeOut',
onComplete: resolve,
});
});
// Phase 2a: flip first half — collapse to zero width
await new Promise(resolve => {
this.tweens.add({
targets: container,
scaleX: 0,
duration: 180,
ease: 'Sine.easeIn',
onComplete: resolve,
});
});
// Swap faces at zero-width moment
backGfx.setVisible(false);
backLabel.setVisible(false);
frontObjs.forEach(o => o.setVisible(true));
// Phase 2b: flip second half — expand back to full width
await new Promise(resolve => {
this.tweens.add({
targets: container,
scaleX: 1,
duration: 180,
ease: 'Sine.easeOut',
onComplete: resolve,
});
});
// Cleanup — render() will immediately draw the static popup at the same coords
container.each(child => { try { child.destroy(); } catch {} });
container.destroy();
overlay.destroy();
}
// ── Card Popup ───────────────────────────────────────────────────────────── // ── Card Popup ─────────────────────────────────────────────────────────────
drawCardPopup() { drawCardPopup() {
if (!this.gs.pendingCard) return; if (!this.gs.pendingCard) return;
@ -751,6 +1018,15 @@ export default class MonopolyGame extends Phaser.Scene {
fontFamily:'"Julius Sans One"', fontSize:'18px', color:'#FFF8E7', fontFamily:'"Julius Sans One"', fontSize:'18px', color:'#FFF8E7',
align:'center', wordWrap:{ width: pw - 30 }, align:'center', wordWrap:{ width: pw - 30 },
}).setOrigin(0.5, 0).setDepth(DEPTH.popup+1)); }).setOrigin(0.5, 0).setDepth(DEPTH.popup+1));
// OK button — only shown on the human player's turn
if (this.gs.current === this.humanSeat) {
const btn = new Button(this, px + pw/2, py + ph - 36, 'OK', () => this.onDismissCard(), {
width: 220, height: 48, fontSize: 20,
});
btn.setDepth(DEPTH.popup + 2);
this.reg(btn);
}
} }
// ── Auction Panel ────────────────────────────────────────────────────────── // ── Auction Panel ──────────────────────────────────────────────────────────
@ -1066,6 +1342,33 @@ export default class MonopolyGame extends Phaser.Scene {
return; return;
} }
// Guard 3: animate card draw once per card event (human and AI)
if (gs.phase === 'card' && gs.pendingCard && !this.cardAnimPlayed) {
this.busy = true;
this.hidePortraits();
this.animateCardDraw().then(() => {
this.cardAnimPlayed = true;
this.busy = false;
this.render();
this.advance();
});
return;
}
// Guard 4: animate rent payment (human and AI)
if (gs.phase === 'rent' && gs.pendingRent) {
this.busy = true;
this.hidePortraits();
this.animateRent().then(() => {
this.gs = applyRent(this.gs);
this.showPortraits();
this.busy = false;
this.render();
this.advance();
});
return;
}
// Determine who acts next // Determine who acts next
let actingSeat = gs.current; let actingSeat = gs.current;
if (gs.phase === 'auction' && gs.pendingAuction) { if (gs.phase === 'auction' && gs.pendingAuction) {
@ -1147,7 +1450,6 @@ export default class MonopolyGame extends Phaser.Scene {
await this.delay(700); // AI "thinking" pause await this.delay(700); // AI "thinking" pause
if (buy) { if (buy) {
this.gs = buyProperty(this.gs, seat); this.gs = buyProperty(this.gs, seat);
playSound(this, SFX.purchase);
await this.dismissPropertyModal(); // fill with owner color + zoom back await this.dismissPropertyModal(); // fill with owner color + zoom back
} else { } else {
this.gs = declineProperty(this.gs, seat); this.gs = declineProperty(this.gs, seat);
@ -1158,6 +1460,7 @@ export default class MonopolyGame extends Phaser.Scene {
} }
case 'card': { case 'card': {
await this.delay(2800); await this.delay(2800);
this.cardAnimPlayed = false;
this.gs = applyCardEffect(this.gs, seat); this.gs = applyCardEffect(this.gs, seat);
this.render(); this.render();
// If card moved player to buy or another phase, handle next advance // If card moved player to buy or another phase, handle next advance
@ -1450,6 +1753,7 @@ export default class MonopolyGame extends Phaser.Scene {
async animateModalFill(seat) { async animateModalFill(seat) {
if (!this.modalContainer) return; if (!this.modalContainer) return;
playSound(this, SFX.MONOPOLY_PURCHASE);
const fillGfx = this.add.graphics(); const fillGfx = this.add.graphics();
this.modalContainer.add(fillGfx); this.modalContainer.add(fillGfx);
const proxy = { h: 0 }; const proxy = { h: 0 };
@ -1564,7 +1868,6 @@ export default class MonopolyGame extends Phaser.Scene {
if (this.busy) return; if (this.busy) return;
this.busy = true; this.busy = true;
this.gs = buyProperty(this.gs, this.humanSeat); // owner set → dismissPropertyModal fills this.gs = buyProperty(this.gs, this.humanSeat); // owner set → dismissPropertyModal fills
playSound(this, SFX.purchase);
await this.dismissPropertyModal(); // fill + zoom back await this.dismissPropertyModal(); // fill + zoom back
this.busy = false; this.busy = false;
this.advance(); this.advance();
@ -1581,6 +1884,7 @@ export default class MonopolyGame extends Phaser.Scene {
onDismissCard() { onDismissCard() {
if (this.busy) return; if (this.busy) return;
this.cardAnimPlayed = false;
this.gs = applyCardEffect(this.gs, this.humanSeat); this.gs = applyCardEffect(this.gs, this.humanSeat);
this.render(); this.render();
this.advance(); this.advance();

View File

@ -62,6 +62,7 @@ export function createInitialState({ playerCount, names, seed = Date.now() }) {
pendingCard: null, pendingCard: null,
pendingBuy: null, pendingBuy: null,
pendingAuction: null, pendingAuction: null,
pendingRent: null,
winner: null, winner: null,
log: [], log: [],
}; };
@ -309,10 +310,9 @@ export function resolveSpace(state, seat) {
} else { } else {
const dice = s.diceRoll[0] + s.diceRoll[1]; const dice = s.diceRoll[0] + s.diceRoll[1];
const rent = calculateRent(s, spIdx, dice); const rent = calculateRent(s, spIdx, dice);
const result = payTo(s, seat, own.owner, rent); s.pendingRent = { payer: seat, receiver: own.owner, amount: rent };
Object.assign(s, result);
log(s, `${p.name} pays $${rent} rent to ${s.players[own.owner].name}.`); log(s, `${p.name} pays $${rent} rent to ${s.players[own.owner].name}.`);
s.phase = 'endturn'; s.phase = 'rent';
} }
break; break;
} }
@ -699,6 +699,16 @@ export function useJailCard(state, seat) {
return s; return s;
} }
export function applyRent(state) {
const s = clone(state);
const { payer, receiver, amount } = s.pendingRent;
const result = payTo(s, payer, receiver, amount);
Object.assign(s, result);
s.pendingRent = null;
s.phase = 'endturn';
return s;
}
// ── Turn ────────────────────────────────────────────────────────────────────── // ── Turn ──────────────────────────────────────────────────────────────────────
export function endTurn(state) { export function endTurn(state) {
const s = clone(state); const s = clone(state);

View File

@ -136,6 +136,9 @@ export default class PreloadScene extends Phaser.Scene {
this.load.spritesheet('monopoly-pawns', '/assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 }); this.load.spritesheet('monopoly-pawns', '/assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 });
// Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300. // Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300.
this.load.spritesheet('monopoly-cards', '/assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 }); this.load.spritesheet('monopoly-cards', '/assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 });
this.load.audio('sfx-monopoly-purchase', '/assets/fx/monopoly-purchase.mp3');
this.load.audio('sfx-monopoly-expense', '/assets/fx/monopoly-expense.mp3');
this.load.audio('sfx-monopoly-paid', '/assets/fx/monopoly-paid.mp3');
} }
async create() { async create() {

View File

@ -32,6 +32,9 @@ export const SFX = {
SCIFI_RISER: 'sfx-scifi-riser', SCIFI_RISER: 'sfx-scifi-riser',
SCIFI_REVEAL: 'sfx-scifi-reveal', SCIFI_REVEAL: 'sfx-scifi-reveal',
SCIFI_WOOSH: 'sfx-scifi-woosh', SCIFI_WOOSH: 'sfx-scifi-woosh',
MONOPOLY_PURCHASE: 'sfx-monopoly-purchase',
MONOPOLY_EXPENSE: 'sfx-monopoly-expense',
MONOPOLY_PAID: 'sfx-monopoly-paid',
}; };
export function playSound(scene, key) { export function playSound(scene, key) {