Files
goyban dc051cbe45 Port Shelem layout/engine, add history, fix card flashing and count desync
Layout & engine borrowed from Shelem: waiting-room menu with seat swap and
force-start, game menu with bar-to-bottom, escalating turn reminder, three
hand modes defaulting to fan on touch, card-count badges instead of card
backs, leave buttons inside phase overlays, version footer with tap-to-update.

Seats now run clockwise from the viewer (left = +1, across = +2, right = +3)
so play flows anti-clockwise on screen as the server advances it; the pass
exchange was flipped to match, otherwise "pass left" delivered to the right.

Game history: finished games are saved with a per-hand breakdown, readable
from the profile and shown on the end-of-hand and end-of-game screens.

Card flashing: the service worker precaches all 52 faces, each face is decoded
once into a template image, and the renderers reconcile against existing DOM
instead of rebuilding it, so card nodes and their images survive a re-render.

Card-count integrity: a seat can no longer put two cards into one trick, and
the server snapshots each trick start and rewinds to the last sound state if
the four hands ever drift apart.

Leaderboard: ranked by win rate with a 5-game minimum; below-threshold players
are listed unranked. Moon shots are now recorded instead of always zero, and
stat reads no longer create and persist a row per user on every request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:13:46 +00:00

487 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Hearts</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#133025">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hearts">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ══════════════ LOBBY ══════════════ -->
<div id="screen-lobby" class="screen active">
<div class="lobby-box">
<h1 class="logo">♥ Hearts ♠</h1>
<p class="tagline">The classic trick-taking card game</p>
<button id="btn-install" class="btn-install hidden">📲 Add to Home Screen</button>
<div id="auth-bar" class="auth-bar">
<span id="auth-status" class="auth-status">Playing as guest</span>
<div class="auth-bar-actions">
<button id="btn-show-leaderboard" class="btn-text">🏆 Leaderboard</button>
<button id="btn-show-profile" class="btn-text hidden">👤 Profile</button>
<button id="btn-logout" class="btn-text hidden">Logout</button>
<button id="btn-show-auth" class="btn-text">Login / Register</button>
</div>
</div>
<div class="field">
<label>Your Name</label>
<input id="input-name" type="text" maxlength="16" placeholder="Enter your name…" autocomplete="off">
</div>
<div class="lobby-tabs">
<button class="tab-btn active" data-tab="create">Create Game</button>
<button class="tab-btn" data-tab="join">Join Game</button>
</div>
<div id="tab-create" class="tab-panel active">
<div class="field">
<label>Score Limit <small>(game ends when a player reaches this — lowest wins)</small></label>
<div class="score-row">
<button class="score-btn" data-score="50">50</button>
<button class="score-btn active" data-score="100">100</button>
<button class="score-btn" data-score="200">200</button>
</div>
</div>
<div class="field">
<label>Visibility</label>
<div class="visibility-row">
<button class="visibility-btn active" data-public="false">🔒 Private</button>
<button class="visibility-btn" data-public="true">🌐 Public</button>
</div>
</div>
<button id="btn-create" class="btn-primary">Create Game</button>
</div>
<div id="tab-join" class="tab-panel">
<div class="field">
<label>Room Code</label>
<input id="input-code" type="text" maxlength="8" placeholder="e.g. AB12CD" autocomplete="off" style="text-transform:uppercase">
</div>
<button id="btn-join" class="btn-primary">Join Game</button>
<hr class="divider">
<div class="public-rooms-section">
<div class="rooms-header">
<span class="rooms-label">Public Rooms</span>
<button id="btn-refresh-rooms" class="btn-text">↺</button>
</div>
<div id="public-rooms-list"><p class="hint">Loading…</p></div>
</div>
<hr class="divider">
<div class="field">
<label>Watch as spectator</label>
<input id="input-spectate-code" type="text" maxlength="8" placeholder="Room code" style="text-transform:uppercase" autocomplete="off">
</div>
<button id="btn-spectate" class="btn-secondary">👁 Watch Game</button>
</div>
<p id="lobby-error" class="error-msg"></p>
</div>
<div id="app-version" class="app-version"></div>
</div>
<!-- ══════════════ WAITING ROOM ══════════════ -->
<div id="screen-waiting" class="screen">
<div class="waiting-box">
<div class="waiting-top-row">
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
<div class="game-menu-wrap" style="margin-left:auto">
<button id="btn-waiting-menu" class="btn-leave-screen" title="Options">☰</button>
<div id="waiting-menu-dropdown" class="game-menu-dropdown hidden">
<button id="btn-waiting-reload" class="game-menu-item">↺ Reload</button>
<button id="btn-waiting-exit" class="game-menu-item game-menu-exit">🚪 Exit</button>
</div>
</div>
</div>
<h2>Waiting for Players</h2>
<div class="room-code-box">
<span class="label">Room Code</span>
<span id="display-room-code" class="room-code">——</span>
<button id="btn-copy" class="btn-copy" title="Copy code">⧉</button>
</div>
<p class="hint">Share this code — 4 players needed</p>
<div class="waiting-seats" id="waiting-seats">
<div class="seat-slot" data-seat="0"><span class="seat-num">1</span><span class="seat-name">—</span></div>
<div class="seat-slot" data-seat="1"><span class="seat-num">2</span><span class="seat-name">—</span></div>
<div class="seat-slot" data-seat="2"><span class="seat-num">3</span><span class="seat-name">—</span></div>
<div class="seat-slot" data-seat="3"><span class="seat-num">4</span><span class="seat-name">—</span></div>
</div>
<p class="hint" style="font-size:.78rem;color:rgba(255,255,255,.4)">Every player for themselves — lowest score wins</p>
<p id="waiting-status" class="waiting-status">Waiting for 3 more players…</p>
<div id="waiting-options" class="waiting-options hidden">
<span class="waiting-opt-label" id="waiting-score-label"></span>
<span class="waiting-opt-label" id="waiting-public-badge"></span>
</div>
<div id="waiting-creator-controls" class="waiting-creator-controls hidden">
<button id="btn-fill-bots" class="btn-fill-bots">🤖 Fill with bots</button>
<button id="btn-start-game" class="btn-start-game">▶ Start Game</button>
</div>
</div>
</div>
<!-- ══════════════ GAME TABLE ══════════════ -->
<div id="screen-game" class="screen">
<!-- Info bar -->
<div id="info-bar">
<button id="btn-leave-game" class="btn-leave-screen">← Menu</button>
<!-- Per-player scores -->
<div id="score-block">
<div id="score-display"></div>
</div>
<!-- Hearts broken + pass direction -->
<div id="game-meta">
<span id="hearts-broken-display" class="hearts-broken hidden">♥ Broken</span>
<span id="pass-dir-display" class="pass-dir-display hidden"></span>
</div>
<div class="game-menu-wrap">
<button id="btn-game-menu" class="btn-leave-screen" title="Options">☰</button>
<div id="game-menu-dropdown" class="game-menu-dropdown hidden">
<button id="btn-refresh-game" class="game-menu-item">↺ Reload</button>
<button id="btn-toggle-bar" class="game-menu-item">⬇ Bar to bottom</button>
<button id="btn-exit-game" class="game-menu-item game-menu-exit">🚪 Exit</button>
</div>
</div>
</div>
<!-- Spectator banner -->
<div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div>
<!-- AFK banner -->
<div id="afk-banner" class="afk-banner hidden">
<span id="afk-banner-msg"></span>
<button id="afk-vote-btn" class="btn-text" style="color:var(--gold);font-weight:700">Let AI Play</button>
<button id="afk-dismiss-btn" class="btn-text">Dismiss</button>
</div>
<!-- AI control banner -->
<div id="ai-control-banner" class="ai-control-banner hidden">
<span id="ai-control-msg"></span>
</div>
<!-- Revert notice (card-count desync recovery) -->
<div id="revert-banner" class="revert-banner hidden">
<span id="revert-banner-msg"></span>
</div>
<!-- Table grid -->
<div id="table-grid">
<div id="area-top" class="player-area area-top">
<div class="player-label">
<span id="top-name">—</span>
<span id="top-turn" class="turn-dot hidden">●</span>
<span id="top-score" class="player-score-badge"></span>
<span id="top-count" class="card-count-badge"></span>
</div>
</div>
<div id="area-left" class="player-area area-left">
<div class="player-label vertical">
<span id="left-name">—</span>
<span id="left-turn" class="turn-dot hidden">●</span>
<span id="left-score" class="player-score-badge"></span>
<span id="left-count" class="card-count-badge"></span>
</div>
</div>
<!-- Center trick area -->
<div id="trick-area">
<div id="trick-top" class="trick-slot"></div>
<div id="trick-middle" class="trick-middle-row">
<div id="trick-left" class="trick-slot"></div>
<div id="trick-center" class="trick-center-info">
<div id="phase-msg" class="phase-msg"></div>
</div>
<div id="trick-right" class="trick-slot"></div>
</div>
<div id="trick-bottom" class="trick-slot"></div>
</div>
<div id="area-right" class="player-area area-right">
<div class="player-label vertical">
<span id="right-name">—</span>
<span id="right-turn" class="turn-dot hidden">●</span>
<span id="right-score" class="player-score-badge"></span>
<span id="right-count" class="card-count-badge"></span>
</div>
</div>
</div><!-- /table-grid -->
<!-- Bottom: my hand -->
<div id="my-area">
<div id="my-label">
<span id="my-name">You</span>
<span id="my-turn" class="turn-dot hidden">●</span>
<span id="my-score" class="player-score-badge"></span>
<span id="my-hand-pts" class="tricks-badge hidden"></span>
<button id="btn-play-mode" class="btn-play-mode hidden" title="Switch play mode">👆 Tap</button>
<button id="btn-hand-mode" class="btn-hand-mode" title="Switch hand display">📜 Scroll</button>
</div>
<div id="my-hand" class="my-hand"></div>
</div>
</div><!-- /screen-game -->
<!-- ══════════════ PASS OVERLAY ══════════════ -->
<div id="overlay-pass" class="overlay hidden">
<div class="overlay-box pass-box">
<h3 id="pass-title">Pass 3 cards</h3>
<p id="pass-hint" class="pass-hint"></p>
<div id="pass-hand" class="pass-hand"></div>
<div class="pass-selected-row">
<span class="pass-selected-label">Passing:</span>
<div id="pass-selected-preview" class="pass-selected-preview"></div>
</div>
<button id="btn-confirm-pass" class="btn-primary" disabled>Confirm Pass</button>
<p id="pass-waiting" class="hint" style="min-height:18px"></p>
<button class="overlay-leave-btn" data-overlay-leave>🚪 Leave game</button>
</div>
</div>
<!-- ══════════════ HAND RESULT OVERLAY ══════════════ -->
<div id="overlay-hand" class="overlay hidden">
<div class="overlay-box">
<div id="hand-result-icon" class="result-icon"></div>
<h3 id="hand-result-title"></h3>
<p id="hand-result-detail"></p>
<div id="hand-result-scores" class="result-scores"></div>
<div id="hand-round-breakdown" class="round-breakdown-wrap"></div>
<p class="hint">Next hand starting…</p>
</div>
</div>
<!-- ══════════════ GAME OVER OVERLAY ══════════════ -->
<div id="overlay-gameover" class="overlay hidden">
<div class="overlay-box">
<div class="result-icon big">🏆</div>
<h2 id="gameover-title"></h2>
<div id="gameover-scores" class="gameover-scores"></div>
<div id="gameover-breakdown" class="round-breakdown-wrap"></div>
<button id="btn-new-game" class="btn-primary">New Game</button>
</div>
</div>
<!-- ══════════════ AUTH MODAL ══════════════ -->
<div id="overlay-auth" class="overlay hidden">
<div class="overlay-box auth-box">
<button id="btn-auth-close" class="btn-close">✕</button>
<div class="auth-tabs">
<button class="auth-tab active" data-auth-tab="login">Login</button>
<button class="auth-tab" data-auth-tab="register">Register</button>
</div>
<div id="auth-panel-login" class="auth-panel active">
<div class="field">
<label>Username</label>
<input id="auth-login-user" type="text" maxlength="16" placeholder="Username" autocomplete="username">
</div>
<div class="field">
<label>Password</label>
<input id="auth-login-pass" type="password" placeholder="Password" autocomplete="current-password">
</div>
<p class="hint" style="font-size:.8rem;color:rgba(255,255,255,.45)">Hokm and Shelem accounts work here too</p>
<button id="btn-do-login" class="btn-primary">Login</button>
<p id="auth-login-error" class="error-msg"></p>
</div>
<div id="auth-panel-register" class="auth-panel">
<div id="reg-step-form">
<div class="field">
<label>Username <small>(2–16 chars)</small></label>
<input id="auth-reg-user" type="text" maxlength="16" placeholder="Choose a username" autocomplete="username">
</div>
<div class="field">
<label>Email</label>
<input id="auth-reg-email" type="email" placeholder="your@email.com" autocomplete="email">
</div>
<div class="field">
<label>Password <small>(min 4 chars)</small></label>
<input id="auth-reg-pass" type="password" placeholder="Choose a password" autocomplete="new-password">
</div>
<div id="auth-captcha-wrap" class="hidden" style="margin:6px 0"></div>
<button id="btn-do-register" class="btn-primary">Send Verification Code</button>
<p id="auth-reg-error" class="error-msg"></p>
</div>
<div id="reg-step-verify" class="hidden">
<p id="reg-verify-hint" style="margin:0 0 12px;color:var(--muted);font-size:.9em"></p>
<div class="field">
<label>Verification Code</label>
<input id="auth-reg-code" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code">
</div>
<button id="btn-do-verify" class="btn-primary">Create Account</button>
<button id="btn-reg-back" class="btn-secondary" style="margin-top:6px;width:100%">← Back</button>
<p id="auth-verify-error" class="error-msg"></p>
</div>
</div>
</div>
</div>
<!-- ══════════════ PROFILE ══════════════ -->
<div id="screen-profile" class="screen">
<div class="profile-wrap">
<div class="profile-box">
<button id="btn-profile-back" class="btn-back">← Back</button>
<div class="profile-avatar">♥</div>
<h2 id="profile-username" class="profile-name"></h2>
<div class="stat-grid">
<div class="stat-card">
<span class="stat-num" id="stat-win-rate">—</span>
<span class="stat-label">Win Rate</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-games-won">—</span>
<span class="stat-label">Games Won</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-games-played">—</span>
<span class="stat-label">Games Played</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-moon-shots">—</span>
<span class="stat-label">Moon Shots 🌙</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-avg-score">—</span>
<span class="stat-label">Avg Score</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-total-score">—</span>
<span class="stat-label">Total Score</span>
</div>
</div>
<div class="profile-section">
<button id="btn-show-history" class="btn-secondary" style="width:100%">📜 Game History</button>
</div>
<div class="points-legend">
<h4>How to win</h4>
<ul>
<li><strong>1 pt</strong> — each ♥ heart taken</li>
<li><strong>13 pts</strong> — taking the ♠Q</li>
<li><strong>Shoot the Moon</strong> — take all 13 hearts + ♠Q: you score 0, everyone else 26</li>
<li>Game ends when any player reaches the score limit. <strong>Lowest score wins.</strong></li>
</ul>
</div>
<div class="profile-section">
<button id="btn-show-change-pass" class="btn-secondary" style="width:100%">🔑 Change Password</button>
<div id="change-pass-form" class="hidden" style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
<div class="field">
<label>Current Password</label>
<input id="change-pass-current" type="password" placeholder="Current password" autocomplete="current-password">
</div>
<div class="field">
<label>New Password <small>(min 4 chars)</small></label>
<input id="change-pass-new" type="password" placeholder="New password" autocomplete="new-password">
</div>
<button id="btn-do-change-pass" class="btn-primary">Update Password</button>
<p id="change-pass-msg" class="error-msg"></p>
</div>
</div>
<div id="admin-panel" class="profile-section admin-section hidden">
<h4 class="admin-title">⚙ Admin</h4>
<div class="admin-row">
<span>New registrations</span>
<button id="btn-toggle-signups" class="btn-secondary btn-admin-toggle">…</button>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════ LEADERBOARD ══════════════ -->
<div id="screen-leaderboard" class="screen">
<div class="profile-wrap">
<div class="profile-box" style="max-width:540px">
<button id="btn-lb-back" class="btn-back">← Back</button>
<div class="profile-avatar">🏆</div>
<h2 class="profile-name">Leaderboard</h2>
<table class="lb-table">
<thead>
<tr>
<th>#</th><th>Player</th><th>Win %</th><th>W</th><th>P</th><th title="Average score per game — lower is better">Avg</th><th title="Moon shots">🌙</th><th></th>
</tr>
</thead>
<tbody id="lb-body"></tbody>
</table>
</div>
</div>
</div>
<!-- ══════════════ GAME HISTORY ══════════════ -->
<div id="screen-history" class="screen">
<div class="profile-wrap">
<div class="profile-box" style="max-width:560px">
<button id="btn-history-back" class="btn-back">← Back</button>
<div class="profile-avatar">📜</div>
<h2 id="history-title" class="profile-name">Game History</h2>
<div id="history-list" class="history-list"><p class="hint">Loading…</p></div>
<div id="history-detail" class="hidden">
<button id="btn-history-detail-back" class="btn-back" style="position:static;margin-bottom:8px">← All games</button>
<h3 id="history-detail-title"></h3>
<div id="history-detail-scores" class="gameover-scores"></div>
<div id="history-detail-breakdown" class="round-breakdown-wrap"></div>
</div>
</div>
</div>
</div>
<!-- ══════════════ PLAYER DETAILS MODAL ══════════════ -->
<div id="overlay-player-details" class="overlay hidden">
<div class="overlay-box">
<button id="btn-details-close" class="btn-back" style="position:static;margin-bottom:12px">← Close</button>
<div class="profile-avatar" style="font-size:2rem">♥</div>
<h3 id="details-username" style="margin:8px 0 16px;color:#fff"></h3>
<table class="lb-table" style="width:100%">
<tbody id="details-body"></tbody>
</table>
</div>
</div>
<!-- ══════════════ EXIT CONFIRM ══════════════ -->
<div id="overlay-exit-confirm" class="overlay hidden">
<div class="overlay-box small" style="text-align:center">
<h3>Leave Game?</h3>
<p style="font-size:.88rem;color:rgba(255,255,255,.65);margin:8px 0 20px">Your session will be discarded.<br>You will not be able to rejoin.</p>
<div style="display:flex;gap:10px;justify-content:center">
<button id="btn-exit-confirm-yes" class="btn-primary" style="background:rgba(200,50,50,.7);border-color:rgba(200,50,50,.9)">Leave &amp; Exit</button>
<button id="btn-exit-confirm-no" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
<!-- ── PWA Install Banner ── -->
<div id="pwa-banner" class="pwa-banner hidden">
<img src="/icons/icon-192.png" class="pwa-banner-icon" alt="">
<div class="pwa-banner-text">
<strong>Install Hearts</strong>
<span>Play offline, launch like an app</span>
</div>
<button id="pwa-banner-install" class="pwa-banner-btn">Install</button>
<button id="pwa-banner-dismiss" class="pwa-banner-close" aria-label="Dismiss">✕</button>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js"></script>
<script>
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
</script>
</body>
</html>