Compare commits
3 Commits
77e334e192
...
359740f4f7
| Author | SHA1 | Date |
|---|---|---|
|
|
359740f4f7 | |
|
|
0317d1c14f | |
|
|
e5c1021322 |
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.
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue