diff --git a/public/assets/fx/monopoly-expense.mp3 b/public/assets/fx/monopoly-expense.mp3 new file mode 100644 index 0000000..2d735dd Binary files /dev/null and b/public/assets/fx/monopoly-expense.mp3 differ diff --git a/public/assets/fx/monopoly-paid.mp3 b/public/assets/fx/monopoly-paid.mp3 new file mode 100644 index 0000000..b911dc3 Binary files /dev/null and b/public/assets/fx/monopoly-paid.mp3 differ diff --git a/public/assets/fx/monopoly-pay.mp3 b/public/assets/fx/monopoly-pay.mp3 new file mode 100644 index 0000000..6f1db49 Binary files /dev/null and b/public/assets/fx/monopoly-pay.mp3 differ diff --git a/public/assets/fx/monopoly-purchase.mp3 b/public/assets/fx/monopoly-purchase.mp3 new file mode 100644 index 0000000..e5bfa3f Binary files /dev/null and b/public/assets/fx/monopoly-purchase.mp3 differ diff --git a/public/assets/images/monopoly-cards.png b/public/assets/images/monopoly-cards.png index 19f886f..8b1c59d 100644 Binary files a/public/assets/images/monopoly-cards.png and b/public/assets/images/monopoly-cards.png differ diff --git a/public/assets/images/monopoly-cards.psd b/public/assets/images/monopoly-cards.psd index 0487b70..f7350c8 100644 Binary files a/public/assets/images/monopoly-cards.psd and b/public/assets/images/monopoly-cards.psd differ diff --git a/public/src/games/monopoly/MonopolyGame.js b/public/src/games/monopoly/MonopolyGame.js index f92c645..5089823 100644 --- a/public/src/games/monopoly/MonopolyGame.js +++ b/public/src/games/monopoly/MonopolyGame.js @@ -15,7 +15,7 @@ import { createInitialState, rollDice, resolveSpace, buyProperty, declineProperty, placeBid, passAuction, buildHouse, buildHotel, sellHouse, sellHotel, mortgageProperty, unmortgageProperty, payJailFine, useJailCard, - applyCardEffect, endTurn, checkGameOver, calculateRent, + applyCardEffect, applyRent, endTurn, checkGameOver, calculateRent, canBuildHouse, canBuildHotel, ownsGroup, netWorth, } from './MonopolyLogic.js'; import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay } from './MonopolyAI.js'; @@ -32,6 +32,9 @@ const RP_W = GAME_WIDTH - RP_X - 20; // ~980 // Depth 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 const MODAL_W = 340; const MODAL_H = 500; @@ -76,6 +79,8 @@ export default class MonopolyGame extends Phaser.Scene { this.modalOverlay = null; this.modalSpaceIdx = null; this.modalOrigin = null; + // Card draw animation flag — suppresses static popup until animation finishes + this.cardAnimPlayed = false; } create() { @@ -154,6 +159,61 @@ export default class MonopolyGame extends Phaser.Scene { // Draw all 40 spaces 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) { @@ -415,7 +475,7 @@ export default class MonopolyGame extends Phaser.Scene { this.positionPawns(); this.drawPlayerPanels(); 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.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons(); // 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) { - mkBtn('OK', () => this.onDismissCard()); - } + // Card OK button is drawn inside drawCardPopup(), overlaid on the card if (phase === 'jailChoice') { // 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 ───────────────────────────────────────────────────────────── drawCardPopup() { if (!this.gs.pendingCard) return; @@ -751,6 +1018,15 @@ export default class MonopolyGame extends Phaser.Scene { fontFamily:'"Julius Sans One"', fontSize:'18px', color:'#FFF8E7', align:'center', wordWrap:{ width: pw - 30 }, }).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 ────────────────────────────────────────────────────────── @@ -1066,6 +1342,33 @@ export default class MonopolyGame extends Phaser.Scene { 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 let actingSeat = gs.current; if (gs.phase === 'auction' && gs.pendingAuction) { @@ -1147,7 +1450,6 @@ export default class MonopolyGame extends Phaser.Scene { await this.delay(700); // AI "thinking" pause if (buy) { this.gs = buyProperty(this.gs, seat); - playSound(this, SFX.purchase); await this.dismissPropertyModal(); // fill with owner color + zoom back } else { this.gs = declineProperty(this.gs, seat); @@ -1158,6 +1460,7 @@ export default class MonopolyGame extends Phaser.Scene { } case 'card': { await this.delay(2800); + this.cardAnimPlayed = false; this.gs = applyCardEffect(this.gs, seat); this.render(); // 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) { if (!this.modalContainer) return; + playSound(this, SFX.MONOPOLY_PURCHASE); const fillGfx = this.add.graphics(); this.modalContainer.add(fillGfx); const proxy = { h: 0 }; @@ -1564,7 +1868,6 @@ export default class MonopolyGame extends Phaser.Scene { if (this.busy) return; this.busy = true; this.gs = buyProperty(this.gs, this.humanSeat); // owner set → dismissPropertyModal fills - playSound(this, SFX.purchase); await this.dismissPropertyModal(); // fill + zoom back this.busy = false; this.advance(); @@ -1581,6 +1884,7 @@ export default class MonopolyGame extends Phaser.Scene { onDismissCard() { if (this.busy) return; + this.cardAnimPlayed = false; this.gs = applyCardEffect(this.gs, this.humanSeat); this.render(); this.advance(); diff --git a/public/src/games/monopoly/MonopolyLogic.js b/public/src/games/monopoly/MonopolyLogic.js index 55568e6..fade16e 100644 --- a/public/src/games/monopoly/MonopolyLogic.js +++ b/public/src/games/monopoly/MonopolyLogic.js @@ -62,6 +62,7 @@ export function createInitialState({ playerCount, names, seed = Date.now() }) { pendingCard: null, pendingBuy: null, pendingAuction: null, + pendingRent: null, winner: null, log: [], }; @@ -309,10 +310,9 @@ export function resolveSpace(state, seat) { } else { const dice = s.diceRoll[0] + s.diceRoll[1]; const rent = calculateRent(s, spIdx, dice); - const result = payTo(s, seat, own.owner, rent); - Object.assign(s, result); + s.pendingRent = { payer: seat, receiver: own.owner, amount: rent }; log(s, `${p.name} pays $${rent} rent to ${s.players[own.owner].name}.`); - s.phase = 'endturn'; + s.phase = 'rent'; } break; } @@ -699,6 +699,16 @@ export function useJailCard(state, seat) { 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 ────────────────────────────────────────────────────────────────────── export function endTurn(state) { const s = clone(state); diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 7858470..d0c8338 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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 }); // 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.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() { diff --git a/public/src/ui/Sounds.js b/public/src/ui/Sounds.js index 0cf8f31..c172b95 100644 --- a/public/src/ui/Sounds.js +++ b/public/src/ui/Sounds.js @@ -32,6 +32,9 @@ export const SFX = { SCIFI_RISER: 'sfx-scifi-riser', SCIFI_REVEAL: 'sfx-scifi-reveal', 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) {