Subtle top banner with dimmed page (not a blocking overlay like the full disconnect state). Shows elapsed time since last update. Separate from the hard disconnect which triggers after 3 fetch failures. Debug panel: added 'Stale' button to test.
834 lines
30 KiB
HTML
834 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Monopoly Board Viewer</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
|
|
|
|
.container { display: flex; flex-direction: column; align-items: center; padding: 20px; gap: 20px; }
|
|
h1 { color: #fff; font-size: 1.5em; text-align: center; }
|
|
.status { font-size: 0.8em; color: #888; }
|
|
|
|
/* Board layout */
|
|
.board-wrapper { position: relative; width: min(90vw, 700px); height: min(90vw, 700px); }
|
|
.board { position: absolute; inset: 0; }
|
|
|
|
/* Squares */
|
|
.square {
|
|
position: absolute;
|
|
border: 1px solid #333;
|
|
background: #16213e;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.55em;
|
|
overflow: hidden;
|
|
cursor: default;
|
|
transition: background 0.2s;
|
|
}
|
|
.square:hover { background: #1a2a50; z-index: 10; }
|
|
.square .name { text-align: center; line-height: 1.1; padding: 1px 2px; font-weight: 500; }
|
|
.square .cost { font-size: 0.85em; color: #aaa; }
|
|
.square .color-bar {
|
|
position: absolute; top: 0; left: 0; right: 0; height: 6px;
|
|
}
|
|
.square .players-here {
|
|
display: flex; flex-wrap: wrap; gap: 1px; margin-top: 2px;
|
|
}
|
|
.player-token {
|
|
width: 12px; height: 12px; border-radius: 50%; display: flex;
|
|
align-items: center; justify-content: center; font-size: 7px;
|
|
font-weight: bold; color: #fff; border: 1px solid rgba(255,255,255,0.3);
|
|
}
|
|
.square .houses-display { display: flex; gap: 1px; margin-top: 1px; }
|
|
.house { width: 6px; height: 6px; background: #2ecc71; }
|
|
.hotel { width: 8px; height: 8px; background: #e74c3c; }
|
|
.mortgaged { opacity: 0.4; }
|
|
.owner-indicator { font-size: 0.7em; color: #ffd700; }
|
|
|
|
/* Corner squares are bigger */
|
|
.corner { width: calc(100% / 8.5); height: calc(100% / 8.5); font-size: 0.65em; }
|
|
|
|
/* Group colors */
|
|
.group-purple { background-color: #6a0dad; }
|
|
.group-lightblue { background-color: #87ceeb; }
|
|
.group-pink { background-color: #d63384; }
|
|
.group-orange { background-color: #e67e22; }
|
|
.group-red { background-color: #e74c3c; }
|
|
.group-yellow { background-color: #f1c40f; }
|
|
.group-green { background-color: #27ae60; }
|
|
.group-darkblue { background-color: #2c3e8a; }
|
|
.group-railroad { background-color: #555; }
|
|
.group-utility { background-color: #777; }
|
|
|
|
/* Player info panel */
|
|
.info-panels { display: flex; flex-wrap: wrap; gap: 15px; justify-content: center; max-width: 900px; width: 100%; }
|
|
.player-panel {
|
|
background: #16213e; border: 2px solid #333; border-radius: 8px;
|
|
padding: 12px; min-width: 200px; flex: 1; max-width: 280px;
|
|
}
|
|
.player-panel h3 { font-size: 1em; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
|
.player-panel .money { font-size: 1.2em; color: #2ecc71; font-weight: bold; }
|
|
.player-panel .money.negative { color: #e74c3c; }
|
|
.player-panel .props { font-size: 0.8em; margin-top: 6px; }
|
|
.player-panel .prop-item { padding: 2px 0; display: flex; align-items: center; gap: 4px; }
|
|
.prop-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
.current-turn { border-color: #ffd700 !important; box-shadow: 0 0 10px rgba(255, 215, 0, 0.3); }
|
|
|
|
/* Game log */
|
|
.game-log {
|
|
background: #16213e; border: 1px solid #333; border-radius: 8px;
|
|
padding: 12px; max-width: 900px; width: 100%; max-height: 200px;
|
|
overflow-y: auto; font-size: 0.85em;
|
|
}
|
|
.game-log h3 { margin-bottom: 8px; }
|
|
.log-entry { padding: 2px 0; border-bottom: 1px solid #1a1a2e; color: #ccc; }
|
|
.log-entry .log-player { color: #ffd700; font-weight: 500; }
|
|
.log-entry .log-time { color: #666; font-size: 0.8em; }
|
|
|
|
/* Center area of board */
|
|
.board-center {
|
|
position: absolute;
|
|
top: calc(100% / 8.5);
|
|
left: calc(100% / 8.5);
|
|
right: calc(100% / 8.5);
|
|
bottom: calc(100% / 8.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-direction: column;
|
|
background: #0f3460;
|
|
border: 2px solid #333;
|
|
font-size: 1.8em;
|
|
font-weight: bold;
|
|
color: #e74c3c;
|
|
text-align: center;
|
|
letter-spacing: 3px;
|
|
}
|
|
.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; }
|
|
|
|
/* ===== STALE STATE ===== */
|
|
.stale-banner {
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 400;
|
|
background: rgba(30, 30, 50, 0.92); border-bottom: 1px solid #444;
|
|
padding: 8px 20px; text-align: center; font-size: 0.85em; color: #aaa;
|
|
display: none; backdrop-filter: blur(2px);
|
|
animation: stale-fade-in 0.3s ease-out;
|
|
}
|
|
@keyframes stale-fade-in { from { opacity: 0; transform: translateY(-100%); } to { opacity: 1; transform: translateY(0); } }
|
|
.stale-banner .icon { margin-right: 6px; }
|
|
.stale-banner .age { color: #f39c12; font-weight: 500; }
|
|
body.stale .container { filter: brightness(0.8) saturate(0.7); }
|
|
|
|
/* ===== 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) {
|
|
.board-wrapper { width: 95vw; height: 95vw; }
|
|
.square { font-size: 0.4em; }
|
|
.player-token { width: 8px; height: 8px; font-size: 5px; }
|
|
.info-panels { flex-direction: column; }
|
|
.player-panel { max-width: 100%; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- 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>
|
|
<h1>🎲 Monopoly Board</h1>
|
|
<div class="status" id="status">Loading game state...</div>
|
|
</div>
|
|
<div class="board-wrapper">
|
|
<div class="board" id="board"></div>
|
|
<div class="board-center">
|
|
MONOPOLY
|
|
<div class="subtitle">IRC Edition</div>
|
|
</div>
|
|
</div>
|
|
<div class="info-panels" id="players"></div>
|
|
<div class="game-log">
|
|
<h3>📜 Game Log</h3>
|
|
<div id="log"></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>
|
|
|
|
<!-- Stale data banner -->
|
|
<div class="stale-banner" id="stale-banner">
|
|
<span class="icon">⏸️</span>
|
|
Game data hasn't updated in <span class="age" id="stale-age">--</span> — the bridge may have stopped
|
|
</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('stale')">Stale (no updates)</button>
|
|
<button onclick="debugState('disconnected')">Disconnected</button>
|
|
<button onclick="debugState('reset')">Reset (live mode)</button>
|
|
</div>
|
|
|
|
<script>
|
|
const PLAYER_COLORS = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c','#e91e63','#00bcd4','#ff5722'];
|
|
|
|
const GROUP_COLORS = {
|
|
purple: '#6a0dad', lightblue: '#87ceeb', pink: '#d63384',
|
|
orange: '#e67e22', red: '#e74c3c', yellow: '#f1c40f',
|
|
green: '#27ae60', darkblue: '#2c3e8a', railroad: '#555', utility: '#777'
|
|
};
|
|
|
|
// Board square positions: [row, col] in an 11x11 grid
|
|
const POSITIONS = [];
|
|
for (let i = 0; i <= 10; i++) POSITIONS[i] = [10, 10 - i];
|
|
for (let i = 1; i <= 9; i++) POSITIONS[10 + i] = [10 - i, 0];
|
|
for (let i = 0; i <= 10; i++) POSITIONS[20 + i] = [0, i];
|
|
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;
|
|
let lastStateTimestamp = null; // ISO string from state.lastUpdated
|
|
const STALE_THRESHOLD_MS = 20000;
|
|
|
|
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) {
|
|
const board = document.getElementById('board');
|
|
board.innerHTML = '';
|
|
const squares = state.squares || [];
|
|
const players = state.players || [];
|
|
|
|
const bw = board.offsetWidth || 700;
|
|
const cornerSize = bw / 8.5;
|
|
const sideSize = (bw - 2 * cornerSize) / 9;
|
|
|
|
squares.forEach((sq, idx) => {
|
|
const [row, col] = POSITIONS[idx];
|
|
const el = document.createElement('div');
|
|
el.className = 'square' + (sq.mortgaged ? ' mortgaged' : '');
|
|
|
|
let x, y, w, h;
|
|
const isCorner = [0, 10, 20, 30].includes(idx);
|
|
|
|
if (isCorner) {
|
|
w = cornerSize; h = cornerSize;
|
|
x = col === 0 ? 0 : col === 10 ? bw - cornerSize : col * sideSize;
|
|
y = row === 0 ? 0 : row === 10 ? bw - cornerSize : row * sideSize;
|
|
} else if (row === 0 || row === 10) {
|
|
w = sideSize; h = cornerSize;
|
|
x = cornerSize + (col - 1) * sideSize;
|
|
y = row === 0 ? 0 : bw - cornerSize;
|
|
} else {
|
|
w = cornerSize; h = sideSize;
|
|
x = col === 0 ? 0 : bw - cornerSize;
|
|
y = cornerSize + (row - 1) * sideSize;
|
|
}
|
|
|
|
el.style.left = x + 'px';
|
|
el.style.top = y + 'px';
|
|
el.style.width = w + 'px';
|
|
el.style.height = h + 'px';
|
|
|
|
if (sq.group && GROUP_COLORS[sq.group]) {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'color-bar';
|
|
bar.style.backgroundColor = GROUP_COLORS[sq.group];
|
|
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 (col === 0) { bar.style.top = '0'; bar.style.left = ''; bar.style.right = '0'; bar.style.width = '6px'; bar.style.height = '100%'; }
|
|
else if (col === 10) { bar.style.top = '0'; bar.style.left = '0'; bar.style.right = ''; bar.style.width = '6px'; bar.style.height = '100%'; }
|
|
el.appendChild(bar);
|
|
}
|
|
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'name';
|
|
nameEl.textContent = sq.name.replace(' Ave.', '').replace(' Place', ' Pl').replace(' Railroad', ' RR').replace('Community Chest', 'Comm. Chest').replace('Pennsylvania', 'Penn.');
|
|
el.appendChild(nameEl);
|
|
|
|
if (sq.cost > 0) {
|
|
const costEl = document.createElement('div');
|
|
costEl.className = 'cost';
|
|
costEl.textContent = '$' + sq.cost;
|
|
el.appendChild(costEl);
|
|
}
|
|
|
|
if (sq.owner >= 0 && sq.owner < players.length) {
|
|
const ownerEl = document.createElement('div');
|
|
ownerEl.className = 'owner-indicator';
|
|
ownerEl.textContent = '⬤';
|
|
ownerEl.style.color = PLAYER_COLORS[sq.owner % PLAYER_COLORS.length];
|
|
el.appendChild(ownerEl);
|
|
}
|
|
|
|
if (sq.houses > 0 && sq.houses < 5) {
|
|
const hd = document.createElement('div');
|
|
hd.className = 'houses-display';
|
|
for (let h = 0; h < sq.houses; h++) {
|
|
const house = document.createElement('div');
|
|
house.className = 'house';
|
|
hd.appendChild(house);
|
|
}
|
|
el.appendChild(hd);
|
|
} else if (sq.houses >= 5) {
|
|
const hd = document.createElement('div');
|
|
hd.className = 'houses-display';
|
|
const hotel = document.createElement('div');
|
|
hotel.className = 'hotel';
|
|
hd.appendChild(hotel);
|
|
el.appendChild(hd);
|
|
}
|
|
|
|
const playersHere = players.filter(p => p.location === idx);
|
|
if (playersHere.length > 0) {
|
|
const ph = document.createElement('div');
|
|
ph.className = 'players-here';
|
|
playersHere.forEach(p => {
|
|
const pidx = players.indexOf(p);
|
|
const tok = document.createElement('div');
|
|
tok.className = 'player-token';
|
|
tok.style.backgroundColor = PLAYER_COLORS[pidx % PLAYER_COLORS.length];
|
|
tok.textContent = p.name.charAt(0).toUpperCase();
|
|
tok.title = p.name;
|
|
ph.appendChild(tok);
|
|
});
|
|
el.appendChild(ph);
|
|
}
|
|
|
|
board.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function renderPlayers(state) {
|
|
const container = document.getElementById('players');
|
|
container.innerHTML = '';
|
|
const players = state.players || [];
|
|
const squares = state.squares || [];
|
|
|
|
players.forEach((p, idx) => {
|
|
const panel = document.createElement('div');
|
|
panel.className = 'player-panel' + (p.number === state.currentPlayer ? ' current-turn' : '');
|
|
|
|
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}`;
|
|
if (p.number === state.currentPlayer) html += ' 🎲';
|
|
html += '</h3>';
|
|
|
|
html += `<div class="money ${p.money < 0 ? 'negative' : ''}">$${p.money.toLocaleString()}</div>`;
|
|
|
|
if (p.getOutOfJailFreeCards > 0) {
|
|
html += `<div style="font-size:0.8em;margin-top:4px">🃏 ${p.getOutOfJailFreeCards} GOJF card${p.getOutOfJailFreeCards > 1 ? 's' : ''}</div>`;
|
|
}
|
|
if (p.inJail) {
|
|
html += `<div style="font-size:0.8em;color:#e74c3c;margin-top:4px">🔒 In Jail (turn ${p.jailTurns})</div>`;
|
|
}
|
|
|
|
const loc = p.location === 40 ? { name: 'JAIL' } : squares[p.location];
|
|
if (loc) {
|
|
html += `<div style="font-size:0.8em;margin-top:4px;color:#aaa">📍 ${loc.name}</div>`;
|
|
}
|
|
|
|
// Properties owned by this player
|
|
const owned = squares.filter(sq => sq.owner === p.number);
|
|
if (owned.length > 0) {
|
|
html += '<div class="props">';
|
|
owned.forEach(sq => {
|
|
const gc = sq.group ? GROUP_COLORS[sq.group] || '#666' : '#666';
|
|
html += `<div class="prop-item"><span class="prop-dot" style="background:${gc}"></span>${sq.name}`;
|
|
if (sq.houses > 0 && sq.houses < 5) html += ` 🏠×${sq.houses}`;
|
|
else if (sq.houses >= 5) html += ' 🏨';
|
|
if (sq.mortgaged) html += ' (M)';
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
panel.innerHTML = html;
|
|
container.appendChild(panel);
|
|
});
|
|
}
|
|
|
|
function renderLog(state) {
|
|
const container = document.getElementById('log');
|
|
const entries = (state.log || []).slice(-30).reverse();
|
|
container.innerHTML = entries.map(e => {
|
|
const time = e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : '';
|
|
const player = e.player ? `<span class="log-player">${e.player}</span>: ` : '';
|
|
return `<div class="log-entry"><span class="log-time">${time}</span> ${player}${e.text}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderGame(state) {
|
|
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;
|
|
}
|
|
|
|
// ===== STALE DETECTION =====
|
|
function checkStale() {
|
|
if (!lastStateTimestamp || currentView === 'zero' || currentView === 'gameover') {
|
|
hideStale();
|
|
return;
|
|
}
|
|
const age = Date.now() - new Date(lastStateTimestamp).getTime();
|
|
if (age > STALE_THRESHOLD_MS) {
|
|
showStale(age);
|
|
} else {
|
|
hideStale();
|
|
}
|
|
}
|
|
|
|
function showStale(ageMs) {
|
|
const secs = Math.floor(ageMs / 1000);
|
|
let ageText;
|
|
if (secs < 60) ageText = `${secs}s`;
|
|
else if (secs < 3600) ageText = `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
|
else ageText = `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
|
|
|
|
document.getElementById('stale-age').textContent = ageText;
|
|
document.getElementById('stale-banner').style.display = 'block';
|
|
document.body.classList.add('stale');
|
|
}
|
|
|
|
function hideStale() {
|
|
document.getElementById('stale-banner').style.display = 'none';
|
|
document.body.classList.remove('stale');
|
|
}
|
|
|
|
// ===== 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 {
|
|
const resp = await fetch('game-state.json?t=' + Date.now());
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
const state = await resp.json();
|
|
|
|
// Success — clear failure state
|
|
if (consecutiveFailures >= 3) hideDisconnected();
|
|
consecutiveFailures = 0;
|
|
|
|
// Track last update time for stale detection
|
|
if (state.lastUpdated) lastStateTimestamp = state.lastUpdated;
|
|
checkStale();
|
|
|
|
// 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);
|
|
if (updateStr !== lastUpdate) {
|
|
lastUpdate = updateStr;
|
|
showView('playing');
|
|
renderGame(state);
|
|
}
|
|
document.getElementById('status').textContent =
|
|
`Live · ${state.players.length} players · Updated: ${new Date(state.lastUpdated).toLocaleTimeString()}`;
|
|
|
|
} catch (e) {
|
|
consecutiveFailures++;
|
|
checkStale(); // still update stale timer on failures
|
|
if (consecutiveFailures >= 3) {
|
|
showDisconnected();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== DEMO / TEST STATES =====
|
|
function demoSquares() {
|
|
const names = [
|
|
"=== GO ===","Mediterranean ave. (P)","Community Chest i","Baltic ave. (P)","Income Tax",
|
|
"Reading RR","Oriental ave. (L)","Chance i","Vermont ave. (L)","Connecticut ave. (L)",
|
|
"Just Visiting","St. Charles pl. (V)","Electric Co.","States ave. (V)","Virginia ave. (V)",
|
|
"Pennsylvania RR","St. James pl. (O)","Community Chest ii","Tennessee ave. (O)","New York ave. (O)",
|
|
"Free Parking","Kentucky ave. (R)","Chance ii","Indiana ave. (R)","Illinois ave. (R)",
|
|
"B&O RR","Atlantic ave. (Y)","Ventnor ave. (Y)","Water Works","Marvin Gardens (Y)",
|
|
"GO TO JAIL","Pacific ave. (G)","N. Carolina ave. (G)","Community Chest iii","Pennsylvania ave. (G)",
|
|
"Short Line RR","Chance iii","Park place (D)","Luxury Tax","Boardwalk (D)"
|
|
];
|
|
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 {
|
|
lastUpdated: new Date().toISOString(),
|
|
currentPlayer: 2,
|
|
players: [
|
|
{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,doublesCount:0,getOutOfJailFreeCards:0},
|
|
{name:"Charlie",number:3,money:640,location:40,inJail:true,jailTurns:2,doublesCount:0,getOutOfJailFreeCards:0},
|
|
],
|
|
squares: sq,
|
|
log: [
|
|
{timestamp:new Date(Date.now()-60000).toISOString(),text:"roll is 4, 3",player:"Alice"},
|
|
{timestamp:new Date(Date.now()-50000).toISOString(),text:"Landed on Illinois ave. (R)",player:"Alice"},
|
|
{timestamp:new Date(Date.now()-40000).toISOString(),text:"roll is 6, 6",player:"Bob"},
|
|
{timestamp:new Date(Date.now()-30000).toISOString(),text:"Passed GO — collected $200",player:"Bob"},
|
|
{timestamp:new Date(Date.now()-20000).toISOString(),text:"Landed on Boardwalk (D)",player:"Bob"},
|
|
{timestamp:new Date(Date.now()-10000).toISOString(),text:"Still in jail (turn 2)",player:"Charlie"},
|
|
]
|
|
};
|
|
}
|
|
|
|
function demoGameOverState() {
|
|
const st = demoPlayingState();
|
|
st.players = [
|
|
{name:"Bob",number:1,money:4250,location:39,inJail:false,jailTurns:0,doublesCount:0,getOutOfJailFreeCards:2},
|
|
];
|
|
st.currentPlayer = 1;
|
|
st.log.push({timestamp:new Date().toISOString(), text:"Bob WINS!", player:"Bob"});
|
|
return st;
|
|
}
|
|
|
|
// ===== DEBUG MODE =====
|
|
function debugState(mode) {
|
|
stopConfetti();
|
|
gameOverShown = false;
|
|
|
|
if (mode === 'reset') {
|
|
debugMode = null;
|
|
lastUpdate = '';
|
|
lastStateTimestamp = null;
|
|
hideDisconnected();
|
|
hideStale();
|
|
update();
|
|
return;
|
|
}
|
|
|
|
debugMode = mode;
|
|
|
|
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 === 'stale') {
|
|
const st = demoPlayingState();
|
|
st.lastUpdated = new Date(Date.now() - 120000).toISOString(); // 2 min ago
|
|
lastStateTimestamp = st.lastUpdated;
|
|
showView('playing');
|
|
renderGame(st);
|
|
checkStale();
|
|
document.getElementById('status').textContent = 'Debug: Stale data';
|
|
} 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();
|
|
setInterval(update, 2000);
|
|
</script>
|
|
</body>
|
|
</html>
|