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

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