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