2896 lines
110 KiB
JavaScript
2896 lines
110 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, applyRent, endTurn, checkGameOver, calculateRent,
|
||
canBuildHouse, canBuildHotel, ownsGroup, netWorth,
|
||
isTradeable, validateTrade, applyTrade,
|
||
} from './MonopolyLogic.js';
|
||
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay, evaluateTrade, buildAiTradeOffer } from './MonopolyAI.js';
|
||
|
||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||
const BL = 30; // board left
|
||
const BT = 120; // board top
|
||
const BS = BOARD_SIZE; // 840
|
||
|
||
// Right panel
|
||
const RP_X = BL + BS + 50; // 920
|
||
const RP_W = GAME_WIDTH - RP_X - 20; // ~980
|
||
|
||
// Depth
|
||
const DEPTH = { bg:0, board:5, band:6, text:7, houses:10, pawns:15, ui:25, dice:40, popup:50, banner:90 };
|
||
|
||
// Center deck offset (must match drawCenterDecks constant)
|
||
const DECK_D = 130;
|
||
|
||
// Property purchase modal
|
||
const MODAL_W = 340;
|
||
const MODAL_H = 500;
|
||
const MODAL_BAND_H = 80;
|
||
const MODAL_TARGET_X = GAME_WIDTH / 2; // 960
|
||
const MODAL_TARGET_Y = GAME_HEIGHT / 2; // 540
|
||
const MODAL_AUCTION_X = 680;
|
||
const MODAL_AUCTION_Y = 500;
|
||
const MODAL_AUCTION_SCALE = 0.80;
|
||
|
||
// Pip positions for each die face (relative to die center)
|
||
const PIPS = {
|
||
1: [[0,0]],
|
||
2: [[-1,-1],[1,1]],
|
||
3: [[-1,-1],[0,0],[1,1]],
|
||
4: [[-1,-1],[1,-1],[-1,1],[1,1]],
|
||
5: [[-1,-1],[1,-1],[0,0],[-1,1],[1,1]],
|
||
6: [[-1,-1],[1,-1],[-1,0],[1,0],[-1,1],[1,1]],
|
||
};
|
||
|
||
export default class MonopolyGame extends Phaser.Scene {
|
||
constructor() { super('MonopolyGame'); }
|
||
|
||
init(data) {
|
||
this.gameDef = data.game;
|
||
this.opponents = data.opponents ?? [];
|
||
this.playfield = data.playfield ?? null;
|
||
this.humanSeat = 0;
|
||
this.gs = null;
|
||
this.busy = false;
|
||
this.dyn = [];
|
||
this.portraits = [];
|
||
this.pawns = {}; // seat → image/circle
|
||
this.dieGfx = []; // [die1Graphics, die2Graphics]
|
||
this.dieVals = [1,1];
|
||
this.dicePositions = []; // [{cx,cy,angle}×2] — updated on each throw landing
|
||
this.diceAnimating = false;
|
||
this.cardPopup = null; // popup container
|
||
this.bidInput = 0; // human bid amount for auction
|
||
// Property purchase modal (managed outside dyn)
|
||
this.modalActive = false;
|
||
this.modalGfx = [];
|
||
this.modalContainer = null;
|
||
this.modalOverlay = null;
|
||
this.modalSpaceIdx = null;
|
||
this.modalOrigin = null;
|
||
// Card draw animation flag — suppresses static popup until animation finishes
|
||
this.cardAnimPlayed = false;
|
||
// Trade modal (self-contained overlay, like build/mortgage menus)
|
||
this.tradeMenuOpen = false;
|
||
this.tradeMenuObjs = [];
|
||
this.tradeHoverCard = null;
|
||
this.tradeOffer = null; // { giveProps, getProps, giveCash, getCash }
|
||
this.tradeCounterparty = null; // selected opponent seat
|
||
this.tradeDragGhost = null;
|
||
this._dragHintTween = null; // pulses draggable cards while offer is empty
|
||
this._dragHintCards = [];
|
||
// AI-initiated trades: per-seat cooldown (in that seat's own turns) for proposing
|
||
// to the human, so each AI bothers the player at most once every other of its turns.
|
||
this.humanTradeCooldown = {};
|
||
// Counter-offer state (when the human counters an AI's incoming proposal)
|
||
this.tradeCounterMode = false;
|
||
this.tradeCounterFrom = null;
|
||
this._tradeResolve = null; // resolves the pending AI-proposal promise
|
||
this.tradeSummaryOpen = false; // incoming-proposal / AI-AI flash popup is showing
|
||
}
|
||
|
||
create() {
|
||
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* */ }
|
||
this.hasPawns = this.textures.exists('monopoly-pawns');
|
||
this.hasCards = this.textures.exists('monopoly-cards');
|
||
|
||
const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length));
|
||
this.skillBySeat = {};
|
||
const names = [];
|
||
for (let seat = 0; seat < playerCount; seat++) {
|
||
if (seat === this.humanSeat) {
|
||
names.push(auth.user?.username ?? 'You');
|
||
this.skillBySeat[seat] = 5;
|
||
} else {
|
||
const opp = this.opponents[seat - 1];
|
||
names.push(opp?.name ?? `Player ${seat + 1}`);
|
||
this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
|
||
}
|
||
}
|
||
|
||
this.gs = createInitialState({ playerCount, names });
|
||
|
||
this.buildBackground();
|
||
this.buildBoard();
|
||
this.buildPawns();
|
||
this.buildDiceDisplay();
|
||
this.buildPortraits();
|
||
|
||
new Button(this, GAME_WIDTH - 80, GAME_HEIGHT - 36, 'Leave',
|
||
() => this.scene.start('GameMenu'),
|
||
{ variant:'ghost', width:120, height:40, fontSize:18 }).setDepth(DEPTH.ui);
|
||
|
||
this.render();
|
||
this.advance();
|
||
}
|
||
|
||
// ── Background ──────────────────────────────────────────────────────────────
|
||
buildBackground() {
|
||
const pf = this.playfield;
|
||
if (pf?.key && this.textures.exists(pf.key)) {
|
||
this.add.image(GAME_WIDTH/2, GAME_HEIGHT/2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||
} else {
|
||
const g = this.add.graphics().setDepth(DEPTH.bg);
|
||
g.fillGradientStyle(0x1a1508, 0x1a1508, 0x0a0805, 0x0a0805, 1);
|
||
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||
}
|
||
this.add.text(BL + BS/2, 60, 'Monopoly', {
|
||
fontFamily:'Righteous', fontSize:'52px', color:'#E8C12C',
|
||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||
}
|
||
|
||
// ── Static Board ────────────────────────────────────────────────────────────
|
||
buildBoard() {
|
||
const g = this.add.graphics().setDepth(DEPTH.board);
|
||
// Outer board background
|
||
g.fillStyle(0xFFF8E7, 1);
|
||
g.fillRect(BL, BT, BS, BS);
|
||
g.lineStyle(3, 0x2c1810, 1);
|
||
g.strokeRect(BL, BT, BS, BS);
|
||
|
||
// Center area
|
||
const cx = BL + CORNER_SIZE;
|
||
const cy = BT + CORNER_SIZE;
|
||
const cw = BS - 2 * CORNER_SIZE;
|
||
g.fillStyle(0xFFF0D0, 1);
|
||
g.fillRect(cx, cy, cw, cw);
|
||
|
||
// Center MONOPOLY logo
|
||
this.add.text(BL + BS/2, BT + BS/2 - 30, 'MONOPOLY', {
|
||
fontFamily:'Righteous', fontSize:'52px', color:'#B71C1C', stroke:'#7f1010', strokeThickness:3,
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
this.add.text(BL + BS/2, BT + BS/2 + 32, '🎩 THE CLASSIC BOARD GAME', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'14px', color:'#555544',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
|
||
// Draw all 40 spaces
|
||
for (let i = 0; i < 40; i++) this.drawBoardSpace(g, i);
|
||
|
||
this.drawCenterDecks();
|
||
}
|
||
|
||
drawCenterDecks() {
|
||
const cx = BL + BS / 2; // 450
|
||
const cy = BT + BS / 2; // 540
|
||
// Chance: lower-left, orange; Community Chest: upper-right, blue
|
||
this._drawCardDeck(cx - DECK_D, cy + DECK_D, 88, 126, -0.14, 0xE77A2C, 'Chance');
|
||
this._drawCardDeck(cx + DECK_D, cy - DECK_D, 88, 126, 0.12, 0x1565C0, 'Community\nChest');
|
||
}
|
||
|
||
_drawCardDeck(x, y, w, h, rot, color, label) {
|
||
// Darken the base color for shadow card layers
|
||
const dr = (((color >> 16) & 0xFF) * 0.60) | 0;
|
||
const dg = (((color >> 8) & 0xFF) * 0.60) | 0;
|
||
const db = ((color & 0xFF) * 0.60) | 0;
|
||
const dark = (dr << 16) | (dg << 8) | db;
|
||
|
||
const container = this.add.container(x, y).setDepth(DEPTH.text).setRotation(rot);
|
||
|
||
// Shadow card layers — offset downward-right to simulate deck thickness
|
||
for (let i = 3; i >= 1; i--) {
|
||
const sg = this.add.graphics();
|
||
sg.fillStyle(dark, 1);
|
||
sg.lineStyle(1, 0x1a1208, 0.6);
|
||
sg.fillRoundedRect(-w / 2 + i * 2, -h / 2 + i * 2, w, h, 5);
|
||
sg.strokeRoundedRect(-w / 2 + i * 2, -h / 2 + i * 2, w, h, 5);
|
||
container.add(sg);
|
||
}
|
||
|
||
// Top card body
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(color, 1);
|
||
bg.lineStyle(2, 0x1a1208, 1);
|
||
bg.fillRoundedRect(-w / 2, -h / 2, w, h, 5);
|
||
bg.strokeRoundedRect(-w / 2, -h / 2, w, h, 5);
|
||
container.add(bg);
|
||
|
||
// Inner cream border
|
||
const bdr = this.add.graphics();
|
||
bdr.lineStyle(1.5, 0xFFF8E7, 0.85);
|
||
bdr.strokeRoundedRect(-w / 2 + 6, -h / 2 + 6, w - 12, h - 12, 3);
|
||
container.add(bdr);
|
||
|
||
// Label
|
||
const txt = this.add.text(0, 0, label, {
|
||
fontFamily: 'Righteous',
|
||
fontSize: '11px',
|
||
color: '#FFFFFF',
|
||
align: 'center',
|
||
stroke: '#00000055',
|
||
strokeThickness: 1,
|
||
}).setOrigin(0.5);
|
||
container.add(txt);
|
||
}
|
||
|
||
drawBoardSpace(g, idx) {
|
||
const geo = spaceGeometry(idx);
|
||
const bx = BL + geo.x, by = BT + geo.y;
|
||
const sp = SPACES[idx];
|
||
|
||
// Space background
|
||
g.fillStyle(0xFFF8E7, 1);
|
||
g.fillRect(bx, by, geo.w, geo.h);
|
||
g.lineStyle(1, 0x2c1810, 1);
|
||
g.strokeRect(bx, by, geo.w, geo.h);
|
||
|
||
if (geo.isCorner) {
|
||
this.drawCornerSpace(g, idx, bx, by, geo.w, geo.h);
|
||
return;
|
||
}
|
||
|
||
// Color band for properties
|
||
if (sp.group && GROUP_COLORS[sp.group]) {
|
||
const col = GROUP_COLORS[sp.group];
|
||
g.fillStyle(col, 1);
|
||
switch (geo.bandEdge) {
|
||
case 'top': g.fillRect(bx, by, geo.w, BAND_H); break;
|
||
case 'bottom': g.fillRect(bx, by + geo.h - BAND_H, geo.w, BAND_H); break;
|
||
case 'left': g.fillRect(bx, by, BAND_H, geo.h); break;
|
||
case 'right': g.fillRect(bx + geo.w - BAND_H, by, BAND_H, geo.h); break;
|
||
}
|
||
g.lineStyle(1, 0x2c1810, 1);
|
||
switch (geo.bandEdge) {
|
||
case 'top': g.strokeRect(bx, by, geo.w, BAND_H); break;
|
||
case 'bottom': g.strokeRect(bx, by + geo.h - BAND_H, geo.w, BAND_H); break;
|
||
case 'left': g.strokeRect(bx, by, BAND_H, geo.h); break;
|
||
case 'right': g.strokeRect(bx + geo.w - BAND_H, by, BAND_H, geo.h); break;
|
||
}
|
||
}
|
||
|
||
// Space name
|
||
const cx = bx + geo.w / 2;
|
||
const cy = by + geo.h / 2;
|
||
const ww = geo.rotation === 0 || geo.rotation === Math.PI ? geo.w - 6 : geo.h - 6;
|
||
const nameText = this.add.text(cx, cy, sp.name, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1a1208',
|
||
align:'center', wordWrap:{ width: ww, useAdvancedWrap:true },
|
||
}).setOrigin(0.5).setRotation(geo.rotation).setDepth(DEPTH.text);
|
||
|
||
// Price/amount below name
|
||
let sub = '';
|
||
if (sp.type === 'property') sub = `$${sp.price}`;
|
||
else if (sp.type === 'railroad') sub = `$${sp.price}`;
|
||
else if (sp.type === 'utility') sub = `$${sp.price}`;
|
||
else if (sp.type === 'tax') sub = `$${sp.amount}`;
|
||
|
||
if (sub) {
|
||
// Offset price below name, accounting for rotation
|
||
const offsetAlong = geo.rotation === 0 ? { x:0, y:20 }
|
||
: geo.rotation === Math.PI ? { x:0, y:-20 }
|
||
: geo.rotation === Math.PI/2 ? { x:-20, y:0 }
|
||
: { x:20, y:0 };
|
||
this.add.text(cx + offsetAlong.x, cy + offsetAlong.y, sub, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'7px', color:'#444433',
|
||
}).setOrigin(0.5).setRotation(geo.rotation).setDepth(DEPTH.text);
|
||
}
|
||
|
||
// Railroad indicator
|
||
if (sp.type === 'railroad') {
|
||
const g2 = this.add.graphics().setDepth(DEPTH.text);
|
||
g2.fillStyle(0x1a1208, 1);
|
||
// Small locomotive silhouette: just a rounded rect
|
||
const rw = 20, rh = 12;
|
||
g2.fillRoundedRect(cx - rw/2, cy - 14 - rh/2, rw, rh, 3);
|
||
g2.fillRect(cx - 8, cy - 14 + rh/2, 16, 4);
|
||
}
|
||
|
||
// Utility indicator
|
||
if (sp.type === 'utility') {
|
||
const g2 = this.add.graphics().setDepth(DEPTH.text);
|
||
const isElectric = idx === 12;
|
||
g2.fillStyle(isElectric ? 0xFFD700 : 0x1565C0, 1);
|
||
g2.fillCircle(cx, cy - 12, 9);
|
||
g2.lineStyle(2, 0x1a1208, 1);
|
||
g2.strokeCircle(cx, cy - 12, 9);
|
||
}
|
||
}
|
||
|
||
drawCornerSpace(g, idx, bx, by, w, h) {
|
||
const mid = { x: bx + w/2, y: by + h/2 };
|
||
switch (idx) {
|
||
case 0: { // Go
|
||
g.fillStyle(0x1B5E20, 1);
|
||
g.fillRect(bx, by, w, 3);
|
||
g.fillRect(bx, by, 3, h);
|
||
this.add.text(bx + w/2, by + h/2 - 10, 'GO', {
|
||
fontFamily:'Righteous', fontSize:'24px', color:'#B71C1C',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
this.add.text(bx + w/2, by + h/2 + 16, 'COLLECT\n$200 SALARY', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1B5E20', align:'center',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
// Arrow
|
||
const ag = this.add.graphics().setDepth(DEPTH.text);
|
||
ag.fillStyle(0x1B5E20, 1);
|
||
ag.fillTriangle(bx+14, by+h-14, bx+28, by+h-28, bx+28, by+h-14);
|
||
break;
|
||
}
|
||
case 10: { // Jail
|
||
// Just Visiting bar
|
||
g.fillStyle(0xE8C12C, 1);
|
||
g.fillRect(bx+3, by+3, w-6, 6);
|
||
this.add.text(bx + w/2, by + h*0.3, 'JUST\nVISITING', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1a1208', align:'center',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
// Jail bars
|
||
const jg = this.add.graphics().setDepth(DEPTH.text);
|
||
jg.lineStyle(2, 0x555544, 1);
|
||
for (let bar = 0; bar < 4; bar++) {
|
||
const bx2 = bx + 14 + bar * 14;
|
||
jg.lineBetween(bx2, by + h*0.5, bx2, by + h*0.85);
|
||
}
|
||
jg.lineBetween(bx+10, by+h*0.5, bx+66, by+h*0.5);
|
||
jg.lineBetween(bx+10, by+h*0.85, bx+66, by+h*0.85);
|
||
this.add.text(bx + w/2, by + h*0.7, 'JAIL', {
|
||
fontFamily:'Righteous', fontSize:'14px', color:'#E53935',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text + 1);
|
||
break;
|
||
}
|
||
case 20: { // Free Parking
|
||
this.add.text(bx + w/2, by + h/2 - 14, 'FREE', {
|
||
fontFamily:'Righteous', fontSize:'18px', color:'#E77A2C',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
this.add.text(bx + w/2, by + h/2 + 4, 'PARKING', {
|
||
fontFamily:'Righteous', fontSize:'13px', color:'#E77A2C',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
// Car icon
|
||
const cg = this.add.graphics().setDepth(DEPTH.text);
|
||
cg.fillStyle(0x1565C0, 1);
|
||
cg.fillRoundedRect(bx+22, by+h-30, 60, 16, 4);
|
||
cg.fillRoundedRect(bx+30, by+h-42, 44, 14, 4);
|
||
cg.fillStyle(0x1a1208, 1);
|
||
cg.fillCircle(bx+32, by+h-14, 5);
|
||
cg.fillCircle(bx+72, by+h-14, 5);
|
||
break;
|
||
}
|
||
case 30: { // Go to Jail
|
||
g.fillStyle(0xE53935, 1);
|
||
g.fillRect(bx, by+h-3, w, 3);
|
||
g.fillRect(bx+w-3, by, 3, h);
|
||
this.add.text(bx + w/2, by + h/2 - 20, 'GO TO\nJAIL', {
|
||
fontFamily:'Righteous', fontSize:'16px', color:'#E53935', align:'center',
|
||
}).setOrigin(0.5).setDepth(DEPTH.text);
|
||
// Police badge
|
||
const pg = this.add.graphics().setDepth(DEPTH.text);
|
||
pg.fillStyle(0xE8C12C, 1);
|
||
pg.fillCircle(bx + w/2, by + h/2 + 20, 18);
|
||
pg.lineStyle(2, 0x1a1208, 1);
|
||
pg.strokeCircle(bx + w/2, by + h/2 + 20, 18);
|
||
this.add.text(bx + w/2, by + h/2 + 20, '🚔', { fontSize:'18px' }).setOrigin(0.5).setDepth(DEPTH.text+1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pawns (created once, positioned dynamically) ────────────────────────────
|
||
buildPawns() {
|
||
for (let seat = 0; seat < this.gs.playerCount; seat++) {
|
||
const { x, y } = this.spacePxCenter(0); // start at Go
|
||
let pawn;
|
||
if (this.hasPawns) {
|
||
pawn = this.add.image(x, y, 'monopoly-pawns', PAWN_FRAME(seat))
|
||
.setDisplaySize(32, 32).setDepth(DEPTH.pawns);
|
||
} else {
|
||
const g = this.add.graphics().setDepth(DEPTH.pawns);
|
||
g.fillStyle(PLAYER_COLORS[seat], 1);
|
||
g.fillCircle(0, 0, 12);
|
||
g.lineStyle(2, 0xffffff, 0.8);
|
||
g.strokeCircle(0, 0, 12);
|
||
g.x = x; g.y = y;
|
||
pawn = g;
|
||
}
|
||
this.pawns[seat] = pawn;
|
||
}
|
||
}
|
||
|
||
// ── Dice Display (created once in right panel) ──────────────────────────────
|
||
buildDiceDisplay() {
|
||
const dx = RP_X + RP_W/2 - 55;
|
||
const dy = BT + this.playerPanelTotalH() + 30;
|
||
this.diceY = dy;
|
||
this.dicePositions = [
|
||
{ cx: dx, cy: dy, angle: 0 },
|
||
{ cx: dx + 84, cy: dy, angle: 0 },
|
||
];
|
||
this.dieGfx = [
|
||
this.add.graphics().setDepth(DEPTH.dice),
|
||
this.add.graphics().setDepth(DEPTH.dice),
|
||
];
|
||
this.drawDie(0, dx, dy, 1);
|
||
this.drawDie(1, dx + 84, dy, 1);
|
||
}
|
||
|
||
playerPanelTotalH() {
|
||
const n = this.gs.playerCount;
|
||
const rows = Math.ceil(n / 2);
|
||
return rows * 190 + (rows - 1) * 12 + 20;
|
||
}
|
||
|
||
drawDie(idx, cx, cy, value, angle = 0) {
|
||
const g = this.dieGfx[idx];
|
||
const size = 66;
|
||
const half = size / 2;
|
||
g.clear();
|
||
g.setPosition(cx, cy);
|
||
g.setAngle(angle);
|
||
g.fillStyle(0xFFF8E7, 1);
|
||
g.fillRoundedRect(-half, -half, size, size, 10);
|
||
g.lineStyle(2, 0x4A3728, 1);
|
||
g.strokeRoundedRect(-half, -half, size, size, 10);
|
||
// Pips drawn in local space (centered at origin)
|
||
g.fillStyle(0x1a1208, 1);
|
||
const pipR = 5;
|
||
const step = 18;
|
||
const pips = PIPS[value] ?? PIPS[1];
|
||
for (const [px, py] of pips) {
|
||
g.fillCircle(px * step, py * step, pipR);
|
||
}
|
||
}
|
||
|
||
// ── Portraits ──────────────────────────────────────────────────────────────
|
||
buildPortraits() {
|
||
const n = this.gs.playerCount;
|
||
for (let seat = 0; seat < n; seat++) {
|
||
const { px, py } = this.panelPos(seat);
|
||
const portraitR = 40;
|
||
if (seat === this.humanSeat) {
|
||
this.portraits[seat] = createPlayerPortrait(this, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1, 'MonopolyGame');
|
||
} else {
|
||
const opp = this.opponents[seat - 1];
|
||
this.portraits[seat] = createOpponentPortrait(this, opp, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1);
|
||
}
|
||
}
|
||
}
|
||
|
||
panelPos(seat) {
|
||
const col = seat % 2;
|
||
const row = Math.floor(seat / 2);
|
||
const panelW = this.gs.playerCount <= 2 ? RP_W - 10 : Math.floor((RP_W - 10) / 2);
|
||
const px = RP_X + col * (panelW + 10);
|
||
const py = BT + row * (190 + 12);
|
||
return { px, py, panelW, panelH: 182 };
|
||
}
|
||
|
||
// ── Dynamic Render ─────────────────────────────────────────────────────────
|
||
reg(o) { this.dyn.push(o); return o; }
|
||
clearDyn() { this.dyn.forEach(o => { try { o.destroy(); } catch {} }); this.dyn = []; }
|
||
|
||
hidePortraits() { this.portraits.forEach(p => { if (p?.hide) p.hide(); }); }
|
||
showPortraits() { this.portraits.forEach(p => { if (p?.show) p.show(); }); }
|
||
|
||
render() {
|
||
this.clearDyn();
|
||
this.drawHousesHotels();
|
||
this.positionPawns();
|
||
this.drawPlayerPanels();
|
||
this.drawActionBar();
|
||
if (this.gs.pendingCard && this.cardAnimPlayed) this.drawCardPopup();
|
||
if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel();
|
||
if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons();
|
||
// DOM video portraits always render above canvas — hide them during any overlay
|
||
if (this.gs.pendingCard || this.modalActive || this.tradeMenuOpen || this.tradeSummaryOpen) this.hidePortraits();
|
||
else this.showPortraits();
|
||
}
|
||
|
||
drawHousesHotels() {
|
||
const g = this.reg(this.add.graphics().setDepth(DEPTH.houses));
|
||
for (const idx of PURCHASABLE) {
|
||
const own = this.gs.board[idx];
|
||
if (!own || own.mortgaged) {
|
||
if (own?.mortgaged) {
|
||
// Show mortgage stripe
|
||
const geo = spaceGeometry(idx);
|
||
const bx = BL + geo.x, by = BT + geo.y;
|
||
g.fillStyle(0x888888, 0.4);
|
||
g.fillRect(bx, by, geo.w, geo.h);
|
||
}
|
||
continue;
|
||
}
|
||
if (own.owner !== null) {
|
||
// Subtle ownership tint over the cream background
|
||
const geo = spaceGeometry(idx);
|
||
const bx = BL + geo.x, by = BT + geo.y;
|
||
g.fillStyle(PLAYER_COLORS[own.owner], 0.20);
|
||
g.fillRect(bx, by, geo.w, geo.h);
|
||
}
|
||
if (own.hotel) {
|
||
this.drawHotelOnSpace(g, idx);
|
||
} else if (own.houses > 0) {
|
||
this.drawHousesOnSpace(g, idx, own.houses);
|
||
}
|
||
}
|
||
}
|
||
|
||
drawHousesOnSpace(g, idx, count) {
|
||
const geo = spaceGeometry(idx);
|
||
const bx = BL + geo.x, by = BT + geo.y;
|
||
const hw = 10, hh = 12;
|
||
const totalW = count * hw + (count - 1) * 2;
|
||
let sx, sy;
|
||
switch (geo.bandEdge) {
|
||
case 'top': sx = bx + (geo.w - totalW)/2; sy = by + geo.h - hh - 3; break;
|
||
case 'bottom': sx = bx + (geo.w - totalW)/2; sy = by + 3; break;
|
||
case 'left': sx = bx + geo.w - hh - 3; sy = by + (geo.h - totalW)/2; break;
|
||
case 'right': sx = bx + 3; sy = by + (geo.h - totalW)/2; break;
|
||
default: sx = bx + 4; sy = by + geo.h - hh - 3;
|
||
}
|
||
g.fillStyle(0x1B5E20, 1);
|
||
g.lineStyle(1, 0xffffff, 0.8);
|
||
for (let i = 0; i < count; i++) {
|
||
if (geo.bandEdge === 'left' || geo.bandEdge === 'right') {
|
||
g.fillRect(sx, sy + i * (hw+2), hh, hw);
|
||
g.strokeRect(sx, sy + i * (hw+2), hh, hw);
|
||
} else {
|
||
g.fillRect(sx + i * (hw+2), sy, hw, hh);
|
||
g.strokeRect(sx + i * (hw+2), sy, hw, hh);
|
||
}
|
||
}
|
||
}
|
||
|
||
drawHotelOnSpace(g, idx) {
|
||
const geo = spaceGeometry(idx);
|
||
const bx = BL + geo.x, by = BT + geo.y;
|
||
const hw = 20, hh = 14;
|
||
let hx, hy;
|
||
switch (geo.bandEdge) {
|
||
case 'top': hx = bx + (geo.w - hw)/2; hy = by + geo.h - hh - 3; break;
|
||
case 'bottom': hx = bx + (geo.w - hw)/2; hy = by + 3; break;
|
||
case 'left': hx = bx + geo.w - hh - 3; hy = by + (geo.h - hw)/2; break;
|
||
case 'right': hx = bx + 3; hy = by + (geo.h - hw)/2; break;
|
||
default: hx = bx + 4; hy = by + geo.h - hh - 3;
|
||
}
|
||
g.fillStyle(0xB71C1C, 1);
|
||
g.lineStyle(1, 0xffffff, 0.8);
|
||
if (geo.bandEdge === 'left' || geo.bandEdge === 'right') {
|
||
g.fillRect(hx, hy, hh, hw);
|
||
g.strokeRect(hx, hy, hh, hw);
|
||
} else {
|
||
g.fillRect(hx, hy, hw, hh);
|
||
g.strokeRect(hx, hy, hw, hh);
|
||
}
|
||
}
|
||
|
||
positionPawns() {
|
||
const gs = this.gs;
|
||
const seated = {}; // position → count of seated players
|
||
for (let seat = 0; seat < gs.playerCount; seat++) {
|
||
if (gs.players[seat].bankrupt) {
|
||
if (this.pawns[seat]) { try { this.pawns[seat].setVisible(false); } catch {} }
|
||
continue;
|
||
}
|
||
const pos = gs.players[seat].position;
|
||
seated[pos] = (seated[pos] ?? 0) + 1;
|
||
}
|
||
const placed = {};
|
||
for (let seat = 0; seat < gs.playerCount; seat++) {
|
||
if (gs.players[seat].bankrupt) continue;
|
||
const pos = gs.players[seat].position;
|
||
const { x, y } = this.spacePxCenter(pos);
|
||
const n = seated[pos] ?? 1;
|
||
const i = placed[pos] ?? 0;
|
||
placed[pos] = i + 1;
|
||
const offsets = this.pawnOffsets(n);
|
||
const pawn = this.pawns[seat];
|
||
if (!pawn) continue;
|
||
try {
|
||
pawn.setVisible(true);
|
||
if (typeof pawn.setPosition === 'function') pawn.setPosition(x + offsets[i].x, y + offsets[i].y);
|
||
else { pawn.x = x + offsets[i].x; pawn.y = y + offsets[i].y; }
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
pawnOffsets(n) {
|
||
const offsets = [
|
||
[{x:0,y:0}],
|
||
[{x:-8,y:0},{x:8,y:0}],
|
||
[{x:-8,y:-6},{x:8,y:-6},{x:0,y:8}],
|
||
[{x:-8,y:-6},{x:8,y:-6},{x:-8,y:8},{x:8,y:8}],
|
||
];
|
||
return offsets[Math.min(n,4) - 1] ?? offsets[0];
|
||
}
|
||
|
||
spacePxCenter(idx) {
|
||
const c = spaceCenter(idx);
|
||
return { x: BL + c.x, y: BT + c.y };
|
||
}
|
||
|
||
// ── Player Panels ──────────────────────────────────────────────────────────
|
||
drawPlayerPanels() {
|
||
const n = this.gs.playerCount;
|
||
for (let seat = 0; seat < n; seat++) {
|
||
this.drawOnePanel(seat);
|
||
}
|
||
}
|
||
|
||
drawOnePanel(seat) {
|
||
const { px, py, panelW, panelH } = this.panelPos(seat);
|
||
const p = this.gs.players[seat];
|
||
const isCurrent = this.gs.current === seat && this.gs.phase !== 'gameover';
|
||
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
|
||
|
||
// Panel background
|
||
const bg = isCurrent ? 0x2a2010 : 0x1e1a12;
|
||
g.fillStyle(bg, 1);
|
||
g.fillRoundedRect(px, py, panelW, panelH, 8);
|
||
g.lineStyle(2, isCurrent ? COLORS.gold : COLORS.accent, isCurrent ? 1 : 0.5);
|
||
g.strokeRoundedRect(px, py, panelW, panelH, 8);
|
||
|
||
if (p.bankrupt) {
|
||
this.reg(this.add.text(px + panelW/2, py + panelH/2, 'BANKRUPT', {
|
||
fontFamily:'Righteous', fontSize:'22px', color:COLORS.dangerHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.ui+1));
|
||
return;
|
||
}
|
||
|
||
// Name
|
||
const nameColor = isCurrent ? COLORS.goldHex : COLORS.textHex;
|
||
this.reg(this.add.text(px + 96, py + 14, p.name, {
|
||
fontFamily:'Righteous', fontSize:'17px', color: nameColor,
|
||
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
|
||
|
||
// Cash
|
||
this.reg(this.add.text(px + 96, py + 36, `$${p.cash.toLocaleString()}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'26px', color:'#7fb87f',
|
||
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
|
||
|
||
// Net worth
|
||
const nw = netWorth(this.gs, seat);
|
||
this.reg(this.add.text(px + 96, py + 72, `Net: $${nw.toLocaleString()}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex,
|
||
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
|
||
|
||
// Jail indicator
|
||
if (p.jailed) {
|
||
this.reg(this.add.text(px + 96, py + 90, '🔒 In Jail', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'12px', color:COLORS.dangerHex,
|
||
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
|
||
}
|
||
|
||
// GOOJF card indicator
|
||
if (p.getOutOfJailFree > 0) {
|
||
this.reg(this.add.text(px + 96, py + (p.jailed ? 106 : 90), `🎴 ×${p.getOutOfJailFree}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#aaccaa',
|
||
}).setOrigin(0, 0).setDepth(DEPTH.ui+1));
|
||
}
|
||
|
||
// Property color swatches
|
||
let sx = px + 96, sy = py + panelH - 26;
|
||
for (const [group, idxArr] of Object.entries(GROUPS)) {
|
||
const owned = idxArr.filter(i => this.gs.board[i]?.owner === seat).length;
|
||
if (owned === 0) continue;
|
||
const hasAll = owned === idxArr.length;
|
||
g.fillStyle(GROUP_COLORS[group], hasAll ? 1 : 0.4);
|
||
g.fillRoundedRect(sx, sy, 16, 14, 3);
|
||
g.lineStyle(1, 0xffffff, 0.5);
|
||
g.strokeRoundedRect(sx, sy, 16, 14, 3);
|
||
sx += 20;
|
||
}
|
||
// Railroads
|
||
const rrOwned = RAILROADS.filter(i => this.gs.board[i]?.owner === seat).length;
|
||
if (rrOwned > 0) {
|
||
this.reg(this.add.text(sx, sy + 2, `🚂×${rrOwned}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'11px', color:COLORS.mutedHex,
|
||
}).setOrigin(0,0).setDepth(DEPTH.ui+1));
|
||
sx += 40;
|
||
}
|
||
}
|
||
|
||
// ── Action Bar ─────────────────────────────────────────────────────────────
|
||
drawActionBar() {
|
||
const gs = this.gs;
|
||
if (gs.phase === 'gameover') return;
|
||
|
||
// Dice values display — use stored landing positions/angles; skip during throw animation
|
||
if (gs.diceRoll && !this.diceAnimating) {
|
||
const [dp0, dp1] = this.dicePositions;
|
||
this.drawDie(0, dp0.cx, dp0.cy, gs.diceRoll[0], dp0.angle);
|
||
this.drawDie(1, dp1.cx, dp1.cy, gs.diceRoll[1], dp1.angle);
|
||
}
|
||
|
||
// Buttons only for human's turn
|
||
const isHumanTurn = gs.current === this.humanSeat;
|
||
const inAuction = gs.phase === 'auction' && gs.pendingAuction;
|
||
const auctionIsHuman = inAuction &&
|
||
gs.pendingAuction.bidOrder[gs.pendingAuction.currentBidderIdx] === this.humanSeat;
|
||
|
||
if (!isHumanTurn && !auctionIsHuman) return;
|
||
if (inAuction) return; // auction panel handles its own buttons
|
||
|
||
const btnW = RP_W - 20;
|
||
const BTN_H = 52;
|
||
const BTN_GAP = 10; // spacing between buttons (62 = BTN_H + BTN_GAP)
|
||
|
||
// First pass: count buttons to determine starting Y
|
||
const p = gs.players[this.humanSeat];
|
||
const phase = gs.phase;
|
||
let btnCount = 0;
|
||
|
||
if (phase === 'preroll' || phase === 'endturn') {
|
||
if (phase === 'preroll') {
|
||
if (p.jailed) {
|
||
if (p.getOutOfJailFree > 0) btnCount++;
|
||
btnCount++;
|
||
btnCount++;
|
||
} else {
|
||
btnCount++;
|
||
}
|
||
}
|
||
if (phase === 'endturn') btnCount++;
|
||
if (PURCHASABLE.some(idx =>
|
||
canBuildHouse(gs, this.humanSeat, idx) || canBuildHotel(gs, this.humanSeat, idx))) {
|
||
btnCount++;
|
||
}
|
||
if (PURCHASABLE.some(idx => {
|
||
const own = gs.board[idx];
|
||
return own?.owner === this.humanSeat && !own.mortgaged && own.houses === 0 && !own.hotel;
|
||
}) || PURCHASABLE.some(idx => {
|
||
const own = gs.board[idx];
|
||
return own?.owner === this.humanSeat && own.mortgaged &&
|
||
p.cash >= Math.ceil(SPACES[idx].mortgage * 1.1);
|
||
})) {
|
||
btnCount++;
|
||
}
|
||
if (phase === 'endturn' && this.canInitiateTrade()) btnCount++;
|
||
}
|
||
|
||
// Second pass: draw buttons aligned to board bottom (BT + BS)
|
||
const boardBottom = BT + BS;
|
||
const totalBtnH = btnCount * BTN_H + (btnCount - 1) * BTN_GAP;
|
||
const btnY0 = boardBottom - totalBtnH;
|
||
let yOff = 0;
|
||
const mkBtn = (label, cb, enabled=true, opts={}) => {
|
||
const btn = new Button(this, RP_X + btnW/2 + 10, btnY0 + yOff, label, cb,
|
||
{ width: btnW, height: BTN_H, fontSize: 22, ...opts });
|
||
btn.setDepth(DEPTH.ui);
|
||
if (!enabled) btn.setEnabled(false);
|
||
this.reg(btn);
|
||
yOff += BTN_H + BTN_GAP;
|
||
};
|
||
|
||
if (phase === 'preroll' || phase === 'endturn') {
|
||
if (phase === 'preroll') {
|
||
if (p.jailed) {
|
||
if (p.getOutOfJailFree > 0) {
|
||
mkBtn('Use GOOJF Card', () => this.onUseJailCard());
|
||
}
|
||
mkBtn('Pay $50 Fine', () => this.onPayJailFine(), p.cash >= 50);
|
||
mkBtn('Roll Dice', () => this.onRollDice());
|
||
} else {
|
||
mkBtn('Roll Dice', () => this.onRollDice());
|
||
}
|
||
}
|
||
if (phase === 'endturn') {
|
||
mkBtn('End Turn', () => this.onEndTurn());
|
||
}
|
||
// Build options
|
||
const canBuild = PURCHASABLE.some(idx =>
|
||
canBuildHouse(gs, this.humanSeat, idx) || canBuildHotel(gs, this.humanSeat, idx));
|
||
if (canBuild) {
|
||
mkBtn('Build Houses / Hotels', () => this.showBuildMenu(), true, { variant:'ghost' });
|
||
}
|
||
// Mortgage options
|
||
const canMortgage = PURCHASABLE.some(idx => {
|
||
const own = gs.board[idx];
|
||
return own?.owner === this.humanSeat && !own.mortgaged && own.houses === 0 && !own.hotel;
|
||
});
|
||
const canUnmortgage = PURCHASABLE.some(idx => {
|
||
const own = gs.board[idx];
|
||
return own?.owner === this.humanSeat && own.mortgaged &&
|
||
p.cash >= Math.ceil(SPACES[idx].mortgage * 1.1);
|
||
});
|
||
if (canMortgage || canUnmortgage) {
|
||
mkBtn('Mortgage / Unmortgage', () => this.showMortgageMenu(), true, { variant:'ghost' });
|
||
}
|
||
// Trade option (after rolling, i.e. endturn)
|
||
if (phase === 'endturn' && this.canInitiateTrade()) {
|
||
mkBtn('Initiate Trade', () => this.showTradeModal(), true, { variant:'ghost' });
|
||
}
|
||
}
|
||
|
||
// Card OK button is drawn inside drawCardPopup(), overlaid on the card
|
||
|
||
if (phase === 'jailChoice') {
|
||
// Jail handling is in preroll above
|
||
}
|
||
}
|
||
|
||
// ── Rent Payment Animation ─────────────────────────────────────────────────
|
||
async animateRent() {
|
||
const { payer, receiver, amount } = this.gs.pendingRent;
|
||
const depth = DEPTH.banner - 1; // 89 — above all game elements
|
||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||
|
||
playSound(this, SFX.MONOPOLY_EXPENSE);
|
||
|
||
// Phase 1: Banner + amount appear centered
|
||
const bannerTxt = this.add.text(cx, cy - 60, 'PAY RENT', {
|
||
fontFamily: 'Righteous', fontSize: '80px', color: '#FFFFFF',
|
||
stroke: '#1a1208', strokeThickness: 5,
|
||
}).setOrigin(0.5).setDepth(depth);
|
||
|
||
const amtTxt = this.add.text(cx, cy + 40, `$${amount.toLocaleString()}`, {
|
||
fontFamily: 'Righteous', fontSize: '56px', color: '#FFD700',
|
||
stroke: '#1a1208', strokeThickness: 4,
|
||
}).setOrigin(0.5).setDepth(depth);
|
||
|
||
await this.delay(1000);
|
||
|
||
// Phase 2: Amount flies to payer's panel (750 ms), turns red, adds minus
|
||
const { px: ppx, py: ppy, panelW: ppw } = this.panelPos(payer);
|
||
const { px: rpx, py: rpy, panelW: rpw } = this.panelPos(receiver);
|
||
const payerX = ppx + ppw / 2, payerY = ppy + 44;
|
||
const recvX = rpx + rpw / 2, recvY = rpy + 44;
|
||
|
||
amtTxt.setText(`-$${amount.toLocaleString()}`);
|
||
amtTxt.setColor('#FF4444');
|
||
// Fade banner out simultaneously
|
||
this.tweens.add({ targets: bannerTxt, alpha: 0, duration: 750, ease: 'Linear' });
|
||
|
||
await new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: amtTxt,
|
||
x: payerX, y: payerY,
|
||
scaleX: 0.5, scaleY: 0.5,
|
||
duration: 750, ease: 'Cubic.easeIn',
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
await this.delay(250);
|
||
|
||
playSound(this, SFX.MONOPAY);
|
||
|
||
// Phase 3: Amount arches to receiver's panel (1200 ms), turns green, adds plus
|
||
amtTxt.setText(`+$${amount.toLocaleString()}`);
|
||
amtTxt.setColor('#44FF88');
|
||
|
||
const sx = amtTxt.x, sy = amtTxt.y;
|
||
const midX = (sx + recvX) / 2;
|
||
const midY = Math.min(sy, recvY) - 220; // arch above both panels
|
||
const proxy = { t: 0 };
|
||
|
||
await new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: proxy, t: 1,
|
||
duration: 1200, ease: 'Sine.easeInOut',
|
||
onUpdate: () => {
|
||
const t = proxy.t, u = 1 - t;
|
||
amtTxt.x = u*u*sx + 2*u*t*midX + t*t*recvX;
|
||
amtTxt.y = u*u*sy + 2*u*t*midY + t*t*recvY;
|
||
},
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
playSound(this, SFX.MONOPOLY_PAID);
|
||
await this.delay(300);
|
||
bannerTxt.destroy();
|
||
amtTxt.destroy();
|
||
}
|
||
|
||
// ── Card Draw Animation ────────────────────────────────────────────────────
|
||
async animateCardDraw() {
|
||
const { cardType, text } = this.gs.pendingCard;
|
||
const isChance = cardType === 'chance';
|
||
const BOARD_CX = BL + BS / 2;
|
||
const BOARD_CY = BT + BS / 2;
|
||
const cardColor = isChance ? 0xE77A2C : 0x1565C0;
|
||
|
||
// Deck position — must match drawCenterDecks()
|
||
const deckX = isChance ? BOARD_CX - DECK_D : BOARD_CX + DECK_D;
|
||
const deckY = isChance ? BOARD_CY + DECK_D : BOARD_CY - DECK_D;
|
||
const deckRot = isChance ? -0.14 : 0.12;
|
||
|
||
// Full popup card size; container scales up from deck visual width
|
||
const CW = 360, CH = 480;
|
||
const startScale = 88 / CW; // ≈ 0.244
|
||
|
||
// Dim overlay (not in dyn — destroyed at end of animation)
|
||
const overlay = this.add.rectangle(
|
||
GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0
|
||
).setDepth(DEPTH.popup - 2);
|
||
this.tweens.add({ targets: overlay, alpha: 0.6, duration: 500, ease: 'Linear' });
|
||
|
||
// Container starts at deck position, scaled and rotated to match deck
|
||
const container = this.add.container(deckX, deckY)
|
||
.setDepth(DEPTH.popup - 1)
|
||
.setScale(startScale)
|
||
.setRotation(deckRot);
|
||
|
||
// ── Back face (matches the face-down deck appearance) ─────────────────
|
||
const backGfx = this.add.graphics();
|
||
backGfx.fillStyle(cardColor, 1);
|
||
backGfx.lineStyle(3, 0xFFF8E7, 1);
|
||
backGfx.fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
|
||
backGfx.strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
|
||
backGfx.lineStyle(2, 0xFFF8E7, 0.75);
|
||
backGfx.strokeRoundedRect(-CW / 2 + 14, -CH / 2 + 14, CW - 28, CH - 28, 10);
|
||
container.add(backGfx);
|
||
|
||
const backLabel = this.add.text(0, 0, isChance ? 'Chance' : 'Community\nChest', {
|
||
fontFamily: 'Righteous', fontSize: '36px', color: '#FFF8E7', align: 'center',
|
||
}).setOrigin(0.5);
|
||
container.add(backLabel);
|
||
|
||
// ── Front face (matches drawCardPopup content, hidden until flip) ─────
|
||
const frontObjs = [];
|
||
|
||
const frontBg = this.add.graphics();
|
||
frontBg.fillStyle(cardColor, 1);
|
||
frontBg.lineStyle(4, 0xFFF8E7, 1);
|
||
frontBg.fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
|
||
frontBg.strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 16);
|
||
frontBg.setVisible(false);
|
||
container.add(frontBg);
|
||
frontObjs.push(frontBg);
|
||
|
||
if (this.hasCards) {
|
||
const frame = isChance ? CARD_FRAME.chance : CARD_FRAME.community_chest;
|
||
const art = this.add.image(0, -CH / 2 + 120, 'monopoly-cards', frame)
|
||
.setDisplaySize(CW - 20, 220).setVisible(false);
|
||
container.add(art);
|
||
frontObjs.push(art);
|
||
} else {
|
||
const fallBg = this.add.graphics();
|
||
fallBg.fillStyle(0xffffff, 0.15);
|
||
fallBg.fillRoundedRect(-CW / 2 + 10, -CH / 2 + 10, CW - 20, 210, 12);
|
||
fallBg.setVisible(false);
|
||
container.add(fallBg);
|
||
frontObjs.push(fallBg);
|
||
|
||
const icon = this.add.text(0, -CH / 2 + 110, isChance ? '?' : '📦', {
|
||
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
|
||
}).setOrigin(0.5).setVisible(false);
|
||
container.add(icon);
|
||
frontObjs.push(icon);
|
||
}
|
||
|
||
const frontTitle = this.add.text(0, -CH / 2 + 30, isChance ? 'CHANCE' : 'COMMUNITY CHEST', {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: '#FFF8E7',
|
||
}).setOrigin(0.5).setVisible(false);
|
||
container.add(frontTitle);
|
||
frontObjs.push(frontTitle);
|
||
|
||
const frontBody = this.add.text(0, -CH / 2 + 250, text, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#FFF8E7',
|
||
align: 'center', wordWrap: { width: CW - 30 },
|
||
}).setOrigin(0.5, 0).setVisible(false);
|
||
container.add(frontBody);
|
||
frontObjs.push(frontBody);
|
||
|
||
// Phase 1: fly from deck to center, grow, straighten (700 ms)
|
||
await new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: container,
|
||
x: GAME_WIDTH / 2,
|
||
y: GAME_HEIGHT / 2,
|
||
scaleX: 1, scaleY: 1,
|
||
rotation: 0,
|
||
duration: 700,
|
||
ease: 'Cubic.easeOut',
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
// Phase 2a: flip first half — collapse to zero width
|
||
await new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: container,
|
||
scaleX: 0,
|
||
duration: 180,
|
||
ease: 'Sine.easeIn',
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
// Swap faces at zero-width moment
|
||
backGfx.setVisible(false);
|
||
backLabel.setVisible(false);
|
||
frontObjs.forEach(o => o.setVisible(true));
|
||
|
||
// Phase 2b: flip second half — expand back to full width
|
||
await new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: container,
|
||
scaleX: 1,
|
||
duration: 180,
|
||
ease: 'Sine.easeOut',
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
// Cleanup — render() will immediately draw the static popup at the same coords
|
||
container.each(child => { try { child.destroy(); } catch {} });
|
||
container.destroy();
|
||
overlay.destroy();
|
||
}
|
||
|
||
// ── Card Popup ─────────────────────────────────────────────────────────────
|
||
drawCardPopup() {
|
||
if (!this.gs.pendingCard) return;
|
||
const { cardType, text } = this.gs.pendingCard;
|
||
const isChance = cardType === 'chance';
|
||
const pw = 360, ph = 480;
|
||
const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2;
|
||
|
||
// Overlay
|
||
const overlay = this.reg(this.add.graphics().setDepth(DEPTH.popup - 1));
|
||
overlay.fillStyle(0x000000, 0.6);
|
||
overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||
|
||
// Card background
|
||
const g = this.reg(this.add.graphics().setDepth(DEPTH.popup));
|
||
const cardColor = isChance ? 0xE77A2C : 0x1565C0;
|
||
g.fillStyle(cardColor, 1);
|
||
g.fillRoundedRect(px, py, pw, ph, 16);
|
||
g.lineStyle(4, 0xFFF8E7, 1);
|
||
g.strokeRoundedRect(px, py, pw, ph, 16);
|
||
|
||
if (this.hasCards) {
|
||
const frame = isChance ? CARD_FRAME.chance : CARD_FRAME.community_chest;
|
||
this.reg(this.add.image(px + pw/2, py + 120, 'monopoly-cards', frame)
|
||
.setDisplaySize(pw - 20, 220).setDepth(DEPTH.popup));
|
||
} else {
|
||
// Fallback art
|
||
const ag = this.reg(this.add.graphics().setDepth(DEPTH.popup));
|
||
ag.fillStyle(0xffffff, 0.15);
|
||
ag.fillRoundedRect(px + 10, py + 10, pw - 20, 210, 12);
|
||
this.reg(this.add.text(px + pw/2, py + 110, isChance ? '?' : '📦', {
|
||
fontFamily:'Righteous', fontSize:'80px', color:'#ffffff',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
}
|
||
|
||
this.reg(this.add.text(px + pw/2, py + 30, isChance ? 'CHANCE' : 'COMMUNITY CHEST', {
|
||
fontFamily:'Righteous', fontSize:'18px', color:'#FFF8E7',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
this.reg(this.add.text(px + pw/2, py + 250, text, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'18px', color:'#FFF8E7',
|
||
align:'center', wordWrap:{ width: pw - 30 },
|
||
}).setOrigin(0.5, 0).setDepth(DEPTH.popup+1));
|
||
|
||
// OK button — only shown on the human player's turn
|
||
if (this.gs.current === this.humanSeat) {
|
||
const btn = new Button(this, px + pw/2, py + ph - 36, 'OK', () => this.onDismissCard(), {
|
||
width: 220, height: 48, fontSize: 20,
|
||
});
|
||
btn.setDepth(DEPTH.popup + 2);
|
||
this.reg(btn);
|
||
}
|
||
}
|
||
|
||
// ── Auction Panel ──────────────────────────────────────────────────────────
|
||
drawAuctionPanel() {
|
||
const auc = this.gs.pendingAuction;
|
||
const sp = SPACES[auc.spaceIdx];
|
||
const bidderSeat = auc.bidOrder[auc.currentBidderIdx];
|
||
const isHuman = bidderSeat === this.humanSeat;
|
||
|
||
const pw = this.modalActive ? 520 : RP_W - 20;
|
||
const ph = 360;
|
||
const px = this.modalActive ? GAME_WIDTH - pw - 30 : RP_X + 10;
|
||
const py = GAME_HEIGHT/2 - ph/2;
|
||
|
||
const g = this.reg(this.add.graphics().setDepth(DEPTH.popup));
|
||
g.fillStyle(0x1e1a12, 1);
|
||
g.fillRoundedRect(px, py, pw, ph, 12);
|
||
g.lineStyle(2, COLORS.gold, 1);
|
||
g.strokeRoundedRect(px, py, pw, ph, 12);
|
||
|
||
this.reg(this.add.text(px + pw/2, py + 20, 'AUCTION', {
|
||
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
// Property color band
|
||
if (sp.group) {
|
||
const bg2 = this.reg(this.add.graphics().setDepth(DEPTH.popup));
|
||
bg2.fillStyle(GROUP_COLORS[sp.group] ?? COLORS.accent, 1);
|
||
bg2.fillRect(px + 20, py + 55, pw - 40, 22);
|
||
}
|
||
this.reg(this.add.text(px + pw/2, py + 66, sp.name, {
|
||
fontFamily:'Righteous', fontSize:'18px', color:'#FFF8E7',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
this.reg(this.add.text(px + pw/2, py + 100, `List Price: $${sp.price}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'15px', color:COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
const highBidText = auc.highBid > 0
|
||
? `High bid: $${auc.highBid} (${this.gs.players[auc.highBidder].name})`
|
||
: 'No bids yet';
|
||
this.reg(this.add.text(px + pw/2, py + 128, highBidText, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#aaddaa',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
const bidderName = this.gs.players[bidderSeat]?.name ?? '?';
|
||
this.reg(this.add.text(px + pw/2, py + 156, `${bidderName}'s turn to bid`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'14px', color:COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
if (isHuman) {
|
||
// Bid controls
|
||
const minBid = auc.highBid + 1;
|
||
if (this.bidInput < minBid) this.bidInput = minBid;
|
||
const phuman = this.gs.players[this.humanSeat];
|
||
if (this.bidInput > phuman.cash) this.bidInput = phuman.cash;
|
||
|
||
this.reg(this.add.text(px + pw/2, py + 188, `Your bid: $${this.bidInput}`, {
|
||
fontFamily:'Righteous', fontSize:'22px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
const btnH = 44, btnGap = 10;
|
||
// -50, -10, +10, +50 buttons
|
||
const nudgeValues = [[-50, '−50'], [-10, '−10'], [+10, '+10'], [+50, '+50']];
|
||
const nudgeBtnW = (pw - 50) / 4;
|
||
nudgeValues.forEach(([delta, label], i) => {
|
||
const nbx = px + 20 + i * (nudgeBtnW + 4);
|
||
const btn = new Button(this, nbx + nudgeBtnW/2, py + 230, label, () => {
|
||
this.bidInput = Math.max(minBid, Math.min(phuman.cash, this.bidInput + delta));
|
||
this.render();
|
||
}, { width: nudgeBtnW, height: 38, fontSize: 16 });
|
||
btn.setDepth(DEPTH.popup+2);
|
||
this.reg(btn);
|
||
});
|
||
|
||
const bidBtn = new Button(this, px + pw/2 - 80, py + 288, 'BID', () => {
|
||
if (this.bidInput >= minBid && this.bidInput <= phuman.cash) {
|
||
this.gs = placeBid(this.gs, this.humanSeat, this.bidInput);
|
||
this.bidInput = 0;
|
||
this.render();
|
||
this.advance();
|
||
}
|
||
}, { width: 130, height: btnH, fontSize: 20 });
|
||
bidBtn.setDepth(DEPTH.popup+2);
|
||
this.reg(bidBtn);
|
||
|
||
const passBtn = new Button(this, px + pw/2 + 80, py + 288, 'PASS', () => {
|
||
this.gs = passAuction(this.gs, this.humanSeat);
|
||
this.bidInput = 0;
|
||
this.render();
|
||
this.advance();
|
||
}, { width: 130, height: btnH, fontSize: 20, variant:'ghost' });
|
||
passBtn.setDepth(DEPTH.popup+2);
|
||
this.reg(passBtn);
|
||
} else {
|
||
this.reg(this.add.text(px + pw/2, py + 230, 'Waiting for AI…', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
}
|
||
}
|
||
|
||
// ── Build Menu ─────────────────────────────────────────────────────────────
|
||
showBuildMenu() {
|
||
if (this.buildMenuOpen) return;
|
||
this.buildMenuOpen = true;
|
||
this.buildMenuObjs = [];
|
||
|
||
const gs = this.gs;
|
||
const seat = this.humanSeat;
|
||
const eligible = PURCHASABLE.filter(idx =>
|
||
canBuildHouse(gs, seat, idx) || canBuildHotel(gs, seat, idx));
|
||
|
||
const pw = 420, itemH = 48;
|
||
const ph = Math.min(600, 60 + eligible.length * (itemH + 8) + 20);
|
||
const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2;
|
||
|
||
const overlay = this.add.graphics().setDepth(DEPTH.popup - 1);
|
||
overlay.fillStyle(0x000000, 0.5);
|
||
overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||
this.buildMenuObjs.push(overlay);
|
||
|
||
const panel = this.add.graphics().setDepth(DEPTH.popup);
|
||
panel.fillStyle(0x1e1a12, 1);
|
||
panel.fillRoundedRect(px, py, pw, ph, 12);
|
||
panel.lineStyle(2, COLORS.gold, 1);
|
||
panel.strokeRoundedRect(px, py, pw, ph, 12);
|
||
this.buildMenuObjs.push(panel);
|
||
|
||
const title = this.add.text(px + pw/2, py + 22, 'Build Houses / Hotels', {
|
||
fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1);
|
||
this.buildMenuObjs.push(title);
|
||
|
||
eligible.forEach((idx, i) => {
|
||
const sp = SPACES[idx];
|
||
const own = gs.board[idx];
|
||
const hotelReady = canBuildHotel(gs, seat, idx);
|
||
const label = hotelReady
|
||
? `${sp.name} — Build Hotel ($${sp.houseCost})`
|
||
: `${sp.name} — House ${own.houses + 1}/4 ($${sp.houseCost})`;
|
||
const by = py + 56 + i * (itemH + 8);
|
||
const btn = new Button(this, px + pw/2, by + itemH/2, label, () => {
|
||
this.closeBuildMenu();
|
||
if (hotelReady) {
|
||
this.gs = buildHotel(this.gs, seat, idx);
|
||
} else {
|
||
this.gs = buildHouse(this.gs, seat, idx);
|
||
}
|
||
this.render();
|
||
}, { width: pw - 20, height: itemH, fontSize: 16 });
|
||
btn.setDepth(DEPTH.popup+2);
|
||
this.buildMenuObjs.push(btn);
|
||
});
|
||
|
||
if (eligible.length === 0) {
|
||
const noElig = this.add.text(px + pw/2, py + 80, 'No properties eligible to build on.', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1);
|
||
this.buildMenuObjs.push(noElig);
|
||
}
|
||
|
||
const closeBtn = new Button(this, px + pw/2, py + ph - 30, 'Close', () => this.closeBuildMenu(),
|
||
{ variant:'ghost', width:120, height:40, fontSize:16 });
|
||
closeBtn.setDepth(DEPTH.popup+2);
|
||
this.buildMenuObjs.push(closeBtn);
|
||
}
|
||
|
||
closeBuildMenu() {
|
||
this.buildMenuOpen = false;
|
||
this.buildMenuObjs?.forEach(o => { try { o.destroy(); } catch {} });
|
||
this.buildMenuObjs = [];
|
||
}
|
||
|
||
// ── Mortgage Menu ──────────────────────────────────────────────────────────
|
||
showMortgageMenu() {
|
||
if (this.mortMenuOpen) return;
|
||
this.mortMenuOpen = true;
|
||
this.mortMenuObjs = [];
|
||
|
||
const gs = this.gs;
|
||
const seat = this.humanSeat;
|
||
const canMort = PURCHASABLE.filter(idx => {
|
||
const own = gs.board[idx];
|
||
return own?.owner === seat && !own.mortgaged && own.houses === 0 && !own.hotel;
|
||
});
|
||
const canUnmort = PURCHASABLE.filter(idx => {
|
||
const own = gs.board[idx];
|
||
return own?.owner === seat && own.mortgaged;
|
||
});
|
||
|
||
const items = [
|
||
...canMort.map(idx => ({ idx, action:'mortgage' })),
|
||
...canUnmort.map(idx => ({ idx, action:'unmortgage' })),
|
||
];
|
||
|
||
const pw = 440, itemH = 48;
|
||
const ph = Math.min(800, 60 + items.length * (itemH + 8) + 48);
|
||
const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2;
|
||
|
||
const overlay = this.add.graphics().setDepth(DEPTH.popup - 1);
|
||
overlay.fillStyle(0x000000, 0.5);
|
||
overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||
this.mortMenuObjs.push(overlay);
|
||
|
||
const panel = this.add.graphics().setDepth(DEPTH.popup);
|
||
panel.fillStyle(0x1e1a12, 1);
|
||
panel.fillRoundedRect(px, py, pw, ph, 12);
|
||
panel.lineStyle(2, COLORS.gold, 1);
|
||
panel.strokeRoundedRect(px, py, pw, ph, 12);
|
||
this.mortMenuObjs.push(panel);
|
||
|
||
const title = this.add.text(px + pw/2, py + 22, 'Mortgage / Unmortgage', {
|
||
fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1);
|
||
this.mortMenuObjs.push(title);
|
||
|
||
items.forEach(({ idx, action }, i) => {
|
||
const sp = SPACES[idx];
|
||
const cost = action === 'mortgage' ? sp.mortgage : Math.ceil(sp.mortgage * 1.1);
|
||
const label = action === 'mortgage'
|
||
? `Mortgage ${sp.name} (+$${cost})`
|
||
: `Unmortgage ${sp.name} (−$${cost})`;
|
||
const enabled = action === 'unmortgage' ? gs.players[seat].cash >= cost : true;
|
||
const by = py + 56 + i * (itemH + 8);
|
||
const btn = new Button(this, px + pw/2, by + itemH/2, label, () => {
|
||
this.closeMortMenu();
|
||
if (action === 'mortgage') {
|
||
this.gs = mortgageProperty(this.gs, seat, idx);
|
||
} else {
|
||
this.gs = unmortgageProperty(this.gs, seat, idx);
|
||
}
|
||
this.render();
|
||
}, { width: pw - 20, height: itemH, fontSize: 15 });
|
||
btn.setDepth(DEPTH.popup+2);
|
||
if (!enabled) btn.setEnabled(false);
|
||
this.mortMenuObjs.push(btn);
|
||
});
|
||
|
||
if (items.length === 0) {
|
||
const noItems = this.add.text(px + pw/2, py + 80, 'Nothing to mortgage or unmortgage.', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1);
|
||
this.mortMenuObjs.push(noItems);
|
||
}
|
||
|
||
const closeBtn = new Button(this, px + pw/2, py + ph - 30, 'Close', () => this.closeMortMenu(),
|
||
{ variant:'ghost', width:120, height:40, fontSize:16 });
|
||
closeBtn.setDepth(DEPTH.popup+2);
|
||
this.mortMenuObjs.push(closeBtn);
|
||
}
|
||
|
||
closeMortMenu() {
|
||
this.mortMenuOpen = false;
|
||
this.mortMenuObjs?.forEach(o => { try { o.destroy(); } catch {} });
|
||
this.mortMenuObjs = [];
|
||
}
|
||
|
||
// ── Trade Modal ────────────────────────────────────────────────────────────
|
||
canInitiateTrade() {
|
||
const gs = this.gs;
|
||
return gs.players.some(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt &&
|
||
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat));
|
||
}
|
||
|
||
// Band color matching buildPropertyCardContainer / drawBoardSpace
|
||
tradeBandColor(idx) {
|
||
const sp = SPACES[idx];
|
||
return sp.group ? GROUP_COLORS[sp.group]
|
||
: sp.type === 'railroad' ? 0x1a1208
|
||
: sp.type === 'utility' && idx === 12 ? 0xFFD700
|
||
: 0x1565C0;
|
||
}
|
||
|
||
// ── AI-initiated trades ──────────────────────────────────────────────────────
|
||
// Called during an AI seat's endturn. Proposes to the human (rate-limited to once
|
||
// every other of this AI's own turns) then completes up to 5 trades with other AIs.
|
||
async aiAttemptTrades(seat) {
|
||
const gs = this.gs;
|
||
if (!gs || gs.phase !== 'endturn' || gs.current !== seat) return;
|
||
const me = gs.players[seat];
|
||
if (!me || !me.active || me.bankrupt) return;
|
||
|
||
// Tick this AI's human-proposal cooldown down once per its own turn.
|
||
if (this.humanTradeCooldown[seat] > 0) this.humanTradeCooldown[seat]--;
|
||
|
||
const skill = this.skillBySeat[seat] ?? 3;
|
||
|
||
// 1. Propose to the human, if off cooldown.
|
||
const human = this.gs.players[this.humanSeat];
|
||
if (human && human.active && !human.bankrupt && (this.humanTradeCooldown[seat] ?? 0) <= 0) {
|
||
const offer = buildAiTradeOffer(this.gs, seat, this.humanSeat, skill,
|
||
{ targetHuman: true, targetSkill: this.skillBySeat[this.humanSeat] ?? 3 });
|
||
if (offer && validateTrade(this.gs, offer).ok) {
|
||
this.humanTradeCooldown[seat] = 2; // blocks this AI's next own turn
|
||
await this.showIncomingTradeProposal(seat, offer);
|
||
}
|
||
}
|
||
|
||
// 2. Trade with other AI opponents — up to 5 completed deals this turn.
|
||
let done = 0, guard = 0;
|
||
while (done < 5 && guard++ < 12) {
|
||
const others = this.gs.players
|
||
.filter(pl => pl.seat !== seat && pl.seat !== this.humanSeat && pl.active && !pl.bankrupt)
|
||
.map(pl => pl.seat);
|
||
Phaser.Utils.Array.Shuffle(others);
|
||
let made = false;
|
||
for (const tSeat of others) {
|
||
const tSkill = this.skillBySeat[tSeat] ?? 3;
|
||
const offer = buildAiTradeOffer(this.gs, seat, tSeat, skill, { targetSkill: tSkill });
|
||
if (!offer || !validateTrade(this.gs, offer).ok) continue;
|
||
if (!evaluateTrade(this.gs, tSeat, offer, tSkill).accept) continue;
|
||
this.gs = applyTrade(this.gs, offer);
|
||
this.render();
|
||
await this.flashAiTrade(offer);
|
||
done++; made = true;
|
||
if (done >= 5) break;
|
||
}
|
||
if (!made) break;
|
||
}
|
||
}
|
||
|
||
// Shared summary layout for both the incoming-proposal popup and the AI-AI flash.
|
||
// Returns an array of created display objects for the caller to destroy.
|
||
buildTradeSummaryContents(offer, geo, topLabel, bottomLabel) {
|
||
const objs = [];
|
||
const cardScale = 0.46;
|
||
const cw = MODAL_W * cardScale, gap = 24, rowH = MODAL_H * cardScale;
|
||
const midX = geo.x + geo.w / 2;
|
||
|
||
const drawRow = (label, props, cash, labelY) => {
|
||
objs.push(this.add.text(geo.x + 30, labelY, label, {
|
||
fontFamily:'Righteous', fontSize:'18px', color:COLORS.goldHex,
|
||
}).setOrigin(0, 0).setDepth(DEPTH.popup + 1));
|
||
|
||
const n = props.length + (cash > 0 ? 1 : 0);
|
||
const totalW = n > 0 ? n * cw + (n - 1) * gap : 0;
|
||
let x = midX - totalW / 2 + cw / 2;
|
||
const cy = labelY + 30 + rowH / 2;
|
||
for (const idx of props) {
|
||
const card = this.buildPropertyCardContainer(idx);
|
||
card.setPosition(x, cy).setScale(cardScale).setDepth(DEPTH.popup + 1);
|
||
objs.push(card);
|
||
x += cw + gap;
|
||
}
|
||
if (cash > 0) {
|
||
const badge = this.add.container(x, cy).setDepth(DEPTH.popup + 1);
|
||
const g = this.add.graphics();
|
||
g.fillStyle(0x14110a, 1); g.fillRoundedRect(-cw/2, -42, cw, 84, 10);
|
||
g.lineStyle(2, 0x7fb87f, 1); g.strokeRoundedRect(-cw/2, -42, cw, 84, 10);
|
||
badge.add(g);
|
||
badge.add(this.add.text(0, 0, `$${cash.toLocaleString()}`, {
|
||
fontFamily:'Righteous', fontSize:'26px', color:'#7fdd9f',
|
||
}).setOrigin(0.5));
|
||
objs.push(badge);
|
||
}
|
||
if (props.length === 0 && cash <= 0) {
|
||
objs.push(this.add.text(midX, cy, '— nothing —', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||
}
|
||
};
|
||
|
||
const row1Y = geo.y + 4;
|
||
drawRow(topLabel, offer.giveProps ?? [], offer.giveCash ?? 0, row1Y);
|
||
|
||
const divY = row1Y + 30 + rowH + 22;
|
||
const dg = this.add.graphics().setDepth(DEPTH.popup + 1);
|
||
dg.lineStyle(1, COLORS.accent, 0.5);
|
||
dg.beginPath(); dg.moveTo(geo.x + 30, divY); dg.lineTo(geo.x + geo.w - 30, divY); dg.strokePath();
|
||
objs.push(dg);
|
||
|
||
drawRow(bottomLabel, offer.getProps ?? [], offer.getCash ?? 0, divY + 14);
|
||
return objs;
|
||
}
|
||
|
||
// Incoming proposal from an AI to the human. Resolves when the human Accepts,
|
||
// Declines, or finishes a Counter-offer.
|
||
showIncomingTradeProposal(fromSeat, offer) {
|
||
return new Promise(resolve => {
|
||
this._tradeResolve = resolve;
|
||
this.tradeSummaryOpen = true;
|
||
this.hidePortraits();
|
||
const fromName = this.gs.players[fromSeat].name;
|
||
const objs = [];
|
||
|
||
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
|
||
.setDepth(DEPTH.popup - 1).setInteractive();
|
||
objs.push(overlay);
|
||
|
||
const PW = 1180, PH = 760;
|
||
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
|
||
const panel = this.add.graphics().setDepth(DEPTH.popup);
|
||
panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(PX, PY, PW, PH, 14);
|
||
panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(PX, PY, PW, PH, 14);
|
||
objs.push(panel);
|
||
|
||
objs.push(this.add.text(GAME_WIDTH/2, PY + 22, `${fromName} proposes a trade`, {
|
||
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||
|
||
objs.push(...this.buildTradeSummaryContents(offer,
|
||
{ x: PX, y: PY + 58, w: PW }, `${fromName} gives you`, `You give ${fromName}`));
|
||
|
||
const finish = (result) => {
|
||
objs.forEach(o => { try { o.destroy(); } catch {} });
|
||
this.tradeSummaryOpen = false;
|
||
if (result === 'counter') {
|
||
this.openCounterModal(fromSeat, offer); // resolves _tradeResolve on close
|
||
} else {
|
||
this.showPortraits();
|
||
const r = this._tradeResolve; this._tradeResolve = null;
|
||
if (r) r();
|
||
}
|
||
};
|
||
|
||
const by = PY + PH - 54;
|
||
const accept = new Button(this, GAME_WIDTH/2 - 230, by, 'Accept', () => {
|
||
if (validateTrade(this.gs, offer).ok) {
|
||
this.gs = applyTrade(this.gs, offer);
|
||
playSound(this, SFX.MONOPOLY_PURCHASE);
|
||
this.render();
|
||
}
|
||
finish('accept');
|
||
}, { width: 200, height: 50, fontSize: 22 });
|
||
accept.setDepth(DEPTH.popup + 2); objs.push(accept);
|
||
|
||
const counter = new Button(this, GAME_WIDTH/2, by, 'Counter-offer', () => finish('counter'),
|
||
{ width: 230, height: 50, fontSize: 20, variant:'ghost' });
|
||
counter.setDepth(DEPTH.popup + 2); objs.push(counter);
|
||
|
||
const decline = new Button(this, GAME_WIDTH/2 + 230, by, 'Decline', () => finish('decline'),
|
||
{ width: 200, height: 50, fontSize: 22, variant:'ghost' });
|
||
decline.setDepth(DEPTH.popup + 2); objs.push(decline);
|
||
});
|
||
}
|
||
|
||
// Brief read-only summary of a completed AI-vs-AI trade.
|
||
flashAiTrade(offer) {
|
||
return new Promise(resolve => {
|
||
this.tradeSummaryOpen = true;
|
||
this.hidePortraits();
|
||
const fromName = this.gs.players[offer.fromSeat].name;
|
||
const toName = this.gs.players[offer.toSeat].name;
|
||
const objs = [];
|
||
|
||
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||
.setDepth(DEPTH.popup - 1).setInteractive();
|
||
objs.push(overlay);
|
||
|
||
const PW = 1180, PH = 760;
|
||
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
|
||
const panel = this.add.graphics().setDepth(DEPTH.popup);
|
||
panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(PX, PY, PW, PH, 14);
|
||
panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(PX, PY, PW, PH, 14);
|
||
objs.push(panel);
|
||
|
||
objs.push(this.add.text(GAME_WIDTH/2, PY + 22, `${fromName} ⇄ ${toName} — Trade`, {
|
||
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||
|
||
objs.push(...this.buildTradeSummaryContents(offer,
|
||
{ x: PX, y: PY + 58, w: PW }, `${fromName} gives`, `${toName} gives`));
|
||
|
||
objs.push(this.add.text(GAME_WIDTH/2, PY + PH - 36, 'ACCEPTED', {
|
||
fontFamily:'Righteous', fontSize:'28px', color:'#7fdd9f',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||
|
||
this.time.delayedCall(1500, () => {
|
||
objs.forEach(o => { try { o.destroy(); } catch {} });
|
||
this.tradeSummaryOpen = false;
|
||
this.showPortraits();
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Open the existing drag-drop trade builder in "counter" mode, seeded with the AI's
|
||
// offer reversed to the human's perspective and locked to that opponent.
|
||
openCounterModal(fromSeat, offer) {
|
||
const seed = {
|
||
giveProps: [...(offer.getProps ?? [])], // human gives what the AI requested
|
||
getProps: [...(offer.giveProps ?? [])], // human gets what the AI offered
|
||
giveCash: offer.getCash ?? 0,
|
||
getCash: offer.giveCash ?? 0,
|
||
};
|
||
this.showTradeModal({ counter: true, counterFrom: fromSeat, seedOffer: seed });
|
||
}
|
||
|
||
showTradeModal(opts = {}) {
|
||
if (this.tradeMenuOpen) return;
|
||
if (!opts.counter && this.busy) return;
|
||
if (!opts.counter && (this.gs.current !== this.humanSeat || this.gs.phase !== 'endturn')) return;
|
||
this.tradeCounterMode = !!opts.counter;
|
||
this.tradeCounterFrom = opts.counterFrom ?? null;
|
||
this.tradeMenuOpen = true;
|
||
this.tradeMenuObjs = [];
|
||
this.tradeLaneObjs = [];
|
||
this.tradeRightObjs = [];
|
||
this.tradeMineCards = {};
|
||
this.tradeOppCards = {};
|
||
this.tradeOffer = opts.seedOffer
|
||
? { giveProps:[...opts.seedOffer.giveProps], getProps:[...opts.seedOffer.getProps],
|
||
giveCash: opts.seedOffer.giveCash, getCash: opts.seedOffer.getCash }
|
||
: { giveProps: [], getProps: [], giveCash: 0, getCash: 0 };
|
||
this.tradeDragGhost = null;
|
||
this._tradeDidDrag = false;
|
||
|
||
// DOM video portraits render above the canvas — hide them behind the modal
|
||
this.hidePortraits();
|
||
|
||
const gs = this.gs;
|
||
if (this.tradeCounterMode && this.tradeCounterFrom !== null) {
|
||
this.tradeCounterparty = this.tradeCounterFrom; // locked to the proposing AI
|
||
} else {
|
||
// Default counterparty: first active opponent owning a property, else first opponent
|
||
const opps = gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
|
||
this.tradeCounterparty = (opps.find(pl =>
|
||
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat)) ?? opps[0])?.seat ?? null;
|
||
}
|
||
|
||
// Geometry
|
||
const PW = 1600, PH = 860;
|
||
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
|
||
this._tradeGeo = { PW, PH, PX, PY,
|
||
LX: PX + 24, LW: 430,
|
||
CX: PX + 474, CW: 600,
|
||
RX: PX + 1094, RW: 482,
|
||
};
|
||
|
||
// Overlay (swallows background clicks)
|
||
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
|
||
.setDepth(DEPTH.popup - 1).setInteractive();
|
||
this.tradeMenuObjs.push(overlay);
|
||
|
||
// Panel
|
||
const panel = this.add.graphics().setDepth(DEPTH.popup);
|
||
panel.fillStyle(0x1e1a12, 1);
|
||
panel.fillRoundedRect(PX, PY, PW, PH, 14);
|
||
panel.lineStyle(2, COLORS.gold, 1);
|
||
panel.strokeRoundedRect(PX, PY, PW, PH, 14);
|
||
this.tradeMenuObjs.push(panel);
|
||
|
||
const titleTxt = this.tradeCounterMode && this.tradeCounterparty !== null
|
||
? `Counter-offer to ${gs.players[this.tradeCounterparty].name}`
|
||
: 'Propose a Trade';
|
||
this.tradeMenuObjs.push(this.add.text(GAME_WIDTH/2, PY + 24, titleTxt, {
|
||
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
this.buildTradeLeftColumn();
|
||
this.buildTradeCenterColumn();
|
||
this.renderRightColumn();
|
||
|
||
// Drag handlers (registered once per open)
|
||
this._onTradeDragStart = (pointer, obj) => {
|
||
if (!obj || obj._spaceIdx === undefined) return;
|
||
this.tradeDragGhost = obj; this._tradeDidDrag = true; obj._dropped = false;
|
||
this.stopDragHints();
|
||
this.clearTradeHoverCard();
|
||
this.showDropHint(obj._side);
|
||
obj._homeX = obj.x; obj._homeY = obj.y;
|
||
obj.setDepth(DEPTH.popup + 12);
|
||
};
|
||
this._onTradeDrag = (pointer, obj, dragX, dragY) => {
|
||
if (obj !== this.tradeDragGhost) return;
|
||
obj.x = dragX; obj.y = dragY;
|
||
};
|
||
// Native drop — fires only when released over a lane drop zone. Route by whose
|
||
// card it is (your card → give, their card → get) so it always lands correctly.
|
||
this._onTradeDrop = (pointer, obj, zone) => {
|
||
if (obj !== this.tradeDragGhost) return;
|
||
const type = zone?.getData?.('laneType');
|
||
if (type !== 'give' && type !== 'get') return;
|
||
obj._dropped = true;
|
||
this.addTradeProp(obj._spaceIdx, obj._side === 'mine' ? 'give' : 'get');
|
||
};
|
||
this._onTradeDragEnd = (pointer, obj) => {
|
||
if (obj !== this.tradeDragGhost) return;
|
||
obj.x = obj._homeX; obj.y = obj._homeY; // snap home; drop already handled above
|
||
obj.setDepth(DEPTH.popup + 1);
|
||
this.tradeDragGhost = null;
|
||
this.clearDropHint();
|
||
this.refreshDragHints(); // resume pulsing if nothing was dropped
|
||
};
|
||
this.input.on('dragstart', this._onTradeDragStart);
|
||
this.input.on('drag', this._onTradeDrag);
|
||
this.input.on('drop', this._onTradeDrop);
|
||
this.input.on('dragend', this._onTradeDragEnd);
|
||
|
||
this.renderTradeOffer();
|
||
this.renderTradeCash();
|
||
}
|
||
|
||
buildTradeLeftColumn() {
|
||
const { LX, LW, PY } = this._tradeGeo;
|
||
const gs = this.gs;
|
||
const p = gs.players[this.humanSeat];
|
||
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 64, 'Your Properties', {
|
||
fontFamily:'Righteous', fontSize:'18px', color:COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 88, `Cash: $${p.cash.toLocaleString()}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
const owned = PURCHASABLE.filter(i => gs.board[i]?.owner === this.humanSeat);
|
||
owned.forEach((idx, i) => {
|
||
const col = i % 3, row = Math.floor(i / 3);
|
||
const cx = LX + 70 + col * 140;
|
||
const cy = PY + 130 + row * 78;
|
||
const card = this.buildTradeMiniCard(idx, 'mine');
|
||
card.setPosition(cx, cy);
|
||
this.tradeMineCards[idx] = card;
|
||
this.tradeMenuObjs.push(card);
|
||
});
|
||
if (owned.length === 0) {
|
||
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 150, 'You own no properties.\nYou can still offer cash.', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex, align:'center',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
}
|
||
}
|
||
|
||
buildTradeCenterColumn() {
|
||
const { CX, CW, PY } = this._tradeGeo;
|
||
const midX = CX + CW/2;
|
||
|
||
// Give lane
|
||
this.tradeMenuObjs.push(this.add.text(midX, PY + 70, 'You give →', {
|
||
fontFamily:'Righteous', fontSize:'16px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
this.tradeGiveLane = { x: CX + 10, y: PY + 90, w: CW - 20, h: 86 };
|
||
// Get lane
|
||
this.tradeMenuObjs.push(this.add.text(midX, PY + 192, '← You get', {
|
||
fontFamily:'Righteous', fontSize:'16px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
this.tradeGetLane = { x: CX + 10, y: PY + 212, w: CW - 20, h: 86 };
|
||
|
||
const laneG = this.add.graphics().setDepth(DEPTH.popup);
|
||
for (const lane of [this.tradeGiveLane, this.tradeGetLane]) {
|
||
laneG.fillStyle(0x14110a, 1);
|
||
laneG.fillRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
|
||
laneG.lineStyle(1, COLORS.accent, 0.6);
|
||
laneG.strokeRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
|
||
}
|
||
this.tradeMenuObjs.push(laneG);
|
||
|
||
// Real Phaser drop zones over each lane. Native drop uses Phaser's own
|
||
// render-consistent hit testing (same pipeline as normal clicks), so the
|
||
// droppable area matches exactly what's drawn. add.zone(x,y,...) takes the
|
||
// CENTER; setRectangleDropZone centers the hit area. A small pad eases aiming.
|
||
const mkZone = (lane, type) => {
|
||
const zw = lane.w + 16, zh = lane.h + 16;
|
||
const z = this.add.zone(lane.x + lane.w/2, lane.y + lane.h/2, zw, zh)
|
||
.setRectangleDropZone(zw, zh)
|
||
.setDepth(DEPTH.popup + 2);
|
||
z.setData('laneType', type);
|
||
this.tradeMenuObjs.push(z);
|
||
return z;
|
||
};
|
||
this.tradeGiveZone = mkZone(this.tradeGiveLane, 'give');
|
||
this.tradeGetZone = mkZone(this.tradeGetLane, 'get');
|
||
|
||
// Cash steppers
|
||
this.buildCashStepper('give', PY + 326);
|
||
this.buildCashStepper('get', PY + 396);
|
||
|
||
// Propose / Cancel
|
||
const proposeLabel = this.tradeCounterMode ? 'Send Counter-offer' : 'Propose Trade';
|
||
const proposeBtn = new Button(this, midX, PY + 476, proposeLabel, () => this.onProposeTrade(),
|
||
{ width: 260, height: 52, fontSize: 22 });
|
||
proposeBtn.setDepth(DEPTH.popup+2);
|
||
this.tradeMenuObjs.push(proposeBtn);
|
||
|
||
const cancelBtn = new Button(this, midX, PY + 540, 'Cancel', () => this.closeTradeModal(),
|
||
{ width: 180, height: 44, fontSize: 18, variant:'ghost' });
|
||
cancelBtn.setDepth(DEPTH.popup+2);
|
||
this.tradeMenuObjs.push(cancelBtn);
|
||
|
||
this.tradeFeedbackText = this.add.text(midX, PY + 600, 'Build your offer, then propose.', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'15px', color:COLORS.mutedHex,
|
||
align:'center', wordWrap:{ width: CW - 20 },
|
||
}).setOrigin(0.5, 0).setDepth(DEPTH.popup+1);
|
||
this.tradeMenuObjs.push(this.tradeFeedbackText);
|
||
}
|
||
|
||
buildCashStepper(side, y) {
|
||
const { CX, CW } = this._tradeGeo;
|
||
const midX = CX + CW/2;
|
||
const label = side === 'give' ? 'You add cash' : 'You request cash';
|
||
this.tradeMenuObjs.push(this.add.text(CX + 10, y - 18, label, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'14px', color:COLORS.textHex,
|
||
}).setOrigin(0, 0.5).setDepth(DEPTH.popup+1));
|
||
|
||
const valText = this.add.text(midX, y + 8, '$0', {
|
||
fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1);
|
||
this.tradeMenuObjs.push(valText);
|
||
if (side === 'give') this.tradeGiveCashText = valText; else this.tradeGetCashText = valText;
|
||
|
||
const deltas = [[-50,'−50'], [-10,'−10'], [+10,'+10'], [+50,'+50']];
|
||
const bw = 64, gap = 8, totalW = deltas.length * bw + (deltas.length - 1) * gap;
|
||
let bx = midX - totalW/2 + bw/2;
|
||
const rowY = y + 36;
|
||
for (const [delta, lbl] of deltas) {
|
||
const b = new Button(this, bx, rowY, lbl, () => this.adjustTradeCash(side, delta),
|
||
{ width: bw, height: 30, fontSize: 14, variant:'ghost' });
|
||
b.setDepth(DEPTH.popup+2);
|
||
this.tradeMenuObjs.push(b);
|
||
bx += bw + gap;
|
||
}
|
||
}
|
||
|
||
adjustTradeCash(side, delta) {
|
||
if (!this.tradeOffer) return;
|
||
if (side === 'give') {
|
||
const max = this.gs.players[this.humanSeat].cash;
|
||
this.tradeOffer.giveCash = Phaser.Math.Clamp(this.tradeOffer.giveCash + delta, 0, max);
|
||
} else {
|
||
const cp = this.tradeCounterparty;
|
||
const max = cp !== null ? this.gs.players[cp].cash : 0;
|
||
this.tradeOffer.getCash = Phaser.Math.Clamp(this.tradeOffer.getCash + delta, 0, max);
|
||
}
|
||
this.renderTradeCash();
|
||
}
|
||
|
||
renderTradeCash() {
|
||
if (this.tradeGiveCashText) this.tradeGiveCashText.setText(`$${this.tradeOffer.giveCash}`);
|
||
if (this.tradeGetCashText) this.tradeGetCashText.setText(`$${this.tradeOffer.getCash}`);
|
||
this.refreshDragHints();
|
||
}
|
||
|
||
renderRightColumn() {
|
||
(this.tradeRightObjs || []).forEach(o => { try { o.destroy(); } catch {} });
|
||
this.tradeRightObjs = [];
|
||
this.tradeOppCards = {};
|
||
const { RX, RW, PY } = this._tradeGeo;
|
||
const gs = this.gs;
|
||
const opps = this.tradeCounterMode
|
||
? gs.players.filter(pl => pl.seat === this.tradeCounterparty)
|
||
: gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
|
||
|
||
// Opponent tabs
|
||
const tabW = Math.min(150, Math.floor((RW - (opps.length - 1) * 8) / Math.max(1, opps.length)));
|
||
let tx = RX + tabW/2;
|
||
for (const pl of opps) {
|
||
const selected = pl.seat === this.tradeCounterparty;
|
||
const b = new Button(this, tx, PY + 66, pl.name.length > 10 ? pl.name.slice(0,9)+'…' : pl.name,
|
||
() => this.selectTradeCounterparty(pl.seat),
|
||
{ width: tabW, height: 36, fontSize: 14, variant: selected ? 'solid' : 'ghost' });
|
||
b.setDepth(DEPTH.popup+2);
|
||
this.tradeRightObjs.push(b);
|
||
tx += tabW + 8;
|
||
}
|
||
|
||
const cp = this.tradeCounterparty;
|
||
if (cp === null) return;
|
||
this.tradeRightObjs.push(this.add.text(RX + RW/2, PY + 96, `Cash: $${gs.players[cp].cash.toLocaleString()}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f',
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
|
||
const owned = PURCHASABLE.filter(i => gs.board[i]?.owner === cp);
|
||
owned.forEach((idx, i) => {
|
||
const col = i % 3, row = Math.floor(i / 3);
|
||
const cx = RX + 70 + col * 140;
|
||
const cy = PY + 138 + row * 78;
|
||
const card = this.buildTradeMiniCard(idx, 'opp');
|
||
card.setPosition(cx, cy);
|
||
this.tradeOppCards[idx] = card;
|
||
this.tradeRightObjs.push(card);
|
||
});
|
||
if (owned.length === 0) {
|
||
this.tradeRightObjs.push(this.add.text(RX + RW/2, PY + 150, 'They own no properties.', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||
}
|
||
this.refreshMiniStates();
|
||
}
|
||
|
||
selectTradeCounterparty(seat) {
|
||
if (this.tradeCounterMode) return; // locked to the proposing AI
|
||
if (seat === this.tradeCounterparty) return;
|
||
this.tradeCounterparty = seat;
|
||
// getProps/getCash referenced the previous opponent — reset them
|
||
this.tradeOffer.getProps = [];
|
||
this.tradeOffer.getCash = 0;
|
||
this.renderRightColumn();
|
||
this.renderTradeOffer();
|
||
this.renderTradeCash();
|
||
this.setTradeFeedback('Build your offer, then propose.', 'muted');
|
||
}
|
||
|
||
buildTradeMiniCard(idx, side) {
|
||
const sp = SPACES[idx];
|
||
const own = this.gs.board[idx];
|
||
const MW = 128, MH = 66;
|
||
const c = this.add.container(0, 0).setDepth(DEPTH.popup + 1);
|
||
|
||
const g = this.add.graphics();
|
||
g.fillStyle(0xFFF8E7, 1);
|
||
g.fillRoundedRect(-MW/2, -MH/2, MW, MH, 6);
|
||
g.lineStyle(1, 0x2c1810, 1);
|
||
g.strokeRoundedRect(-MW/2, -MH/2, MW, MH, 6);
|
||
g.fillStyle(this.tradeBandColor(idx), 1);
|
||
g.fillRect(-MW/2, -MH/2, MW, 14);
|
||
c.add(g);
|
||
|
||
c.add(this.add.text(0, -MH/2 + 18, sp.name, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'10px', color:'#1a1208',
|
||
align:'center', wordWrap:{ width: MW - 10, useAdvancedWrap:true },
|
||
}).setOrigin(0.5, 0));
|
||
|
||
const sub = (sp.type === 'property' || sp.type === 'railroad' || sp.type === 'utility') ? `$${sp.price}` : '';
|
||
if (sub) {
|
||
c.add(this.add.text(0, MH/2 - 14, sub, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'9px', color:'#555544',
|
||
}).setOrigin(0.5, 0));
|
||
}
|
||
if (own.mortgaged) {
|
||
const mg = this.add.graphics();
|
||
mg.fillStyle(0x888888, 0.45);
|
||
mg.fillRoundedRect(-MW/2, -MH/2, MW, MH, 6);
|
||
c.add(mg);
|
||
c.add(this.add.text(0, 4, 'MORTGAGED', {
|
||
fontFamily:'"Julius Sans One"', fontSize:'9px', color:'#cccccc',
|
||
}).setOrigin(0.5));
|
||
}
|
||
|
||
const tradeable = isTradeable(this.gs, idx);
|
||
if (tradeable) {
|
||
const og = this.add.graphics();
|
||
og.lineStyle(2, 0x44cc66, 1);
|
||
og.strokeRoundedRect(-MW/2, -MH/2, MW, MH, 6);
|
||
c.add(og);
|
||
} else {
|
||
c.setAlpha(0.45);
|
||
}
|
||
|
||
c._spaceIdx = idx; c._side = side; c._tradeable = tradeable;
|
||
c._hw = MW/2; c._hh = MH/2; // half-extents for drop-zone overlap testing
|
||
// NB: do NOT call setSize() — on a Container it sets displayOrigin = size/2,
|
||
// which Phaser's hit test adds to the local point and shifts the hit area up/left.
|
||
// NB: the 3rd setInteractive arg is `dropZone` (boolean). Passing a config object
|
||
// there made every card a drop zone and broke drag-drop — keep it to 2 args.
|
||
c.setInteractive(new Phaser.Geom.Rectangle(-MW/2, -MH/2, MW, MH), Phaser.Geom.Rectangle.Contains);
|
||
if (c.input) c.input.cursor = tradeable ? 'grab' : 'default';
|
||
c.on('pointerover', () => { if (!this.tradeDragGhost) this.showTradeHoverCard(idx, c.x, c.y); });
|
||
c.on('pointerout', () => this.clearTradeHoverCard());
|
||
if (tradeable) {
|
||
this.input.setDraggable(c, true);
|
||
c.on('pointerdown', () => { this._tradeDidDrag = false; });
|
||
c.on('pointerup', () => { if (!this._tradeDidDrag) this.toggleTradeProp(idx, side); });
|
||
}
|
||
return c;
|
||
}
|
||
|
||
showDropHint(side) {
|
||
this.clearDropHint();
|
||
const lane = side === 'mine' ? this.tradeGiveLane
|
||
: side === 'opp' ? this.tradeGetLane : null;
|
||
if (!lane) return;
|
||
|
||
const g = this.add.graphics().setDepth(DEPTH.popup + 3);
|
||
g.fillStyle(0x44cc66, 0.22);
|
||
g.fillRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
|
||
g.lineStyle(4, 0x66ff88, 1);
|
||
g.strokeRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
|
||
|
||
const label = this.add.text(lane.x + lane.w/2, lane.y + lane.h/2,
|
||
side === 'mine' ? '⬇ DROP HERE TO GIVE' : '⬇ DROP HERE TO GET', {
|
||
fontFamily:'Righteous', fontSize:'22px', color:'#d6ffe0',
|
||
stroke:'#0a3a18', strokeThickness:4,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 4);
|
||
|
||
this.tradeDropHintObjs = [g, label];
|
||
this.tradeDropHintTween = this.tweens.add({
|
||
targets: [g, label],
|
||
alpha: { from: 1, to: 0.45 },
|
||
duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
|
||
});
|
||
}
|
||
|
||
clearDropHint() {
|
||
if (this.tradeDropHintTween) { try { this.tradeDropHintTween.stop(); } catch {} this.tradeDropHintTween = null; }
|
||
(this.tradeDropHintObjs || []).forEach(o => { try { o.destroy(); } catch {} });
|
||
this.tradeDropHintObjs = [];
|
||
}
|
||
|
||
|
||
toggleTradeProp(idx, side) {
|
||
const lane = side === 'mine' ? 'give' : 'get';
|
||
const arr = lane === 'give' ? this.tradeOffer.giveProps : this.tradeOffer.getProps;
|
||
if (arr.includes(idx)) this.removeTradeProp(idx, lane);
|
||
else this.addTradeProp(idx, lane);
|
||
}
|
||
|
||
addTradeProp(idx, lane) {
|
||
const arr = lane === 'give' ? this.tradeOffer.giveProps : this.tradeOffer.getProps;
|
||
if (!arr.includes(idx)) { arr.push(idx); this.renderTradeOffer(); }
|
||
}
|
||
|
||
removeTradeProp(idx, lane) {
|
||
if (lane === 'give') this.tradeOffer.giveProps = this.tradeOffer.giveProps.filter(i => i !== idx);
|
||
else this.tradeOffer.getProps = this.tradeOffer.getProps.filter(i => i !== idx);
|
||
this.renderTradeOffer();
|
||
}
|
||
|
||
renderTradeOffer() {
|
||
(this.tradeLaneObjs || []).forEach(o => { try { o.destroy(); } catch {} });
|
||
this.tradeLaneObjs = [];
|
||
|
||
const layoutChips = (idxs, lane) => {
|
||
const CW = 178, CH = 30, gap = 8, perRow = Math.max(1, Math.floor(lane.w / (CW + gap)));
|
||
idxs.forEach((idx, i) => {
|
||
const col = i % perRow, row = Math.floor(i / perRow);
|
||
const cx = lane.x + 12 + CW/2 + col * (CW + gap);
|
||
const cy = lane.y + 20 + row * (CH + 6);
|
||
this.tradeLaneObjs.push(this.buildTradeChip(idx, cx, cy,
|
||
lane === this.tradeGiveLane ? 'give' : 'get', CW, CH));
|
||
});
|
||
};
|
||
layoutChips(this.tradeOffer.giveProps, this.tradeGiveLane);
|
||
layoutChips(this.tradeOffer.getProps, this.tradeGetLane);
|
||
this.refreshMiniStates();
|
||
this.refreshDragHints();
|
||
}
|
||
|
||
buildTradeChip(idx, cx, cy, lane, CW, CH) {
|
||
const sp = SPACES[idx];
|
||
const c = this.add.container(cx, cy).setDepth(DEPTH.popup + 2);
|
||
const g = this.add.graphics();
|
||
g.fillStyle(0x2a2418, 1);
|
||
g.fillRoundedRect(-CW/2, -CH/2, CW, CH, 6);
|
||
g.lineStyle(2, this.tradeBandColor(idx), 1);
|
||
g.strokeRoundedRect(-CW/2, -CH/2, CW, CH, 6);
|
||
c.add(g);
|
||
c.add(this.add.text(-CW/2 + 8, 0, sp.name, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#FFF8E7',
|
||
wordWrap:{ width: CW - 34 },
|
||
}).setOrigin(0, 0.5));
|
||
c.add(this.add.text(CW/2 - 12, 0, '✕', {
|
||
fontFamily:'Righteous', fontSize:'14px', color:'#ff8888',
|
||
}).setOrigin(0.5));
|
||
c.setInteractive(new Phaser.Geom.Rectangle(-CW/2, -CH/2, CW, CH), Phaser.Geom.Rectangle.Contains);
|
||
if (c.input) c.input.cursor = 'pointer';
|
||
c.on('pointerup', () => this.removeTradeProp(idx, lane));
|
||
return c;
|
||
}
|
||
|
||
refreshMiniStates() {
|
||
const mark = (map, arr) => {
|
||
for (const [idx, card] of Object.entries(map)) {
|
||
if (!card || !card.active) continue;
|
||
const inOffer = arr.includes(Number(idx));
|
||
if (!card._tradeable) { card.setAlpha(0.45); continue; }
|
||
card.setAlpha(inOffer ? 0.35 : 1);
|
||
}
|
||
};
|
||
mark(this.tradeMineCards, this.tradeOffer.giveProps);
|
||
mark(this.tradeOppCards, this.tradeOffer.getProps);
|
||
}
|
||
|
||
isTradeOfferEmpty() {
|
||
const o = this.tradeOffer;
|
||
return !o || (o.giveProps.length === 0 && o.getProps.length === 0 && o.giveCash === 0 && o.getCash === 0);
|
||
}
|
||
|
||
// While the offer is empty, gently pulse the draggable cards so it's obvious they
|
||
// can be picked up. Stops the moment anything is offered or requested.
|
||
startDragHints() {
|
||
this.stopDragHints();
|
||
if (!this.tradeMenuOpen) return;
|
||
const cards = [
|
||
...Object.values(this.tradeMineCards || {}),
|
||
...Object.values(this.tradeOppCards || {}),
|
||
].filter(c => c && c.active && c._tradeable);
|
||
if (!cards.length) return;
|
||
cards.forEach(c => c.setScale(1));
|
||
this._dragHintCards = cards;
|
||
this._dragHintTween = this.tweens.add({
|
||
targets: cards,
|
||
scaleX: 1.07, scaleY: 1.07,
|
||
duration: 640, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
|
||
});
|
||
}
|
||
|
||
stopDragHints() {
|
||
if (this._dragHintTween) { try { this._dragHintTween.stop(); } catch {} this._dragHintTween = null; }
|
||
(this._dragHintCards || []).forEach(c => { if (c && c.active) c.setScale(1); });
|
||
this._dragHintCards = [];
|
||
}
|
||
|
||
refreshDragHints() {
|
||
if (this.tradeMenuOpen && this.isTradeOfferEmpty()) this.startDragHints();
|
||
else this.stopDragHints();
|
||
}
|
||
|
||
showTradeHoverCard(idx, x, y) {
|
||
this.clearTradeHoverCard();
|
||
const card = this.buildPropertyCardContainer(idx);
|
||
const s = 0.82;
|
||
const hw = MODAL_W * s / 2, hh = MODAL_H * s / 2;
|
||
const hx = Phaser.Math.Clamp(x, hw + 10, GAME_WIDTH - hw - 10);
|
||
const hy = Phaser.Math.Clamp(y, hh + 10, GAME_HEIGHT - hh - 10);
|
||
card.setPosition(hx, hy).setScale(s).setDepth(DEPTH.popup + 6);
|
||
this.tradeHoverCard = card;
|
||
}
|
||
|
||
clearTradeHoverCard() {
|
||
if (this.tradeHoverCard) {
|
||
this.tradeHoverCard.each(c => { try { c.destroy(); } catch {} });
|
||
try { this.tradeHoverCard.destroy(); } catch {}
|
||
this.tradeHoverCard = null;
|
||
}
|
||
}
|
||
|
||
setTradeFeedback(msg, tone = 'muted') {
|
||
if (!this.tradeFeedbackText) return;
|
||
const color = tone === 'good' ? '#7fdd9f'
|
||
: tone === 'bad' ? COLORS.dangerHex
|
||
: tone === 'warn' ? COLORS.goldHex
|
||
: COLORS.mutedHex;
|
||
this.tradeFeedbackText.setColor(color);
|
||
this.tradeFeedbackText.setText(msg);
|
||
}
|
||
|
||
onProposeTrade() {
|
||
if (!this.tradeOffer || this.tradeCounterparty === null) return;
|
||
const offer = {
|
||
fromSeat: this.humanSeat,
|
||
toSeat: this.tradeCounterparty,
|
||
giveProps: [...this.tradeOffer.giveProps],
|
||
getProps: [...this.tradeOffer.getProps],
|
||
giveCash: this.tradeOffer.giveCash,
|
||
getCash: this.tradeOffer.getCash,
|
||
};
|
||
const v = validateTrade(this.gs, offer);
|
||
if (!v.ok) { this.setTradeFeedback(v.reason, 'warn'); return; }
|
||
|
||
const skill = this.skillBySeat[this.tradeCounterparty] ?? 3;
|
||
const verdict = evaluateTrade(this.gs, this.tradeCounterparty, offer, skill);
|
||
if (verdict.accept) {
|
||
this.gs = applyTrade(this.gs, offer);
|
||
this.setTradeFeedback('Accepted! ' + verdict.reason, 'good');
|
||
playSound(this, SFX.MONOPOLY_PURCHASE);
|
||
this.time.delayedCall(1200, () => {
|
||
this.closeTradeModal();
|
||
this.render();
|
||
});
|
||
} else if (this.tradeCounterMode) {
|
||
// The AI gets a single chance to accept a counter — a rejection ends it.
|
||
this.setTradeFeedback('They declined your counter. ' + verdict.reason, 'bad');
|
||
this.time.delayedCall(1500, () => { this.closeTradeModal(); this.render(); });
|
||
} else {
|
||
this.setTradeFeedback('Rejected: ' + verdict.reason, 'bad');
|
||
}
|
||
}
|
||
|
||
closeTradeModal() {
|
||
this.tradeMenuOpen = false;
|
||
this.showPortraits();
|
||
this.stopDragHints();
|
||
this.clearDropHint();
|
||
if (this._onTradeDragStart) this.input.off('dragstart', this._onTradeDragStart);
|
||
if (this._onTradeDrag) this.input.off('drag', this._onTradeDrag);
|
||
if (this._onTradeDrop) this.input.off('drop', this._onTradeDrop);
|
||
if (this._onTradeDragEnd) this.input.off('dragend', this._onTradeDragEnd);
|
||
this._onTradeDragStart = this._onTradeDrag = this._onTradeDrop = this._onTradeDragEnd = null;
|
||
this.clearTradeHoverCard();
|
||
(this.tradeLaneObjs || []).forEach(o => { try { o.destroy(); } catch {} });
|
||
(this.tradeRightObjs || []).forEach(o => { try { o.destroy(); } catch {} });
|
||
(this.tradeMenuObjs || []).forEach(o => { try { o.destroy(); } catch {} });
|
||
this.tradeLaneObjs = [];
|
||
this.tradeRightObjs = [];
|
||
this.tradeMenuObjs = [];
|
||
this.tradeMineCards = {};
|
||
this.tradeOppCards = {};
|
||
this.tradeOffer = null;
|
||
this.tradeCounterparty = null;
|
||
this.tradeDragGhost = null;
|
||
this.tradeGiveCashText = null;
|
||
this.tradeGetCashText = null;
|
||
this.tradeFeedbackText = null;
|
||
this.tradeCounterMode = false;
|
||
this.tradeCounterFrom = null;
|
||
// If this modal was a counter to an AI proposal, resolve that pending promise.
|
||
const r = this._tradeResolve; this._tradeResolve = null;
|
||
if (r) r();
|
||
}
|
||
|
||
// ── Game Over ──────────────────────────────────────────────────────────────
|
||
showGameOver() {
|
||
const winner = this.gs.winner !== null ? this.gs.players[this.gs.winner] : null;
|
||
const overlay = this.add.graphics().setDepth(DEPTH.banner);
|
||
overlay.fillStyle(0x000000, 0.7);
|
||
overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||
|
||
const pw = 600, ph = 280;
|
||
const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2;
|
||
const bg = this.add.graphics().setDepth(DEPTH.banner+1);
|
||
bg.fillStyle(0x1e1a12, 1);
|
||
bg.fillRoundedRect(px, py, pw, ph, 16);
|
||
bg.lineStyle(3, COLORS.gold, 1);
|
||
bg.strokeRoundedRect(px, py, pw, ph, 16);
|
||
|
||
const msg = winner ? `${winner.name} Wins!` : 'Game Over';
|
||
this.add.text(GAME_WIDTH/2, py + 60, msg, {
|
||
fontFamily:'Righteous', fontSize:'54px', color:COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.banner+2);
|
||
|
||
if (winner) {
|
||
this.add.text(GAME_WIDTH/2, py + 140, `Net worth: $${netWorth(this.gs, winner.seat).toLocaleString()}`, {
|
||
fontFamily:'"Julius Sans One"', fontSize:'22px', color:COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.banner+2);
|
||
}
|
||
|
||
new Button(this, GAME_WIDTH/2, py + 218, 'Back to Menu', () => this.scene.start('GameMenu'), {
|
||
width:220, height:52, fontSize:22,
|
||
}).setDepth(DEPTH.banner+3);
|
||
}
|
||
|
||
// ── Game Flow ──────────────────────────────────────────────────────────────
|
||
advance() {
|
||
if (this.busy) return;
|
||
this.render();
|
||
const gs = this.gs;
|
||
if (gs.phase === 'gameover') { this.showGameOver(); return; }
|
||
|
||
// Guard 1: zoom property card to center when entering 'buy' phase
|
||
if (gs.phase === 'buy' && gs.pendingBuy && !this.modalActive) {
|
||
this.busy = true;
|
||
this.showPropertyModal(gs.pendingBuy.spaceIdx).then(() => {
|
||
this.busy = false;
|
||
this.advance();
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Guard 2: dismiss modal when phase leaves buy/auction
|
||
if (this.modalActive && gs.phase !== 'buy' && gs.phase !== 'auction') {
|
||
this.busy = true;
|
||
this.dismissPropertyModal().then(() => {
|
||
this.busy = false;
|
||
this.time.delayedCall(0, () => this.advance());
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Guard 3: animate card draw once per card event (human and AI)
|
||
if (gs.phase === 'card' && gs.pendingCard && !this.cardAnimPlayed) {
|
||
this.busy = true;
|
||
this.hidePortraits();
|
||
this.animateCardDraw().then(() => {
|
||
this.cardAnimPlayed = true;
|
||
this.busy = false;
|
||
this.render();
|
||
this.advance();
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Guard 4: animate rent payment (human and AI)
|
||
if (gs.phase === 'rent' && gs.pendingRent) {
|
||
this.busy = true;
|
||
this.hidePortraits();
|
||
this.animateRent().then(() => {
|
||
this.gs = applyRent(this.gs);
|
||
this.showPortraits();
|
||
this.busy = false;
|
||
this.render();
|
||
this.advance();
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Determine who acts next
|
||
let actingSeat = gs.current;
|
||
if (gs.phase === 'auction' && gs.pendingAuction) {
|
||
actingSeat = gs.pendingAuction.bidOrder[gs.pendingAuction.currentBidderIdx];
|
||
}
|
||
|
||
if (actingSeat !== this.humanSeat) {
|
||
this.busy = true;
|
||
const delay = nextThinkDelay(this.skillBySeat[actingSeat] ?? 3);
|
||
this.time.delayedCall(delay, () => {
|
||
this.doAiAction(actingSeat).then(() => {
|
||
this.busy = false;
|
||
this.time.delayedCall(0, () => this.advance());
|
||
});
|
||
});
|
||
}
|
||
// Human: buttons are live from render()
|
||
}
|
||
|
||
aiDelay(seat) {
|
||
return nextThinkDelay(this.skillBySeat[seat] ?? 3);
|
||
}
|
||
|
||
delay(ms) {
|
||
return new Promise(r => this.time.delayedCall(ms, r));
|
||
}
|
||
|
||
async doAiAction(seat) {
|
||
const gs = this.gs;
|
||
const skill = this.skillBySeat[seat] ?? 3;
|
||
|
||
if (gs.phase === 'auction') {
|
||
const bid = chooseBid(gs, seat, skill);
|
||
if (bid !== null) {
|
||
this.gs = placeBid(this.gs, seat, bid);
|
||
} else {
|
||
this.gs = passAuction(this.gs, seat);
|
||
}
|
||
this.render();
|
||
return;
|
||
}
|
||
|
||
if (gs.current !== seat) return;
|
||
|
||
switch (gs.phase) {
|
||
case 'preroll': {
|
||
// Build first if possible
|
||
const buildAct = chooseBuild(gs, seat, skill);
|
||
if (buildAct) {
|
||
if (buildAct.action === 'hotel') {
|
||
this.gs = buildHotel(this.gs, seat, buildAct.spaceIdx);
|
||
} else {
|
||
this.gs = buildHouse(this.gs, seat, buildAct.spaceIdx);
|
||
}
|
||
this.render();
|
||
await this.delay(350);
|
||
await this.doAiAction(seat);
|
||
return;
|
||
}
|
||
// Jail handling
|
||
if (gs.players[seat].jailed) {
|
||
const ja = chooseJailAction(gs, seat, skill);
|
||
if (ja === 'card' && gs.players[seat].getOutOfJailFree > 0) {
|
||
this.gs = useJailCard(this.gs, seat);
|
||
this.render();
|
||
await this.delay(500);
|
||
} else if (ja === 'pay' && gs.players[seat].cash >= 50) {
|
||
this.gs = payJailFine(this.gs, seat);
|
||
this.render();
|
||
await this.delay(500);
|
||
}
|
||
}
|
||
await this.executeRoll(seat);
|
||
break;
|
||
}
|
||
case 'buy': {
|
||
// Modal already zoomed in (advance() called showPropertyModal before doAiAction)
|
||
const buy = chooseBuy(gs, seat, skill);
|
||
await this.delay(700); // AI "thinking" pause
|
||
if (buy) {
|
||
this.gs = buyProperty(this.gs, seat);
|
||
await this.dismissPropertyModal(); // fill with owner color + zoom back
|
||
} else {
|
||
this.gs = declineProperty(this.gs, seat);
|
||
await this.shiftModalForAuction();
|
||
}
|
||
this.render();
|
||
break;
|
||
}
|
||
case 'card': {
|
||
await this.delay(2800);
|
||
this.cardAnimPlayed = false;
|
||
this.gs = applyCardEffect(this.gs, seat);
|
||
this.render();
|
||
// If card moved player to buy or another phase, handle next advance
|
||
break;
|
||
}
|
||
case 'endturn': {
|
||
await this.aiAttemptTrades(seat);
|
||
await this.delay(450);
|
||
this.gs = endTurn(this.gs);
|
||
this.render();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
async executeRoll(seat) {
|
||
const d1 = Math.floor(Math.random() * 6) + 1;
|
||
const d2 = Math.floor(Math.random() * 6) + 1;
|
||
playSound(this, SFX.DICE_ROLL);
|
||
await this.animateDice(d1, d2);
|
||
const prevPos = this.gs.players[seat].position;
|
||
const wasJailed = this.gs.players[seat].jailed;
|
||
this.gs = rollDice(this.gs, seat, d1, d2);
|
||
const finalPos = this.gs.players[seat].position;
|
||
const nowJailed = this.gs.players[seat].jailed;
|
||
// Animate the dice-total steps; snap to finalPos afterward if redirected (e.g. Go to Jail)
|
||
const diceTarget = (prevPos + d1 + d2) % 40;
|
||
const shouldAnimate = wasJailed ? !nowJailed : true;
|
||
if (shouldAnimate) {
|
||
await this.animatePawnMove(seat, prevPos, diceTarget);
|
||
if (finalPos !== diceTarget) {
|
||
const pawn = this.pawns[seat];
|
||
if (pawn) { const { x, y } = this.spacePxCenter(finalPos); pawn.x = x; pawn.y = y; }
|
||
}
|
||
}
|
||
this.render();
|
||
}
|
||
|
||
// ── Animations ─────────────────────────────────────────────────────────────
|
||
animateDice(d1, d2) {
|
||
return new Promise(resolve => {
|
||
const defaultX = RP_X + RP_W / 2 - 55;
|
||
const defaultY = this.diceY;
|
||
|
||
// Landing positions — scattered near default spots, random angle
|
||
const land0 = {
|
||
x: defaultX + Phaser.Math.Between(-20, 20),
|
||
y: defaultY + Phaser.Math.Between(-14, 14),
|
||
angle: Phaser.Math.Between(-30, 30),
|
||
};
|
||
const land1 = {
|
||
x: defaultX + 84 + Phaser.Math.Between(-20, 20),
|
||
y: defaultY + Phaser.Math.Between(-14, 14),
|
||
angle: Phaser.Math.Between(-30, 30),
|
||
};
|
||
|
||
// Throw origin — below the landing area (like a hand tossing upward)
|
||
const throwX = RP_X + RP_W / 2;
|
||
const throwY = GAME_HEIGHT - 40;
|
||
|
||
// Arch control points — above the landing area so dice overshoot upward then fall back down
|
||
const ctrl0 = { x: defaultX - 60, y: defaultY - 380 };
|
||
const ctrl1 = { x: defaultX + 80, y: defaultY - 350 };
|
||
|
||
// Total spin per die: 2–3 full rotations ending at the landing angle
|
||
const dir0 = Math.random() < 0.5 ? 1 : -1;
|
||
const dir1 = Math.random() < 0.5 ? 1 : -1;
|
||
const spin0 = dir0 * (Phaser.Math.Between(2, 3) * 360 + land0.angle * dir0);
|
||
const spin1 = dir1 * (Phaser.Math.Between(2, 3) * 360 + land1.angle * dir1);
|
||
|
||
const THROW_MS = 820;
|
||
let face0 = Phaser.Math.Between(1, 6);
|
||
let face1 = Phaser.Math.Between(1, 6);
|
||
|
||
// Randomize pip faces during flight
|
||
const faceTimer = this.time.addEvent({
|
||
delay: 90,
|
||
repeat: Math.ceil(THROW_MS / 90),
|
||
callback: () => {
|
||
face0 = Phaser.Math.Between(1, 6);
|
||
face1 = Phaser.Math.Between(1, 6);
|
||
},
|
||
});
|
||
|
||
this.diceAnimating = true;
|
||
|
||
const proxy = { t: 0 };
|
||
this.tweens.add({
|
||
targets: proxy,
|
||
t: 1,
|
||
duration: THROW_MS,
|
||
ease: 'Sine.easeIn',
|
||
onUpdate: () => {
|
||
const t = proxy.t;
|
||
const inv = 1 - t;
|
||
|
||
// Quadratic bezier: start → control → land
|
||
const x0 = inv*inv*throwX + 2*inv*t*ctrl0.x + t*t*land0.x;
|
||
const y0 = inv*inv*throwY + 2*inv*t*ctrl0.y + t*t*land0.y;
|
||
const x1 = inv*inv*throwX + 2*inv*t*ctrl1.x + t*t*land1.x;
|
||
const y1 = inv*inv*throwY + 2*inv*t*ctrl1.y + t*t*land1.y;
|
||
|
||
// Scale up as dice approach (perspective/depth effect)
|
||
const scale = 0.45 + t * 0.55;
|
||
this.dieGfx[0].setScale(scale);
|
||
this.dieGfx[1].setScale(scale);
|
||
|
||
this.drawDie(0, x0, y0, face0, spin0 * t);
|
||
this.drawDie(1, x1, y1, face1, spin1 * t);
|
||
},
|
||
onComplete: () => {
|
||
faceTimer.remove();
|
||
|
||
// Snap to final values at landing positions
|
||
this.dieGfx[0].setScale(1);
|
||
this.dieGfx[1].setScale(1);
|
||
this.drawDie(0, land0.x, land0.y, d1, land0.angle);
|
||
this.drawDie(1, land1.x, land1.y, d2, land1.angle);
|
||
|
||
// Store landing state for subsequent renders
|
||
this.dicePositions[0] = { cx: land0.x, cy: land0.y, angle: land0.angle };
|
||
this.dicePositions[1] = { cx: land1.x, cy: land1.y, angle: land1.angle };
|
||
|
||
// Impact bounce: scale 1 → 1.18 → 1
|
||
this.dieGfx[0].setScale(1.18);
|
||
this.dieGfx[1].setScale(1.18);
|
||
this.tweens.add({
|
||
targets: [this.dieGfx[0], this.dieGfx[1]],
|
||
scaleX: 1, scaleY: 1,
|
||
duration: 200,
|
||
ease: 'Back.easeOut',
|
||
onComplete: () => {
|
||
this.diceAnimating = false;
|
||
resolve();
|
||
},
|
||
});
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
animatePawnMove(seat, fromPos, toPos) {
|
||
return new Promise(resolve => {
|
||
const steps = ((toPos - fromPos + 40) % 40) || 0;
|
||
if (steps === 0) { resolve(); return; }
|
||
const pawn = this.pawns[seat];
|
||
if (!pawn) { resolve(); return; }
|
||
|
||
// Board center — arches always point toward here
|
||
const BOARD_CX = BL + BS / 2;
|
||
const BOARD_CY = BT + BS / 2;
|
||
const ARCH_H = 44; // pixels the arc peak is pushed toward board center
|
||
|
||
let step = 0;
|
||
let cur = fromPos;
|
||
|
||
const hopOne = () => {
|
||
if (step >= steps) { resolve(); return; }
|
||
step++;
|
||
const fromSpace = cur;
|
||
cur = (cur + 1) % 40;
|
||
|
||
const start = { x: pawn.x, y: pawn.y };
|
||
const { x: ex, y: ey } = this.spacePxCenter(cur);
|
||
|
||
// Midpoint of start → end
|
||
const mx = (start.x + ex) / 2;
|
||
const my = (start.y + ey) / 2;
|
||
|
||
// Unit vector from midpoint toward board center
|
||
const dx = BOARD_CX - mx;
|
||
const dy = BOARD_CY - my;
|
||
const dist = Math.hypot(dx, dy);
|
||
let nx = dist > 0 ? dx / dist : 0;
|
||
let ny = dist > 0 ? dy / dist : -1;
|
||
|
||
// Top row (spaces 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: () => {
|
||
playSound(this, SFX.PIECE_CLICK);
|
||
hopOne();
|
||
},
|
||
});
|
||
};
|
||
|
||
hopOne();
|
||
});
|
||
}
|
||
|
||
// ── Property Purchase Modal ────────────────────────────────────────────────
|
||
|
||
buildPropertyCardContainer(spaceIdx) {
|
||
const sp = SPACES[spaceIdx];
|
||
const w = MODAL_W, h = MODAL_H, bh = MODAL_BAND_H;
|
||
const container = this.add.container(0, 0);
|
||
|
||
const g = this.add.graphics();
|
||
|
||
// Card background + border
|
||
g.fillStyle(0xFFF8E7, 1);
|
||
g.fillRoundedRect(-w/2, -h/2, w, h, 8);
|
||
g.lineStyle(2, 0x2c1810, 1);
|
||
g.strokeRoundedRect(-w/2, -h/2, w, h, 8);
|
||
|
||
// Top band
|
||
const bandCol = sp.group ? GROUP_COLORS[sp.group]
|
||
: sp.type === 'railroad' ? 0x1a1208
|
||
: sp.type === 'utility' && spaceIdx === 12 ? 0xFFD700
|
||
: 0x1565C0;
|
||
g.fillStyle(bandCol, 1);
|
||
g.fillRoundedRect(-w/2, -h/2, w, bh, { tl:8, tr:8, bl:0, br:0 });
|
||
|
||
// Band separator line
|
||
g.lineStyle(1, 0x2c1810, 0.5);
|
||
g.beginPath(); g.moveTo(-w/2, -h/2 + bh); g.lineTo(w/2, -h/2 + bh); g.strokePath();
|
||
|
||
container.add(g);
|
||
|
||
const bandTextCol = '#FFF8E7';
|
||
const darkTextCol = '#1a1208';
|
||
|
||
// "TITLE DEED" inside band
|
||
container.add(this.add.text(0, -h/2 + 8, 'TITLE DEED', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '10px', color: bandTextCol, align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
|
||
// Property name inside band
|
||
container.add(this.add.text(0, -h/2 + 24, sp.name, {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: bandTextCol,
|
||
align: 'center', wordWrap: { width: w - 20, useAdvancedWrap: true },
|
||
}).setOrigin(0.5, 0));
|
||
|
||
// --- Content area below band ---
|
||
const contentTop = -h/2 + bh + 14;
|
||
let cy = contentTop;
|
||
|
||
if (sp.type === 'property') {
|
||
const rentLabels = [
|
||
['Rent', sp.rent[0]],
|
||
['Color group', sp.rent[1]],
|
||
['1 House', sp.rent[2]],
|
||
['2 Houses', sp.rent[3]],
|
||
['3 Houses', sp.rent[4]],
|
||
['4 Houses', sp.rent[5]],
|
||
['Hotel', sp.rent[6]],
|
||
];
|
||
rentLabels.forEach(([label, val]) => {
|
||
const row = this.add.text(0, cy, `${label} $${val}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: darkTextCol, align: 'center',
|
||
}).setOrigin(0.5, 0);
|
||
container.add(row);
|
||
cy += 20;
|
||
});
|
||
cy += 6;
|
||
container.add(this.add.text(0, cy, `Houses / Hotels $${sp.houseCost} each`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
cy += 16;
|
||
container.add(this.add.text(0, cy, `Mortgage value $${sp.mortgage}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
} else if (sp.type === 'railroad') {
|
||
[['1 Railroad', '$25'], ['2 Railroads', '$50'], ['3 Railroads', '$100'], ['4 Railroads', '$200']]
|
||
.forEach(([label, val]) => {
|
||
container.add(this.add.text(0, cy, `${label} ${val}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: darkTextCol, align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
cy += 24;
|
||
});
|
||
cy += 6;
|
||
container.add(this.add.text(0, cy, `Mortgage value $${sp.mortgage}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
} else if (sp.type === 'utility') {
|
||
['If 1 Utility owned:', '4× your dice roll', '', 'If 2 Utilities owned:', '10× your dice roll']
|
||
.forEach((line) => {
|
||
container.add(this.add.text(0, cy, line, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: darkTextCol, align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
cy += line ? 20 : 8;
|
||
});
|
||
cy += 6;
|
||
container.add(this.add.text(0, cy, `Mortgage value $${sp.mortgage}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#555544', align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
}
|
||
|
||
// Price banner near bottom of info area
|
||
container.add(this.add.text(0, h/2 - 88, `Purchase Price $${sp.price}`, {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: darkTextCol, align: 'center',
|
||
}).setOrigin(0.5, 0));
|
||
|
||
// Horizontal rule above price
|
||
const ruleG = this.add.graphics();
|
||
ruleG.lineStyle(1, 0x2c1810, 0.4);
|
||
ruleG.beginPath(); ruleG.moveTo(-w/2 + 12, h/2 - 98); ruleG.lineTo(w/2 - 12, h/2 - 98); ruleG.strokePath();
|
||
container.add(ruleG);
|
||
|
||
// Player pieces currently on this space
|
||
const onSpace = this.gs.players.filter(p => p.position === spaceIdx && !p.bankrupt);
|
||
if (onSpace.length > 0) {
|
||
const spacing = 52;
|
||
const startX = -(onSpace.length - 1) * spacing / 2;
|
||
onSpace.forEach((p, i) => {
|
||
const px = startX + i * spacing;
|
||
const py = h/2 - 44;
|
||
if (this.hasPawns) {
|
||
container.add(this.add.image(px, py, 'monopoly-pawns', PAWN_FRAME(p.seat)).setDisplaySize(44, 44));
|
||
} else {
|
||
const pg = this.add.graphics();
|
||
pg.fillStyle(PLAYER_COLORS[p.seat], 1);
|
||
pg.fillCircle(px, py, 20);
|
||
pg.lineStyle(2, 0xffffff, 0.8);
|
||
pg.strokeCircle(px, py, 20);
|
||
container.add(pg);
|
||
}
|
||
});
|
||
}
|
||
|
||
return container;
|
||
}
|
||
|
||
async showPropertyModal(spaceIdx) {
|
||
const geo = spaceGeometry(spaceIdx);
|
||
const ox = BL + geo.x + geo.w / 2;
|
||
const oy = BT + geo.y + geo.h / 2;
|
||
const scaleStart = geo.w / MODAL_W;
|
||
const rotStart = -geo.rotation;
|
||
|
||
// Hide DOM portraits immediately — they render above canvas regardless of depth
|
||
this.hidePortraits();
|
||
|
||
// Dim overlay
|
||
this.modalOverlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0)
|
||
.setDepth(DEPTH.popup - 2).setInteractive();
|
||
this.modalGfx.push(this.modalOverlay);
|
||
this.tweens.add({ targets: this.modalOverlay, alpha: 0.68, duration: 400 });
|
||
|
||
// Property card container
|
||
const container = this.buildPropertyCardContainer(spaceIdx);
|
||
container.setPosition(ox, oy).setScale(scaleStart).setRotation(rotStart).setDepth(DEPTH.popup - 1);
|
||
this.modalGfx.push(container);
|
||
this.modalContainer = container;
|
||
this.modalSpaceIdx = spaceIdx;
|
||
this.modalOrigin = { ox, oy, scaleStart, rotStart };
|
||
this.modalActive = true;
|
||
|
||
return new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: container,
|
||
x: MODAL_TARGET_X, y: MODAL_TARGET_Y,
|
||
scaleX: 1, scaleY: 1,
|
||
rotation: 0,
|
||
duration: 700,
|
||
ease: 'Cubic.easeOut',
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
}
|
||
|
||
async animateModalFill(seat) {
|
||
if (!this.modalContainer) return;
|
||
playSound(this, SFX.MONOPOLY_PURCHASE);
|
||
const fillGfx = this.add.graphics();
|
||
this.modalContainer.add(fillGfx);
|
||
const proxy = { h: 0 };
|
||
return new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: proxy,
|
||
h: MODAL_H,
|
||
duration: 1200,
|
||
ease: 'Linear',
|
||
onUpdate: () => {
|
||
fillGfx.clear();
|
||
fillGfx.fillStyle(PLAYER_COLORS[seat], 0.55);
|
||
fillGfx.fillRect(-MODAL_W/2, MODAL_H/2 - proxy.h, MODAL_W, proxy.h);
|
||
},
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
}
|
||
|
||
async shiftModalForAuction() {
|
||
if (!this.modalContainer) return;
|
||
return new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: this.modalContainer,
|
||
x: MODAL_AUCTION_X, y: MODAL_AUCTION_Y,
|
||
scaleX: MODAL_AUCTION_SCALE, scaleY: MODAL_AUCTION_SCALE,
|
||
duration: 400,
|
||
ease: 'Cubic.easeInOut',
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
}
|
||
|
||
async dismissPropertyModal() {
|
||
if (!this.modalContainer) return;
|
||
|
||
// Fill with owner color if someone bought this property
|
||
const winner = this.gs.board?.[this.modalSpaceIdx]?.owner;
|
||
if (winner !== null && winner !== undefined) {
|
||
await this.animateModalFill(winner);
|
||
await this.delay(500);
|
||
}
|
||
|
||
const { ox, oy, scaleStart, rotStart } = this.modalOrigin;
|
||
|
||
// Animate card back to board position
|
||
const returnP = new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: this.modalContainer,
|
||
x: ox, y: oy,
|
||
scaleX: scaleStart, scaleY: scaleStart,
|
||
rotation: rotStart,
|
||
duration: 600,
|
||
ease: 'Cubic.easeIn',
|
||
onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
// Simultaneously fade out overlay
|
||
this.tweens.add({ targets: this.modalOverlay, alpha: 0, duration: 400 });
|
||
|
||
await returnP;
|
||
|
||
// Destroy container children first (Phaser won't do it automatically)
|
||
if (this.modalContainer) {
|
||
this.modalContainer.each(child => { try { child.destroy(); } catch {} });
|
||
this.modalContainer.destroy();
|
||
}
|
||
if (this.modalOverlay) this.modalOverlay.destroy();
|
||
this.modalGfx = [];
|
||
this.modalContainer = null;
|
||
this.modalOverlay = null;
|
||
this.modalSpaceIdx = null;
|
||
this.modalOrigin = null;
|
||
this.modalActive = false;
|
||
}
|
||
|
||
drawModalBuyButtons() {
|
||
const gs = this.gs;
|
||
if (!gs.pendingBuy || gs.current !== this.humanSeat) return;
|
||
const sp = SPACES[gs.pendingBuy.spaceIdx];
|
||
const p = gs.players[this.humanSeat];
|
||
const bx = GAME_WIDTH / 2;
|
||
const by = MODAL_TARGET_Y + MODAL_H / 2 + 52;
|
||
|
||
const buyBtn = new Button(this, bx, by,
|
||
`Buy $${sp.price}`,
|
||
() => this.onBuyProperty(),
|
||
{ width: 340, height: 56, fontSize: 24, enabled: p.cash >= sp.price });
|
||
buyBtn.setDepth(DEPTH.popup + 1);
|
||
this.reg(buyBtn);
|
||
|
||
const declineBtn = new Button(this, bx, by + 66,
|
||
'Decline → Auction',
|
||
() => this.onDeclineProperty(),
|
||
{ width: 340, height: 50, fontSize: 20, variant: 'ghost' });
|
||
declineBtn.setDepth(DEPTH.popup + 1);
|
||
this.reg(declineBtn);
|
||
}
|
||
|
||
// ── Human Handlers ─────────────────────────────────────────────────────────
|
||
onRollDice() {
|
||
if (this.busy) return;
|
||
this.busy = true;
|
||
this.executeRoll(this.humanSeat).then(() => {
|
||
this.busy = false;
|
||
this.advance();
|
||
});
|
||
}
|
||
|
||
async onBuyProperty() {
|
||
if (this.busy) return;
|
||
this.busy = true;
|
||
this.gs = buyProperty(this.gs, this.humanSeat); // owner set → dismissPropertyModal fills
|
||
await this.dismissPropertyModal(); // fill + zoom back
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
async onDeclineProperty() {
|
||
if (this.busy) return;
|
||
this.busy = true;
|
||
this.gs = declineProperty(this.gs, this.humanSeat);
|
||
await this.shiftModalForAuction();
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
onDismissCard() {
|
||
if (this.busy) return;
|
||
this.cardAnimPlayed = false;
|
||
this.gs = applyCardEffect(this.gs, this.humanSeat);
|
||
this.render();
|
||
this.advance();
|
||
}
|
||
|
||
onEndTurn() {
|
||
if (this.busy) return;
|
||
this.gs = endTurn(this.gs);
|
||
this.render();
|
||
this.advance();
|
||
}
|
||
|
||
onPayJailFine() {
|
||
if (this.busy) return;
|
||
if (this.gs.players[this.humanSeat].cash < 50) return;
|
||
this.gs = payJailFine(this.gs, this.humanSeat);
|
||
this.render();
|
||
this.advance();
|
||
}
|
||
|
||
onUseJailCard() {
|
||
if (this.busy) return;
|
||
this.gs = useJailCard(this.gs, this.humanSeat);
|
||
this.render();
|
||
this.advance();
|
||
}
|
||
}
|