Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 359740f4f7 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
2026-06-07 16:32:12 -06:00
Brian Fertig 0317d1c14f Fix video overlay 2026-06-07 15:23:53 -06:00
Brian Fertig e5c1021322 Purchase property routine 2026-06-07 15:15:16 -06:00
10 changed files with 664 additions and 24 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

View File

@ -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,19 @@ 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;
const MODAL_BAND_H = 80;
const MODAL_TARGET_X = GAME_WIDTH / 2; // 960
const MODAL_TARGET_Y = GAME_HEIGHT / 2; // 540
const MODAL_AUCTION_X = 680;
const MODAL_AUCTION_Y = 500;
const MODAL_AUCTION_SCALE = 0.80;
// Pip positions for each die face (relative to die center)
const PIPS = {
1: [[0,0]],
@ -59,6 +72,15 @@ export default class MonopolyGame extends Phaser.Scene {
this.dieVals = [1,1];
this.cardPopup = null; // popup container
this.bidInput = 0; // human bid amount for auction
// Property purchase modal (managed outside dyn)
this.modalActive = false;
this.modalGfx = [];
this.modalContainer = null;
this.modalOverlay = null;
this.modalSpaceIdx = null;
this.modalOrigin = null;
// Card draw animation flag — suppresses static popup until animation finishes
this.cardAnimPlayed = false;
}
create() {
@ -137,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) {
@ -389,14 +466,21 @@ export default class MonopolyGame extends Phaser.Scene {
reg(o) { this.dyn.push(o); return o; }
clearDyn() { this.dyn.forEach(o => { try { o.destroy(); } catch {} }); this.dyn = []; }
hidePortraits() { this.portraits.forEach(p => { if (p?.hide) p.hide(); }); }
showPortraits() { this.portraits.forEach(p => { if (p?.show) p.show(); }); }
render() {
this.clearDyn();
this.drawHousesHotels();
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
if (this.gs.pendingCard || this.modalActive) this.hidePortraits();
else this.showPortraits();
}
drawHousesHotels() {
@ -675,21 +759,222 @@ export default class MonopolyGame extends Phaser.Scene {
}
}
if (phase === 'buy' && gs.pendingBuy) {
const sp = SPACES[gs.pendingBuy.spaceIdx];
mkBtn(`Buy ${sp.name}\n$${sp.price}`, () => this.onBuyProperty(), p.cash >= sp.price);
mkBtn('Decline (Auction)', () => this.onDeclineProperty(), true, { variant:'ghost' });
}
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;
@ -733,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 ──────────────────────────────────────────────────────────
@ -742,8 +1036,10 @@ export default class MonopolyGame extends Phaser.Scene {
const bidderSeat = auc.bidOrder[auc.currentBidderIdx];
const isHuman = bidderSeat === this.humanSeat;
const pw = RP_W - 20, ph = 340;
const px = RP_X + 10, py = GAME_HEIGHT/2 - ph/2;
const pw = this.modalActive ? 520 : RP_W - 20;
const ph = 360;
const px = this.modalActive ? GAME_WIDTH - pw - 30 : RP_X + 10;
const py = GAME_HEIGHT/2 - ph/2;
const g = this.reg(this.add.graphics().setDepth(DEPTH.popup));
g.fillStyle(0x1e1a12, 1);
@ -1026,6 +1322,53 @@ export default class MonopolyGame extends Phaser.Scene {
const gs = this.gs;
if (gs.phase === 'gameover') { this.showGameOver(); return; }
// Guard 1: zoom property card to center when entering 'buy' phase
if (gs.phase === 'buy' && gs.pendingBuy && !this.modalActive) {
this.busy = true;
this.showPropertyModal(gs.pendingBuy.spaceIdx).then(() => {
this.busy = false;
this.advance();
});
return;
}
// Guard 2: dismiss modal when phase leaves buy/auction
if (this.modalActive && gs.phase !== 'buy' && gs.phase !== 'auction') {
this.busy = true;
this.dismissPropertyModal().then(() => {
this.busy = false;
this.time.delayedCall(0, () => this.advance());
});
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) {
@ -1102,19 +1445,22 @@ export default class MonopolyGame extends Phaser.Scene {
break;
}
case 'buy': {
// Modal already zoomed in (advance() called showPropertyModal before doAiAction)
const buy = chooseBuy(gs, seat, skill);
await this.delay(700);
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);
await this.shiftModalForAuction();
}
this.render();
break;
}
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
@ -1234,6 +1580,280 @@ export default class MonopolyGame extends Phaser.Scene {
});
}
// ── Property Purchase Modal ────────────────────────────────────────────────
buildPropertyCardContainer(spaceIdx) {
const sp = SPACES[spaceIdx];
const w = MODAL_W, h = MODAL_H, bh = MODAL_BAND_H;
const container = this.add.container(0, 0);
const g = this.add.graphics();
// Card background + border
g.fillStyle(0xFFF8E7, 1);
g.fillRoundedRect(-w/2, -h/2, w, h, 8);
g.lineStyle(2, 0x2c1810, 1);
g.strokeRoundedRect(-w/2, -h/2, w, h, 8);
// Top band
const bandCol = sp.group ? GROUP_COLORS[sp.group]
: sp.type === 'railroad' ? 0x1a1208
: sp.type === 'utility' && spaceIdx === 12 ? 0xFFD700
: 0x1565C0;
g.fillStyle(bandCol, 1);
g.fillRoundedRect(-w/2, -h/2, w, bh, { tl:8, tr:8, bl:0, br:0 });
// Band separator line
g.lineStyle(1, 0x2c1810, 0.5);
g.beginPath(); g.moveTo(-w/2, -h/2 + bh); g.lineTo(w/2, -h/2 + bh); g.strokePath();
container.add(g);
const bandTextCol = '#FFF8E7';
const darkTextCol = '#1a1208';
// "TITLE DEED" inside band
container.add(this.add.text(0, -h/2 + 8, 'TITLE DEED', {
fontFamily: '"Julius Sans One"', fontSize: '10px', color: bandTextCol, align: 'center',
}).setOrigin(0.5, 0));
// Property name inside band
container.add(this.add.text(0, -h/2 + 24, sp.name, {
fontFamily: 'Righteous', fontSize: '18px', color: bandTextCol,
align: 'center', wordWrap: { width: w - 20, useAdvancedWrap: true },
}).setOrigin(0.5, 0));
// --- Content area below band ---
const contentTop = -h/2 + bh + 14;
let cy = contentTop;
if (sp.type === 'property') {
const rentLabels = [
['Rent', sp.rent[0]],
['Color group', sp.rent[1]],
['1 House', sp.rent[2]],
['2 Houses', sp.rent[3]],
['3 Houses', sp.rent[4]],
['4 Houses', sp.rent[5]],
['Hotel', sp.rent[6]],
];
rentLabels.forEach(([label, val]) => {
const row = this.add.text(0, cy, `${label} $${val}`, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: darkTextCol, align: 'center',
}).setOrigin(0.5, 0);
container.add(row);
cy += 20;
});
cy += 6;
container.add(this.add.text(0, cy, `Houses / Hotels $${sp.houseCost} each`, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
}).setOrigin(0.5, 0));
cy += 16;
container.add(this.add.text(0, cy, `Mortgage value $${sp.mortgage}`, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
}).setOrigin(0.5, 0));
} else if (sp.type === 'railroad') {
[['1 Railroad', '$25'], ['2 Railroads', '$50'], ['3 Railroads', '$100'], ['4 Railroads', '$200']]
.forEach(([label, val]) => {
container.add(this.add.text(0, cy, `${label} ${val}`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: darkTextCol, align: 'center',
}).setOrigin(0.5, 0));
cy += 24;
});
cy += 6;
container.add(this.add.text(0, cy, `Mortgage value $${sp.mortgage}`, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
}).setOrigin(0.5, 0));
} else if (sp.type === 'utility') {
['If 1 Utility owned:', '4× your dice roll', '', 'If 2 Utilities owned:', '10× your dice roll']
.forEach((line) => {
container.add(this.add.text(0, cy, line, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: darkTextCol, align: 'center',
}).setOrigin(0.5, 0));
cy += line ? 20 : 8;
});
cy += 6;
container.add(this.add.text(0, cy, `Mortgage value $${sp.mortgage}`, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
}).setOrigin(0.5, 0));
}
// Price banner near bottom of info area
container.add(this.add.text(0, h/2 - 88, `Purchase Price $${sp.price}`, {
fontFamily: 'Righteous', fontSize: '18px', color: darkTextCol, align: 'center',
}).setOrigin(0.5, 0));
// Horizontal rule above price
const ruleG = this.add.graphics();
ruleG.lineStyle(1, 0x2c1810, 0.4);
ruleG.beginPath(); ruleG.moveTo(-w/2 + 12, h/2 - 98); ruleG.lineTo(w/2 - 12, h/2 - 98); ruleG.strokePath();
container.add(ruleG);
// Player pieces currently on this space
const onSpace = this.gs.players.filter(p => p.position === spaceIdx && !p.bankrupt);
if (onSpace.length > 0) {
const spacing = 52;
const startX = -(onSpace.length - 1) * spacing / 2;
onSpace.forEach((p, i) => {
const px = startX + i * spacing;
const py = h/2 - 44;
if (this.hasPawns) {
container.add(this.add.image(px, py, 'monopoly-pawns', PAWN_FRAME(p.seat)).setDisplaySize(44, 44));
} else {
const pg = this.add.graphics();
pg.fillStyle(PLAYER_COLORS[p.seat], 1);
pg.fillCircle(px, py, 20);
pg.lineStyle(2, 0xffffff, 0.8);
pg.strokeCircle(px, py, 20);
container.add(pg);
}
});
}
return container;
}
async showPropertyModal(spaceIdx) {
const geo = spaceGeometry(spaceIdx);
const ox = BL + geo.x + geo.w / 2;
const oy = BT + geo.y + geo.h / 2;
const scaleStart = geo.w / MODAL_W;
const rotStart = -geo.rotation;
// Hide DOM portraits immediately — they render above canvas regardless of depth
this.hidePortraits();
// Dim overlay
this.modalOverlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0)
.setDepth(DEPTH.popup - 2).setInteractive();
this.modalGfx.push(this.modalOverlay);
this.tweens.add({ targets: this.modalOverlay, alpha: 0.68, duration: 400 });
// Property card container
const container = this.buildPropertyCardContainer(spaceIdx);
container.setPosition(ox, oy).setScale(scaleStart).setRotation(rotStart).setDepth(DEPTH.popup - 1);
this.modalGfx.push(container);
this.modalContainer = container;
this.modalSpaceIdx = spaceIdx;
this.modalOrigin = { ox, oy, scaleStart, rotStart };
this.modalActive = true;
return new Promise(resolve => {
this.tweens.add({
targets: container,
x: MODAL_TARGET_X, y: MODAL_TARGET_Y,
scaleX: 1, scaleY: 1,
rotation: 0,
duration: 700,
ease: 'Cubic.easeOut',
onComplete: resolve,
});
});
}
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 };
return new Promise(resolve => {
this.tweens.add({
targets: proxy,
h: MODAL_H,
duration: 1200,
ease: 'Linear',
onUpdate: () => {
fillGfx.clear();
fillGfx.fillStyle(PLAYER_COLORS[seat], 0.55);
fillGfx.fillRect(-MODAL_W/2, MODAL_H/2 - proxy.h, MODAL_W, proxy.h);
},
onComplete: resolve,
});
});
}
async shiftModalForAuction() {
if (!this.modalContainer) return;
return new Promise(resolve => {
this.tweens.add({
targets: this.modalContainer,
x: MODAL_AUCTION_X, y: MODAL_AUCTION_Y,
scaleX: MODAL_AUCTION_SCALE, scaleY: MODAL_AUCTION_SCALE,
duration: 400,
ease: 'Cubic.easeInOut',
onComplete: resolve,
});
});
}
async dismissPropertyModal() {
if (!this.modalContainer) return;
// Fill with owner color if someone bought this property
const winner = this.gs.board?.[this.modalSpaceIdx]?.owner;
if (winner !== null && winner !== undefined) {
await this.animateModalFill(winner);
await this.delay(500);
}
const { ox, oy, scaleStart, rotStart } = this.modalOrigin;
// Animate card back to board position
const returnP = new Promise(resolve => {
this.tweens.add({
targets: this.modalContainer,
x: ox, y: oy,
scaleX: scaleStart, scaleY: scaleStart,
rotation: rotStart,
duration: 600,
ease: 'Cubic.easeIn',
onComplete: resolve,
});
});
// Simultaneously fade out overlay
this.tweens.add({ targets: this.modalOverlay, alpha: 0, duration: 400 });
await returnP;
// Destroy container children first (Phaser won't do it automatically)
if (this.modalContainer) {
this.modalContainer.each(child => { try { child.destroy(); } catch {} });
this.modalContainer.destroy();
}
if (this.modalOverlay) this.modalOverlay.destroy();
this.modalGfx = [];
this.modalContainer = null;
this.modalOverlay = null;
this.modalSpaceIdx = null;
this.modalOrigin = null;
this.modalActive = false;
}
drawModalBuyButtons() {
const gs = this.gs;
if (!gs.pendingBuy || gs.current !== this.humanSeat) return;
const sp = SPACES[gs.pendingBuy.spaceIdx];
const p = gs.players[this.humanSeat];
const bx = GAME_WIDTH / 2;
const by = MODAL_TARGET_Y + MODAL_H / 2 + 52;
const buyBtn = new Button(this, bx, by,
`Buy $${sp.price}`,
() => this.onBuyProperty(),
{ width: 340, height: 56, fontSize: 24, enabled: p.cash >= sp.price });
buyBtn.setDepth(DEPTH.popup + 1);
this.reg(buyBtn);
const declineBtn = new Button(this, bx, by + 66,
'Decline → Auction',
() => this.onDeclineProperty(),
{ width: 340, height: 50, fontSize: 20, variant: 'ghost' });
declineBtn.setDepth(DEPTH.popup + 1);
this.reg(declineBtn);
}
// ── Human Handlers ─────────────────────────────────────────────────────────
onRollDice() {
if (this.busy) return;
@ -1244,23 +1864,27 @@ export default class MonopolyGame extends Phaser.Scene {
});
}
onBuyProperty() {
async onBuyProperty() {
if (this.busy) return;
this.gs = buyProperty(this.gs, this.humanSeat);
playSound(this, SFX.purchase);
this.render();
this.busy = true;
this.gs = buyProperty(this.gs, this.humanSeat); // owner set → dismissPropertyModal fills
await this.dismissPropertyModal(); // fill + zoom back
this.busy = false;
this.advance();
}
onDeclineProperty() {
async onDeclineProperty() {
if (this.busy) return;
this.busy = true;
this.gs = declineProperty(this.gs, this.humanSeat);
this.render();
await this.shiftModalForAuction();
this.busy = false;
this.advance();
}
onDismissCard() {
if (this.busy) return;
this.cardAnimPlayed = false;
this.gs = applyCardEffect(this.gs, this.humanSeat);
this.render();
this.advance();

View File

@ -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);

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 });
// 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() {

View File

@ -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) {