diff --git a/public/assets/images/monopoly-cards.png b/public/assets/images/monopoly-cards.png new file mode 100644 index 0000000..19f886f Binary files /dev/null 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 new file mode 100644 index 0000000..0487b70 Binary files /dev/null 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 a8f43a0..54bf724 100644 --- a/public/src/games/monopoly/MonopolyGame.js +++ b/public/src/games/monopoly/MonopolyGame.js @@ -32,6 +32,16 @@ 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 }; +// 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 +69,13 @@ 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; } create() { @@ -397,6 +414,7 @@ export default class MonopolyGame extends Phaser.Scene { this.drawActionBar(); if (this.gs.pendingCard) this.drawCardPopup(); if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel(); + if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons(); } drawHousesHotels() { @@ -675,12 +693,6 @@ 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()); } @@ -742,8 +754,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 +1040,26 @@ 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; + } + // Determine who acts next let actingSeat = gs.current; if (gs.phase === 'auction' && gs.pendingAuction) { @@ -1102,13 +1136,16 @@ 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; @@ -1234,6 +1271,276 @@ 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; + + // 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; + 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,18 +1551,22 @@ export default class MonopolyGame extends Phaser.Scene { }); } - onBuyProperty() { + async onBuyProperty() { if (this.busy) return; - this.gs = buyProperty(this.gs, this.humanSeat); + this.busy = true; + this.gs = buyProperty(this.gs, this.humanSeat); // owner set → dismissPropertyModal fills playSound(this, SFX.purchase); - this.render(); + 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(); }