diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 9116fde..60c1597 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index e6e89e8..038def1 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/assets/images/monopoly-pawns.png b/public/assets/images/monopoly-pawns.png new file mode 100644 index 0000000..a89ffcc Binary files /dev/null and b/public/assets/images/monopoly-pawns.png differ diff --git a/public/src/games/monopoly/MonopolyAI.js b/public/src/games/monopoly/MonopolyAI.js new file mode 100644 index 0000000..031dbdb --- /dev/null +++ b/public/src/games/monopoly/MonopolyAI.js @@ -0,0 +1,82 @@ +// Monopoly — AI decisions. Greedy heuristic, skill 1–5. + +import { + SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS, +} from './MonopolyData.js'; +import { + ownsGroup, canBuildHouse, canBuildHotel, netWorth, +} from './MonopolyLogic.js'; + +const PROFILES = { + 1: { reserve:0, maxBidMult:0.70, noise:50, blunder:0.35, delay:[900,1500] }, + 2: { reserve:100, maxBidMult:0.80, noise:30, blunder:0.20, delay:[750,1300] }, + 3: { reserve:200, maxBidMult:0.90, noise:15, blunder:0.10, delay:[600,1100] }, + 4: { reserve:300, maxBidMult:1.00, noise:5, blunder:0.03, delay:[500,950] }, + 5: { reserve:400, maxBidMult:1.10, noise:0, blunder:0.00, delay:[400,800] }, +}; + +function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function noise(n) { return (Math.random() - 0.5) * n; } + +export function nextThinkDelay(skill) { + const [lo, hi] = PROFILES[skill]?.delay ?? [600, 1100]; + return rnd(lo, hi); +} + +// ── Buy decision ─────────────────────────────────────────────────────────────── +export function chooseBuy(state, seat, skill) { + const prof = PROFILES[skill] ?? PROFILES[3]; + if (Math.random() < prof.blunder) return Math.random() < 0.5; + const { spaceIdx } = state.pendingBuy; + const sp = SPACES[spaceIdx]; + const p = state.players[seat]; + const reserve = prof.reserve + noise(prof.noise); + return p.cash - sp.price >= reserve; +} + +// ── Auction bid ─────────────────────────────────────────────────────────────── +export function chooseBid(state, seat, skill) { + const prof = PROFILES[skill] ?? PROFILES[3]; + const auc = state.pendingAuction; + const sp = SPACES[auc.spaceIdx]; + const p = state.players[seat]; + const maxBid = Math.min(p.cash * 0.7, sp.price * prof.maxBidMult + noise(prof.noise)); + if (maxBid <= auc.highBid) return null; // pass + return Math.max(auc.highBid + 1, Math.floor(maxBid)); +} + +// ── Jail decision ───────────────────────────────────────────────────────────── +export function chooseJailAction(state, seat, skill) { + const p = state.players[seat]; + if (p.getOutOfJailFree > 0) return 'card'; + if (skill >= 3 && p.cash >= 50) { + // Higher skill: pay to get back in action + return 'pay'; + } + return 'roll'; +} + +// ── Build decisions ──────────────────────────────────────────────────────────── +// Returns { action: 'house'|'hotel', spaceIdx } or null +export function chooseBuild(state, seat, skill) { + const prof = PROFILES[skill] ?? PROFILES[3]; + const p = state.players[seat]; + + // Try to build hotels first on complete sets + for (const idx of PURCHASABLE) { + if (!canBuildHotel(state, seat, idx)) continue; + const sp = SPACES[idx]; + if (p.cash - sp.houseCost >= prof.reserve) { + return { action: 'hotel', spaceIdx: idx }; + } + } + // Build houses on monopolies + for (const idx of PURCHASABLE) { + if (!canBuildHouse(state, seat, idx)) continue; + const sp = SPACES[idx]; + if (p.cash - sp.houseCost >= prof.reserve) { + return { action: 'house', spaceIdx: idx }; + } + } + return null; +} diff --git a/public/src/games/monopoly/MonopolyData.js b/public/src/games/monopoly/MonopolyData.js new file mode 100644 index 0000000..d0f0b65 --- /dev/null +++ b/public/src/games/monopoly/MonopolyData.js @@ -0,0 +1,165 @@ +// Monopoly — static data. No Phaser, no state. + +export const BOARD_SIZE = 840; +export const CORNER_SIZE = 105; +export const SPACE_W = 70; +export const BAND_H = 18; + +export const GROUP_COLORS = { + brown: 0x9B5524, + lightblue:0x00B3E8, + pink: 0xD63384, + orange: 0xE77A2C, + red: 0xDC3545, + yellow: 0xE8C12C, + green: 0x1F8C3B, + darkblue: 0x003580, +}; + +export const GROUP_HEX = { + brown: '#9B5524', + lightblue:'#00B3E8', + pink: '#D63384', + orange: '#E77A2C', + red: '#DC3545', + yellow: '#E8C12C', + green: '#1F8C3B', + darkblue: '#003580', +}; + +// 40 spaces, index 0–39, clockwise starting from Go. +// rent[]: [base, monopoly, 1house, 2h, 3h, 4h, hotel] +export const SPACES = [ + { type:'go', name:'GO' }, + { type:'property', name:'Mediterranean Ave', group:'brown', price:60, rent:[2,4,10,30,90,160,250], houseCost:50, mortgage:30 }, + { type:'community_chest', name:'Community Chest' }, + { type:'property', name:'Baltic Ave', group:'brown', price:60, rent:[4,8,20,60,180,320,450], houseCost:50, mortgage:30 }, + { type:'tax', name:'Income Tax', amount:200 }, + { type:'railroad', name:'Reading Railroad', price:200, mortgage:100 }, + { type:'property', name:'Oriental Ave', group:'lightblue',price:100, rent:[6,12,30,90,270,400,550], houseCost:50, mortgage:50 }, + { type:'chance', name:'Chance' }, + { type:'property', name:'Vermont Ave', group:'lightblue',price:100, rent:[6,12,30,90,270,400,550], houseCost:50, mortgage:50 }, + { type:'property', name:'Connecticut Ave', group:'lightblue',price:120, rent:[8,16,40,100,300,450,600], houseCost:50, mortgage:60 }, + { type:'jail', name:'Jail / Just Visiting' }, + { type:'property', name:'St. Charles Place', group:'pink', price:140, rent:[10,20,50,150,450,625,750], houseCost:100, mortgage:70 }, + { type:'utility', name:'Electric Company', price:150, mortgage:75 }, + { type:'property', name:'States Ave', group:'pink', price:140, rent:[10,20,50,150,450,625,750], houseCost:100, mortgage:70 }, + { type:'property', name:'Virginia Ave', group:'pink', price:160, rent:[12,24,60,180,500,700,900], houseCost:100, mortgage:80 }, + { type:'railroad', name:'Pennsylvania Railroad',price:200, mortgage:100 }, + { type:'property', name:'St. James Place', group:'orange', price:180, rent:[14,28,70,200,550,750,950], houseCost:100, mortgage:90 }, + { type:'community_chest', name:'Community Chest' }, + { type:'property', name:'Tennessee Ave', group:'orange', price:180, rent:[14,28,70,200,550,750,950], houseCost:100, mortgage:90 }, + { type:'property', name:'New York Ave', group:'orange', price:200, rent:[16,32,80,220,600,800,1000], houseCost:100, mortgage:100 }, + { type:'freeparking', name:'Free Parking' }, + { type:'property', name:'Kentucky Ave', group:'red', price:220, rent:[18,36,90,250,700,875,1050], houseCost:150, mortgage:110 }, + { type:'chance', name:'Chance' }, + { type:'property', name:'Indiana Ave', group:'red', price:220, rent:[18,36,90,250,700,875,1050], houseCost:150, mortgage:110 }, + { type:'property', name:'Illinois Ave', group:'red', price:240, rent:[20,40,100,300,750,925,1100], houseCost:150, mortgage:120 }, + { type:'railroad', name:'B&O Railroad', price:200, mortgage:100 }, + { type:'property', name:'Atlantic Ave', group:'yellow', price:260, rent:[22,44,110,330,800,975,1150], houseCost:150, mortgage:130 }, + { type:'property', name:'Ventnor Ave', group:'yellow', price:260, rent:[22,44,110,330,800,975,1150], houseCost:150, mortgage:130 }, + { type:'utility', name:'Water Works', price:150, mortgage:75 }, + { type:'property', name:'Marvin Gardens', group:'yellow', price:280, rent:[24,48,120,360,850,1025,1200], houseCost:150, mortgage:140 }, + { type:'gotojail', name:'Go To Jail' }, + { type:'property', name:'Pacific Ave', group:'green', price:300, rent:[26,52,130,390,900,1100,1275], houseCost:200, mortgage:150 }, + { type:'property', name:'North Carolina Ave', group:'green', price:300, rent:[26,52,130,390,900,1100,1275], houseCost:200, mortgage:150 }, + { type:'community_chest', name:'Community Chest' }, + { type:'property', name:'Pennsylvania Ave', group:'green', price:320, rent:[28,56,150,450,1000,1200,1400], houseCost:200, mortgage:160 }, + { type:'railroad', name:'Short Line Railroad', price:200, mortgage:100 }, + { type:'chance', name:'Chance' }, + { type:'property', name:'Park Place', group:'darkblue',price:350, rent:[35,70,175,500,1100,1300,1500], houseCost:200, mortgage:175 }, + { type:'tax', name:'Luxury Tax', amount:100 }, + { type:'property', name:'Boardwalk', group:'darkblue',price:400, rent:[50,100,200,600,1400,1700,2000], houseCost:200, mortgage:200 }, +]; + +export const RAILROADS = [5, 15, 25, 35]; +export const UTILITIES = [12, 28]; +export const PURCHASABLE = SPACES.reduce((a,sp,i) => + (sp.type==='property'||sp.type==='railroad'||sp.type==='utility') ? [...a,i] : a, []); + +export const GROUPS = { + brown: [1, 3], + lightblue:[6, 8, 9], + pink: [11, 13, 14], + orange: [16, 18, 19], + red: [21, 23, 24], + yellow: [26, 27, 29], + green: [31, 32, 34], + darkblue: [37, 39], +}; + +export const PLAYER_COLORS = [0xE53935, 0x1565C0, 0x2E7D32, 0xF57F17]; +export const PLAYER_COLOR_HEX = ['#E53935', '#1565C0', '#2E7D32', '#F57F17']; + +// Chance cards (16) +export const CHANCE_CARDS = [ + { text:'Advance to GO.\nCollect $200.', effect:'advance_to', target:0 }, + { text:'Advance to Illinois Ave.\nIf you pass GO, collect $200.', effect:'advance_to', target:24 }, + { text:'Advance to St. Charles Place.\nIf you pass GO, collect $200.', effect:'advance_to', target:11 }, + { text:'Advance to the nearest Railroad.\nIf unowned, you may buy it.\nIf owned, pay double rent.', effect:'nearest_railroad', doubleRent:true }, + { text:'Advance to the nearest Railroad.\nIf unowned, you may buy it.\nIf owned, pay double rent.', effect:'nearest_railroad', doubleRent:true }, + { text:'Advance to the nearest Utility.\nIf unowned, you may buy it.\nIf owned, pay 10× your dice.', effect:'nearest_utility' }, + { text:'Bank pays you a dividend of $50.', effect:'collect', amount:50 }, + { text:'Get Out of Jail Free.\nKeep this card until needed.', effect:'goojf' }, + { text:'Go back 3 spaces.', effect:'back3' }, + { text:'Go directly to Jail.\nDo not pass GO. Do not collect $200.', effect:'go_to_jail' }, + { text:'Make general repairs on all your property.\n$25 per house · $100 per hotel.', effect:'repairs', house:25, hotel:100 }, + { text:'Pay a poor tax of $15.', effect:'pay', amount:15 }, + { text:'Take a trip to Reading Railroad.\nIf you pass GO, collect $200.', effect:'advance_to', target:5 }, + { text:'Advance to Boardwalk.', effect:'advance_to', target:39 }, + { text:'You have been elected Chairman of the Board.\nPay each player $50.', effect:'pay_each', amount:50 }, + { text:'Your building and loan matures.\nCollect $150.', effect:'collect', amount:150 }, +]; + +// Community Chest cards (16) +export const CC_CARDS = [ + { text:'Advance to GO.\nCollect $200.', effect:'advance_to', target:0 }, + { text:'Bank error in your favor.\nCollect $200.', effect:'collect', amount:200 }, + { text:"Doctor's fee.\nPay $50.", effect:'pay', amount:50 }, + { text:'From sale of stock you get $50.', effect:'collect', amount:50 }, + { text:'Get Out of Jail Free.\nKeep this card until needed.', effect:'goojf' }, + { text:'Go directly to Jail.\nDo not pass GO. Do not collect $200.', effect:'go_to_jail' }, + { text:'Grand Opera Night.\nCollect $50 from every player.', effect:'collect_each', amount:50 }, + { text:'Holiday fund matures.\nCollect $100.', effect:'collect', amount:100 }, + { text:'Income tax refund.\nCollect $20.', effect:'collect', amount:20 }, + { text:'It is your birthday!\nCollect $10 from every player.', effect:'collect_each', amount:10 }, + { text:'Life insurance matures.\nCollect $100.', effect:'collect', amount:100 }, + { text:'Pay hospital fees of $100.', effect:'pay', amount:100 }, + { text:'Pay school fees of $150.', effect:'pay', amount:150 }, + { text:'Receive $25 consultancy fee.', effect:'collect', amount:25 }, + { text:'You are assessed for street repairs.\n$40 per house · $115 per hotel.', effect:'repairs', house:40, hotel:115 }, + { text:'You have won second prize in a beauty contest.\nCollect $10.', effect:'collect', amount:10 }, +]; + +// Board-relative space geometry (add boardLeft, boardTop in scene) +export function spaceGeometry(idx) { + const C = CORNER_SIZE, W = SPACE_W, S = BOARD_SIZE; + if (idx === 0) return { x:S-C, y:S-C, w:C, h:C, isCorner:true }; + if (idx === 10) return { x:0, y:S-C, w:C, h:C, isCorner:true }; + if (idx === 20) return { x:0, y:0, w:C, h:C, isCorner:true }; + if (idx === 30) return { x:S-C, y:0, w:C, h:C, isCorner:true }; + if (idx >= 1 && idx <= 9) return { x:S-C-W*idx, y:S-C, w:W, h:C, bandEdge:'top', rotation:0 }; + if (idx >= 11 && idx <= 19) return { x:0, y:S-C-W*(idx-10), w:C, h:W, bandEdge:'right', rotation:-Math.PI/2 }; + if (idx >= 21 && idx <= 29) return { x:C+W*(idx-21), y:0, w:W, h:C, bandEdge:'bottom', rotation:Math.PI }; + if (idx >= 31 && idx <= 39) return { x:S-C, y:C+W*(idx-31),w:C, h:W, bandEdge:'left', rotation:Math.PI/2 }; +} + +export function spaceCenter(idx) { + const g = spaceGeometry(idx); + return { x: g.x + g.w / 2, y: g.y + g.h / 2 }; +} + +export function nearestRailroad(pos) { + for (const r of RAILROADS) if (r > pos) return r; + return RAILROADS[0]; +} + +export function nearestUtility(pos) { + for (const u of UTILITIES) if (u > pos) return u; + return UTILITIES[0]; +} + +// Pawn spritesheet: frame = seat index (0-3), 80×80 px cells +export const PAWN_FRAME = (seat) => seat; +// Card spritesheet: frame 0 = Chance, frame 1 = Community Chest, 200×300 px cells +export const CARD_FRAME = { chance: 0, community_chest: 1 }; diff --git a/public/src/games/monopoly/MonopolyGame.js b/public/src/games/monopoly/MonopolyGame.js new file mode 100644 index 0000000..cfbb434 --- /dev/null +++ b/public/src/games/monopoly/MonopolyGame.js @@ -0,0 +1,1286 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { auth } from '../../services/auth.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { + SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS, GROUP_COLORS, GROUP_HEX, + PLAYER_COLORS, PLAYER_COLOR_HEX, BAND_H, CORNER_SIZE, SPACE_W, BOARD_SIZE, + CHANCE_CARDS, CC_CARDS, CARD_FRAME, PAWN_FRAME, + spaceGeometry, spaceCenter, +} from './MonopolyData.js'; +import { + createInitialState, rollDice, resolveSpace, buyProperty, declineProperty, + placeBid, passAuction, buildHouse, buildHotel, sellHouse, sellHotel, + mortgageProperty, unmortgageProperty, payJailFine, useJailCard, + applyCardEffect, endTurn, checkGameOver, calculateRent, + canBuildHouse, canBuildHotel, ownsGroup, netWorth, +} from './MonopolyLogic.js'; +import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay } from './MonopolyAI.js'; + +// ── Layout ──────────────────────────────────────────────────────────────────── +const BL = 30; // board left +const BT = 120; // board top +const BS = BOARD_SIZE; // 840 + +// Right panel +const RP_X = BL + BS + 50; // 920 +const RP_W = GAME_WIDTH - RP_X - 20; // ~980 + +// Depth +const DEPTH = { bg:0, board:5, band:6, text:7, houses:10, pawns:15, ui:25, popup:50, banner:90 }; + +// Pip positions for each die face (relative to die center) +const PIPS = { + 1: [[0,0]], + 2: [[-1,-1],[1,1]], + 3: [[-1,-1],[0,0],[1,1]], + 4: [[-1,-1],[1,-1],[-1,1],[1,1]], + 5: [[-1,-1],[1,-1],[0,0],[-1,1],[1,1]], + 6: [[-1,-1],[1,-1],[-1,0],[1,0],[-1,1],[1,1]], +}; + +export default class MonopolyGame extends Phaser.Scene { + constructor() { super('MonopolyGame'); } + + init(data) { + this.gameDef = data.game; + this.opponents = data.opponents ?? []; + this.playfield = data.playfield ?? null; + this.humanSeat = 0; + this.gs = null; + this.busy = false; + this.dyn = []; + this.portraits = []; + this.pawns = {}; // seat → image/circle + this.dieGfx = []; // [die1Graphics, die2Graphics] + this.dieVals = [1,1]; + this.cardPopup = null; // popup container + this.bidInput = 0; // human bid amount for auction + } + + create() { + try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* */ } + this.hasPawns = this.textures.exists('monopoly-pawns'); + this.hasCards = this.textures.exists('monopoly-cards'); + + const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length)); + this.skillBySeat = {}; + const names = []; + for (let seat = 0; seat < playerCount; seat++) { + if (seat === this.humanSeat) { + names.push(auth.user?.username ?? 'You'); + this.skillBySeat[seat] = 5; + } else { + const opp = this.opponents[seat - 1]; + names.push(opp?.name ?? `Player ${seat + 1}`); + this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3)); + } + } + + this.gs = createInitialState({ playerCount, names }); + + this.buildBackground(); + this.buildBoard(); + this.buildPawns(); + this.buildDiceDisplay(); + this.buildPortraits(); + + new Button(this, GAME_WIDTH - 80, GAME_HEIGHT - 36, 'Leave', + () => this.scene.start('GameMenu'), + { variant:'ghost', width:120, height:40, fontSize:18 }).setDepth(DEPTH.ui); + + this.render(); + this.advance(); + } + + // ── Background ────────────────────────────────────────────────────────────── + buildBackground() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH/2, GAME_HEIGHT/2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg); + } else { + const g = this.add.graphics().setDepth(DEPTH.bg); + g.fillGradientStyle(0x1a1508, 0x1a1508, 0x0a0805, 0x0a0805, 1); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + } + this.add.text(BL + BS/2, 60, 'Monopoly', { + fontFamily:'Righteous', fontSize:'52px', color:'#E8C12C', + }).setOrigin(0.5).setDepth(DEPTH.ui); + } + + // ── Static Board ──────────────────────────────────────────────────────────── + buildBoard() { + const g = this.add.graphics().setDepth(DEPTH.board); + // Outer board background + g.fillStyle(0xFFF8E7, 1); + g.fillRect(BL, BT, BS, BS); + g.lineStyle(3, 0x2c1810, 1); + g.strokeRect(BL, BT, BS, BS); + + // Center area + const cx = BL + CORNER_SIZE; + const cy = BT + CORNER_SIZE; + const cw = BS - 2 * CORNER_SIZE; + g.fillStyle(0xFFF0D0, 1); + g.fillRect(cx, cy, cw, cw); + + // Center MONOPOLY logo + this.add.text(BL + BS/2, BT + BS/2 - 30, 'MONOPOLY', { + fontFamily:'Righteous', fontSize:'52px', color:'#B71C1C', stroke:'#7f1010', strokeThickness:3, + }).setOrigin(0.5).setDepth(DEPTH.text); + this.add.text(BL + BS/2, BT + BS/2 + 32, '🎩 THE CLASSIC BOARD GAME', { + fontFamily:'"Julius Sans One"', fontSize:'14px', color:'#555544', + }).setOrigin(0.5).setDepth(DEPTH.text); + + // Draw all 40 spaces + for (let i = 0; i < 40; i++) this.drawBoardSpace(g, i); + } + + drawBoardSpace(g, idx) { + const geo = spaceGeometry(idx); + const bx = BL + geo.x, by = BT + geo.y; + const sp = SPACES[idx]; + + // Space background + g.fillStyle(0xFFF8E7, 1); + g.fillRect(bx, by, geo.w, geo.h); + g.lineStyle(1, 0x2c1810, 1); + g.strokeRect(bx, by, geo.w, geo.h); + + if (geo.isCorner) { + this.drawCornerSpace(g, idx, bx, by, geo.w, geo.h); + return; + } + + // Color band for properties + if (sp.group && GROUP_COLORS[sp.group]) { + const col = GROUP_COLORS[sp.group]; + g.fillStyle(col, 1); + switch (geo.bandEdge) { + case 'top': g.fillRect(bx, by, geo.w, BAND_H); break; + case 'bottom': g.fillRect(bx, by + geo.h - BAND_H, geo.w, BAND_H); break; + case 'left': g.fillRect(bx, by, BAND_H, geo.h); break; + case 'right': g.fillRect(bx + geo.w - BAND_H, by, BAND_H, geo.h); break; + } + g.lineStyle(1, 0x2c1810, 1); + switch (geo.bandEdge) { + case 'top': g.strokeRect(bx, by, geo.w, BAND_H); break; + case 'bottom': g.strokeRect(bx, by + geo.h - BAND_H, geo.w, BAND_H); break; + case 'left': g.strokeRect(bx, by, BAND_H, geo.h); break; + case 'right': g.strokeRect(bx + geo.w - BAND_H, by, BAND_H, geo.h); break; + } + } + + // Space name + const cx = bx + geo.w / 2; + const cy = by + geo.h / 2; + const ww = geo.rotation === 0 || geo.rotation === Math.PI ? geo.w - 6 : geo.h - 6; + const nameText = this.add.text(cx, cy, sp.name, { + fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1a1208', + align:'center', wordWrap:{ width: ww, useAdvancedWrap:true }, + }).setOrigin(0.5).setRotation(geo.rotation).setDepth(DEPTH.text); + + // Price/amount below name + let sub = ''; + if (sp.type === 'property') sub = `$${sp.price}`; + else if (sp.type === 'railroad') sub = `$${sp.price}`; + else if (sp.type === 'utility') sub = `$${sp.price}`; + else if (sp.type === 'tax') sub = `$${sp.amount}`; + + if (sub) { + // Offset price below name, accounting for rotation + const offsetAlong = geo.rotation === 0 ? { x:0, y:20 } + : geo.rotation === Math.PI ? { x:0, y:-20 } + : geo.rotation === Math.PI/2 ? { x:-20, y:0 } + : { x:20, y:0 }; + this.add.text(cx + offsetAlong.x, cy + offsetAlong.y, sub, { + fontFamily:'"Julius Sans One"', fontSize:'7px', color:'#444433', + }).setOrigin(0.5).setRotation(geo.rotation).setDepth(DEPTH.text); + } + + // Railroad indicator + if (sp.type === 'railroad') { + const g2 = this.add.graphics().setDepth(DEPTH.text); + g2.fillStyle(0x1a1208, 1); + // Small locomotive silhouette: just a rounded rect + const rw = 20, rh = 12; + g2.fillRoundedRect(cx - rw/2, cy - 14 - rh/2, rw, rh, 3); + g2.fillRect(cx - 8, cy - 14 + rh/2, 16, 4); + } + + // Utility indicator + if (sp.type === 'utility') { + const g2 = this.add.graphics().setDepth(DEPTH.text); + const isElectric = idx === 12; + g2.fillStyle(isElectric ? 0xFFD700 : 0x1565C0, 1); + g2.fillCircle(cx, cy - 12, 9); + g2.lineStyle(2, 0x1a1208, 1); + g2.strokeCircle(cx, cy - 12, 9); + } + } + + drawCornerSpace(g, idx, bx, by, w, h) { + const mid = { x: bx + w/2, y: by + h/2 }; + switch (idx) { + case 0: { // Go + g.fillStyle(0x1B5E20, 1); + g.fillRect(bx, by, w, 3); + g.fillRect(bx, by, 3, h); + this.add.text(bx + w/2, by + h/2 - 10, 'GO', { + fontFamily:'Righteous', fontSize:'24px', color:'#B71C1C', + }).setOrigin(0.5).setDepth(DEPTH.text); + this.add.text(bx + w/2, by + h/2 + 16, 'COLLECT\n$200 SALARY', { + fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1B5E20', align:'center', + }).setOrigin(0.5).setDepth(DEPTH.text); + // Arrow + const ag = this.add.graphics().setDepth(DEPTH.text); + ag.fillStyle(0x1B5E20, 1); + ag.fillTriangle(bx+14, by+h-14, bx+28, by+h-28, bx+28, by+h-14); + break; + } + case 10: { // Jail + // Just Visiting bar + g.fillStyle(0xE8C12C, 1); + g.fillRect(bx+3, by+3, w-6, 6); + this.add.text(bx + w/2, by + h*0.3, 'JUST\nVISITING', { + fontFamily:'"Julius Sans One"', fontSize:'8px', color:'#1a1208', align:'center', + }).setOrigin(0.5).setDepth(DEPTH.text); + // Jail bars + const jg = this.add.graphics().setDepth(DEPTH.text); + jg.lineStyle(2, 0x555544, 1); + for (let bar = 0; bar < 4; bar++) { + const bx2 = bx + 14 + bar * 14; + jg.lineBetween(bx2, by + h*0.5, bx2, by + h*0.85); + } + jg.lineBetween(bx+10, by+h*0.5, bx+66, by+h*0.5); + jg.lineBetween(bx+10, by+h*0.85, bx+66, by+h*0.85); + this.add.text(bx + w/2, by + h*0.7, 'JAIL', { + fontFamily:'Righteous', fontSize:'14px', color:'#E53935', + }).setOrigin(0.5).setDepth(DEPTH.text + 1); + break; + } + case 20: { // Free Parking + this.add.text(bx + w/2, by + h/2 - 14, 'FREE', { + fontFamily:'Righteous', fontSize:'18px', color:'#E77A2C', + }).setOrigin(0.5).setDepth(DEPTH.text); + this.add.text(bx + w/2, by + h/2 + 4, 'PARKING', { + fontFamily:'Righteous', fontSize:'13px', color:'#E77A2C', + }).setOrigin(0.5).setDepth(DEPTH.text); + // Car icon + const cg = this.add.graphics().setDepth(DEPTH.text); + cg.fillStyle(0x1565C0, 1); + cg.fillRoundedRect(bx+22, by+h-30, 60, 16, 4); + cg.fillRoundedRect(bx+30, by+h-42, 44, 14, 4); + cg.fillStyle(0x1a1208, 1); + cg.fillCircle(bx+32, by+h-14, 5); + cg.fillCircle(bx+72, by+h-14, 5); + break; + } + case 30: { // Go to Jail + g.fillStyle(0xE53935, 1); + g.fillRect(bx, by+h-3, w, 3); + g.fillRect(bx+w-3, by, 3, h); + this.add.text(bx + w/2, by + h/2 - 20, 'GO TO\nJAIL', { + fontFamily:'Righteous', fontSize:'16px', color:'#E53935', align:'center', + }).setOrigin(0.5).setDepth(DEPTH.text); + // Police badge + const pg = this.add.graphics().setDepth(DEPTH.text); + pg.fillStyle(0xE8C12C, 1); + pg.fillCircle(bx + w/2, by + h/2 + 20, 18); + pg.lineStyle(2, 0x1a1208, 1); + pg.strokeCircle(bx + w/2, by + h/2 + 20, 18); + this.add.text(bx + w/2, by + h/2 + 20, '🚔', { fontSize:'18px' }).setOrigin(0.5).setDepth(DEPTH.text+1); + break; + } + } + } + + // ── Pawns (created once, positioned dynamically) ──────────────────────────── + buildPawns() { + for (let seat = 0; seat < this.gs.playerCount; seat++) { + const { x, y } = this.spacePxCenter(0); // start at Go + let pawn; + if (this.hasPawns) { + pawn = this.add.image(x, y, 'monopoly-pawns', PAWN_FRAME(seat)) + .setDisplaySize(32, 32).setDepth(DEPTH.pawns); + } else { + const g = this.add.graphics().setDepth(DEPTH.pawns); + g.fillStyle(PLAYER_COLORS[seat], 1); + g.fillCircle(0, 0, 12); + g.lineStyle(2, 0xffffff, 0.8); + g.strokeCircle(0, 0, 12); + g.x = x; g.y = y; + pawn = g; + } + this.pawns[seat] = pawn; + } + } + + // ── Dice Display (created once in right panel) ────────────────────────────── + buildDiceDisplay() { + const dx = RP_X + RP_W/2 - 55; + const dy = BT + this.playerPanelTotalH() + 30; + this.diceY = dy; + this.dieGfx = [ + this.add.graphics().setDepth(DEPTH.ui), + this.add.graphics().setDepth(DEPTH.ui), + ]; + this.drawDie(0, dx, dy, 1); + this.drawDie(1, dx + 84, dy, 1); + } + + playerPanelTotalH() { + const n = this.gs.playerCount; + const rows = Math.ceil(n / 2); + return rows * 190 + (rows - 1) * 12 + 20; + } + + drawDie(idx, cx, cy, value) { + const g = this.dieGfx[idx]; + const size = 66; + const half = size / 2; + g.clear(); + g.fillStyle(0xFFF8E7, 1); + g.fillRoundedRect(cx - half, cy - half, size, size, 10); + g.lineStyle(2, 0x4A3728, 1); + g.strokeRoundedRect(cx - half, cy - half, size, size, 10); + // Pips + g.fillStyle(0x1a1208, 1); + const pipR = 5; + const step = 18; + const pips = PIPS[value] ?? PIPS[1]; + for (const [px, py] of pips) { + g.fillCircle(cx + px * step, cy + py * step, pipR); + } + this.dieGfx[idx] = g; + // Store die positions for later re-draw + if (!this.diePositions) this.diePositions = []; + this.diePositions[idx] = { cx, cy }; + } + + // ── Portraits ────────────────────────────────────────────────────────────── + buildPortraits() { + const n = this.gs.playerCount; + for (let seat = 0; seat < n; seat++) { + const { px, py } = this.panelPos(seat); + const portraitR = 28; + if (seat === this.humanSeat) { + this.portraits[seat] = createPlayerPortrait(this, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1, 'MonopolyGame'); + } else { + const opp = this.opponents[seat - 1]; + this.portraits[seat] = createOpponentPortrait(this, opp, px + 16 + portraitR, py + 16 + portraitR, portraitR, DEPTH.ui+1); + } + } + } + + panelPos(seat) { + const col = seat % 2; + const row = Math.floor(seat / 2); + const panelW = this.gs.playerCount <= 2 ? RP_W - 10 : Math.floor((RP_W - 10) / 2); + const px = RP_X + col * (panelW + 10); + const py = BT + row * (190 + 12); + return { px, py, panelW, panelH: 182 }; + } + + // ── Dynamic Render ───────────────────────────────────────────────────────── + reg(o) { this.dyn.push(o); return o; } + clearDyn() { this.dyn.forEach(o => { try { o.destroy(); } catch {} }); this.dyn = []; } + + render() { + this.clearDyn(); + this.drawHousesHotels(); + this.positionPawns(); + this.drawPlayerPanels(); + this.drawActionBar(); + if (this.gs.pendingCard) this.drawCardPopup(); + if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel(); + } + + 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) { + // Owner color dot in top corner + const geo = spaceGeometry(idx); + const bx = BL + geo.x, by = BT + geo.y; + g.fillStyle(PLAYER_COLORS[own.owner], 1); + g.fillCircle(bx + geo.w - 7, by + 7, 5); + } + if (own.hotel) { + this.drawHotelOnSpace(g, idx); + } else if (own.houses > 0) { + this.drawHousesOnSpace(g, idx, own.houses); + } + } + } + + drawHousesOnSpace(g, idx, count) { + const geo = spaceGeometry(idx); + const bx = BL + geo.x, by = BT + geo.y; + const hw = 10, hh = 12; + const totalW = count * hw + (count - 1) * 2; + let sx, sy; + switch (geo.bandEdge) { + case 'top': sx = bx + (geo.w - totalW)/2; sy = by + geo.h - hh - 3; break; + case 'bottom': sx = bx + (geo.w - totalW)/2; sy = by + 3; break; + case 'left': sx = bx + geo.w - hh - 3; sy = by + (geo.h - totalW)/2; break; + case 'right': sx = bx + 3; sy = by + (geo.h - totalW)/2; break; + default: sx = bx + 4; sy = by + geo.h - hh - 3; + } + g.fillStyle(0x1B5E20, 1); + g.lineStyle(1, 0xffffff, 0.8); + for (let i = 0; i < count; i++) { + if (geo.bandEdge === 'left' || geo.bandEdge === 'right') { + g.fillRect(sx, sy + i * (hw+2), hh, hw); + g.strokeRect(sx, sy + i * (hw+2), hh, hw); + } else { + g.fillRect(sx + i * (hw+2), sy, hw, hh); + g.strokeRect(sx + i * (hw+2), sy, hw, hh); + } + } + } + + drawHotelOnSpace(g, idx) { + const geo = spaceGeometry(idx); + const bx = BL + geo.x, by = BT + geo.y; + const hw = 20, hh = 14; + let hx, hy; + switch (geo.bandEdge) { + case 'top': hx = bx + (geo.w - hw)/2; hy = by + geo.h - hh - 3; break; + case 'bottom': hx = bx + (geo.w - hw)/2; hy = by + 3; break; + case 'left': hx = bx + geo.w - hh - 3; hy = by + (geo.h - hw)/2; break; + case 'right': hx = bx + 3; hy = by + (geo.h - hw)/2; break; + default: hx = bx + 4; hy = by + geo.h - hh - 3; + } + g.fillStyle(0xB71C1C, 1); + g.lineStyle(1, 0xffffff, 0.8); + if (geo.bandEdge === 'left' || geo.bandEdge === 'right') { + g.fillRect(hx, hy, hh, hw); + g.strokeRect(hx, hy, hh, hw); + } else { + g.fillRect(hx, hy, hw, hh); + g.strokeRect(hx, hy, hw, hh); + } + } + + positionPawns() { + const gs = this.gs; + const seated = {}; // position → count of seated players + for (let seat = 0; seat < gs.playerCount; seat++) { + if (gs.players[seat].bankrupt) { + if (this.pawns[seat]) { try { this.pawns[seat].setVisible(false); } catch {} } + continue; + } + const pos = gs.players[seat].position; + seated[pos] = (seated[pos] ?? 0) + 1; + } + const placed = {}; + for (let seat = 0; seat < gs.playerCount; seat++) { + if (gs.players[seat].bankrupt) continue; + const pos = gs.players[seat].position; + const { x, y } = this.spacePxCenter(pos); + const n = seated[pos] ?? 1; + const i = placed[pos] ?? 0; + placed[pos] = i + 1; + const offsets = this.pawnOffsets(n); + const pawn = this.pawns[seat]; + if (!pawn) continue; + try { + pawn.setVisible(true); + if (typeof pawn.setPosition === 'function') pawn.setPosition(x + offsets[i].x, y + offsets[i].y); + else { pawn.x = x + offsets[i].x; pawn.y = y + offsets[i].y; } + } catch {} + } + } + + pawnOffsets(n) { + const offsets = [ + [{x:0,y:0}], + [{x:-8,y:0},{x:8,y:0}], + [{x:-8,y:-6},{x:8,y:-6},{x:0,y:8}], + [{x:-8,y:-6},{x:8,y:-6},{x:-8,y:8},{x:8,y:8}], + ]; + return offsets[Math.min(n,4) - 1] ?? offsets[0]; + } + + spacePxCenter(idx) { + const c = spaceCenter(idx); + return { x: BL + c.x, y: BT + c.y }; + } + + // ── Player Panels ────────────────────────────────────────────────────────── + drawPlayerPanels() { + const n = this.gs.playerCount; + for (let seat = 0; seat < n; seat++) { + this.drawOnePanel(seat); + } + } + + drawOnePanel(seat) { + const { px, py, panelW, panelH } = this.panelPos(seat); + const p = this.gs.players[seat]; + const isCurrent = this.gs.current === seat && this.gs.phase !== 'gameover'; + const g = this.reg(this.add.graphics().setDepth(DEPTH.ui)); + + // Panel background + const bg = isCurrent ? 0x2a2010 : 0x1e1a12; + g.fillStyle(bg, 1); + g.fillRoundedRect(px, py, panelW, panelH, 8); + g.lineStyle(2, isCurrent ? COLORS.gold : COLORS.accent, isCurrent ? 1 : 0.5); + g.strokeRoundedRect(px, py, panelW, panelH, 8); + + if (p.bankrupt) { + this.reg(this.add.text(px + panelW/2, py + panelH/2, 'BANKRUPT', { + fontFamily:'Righteous', fontSize:'22px', color:COLORS.dangerHex, + }).setOrigin(0.5).setDepth(DEPTH.ui+1)); + return; + } + + // Name + const nameColor = isCurrent ? COLORS.goldHex : COLORS.textHex; + this.reg(this.add.text(px + 72, py + 14, p.name, { + fontFamily:'Righteous', fontSize:'17px', color: nameColor, + }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); + + // Cash + this.reg(this.add.text(px + 72, py + 36, `$${p.cash.toLocaleString()}`, { + fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f', + }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); + + // Net worth + const nw = netWorth(this.gs, seat); + this.reg(this.add.text(px + 72, py + 56, `Net: $${nw.toLocaleString()}`, { + fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex, + }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); + + // Jail indicator + if (p.jailed) { + this.reg(this.add.text(px + 72, py + 74, '🔒 In Jail', { + fontFamily:'"Julius Sans One"', fontSize:'12px', color:COLORS.dangerHex, + }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); + } + + // GOOJF card indicator + if (p.getOutOfJailFree > 0) { + this.reg(this.add.text(px + 72, py + (p.jailed ? 90 : 74), `🎴 ×${p.getOutOfJailFree}`, { + fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#aaccaa', + }).setOrigin(0, 0).setDepth(DEPTH.ui+1)); + } + + // Property color swatches + let sx = px + 72, sy = py + panelH - 26; + for (const [group, idxArr] of Object.entries(GROUPS)) { + const owned = idxArr.filter(i => this.gs.board[i]?.owner === seat).length; + if (owned === 0) continue; + const hasAll = owned === idxArr.length; + g.fillStyle(GROUP_COLORS[group], hasAll ? 1 : 0.4); + g.fillRoundedRect(sx, sy, 16, 14, 3); + g.lineStyle(1, 0xffffff, 0.5); + g.strokeRoundedRect(sx, sy, 16, 14, 3); + sx += 20; + } + // Railroads + const rrOwned = RAILROADS.filter(i => this.gs.board[i]?.owner === seat).length; + if (rrOwned > 0) { + this.reg(this.add.text(sx, sy + 2, `🚂×${rrOwned}`, { + fontFamily:'"Julius Sans One"', fontSize:'11px', color:COLORS.mutedHex, + }).setOrigin(0,0).setDepth(DEPTH.ui+1)); + sx += 40; + } + } + + // ── Action Bar ───────────────────────────────────────────────────────────── + drawActionBar() { + const gs = this.gs; + if (gs.phase === 'gameover') return; + + // Dice values display (update) + const diceX = RP_X + RP_W/2 - 55; + if (gs.diceRoll) { + this.drawDie(0, diceX, this.diceY, gs.diceRoll[0]); + this.drawDie(1, diceX + 84, this.diceY, gs.diceRoll[1]); + } + + // Buttons only for human's turn + const isHumanTurn = gs.current === this.humanSeat; + const inAuction = gs.phase === 'auction' && gs.pendingAuction; + const auctionIsHuman = inAuction && + gs.pendingAuction.bidOrder[gs.pendingAuction.currentBidderIdx] === this.humanSeat; + + if (!isHumanTurn && !auctionIsHuman) return; + if (inAuction) return; // auction panel handles its own buttons + + const btnY0 = this.diceY + 56; + const btnW = RP_W - 20; + let yOff = 0; + const mkBtn = (label, cb, enabled=true, opts={}) => { + const btn = new Button(this, RP_X + btnW/2 + 10, btnY0 + yOff, label, cb, + { width: btnW, height: 52, fontSize: 22, ...opts }); + btn.setDepth(DEPTH.ui); + if (!enabled) btn.setEnabled(false); + this.reg(btn); + yOff += 62; + }; + + const p = gs.players[this.humanSeat]; + const phase = gs.phase; + + if (phase === 'preroll' || phase === 'endturn') { + if (phase === 'preroll') { + if (p.jailed) { + if (p.getOutOfJailFree > 0) { + mkBtn('Use GOOJF Card', () => this.onUseJailCard()); + } + mkBtn('Pay $50 Fine', () => this.onPayJailFine(), p.cash >= 50); + mkBtn('Roll Dice', () => this.onRollDice()); + } else { + mkBtn('Roll Dice', () => this.onRollDice()); + } + } + if (phase === 'endturn') { + mkBtn('End Turn', () => this.onEndTurn()); + } + // Build options + const canBuild = PURCHASABLE.some(idx => + canBuildHouse(gs, this.humanSeat, idx) || canBuildHotel(gs, this.humanSeat, idx)); + if (canBuild) { + mkBtn('Build Houses / Hotels', () => this.showBuildMenu(), true, { variant:'ghost' }); + } + // Mortgage options + const canMortgage = PURCHASABLE.some(idx => { + const own = gs.board[idx]; + return own?.owner === this.humanSeat && !own.mortgaged && own.houses === 0 && !own.hotel; + }); + const canUnmortgage = PURCHASABLE.some(idx => { + const own = gs.board[idx]; + return own?.owner === this.humanSeat && own.mortgaged && + p.cash >= Math.ceil(SPACES[idx].mortgage * 1.1); + }); + if (canMortgage || canUnmortgage) { + mkBtn('Mortgage / Unmortgage', () => this.showMortgageMenu(), true, { variant:'ghost' }); + } + } + + if (phase === 'buy' && gs.pendingBuy) { + const sp = SPACES[gs.pendingBuy.spaceIdx]; + mkBtn(`Buy ${sp.name}\n$${sp.price}`, () => this.onBuyProperty(), p.cash >= sp.price); + mkBtn('Decline (Auction)', () => this.onDeclineProperty(), true, { variant:'ghost' }); + } + + if (phase === 'card' && gs.pendingCard && gs.current === this.humanSeat) { + mkBtn('OK', () => this.onDismissCard()); + } + + if (phase === 'jailChoice') { + // Jail handling is in preroll above + } + } + + // ── Card Popup ───────────────────────────────────────────────────────────── + drawCardPopup() { + if (!this.gs.pendingCard) return; + const { cardType, text } = this.gs.pendingCard; + const isChance = cardType === 'chance'; + const pw = 360, ph = 480; + const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; + + // Overlay + const overlay = this.reg(this.add.graphics().setDepth(DEPTH.popup - 1)); + overlay.fillStyle(0x000000, 0.6); + overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + + // Card background + const g = this.reg(this.add.graphics().setDepth(DEPTH.popup)); + const cardColor = isChance ? 0xE77A2C : 0x1565C0; + g.fillStyle(cardColor, 1); + g.fillRoundedRect(px, py, pw, ph, 16); + g.lineStyle(4, 0xFFF8E7, 1); + g.strokeRoundedRect(px, py, pw, ph, 16); + + if (this.hasCards) { + const frame = isChance ? CARD_FRAME.chance : CARD_FRAME.community_chest; + this.reg(this.add.image(px + pw/2, py + 120, 'monopoly-cards', frame) + .setDisplaySize(pw - 20, 220).setDepth(DEPTH.popup)); + } else { + // Fallback art + const ag = this.reg(this.add.graphics().setDepth(DEPTH.popup)); + ag.fillStyle(0xffffff, 0.15); + ag.fillRoundedRect(px + 10, py + 10, pw - 20, 210, 12); + this.reg(this.add.text(px + pw/2, py + 110, isChance ? '?' : '📦', { + fontFamily:'Righteous', fontSize:'80px', color:'#ffffff', + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + } + + this.reg(this.add.text(px + pw/2, py + 30, isChance ? 'CHANCE' : 'COMMUNITY CHEST', { + fontFamily:'Righteous', fontSize:'18px', color:'#FFF8E7', + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + + this.reg(this.add.text(px + pw/2, py + 250, text, { + fontFamily:'"Julius Sans One"', fontSize:'18px', color:'#FFF8E7', + align:'center', wordWrap:{ width: pw - 30 }, + }).setOrigin(0.5, 0).setDepth(DEPTH.popup+1)); + } + + // ── Auction Panel ────────────────────────────────────────────────────────── + drawAuctionPanel() { + const auc = this.gs.pendingAuction; + const sp = SPACES[auc.spaceIdx]; + const bidderSeat = auc.bidOrder[auc.currentBidderIdx]; + const isHuman = bidderSeat === this.humanSeat; + + const pw = RP_W - 20, ph = 340; + const px = RP_X + 10, py = GAME_HEIGHT/2 - ph/2; + + const g = this.reg(this.add.graphics().setDepth(DEPTH.popup)); + g.fillStyle(0x1e1a12, 1); + g.fillRoundedRect(px, py, pw, ph, 12); + g.lineStyle(2, COLORS.gold, 1); + g.strokeRoundedRect(px, py, pw, ph, 12); + + this.reg(this.add.text(px + pw/2, py + 20, 'AUCTION', { + fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + + // Property color band + if (sp.group) { + const bg2 = this.reg(this.add.graphics().setDepth(DEPTH.popup)); + bg2.fillStyle(GROUP_COLORS[sp.group] ?? COLORS.accent, 1); + bg2.fillRect(px + 20, py + 55, pw - 40, 22); + } + this.reg(this.add.text(px + pw/2, py + 66, sp.name, { + fontFamily:'Righteous', fontSize:'18px', color:'#FFF8E7', + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + + this.reg(this.add.text(px + pw/2, py + 100, `List Price: $${sp.price}`, { + fontFamily:'"Julius Sans One"', fontSize:'15px', color:COLORS.mutedHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + + const highBidText = auc.highBid > 0 + ? `High bid: $${auc.highBid} (${this.gs.players[auc.highBidder].name})` + : 'No bids yet'; + this.reg(this.add.text(px + pw/2, py + 128, highBidText, { + fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#aaddaa', + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + + const bidderName = this.gs.players[bidderSeat]?.name ?? '?'; + this.reg(this.add.text(px + pw/2, py + 156, `${bidderName}'s turn to bid`, { + fontFamily:'"Julius Sans One"', fontSize:'14px', color:COLORS.textHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + + if (isHuman) { + // Bid controls + const minBid = auc.highBid + 1; + if (this.bidInput < minBid) this.bidInput = minBid; + const phuman = this.gs.players[this.humanSeat]; + if (this.bidInput > phuman.cash) this.bidInput = phuman.cash; + + this.reg(this.add.text(px + pw/2, py + 188, `Your bid: $${this.bidInput}`, { + fontFamily:'Righteous', fontSize:'22px', color:COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + + const btnH = 44, btnGap = 10; + // -50, -10, +10, +50 buttons + const nudgeValues = [[-50, '−50'], [-10, '−10'], [+10, '+10'], [+50, '+50']]; + const nudgeBtnW = (pw - 50) / 4; + nudgeValues.forEach(([delta, label], i) => { + const nbx = px + 20 + i * (nudgeBtnW + 4); + const btn = new Button(this, nbx + nudgeBtnW/2, py + 230, label, () => { + this.bidInput = Math.max(minBid, Math.min(phuman.cash, this.bidInput + delta)); + this.render(); + }, { width: nudgeBtnW, height: 38, fontSize: 16 }); + btn.setDepth(DEPTH.popup+2); + this.reg(btn); + }); + + const bidBtn = new Button(this, px + pw/2 - 80, py + 288, 'BID', () => { + if (this.bidInput >= minBid && this.bidInput <= phuman.cash) { + this.gs = placeBid(this.gs, this.humanSeat, this.bidInput); + this.bidInput = 0; + this.render(); + this.advance(); + } + }, { width: 130, height: btnH, fontSize: 20 }); + bidBtn.setDepth(DEPTH.popup+2); + this.reg(bidBtn); + + const passBtn = new Button(this, px + pw/2 + 80, py + 288, 'PASS', () => { + this.gs = passAuction(this.gs, this.humanSeat); + this.bidInput = 0; + this.render(); + this.advance(); + }, { width: 130, height: btnH, fontSize: 20, variant:'ghost' }); + passBtn.setDepth(DEPTH.popup+2); + this.reg(passBtn); + } else { + this.reg(this.add.text(px + pw/2, py + 230, 'Waiting for AI…', { + fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1)); + } + } + + // ── Build Menu ───────────────────────────────────────────────────────────── + showBuildMenu() { + if (this.buildMenuOpen) return; + this.buildMenuOpen = true; + this.buildMenuObjs = []; + + const gs = this.gs; + const seat = this.humanSeat; + const eligible = PURCHASABLE.filter(idx => + canBuildHouse(gs, seat, idx) || canBuildHotel(gs, seat, idx)); + + const pw = 420, itemH = 48; + const ph = Math.min(600, 60 + eligible.length * (itemH + 8) + 20); + const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; + + const overlay = this.add.graphics().setDepth(DEPTH.popup - 1); + overlay.fillStyle(0x000000, 0.5); + overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + this.buildMenuObjs.push(overlay); + + const panel = this.add.graphics().setDepth(DEPTH.popup); + panel.fillStyle(0x1e1a12, 1); + panel.fillRoundedRect(px, py, pw, ph, 12); + panel.lineStyle(2, COLORS.gold, 1); + panel.strokeRoundedRect(px, py, pw, ph, 12); + this.buildMenuObjs.push(panel); + + const title = this.add.text(px + pw/2, py + 22, 'Build Houses / Hotels', { + fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1); + this.buildMenuObjs.push(title); + + eligible.forEach((idx, i) => { + const sp = SPACES[idx]; + const own = gs.board[idx]; + const hotelReady = canBuildHotel(gs, seat, idx); + const label = hotelReady + ? `${sp.name} — Build Hotel ($${sp.houseCost})` + : `${sp.name} — House ${own.houses + 1}/4 ($${sp.houseCost})`; + const by = py + 56 + i * (itemH + 8); + const btn = new Button(this, px + pw/2, by + itemH/2, label, () => { + this.closeBuildMenu(); + if (hotelReady) { + this.gs = buildHotel(this.gs, seat, idx); + } else { + this.gs = buildHouse(this.gs, seat, idx); + } + this.render(); + }, { width: pw - 20, height: itemH, fontSize: 16 }); + btn.setDepth(DEPTH.popup+2); + this.buildMenuObjs.push(btn); + }); + + if (eligible.length === 0) { + const noElig = this.add.text(px + pw/2, py + 80, 'No properties eligible to build on.', { + fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1); + this.buildMenuObjs.push(noElig); + } + + const closeBtn = new Button(this, px + pw/2, py + ph - 30, 'Close', () => this.closeBuildMenu(), + { variant:'ghost', width:120, height:40, fontSize:16 }); + closeBtn.setDepth(DEPTH.popup+2); + this.buildMenuObjs.push(closeBtn); + } + + closeBuildMenu() { + this.buildMenuOpen = false; + this.buildMenuObjs?.forEach(o => { try { o.destroy(); } catch {} }); + this.buildMenuObjs = []; + } + + // ── Mortgage Menu ────────────────────────────────────────────────────────── + showMortgageMenu() { + if (this.mortMenuOpen) return; + this.mortMenuOpen = true; + this.mortMenuObjs = []; + + const gs = this.gs; + const seat = this.humanSeat; + const canMort = PURCHASABLE.filter(idx => { + const own = gs.board[idx]; + return own?.owner === seat && !own.mortgaged && own.houses === 0 && !own.hotel; + }); + const canUnmort = PURCHASABLE.filter(idx => { + const own = gs.board[idx]; + return own?.owner === seat && own.mortgaged; + }); + + const items = [ + ...canMort.map(idx => ({ idx, action:'mortgage' })), + ...canUnmort.map(idx => ({ idx, action:'unmortgage' })), + ]; + + const pw = 440, itemH = 48; + const ph = Math.min(620, 60 + items.length * (itemH + 8) + 20); + const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; + + const overlay = this.add.graphics().setDepth(DEPTH.popup - 1); + overlay.fillStyle(0x000000, 0.5); + overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + this.mortMenuObjs.push(overlay); + + const panel = this.add.graphics().setDepth(DEPTH.popup); + panel.fillStyle(0x1e1a12, 1); + panel.fillRoundedRect(px, py, pw, ph, 12); + panel.lineStyle(2, COLORS.gold, 1); + panel.strokeRoundedRect(px, py, pw, ph, 12); + this.mortMenuObjs.push(panel); + + const title = this.add.text(px + pw/2, py + 22, 'Mortgage / Unmortgage', { + fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1); + this.mortMenuObjs.push(title); + + items.forEach(({ idx, action }, i) => { + const sp = SPACES[idx]; + const cost = action === 'mortgage' ? sp.mortgage : Math.ceil(sp.mortgage * 1.1); + const label = action === 'mortgage' + ? `Mortgage ${sp.name} (+$${cost})` + : `Unmortgage ${sp.name} (−$${cost})`; + const enabled = action === 'unmortgage' ? gs.players[seat].cash >= cost : true; + const by = py + 56 + i * (itemH + 8); + const btn = new Button(this, px + pw/2, by + itemH/2, label, () => { + this.closeMortMenu(); + if (action === 'mortgage') { + this.gs = mortgageProperty(this.gs, seat, idx); + } else { + this.gs = unmortgageProperty(this.gs, seat, idx); + } + this.render(); + }, { width: pw - 20, height: itemH, fontSize: 15 }); + btn.setDepth(DEPTH.popup+2); + if (!enabled) btn.setEnabled(false); + this.mortMenuObjs.push(btn); + }); + + if (items.length === 0) { + const noItems = this.add.text(px + pw/2, py + 80, 'Nothing to mortgage or unmortgage.', { + fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex, + }).setOrigin(0.5).setDepth(DEPTH.popup+1); + this.mortMenuObjs.push(noItems); + } + + const closeBtn = new Button(this, px + pw/2, py + ph - 30, 'Close', () => this.closeMortMenu(), + { variant:'ghost', width:120, height:40, fontSize:16 }); + closeBtn.setDepth(DEPTH.popup+2); + this.mortMenuObjs.push(closeBtn); + } + + closeMortMenu() { + this.mortMenuOpen = false; + this.mortMenuObjs?.forEach(o => { try { o.destroy(); } catch {} }); + this.mortMenuObjs = []; + } + + // ── Game Over ────────────────────────────────────────────────────────────── + showGameOver() { + const winner = this.gs.winner !== null ? this.gs.players[this.gs.winner] : null; + const overlay = this.add.graphics().setDepth(DEPTH.banner); + overlay.fillStyle(0x000000, 0.7); + overlay.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + + const pw = 600, ph = 280; + const px = GAME_WIDTH/2 - pw/2, py = GAME_HEIGHT/2 - ph/2; + const bg = this.add.graphics().setDepth(DEPTH.banner+1); + bg.fillStyle(0x1e1a12, 1); + bg.fillRoundedRect(px, py, pw, ph, 16); + bg.lineStyle(3, COLORS.gold, 1); + bg.strokeRoundedRect(px, py, pw, ph, 16); + + const msg = winner ? `${winner.name} Wins!` : 'Game Over'; + this.add.text(GAME_WIDTH/2, py + 60, msg, { + fontFamily:'Righteous', fontSize:'54px', color:COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.banner+2); + + if (winner) { + this.add.text(GAME_WIDTH/2, py + 140, `Net worth: $${netWorth(this.gs, winner.seat).toLocaleString()}`, { + fontFamily:'"Julius Sans One"', fontSize:'22px', color:COLORS.textHex, + }).setOrigin(0.5).setDepth(DEPTH.banner+2); + } + + new Button(this, GAME_WIDTH/2, py + 218, 'Back to Menu', () => this.scene.start('GameMenu'), { + width:220, height:52, fontSize:22, + }).setDepth(DEPTH.banner+3); + } + + // ── Game Flow ────────────────────────────────────────────────────────────── + advance() { + if (this.busy) return; + this.render(); + const gs = this.gs; + if (gs.phase === 'gameover') { this.showGameOver(); return; } + + // 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': { + const buy = chooseBuy(gs, seat, skill); + await this.delay(700); + if (buy) { + this.gs = buyProperty(this.gs, seat); + playSound(this, SFX.purchase); + } else { + this.gs = declineProperty(this.gs, seat); + } + this.render(); + break; + } + case 'card': { + await this.delay(2800); + this.gs = applyCardEffect(this.gs, seat); + this.render(); + // If card moved player to buy or another phase, handle next advance + break; + } + case 'endturn': { + await this.delay(450); + this.gs = endTurn(this.gs); + this.render(); + break; + } + } + } + + async executeRoll(seat) { + const d1 = Math.floor(Math.random() * 6) + 1; + const d2 = Math.floor(Math.random() * 6) + 1; + await this.animateDice(d1, d2); + playSound(this, SFX.diceRoll); + const prevPos = this.gs.players[seat].position; + const wasJailed = this.gs.players[seat].jailed; + this.gs = rollDice(this.gs, seat, d1, d2); + const finalPos = this.gs.players[seat].position; + const nowJailed = this.gs.players[seat].jailed; + // Animate the dice-total steps; snap to finalPos afterward if redirected (e.g. Go to Jail) + const diceTarget = (prevPos + d1 + d2) % 40; + const shouldAnimate = wasJailed ? !nowJailed : true; + if (shouldAnimate) { + await this.animatePawnMove(seat, prevPos, diceTarget); + if (finalPos !== diceTarget) { + const pawn = this.pawns[seat]; + if (pawn) { const { x, y } = this.spacePxCenter(finalPos); pawn.x = x; pawn.y = y; } + } + } + this.render(); + } + + // ── Animations ───────────────────────────────────────────────────────────── + animateDice(d1, d2) { + return new Promise(resolve => { + let count = 0; + const total = 12; + const diceX = RP_X + RP_W/2 - 55; + const ev = this.time.addEvent({ + delay: 70, + repeat: total - 1, + callback: () => { + count++; + const r1 = count < total ? Math.floor(Math.random()*6)+1 : d1; + const r2 = count < total ? Math.floor(Math.random()*6)+1 : d2; + this.drawDie(0, diceX, this.diceY, r1); + this.drawDie(1, diceX + 84, this.diceY, r2); + if (count >= total) resolve(); + }, + }); + }); + } + + animatePawnMove(seat, fromPos, toPos) { + return new Promise(resolve => { + const steps = ((toPos - fromPos + 40) % 40) || 0; + if (steps === 0) { resolve(); return; } + const pawn = this.pawns[seat]; + if (!pawn) { resolve(); return; } + + // Board center — arches always point toward here + const BOARD_CX = BL + BS / 2; + const BOARD_CY = BT + BS / 2; + const ARCH_H = 44; // pixels the arc peak is pushed toward board center + + let step = 0; + let cur = fromPos; + + const hopOne = () => { + if (step >= steps) { resolve(); return; } + step++; + 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); + const nx = dist > 0 ? dx / dist : 0; + const ny = dist > 0 ? dy / dist : -1; + + // Quadratic Bézier control point: ARCH_H px toward board center from midpoint + const ctrl = { x: mx + nx * ARCH_H, y: my + ny * ARCH_H }; + + const proxy = { t: 0 }; + this.tweens.add({ + targets: proxy, + t: 1, + duration: 500, + ease: 'Sine.easeInOut', + onUpdate: () => { + const t = proxy.t; + const inv = 1 - t; + pawn.x = inv * inv * start.x + 2 * inv * t * ctrl.x + t * t * ex; + pawn.y = inv * inv * start.y + 2 * inv * t * ctrl.y + t * t * ey; + }, + onComplete: hopOne, + }); + }; + + hopOne(); + }); + } + + // ── Human Handlers ───────────────────────────────────────────────────────── + onRollDice() { + if (this.busy) return; + this.busy = true; + this.executeRoll(this.humanSeat).then(() => { + this.busy = false; + this.advance(); + }); + } + + onBuyProperty() { + if (this.busy) return; + this.gs = buyProperty(this.gs, this.humanSeat); + playSound(this, SFX.purchase); + this.render(); + this.advance(); + } + + onDeclineProperty() { + if (this.busy) return; + this.gs = declineProperty(this.gs, this.humanSeat); + this.render(); + this.advance(); + } + + onDismissCard() { + if (this.busy) return; + this.gs = applyCardEffect(this.gs, this.humanSeat); + this.render(); + this.advance(); + } + + onEndTurn() { + if (this.busy) return; + this.gs = endTurn(this.gs); + this.render(); + this.advance(); + } + + onPayJailFine() { + if (this.busy) return; + if (this.gs.players[this.humanSeat].cash < 50) return; + this.gs = payJailFine(this.gs, this.humanSeat); + this.render(); + this.advance(); + } + + onUseJailCard() { + if (this.busy) return; + this.gs = useJailCard(this.gs, this.humanSeat); + this.render(); + this.advance(); + } +} diff --git a/public/src/games/monopoly/MonopolyLogic.js b/public/src/games/monopoly/MonopolyLogic.js new file mode 100644 index 0000000..55568e6 --- /dev/null +++ b/public/src/games/monopoly/MonopolyLogic.js @@ -0,0 +1,721 @@ +// Monopoly — pure state engine. No Phaser, no rendering. +// All mutators deep-clone and return a fresh state. + +import { + SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS, GROUP_COLORS, + CHANCE_CARDS, CC_CARDS, nearestRailroad, nearestUtility, +} from './MonopolyData.js'; + +// ── RNG ─────────────────────────────────────────────────────────────────────── +function rngFrom(seed) { + let a = (seed >>> 0) || 1; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function shuffle(arr, rng) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +const clone = (s) => JSON.parse(JSON.stringify(s)); + +// ── State ───────────────────────────────────────────────────────────────────── +export function createInitialState({ playerCount, names, seed = Date.now() }) { + const rng = rngFrom(seed); + const board = {}; + for (const idx of PURCHASABLE) board[idx] = { owner: null, houses: 0, hotel: false, mortgaged: false }; + + return { + playerCount, + seed, + phase: 'preroll', + current: 0, + doublesCount: 0, + diceRoll: null, + houseSupply: 32, + hotelSupply: 12, + players: Array.from({ length: playerCount }, (_, seat) => ({ + seat, + name: names[seat] ?? `Player ${seat + 1}`, + cash: 1500, + position: 0, + jailed: false, + jailTurns: 0, + getOutOfJailFree: 0, + bankrupt: false, + active: true, + })), + board, + chanceOrder: shuffle(CHANCE_CARDS.map((_, i) => i), rng), + chanceIdx: 0, + ccOrder: shuffle(CC_CARDS.map((_, i) => i), rng), + ccIdx: 0, + pendingCard: null, + pendingBuy: null, + pendingAuction: null, + winner: null, + log: [], + }; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function log(s, msg) { s.log = [msg, ...s.log.slice(0, 49)]; } + +export function ownsGroup(state, seat, group) { + const indices = GROUPS[group]; + if (!indices) return false; + return indices.every(i => state.board[i]?.owner === seat); +} + +function railroadsOwned(state, seat) { + return RAILROADS.filter(i => state.board[i]?.owner === seat).length; +} + +function utilitiesOwned(state, seat) { + return UTILITIES.filter(i => state.board[i]?.owner === seat).length; +} + +export function calculateRent(state, spaceIdx, diceTotal, forceUtilityMult = null) { + const sp = SPACES[spaceIdx]; + const own = state.board[spaceIdx]; + if (!own || own.owner === null || own.mortgaged) return 0; + const { owner, houses, hotel } = own; + + if (sp.type === 'property') { + let idx; + if (hotel) idx = 6; + else if (houses>0) idx = houses + 1; + else if (ownsGroup(state, owner, sp.group)) idx = 1; + else idx = 0; + return sp.rent[idx]; + } + if (sp.type === 'railroad') { + const cnt = railroadsOwned(state, owner); + return 25 * Math.pow(2, cnt - 1); + } + if (sp.type === 'utility') { + const mult = forceUtilityMult ?? (utilitiesOwned(state, owner) === 2 ? 10 : 4); + return mult * diceTotal; + } + return 0; +} + +export function netWorth(state, seat) { + const p = state.players[seat]; + let w = p.cash; + for (const idx of PURCHASABLE) { + const own = state.board[idx]; + if (own?.owner !== seat) continue; + const sp = SPACES[idx]; + w += sp.mortgage ?? 0; // half price + if (!own.mortgaged) { + if (sp.type === 'property') { + w += own.houses * (sp.houseCost / 2); + if (own.hotel) w += sp.houseCost * 2; // hotel = 4 houses → sell back half + } + } + } + return w; +} + +// Auto-liquidate houses and mortgage properties to cover a debt. +// Returns new state; sets player bankrupt if still can't pay. +function liquidate(state, seat, creditorSeat) { + const s = clone(state); + const p = s.players[seat]; + + // Sell houses first (even selling), then hotels + let progress = true; + while (p.cash < 0 && progress) { + progress = false; + for (const idx of PURCHASABLE) { + if (p.cash >= 0) break; + const own = s.board[idx]; + if (own?.owner !== seat) continue; + const sp = SPACES[idx]; + if (own.hotel) { + own.hotel = false; + own.houses = 4; // hotels become 4 houses + p.cash += sp.houseCost; // sell hotel back at half + s.hotelSupply++; + progress = true; + } + } + for (const idx of PURCHASABLE) { + if (p.cash >= 0) break; + const own = s.board[idx]; + if (own?.owner !== seat || own.houses === 0) continue; + const sp = SPACES[idx]; + own.houses--; + p.cash += sp.houseCost / 2; + s.houseSupply++; + progress = true; + } + // Mortgage un-mortgaged properties + for (const idx of PURCHASABLE) { + if (p.cash >= 0) break; + const own = s.board[idx]; + if (own?.owner !== seat || own.mortgaged || own.houses > 0 || own.hotel) continue; + const sp = SPACES[idx]; + own.mortgaged = true; + p.cash += sp.mortgage; + progress = true; + } + } + + if (p.cash < 0) { + // Bankrupt: transfer remaining properties to creditor (or bank if creditorSeat null) + for (const idx of PURCHASABLE) { + const own = s.board[idx]; + if (own?.owner !== seat) continue; + if (creditorSeat !== null && creditorSeat !== undefined) { + own.owner = creditorSeat; + own.mortgaged = true; // creditor inherits mortgaged + own.houses = 0; own.hotel = false; + } else { + own.owner = null; + own.houses = 0; own.hotel = false; + own.mortgaged = false; + } + } + if (creditorSeat !== null && creditorSeat !== undefined) { + s.players[creditorSeat].cash += Math.max(0, p.cash + p.cash); // whatever was left (≤0, so skip) + } + p.cash = 0; + p.bankrupt = true; + p.active = false; + log(s, `${p.name} is bankrupt and out of the game.`); + } + return s; +} + +function payTo(state, payerSeat, receiverSeat, amount) { + const s = clone(state); + s.players[payerSeat].cash -= amount; + if (receiverSeat !== null && receiverSeat !== undefined) { + s.players[receiverSeat].cash += amount; + } + if (s.players[payerSeat].cash < 0) { + return liquidate(s, payerSeat, receiverSeat); + } + return s; +} + +export function checkGameOver(state) { + const s = clone(state); + const active = s.players.filter(p => p.active); + if (active.length <= 1) { + s.phase = 'gameover'; + s.winner = active[0]?.seat ?? null; + if (s.winner !== null) log(s, `${s.players[s.winner].name} wins!`); + } + return s; +} + +// ── Roll & Move ─────────────────────────────────────────────────────────────── +export function rollDice(state, seat, d1, d2) { + const s = clone(state); + s.diceRoll = [d1, d2]; + const p = s.players[seat]; + const isDoubles = d1 === d2; + + if (p.jailed) { + if (isDoubles) { + p.jailed = false; + p.jailTurns = 0; + const newPos = (p.position + d1 + d2) % 40; + p.position = newPos; + // Don't get another turn from jail doubles + s.doublesCount = 0; + log(s, `${p.name} rolls doubles and escapes jail!`); + } else { + p.jailTurns++; + if (p.jailTurns >= 3) { + p.cash -= 50; + if (p.cash < 0) { const ls = liquidate(s, seat, null); Object.assign(s, ls); } + p.jailed = false; + p.jailTurns = 0; + const newPos = (p.position + d1 + d2) % 40; + s.players[seat].position = newPos; + log(s, `${p.name} pays $50 to leave jail after 3 turns.`); + } else { + log(s, `${p.name} fails to roll doubles in jail (turn ${p.jailTurns}).`); + s.phase = 'endturn'; + return s; + } + } + } else { + if (isDoubles) { + s.doublesCount++; + if (s.doublesCount >= 3) { + // Three doubles: go to jail + p.position = 10; + p.jailed = true; + p.jailTurns = 0; + s.doublesCount = 0; + log(s, `${p.name} rolls 3 doubles in a row — Go to Jail!`); + s.phase = 'endturn'; + return s; + } + } else { + s.doublesCount = 0; + } + const oldPos = p.position; + const newPos = (oldPos + d1 + d2) % 40; + p.position = newPos; + if (newPos < oldPos || (oldPos === 0 && newPos > 0)) { + p.cash += 200; + log(s, `${p.name} passes GO, collects $200.`); + } + } + + return resolveSpace(s, seat); +} + +// ── Space Resolution ────────────────────────────────────────────────────────── +export function resolveSpace(state, seat) { + const s = clone(state); + const p = s.players[seat]; + const spIdx = p.position; + const sp = SPACES[spIdx]; + + switch (sp.type) { + case 'go': + s.phase = 'endturn'; + break; + + case 'property': + case 'railroad': + case 'utility': { + const own = s.board[spIdx]; + if (!own || own.owner === null) { + s.pendingBuy = { spaceIdx: spIdx }; + s.phase = 'buy'; + } else if (own.owner === seat) { + log(s, `${p.name} owns this property.`); + s.phase = 'endturn'; + } else if (own.mortgaged) { + log(s, `${SPACES[spIdx].name} is mortgaged — no rent.`); + s.phase = 'endturn'; + } else { + const dice = s.diceRoll[0] + s.diceRoll[1]; + const rent = calculateRent(s, spIdx, dice); + const result = payTo(s, seat, own.owner, rent); + Object.assign(s, result); + log(s, `${p.name} pays $${rent} rent to ${s.players[own.owner].name}.`); + s.phase = 'endturn'; + } + break; + } + + case 'tax': { + const result = payTo(s, seat, null, sp.amount); + Object.assign(s, result); + log(s, `${p.name} pays ${sp.name} ($${sp.amount}).`); + s.phase = 'endturn'; + break; + } + + case 'chance': { + const cardIdx = s.chanceOrder[s.chanceIdx % s.chanceOrder.length]; + s.chanceIdx++; + const card = CHANCE_CARDS[cardIdx]; + s.pendingCard = { cardType: 'chance', cardIdx, text: card.text, effect: card }; + s.phase = 'card'; + break; + } + + case 'community_chest': { + const cardIdx = s.ccOrder[s.ccIdx % s.ccOrder.length]; + s.ccIdx++; + const card = CC_CARDS[cardIdx]; + s.pendingCard = { cardType: 'community_chest', cardIdx, text: card.text, effect: card }; + s.phase = 'card'; + break; + } + + case 'gotojail': + p.position = 10; + p.jailed = true; + p.jailTurns = 0; + s.doublesCount = 0; + log(s, `${p.name} is sent to Jail!`); + s.phase = 'endturn'; + break; + + case 'jail': + case 'freeparking': + default: + s.phase = 'endturn'; + break; + } + return s; +} + +// ── Card Effects ────────────────────────────────────────────────────────────── +export function applyCardEffect(state, seat) { + let s = clone(state); + const p = s.players[seat]; + const card = s.pendingCard?.effect; + if (!card) { s.pendingCard = null; s.phase = 'endturn'; return s; } + + switch (card.effect) { + case 'advance_to': { + const target = card.target; + const old = p.position; + p.position = target; + if (target <= old && target !== old) { + p.cash += 200; + log(s, `${p.name} passes GO, collects $200.`); + } + s.pendingCard = null; + s = resolveSpace(s, seat); + break; + } + case 'nearest_railroad': { + const target = nearestRailroad(p.position); + const old = p.position; + p.position = target; + if (target <= old) { p.cash += 200; log(s, `${p.name} passes GO, collects $200.`); } + s.pendingCard = null; + const own = s.board[target]; + if (!own || own.owner === null) { + s.pendingBuy = { spaceIdx: target }; + s.phase = 'buy'; + } else if (own.owner !== seat && !own.mortgaged) { + const cnt = railroadsOwned(s, own.owner); + const rent = 25 * Math.pow(2, cnt - 1) * (card.doubleRent ? 2 : 1); + const result = payTo(s, seat, own.owner, rent); + Object.assign(s, result); + log(s, `${p.name} pays $${rent} (double) railroad rent.`); + s.phase = 'endturn'; + } else { + s.phase = 'endturn'; + } + break; + } + case 'nearest_utility': { + const target = nearestUtility(p.position); + const old = p.position; + p.position = target; + if (target <= old) { p.cash += 200; log(s, `${p.name} passes GO, collects $200.`); } + s.pendingCard = null; + const own = s.board[target]; + if (!own || own.owner === null) { + s.pendingBuy = { spaceIdx: target }; + s.phase = 'buy'; + } else if (own.owner !== seat && !own.mortgaged) { + const dice = s.diceRoll[0] + s.diceRoll[1]; + const rent = 10 * dice; + const result = payTo(s, seat, own.owner, rent); + Object.assign(s, result); + log(s, `${p.name} pays $${rent} (10× dice) utility rent.`); + s.phase = 'endturn'; + } else { + s.phase = 'endturn'; + } + break; + } + case 'collect': + p.cash += card.amount; + log(s, `${p.name} collects $${card.amount}.`); + s.pendingCard = null; + s.phase = 'endturn'; + break; + case 'pay': { + const result = payTo(s, seat, null, card.amount); + Object.assign(s, result); + log(s, `${p.name} pays $${card.amount}.`); + s.pendingCard = null; + s.phase = 'endturn'; + break; + } + case 'goojf': + p.getOutOfJailFree++; + log(s, `${p.name} receives a Get Out of Jail Free card.`); + s.pendingCard = null; + s.phase = 'endturn'; + break; + case 'go_to_jail': + p.position = 10; + p.jailed = true; + p.jailTurns = 0; + s.doublesCount = 0; + log(s, `${p.name} goes to Jail!`); + s.pendingCard = null; + s.phase = 'endturn'; + break; + case 'back3': { + p.position = (p.position - 3 + 40) % 40; + s.pendingCard = null; + s = resolveSpace(s, seat); + // Don't award Go on back3 + break; + } + case 'repairs': { + let total = 0; + for (const idx of PURCHASABLE) { + const own = s.board[idx]; + if (own?.owner !== seat) continue; + total += own.houses * card.house; + if (own.hotel) total += card.hotel; + } + if (total > 0) { + const result = payTo(s, seat, null, total); + Object.assign(s, result); + log(s, `${p.name} pays $${total} for repairs.`); + } + s.pendingCard = null; + s.phase = 'endturn'; + break; + } + case 'pay_each': { + let paid = 0; + for (const other of s.players) { + if (!other.active || other.seat === seat) continue; + const amount = Math.min(card.amount, s.players[seat].cash); + s.players[seat].cash -= amount; + other.cash += amount; + paid += amount; + } + log(s, `${p.name} pays $${card.amount} to each player.`); + s.pendingCard = null; + s.phase = 'endturn'; + break; + } + case 'collect_each': { + for (const other of s.players) { + if (!other.active || other.seat === seat) continue; + const amount = Math.min(card.amount, other.cash); + other.cash -= amount; + s.players[seat].cash += amount; + } + log(s, `${p.name} collects $${card.amount} from each player.`); + s.pendingCard = null; + s.phase = 'endturn'; + break; + } + default: + s.pendingCard = null; + s.phase = 'endturn'; + } + return s; +} + +// ── Property Purchase ───────────────────────────────────────────────────────── +export function buyProperty(state, seat) { + const s = clone(state); + const { spaceIdx } = s.pendingBuy; + const sp = SPACES[spaceIdx]; + s.players[seat].cash -= sp.price; + s.board[spaceIdx].owner = seat; + log(s, `${s.players[seat].name} buys ${sp.name} for $${sp.price}.`); + s.pendingBuy = null; + s.phase = s.doublesCount > 0 ? 'preroll' : 'endturn'; + return checkGameOver(s); +} + +export function declineProperty(state, seat) { + return startAuction(state, state.pendingBuy.spaceIdx); +} + +// ── Auction ─────────────────────────────────────────────────────────────────── +function startAuction(state, spaceIdx) { + const s = clone(state); + const bidOrder = []; + for (let i = 0; i < s.playerCount; i++) { + const seat = (s.current + i) % s.playerCount; + if (s.players[seat].active) bidOrder.push(seat); + } + s.pendingBuy = null; + s.pendingAuction = { spaceIdx, bidOrder, currentBidderIdx: 0, bids: {}, highBid: 0, highBidder: null }; + s.phase = 'auction'; + return s; +} + +export function placeBid(state, seat, amount) { + const s = clone(state); + const auc = s.pendingAuction; + if (amount > s.players[seat].cash) amount = s.players[seat].cash; + if (amount <= auc.highBid) return passAuction(s, seat); + auc.bids[seat] = amount; + auc.highBid = amount; + auc.highBidder = seat; + auc.currentBidderIdx = (auc.currentBidderIdx + 1) % auc.bidOrder.length; + // Skip to next active bidder who hasn't committed to their max + return advanceAuction(s); +} + +export function passAuction(state, seat) { + const s = clone(state); + const auc = s.pendingAuction; + auc.bidOrder = auc.bidOrder.filter(bs => bs !== seat); + if (auc.currentBidderIdx >= auc.bidOrder.length) auc.currentBidderIdx = 0; + return advanceAuction(s); +} + +function advanceAuction(state) { + const s = clone(state); + const auc = s.pendingAuction; + if (auc.bidOrder.length === 0 || (auc.bidOrder.length === 1 && auc.highBidder === auc.bidOrder[0])) { + return resolveAuction(s); + } + if (auc.bidOrder.length === 1 && auc.highBidder === null) { + return resolveAuction(s); // no one bid + } + return s; +} + +function resolveAuction(state) { + const s = clone(state); + const auc = s.pendingAuction; + if (auc.highBidder !== null && auc.highBid > 0) { + s.players[auc.highBidder].cash -= auc.highBid; + s.board[auc.spaceIdx].owner = auc.highBidder; + log(s, `${s.players[auc.highBidder].name} wins auction for ${SPACES[auc.spaceIdx].name} at $${auc.highBid}.`); + } else { + log(s, `No one bid on ${SPACES[auc.spaceIdx].name} — it stays in the bank.`); + } + s.pendingAuction = null; + s.phase = s.doublesCount > 0 ? 'preroll' : 'endturn'; + return checkGameOver(s); +} + +// ── Building ────────────────────────────────────────────────────────────────── +export function canBuildHouse(state, seat, spaceIdx) { + const sp = SPACES[spaceIdx]; + const own = state.board[spaceIdx]; + if (!own || own.owner !== seat || own.mortgaged || own.hotel) return false; + if (sp.type !== 'property') return false; + if (!ownsGroup(state, seat, sp.group)) return false; + if (state.houseSupply <= 0) return false; + // Even building: can't have more than 1 house more than any other in group + const groupMin = Math.min(...GROUPS[sp.group].map(i => state.board[i]?.houses ?? 0)); + return own.houses <= groupMin && own.houses < 4; +} + +export function buildHouse(state, seat, spaceIdx) { + const s = clone(state); + const sp = SPACES[spaceIdx]; + s.players[seat].cash -= sp.houseCost; + s.board[spaceIdx].houses++; + s.houseSupply--; + log(s, `${s.players[seat].name} builds a house on ${sp.name}.`); + return s; +} + +export function canBuildHotel(state, seat, spaceIdx) { + const sp = SPACES[spaceIdx]; + const own = state.board[spaceIdx]; + if (!own || own.owner !== seat || own.mortgaged || own.hotel) return false; + if (sp.type !== 'property') return false; + if (!ownsGroup(state, seat, sp.group)) return false; + if (state.hotelSupply <= 0) return false; + // All properties in group must have 4 houses + return GROUPS[sp.group].every(i => state.board[i]?.houses === 4 || state.board[i]?.hotel); +} + +export function buildHotel(state, seat, spaceIdx) { + const s = clone(state); + const sp = SPACES[spaceIdx]; + s.players[seat].cash -= sp.houseCost; + s.board[spaceIdx].houses = 0; + s.board[spaceIdx].hotel = true; + s.houseSupply += 4; // return the 4 houses + s.hotelSupply--; + log(s, `${s.players[seat].name} builds a hotel on ${sp.name}.`); + return s; +} + +export function sellHouse(state, seat, spaceIdx) { + const s = clone(state); + const sp = SPACES[spaceIdx]; + s.board[spaceIdx].houses--; + s.players[seat].cash += Math.floor(sp.houseCost / 2); + s.houseSupply++; + return s; +} + +export function sellHotel(state, seat, spaceIdx) { + const s = clone(state); + const sp = SPACES[spaceIdx]; + s.board[spaceIdx].hotel = false; + s.board[spaceIdx].houses = 0; + s.players[seat].cash += Math.floor(sp.houseCost / 2); + s.hotelSupply++; + s.houseSupply -= 4; + return s; +} + +export function mortgageProperty(state, seat, spaceIdx) { + const s = clone(state); + const sp = SPACES[spaceIdx]; + const own = s.board[spaceIdx]; + if (own.houses > 0 || own.hotel) return s; // must sell buildings first + own.mortgaged = true; + s.players[seat].cash += sp.mortgage; + log(s, `${s.players[seat].name} mortgages ${sp.name} for $${sp.mortgage}.`); + return s; +} + +export function unmortgageProperty(state, seat, spaceIdx) { + const s = clone(state); + const sp = SPACES[spaceIdx]; + const cost = Math.ceil(sp.mortgage * 1.1); + if (s.players[seat].cash < cost) return s; + s.board[spaceIdx].mortgaged = false; + s.players[seat].cash -= cost; + log(s, `${s.players[seat].name} unmortgages ${sp.name} for $${cost}.`); + return s; +} + +// ── Jail ────────────────────────────────────────────────────────────────────── +export function payJailFine(state, seat) { + const s = clone(state); + s.players[seat].cash -= 50; + s.players[seat].jailed = false; + s.players[seat].jailTurns = 0; + log(s, `${s.players[seat].name} pays $50 to get out of jail.`); + s.phase = 'preroll'; + return s; +} + +export function useJailCard(state, seat) { + const s = clone(state); + s.players[seat].getOutOfJailFree--; + s.players[seat].jailed = false; + s.players[seat].jailTurns = 0; + log(s, `${s.players[seat].name} uses a Get Out of Jail Free card.`); + s.phase = 'preroll'; + return s; +} + +// ── Turn ────────────────────────────────────────────────────────────────────── +export function endTurn(state) { + const s = clone(state); + // If doubles were rolled and player isn't jailed: same player rolls again + if (s.doublesCount > 0 && !s.players[s.current].jailed) { + s.phase = 'preroll'; + return s; + } + s.doublesCount = 0; + // Advance to next active player + let next = (s.current + 1) % s.playerCount; + let guard = 0; + while (!s.players[next].active && guard++ < s.playerCount) { + next = (next + 1) % s.playerCount; + } + s.current = next; + s.diceRoll = null; + s.phase = 'preroll'; + return checkGameOver(s); +} diff --git a/public/src/games/monopoly/sprites.md b/public/src/games/monopoly/sprites.md new file mode 100644 index 0000000..2abf1a4 --- /dev/null +++ b/public/src/games/monopoly/sprites.md @@ -0,0 +1,103 @@ +# Monopoly — Spritesheet Guide + +Three image files need to be created or extended. All dimensions are exact — the engine reads pixel-perfect frame boundaries. + +--- + +## 1. `assets/images/monopoly-pawns.png` + +**Total sheet size:** 320 × 80 px +**Frame size:** 80 × 80 px +**Layout:** 4 frames in a single horizontal row +**Displayed at:** 32 × 32 px on the board (the image is scaled down, so keep art centered and avoid fine detail near the edges) + +Each frame is a single player token on a transparent background. The token should be centered in the 80 × 80 cell with roughly 8–10 px of breathing room on all sides so it doesn't clip when scaled. + +| Frame | X offset | Player | Color theme | Suggested token | +|-------|----------|--------|-------------|-----------------| +| 0 | x=0 | Player 1 | Red `#E53935` | Top hat | +| 1 | x=80 | Player 2 | Blue `#1565C0` | Racing car | +| 2 | x=160 | Player 3 | Green `#2E7D32` | Scottie dog | +| 3 | x=240 | Player 4 | Gold `#F57F17` | Battleship | + +### Visual guidance per token + +**Top hat (frame 0 — red):** +Iconic tall cylinder hat. Brim at bottom, oval crown at top. Deep red or black body with a red highlight/shadow. Works well as a simple 2-tone silhouette. + +**Racing car (frame 1 — blue):** +Classic 1930s open-wheel racer viewed at a slight 3/4 angle. Blue body, yellow or silver wheels. Keep it chunky and readable at 32 × 32 px — avoid thin spokes. + +**Scottie dog (frame 2 — green):** +Side-on silhouette of a Scottish Terrier with wiry fur. Body in dark green or black-green; eyes as a small white or yellow dot. The iconic bushy-beard chin and erect tail are the key recognition cues. + +**Battleship (frame 3 — gold):** +Top-down or slight 3/4 view of a naval destroyer. Gold hull, dark gray turrets, small white wake lines. Keep it horizontal/landscape within the square cell. + +--- + +## 2. `assets/images/monopoly-cards.png` + +**Total sheet size:** 400 × 300 px +**Frame size:** 200 × 300 px +**Layout:** 2 frames side by side in one row +**Displayed at:** 340 × 220 px inside the card popup (the engine scales the frame to fit the top area of a 360 × 480 popup) + +The code overlays its own text labels on top — "CHANCE" or "COMMUNITY CHEST" is printed at the top by the engine, and the card's effect text is printed in the lower half. **The image only needs to fill the upper ~220 px of visible space** — think of it as providing the background art and mood, not the text. + +| Frame | X offset | Card type | Background color | Icon | +|-------|----------|-----------|------------------|------| +| 0 | x=0 | Chance | Orange `#E77A2C` | Large "?" | +| 1 | x=200 | Community Chest | Blue `#1565C0` | Treasure chest | + +### Frame 0 — Chance (200 × 300 px) + +- **Background:** Warm orange gradient, darker toward the top and bottom edges +- **Border:** A thin ornate gold or cream inner border rect (≈ 8 px from each edge) +- **Central icon:** A large bold "?" character, roughly 100 × 140 px, in cream or white with a slight drop shadow. The "?" should be centered horizontally and sit in the lower ⅔ of the frame (the engine prints "CHANCE" at the very top, so leave the top 40–50 px relatively clean) +- **Decorative touches (optional):** Small radiating lines or sunburst behind the "?", art deco corner flourishes + +### Frame 1 — Community Chest (200 × 300 px) + +- **Background:** Rich blue gradient, `#1565C0` center fading to `#0D3A6E` at edges +- **Border:** Same style ornate inner border as Chance, in gold or cream +- **Central icon:** A wooden treasure chest, slightly open, with a warm interior glow or gold coins visible. Roughly 80 × 70 px, centered horizontally, sitting in the lower ⅔ of the frame. Classic Monopoly Community Chest chests are brown/tan with gold hardware +- **Decorative touches (optional):** Small star or coin scattered around the chest, "art deco" corner ornaments matching the Chance card style for consistency + +--- + +## 3. `assets/images/game-icons.png` — extend existing sheet + +**Current sheet size:** 660 × 660 px +**Grid:** 15 columns × 15 rows of 44 × 44 px cells +**Current frames:** 0 – 47 (48 icons) +**Monopoly frame index:** **48** + +### Where to paint frame 48 + +Frame 48 falls at: + +``` +Column = 48 % 15 = 3 → x = 3 × 44 = 132 px +Row = 48 / 15 = 3 → y = 3 × 44 = 132 px +``` + +**Paint the Monopoly icon into the cell at (132, 132) — a 44 × 44 px region — in your existing game-icons PSD.** + +### Monopoly icon design (44 × 44 px) + +The icon is shown at 44 × 44 px in the game menu next to the game title. Suggested design: + +- **Background:** Deep green `#1F3D1F` or a classic Monopoly board green +- **Foreground:** A small white/cream Monopoly board viewed from above — simplified to just the four corner squares (a white square outline with the four corner squares indicated) OR simply the red "M" monogram from the Monopoly logo +- **Alternative:** A gold top hat silhouette centered on a dark background — very legible at small size and immediately recognizable + +Keep the art to 2–3 colors at most. At 44 × 44 the icon is tiny; bold shapes with strong contrast read far better than detailed illustrations. + +--- + +## Checklist + +- [ ] `monopoly-pawns.png` — 4 tokens, 320 × 80 px, transparent background per token +- [ ] `monopoly-cards.png` — 2 card arts, 400 × 300 px, no text (engine adds text) +- [ ] `game-icons.png` — add Monopoly icon at pixel (132, 132) in the existing 660 × 660 sheet diff --git a/public/src/main.js b/public/src/main.js index e27a9c8..a7a8b11 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -58,6 +58,7 @@ import VideoPokerGame from './games/videopoker/VideoPokerGame.js'; import FarkelGame from './games/farkel/FarkelGame.js'; import StrategoGame from './games/stratego/StrategoGame.js'; import KiitosGame from './games/kiitos/KiitosGame.js'; +import MonopolyGame from './games/monopoly/MonopolyGame.js'; const config = { type: Phaser.AUTO, @@ -129,6 +130,7 @@ const config = { FarkelGame, StrategoGame, KiitosGame, + MonopolyGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 2bc3af2..62f221d 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 465de1a..7858470 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -132,6 +132,10 @@ export default class PreloadScene extends Phaser.Scene { // Stratego unit art: 12 transparent frames (0=Flag, 1..10=rank, 11=Bomb), // 6 cols × 2 rows. Optional — the scene draws vector glyphs when absent. this.load.spritesheet('stratego-pieces', '/assets/images/stratego-pieces.png', { frameWidth: 140, frameHeight: 140 }); + // Monopoly pawns: 4 frames (one per seat) at 80×80. Optional — falls back to colored circles. + this.load.spritesheet('monopoly-pawns', '/assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 }); + // Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300. + this.load.spritesheet('monopoly-cards', '/assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 }); } async create() { diff --git a/server/games/registry.js b/server/games/registry.js index d6d7387..5f499d5 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -73,3 +73,4 @@ registerGame({ slug: 'videopoker', name: 'Video Poker', category: ' registerGame({ slug: 'farkel', name: 'Farkle', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 }); registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 }); registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 }); +registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 });