Add zero state, disconnected overlay, game over confetti, and debug panel

- Zero state: bouncing dice + message when no game exists
- Disconnected: greyed overlay after 3 consecutive fetch failures,
  with retry countdown
- Game over: gold banner with winner name + confetti animation,
  detected from log ('WINS!') or single remaining player
- Debug panel: Ctrl+Shift+D toggles hidden panel with buttons to
  test all four states (zero, playing, gameover, disconnected, reset)
- Also fixed player panel to use p.number for current-turn matching
  and property ownership display
This commit is contained in:
Jarvis 2026-02-21 11:08:06 +00:00
parent 19fc56d982
commit 34a4f47402
2 changed files with 547 additions and 250 deletions

View file

@ -3,8 +3,8 @@
{ {
"name": "alice", "name": "alice",
"number": 1, "number": 1,
"money": 624, "money": 919,
"location": 21, "location": 25,
"inJail": false, "inJail": false,
"jailTurns": 0, "jailTurns": 0,
"doublesCount": 0, "doublesCount": 0,
@ -13,8 +13,8 @@
{ {
"name": "bob", "name": "bob",
"number": 2, "number": 2,
"money": 1229, "money": 1292,
"location": 23, "location": 37,
"inJail": false, "inJail": false,
"jailTurns": 0, "jailTurns": 0,
"doublesCount": 0, "doublesCount": 0,
@ -23,15 +23,15 @@
{ {
"name": "charlie", "name": "charlie",
"number": 3, "number": 3,
"money": -12, "money": 320,
"location": 24, "location": 40,
"inJail": false, "inJail": true,
"jailTurns": 0, "jailTurns": 0,
"doublesCount": 0, "doublesCount": 0,
"getOutOfJailFreeCards": 1 "getOutOfJailFreeCards": 1
} }
], ],
"currentPlayer": 3, "currentPlayer": 2,
"squares": [ "squares": [
{ {
"id": 0, "id": 0,
@ -96,7 +96,7 @@
"id": 8, "id": 8,
"name": "Vermont ave. (L)", "name": "Vermont ave. (L)",
"type": "property", "type": "property",
"owner": null, "owner": 3,
"mortgaged": false, "mortgaged": false,
"group": "lightblue", "group": "lightblue",
"cost": 100, "cost": 100,
@ -370,150 +370,149 @@
], ],
"log": [ "log": [
{ {
"text": "roll is 6, 5", "text": "charlie's turn \u2014 $320 on Electric Co.",
"player": "charlie", "player": "charlie",
"timestamp": "2026-02-21 11:01:40" "timestamp": "2026-02-21 11:03:37"
}, },
{ {
"text": "Passed GO \u2014 collected $200", "text": "roll is 4, 6",
"player": "charlie", "player": "charlie",
"timestamp": "2026-02-21 11:01:40" "timestamp": "2026-02-21 11:03:38"
}, },
{ {
"text": "Landed on Income Tax", "text": "Landed on Chance ii",
"player": "charlie", "player": "charlie",
"timestamp": "2026-02-21 11:01:41" "timestamp": "2026-02-21 11:03:39"
}, },
{ {
"text": "alice's turn \u2014 $289 on Just Visiting", "text": "Go Back 3 Spaces",
"player": "alice",
"timestamp": "2026-02-21 11:01:47"
},
{
"text": "Trade completed between alice and bob",
"player": null
},
{
"text": "alice's turn \u2014 $484 on Just Visiting",
"player": "alice",
"timestamp": "2026-02-21 11:01:58"
},
{
"text": "roll is 3, 2",
"player": "alice",
"timestamp": "2026-02-21 11:01:59"
},
{
"text": "Landed on Pennsylvania RR",
"player": "alice",
"timestamp": "2026-02-21 11:01:59"
},
{
"text": "bob's turn \u2014 $1279 on Electric Co.",
"player": "bob",
"timestamp": "2026-02-21 11:02:00"
},
{
"text": "roll is 1, 5",
"player": "bob",
"timestamp": "2026-02-21 11:02:02"
},
{
"text": "Landed on Tennessee ave. (O)",
"player": "bob",
"timestamp": "2026-02-21 11:02:02"
},
{
"text": "Paid $14 rent to alice",
"player": "bob"
},
{
"text": "charlie's turn \u2014 $78 on Income Tax",
"player": "charlie",
"timestamp": "2026-02-21 11:02:04"
},
{
"text": "roll is 6, 5",
"player": "charlie",
"timestamp": "2026-02-21 11:02:05"
},
{
"text": "Landed on Pennsylvania RR",
"player": "charlie",
"timestamp": "2026-02-21 11:02:05"
},
{
"text": "Paid $50 rent to alice",
"player": "charlie" "player": "charlie"
}, },
{ {
"text": "alice's turn \u2014 $548 on Pennsylvania RR", "text": "Landed on New York ave. (O)",
"player": "charlie",
"timestamp": "2026-02-21 11:03:41"
},
{
"text": "alice's turn \u2014 $912 on States ave. (V)",
"player": "alice", "player": "alice",
"timestamp": "2026-02-21 11:02:07" "timestamp": "2026-02-21 11:03:42"
},
{
"text": "roll is 5, 1",
"player": "alice",
"timestamp": "2026-02-21 11:02:08"
},
{
"text": "Landed on Kentucky ave. (R)",
"player": "alice",
"timestamp": "2026-02-21 11:02:09"
},
{
"text": "bob's turn \u2014 $1265 on Tennessee ave. (O)",
"player": "bob",
"timestamp": "2026-02-21 11:02:10"
},
{
"text": "roll is 1, 1",
"player": "bob",
"timestamp": "2026-02-21 11:02:11"
},
{
"text": "Landed on Free Parking",
"player": "bob",
"timestamp": "2026-02-21 11:02:11"
},
{
"text": "bob's turn \u2014 $1265 on Free Parking",
"player": "bob",
"timestamp": "2026-02-21 11:02:13"
}, },
{ {
"text": "roll is 1, 2", "text": "roll is 1, 2",
"player": "bob", "player": "alice",
"timestamp": "2026-02-21 11:02:14" "timestamp": "2026-02-21 11:03:43"
}, },
{ {
"text": "Landed on Indiana ave. (R)", "text": "Landed on St. James pl. (O)",
"player": "alice",
"timestamp": "2026-02-21 11:03:43"
},
{
"text": "Paid $14 rent to bob",
"player": "alice"
},
{
"text": "bob's turn \u2014 $1113 on Pennsylvania RR",
"player": "bob", "player": "bob",
"timestamp": "2026-02-21 11:02:14" "timestamp": "2026-02-21 11:03:45"
},
{
"text": "roll is 5, 1",
"player": "bob",
"timestamp": "2026-02-21 11:03:46"
},
{
"text": "Landed on Kentucky ave. (R)",
"player": "bob",
"timestamp": "2026-02-21 11:03:47"
}, },
{ {
"text": "Paid $36 rent to alice", "text": "Paid $36 rent to alice",
"player": "bob" "player": "bob"
}, },
{ {
"text": "charlie's turn \u2014 $28 on Pennsylvania RR", "text": "charlie's turn \u2014 $320 on New York ave. (O)",
"player": "charlie", "player": "charlie",
"timestamp": "2026-02-21 11:02:16" "timestamp": "2026-02-21 11:03:48"
},
{
"text": "roll is 6, 5",
"player": "charlie",
"timestamp": "2026-02-21 11:03:50"
},
{
"text": "Landed on GO TO JAIL!",
"player": "charlie",
"timestamp": "2026-02-21 11:03:50"
},
{
"text": "alice's turn \u2014 $934 on St. James pl. (O)",
"player": "alice",
"timestamp": "2026-02-21 11:03:51"
}, },
{ {
"text": "roll is 5, 4", "text": "roll is 5, 4",
"player": "charlie", "player": "alice",
"timestamp": "2026-02-21 11:02:17" "timestamp": "2026-02-21 11:03:52"
}, },
{ {
"text": "Landed on Illinois ave. (R)", "text": "Landed on B&O RR",
"player": "charlie", "player": "alice",
"timestamp": "2026-02-21 11:02:18" "timestamp": "2026-02-21 11:03:52"
}, },
{ {
"text": "Paid $40 rent to alice", "text": "Paid $50 rent to bob",
"player": "charlie" "player": "alice"
},
{
"text": "bob's turn \u2014 $1127 on Kentucky ave. (R)",
"player": "bob",
"timestamp": "2026-02-21 11:03:54"
},
{
"text": "bob's turn \u2014 $1127 on Kentucky ave. (R)",
"player": "bob",
"timestamp": "2026-02-21 11:04:04"
},
{
"text": "roll is 6, 6",
"player": "bob",
"timestamp": "2026-02-21 11:04:05"
},
{
"text": "Landed on Community Chest iii",
"player": "bob",
"timestamp": "2026-02-21 11:04:06"
},
{
"text": "Bank Error in Your Favor.",
"player": "bob"
},
{
"text": "bob's turn \u2014 $1327 on Community Chest iii",
"player": "bob",
"timestamp": "2026-02-21 11:04:09"
},
{
"text": "roll is 2, 2",
"player": "bob",
"timestamp": "2026-02-21 11:04:10"
},
{
"text": "Landed on Park place (D)",
"player": "bob",
"timestamp": "2026-02-21 11:04:10"
},
{
"text": "Paid $35 rent to alice",
"player": "bob"
},
{
"text": "bob's turn \u2014 $1292 on Park place (D)",
"player": "bob",
"timestamp": "2026-02-21 11:04:12"
} }
], ],
"lastUpdated": "2026-02-21T11:02:22.605328+00:00" "lastUpdated": "2026-02-21T11:04:14.993563+00:00"
} }

View file

@ -111,6 +111,69 @@ h1 { color: #fff; font-size: 1.5em; text-align: center; }
} }
.board-center .subtitle { font-size: 0.35em; color: #888; letter-spacing: 0; margin-top: 8px; font-weight: normal; } .board-center .subtitle { font-size: 0.35em; color: #888; letter-spacing: 0; margin-top: 8px; font-weight: normal; }
/* ===== ZERO STATE ===== */
.zero-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 80vh; text-align: center; gap: 20px; padding: 40px;
}
.zero-state .dice { font-size: 5em; animation: dice-bounce 2s ease-in-out infinite; }
@keyframes dice-bounce {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-20px) rotate(15deg); }
}
.zero-state h2 { color: #fff; font-size: 2em; }
.zero-state p { color: #888; font-size: 1.1em; max-width: 400px; line-height: 1.5; }
/* ===== DISCONNECTED OVERLAY ===== */
.disconnected-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 1000; backdrop-filter: blur(3px);
}
.disconnected-overlay .message {
background: #16213e; border: 2px solid #e74c3c; border-radius: 12px;
padding: 40px; text-align: center; max-width: 400px;
}
.disconnected-overlay .icon { font-size: 3em; margin-bottom: 15px; }
.disconnected-overlay h3 { color: #e74c3c; margin-bottom: 10px; }
.disconnected-overlay p { color: #888; font-size: 0.9em; }
.disconnected-overlay .retry { color: #555; font-size: 0.8em; margin-top: 15px; }
body.greyed-out .container { filter: grayscale(80%) brightness(0.5); pointer-events: none; }
/* ===== GAME OVER ===== */
.game-over-banner {
position: fixed; top: 0; left: 0; right: 0;
background: linear-gradient(135deg, #ffd700, #ff8c00);
color: #1a1a2e; text-align: center; padding: 20px;
font-size: 1.5em; font-weight: bold; z-index: 500;
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.5);
animation: banner-slide 0.5s ease-out;
}
@keyframes banner-slide { from { transform: translateY(-100%); } to { transform: translateY(0); } }
.game-over-banner .winner { font-size: 1.4em; }
.game-over-banner .sub { font-size: 0.5em; font-weight: normal; color: #333; }
body.game-over .container { padding-top: 100px; }
/* Confetti canvas */
#confetti-canvas {
position: fixed; inset: 0; z-index: 499; pointer-events: none;
}
/* ===== DEBUG PANEL ===== */
.debug-panel {
position: fixed; bottom: 10px; right: 10px; background: #0d1117;
border: 1px solid #30363d; border-radius: 8px; padding: 12px;
font-size: 0.75em; z-index: 2000; display: none;
}
.debug-panel.visible { display: block; }
.debug-panel h4 { color: #58a6ff; margin-bottom: 8px; }
.debug-panel button {
display: block; width: 100%; margin: 4px 0; padding: 6px 10px;
background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
border-radius: 4px; cursor: pointer; text-align: left; font-size: 1em;
}
.debug-panel button:hover { background: #30363d; }
@media (max-width: 600px) { @media (max-width: 600px) {
.board-wrapper { width: 95vw; height: 95vw; } .board-wrapper { width: 95vw; height: 95vw; }
.square { font-size: 0.4em; } .square { font-size: 0.4em; }
@ -121,7 +184,17 @@ h1 { color: #fff; font-size: 1.5em; text-align: center; }
</style> </style>
</head> </head>
<body> <body>
<div class="container">
<!-- Zero state (shown when no game exists) -->
<div class="zero-state" id="zero-state" style="display:none">
<div class="dice">🎲</div>
<h2>No Game in Progress</h2>
<p>Start a Monopoly game on IRC and the board will appear here automatically.</p>
<div style="color:#555; font-size:0.85em">Checking for game data every 2 seconds...</div>
</div>
<!-- Main game UI -->
<div class="container" id="game-container" style="display:none">
<div> <div>
<h1>🎲 Monopoly Board</h1> <h1>🎲 Monopoly Board</h1>
<div class="status" id="status">Loading game state...</div> <div class="status" id="status">Loading game state...</div>
@ -140,6 +213,35 @@ h1 { color: #fff; font-size: 1.5em; text-align: center; }
</div> </div>
</div> </div>
<!-- Disconnected overlay -->
<div class="disconnected-overlay" id="disconnect-overlay" style="display:none">
<div class="message">
<div class="icon">📡</div>
<h3>Connection Lost</h3>
<p>Unable to reach the game server. The bridge may be down or the network is unreachable.</p>
<div class="retry" id="disconnect-retry">Retrying in <span id="retry-countdown">--</span>s...</div>
</div>
</div>
<!-- Confetti canvas -->
<canvas id="confetti-canvas"></canvas>
<!-- Game over banner -->
<div class="game-over-banner" id="game-over-banner" style="display:none">
<div class="winner" id="winner-name">🏆 WINS!</div>
<div class="sub">Game Over</div>
</div>
<!-- Debug panel (hidden, activate with Ctrl+Shift+D) -->
<div class="debug-panel" id="debug-panel">
<h4>🔧 Debug States</h4>
<button onclick="debugState('zero')">Zero State (no game)</button>
<button onclick="debugState('playing')">Playing (demo)</button>
<button onclick="debugState('gameover')">Game Over (confetti)</button>
<button onclick="debugState('disconnected')">Disconnected</button>
<button onclick="debugState('reset')">Reset (live mode)</button>
</div>
<script> <script>
const PLAYER_COLORS = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c','#e91e63','#00bcd4','#ff5722']; const PLAYER_COLORS = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c','#e91e63','#00bcd4','#ff5722'];
@ -150,21 +252,37 @@ const GROUP_COLORS = {
}; };
// Board square positions: [row, col] in an 11x11 grid // Board square positions: [row, col] in an 11x11 grid
// Bottom row (right to left): squares 0-10
// Left col (bottom to top): squares 10-20
// Top row (left to right): squares 20-30
// Right col (top to bottom): squares 30-39 + 0
const POSITIONS = []; const POSITIONS = [];
// Bottom row: 0=Go (bottom-right corner) to 10 (bottom-left corner)
for (let i = 0; i <= 10; i++) POSITIONS[i] = [10, 10 - i]; for (let i = 0; i <= 10; i++) POSITIONS[i] = [10, 10 - i];
// Left column: 11-19 (going up, excluding corners)
for (let i = 1; i <= 9; i++) POSITIONS[10 + i] = [10 - i, 0]; for (let i = 1; i <= 9; i++) POSITIONS[10 + i] = [10 - i, 0];
// Top row: 20=Free Parking (top-left) to 30 (top-right)
for (let i = 0; i <= 10; i++) POSITIONS[20 + i] = [0, i]; for (let i = 0; i <= 10; i++) POSITIONS[20 + i] = [0, i];
// Right column: 31-39 (going down, excluding corners)
for (let i = 1; i <= 9; i++) POSITIONS[30 + i] = [i, 10]; for (let i = 1; i <= 9; i++) POSITIONS[30 + i] = [i, 10];
// ===== STATE MANAGEMENT =====
let lastUpdate = '';
let currentView = 'loading'; // loading, zero, playing, gameover, disconnected
let debugMode = null; // null = live, or a forced state name
let consecutiveFailures = 0;
let retryCountdown = 0;
let retryTimer = null;
let gameOverShown = false; // only trigger confetti once per game
let confettiRunning = false;
function showView(view) {
currentView = view;
document.getElementById('zero-state').style.display = view === 'zero' ? 'flex' : 'none';
document.getElementById('game-container').style.display = (view === 'playing' || view === 'gameover') ? 'flex' : 'none';
document.getElementById('disconnect-overlay').style.display = view === 'disconnected' ? 'flex' : 'none';
document.getElementById('game-over-banner').style.display = view === 'gameover' ? 'block' : 'none';
document.body.classList.toggle('greyed-out', view === 'disconnected');
document.body.classList.toggle('game-over', view === 'gameover');
if (view !== 'gameover') {
stopConfetti();
}
}
// ===== RENDERING =====
function renderBoard(state) { function renderBoard(state) {
const board = document.getElementById('board'); const board = document.getElementById('board');
board.innerHTML = ''; board.innerHTML = '';
@ -180,7 +298,6 @@ function renderBoard(state) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'square' + (sq.mortgaged ? ' mortgaged' : ''); el.className = 'square' + (sq.mortgaged ? ' mortgaged' : '');
// Position and size
let x, y, w, h; let x, y, w, h;
const isCorner = [0, 10, 20, 30].includes(idx); const isCorner = [0, 10, 20, 30].includes(idx);
@ -189,12 +306,10 @@ function renderBoard(state) {
x = col === 0 ? 0 : col === 10 ? bw - cornerSize : col * sideSize; x = col === 0 ? 0 : col === 10 ? bw - cornerSize : col * sideSize;
y = row === 0 ? 0 : row === 10 ? bw - cornerSize : row * sideSize; y = row === 0 ? 0 : row === 10 ? bw - cornerSize : row * sideSize;
} else if (row === 0 || row === 10) { } else if (row === 0 || row === 10) {
// Top or bottom row
w = sideSize; h = cornerSize; w = sideSize; h = cornerSize;
x = cornerSize + (col - 1) * sideSize; x = cornerSize + (col - 1) * sideSize;
y = row === 0 ? 0 : bw - cornerSize; y = row === 0 ? 0 : bw - cornerSize;
} else { } else {
// Left or right column
w = cornerSize; h = sideSize; w = cornerSize; h = sideSize;
x = col === 0 ? 0 : bw - cornerSize; x = col === 0 ? 0 : bw - cornerSize;
y = cornerSize + (row - 1) * sideSize; y = cornerSize + (row - 1) * sideSize;
@ -205,12 +320,10 @@ function renderBoard(state) {
el.style.width = w + 'px'; el.style.width = w + 'px';
el.style.height = h + 'px'; el.style.height = h + 'px';
// Color bar for property groups
if (sq.group && GROUP_COLORS[sq.group]) { if (sq.group && GROUP_COLORS[sq.group]) {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'color-bar'; bar.className = 'color-bar';
bar.style.backgroundColor = GROUP_COLORS[sq.group]; bar.style.backgroundColor = GROUP_COLORS[sq.group];
// Bar on the inside edge
if (row === 10) { bar.style.top = '0'; bar.style.bottom = ''; } if (row === 10) { bar.style.top = '0'; bar.style.bottom = ''; }
else if (row === 0) { bar.style.top = ''; bar.style.bottom = '0'; bar.style.top = 'auto'; } else if (row === 0) { bar.style.top = ''; bar.style.bottom = '0'; bar.style.top = 'auto'; }
else if (col === 0) { bar.style.top = '0'; bar.style.left = ''; bar.style.right = '0'; bar.style.width = '6px'; bar.style.height = '100%'; } else if (col === 0) { bar.style.top = '0'; bar.style.left = ''; bar.style.right = '0'; bar.style.width = '6px'; bar.style.height = '100%'; }
@ -218,13 +331,11 @@ function renderBoard(state) {
el.appendChild(bar); el.appendChild(bar);
} }
// Name
const nameEl = document.createElement('div'); const nameEl = document.createElement('div');
nameEl.className = 'name'; nameEl.className = 'name';
nameEl.textContent = sq.name.replace(' Ave.', '').replace(' Place', ' Pl').replace(' Railroad', ' RR').replace('Community Chest', 'Comm. Chest').replace('Pennsylvania', 'Penn.'); nameEl.textContent = sq.name.replace(' Ave.', '').replace(' Place', ' Pl').replace(' Railroad', ' RR').replace('Community Chest', 'Comm. Chest').replace('Pennsylvania', 'Penn.');
el.appendChild(nameEl); el.appendChild(nameEl);
// Cost
if (sq.cost > 0) { if (sq.cost > 0) {
const costEl = document.createElement('div'); const costEl = document.createElement('div');
costEl.className = 'cost'; costEl.className = 'cost';
@ -232,7 +343,6 @@ function renderBoard(state) {
el.appendChild(costEl); el.appendChild(costEl);
} }
// Owner indicator
if (sq.owner >= 0 && sq.owner < players.length) { if (sq.owner >= 0 && sq.owner < players.length) {
const ownerEl = document.createElement('div'); const ownerEl = document.createElement('div');
ownerEl.className = 'owner-indicator'; ownerEl.className = 'owner-indicator';
@ -241,7 +351,6 @@ function renderBoard(state) {
el.appendChild(ownerEl); el.appendChild(ownerEl);
} }
// Houses/hotel
if (sq.houses > 0 && sq.houses < 5) { if (sq.houses > 0 && sq.houses < 5) {
const hd = document.createElement('div'); const hd = document.createElement('div');
hd.className = 'houses-display'; hd.className = 'houses-display';
@ -260,7 +369,6 @@ function renderBoard(state) {
el.appendChild(hd); el.appendChild(hd);
} }
// Players on this square
const playersHere = players.filter(p => p.location === idx); const playersHere = players.filter(p => p.location === idx);
if (playersHere.length > 0) { if (playersHere.length > 0) {
const ph = document.createElement('div'); const ph = document.createElement('div');
@ -289,41 +397,38 @@ function renderPlayers(state) {
players.forEach((p, idx) => { players.forEach((p, idx) => {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.className = 'player-panel' + (idx === state.currentPlayer ? ' current-turn' : ''); panel.className = 'player-panel' + (p.number === state.currentPlayer ? ' current-turn' : '');
const color = PLAYER_COLORS[idx % PLAYER_COLORS.length]; const color = PLAYER_COLORS[idx % PLAYER_COLORS.length];
let html = `<h3><span class="player-token" style="background:${color};width:20px;height:20px;font-size:11px">${p.name.charAt(0).toUpperCase()}</span> ${p.name}`; let html = `<h3><span class="player-token" style="background:${color};width:20px;height:20px;font-size:11px">${p.name.charAt(0).toUpperCase()}</span> ${p.name}`;
if (idx === state.currentPlayer) html += ' 🎲'; if (p.number === state.currentPlayer) html += ' 🎲';
html += '</h3>'; html += '</h3>';
html += `<div class="money ${p.money < 0 ? 'negative' : ''}">$${p.money.toLocaleString()}</div>`; html += `<div class="money ${p.money < 0 ? 'negative' : ''}">$${p.money.toLocaleString()}</div>`;
if (p.goJailFreeCards > 0) { if (p.getOutOfJailFreeCards > 0) {
html += `<div style="font-size:0.8em;margin-top:4px">🃏 ${p.goJailFreeCards} GOJF card${p.goJailFreeCards > 1 ? 's' : ''}</div>`; html += `<div style="font-size:0.8em;margin-top:4px">🃏 ${p.getOutOfJailFreeCards} GOJF card${p.getOutOfJailFreeCards > 1 ? 's' : ''}</div>`;
} }
if (p.inJail) { if (p.inJail) {
html += `<div style="font-size:0.8em;color:#e74c3c;margin-top:4px">🔒 In Jail</div>`; html += `<div style="font-size:0.8em;color:#e74c3c;margin-top:4px">🔒 In Jail (turn ${p.jailTurns})</div>`;
} }
// Location const loc = p.location === 40 ? { name: 'JAIL' } : squares[p.location];
const loc = squares[p.location];
if (loc) { if (loc) {
html += `<div style="font-size:0.8em;margin-top:4px;color:#aaa">📍 ${loc.name}</div>`; html += `<div style="font-size:0.8em;margin-top:4px;color:#aaa">📍 ${loc.name}</div>`;
} }
// Properties // Properties owned by this player
if (p.properties && p.properties.length > 0) { const owned = squares.filter(sq => sq.owner === p.number);
if (owned.length > 0) {
html += '<div class="props">'; html += '<div class="props">';
p.properties.forEach(sid => { owned.forEach(sq => {
const sq = squares[sid]; const gc = sq.group ? GROUP_COLORS[sq.group] || '#666' : '#666';
if (sq) { html += `<div class="prop-item"><span class="prop-dot" style="background:${gc}"></span>${sq.name}`;
const gc = sq.group ? GROUP_COLORS[sq.group] || '#666' : '#666'; if (sq.houses > 0 && sq.houses < 5) html += ` 🏠×${sq.houses}`;
html += `<div class="prop-item"><span class="prop-dot" style="background:${gc}"></span>${sq.name}`; else if (sq.houses >= 5) html += ' 🏨';
if (sq.houses > 0 && sq.houses < 5) html += ` 🏠×${sq.houses}`; if (sq.mortgaged) html += ' (M)';
else if (sq.houses >= 5) html += ' 🏨'; html += '</div>';
if (sq.mortgaged) html += ' (M)';
html += '</div>';
}
}); });
html += '</div>'; html += '</div>';
} }
@ -343,124 +448,317 @@ function renderLog(state) {
}).join(''); }).join('');
} }
let lastUpdate = ''; function renderGame(state) {
async function fetchState() { renderBoard(state);
renderPlayers(state);
renderLog(state);
}
// ===== CONFETTI =====
const confettiCanvas = document.getElementById('confetti-canvas');
const confettiCtx = confettiCanvas.getContext('2d');
let confettiPieces = [];
let confettiAnimFrame = null;
function resizeConfetti() {
confettiCanvas.width = window.innerWidth;
confettiCanvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeConfetti);
resizeConfetti();
function startConfetti() {
if (confettiRunning) return;
confettiRunning = true;
confettiPieces = [];
const colors = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#ffd700','#e91e63','#00bcd4'];
for (let i = 0; i < 200; i++) {
confettiPieces.push({
x: Math.random() * confettiCanvas.width,
y: Math.random() * confettiCanvas.height - confettiCanvas.height,
w: Math.random() * 10 + 5,
h: Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
vx: (Math.random() - 0.5) * 3,
vy: Math.random() * 3 + 2,
spin: Math.random() * 0.2 - 0.1,
angle: Math.random() * Math.PI * 2,
opacity: 1,
});
}
animateConfetti();
}
function animateConfetti() {
confettiCtx.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height);
let alive = 0;
confettiPieces.forEach(p => {
if (p.opacity <= 0) return;
alive++;
p.x += p.vx;
p.y += p.vy;
p.angle += p.spin;
p.vy += 0.05; // gravity
// Fade out when past bottom
if (p.y > confettiCanvas.height) {
p.opacity -= 0.02;
}
confettiCtx.save();
confettiCtx.translate(p.x, p.y);
confettiCtx.rotate(p.angle);
confettiCtx.globalAlpha = Math.max(0, p.opacity);
confettiCtx.fillStyle = p.color;
confettiCtx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
confettiCtx.restore();
});
if (alive > 0) {
confettiAnimFrame = requestAnimationFrame(animateConfetti);
} else {
confettiRunning = false;
}
}
function stopConfetti() {
if (confettiAnimFrame) {
cancelAnimationFrame(confettiAnimFrame);
confettiAnimFrame = null;
}
confettiRunning = false;
confettiCtx.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height);
}
// ===== GAME OVER DETECTION =====
function findWinner(state) {
const log = state.log || [];
for (let i = log.length - 1; i >= 0; i--) {
const m = log[i].text.match(/^(.+?) WINS!$/);
if (m) return m[1];
}
// If only one player left, they won
if (state.players && state.players.length === 1) {
return state.players[0].name;
}
return null;
}
function isGameOver(state) {
// Check log for WINS
const winner = findWinner(state);
if (winner) return true;
// Check if state explicitly says over (future-proofing)
if (state.phase === 'over') return true;
return false;
}
// ===== CONNECTION MONITORING =====
function showDisconnected() {
if (currentView === 'disconnected') return;
// Show the overlay on top of whatever was showing
document.getElementById('disconnect-overlay').style.display = 'flex';
document.body.classList.add('greyed-out');
startRetryCountdown();
}
function hideDisconnected() {
document.getElementById('disconnect-overlay').style.display = 'none';
document.body.classList.remove('greyed-out');
consecutiveFailures = 0;
if (retryTimer) { clearInterval(retryTimer); retryTimer = null; }
}
function startRetryCountdown() {
retryCountdown = 5;
document.getElementById('retry-countdown').textContent = retryCountdown;
if (retryTimer) clearInterval(retryTimer);
retryTimer = setInterval(() => {
retryCountdown--;
document.getElementById('retry-countdown').textContent = Math.max(0, retryCountdown);
}, 1000);
}
// ===== MAIN UPDATE LOOP =====
async function update() {
if (debugMode) return; // paused during debug
try { try {
const resp = await fetch('game-state.json?t=' + Date.now()); const resp = await fetch('game-state.json?t=' + Date.now());
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
const state = await resp.json(); const state = await resp.json();
// Success — clear failure state
if (consecutiveFailures >= 3) hideDisconnected();
consecutiveFailures = 0;
// Determine what to show
if (!state.players || state.players.length === 0) {
// No players = no game
showView('zero');
lastUpdate = '';
gameOverShown = false;
return;
}
// Check for game over
if (isGameOver(state)) {
const updateStr = JSON.stringify(state);
if (updateStr !== lastUpdate) {
lastUpdate = updateStr;
renderGame(state);
}
if (!gameOverShown) {
gameOverShown = true;
const winner = findWinner(state);
document.getElementById('winner-name').textContent = `🏆 ${winner || 'Someone'} WINS!`;
showView('gameover');
startConfetti();
}
document.getElementById('status').textContent =
`Game Over · ${state.players.length} players · ${new Date(state.lastUpdated).toLocaleTimeString()}`;
return;
}
// Active game
gameOverShown = false;
const updateStr = JSON.stringify(state); const updateStr = JSON.stringify(state);
if (updateStr !== lastUpdate) { if (updateStr !== lastUpdate) {
lastUpdate = updateStr; lastUpdate = updateStr;
renderBoard(state); showView('playing');
renderPlayers(state); renderGame(state);
renderLog(state);
} }
document.getElementById('status').textContent = document.getElementById('status').textContent =
`Last updated: ${state.lastUpdated ? new Date(state.lastUpdated).toLocaleString() : 'never'} · ${state.players?.length || 0} players`; `Live · ${state.players.length} players · Updated: ${new Date(state.lastUpdated).toLocaleTimeString()}`;
} catch (e) { } catch (e) {
document.getElementById('status').textContent = 'Waiting for game-state.json... (' + e.message + ')'; consecutiveFailures++;
if (consecutiveFailures >= 3) {
showDisconnected();
}
} }
} }
// Generate demo state if no real game is running // ===== DEMO / TEST STATES =====
function demoState() { function demoSquares() {
const squares = [ const names = [
{id:0,name:"Go",type:"safe",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]}, "=== GO ===","Mediterranean ave. (P)","Community Chest i","Baltic ave. (P)","Income Tax",
{id:1,name:"Mediterranean Ave.",type:"property",owner:0,cost:60,mortgaged:false,houses:2,monopoly:true,group:"purple",rent:[2,10,30,90,160,250]}, "Reading RR","Oriental ave. (L)","Chance i","Vermont ave. (L)","Connecticut ave. (L)",
{id:2,name:"Community Chest",type:"cc",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]}, "Just Visiting","St. Charles pl. (V)","Electric Co.","States ave. (V)","Virginia ave. (V)",
{id:3,name:"Baltic Ave.",type:"property",owner:0,cost:60,mortgaged:false,houses:1,monopoly:true,group:"purple",rent:[4,20,60,180,320,450]}, "Pennsylvania RR","St. James pl. (O)","Community Chest ii","Tennessee ave. (O)","New York ave. (O)",
{id:4,name:"Income Tax",type:"tax",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]}, "Free Parking","Kentucky ave. (R)","Chance ii","Indiana ave. (R)","Illinois ave. (R)",
{id:5,name:"Reading Railroad",type:"railroad",owner:1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]}, "B&O RR","Atlantic ave. (Y)","Ventnor ave. (Y)","Water Works","Marvin Gardens (Y)",
{id:6,name:"Oriental Ave.",type:"property",owner:-1,cost:100,mortgaged:false,houses:0,monopoly:false,group:"lightblue",rent:[6]}, "GO TO JAIL","Pacific ave. (G)","N. Carolina ave. (G)","Community Chest iii","Pennsylvania ave. (G)",
{id:7,name:"Chance",type:"chance",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]}, "Short Line RR","Chance iii","Park place (D)","Luxury Tax","Boardwalk (D)"
{id:8,name:"Vermont Ave.",type:"property",owner:-1,cost:100,mortgaged:false,houses:0,monopoly:false,group:"lightblue",rent:[6]},
{id:9,name:"Connecticut Ave.",type:"property",owner:2,cost:120,mortgaged:false,houses:0,monopoly:false,group:"lightblue",rent:[8]},
{id:10,name:"Just Visiting",type:"jail",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:11,name:"St. Charles Place",type:"property",owner:1,cost:140,mortgaged:false,houses:0,monopoly:false,group:"pink",rent:[10]},
{id:12,name:"Electric Company",type:"utility",owner:-1,cost:150,mortgaged:false,houses:0,monopoly:false,group:"utility",rent:[0]},
{id:13,name:"States Ave.",type:"property",owner:-1,cost:140,mortgaged:false,houses:0,monopoly:false,group:"pink",rent:[10]},
{id:14,name:"Virginia Ave.",type:"property",owner:-1,cost:160,mortgaged:false,houses:0,monopoly:false,group:"pink",rent:[12]},
{id:15,name:"Pennsylvania Railroad",type:"railroad",owner:-1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]},
{id:16,name:"St. James Place",type:"property",owner:2,cost:180,mortgaged:false,houses:3,monopoly:true,group:"orange",rent:[14,70,200,550,750,950]},
{id:17,name:"Community Chest",type:"cc",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:18,name:"Tennessee Ave.",type:"property",owner:2,cost:180,mortgaged:false,houses:2,monopoly:true,group:"orange",rent:[14,70,200,550,750,950]},
{id:19,name:"New York Ave.",type:"property",owner:2,cost:200,mortgaged:false,houses:0,monopoly:true,group:"orange",rent:[16]},
{id:20,name:"Free Parking",type:"safe",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:21,name:"Kentucky Ave.",type:"property",owner:-1,cost:220,mortgaged:false,houses:0,monopoly:false,group:"red",rent:[18]},
{id:22,name:"Chance",type:"chance",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:23,name:"Indiana Ave.",type:"property",owner:-1,cost:220,mortgaged:false,houses:0,monopoly:false,group:"red",rent:[18]},
{id:24,name:"Illinois Ave.",type:"property",owner:0,cost:240,mortgaged:true,houses:0,monopoly:false,group:"red",rent:[20]},
{id:25,name:"B&O Railroad",type:"railroad",owner:-1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]},
{id:26,name:"Atlantic Ave.",type:"property",owner:-1,cost:260,mortgaged:false,houses:0,monopoly:false,group:"yellow",rent:[22]},
{id:27,name:"Ventnor Ave.",type:"property",owner:-1,cost:260,mortgaged:false,houses:0,monopoly:false,group:"yellow",rent:[22]},
{id:28,name:"Water Works",type:"utility",owner:1,cost:150,mortgaged:false,houses:0,monopoly:false,group:"utility",rent:[0]},
{id:29,name:"Marvin Gardens",type:"property",owner:-1,cost:280,mortgaged:false,houses:0,monopoly:false,group:"yellow",rent:[24]},
{id:30,name:"Go to Jail",type:"gotojail",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:31,name:"Pacific Ave.",type:"property",owner:-1,cost:300,mortgaged:false,houses:0,monopoly:false,group:"green",rent:[26]},
{id:32,name:"North Carolina Ave.",type:"property",owner:-1,cost:300,mortgaged:false,houses:0,monopoly:false,group:"green",rent:[26]},
{id:33,name:"Community Chest",type:"cc",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:34,name:"Pennsylvania Ave.",type:"property",owner:-1,cost:320,mortgaged:false,houses:0,monopoly:false,group:"green",rent:[28]},
{id:35,name:"Short Line Railroad",type:"railroad",owner:-1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]},
{id:36,name:"Chance",type:"chance",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:37,name:"Park Place",type:"property",owner:-1,cost:350,mortgaged:false,houses:0,monopoly:false,group:"darkblue",rent:[35]},
{id:38,name:"Luxury Tax",type:"tax",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:39,name:"Boardwalk",type:"property",owner:1,cost:400,mortgaged:false,houses:5,monopoly:false,group:"darkblue",rent:[50,200,600,1400,1700,2000]},
]; ];
const groups = [
null,"purple",null,"purple",null,"railroad","lightblue",null,"lightblue","lightblue",
null,"violet","utility","violet","violet","railroad","orange",null,"orange","orange",
null,"red",null,"red","red","railroad","yellow","yellow","utility","yellow",
null,"green","green",null,"green","railroad",null,"darkblue",null,"darkblue"
];
const costs = [
0,60,0,60,0,200,100,0,100,120,0,140,150,140,160,200,180,0,180,200,
0,220,0,220,240,200,260,260,150,280,0,300,300,0,320,200,0,350,0,400
];
const types = [
"safe","property","cc","property","tax","railroad","property","chance","property","property",
"safe","property","utility","property","property","railroad","property","cc","property","property",
"safe","property","chance","property","property","railroad","property","property","utility","property",
"gotojail","property","property","cc","property","railroad","chance","property","tax","property"
];
return names.map((name, i) => ({
id: i, name, type: types[i], group: groups[i], cost: costs[i],
owner: null, mortgaged: false, houses: 0
}));
}
function demoPlayingState() {
const sq = demoSquares();
sq[1].owner = 1; sq[3].owner = 1; sq[1].houses = 2; sq[3].houses = 1;
sq[5].owner = 2; sq[39].owner = 2; sq[39].houses = 5;
sq[11].owner = 2; sq[28].owner = 2;
sq[9].owner = 3; sq[16].owner = 3; sq[18].owner = 3; sq[19].owner = 3;
sq[16].houses = 3; sq[18].houses = 2;
sq[24].owner = 1; sq[24].mortgaged = true;
return { return {
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
currentPlayer: 1, currentPlayer: 2,
players: [ players: [
{name:"Alice",number:1,money:820,location:24,inJail:false,jailTurns:0,goJailFreeCards:1,properties:[1,3,24],numRailroads:0,numUtilities:0}, {name:"Alice",number:1,money:820,location:24,inJail:false,jailTurns:0,doublesCount:0,getOutOfJailFreeCards:1},
{name:"Bob",number:2,money:1350,location:39,inJail:false,jailTurns:0,goJailFreeCards:0,properties:[5,11,28,39],numRailroads:1,numUtilities:1}, {name:"Bob",number:2,money:1350,location:39,inJail:false,jailTurns:0,doublesCount:0,getOutOfJailFreeCards:0},
{name:"Charlie",number:3,money:640,location:10,inJail:true,jailTurns:2,goJailFreeCards:0,properties:[9,16,18,19],numRailroads:0,numUtilities:0}, {name:"Charlie",number:3,money:640,location:40,inJail:true,jailTurns:2,doublesCount:0,getOutOfJailFreeCards:0},
], ],
squares: squares, squares: sq,
log: [ log: [
{timestamp:new Date(Date.now()-60000).toISOString(),text:"roll is 4, 3",player:"Alice"}, {timestamp:new Date(Date.now()-60000).toISOString(),text:"roll is 4, 3",player:"Alice"},
{timestamp:new Date(Date.now()-55000).toISOString(),text:"Landed on Illinois Ave.",player:"Alice"}, {timestamp:new Date(Date.now()-50000).toISOString(),text:"Landed on Illinois ave. (R)",player:"Alice"},
{timestamp:new Date(Date.now()-50000).toISOString(),text:"roll is 6, 6",player:"Bob"}, {timestamp:new Date(Date.now()-40000).toISOString(),text:"roll is 6, 6",player:"Bob"},
{timestamp:new Date(Date.now()-45000).toISOString(),text:"Passed Go, collected $200",player:"Bob"}, {timestamp:new Date(Date.now()-30000).toISOString(),text:"Passed GO — collected $200",player:"Bob"},
{timestamp:new Date(Date.now()-40000).toISOString(),text:"Landed on Boardwalk",player:"Bob"}, {timestamp:new Date(Date.now()-20000).toISOString(),text:"Landed on Boardwalk (D)",player:"Bob"},
{timestamp:new Date(Date.now()-35000).toISOString(),text:"Bob rolled doubles",player:"Bob"},
{timestamp:new Date(Date.now()-30000).toISOString(),text:"roll is 2, 5",player:"Bob"},
{timestamp:new Date(Date.now()-25000).toISOString(),text:"Paid $550 rent (3 houses)",player:"Bob"},
{timestamp:new Date(Date.now()-20000).toISOString(),text:"Charlie's turn",player:"Charlie"},
{timestamp:new Date(Date.now()-10000).toISOString(),text:"Still in jail (turn 2)",player:"Charlie"}, {timestamp:new Date(Date.now()-10000).toISOString(),text:"Still in jail (turn 2)",player:"Charlie"},
] ]
}; };
} }
// Try to fetch real state, fall back to demo function demoGameOverState() {
async function update() { const st = demoPlayingState();
try { st.players = [
const resp = await fetch('game-state.json?t=' + Date.now()); {name:"Bob",number:1,money:4250,location:39,inJail:false,jailTurns:0,doublesCount:0,getOutOfJailFreeCards:2},
if (!resp.ok) throw new Error('no file'); ];
const state = await resp.json(); st.currentPlayer = 1;
if (!state.players || state.players.length === 0) throw new Error('empty'); st.log.push({timestamp:new Date().toISOString(), text:"Bob WINS!", player:"Bob"});
const updateStr = JSON.stringify(state); return st;
if (updateStr !== lastUpdate) { }
lastUpdate = updateStr;
renderBoard(state); // ===== DEBUG MODE =====
renderPlayers(state); function debugState(mode) {
renderLog(state); stopConfetti();
document.getElementById('status').textContent = gameOverShown = false;
`Live · ${state.players.length} players · Updated: ${new Date(state.lastUpdated).toLocaleTimeString()}`;
} if (mode === 'reset') {
} catch (e) { debugMode = null;
// Use demo state lastUpdate = '';
const demo = demoState(); hideDisconnected();
const demoStr = 'demo'; update();
if (lastUpdate !== demoStr) { return;
lastUpdate = demoStr; }
renderBoard(demo);
renderPlayers(demo); debugMode = mode;
renderLog(demo);
document.getElementById('status').textContent = 'Demo mode (no live game detected)'; if (mode === 'zero') {
} showView('zero');
} else if (mode === 'playing') {
const st = demoPlayingState();
showView('playing');
renderGame(st);
document.getElementById('status').textContent = 'Debug: Playing (demo data)';
} else if (mode === 'gameover') {
const st = demoGameOverState();
showView('gameover');
renderGame(st);
const winner = findWinner(st);
document.getElementById('winner-name').textContent = `🏆 ${winner} WINS!`;
startConfetti();
document.getElementById('status').textContent = 'Debug: Game Over';
} else if (mode === 'disconnected') {
// Show game underneath the overlay
const st = demoPlayingState();
showView('playing');
renderGame(st);
showDisconnected();
} }
} }
// Ctrl+Shift+D to toggle debug panel
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
e.preventDefault();
document.getElementById('debug-panel').classList.toggle('visible');
}
});
// ===== INIT =====
update(); update();
setInterval(update, 2000); setInterval(update, 2000);
</script> </script>