fertig-classic-games/src/games/monopoly/MonopolyGame.js

2896 lines
110 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, applyRent, endTurn, checkGameOver, calculateRent,
canBuildHouse, canBuildHotel, ownsGroup, netWorth,
isTradeable, validateTrade, applyTrade,
} from './MonopolyLogic.js';
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay, evaluateTrade, buildAiTradeOffer } 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, dice:40, 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]],
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.dicePositions = []; // [{cx,cy,angle}×2] — updated on each throw landing
this.diceAnimating = false;
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;
// Trade modal (self-contained overlay, like build/mortgage menus)
this.tradeMenuOpen = false;
this.tradeMenuObjs = [];
this.tradeHoverCard = null;
this.tradeOffer = null; // { giveProps, getProps, giveCash, getCash }
this.tradeCounterparty = null; // selected opponent seat
this.tradeDragGhost = null;
this._dragHintTween = null; // pulses draggable cards while offer is empty
this._dragHintCards = [];
// AI-initiated trades: per-seat cooldown (in that seat's own turns) for proposing
// to the human, so each AI bothers the player at most once every other of its turns.
this.humanTradeCooldown = {};
// Counter-offer state (when the human counters an AI's incoming proposal)
this.tradeCounterMode = false;
this.tradeCounterFrom = null;
this._tradeResolve = null; // resolves the pending AI-proposal promise
this.tradeSummaryOpen = false; // incoming-proposal / AI-AI flash popup is showing
}
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);
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) {
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.dicePositions = [
{ cx: dx, cy: dy, angle: 0 },
{ cx: dx + 84, cy: dy, angle: 0 },
];
this.dieGfx = [
this.add.graphics().setDepth(DEPTH.dice),
this.add.graphics().setDepth(DEPTH.dice),
];
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, angle = 0) {
const g = this.dieGfx[idx];
const size = 66;
const half = size / 2;
g.clear();
g.setPosition(cx, cy);
g.setAngle(angle);
g.fillStyle(0xFFF8E7, 1);
g.fillRoundedRect(-half, -half, size, size, 10);
g.lineStyle(2, 0x4A3728, 1);
g.strokeRoundedRect(-half, -half, size, size, 10);
// Pips drawn in local space (centered at origin)
g.fillStyle(0x1a1208, 1);
const pipR = 5;
const step = 18;
const pips = PIPS[value] ?? PIPS[1];
for (const [px, py] of pips) {
g.fillCircle(px * step, py * step, pipR);
}
}
// ── Portraits ──────────────────────────────────────────────────────────────
buildPortraits() {
const n = this.gs.playerCount;
for (let seat = 0; seat < n; seat++) {
const { px, py } = this.panelPos(seat);
const portraitR = 40;
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 = []; }
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.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.tradeMenuOpen || this.tradeSummaryOpen) this.hidePortraits();
else this.showPortraits();
}
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 + 96, py + 14, p.name, {
fontFamily:'Righteous', fontSize:'17px', color: nameColor,
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
// Cash
this.reg(this.add.text(px + 96, py + 36, `$${p.cash.toLocaleString()}`, {
fontFamily:'"Julius Sans One"', fontSize:'26px', color:'#7fb87f',
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
// Net worth
const nw = netWorth(this.gs, seat);
this.reg(this.add.text(px + 96, py + 72, `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 + 96, py + 90, '🔒 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 + 96, py + (p.jailed ? 106 : 90), `🎴 ×${p.getOutOfJailFree}`, {
fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#aaccaa',
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
}
// Property color swatches
let sx = px + 96, 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 — use stored landing positions/angles; skip during throw animation
if (gs.diceRoll && !this.diceAnimating) {
const [dp0, dp1] = this.dicePositions;
this.drawDie(0, dp0.cx, dp0.cy, gs.diceRoll[0], dp0.angle);
this.drawDie(1, dp1.cx, dp1.cy, gs.diceRoll[1], dp1.angle);
}
// 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 btnW = RP_W - 20;
const BTN_H = 52;
const BTN_GAP = 10; // spacing between buttons (62 = BTN_H + BTN_GAP)
// First pass: count buttons to determine starting Y
const p = gs.players[this.humanSeat];
const phase = gs.phase;
let btnCount = 0;
if (phase === 'preroll' || phase === 'endturn') {
if (phase === 'preroll') {
if (p.jailed) {
if (p.getOutOfJailFree > 0) btnCount++;
btnCount++;
btnCount++;
} else {
btnCount++;
}
}
if (phase === 'endturn') btnCount++;
if (PURCHASABLE.some(idx =>
canBuildHouse(gs, this.humanSeat, idx) || canBuildHotel(gs, this.humanSeat, idx))) {
btnCount++;
}
if (PURCHASABLE.some(idx => {
const own = gs.board[idx];
return own?.owner === this.humanSeat && !own.mortgaged && own.houses === 0 && !own.hotel;
}) || PURCHASABLE.some(idx => {
const own = gs.board[idx];
return own?.owner === this.humanSeat && own.mortgaged &&
p.cash >= Math.ceil(SPACES[idx].mortgage * 1.1);
})) {
btnCount++;
}
if (phase === 'endturn' && this.canInitiateTrade()) btnCount++;
}
// Second pass: draw buttons aligned to board bottom (BT + BS)
const boardBottom = BT + BS;
const totalBtnH = btnCount * BTN_H + (btnCount - 1) * BTN_GAP;
const btnY0 = boardBottom - totalBtnH;
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: BTN_H, fontSize: 22, ...opts });
btn.setDepth(DEPTH.ui);
if (!enabled) btn.setEnabled(false);
this.reg(btn);
yOff += BTN_H + BTN_GAP;
};
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' });
}
// Trade option (after rolling, i.e. endturn)
if (phase === 'endturn' && this.canInitiateTrade()) {
mkBtn('Initiate Trade', () => this.showTradeModal(), true, { variant:'ghost' });
}
}
// 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);
playSound(this, SFX.MONOPAY);
// 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;
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));
// 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 ──────────────────────────────────────────────────────────
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(800, 60 + items.length * (itemH + 8) + 48);
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 = [];
}
// ── Trade Modal ────────────────────────────────────────────────────────────
canInitiateTrade() {
const gs = this.gs;
return gs.players.some(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt &&
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat));
}
// Band color matching buildPropertyCardContainer / drawBoardSpace
tradeBandColor(idx) {
const sp = SPACES[idx];
return sp.group ? GROUP_COLORS[sp.group]
: sp.type === 'railroad' ? 0x1a1208
: sp.type === 'utility' && idx === 12 ? 0xFFD700
: 0x1565C0;
}
// ── AI-initiated trades ──────────────────────────────────────────────────────
// Called during an AI seat's endturn. Proposes to the human (rate-limited to once
// every other of this AI's own turns) then completes up to 5 trades with other AIs.
async aiAttemptTrades(seat) {
const gs = this.gs;
if (!gs || gs.phase !== 'endturn' || gs.current !== seat) return;
const me = gs.players[seat];
if (!me || !me.active || me.bankrupt) return;
// Tick this AI's human-proposal cooldown down once per its own turn.
if (this.humanTradeCooldown[seat] > 0) this.humanTradeCooldown[seat]--;
const skill = this.skillBySeat[seat] ?? 3;
// 1. Propose to the human, if off cooldown.
const human = this.gs.players[this.humanSeat];
if (human && human.active && !human.bankrupt && (this.humanTradeCooldown[seat] ?? 0) <= 0) {
const offer = buildAiTradeOffer(this.gs, seat, this.humanSeat, skill,
{ targetHuman: true, targetSkill: this.skillBySeat[this.humanSeat] ?? 3 });
if (offer && validateTrade(this.gs, offer).ok) {
this.humanTradeCooldown[seat] = 2; // blocks this AI's next own turn
await this.showIncomingTradeProposal(seat, offer);
}
}
// 2. Trade with other AI opponents — up to 5 completed deals this turn.
let done = 0, guard = 0;
while (done < 5 && guard++ < 12) {
const others = this.gs.players
.filter(pl => pl.seat !== seat && pl.seat !== this.humanSeat && pl.active && !pl.bankrupt)
.map(pl => pl.seat);
Phaser.Utils.Array.Shuffle(others);
let made = false;
for (const tSeat of others) {
const tSkill = this.skillBySeat[tSeat] ?? 3;
const offer = buildAiTradeOffer(this.gs, seat, tSeat, skill, { targetSkill: tSkill });
if (!offer || !validateTrade(this.gs, offer).ok) continue;
if (!evaluateTrade(this.gs, tSeat, offer, tSkill).accept) continue;
this.gs = applyTrade(this.gs, offer);
this.render();
await this.flashAiTrade(offer);
done++; made = true;
if (done >= 5) break;
}
if (!made) break;
}
}
// Shared summary layout for both the incoming-proposal popup and the AI-AI flash.
// Returns an array of created display objects for the caller to destroy.
buildTradeSummaryContents(offer, geo, topLabel, bottomLabel) {
const objs = [];
const cardScale = 0.46;
const cw = MODAL_W * cardScale, gap = 24, rowH = MODAL_H * cardScale;
const midX = geo.x + geo.w / 2;
const drawRow = (label, props, cash, labelY) => {
objs.push(this.add.text(geo.x + 30, labelY, label, {
fontFamily:'Righteous', fontSize:'18px', color:COLORS.goldHex,
}).setOrigin(0, 0).setDepth(DEPTH.popup + 1));
const n = props.length + (cash > 0 ? 1 : 0);
const totalW = n > 0 ? n * cw + (n - 1) * gap : 0;
let x = midX - totalW / 2 + cw / 2;
const cy = labelY + 30 + rowH / 2;
for (const idx of props) {
const card = this.buildPropertyCardContainer(idx);
card.setPosition(x, cy).setScale(cardScale).setDepth(DEPTH.popup + 1);
objs.push(card);
x += cw + gap;
}
if (cash > 0) {
const badge = this.add.container(x, cy).setDepth(DEPTH.popup + 1);
const g = this.add.graphics();
g.fillStyle(0x14110a, 1); g.fillRoundedRect(-cw/2, -42, cw, 84, 10);
g.lineStyle(2, 0x7fb87f, 1); g.strokeRoundedRect(-cw/2, -42, cw, 84, 10);
badge.add(g);
badge.add(this.add.text(0, 0, `$${cash.toLocaleString()}`, {
fontFamily:'Righteous', fontSize:'26px', color:'#7fdd9f',
}).setOrigin(0.5));
objs.push(badge);
}
if (props.length === 0 && cash <= 0) {
objs.push(this.add.text(midX, cy, '— nothing —', {
fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
}
};
const row1Y = geo.y + 4;
drawRow(topLabel, offer.giveProps ?? [], offer.giveCash ?? 0, row1Y);
const divY = row1Y + 30 + rowH + 22;
const dg = this.add.graphics().setDepth(DEPTH.popup + 1);
dg.lineStyle(1, COLORS.accent, 0.5);
dg.beginPath(); dg.moveTo(geo.x + 30, divY); dg.lineTo(geo.x + geo.w - 30, divY); dg.strokePath();
objs.push(dg);
drawRow(bottomLabel, offer.getProps ?? [], offer.getCash ?? 0, divY + 14);
return objs;
}
// Incoming proposal from an AI to the human. Resolves when the human Accepts,
// Declines, or finishes a Counter-offer.
showIncomingTradeProposal(fromSeat, offer) {
return new Promise(resolve => {
this._tradeResolve = resolve;
this.tradeSummaryOpen = true;
this.hidePortraits();
const fromName = this.gs.players[fromSeat].name;
const objs = [];
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
.setDepth(DEPTH.popup - 1).setInteractive();
objs.push(overlay);
const PW = 1180, PH = 760;
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
const panel = this.add.graphics().setDepth(DEPTH.popup);
panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(PX, PY, PW, PH, 14);
panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(PX, PY, PW, PH, 14);
objs.push(panel);
objs.push(this.add.text(GAME_WIDTH/2, PY + 22, `${fromName} proposes a trade`, {
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
objs.push(...this.buildTradeSummaryContents(offer,
{ x: PX, y: PY + 58, w: PW }, `${fromName} gives you`, `You give ${fromName}`));
const finish = (result) => {
objs.forEach(o => { try { o.destroy(); } catch {} });
this.tradeSummaryOpen = false;
if (result === 'counter') {
this.openCounterModal(fromSeat, offer); // resolves _tradeResolve on close
} else {
this.showPortraits();
const r = this._tradeResolve; this._tradeResolve = null;
if (r) r();
}
};
const by = PY + PH - 54;
const accept = new Button(this, GAME_WIDTH/2 - 230, by, 'Accept', () => {
if (validateTrade(this.gs, offer).ok) {
this.gs = applyTrade(this.gs, offer);
playSound(this, SFX.MONOPOLY_PURCHASE);
this.render();
}
finish('accept');
}, { width: 200, height: 50, fontSize: 22 });
accept.setDepth(DEPTH.popup + 2); objs.push(accept);
const counter = new Button(this, GAME_WIDTH/2, by, 'Counter-offer', () => finish('counter'),
{ width: 230, height: 50, fontSize: 20, variant:'ghost' });
counter.setDepth(DEPTH.popup + 2); objs.push(counter);
const decline = new Button(this, GAME_WIDTH/2 + 230, by, 'Decline', () => finish('decline'),
{ width: 200, height: 50, fontSize: 22, variant:'ghost' });
decline.setDepth(DEPTH.popup + 2); objs.push(decline);
});
}
// Brief read-only summary of a completed AI-vs-AI trade.
flashAiTrade(offer) {
return new Promise(resolve => {
this.tradeSummaryOpen = true;
this.hidePortraits();
const fromName = this.gs.players[offer.fromSeat].name;
const toName = this.gs.players[offer.toSeat].name;
const objs = [];
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setDepth(DEPTH.popup - 1).setInteractive();
objs.push(overlay);
const PW = 1180, PH = 760;
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
const panel = this.add.graphics().setDepth(DEPTH.popup);
panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(PX, PY, PW, PH, 14);
panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(PX, PY, PW, PH, 14);
objs.push(panel);
objs.push(this.add.text(GAME_WIDTH/2, PY + 22, `${fromName}${toName} — Trade`, {
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
objs.push(...this.buildTradeSummaryContents(offer,
{ x: PX, y: PY + 58, w: PW }, `${fromName} gives`, `${toName} gives`));
objs.push(this.add.text(GAME_WIDTH/2, PY + PH - 36, 'ACCEPTED', {
fontFamily:'Righteous', fontSize:'28px', color:'#7fdd9f',
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
this.time.delayedCall(1500, () => {
objs.forEach(o => { try { o.destroy(); } catch {} });
this.tradeSummaryOpen = false;
this.showPortraits();
resolve();
});
});
}
// Open the existing drag-drop trade builder in "counter" mode, seeded with the AI's
// offer reversed to the human's perspective and locked to that opponent.
openCounterModal(fromSeat, offer) {
const seed = {
giveProps: [...(offer.getProps ?? [])], // human gives what the AI requested
getProps: [...(offer.giveProps ?? [])], // human gets what the AI offered
giveCash: offer.getCash ?? 0,
getCash: offer.giveCash ?? 0,
};
this.showTradeModal({ counter: true, counterFrom: fromSeat, seedOffer: seed });
}
showTradeModal(opts = {}) {
if (this.tradeMenuOpen) return;
if (!opts.counter && this.busy) return;
if (!opts.counter && (this.gs.current !== this.humanSeat || this.gs.phase !== 'endturn')) return;
this.tradeCounterMode = !!opts.counter;
this.tradeCounterFrom = opts.counterFrom ?? null;
this.tradeMenuOpen = true;
this.tradeMenuObjs = [];
this.tradeLaneObjs = [];
this.tradeRightObjs = [];
this.tradeMineCards = {};
this.tradeOppCards = {};
this.tradeOffer = opts.seedOffer
? { giveProps:[...opts.seedOffer.giveProps], getProps:[...opts.seedOffer.getProps],
giveCash: opts.seedOffer.giveCash, getCash: opts.seedOffer.getCash }
: { giveProps: [], getProps: [], giveCash: 0, getCash: 0 };
this.tradeDragGhost = null;
this._tradeDidDrag = false;
// DOM video portraits render above the canvas — hide them behind the modal
this.hidePortraits();
const gs = this.gs;
if (this.tradeCounterMode && this.tradeCounterFrom !== null) {
this.tradeCounterparty = this.tradeCounterFrom; // locked to the proposing AI
} else {
// Default counterparty: first active opponent owning a property, else first opponent
const opps = gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
this.tradeCounterparty = (opps.find(pl =>
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat)) ?? opps[0])?.seat ?? null;
}
// Geometry
const PW = 1600, PH = 860;
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
this._tradeGeo = { PW, PH, PX, PY,
LX: PX + 24, LW: 430,
CX: PX + 474, CW: 600,
RX: PX + 1094, RW: 482,
};
// Overlay (swallows background clicks)
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
.setDepth(DEPTH.popup - 1).setInteractive();
this.tradeMenuObjs.push(overlay);
// Panel
const panel = this.add.graphics().setDepth(DEPTH.popup);
panel.fillStyle(0x1e1a12, 1);
panel.fillRoundedRect(PX, PY, PW, PH, 14);
panel.lineStyle(2, COLORS.gold, 1);
panel.strokeRoundedRect(PX, PY, PW, PH, 14);
this.tradeMenuObjs.push(panel);
const titleTxt = this.tradeCounterMode && this.tradeCounterparty !== null
? `Counter-offer to ${gs.players[this.tradeCounterparty].name}`
: 'Propose a Trade';
this.tradeMenuObjs.push(this.add.text(GAME_WIDTH/2, PY + 24, titleTxt, {
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.buildTradeLeftColumn();
this.buildTradeCenterColumn();
this.renderRightColumn();
// Drag handlers (registered once per open)
this._onTradeDragStart = (pointer, obj) => {
if (!obj || obj._spaceIdx === undefined) return;
this.tradeDragGhost = obj; this._tradeDidDrag = true; obj._dropped = false;
this.stopDragHints();
this.clearTradeHoverCard();
this.showDropHint(obj._side);
obj._homeX = obj.x; obj._homeY = obj.y;
obj.setDepth(DEPTH.popup + 12);
};
this._onTradeDrag = (pointer, obj, dragX, dragY) => {
if (obj !== this.tradeDragGhost) return;
obj.x = dragX; obj.y = dragY;
};
// Native drop — fires only when released over a lane drop zone. Route by whose
// card it is (your card → give, their card → get) so it always lands correctly.
this._onTradeDrop = (pointer, obj, zone) => {
if (obj !== this.tradeDragGhost) return;
const type = zone?.getData?.('laneType');
if (type !== 'give' && type !== 'get') return;
obj._dropped = true;
this.addTradeProp(obj._spaceIdx, obj._side === 'mine' ? 'give' : 'get');
};
this._onTradeDragEnd = (pointer, obj) => {
if (obj !== this.tradeDragGhost) return;
obj.x = obj._homeX; obj.y = obj._homeY; // snap home; drop already handled above
obj.setDepth(DEPTH.popup + 1);
this.tradeDragGhost = null;
this.clearDropHint();
this.refreshDragHints(); // resume pulsing if nothing was dropped
};
this.input.on('dragstart', this._onTradeDragStart);
this.input.on('drag', this._onTradeDrag);
this.input.on('drop', this._onTradeDrop);
this.input.on('dragend', this._onTradeDragEnd);
this.renderTradeOffer();
this.renderTradeCash();
}
buildTradeLeftColumn() {
const { LX, LW, PY } = this._tradeGeo;
const gs = this.gs;
const p = gs.players[this.humanSeat];
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 64, 'Your Properties', {
fontFamily:'Righteous', fontSize:'18px', color:COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 88, `Cash: $${p.cash.toLocaleString()}`, {
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f',
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
const owned = PURCHASABLE.filter(i => gs.board[i]?.owner === this.humanSeat);
owned.forEach((idx, i) => {
const col = i % 3, row = Math.floor(i / 3);
const cx = LX + 70 + col * 140;
const cy = PY + 130 + row * 78;
const card = this.buildTradeMiniCard(idx, 'mine');
card.setPosition(cx, cy);
this.tradeMineCards[idx] = card;
this.tradeMenuObjs.push(card);
});
if (owned.length === 0) {
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 150, 'You own no properties.\nYou can still offer cash.', {
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex, align:'center',
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
}
}
buildTradeCenterColumn() {
const { CX, CW, PY } = this._tradeGeo;
const midX = CX + CW/2;
// Give lane
this.tradeMenuObjs.push(this.add.text(midX, PY + 70, 'You give →', {
fontFamily:'Righteous', fontSize:'16px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.tradeGiveLane = { x: CX + 10, y: PY + 90, w: CW - 20, h: 86 };
// Get lane
this.tradeMenuObjs.push(this.add.text(midX, PY + 192, '← You get', {
fontFamily:'Righteous', fontSize:'16px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.tradeGetLane = { x: CX + 10, y: PY + 212, w: CW - 20, h: 86 };
const laneG = this.add.graphics().setDepth(DEPTH.popup);
for (const lane of [this.tradeGiveLane, this.tradeGetLane]) {
laneG.fillStyle(0x14110a, 1);
laneG.fillRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
laneG.lineStyle(1, COLORS.accent, 0.6);
laneG.strokeRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
}
this.tradeMenuObjs.push(laneG);
// Real Phaser drop zones over each lane. Native drop uses Phaser's own
// render-consistent hit testing (same pipeline as normal clicks), so the
// droppable area matches exactly what's drawn. add.zone(x,y,...) takes the
// CENTER; setRectangleDropZone centers the hit area. A small pad eases aiming.
const mkZone = (lane, type) => {
const zw = lane.w + 16, zh = lane.h + 16;
const z = this.add.zone(lane.x + lane.w/2, lane.y + lane.h/2, zw, zh)
.setRectangleDropZone(zw, zh)
.setDepth(DEPTH.popup + 2);
z.setData('laneType', type);
this.tradeMenuObjs.push(z);
return z;
};
this.tradeGiveZone = mkZone(this.tradeGiveLane, 'give');
this.tradeGetZone = mkZone(this.tradeGetLane, 'get');
// Cash steppers
this.buildCashStepper('give', PY + 326);
this.buildCashStepper('get', PY + 396);
// Propose / Cancel
const proposeLabel = this.tradeCounterMode ? 'Send Counter-offer' : 'Propose Trade';
const proposeBtn = new Button(this, midX, PY + 476, proposeLabel, () => this.onProposeTrade(),
{ width: 260, height: 52, fontSize: 22 });
proposeBtn.setDepth(DEPTH.popup+2);
this.tradeMenuObjs.push(proposeBtn);
const cancelBtn = new Button(this, midX, PY + 540, 'Cancel', () => this.closeTradeModal(),
{ width: 180, height: 44, fontSize: 18, variant:'ghost' });
cancelBtn.setDepth(DEPTH.popup+2);
this.tradeMenuObjs.push(cancelBtn);
this.tradeFeedbackText = this.add.text(midX, PY + 600, 'Build your offer, then propose.', {
fontFamily:'"Julius Sans One"', fontSize:'15px', color:COLORS.mutedHex,
align:'center', wordWrap:{ width: CW - 20 },
}).setOrigin(0.5, 0).setDepth(DEPTH.popup+1);
this.tradeMenuObjs.push(this.tradeFeedbackText);
}
buildCashStepper(side, y) {
const { CX, CW } = this._tradeGeo;
const midX = CX + CW/2;
const label = side === 'give' ? 'You add cash' : 'You request cash';
this.tradeMenuObjs.push(this.add.text(CX + 10, y - 18, label, {
fontFamily:'"Julius Sans One"', fontSize:'14px', color:COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.popup+1));
const valText = this.add.text(midX, y + 8, '$0', {
fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1);
this.tradeMenuObjs.push(valText);
if (side === 'give') this.tradeGiveCashText = valText; else this.tradeGetCashText = valText;
const deltas = [[-50,'50'], [-10,'10'], [+10,'+10'], [+50,'+50']];
const bw = 64, gap = 8, totalW = deltas.length * bw + (deltas.length - 1) * gap;
let bx = midX - totalW/2 + bw/2;
const rowY = y + 36;
for (const [delta, lbl] of deltas) {
const b = new Button(this, bx, rowY, lbl, () => this.adjustTradeCash(side, delta),
{ width: bw, height: 30, fontSize: 14, variant:'ghost' });
b.setDepth(DEPTH.popup+2);
this.tradeMenuObjs.push(b);
bx += bw + gap;
}
}
adjustTradeCash(side, delta) {
if (!this.tradeOffer) return;
if (side === 'give') {
const max = this.gs.players[this.humanSeat].cash;
this.tradeOffer.giveCash = Phaser.Math.Clamp(this.tradeOffer.giveCash + delta, 0, max);
} else {
const cp = this.tradeCounterparty;
const max = cp !== null ? this.gs.players[cp].cash : 0;
this.tradeOffer.getCash = Phaser.Math.Clamp(this.tradeOffer.getCash + delta, 0, max);
}
this.renderTradeCash();
}
renderTradeCash() {
if (this.tradeGiveCashText) this.tradeGiveCashText.setText(`$${this.tradeOffer.giveCash}`);
if (this.tradeGetCashText) this.tradeGetCashText.setText(`$${this.tradeOffer.getCash}`);
this.refreshDragHints();
}
renderRightColumn() {
(this.tradeRightObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeRightObjs = [];
this.tradeOppCards = {};
const { RX, RW, PY } = this._tradeGeo;
const gs = this.gs;
const opps = this.tradeCounterMode
? gs.players.filter(pl => pl.seat === this.tradeCounterparty)
: gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
// Opponent tabs
const tabW = Math.min(150, Math.floor((RW - (opps.length - 1) * 8) / Math.max(1, opps.length)));
let tx = RX + tabW/2;
for (const pl of opps) {
const selected = pl.seat === this.tradeCounterparty;
const b = new Button(this, tx, PY + 66, pl.name.length > 10 ? pl.name.slice(0,9)+'…' : pl.name,
() => this.selectTradeCounterparty(pl.seat),
{ width: tabW, height: 36, fontSize: 14, variant: selected ? 'solid' : 'ghost' });
b.setDepth(DEPTH.popup+2);
this.tradeRightObjs.push(b);
tx += tabW + 8;
}
const cp = this.tradeCounterparty;
if (cp === null) return;
this.tradeRightObjs.push(this.add.text(RX + RW/2, PY + 96, `Cash: $${gs.players[cp].cash.toLocaleString()}`, {
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f',
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
const owned = PURCHASABLE.filter(i => gs.board[i]?.owner === cp);
owned.forEach((idx, i) => {
const col = i % 3, row = Math.floor(i / 3);
const cx = RX + 70 + col * 140;
const cy = PY + 138 + row * 78;
const card = this.buildTradeMiniCard(idx, 'opp');
card.setPosition(cx, cy);
this.tradeOppCards[idx] = card;
this.tradeRightObjs.push(card);
});
if (owned.length === 0) {
this.tradeRightObjs.push(this.add.text(RX + RW/2, PY + 150, 'They own no properties.', {
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
}
this.refreshMiniStates();
}
selectTradeCounterparty(seat) {
if (this.tradeCounterMode) return; // locked to the proposing AI
if (seat === this.tradeCounterparty) return;
this.tradeCounterparty = seat;
// getProps/getCash referenced the previous opponent — reset them
this.tradeOffer.getProps = [];
this.tradeOffer.getCash = 0;
this.renderRightColumn();
this.renderTradeOffer();
this.renderTradeCash();
this.setTradeFeedback('Build your offer, then propose.', 'muted');
}
buildTradeMiniCard(idx, side) {
const sp = SPACES[idx];
const own = this.gs.board[idx];
const MW = 128, MH = 66;
const c = this.add.container(0, 0).setDepth(DEPTH.popup + 1);
const g = this.add.graphics();
g.fillStyle(0xFFF8E7, 1);
g.fillRoundedRect(-MW/2, -MH/2, MW, MH, 6);
g.lineStyle(1, 0x2c1810, 1);
g.strokeRoundedRect(-MW/2, -MH/2, MW, MH, 6);
g.fillStyle(this.tradeBandColor(idx), 1);
g.fillRect(-MW/2, -MH/2, MW, 14);
c.add(g);
c.add(this.add.text(0, -MH/2 + 18, sp.name, {
fontFamily:'"Julius Sans One"', fontSize:'10px', color:'#1a1208',
align:'center', wordWrap:{ width: MW - 10, useAdvancedWrap:true },
}).setOrigin(0.5, 0));
const sub = (sp.type === 'property' || sp.type === 'railroad' || sp.type === 'utility') ? `$${sp.price}` : '';
if (sub) {
c.add(this.add.text(0, MH/2 - 14, sub, {
fontFamily:'"Julius Sans One"', fontSize:'9px', color:'#555544',
}).setOrigin(0.5, 0));
}
if (own.mortgaged) {
const mg = this.add.graphics();
mg.fillStyle(0x888888, 0.45);
mg.fillRoundedRect(-MW/2, -MH/2, MW, MH, 6);
c.add(mg);
c.add(this.add.text(0, 4, 'MORTGAGED', {
fontFamily:'"Julius Sans One"', fontSize:'9px', color:'#cccccc',
}).setOrigin(0.5));
}
const tradeable = isTradeable(this.gs, idx);
if (tradeable) {
const og = this.add.graphics();
og.lineStyle(2, 0x44cc66, 1);
og.strokeRoundedRect(-MW/2, -MH/2, MW, MH, 6);
c.add(og);
} else {
c.setAlpha(0.45);
}
c._spaceIdx = idx; c._side = side; c._tradeable = tradeable;
c._hw = MW/2; c._hh = MH/2; // half-extents for drop-zone overlap testing
// NB: do NOT call setSize() — on a Container it sets displayOrigin = size/2,
// which Phaser's hit test adds to the local point and shifts the hit area up/left.
// NB: the 3rd setInteractive arg is `dropZone` (boolean). Passing a config object
// there made every card a drop zone and broke drag-drop — keep it to 2 args.
c.setInteractive(new Phaser.Geom.Rectangle(-MW/2, -MH/2, MW, MH), Phaser.Geom.Rectangle.Contains);
if (c.input) c.input.cursor = tradeable ? 'grab' : 'default';
c.on('pointerover', () => { if (!this.tradeDragGhost) this.showTradeHoverCard(idx, c.x, c.y); });
c.on('pointerout', () => this.clearTradeHoverCard());
if (tradeable) {
this.input.setDraggable(c, true);
c.on('pointerdown', () => { this._tradeDidDrag = false; });
c.on('pointerup', () => { if (!this._tradeDidDrag) this.toggleTradeProp(idx, side); });
}
return c;
}
showDropHint(side) {
this.clearDropHint();
const lane = side === 'mine' ? this.tradeGiveLane
: side === 'opp' ? this.tradeGetLane : null;
if (!lane) return;
const g = this.add.graphics().setDepth(DEPTH.popup + 3);
g.fillStyle(0x44cc66, 0.22);
g.fillRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
g.lineStyle(4, 0x66ff88, 1);
g.strokeRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
const label = this.add.text(lane.x + lane.w/2, lane.y + lane.h/2,
side === 'mine' ? '⬇ DROP HERE TO GIVE' : '⬇ DROP HERE TO GET', {
fontFamily:'Righteous', fontSize:'22px', color:'#d6ffe0',
stroke:'#0a3a18', strokeThickness:4,
}).setOrigin(0.5).setDepth(DEPTH.popup + 4);
this.tradeDropHintObjs = [g, label];
this.tradeDropHintTween = this.tweens.add({
targets: [g, label],
alpha: { from: 1, to: 0.45 },
duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
clearDropHint() {
if (this.tradeDropHintTween) { try { this.tradeDropHintTween.stop(); } catch {} this.tradeDropHintTween = null; }
(this.tradeDropHintObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeDropHintObjs = [];
}
toggleTradeProp(idx, side) {
const lane = side === 'mine' ? 'give' : 'get';
const arr = lane === 'give' ? this.tradeOffer.giveProps : this.tradeOffer.getProps;
if (arr.includes(idx)) this.removeTradeProp(idx, lane);
else this.addTradeProp(idx, lane);
}
addTradeProp(idx, lane) {
const arr = lane === 'give' ? this.tradeOffer.giveProps : this.tradeOffer.getProps;
if (!arr.includes(idx)) { arr.push(idx); this.renderTradeOffer(); }
}
removeTradeProp(idx, lane) {
if (lane === 'give') this.tradeOffer.giveProps = this.tradeOffer.giveProps.filter(i => i !== idx);
else this.tradeOffer.getProps = this.tradeOffer.getProps.filter(i => i !== idx);
this.renderTradeOffer();
}
renderTradeOffer() {
(this.tradeLaneObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeLaneObjs = [];
const layoutChips = (idxs, lane) => {
const CW = 178, CH = 30, gap = 8, perRow = Math.max(1, Math.floor(lane.w / (CW + gap)));
idxs.forEach((idx, i) => {
const col = i % perRow, row = Math.floor(i / perRow);
const cx = lane.x + 12 + CW/2 + col * (CW + gap);
const cy = lane.y + 20 + row * (CH + 6);
this.tradeLaneObjs.push(this.buildTradeChip(idx, cx, cy,
lane === this.tradeGiveLane ? 'give' : 'get', CW, CH));
});
};
layoutChips(this.tradeOffer.giveProps, this.tradeGiveLane);
layoutChips(this.tradeOffer.getProps, this.tradeGetLane);
this.refreshMiniStates();
this.refreshDragHints();
}
buildTradeChip(idx, cx, cy, lane, CW, CH) {
const sp = SPACES[idx];
const c = this.add.container(cx, cy).setDepth(DEPTH.popup + 2);
const g = this.add.graphics();
g.fillStyle(0x2a2418, 1);
g.fillRoundedRect(-CW/2, -CH/2, CW, CH, 6);
g.lineStyle(2, this.tradeBandColor(idx), 1);
g.strokeRoundedRect(-CW/2, -CH/2, CW, CH, 6);
c.add(g);
c.add(this.add.text(-CW/2 + 8, 0, sp.name, {
fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#FFF8E7',
wordWrap:{ width: CW - 34 },
}).setOrigin(0, 0.5));
c.add(this.add.text(CW/2 - 12, 0, '✕', {
fontFamily:'Righteous', fontSize:'14px', color:'#ff8888',
}).setOrigin(0.5));
c.setInteractive(new Phaser.Geom.Rectangle(-CW/2, -CH/2, CW, CH), Phaser.Geom.Rectangle.Contains);
if (c.input) c.input.cursor = 'pointer';
c.on('pointerup', () => this.removeTradeProp(idx, lane));
return c;
}
refreshMiniStates() {
const mark = (map, arr) => {
for (const [idx, card] of Object.entries(map)) {
if (!card || !card.active) continue;
const inOffer = arr.includes(Number(idx));
if (!card._tradeable) { card.setAlpha(0.45); continue; }
card.setAlpha(inOffer ? 0.35 : 1);
}
};
mark(this.tradeMineCards, this.tradeOffer.giveProps);
mark(this.tradeOppCards, this.tradeOffer.getProps);
}
isTradeOfferEmpty() {
const o = this.tradeOffer;
return !o || (o.giveProps.length === 0 && o.getProps.length === 0 && o.giveCash === 0 && o.getCash === 0);
}
// While the offer is empty, gently pulse the draggable cards so it's obvious they
// can be picked up. Stops the moment anything is offered or requested.
startDragHints() {
this.stopDragHints();
if (!this.tradeMenuOpen) return;
const cards = [
...Object.values(this.tradeMineCards || {}),
...Object.values(this.tradeOppCards || {}),
].filter(c => c && c.active && c._tradeable);
if (!cards.length) return;
cards.forEach(c => c.setScale(1));
this._dragHintCards = cards;
this._dragHintTween = this.tweens.add({
targets: cards,
scaleX: 1.07, scaleY: 1.07,
duration: 640, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
stopDragHints() {
if (this._dragHintTween) { try { this._dragHintTween.stop(); } catch {} this._dragHintTween = null; }
(this._dragHintCards || []).forEach(c => { if (c && c.active) c.setScale(1); });
this._dragHintCards = [];
}
refreshDragHints() {
if (this.tradeMenuOpen && this.isTradeOfferEmpty()) this.startDragHints();
else this.stopDragHints();
}
showTradeHoverCard(idx, x, y) {
this.clearTradeHoverCard();
const card = this.buildPropertyCardContainer(idx);
const s = 0.82;
const hw = MODAL_W * s / 2, hh = MODAL_H * s / 2;
const hx = Phaser.Math.Clamp(x, hw + 10, GAME_WIDTH - hw - 10);
const hy = Phaser.Math.Clamp(y, hh + 10, GAME_HEIGHT - hh - 10);
card.setPosition(hx, hy).setScale(s).setDepth(DEPTH.popup + 6);
this.tradeHoverCard = card;
}
clearTradeHoverCard() {
if (this.tradeHoverCard) {
this.tradeHoverCard.each(c => { try { c.destroy(); } catch {} });
try { this.tradeHoverCard.destroy(); } catch {}
this.tradeHoverCard = null;
}
}
setTradeFeedback(msg, tone = 'muted') {
if (!this.tradeFeedbackText) return;
const color = tone === 'good' ? '#7fdd9f'
: tone === 'bad' ? COLORS.dangerHex
: tone === 'warn' ? COLORS.goldHex
: COLORS.mutedHex;
this.tradeFeedbackText.setColor(color);
this.tradeFeedbackText.setText(msg);
}
onProposeTrade() {
if (!this.tradeOffer || this.tradeCounterparty === null) return;
const offer = {
fromSeat: this.humanSeat,
toSeat: this.tradeCounterparty,
giveProps: [...this.tradeOffer.giveProps],
getProps: [...this.tradeOffer.getProps],
giveCash: this.tradeOffer.giveCash,
getCash: this.tradeOffer.getCash,
};
const v = validateTrade(this.gs, offer);
if (!v.ok) { this.setTradeFeedback(v.reason, 'warn'); return; }
const skill = this.skillBySeat[this.tradeCounterparty] ?? 3;
const verdict = evaluateTrade(this.gs, this.tradeCounterparty, offer, skill);
if (verdict.accept) {
this.gs = applyTrade(this.gs, offer);
this.setTradeFeedback('Accepted! ' + verdict.reason, 'good');
playSound(this, SFX.MONOPOLY_PURCHASE);
this.time.delayedCall(1200, () => {
this.closeTradeModal();
this.render();
});
} else if (this.tradeCounterMode) {
// The AI gets a single chance to accept a counter — a rejection ends it.
this.setTradeFeedback('They declined your counter. ' + verdict.reason, 'bad');
this.time.delayedCall(1500, () => { this.closeTradeModal(); this.render(); });
} else {
this.setTradeFeedback('Rejected: ' + verdict.reason, 'bad');
}
}
closeTradeModal() {
this.tradeMenuOpen = false;
this.showPortraits();
this.stopDragHints();
this.clearDropHint();
if (this._onTradeDragStart) this.input.off('dragstart', this._onTradeDragStart);
if (this._onTradeDrag) this.input.off('drag', this._onTradeDrag);
if (this._onTradeDrop) this.input.off('drop', this._onTradeDrop);
if (this._onTradeDragEnd) this.input.off('dragend', this._onTradeDragEnd);
this._onTradeDragStart = this._onTradeDrag = this._onTradeDrop = this._onTradeDragEnd = null;
this.clearTradeHoverCard();
(this.tradeLaneObjs || []).forEach(o => { try { o.destroy(); } catch {} });
(this.tradeRightObjs || []).forEach(o => { try { o.destroy(); } catch {} });
(this.tradeMenuObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeLaneObjs = [];
this.tradeRightObjs = [];
this.tradeMenuObjs = [];
this.tradeMineCards = {};
this.tradeOppCards = {};
this.tradeOffer = null;
this.tradeCounterparty = null;
this.tradeDragGhost = null;
this.tradeGiveCashText = null;
this.tradeGetCashText = null;
this.tradeFeedbackText = null;
this.tradeCounterMode = false;
this.tradeCounterFrom = null;
// If this modal was a counter to an AI proposal, resolve that pending promise.
const r = this._tradeResolve; this._tradeResolve = null;
if (r) r();
}
// ── 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;
}
// 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) {
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);
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
break;
}
case 'endturn': {
await this.aiAttemptTrades(seat);
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;
playSound(this, SFX.DICE_ROLL);
await this.animateDice(d1, d2);
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 => {
const defaultX = RP_X + RP_W / 2 - 55;
const defaultY = this.diceY;
// Landing positions — scattered near default spots, random angle
const land0 = {
x: defaultX + Phaser.Math.Between(-20, 20),
y: defaultY + Phaser.Math.Between(-14, 14),
angle: Phaser.Math.Between(-30, 30),
};
const land1 = {
x: defaultX + 84 + Phaser.Math.Between(-20, 20),
y: defaultY + Phaser.Math.Between(-14, 14),
angle: Phaser.Math.Between(-30, 30),
};
// Throw origin — below the landing area (like a hand tossing upward)
const throwX = RP_X + RP_W / 2;
const throwY = GAME_HEIGHT - 40;
// Arch control points — above the landing area so dice overshoot upward then fall back down
const ctrl0 = { x: defaultX - 60, y: defaultY - 380 };
const ctrl1 = { x: defaultX + 80, y: defaultY - 350 };
// Total spin per die: 23 full rotations ending at the landing angle
const dir0 = Math.random() < 0.5 ? 1 : -1;
const dir1 = Math.random() < 0.5 ? 1 : -1;
const spin0 = dir0 * (Phaser.Math.Between(2, 3) * 360 + land0.angle * dir0);
const spin1 = dir1 * (Phaser.Math.Between(2, 3) * 360 + land1.angle * dir1);
const THROW_MS = 820;
let face0 = Phaser.Math.Between(1, 6);
let face1 = Phaser.Math.Between(1, 6);
// Randomize pip faces during flight
const faceTimer = this.time.addEvent({
delay: 90,
repeat: Math.ceil(THROW_MS / 90),
callback: () => {
face0 = Phaser.Math.Between(1, 6);
face1 = Phaser.Math.Between(1, 6);
},
});
this.diceAnimating = true;
const proxy = { t: 0 };
this.tweens.add({
targets: proxy,
t: 1,
duration: THROW_MS,
ease: 'Sine.easeIn',
onUpdate: () => {
const t = proxy.t;
const inv = 1 - t;
// Quadratic bezier: start → control → land
const x0 = inv*inv*throwX + 2*inv*t*ctrl0.x + t*t*land0.x;
const y0 = inv*inv*throwY + 2*inv*t*ctrl0.y + t*t*land0.y;
const x1 = inv*inv*throwX + 2*inv*t*ctrl1.x + t*t*land1.x;
const y1 = inv*inv*throwY + 2*inv*t*ctrl1.y + t*t*land1.y;
// Scale up as dice approach (perspective/depth effect)
const scale = 0.45 + t * 0.55;
this.dieGfx[0].setScale(scale);
this.dieGfx[1].setScale(scale);
this.drawDie(0, x0, y0, face0, spin0 * t);
this.drawDie(1, x1, y1, face1, spin1 * t);
},
onComplete: () => {
faceTimer.remove();
// Snap to final values at landing positions
this.dieGfx[0].setScale(1);
this.dieGfx[1].setScale(1);
this.drawDie(0, land0.x, land0.y, d1, land0.angle);
this.drawDie(1, land1.x, land1.y, d2, land1.angle);
// Store landing state for subsequent renders
this.dicePositions[0] = { cx: land0.x, cy: land0.y, angle: land0.angle };
this.dicePositions[1] = { cx: land1.x, cy: land1.y, angle: land1.angle };
// Impact bounce: scale 1 → 1.18 → 1
this.dieGfx[0].setScale(1.18);
this.dieGfx[1].setScale(1.18);
this.tweens.add({
targets: [this.dieGfx[0], this.dieGfx[1]],
scaleX: 1, scaleY: 1,
duration: 200,
ease: 'Back.easeOut',
onComplete: () => {
this.diceAnimating = false;
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 2029): 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: () => {
playSound(this, SFX.PIECE_CLICK);
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;
// 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;
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
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.cardAnimPlayed = false;
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();
}
}