import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { auth } from '../../services/auth.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; import { SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS, GROUP_COLORS, GROUP_HEX, PLAYER_COLORS, PLAYER_COLOR_HEX, BAND_H, CORNER_SIZE, SPACE_W, BOARD_SIZE, CHANCE_CARDS, CC_CARDS, CARD_FRAME, PAWN_FRAME, spaceGeometry, spaceCenter, } from './MonopolyData.js'; import { createInitialState, rollDice, resolveSpace, buyProperty, declineProperty, placeBid, passAuction, buildHouse, buildHotel, sellHouse, sellHotel, mortgageProperty, unmortgageProperty, payJailFine, useJailCard, applyCardEffect, endTurn, checkGameOver, calculateRent, canBuildHouse, canBuildHotel, ownsGroup, netWorth, } from './MonopolyLogic.js'; import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay } from './MonopolyAI.js'; // ── Layout ──────────────────────────────────────────────────────────────────── const BL = 30; // board left const BT = 120; // board top const BS = BOARD_SIZE; // 840 // Right panel const RP_X = BL + BS + 50; // 920 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]], 2: [[-1,-1],[1,1]], 3: [[-1,-1],[0,0],[1,1]], 4: [[-1,-1],[1,-1],[-1,1],[1,1]], 5: [[-1,-1],[1,-1],[0,0],[-1,1],[1,1]], 6: [[-1,-1],[1,-1],[-1,0],[1,0],[-1,1],[1,1]], }; export default class MonopolyGame extends Phaser.Scene { constructor() { super('MonopolyGame'); } init(data) { this.gameDef = data.game; this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.humanSeat = 0; this.gs = null; this.busy = false; this.dyn = []; this.portraits = []; this.pawns = {}; // seat → image/circle this.dieGfx = []; // [die1Graphics, die2Graphics] 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() { try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* */ } this.hasPawns = this.textures.exists('monopoly-pawns'); this.hasCards = this.textures.exists('monopoly-cards'); const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length)); this.skillBySeat = {}; const names = []; for (let seat = 0; seat < playerCount; seat++) { if (seat === this.humanSeat) { names.push(auth.user?.username ?? 'You'); this.skillBySeat[seat] = 5; } else { const opp = this.opponents[seat - 1]; names.push(opp?.name ?? `Player ${seat + 1}`); this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3)); } } this.gs = createInitialState({ playerCount, names }); this.buildBackground(); this.buildBoard(); this.buildPawns(); this.buildDiceDisplay(); this.buildPortraits(); new Button(this, GAME_WIDTH - 80, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'), { variant:'ghost', width:120, height:40, fontSize:18 }).setDepth(DEPTH.ui); this.render(); this.advance(); } // ── Background ────────────────────────────────────────────────────────────── buildBackground() { const pf = this.playfield; if (pf?.key && this.textures.exists(pf.key)) { this.add.image(GAME_WIDTH/2, GAME_HEIGHT/2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg); } else { const g = this.add.graphics().setDepth(DEPTH.bg); g.fillGradientStyle(0x1a1508, 0x1a1508, 0x0a0805, 0x0a0805, 1); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); } this.add.text(BL + BS/2, 60, 'Monopoly', { fontFamily:'Righteous', fontSize:'52px', color:'#E8C12C', }).setOrigin(0.5).setDepth(DEPTH.ui); } // ── Static Board ──────────────────────────────────────────────────────────── buildBoard() { const g = this.add.graphics().setDepth(DEPTH.board); // Outer board background g.fillStyle(0xFFF8E7, 1); g.fillRect(BL, BT, BS, BS); g.lineStyle(3, 0x2c1810, 1); g.strokeRect(BL, BT, BS, BS); // Center area const cx = BL + CORNER_SIZE; const cy = BT + CORNER_SIZE; const cw = BS - 2 * CORNER_SIZE; g.fillStyle(0xFFF0D0, 1); g.fillRect(cx, cy, cw, cw); // Center MONOPOLY logo this.add.text(BL + BS/2, BT + BS/2 - 30, 'MONOPOLY', { fontFamily:'Righteous', fontSize:'52px', color:'#B71C1C', stroke:'#7f1010', strokeThickness:3, }).setOrigin(0.5).setDepth(DEPTH.text); this.add.text(BL + BS/2, BT + BS/2 + 32, '🎩 THE CLASSIC BOARD GAME', { fontFamily:'"Julius Sans One"', fontSize:'14px', color:'#555544', }).setOrigin(0.5).setDepth(DEPTH.text); // Draw all 40 spaces for (let i = 0; i < 40; i++) this.drawBoardSpace(g, i); } drawBoardSpace(g, idx) { const geo = spaceGeometry(idx); const bx = BL + geo.x, by = BT + geo.y; const sp = SPACES[idx]; // Space background g.fillStyle(0xFFF8E7, 1); g.fillRect(bx, by, geo.w, geo.h); g.lineStyle(1, 0x2c1810, 1); g.strokeRect(bx, by, geo.w, geo.h); if (geo.isCorner) { this.drawCornerSpace(g, idx, bx, by, geo.w, geo.h); return; } // Color band for properties if (sp.group && GROUP_COLORS[sp.group]) { const col = GROUP_COLORS[sp.group]; g.fillStyle(col, 1); switch (geo.bandEdge) { case 'top': g.fillRect(bx, by, geo.w, BAND_H); break; case 'bottom': g.fillRect(bx, by + geo.h - BAND_H, geo.w, BAND_H); break; case 'left': g.fillRect(bx, by, BAND_H, geo.h); break; case 'right': g.fillRect(bx + geo.w - BAND_H, by, BAND_H, geo.h); break; } g.lineStyle(1, 0x2c1810, 1); switch (geo.bandEdge) { case 'top': g.strokeRect(bx, by, geo.w, BAND_H); break; case 'bottom': g.strokeRect(bx, by + geo.h - BAND_H, geo.w, BAND_H); break; case 'left': g.strokeRect(bx, by, BAND_H, geo.h); break; case 'right': g.strokeRect(bx + geo.w - BAND_H, by, BAND_H, geo.h); break; } } // Space name const cx = bx + geo.w / 2; const cy = by + geo.h / 2; const ww = geo.rotation === 0 || geo.rotation === Math.PI ? geo.w - 6 : geo.h - 6; const nameText = this.add.text(cx, cy, sp.name, { fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1a1208', align:'center', wordWrap:{ width: ww, useAdvancedWrap:true }, }).setOrigin(0.5).setRotation(geo.rotation).setDepth(DEPTH.text); // Price/amount below name let sub = ''; if (sp.type === 'property') sub = `$${sp.price}`; else if (sp.type === 'railroad') sub = `$${sp.price}`; else if (sp.type === 'utility') sub = `$${sp.price}`; else if (sp.type === 'tax') sub = `$${sp.amount}`; if (sub) { // Offset price below name, accounting for rotation const offsetAlong = geo.rotation === 0 ? { x:0, y:20 } : geo.rotation === Math.PI ? { x:0, y:-20 } : geo.rotation === Math.PI/2 ? { x:-20, y:0 } : { x:20, y:0 }; this.add.text(cx + offsetAlong.x, cy + offsetAlong.y, sub, { fontFamily:'"Julius Sans One"', fontSize:'7px', color:'#444433', }).setOrigin(0.5).setRotation(geo.rotation).setDepth(DEPTH.text); } // Railroad indicator if (sp.type === 'railroad') { const g2 = this.add.graphics().setDepth(DEPTH.text); g2.fillStyle(0x1a1208, 1); // Small locomotive silhouette: just a rounded rect const rw = 20, rh = 12; g2.fillRoundedRect(cx - rw/2, cy - 14 - rh/2, rw, rh, 3); g2.fillRect(cx - 8, cy - 14 + rh/2, 16, 4); } // Utility indicator if (sp.type === 'utility') { const g2 = this.add.graphics().setDepth(DEPTH.text); const isElectric = idx === 12; g2.fillStyle(isElectric ? 0xFFD700 : 0x1565C0, 1); g2.fillCircle(cx, cy - 12, 9); g2.lineStyle(2, 0x1a1208, 1); g2.strokeCircle(cx, cy - 12, 9); } } drawCornerSpace(g, idx, bx, by, w, h) { const mid = { x: bx + w/2, y: by + h/2 }; switch (idx) { case 0: { // Go g.fillStyle(0x1B5E20, 1); g.fillRect(bx, by, w, 3); g.fillRect(bx, by, 3, h); this.add.text(bx + w/2, by + h/2 - 10, 'GO', { fontFamily:'Righteous', fontSize:'24px', color:'#B71C1C', }).setOrigin(0.5).setDepth(DEPTH.text); this.add.text(bx + w/2, by + h/2 + 16, 'COLLECT\n$200 SALARY', { fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1B5E20', align:'center', }).setOrigin(0.5).setDepth(DEPTH.text); // Arrow const ag = this.add.graphics().setDepth(DEPTH.text); ag.fillStyle(0x1B5E20, 1); ag.fillTriangle(bx+14, by+h-14, bx+28, by+h-28, bx+28, by+h-14); break; } case 10: { // Jail // Just Visiting bar g.fillStyle(0xE8C12C, 1); g.fillRect(bx+3, by+3, w-6, 6); this.add.text(bx + w/2, by + h*0.3, 'JUST\nVISITING', { fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1a1208', align:'center', }).setOrigin(0.5).setDepth(DEPTH.text); // Jail bars const jg = this.add.graphics().setDepth(DEPTH.text); jg.lineStyle(2, 0x555544, 1); for (let bar = 0; bar < 4; bar++) { const bx2 = bx + 14 + bar * 14; jg.lineBetween(bx2, by + h*0.5, bx2, by + h*0.85); } jg.lineBetween(bx+10, by+h*0.5, bx+66, by+h*0.5); jg.lineBetween(bx+10, by+h*0.85, bx+66, by+h*0.85); this.add.text(bx + w/2, by + h*0.7, 'JAIL', { fontFamily:'Righteous', fontSize:'14px', color:'#E53935', }).setOrigin(0.5).setDepth(DEPTH.text + 1); break; } case 20: { // Free Parking this.add.text(bx + w/2, by + h/2 - 14, 'FREE', { fontFamily:'Righteous', fontSize:'18px', color:'#E77A2C', }).setOrigin(0.5).setDepth(DEPTH.text); this.add.text(bx + w/2, by + h/2 + 4, 'PARKING', { fontFamily:'Righteous', fontSize:'13px', color:'#E77A2C', }).setOrigin(0.5).setDepth(DEPTH.text); // Car icon const cg = this.add.graphics().setDepth(DEPTH.text); cg.fillStyle(0x1565C0, 1); cg.fillRoundedRect(bx+22, by+h-30, 60, 16, 4); cg.fillRoundedRect(bx+30, by+h-42, 44, 14, 4); cg.fillStyle(0x1a1208, 1); cg.fillCircle(bx+32, by+h-14, 5); cg.fillCircle(bx+72, by+h-14, 5); break; } case 30: { // Go to Jail g.fillStyle(0xE53935, 1); g.fillRect(bx, by+h-3, w, 3); g.fillRect(bx+w-3, by, 3, h); this.add.text(bx + w/2, by + h/2 - 20, 'GO TO\nJAIL', { fontFamily:'Righteous', fontSize:'16px', color:'#E53935', align:'center', }).setOrigin(0.5).setDepth(DEPTH.text); // Police badge const pg = this.add.graphics().setDepth(DEPTH.text); pg.fillStyle(0xE8C12C, 1); pg.fillCircle(bx + w/2, by + h/2 + 20, 18); pg.lineStyle(2, 0x1a1208, 1); pg.strokeCircle(bx + w/2, by + h/2 + 20, 18); this.add.text(bx + w/2, by + h/2 + 20, '🚔', { fontSize:'18px' }).setOrigin(0.5).setDepth(DEPTH.text+1); break; } } } // ── Pawns (created once, positioned dynamically) ──────────────────────────── buildPawns() { for (let seat = 0; seat < this.gs.playerCount; seat++) { const { x, y } = this.spacePxCenter(0); // start at Go let pawn; if (this.hasPawns) { pawn = this.add.image(x, y, 'monopoly-pawns', PAWN_FRAME(seat)) .setDisplaySize(32, 32).setDepth(DEPTH.pawns); } else { const g = this.add.graphics().setDepth(DEPTH.pawns); g.fillStyle(PLAYER_COLORS[seat], 1); g.fillCircle(0, 0, 12); g.lineStyle(2, 0xffffff, 0.8); g.strokeCircle(0, 0, 12); g.x = x; g.y = y; pawn = g; } this.pawns[seat] = pawn; } } // ── Dice Display (created once in right panel) ────────────────────────────── buildDiceDisplay() { const dx = RP_X + RP_W/2 - 55; const dy = BT + this.playerPanelTotalH() + 30; this.diceY = dy; this.dieGfx = [ this.add.graphics().setDepth(DEPTH.ui), this.add.graphics().setDepth(DEPTH.ui), ]; this.drawDie(0, dx, dy, 1); this.drawDie(1, dx + 84, dy, 1); } playerPanelTotalH() { const n = this.gs.playerCount; const rows = Math.ceil(n / 2); return rows * 190 + (rows - 1) * 12 + 20; } drawDie(idx, cx, cy, value) { const g = this.dieGfx[idx]; const size = 66; const half = size / 2; g.clear(); g.fillStyle(0xFFF8E7, 1); g.fillRoundedRect(cx - half, cy - half, size, size, 10); g.lineStyle(2, 0x4A3728, 1); g.strokeRoundedRect(cx - half, cy - half, size, size, 10); // Pips g.fillStyle(0x1a1208, 1); const pipR = 5; const step = 18; const pips = PIPS[value] ?? PIPS[1]; for (const [px, py] of pips) { g.fillCircle(cx + px * step, cy + py * step, pipR); } this.dieGfx[idx] = g; // Store die positions for later re-draw if (!this.diePositions) this.diePositions = []; this.diePositions[idx] = { cx, cy }; } // ── Portraits ────────────────────────────────────────────────────────────── buildPortraits() { const n = this.gs.playerCount; for (let seat = 0; seat < n; seat++) { const { px, py } = this.panelPos(seat); const portraitR = 28; if (seat === this.humanSeat) { this.portraits[seat] = createPlayerPortrait(this, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1, 'MonopolyGame'); } else { const opp = this.opponents[seat - 1]; this.portraits[seat] = createOpponentPortrait(this, opp, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1); } } } panelPos(seat) { const col = seat % 2; const row = Math.floor(seat / 2); const panelW = this.gs.playerCount <= 2 ? RP_W - 10 : Math.floor((RP_W - 10) / 2); const px = RP_X + col * (panelW + 10); const py = BT + row * (190 + 12); return { px, py, panelW, panelH: 182 }; } // ── Dynamic Render ───────────────────────────────────────────────────────── reg(o) { this.dyn.push(o); return o; } clearDyn() { this.dyn.forEach(o => { try { o.destroy(); } catch {} }); this.dyn = []; } render() { this.clearDyn(); this.drawHousesHotels(); this.positionPawns(); this.drawPlayerPanels(); 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() { const g = this.reg(this.add.graphics().setDepth(DEPTH.houses)); for (const idx of PURCHASABLE) { const own = this.gs.board[idx]; if (!own || own.mortgaged) { if (own?.mortgaged) { // Show mortgage stripe const geo = spaceGeometry(idx); const bx = BL + geo.x, by = BT + geo.y; g.fillStyle(0x888888, 0.4); g.fillRect(bx, by, geo.w, geo.h); } continue; } if (own.owner !== null) { // Subtle ownership tint over the cream background const geo = spaceGeometry(idx); const bx = BL + geo.x, by = BT + geo.y; g.fillStyle(PLAYER_COLORS[own.owner], 0.20); g.fillRect(bx, by, geo.w, geo.h); } if (own.hotel) { this.drawHotelOnSpace(g, idx); } else if (own.houses > 0) { this.drawHousesOnSpace(g, idx, own.houses); } } } drawHousesOnSpace(g, idx, count) { const geo = spaceGeometry(idx); const bx = BL + geo.x, by = BT + geo.y; const hw = 10, hh = 12; const totalW = count * hw + (count - 1) * 2; let sx, sy; switch (geo.bandEdge) { case 'top': sx = bx + (geo.w - totalW)/2; sy = by + geo.h - hh - 3; break; case 'bottom': sx = bx + (geo.w - totalW)/2; sy = by + 3; break; case 'left': sx = bx + geo.w - hh - 3; sy = by + (geo.h - totalW)/2; break; case 'right': sx = bx + 3; sy = by + (geo.h - totalW)/2; break; default: sx = bx + 4; sy = by + geo.h - hh - 3; } g.fillStyle(0x1B5E20, 1); g.lineStyle(1, 0xffffff, 0.8); for (let i = 0; i < count; i++) { if (geo.bandEdge === 'left' || geo.bandEdge === 'right') { g.fillRect(sx, sy + i * (hw+2), hh, hw); g.strokeRect(sx, sy + i * (hw+2), hh, hw); } else { g.fillRect(sx + i * (hw+2), sy, hw, hh); g.strokeRect(sx + i * (hw+2), sy, hw, hh); } } } drawHotelOnSpace(g, idx) { const geo = spaceGeometry(idx); const bx = BL + geo.x, by = BT + geo.y; const hw = 20, hh = 14; let hx, hy; switch (geo.bandEdge) { case 'top': hx = bx + (geo.w - hw)/2; hy = by + geo.h - hh - 3; break; case 'bottom': hx = bx + (geo.w - hw)/2; hy = by + 3; break; case 'left': hx = bx + geo.w - hh - 3; hy = by + (geo.h - hw)/2; break; case 'right': hx = bx + 3; hy = by + (geo.h - hw)/2; break; default: hx = bx + 4; hy = by + geo.h - hh - 3; } g.fillStyle(0xB71C1C, 1); g.lineStyle(1, 0xffffff, 0.8); if (geo.bandEdge === 'left' || geo.bandEdge === 'right') { g.fillRect(hx, hy, hh, hw); g.strokeRect(hx, hy, hh, hw); } else { g.fillRect(hx, hy, hw, hh); g.strokeRect(hx, hy, hw, hh); } } positionPawns() { const gs = this.gs; const seated = {}; // position → count of seated players for (let seat = 0; seat < gs.playerCount; seat++) { if (gs.players[seat].bankrupt) { if (this.pawns[seat]) { try { this.pawns[seat].setVisible(false); } catch {} } continue; } const pos = gs.players[seat].position; seated[pos] = (seated[pos] ?? 0) + 1; } const placed = {}; for (let seat = 0; seat < gs.playerCount; seat++) { if (gs.players[seat].bankrupt) continue; const pos = gs.players[seat].position; const { x, y } = this.spacePxCenter(pos); const n = seated[pos] ?? 1; const i = placed[pos] ?? 0; placed[pos] = i + 1; const offsets = this.pawnOffsets(n); const pawn = this.pawns[seat]; if (!pawn) continue; try { pawn.setVisible(true); if (typeof pawn.setPosition === 'function') pawn.setPosition(x + offsets[i].x, y + offsets[i].y); else { pawn.x = x + offsets[i].x; pawn.y = y + offsets[i].y; } } catch {} } } pawnOffsets(n) { const offsets = [ [{x:0,y:0}], [{x:-8,y:0},{x:8,y:0}], [{x:-8,y:-6},{x:8,y:-6},{x:0,y:8}], [{x:-8,y:-6},{x:8,y:-6},{x:-8,y:8},{x:8,y:8}], ]; return offsets[Math.min(n,4) - 1] ?? offsets[0]; } spacePxCenter(idx) { const c = spaceCenter(idx); return { x: BL + c.x, y: BT + c.y }; } // ── Player Panels ────────────────────────────────────────────────────────── drawPlayerPanels() { const n = this.gs.playerCount; for (let seat = 0; seat < n; seat++) { this.drawOnePanel(seat); } } drawOnePanel(seat) { const { px, py, panelW, panelH } = this.panelPos(seat); const p = this.gs.players[seat]; const isCurrent = this.gs.current === seat && this.gs.phase !== 'gameover'; const g = this.reg(this.add.graphics().setDepth(DEPTH.ui)); // Panel background const bg = isCurrent ? 0x2a2010 : 0x1e1a12; g.fillStyle(bg, 1); g.fillRoundedRect(px, py, panelW, panelH, 8); g.lineStyle(2, isCurrent ? COLORS.gold : COLORS.accent, isCurrent ? 1 : 0.5); g.strokeRoundedRect(px, py, panelW, panelH, 8); if (p.bankrupt) { this.reg(this.add.text(px + panelW/2, py + panelH/2, 'BANKRUPT', { fontFamily:'Righteous', fontSize:'22px', color:COLORS.dangerHex, }).setOrigin(0.5).setDepth(DEPTH.ui+1)); return; } // Name const nameColor = isCurrent ? COLORS.goldHex : COLORS.textHex; this.reg(this.add.text(px + 72, py + 14, p.name, { fontFamily:'Righteous', fontSize:'17px', color: nameColor, }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); // Cash this.reg(this.add.text(px + 72, py + 36, `$${p.cash.toLocaleString()}`, { fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f', }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); // Net worth const nw = netWorth(this.gs, seat); this.reg(this.add.text(px + 72, py + 56, `Net: $${nw.toLocaleString()}`, { fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex, }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); // Jail indicator if (p.jailed) { this.reg(this.add.text(px + 72, py + 74, '🔒 In Jail', { fontFamily:'"Julius Sans One"', fontSize:'12px', color:COLORS.dangerHex, }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); } // GOOJF card indicator if (p.getOutOfJailFree > 0) { this.reg(this.add.text(px + 72, py + (p.jailed ? 90 : 74), `🎴 ×${p.getOutOfJailFree}`, { fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#aaccaa', }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); } // Property color swatches let sx = px + 72, sy = py + panelH - 26; for (const [group, idxArr] of Object.entries(GROUPS)) { const owned = idxArr.filter(i => this.gs.board[i]?.owner === seat).length; if (owned === 0) continue; const hasAll = owned === idxArr.length; g.fillStyle(GROUP_COLORS[group], hasAll ? 1 : 0.4); g.fillRoundedRect(sx, sy, 16, 14, 3); g.lineStyle(1, 0xffffff, 0.5); g.strokeRoundedRect(sx, sy, 16, 14, 3); sx += 20; } // Railroads const rrOwned = RAILROADS.filter(i => this.gs.board[i]?.owner === seat).length; if (rrOwned > 0) { this.reg(this.add.text(sx, sy + 2, `🚂×${rrOwned}`, { fontFamily:'"Julius Sans One"', fontSize:'11px', color:COLORS.mutedHex, }).setOrigin(0,0).setDepth(DEPTH.ui+1)); sx += 40; } } // ── Action Bar ───────────────────────────────────────────────────────────── drawActionBar() { const gs = this.gs; if (gs.phase === 'gameover') return; // Dice values display (update) const diceX = RP_X + RP_W/2 - 55; if (gs.diceRoll) { this.drawDie(0, diceX, this.diceY, gs.diceRoll[0]); this.drawDie(1, diceX + 84, this.diceY, gs.diceRoll[1]); } // Buttons only for human's turn const isHumanTurn = gs.current === this.humanSeat; const inAuction = gs.phase === 'auction' && gs.pendingAuction; const auctionIsHuman = inAuction && gs.pendingAuction.bidOrder[gs.pendingAuction.currentBidderIdx] === this.humanSeat; if (!isHumanTurn && !auctionIsHuman) return; if (inAuction) return; // auction panel handles its own buttons const btnY0 = this.diceY + 56; const btnW = RP_W - 20; let yOff = 0; const mkBtn = (label, cb, enabled=true, opts={}) => { const btn = new Button(this, RP_X + btnW/2 + 10, btnY0 + yOff, label, cb, { width: btnW, height: 52, fontSize: 22, ...opts }); btn.setDepth(DEPTH.ui); if (!enabled) btn.setEnabled(false); this.reg(btn); yOff += 62; }; const p = gs.players[this.humanSeat]; const phase = gs.phase; if (phase === 'preroll' || phase === 'endturn') { if (phase === 'preroll') { if (p.jailed) { if (p.getOutOfJailFree > 0) { mkBtn('Use GOOJF Card', () => this.onUseJailCard()); } mkBtn('Pay $50 Fine', () => this.onPayJailFine(), p.cash >= 50); mkBtn('Roll Dice', () => this.onRollDice()); } else { mkBtn('Roll Dice', () => this.onRollDice()); } } if (phase === 'endturn') { mkBtn('End Turn', () => this.onEndTurn()); } // Build options const canBuild = PURCHASABLE.some(idx => canBuildHouse(gs, this.humanSeat, idx) || canBuildHotel(gs, this.humanSeat, idx)); if (canBuild) { mkBtn('Build Houses / Hotels', () => this.showBuildMenu(), true, { variant:'ghost' }); } // Mortgage options const canMortgage = PURCHASABLE.some(idx => { const own = gs.board[idx]; return own?.owner === this.humanSeat && !own.mortgaged && own.houses === 0 && !own.hotel; }); const canUnmortgage = PURCHASABLE.some(idx => { const own = gs.board[idx]; return own?.owner === this.humanSeat && own.mortgaged && p.cash >= Math.ceil(SPACES[idx].mortgage * 1.1); }); if (canMortgage || canUnmortgage) { mkBtn('Mortgage / Unmortgage', () => this.showMortgageMenu(), true, { variant:'ghost' }); } } if (phase === 'card' && gs.pendingCard && gs.current === this.humanSeat) { mkBtn('OK', () => this.onDismissCard()); } if (phase === 'jailChoice') { // Jail handling is in preroll above } } // ── Card Popup ───────────────────────────────────────────────────────────── drawCardPopup() { if (!this.gs.pendingCard) return; const { cardType, text } = this.gs.pendingCard; const isChance = cardType === 'chance'; const pw = 360, ph = 480; const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; // Overlay const overlay = this.reg(this.add.graphics().setDepth(DEPTH.popup - 1)); overlay.fillStyle(0x000000, 0.6); overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); // Card background const g = this.reg(this.add.graphics().setDepth(DEPTH.popup)); const cardColor = isChance ? 0xE77A2C : 0x1565C0; g.fillStyle(cardColor, 1); g.fillRoundedRect(px, py, pw, ph, 16); g.lineStyle(4, 0xFFF8E7, 1); g.strokeRoundedRect(px, py, pw, ph, 16); if (this.hasCards) { const frame = isChance ? CARD_FRAME.chance : CARD_FRAME.community_chest; this.reg(this.add.image(px + pw/2, py + 120, 'monopoly-cards', frame) .setDisplaySize(pw - 20, 220).setDepth(DEPTH.popup)); } else { // Fallback art const ag = this.reg(this.add.graphics().setDepth(DEPTH.popup)); ag.fillStyle(0xffffff, 0.15); ag.fillRoundedRect(px + 10, py + 10, pw - 20, 210, 12); this.reg(this.add.text(px + pw/2, py + 110, isChance ? '?' : '📦', { fontFamily:'Righteous', fontSize:'80px', color:'#ffffff', }).setOrigin(0.5).setDepth(DEPTH.popup+1)); } this.reg(this.add.text(px + pw/2, py + 30, isChance ? 'CHANCE' : 'COMMUNITY CHEST', { fontFamily:'Righteous', fontSize:'18px', color:'#FFF8E7', }).setOrigin(0.5).setDepth(DEPTH.popup+1)); this.reg(this.add.text(px + pw/2, py + 250, text, { fontFamily:'"Julius Sans One"', fontSize:'18px', color:'#FFF8E7', align:'center', wordWrap:{ width: pw - 30 }, }).setOrigin(0.5, 0).setDepth(DEPTH.popup+1)); } // ── Auction Panel ────────────────────────────────────────────────────────── drawAuctionPanel() { const auc = this.gs.pendingAuction; const sp = SPACES[auc.spaceIdx]; const bidderSeat = auc.bidOrder[auc.currentBidderIdx]; const isHuman = bidderSeat === this.humanSeat; 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); g.fillRoundedRect(px, py, pw, ph, 12); g.lineStyle(2, COLORS.gold, 1); g.strokeRoundedRect(px, py, pw, ph, 12); this.reg(this.add.text(px + pw/2, py + 20, 'AUCTION', { fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1)); // Property color band if (sp.group) { const bg2 = this.reg(this.add.graphics().setDepth(DEPTH.popup)); bg2.fillStyle(GROUP_COLORS[sp.group] ?? COLORS.accent, 1); bg2.fillRect(px + 20, py + 55, pw - 40, 22); } this.reg(this.add.text(px + pw/2, py + 66, sp.name, { fontFamily:'Righteous', fontSize:'18px', color:'#FFF8E7', }).setOrigin(0.5).setDepth(DEPTH.popup+1)); this.reg(this.add.text(px + pw/2, py + 100, `List Price: $${sp.price}`, { fontFamily:'"Julius Sans One"', fontSize:'15px', color:COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1)); const highBidText = auc.highBid > 0 ? `High bid: $${auc.highBid} (${this.gs.players[auc.highBidder].name})` : 'No bids yet'; this.reg(this.add.text(px + pw/2, py + 128, highBidText, { fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#aaddaa', }).setOrigin(0.5).setDepth(DEPTH.popup+1)); const bidderName = this.gs.players[bidderSeat]?.name ?? '?'; this.reg(this.add.text(px + pw/2, py + 156, `${bidderName}'s turn to bid`, { fontFamily:'"Julius Sans One"', fontSize:'14px', color:COLORS.textHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1)); if (isHuman) { // Bid controls const minBid = auc.highBid + 1; if (this.bidInput < minBid) this.bidInput = minBid; const phuman = this.gs.players[this.humanSeat]; if (this.bidInput > phuman.cash) this.bidInput = phuman.cash; this.reg(this.add.text(px + pw/2, py + 188, `Your bid: $${this.bidInput}`, { fontFamily:'Righteous', fontSize:'22px', color:COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1)); const btnH = 44, btnGap = 10; // -50, -10, +10, +50 buttons const nudgeValues = [[-50, '−50'], [-10, '−10'], [+10, '+10'], [+50, '+50']]; const nudgeBtnW = (pw - 50) / 4; nudgeValues.forEach(([delta, label], i) => { const nbx = px + 20 + i * (nudgeBtnW + 4); const btn = new Button(this, nbx + nudgeBtnW/2, py + 230, label, () => { this.bidInput = Math.max(minBid, Math.min(phuman.cash, this.bidInput + delta)); this.render(); }, { width: nudgeBtnW, height: 38, fontSize: 16 }); btn.setDepth(DEPTH.popup+2); this.reg(btn); }); const bidBtn = new Button(this, px + pw/2 - 80, py + 288, 'BID', () => { if (this.bidInput >= minBid && this.bidInput <= phuman.cash) { this.gs = placeBid(this.gs, this.humanSeat, this.bidInput); this.bidInput = 0; this.render(); this.advance(); } }, { width: 130, height: btnH, fontSize: 20 }); bidBtn.setDepth(DEPTH.popup+2); this.reg(bidBtn); const passBtn = new Button(this, px + pw/2 + 80, py + 288, 'PASS', () => { this.gs = passAuction(this.gs, this.humanSeat); this.bidInput = 0; this.render(); this.advance(); }, { width: 130, height: btnH, fontSize: 20, variant:'ghost' }); passBtn.setDepth(DEPTH.popup+2); this.reg(passBtn); } else { this.reg(this.add.text(px + pw/2, py + 230, 'Waiting for AI…', { fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1)); } } // ── Build Menu ───────────────────────────────────────────────────────────── showBuildMenu() { if (this.buildMenuOpen) return; this.buildMenuOpen = true; this.buildMenuObjs = []; const gs = this.gs; const seat = this.humanSeat; const eligible = PURCHASABLE.filter(idx => canBuildHouse(gs, seat, idx) || canBuildHotel(gs, seat, idx)); const pw = 420, itemH = 48; const ph = Math.min(600, 60 + eligible.length * (itemH + 8) + 20); const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; const overlay = this.add.graphics().setDepth(DEPTH.popup - 1); overlay.fillStyle(0x000000, 0.5); overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); this.buildMenuObjs.push(overlay); const panel = this.add.graphics().setDepth(DEPTH.popup); panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(px, py, pw, ph, 12); panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(px, py, pw, ph, 12); this.buildMenuObjs.push(panel); const title = this.add.text(px + pw/2, py + 22, 'Build Houses / Hotels', { fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1); this.buildMenuObjs.push(title); eligible.forEach((idx, i) => { const sp = SPACES[idx]; const own = gs.board[idx]; const hotelReady = canBuildHotel(gs, seat, idx); const label = hotelReady ? `${sp.name} — Build Hotel ($${sp.houseCost})` : `${sp.name} — House ${own.houses + 1}/4 ($${sp.houseCost})`; const by = py + 56 + i * (itemH + 8); const btn = new Button(this, px + pw/2, by + itemH/2, label, () => { this.closeBuildMenu(); if (hotelReady) { this.gs = buildHotel(this.gs, seat, idx); } else { this.gs = buildHouse(this.gs, seat, idx); } this.render(); }, { width: pw - 20, height: itemH, fontSize: 16 }); btn.setDepth(DEPTH.popup+2); this.buildMenuObjs.push(btn); }); if (eligible.length === 0) { const noElig = this.add.text(px + pw/2, py + 80, 'No properties eligible to build on.', { fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1); this.buildMenuObjs.push(noElig); } const closeBtn = new Button(this, px + pw/2, py + ph - 30, 'Close', () => this.closeBuildMenu(), { variant:'ghost', width:120, height:40, fontSize:16 }); closeBtn.setDepth(DEPTH.popup+2); this.buildMenuObjs.push(closeBtn); } closeBuildMenu() { this.buildMenuOpen = false; this.buildMenuObjs?.forEach(o => { try { o.destroy(); } catch {} }); this.buildMenuObjs = []; } // ── Mortgage Menu ────────────────────────────────────────────────────────── showMortgageMenu() { if (this.mortMenuOpen) return; this.mortMenuOpen = true; this.mortMenuObjs = []; const gs = this.gs; const seat = this.humanSeat; const canMort = PURCHASABLE.filter(idx => { const own = gs.board[idx]; return own?.owner === seat && !own.mortgaged && own.houses === 0 && !own.hotel; }); const canUnmort = PURCHASABLE.filter(idx => { const own = gs.board[idx]; return own?.owner === seat && own.mortgaged; }); const items = [ ...canMort.map(idx => ({ idx, action:'mortgage' })), ...canUnmort.map(idx => ({ idx, action:'unmortgage' })), ]; const pw = 440, itemH = 48; const ph = Math.min(620, 60 + items.length * (itemH + 8) + 20); const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; const overlay = this.add.graphics().setDepth(DEPTH.popup - 1); overlay.fillStyle(0x000000, 0.5); overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); this.mortMenuObjs.push(overlay); const panel = this.add.graphics().setDepth(DEPTH.popup); panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(px, py, pw, ph, 12); panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(px, py, pw, ph, 12); this.mortMenuObjs.push(panel); const title = this.add.text(px + pw/2, py + 22, 'Mortgage / Unmortgage', { fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1); this.mortMenuObjs.push(title); items.forEach(({ idx, action }, i) => { const sp = SPACES[idx]; const cost = action === 'mortgage' ? sp.mortgage : Math.ceil(sp.mortgage * 1.1); const label = action === 'mortgage' ? `Mortgage ${sp.name} (+$${cost})` : `Unmortgage ${sp.name} (−$${cost})`; const enabled = action === 'unmortgage' ? gs.players[seat].cash >= cost : true; const by = py + 56 + i * (itemH + 8); const btn = new Button(this, px + pw/2, by + itemH/2, label, () => { this.closeMortMenu(); if (action === 'mortgage') { this.gs = mortgageProperty(this.gs, seat, idx); } else { this.gs = unmortgageProperty(this.gs, seat, idx); } this.render(); }, { width: pw - 20, height: itemH, fontSize: 15 }); btn.setDepth(DEPTH.popup+2); if (!enabled) btn.setEnabled(false); this.mortMenuObjs.push(btn); }); if (items.length === 0) { const noItems = this.add.text(px + pw/2, py + 80, 'Nothing to mortgage or unmortgage.', { fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.popup+1); this.mortMenuObjs.push(noItems); } const closeBtn = new Button(this, px + pw/2, py + ph - 30, 'Close', () => this.closeMortMenu(), { variant:'ghost', width:120, height:40, fontSize:16 }); closeBtn.setDepth(DEPTH.popup+2); this.mortMenuObjs.push(closeBtn); } closeMortMenu() { this.mortMenuOpen = false; this.mortMenuObjs?.forEach(o => { try { o.destroy(); } catch {} }); this.mortMenuObjs = []; } // ── Game Over ────────────────────────────────────────────────────────────── showGameOver() { const winner = this.gs.winner !== null ? this.gs.players[this.gs.winner] : null; const overlay = this.add.graphics().setDepth(DEPTH.banner); overlay.fillStyle(0x000000, 0.7); overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); const pw = 600, ph = 280; const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; const bg = this.add.graphics().setDepth(DEPTH.banner+1); bg.fillStyle(0x1e1a12, 1); bg.fillRoundedRect(px, py, pw, ph, 16); bg.lineStyle(3, COLORS.gold, 1); bg.strokeRoundedRect(px, py, pw, ph, 16); const msg = winner ? `${winner.name} Wins!` : 'Game Over'; this.add.text(GAME_WIDTH/2, py + 60, msg, { fontFamily:'Righteous', fontSize:'54px', color:COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.banner+2); if (winner) { this.add.text(GAME_WIDTH/2, py + 140, `Net worth: $${netWorth(this.gs, winner.seat).toLocaleString()}`, { fontFamily:'"Julius Sans One"', fontSize:'22px', color:COLORS.textHex, }).setOrigin(0.5).setDepth(DEPTH.banner+2); } new Button(this, GAME_WIDTH/2, py + 218, 'Back to Menu', () => this.scene.start('GameMenu'), { width:220, height:52, fontSize:22, }).setDepth(DEPTH.banner+3); } // ── Game Flow ────────────────────────────────────────────────────────────── advance() { if (this.busy) return; this.render(); 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) { actingSeat = gs.pendingAuction.bidOrder[gs.pendingAuction.currentBidderIdx]; } if (actingSeat !== this.humanSeat) { this.busy = true; const delay = nextThinkDelay(this.skillBySeat[actingSeat] ?? 3); this.time.delayedCall(delay, () => { this.doAiAction(actingSeat).then(() => { this.busy = false; this.time.delayedCall(0, () => this.advance()); }); }); } // Human: buttons are live from render() } aiDelay(seat) { return nextThinkDelay(this.skillBySeat[seat] ?? 3); } delay(ms) { return new Promise(r => this.time.delayedCall(ms, r)); } async doAiAction(seat) { const gs = this.gs; const skill = this.skillBySeat[seat] ?? 3; if (gs.phase === 'auction') { const bid = chooseBid(gs, seat, skill); if (bid !== null) { this.gs = placeBid(this.gs, seat, bid); } else { this.gs = passAuction(this.gs, seat); } this.render(); return; } if (gs.current !== seat) return; switch (gs.phase) { case 'preroll': { // Build first if possible const buildAct = chooseBuild(gs, seat, skill); if (buildAct) { if (buildAct.action === 'hotel') { this.gs = buildHotel(this.gs, seat, buildAct.spaceIdx); } else { this.gs = buildHouse(this.gs, seat, buildAct.spaceIdx); } this.render(); await this.delay(350); await this.doAiAction(seat); return; } // Jail handling if (gs.players[seat].jailed) { const ja = chooseJailAction(gs, seat, skill); if (ja === 'card' && gs.players[seat].getOutOfJailFree > 0) { this.gs = useJailCard(this.gs, seat); this.render(); await this.delay(500); } else if (ja === 'pay' && gs.players[seat].cash >= 50) { this.gs = payJailFine(this.gs, seat); this.render(); await this.delay(500); } } await this.executeRoll(seat); break; } case 'buy': { // Modal already zoomed in (advance() called showPropertyModal before doAiAction) const buy = chooseBuy(gs, seat, skill); 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.gs = applyCardEffect(this.gs, seat); this.render(); // If card moved player to buy or another phase, handle next advance break; } case 'endturn': { await this.delay(450); this.gs = endTurn(this.gs); this.render(); break; } } } async executeRoll(seat) { const d1 = Math.floor(Math.random() * 6) + 1; const d2 = Math.floor(Math.random() * 6) + 1; await this.animateDice(d1, d2); playSound(this, SFX.diceRoll); const prevPos = this.gs.players[seat].position; const wasJailed = this.gs.players[seat].jailed; this.gs = rollDice(this.gs, seat, d1, d2); const finalPos = this.gs.players[seat].position; const nowJailed = this.gs.players[seat].jailed; // Animate the dice-total steps; snap to finalPos afterward if redirected (e.g. Go to Jail) const diceTarget = (prevPos + d1 + d2) % 40; const shouldAnimate = wasJailed ? !nowJailed : true; if (shouldAnimate) { await this.animatePawnMove(seat, prevPos, diceTarget); if (finalPos !== diceTarget) { const pawn = this.pawns[seat]; if (pawn) { const { x, y } = this.spacePxCenter(finalPos); pawn.x = x; pawn.y = y; } } } this.render(); } // ── Animations ───────────────────────────────────────────────────────────── animateDice(d1, d2) { return new Promise(resolve => { let count = 0; const total = 12; const diceX = RP_X + RP_W/2 - 55; const ev = this.time.addEvent({ delay: 70, repeat: total - 1, callback: () => { count++; const r1 = count < total ? Math.floor(Math.random()*6)+1 : d1; const r2 = count < total ? Math.floor(Math.random()*6)+1 : d2; this.drawDie(0, diceX, this.diceY, r1); this.drawDie(1, diceX + 84, this.diceY, r2); if (count >= total) resolve(); }, }); }); } animatePawnMove(seat, fromPos, toPos) { return new Promise(resolve => { const steps = ((toPos - fromPos + 40) % 40) || 0; if (steps === 0) { resolve(); return; } const pawn = this.pawns[seat]; if (!pawn) { resolve(); return; } // Board center — arches always point toward here const BOARD_CX = BL + BS / 2; const BOARD_CY = BT + BS / 2; const ARCH_H = 44; // pixels the arc peak is pushed toward board center let step = 0; let cur = fromPos; const hopOne = () => { if (step >= steps) { resolve(); return; } step++; const fromSpace = cur; cur = (cur + 1) % 40; const start = { x: pawn.x, y: pawn.y }; const { x: ex, y: ey } = this.spacePxCenter(cur); // Midpoint of start → end const mx = (start.x + ex) / 2; const my = (start.y + ey) / 2; // Unit vector from midpoint toward board center const dx = BOARD_CX - mx; const dy = BOARD_CY - my; const dist = Math.hypot(dx, dy); let nx = dist > 0 ? dx / dist : 0; let ny = dist > 0 ? dy / dist : -1; // Top row (spaces 20–29): arch away from center so the piece hops outward if (fromSpace >= 20 && fromSpace <= 29) { nx = -nx; ny = -ny; } // Quadratic Bézier control point: ARCH_H px toward/away from board center const ctrl = { x: mx + nx * ARCH_H, y: my + ny * ARCH_H }; const proxy = { t: 0 }; this.tweens.add({ targets: proxy, t: 1, duration: 500, ease: 'Sine.easeInOut', onUpdate: () => { const t = proxy.t; const inv = 1 - t; pawn.x = inv * inv * start.x + 2 * inv * t * ctrl.x + t * t * ex; pawn.y = inv * inv * start.y + 2 * inv * t * ctrl.y + t * t * ey; }, onComplete: hopOne, }); }; hopOne(); }); } // ── 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; this.busy = true; this.executeRoll(this.humanSeat).then(() => { this.busy = false; this.advance(); }); } async onBuyProperty() { 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(); } async onDeclineProperty() { if (this.busy) return; this.busy = true; this.gs = declineProperty(this.gs, this.humanSeat); await this.shiftModalForAuction(); this.busy = false; this.advance(); } onDismissCard() { if (this.busy) return; this.gs = applyCardEffect(this.gs, this.humanSeat); this.render(); this.advance(); } onEndTurn() { if (this.busy) return; this.gs = endTurn(this.gs); this.render(); this.advance(); } onPayJailFine() { if (this.busy) return; if (this.gs.players[this.humanSeat].cash < 50) return; this.gs = payJailFine(this.gs, this.humanSeat); this.render(); this.advance(); } onUseJailCard() { if (this.busy) return; this.gs = useJailCard(this.gs, this.humanSeat); this.render(); this.advance(); } }