From 10ac18ab6ec788486c721343e1acfdcacb3fdd38 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 7 Jun 2026 14:37:06 -0600 Subject: [PATCH] feat: add Monopoly board game - Add complete Monopoly implementation with pure state engine (MonopolyLogic.js), static data (MonopolyData.js), AI (MonopolyAI.js), and Phaser scene (MonopolyGame.js) - Implement full game rules: property buying, auctions, building houses/hotels, mortgages, jail, chance/community chest cards, rent calculation, bankruptcy - Add 5-level AI with configurable greed, blunder rate, and thinking delay - Add spritesheet loading for monopoly pawns and cards with graceful fallbacks - Register game in registry, main.js, and game room scene dispatcher - Add spritesheet creation guide (sprites.md) and update game-icons.png --- public/assets/images/game-icons.png | Bin 181564 -> 185829 bytes public/assets/images/game-icons.psd | Bin 483355 -> 492689 bytes public/assets/images/monopoly-pawns.png | Bin 0 -> 23514 bytes public/src/games/monopoly/MonopolyAI.js | 82 ++ public/src/games/monopoly/MonopolyData.js | 165 +++ public/src/games/monopoly/MonopolyGame.js | 1286 ++++++++++++++++++++ public/src/games/monopoly/MonopolyLogic.js | 721 +++++++++++ public/src/games/monopoly/sprites.md | 103 ++ public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- public/src/scenes/PreloadScene.js | 4 + server/games/registry.js | 1 + 12 files changed, 2365 insertions(+), 1 deletion(-) create mode 100644 public/assets/images/monopoly-pawns.png create mode 100644 public/src/games/monopoly/MonopolyAI.js create mode 100644 public/src/games/monopoly/MonopolyData.js create mode 100644 public/src/games/monopoly/MonopolyGame.js create mode 100644 public/src/games/monopoly/MonopolyLogic.js create mode 100644 public/src/games/monopoly/sprites.md diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 9116fdef6b9962c07426b21949eaee423f0177c1..60c159714546e6842082f8878d34b0ce1f504d6a 100644 GIT binary patch delta 25321 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

oK(T7I~0R+P%df`H^02sv6 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

-evR5cPOUU=T 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 delta 21023 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 E1BU(G=Kufz delta 5760 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 LNkvXXu0mjf6BRn& literal 0 HcmV?d00001 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 });