Files
bgclock/public_html/index.html
T
hansdezwartandClaude Opus 5 92bb31b18e Track a match: length, score sheet, and a clock that survives a quit
Match length is a new first row in the settings, 1 to 29 points, stepping by
2 so it can only ever be odd. The Time setting now means time *per point* and
each clock starts at time × points, which the settings sheet spells out.

The middle bar icon opens a score sheet. Edits are staged there and only
reach the state on Done, which then does one of three things:

  nothing changed  turn, delay position and moves all untouched
  score changed    displayed times become the reserves, then back to the
                   first tap with moves zeroed — the clocks keep their value
  clock changed    the spent delay is added back on so the panel shows
                   exactly what was typed, and the turn carries on

Tapping outside the sheet discards the staged edits, so a stray tap can't
restart a game.

reset() was called both by the Reset button and by every settings change, so
clearing the score in it would have wiped a live match whenever the delay was
nudged. It splits into resetClocks() and resetAll(); only the button clears
the score. Shortening a match clamps a score that no longer fits.

The whole game now persists, not just the settings: score, both reserves,
moves, whose turn and how much delay was left, saved on the discrete
transitions and on pagehide/visibilitychange. Time passing while the app is
dead can't be charged to anyone, so a running clock is stored as paused and
comes back on the play button. A malformed saved game is ignored rather than
half-applied.

Each panel shows its own score first, stacked above its move count at the
top left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:39:32 +02:00

1120 lines
40 KiB
HTML
Raw 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, viewport-fit=cover, user-scalable=no">
<title>Backgammon Clock</title>
<meta name="theme-color" content="#332e2b">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="BG Clock">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon-192.png">
<link rel="apple-touch-icon" href="icon-192.png">
<style>
:root{
--accent:#7E9A79;
--accent-ink:#FFFFFF;
--accent-mute:rgba(0,0,0,.34);
--idle:#9A9A9A;
--idle-ink:#2E2E2E;
--idle-mute:rgba(0,0,0,.36);
--bar:#332E2B;
--icon:#8E8880;
--icon-hot:#DFD9D1;
--flag:#A6564E;
--sheet:#232120;
--sheet-line:rgba(255,255,255,.09);
--sheet-ink:#EDE9E4;
--sheet-dim:#A29A92;
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:var(--bar)}
body{
font-family:var(--ui);
font-variant-numeric:tabular-nums;
font-feature-settings:"tnum" 1;
-webkit-tap-highlight-color:transparent;
-webkit-user-select:none;user-select:none;
-webkit-touch-callout:none; /* no iOS long-press callout on a panel */
overscroll-behavior:none;
touch-action:manipulation;
}
/* ---------- frame ---------- */
.app{
height:100vh;height:100dvh; /* dvh must win — 100vh on iOS Safari is the
large viewport, so the bar and the bottom
panel end up under the toolbar */
display:grid;
grid-template-rows:1fr auto 1fr;
}
/* ---------- player panels ---------- */
.panel{
position:relative;
background:var(--idle);
color:var(--idle-ink);
overflow:hidden;
transition:background-color .18s ease;
}
.panel[data-state="active"]{background:var(--accent);color:var(--accent-ink)}
.panel[data-state="flagged"]{background:var(--flag);color:#fff}
.inner{
position:absolute;inset:0;
display:flex;flex-direction:column;
padding:max(14px,env(safe-area-inset-top)) 20px 14px;
}
.panel--top .inner{transform:rotate(180deg)}
.panel--top .inner{padding:14px 20px max(14px,env(safe-area-inset-top))}
.panel--bottom .inner{padding:14px 20px max(14px,env(safe-area-inset-bottom))}
.head{
display:flex;flex-direction:column;align-items:flex-start;
gap:3px;
order:-1;
}
.score,.moves{
font-size:clamp(13px,3.7vw,20px);
font-weight:700;
letter-spacing:.01em;
color:var(--idle-mute);
white-space:nowrap;
}
.panel[data-state="active"] .score,
.panel[data-state="active"] .moves,
.panel[data-state="flagged"] .score,
.panel[data-state="flagged"] .moves{color:var(--accent-mute)}
.panel[data-state="flagged"] .score,
.panel[data-state="flagged"] .moves{color:rgba(255,255,255,.55)}
.stack{
flex:1;
display:flex;flex-direction:column;
align-items:center;justify-content:center;
padding-bottom:16%;
gap:clamp(10px,2.4vw,18px);
}
.time{
font-size:clamp(58px,24vw,190px);
line-height:.9;
font-weight:700;
letter-spacing:-.02em;
}
/* ---------- the delay readout (the bit chess.com doesn't have) ---------- */
.delay{
display:flex;align-items:center;
gap:clamp(8px,2.2vw,14px);
opacity:0;
transition:opacity .16s ease;
}
.delay.on{opacity:1}
.track{
width:clamp(72px,20vw,140px);
height:clamp(4px,1.1vw,7px);
border-radius:99px;
background:var(--accent-mute);
opacity:.45;
overflow:hidden;
}
.fill{
display:block;height:100%;width:100%;
border-radius:99px;
background:var(--accent-ink);
transform-origin:left center;
}
.dnum{
font-size:clamp(15px,4.4vw,26px);
font-weight:700;
min-width:1.6em;
}
/* ---------- control bar ---------- */
.bar{
background:var(--bar);
height:clamp(58px,8.2vh,86px);
display:flex;align-items:center;
padding:0 clamp(12px,4vw,28px);
}
.bar .gap{flex:.75}
.btn{
flex:1;
height:100%;
display:grid;place-items:center;
background:none;border:0;
color:var(--icon);
cursor:pointer;
}
.btn:active{color:var(--icon-hot)}
.btn[aria-pressed="false"]{color:#5E5954}
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
.btn svg[hidden]{display:none}
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
/* reset armed for a second tap — the ring unwinds over the same 2s as the timeout */
#reset{position:relative}
#reset.armed{color:var(--icon-hot)}
#reset .ring{
position:absolute;
top:50%;left:50%;
width:clamp(36px,9.6vw,48px);height:clamp(36px,9.6vw,48px);
transform:translate(-50%,-50%) rotate(-90deg); /* start the arc at 12 o'clock */
display:none;
pointer-events:none;
}
#reset.armed .ring{display:block}
#reset .ring circle{
fill:none;
stroke:currentColor; /* the same flash the buttons use */
stroke-width:2.6;
stroke-linecap:round;
stroke-dasharray:113.1; /* 2πr, r=18 */
animation:unwind 2s linear forwards;
}
@keyframes unwind{
from{stroke-dashoffset:0}
to{stroke-dashoffset:113.1}
}
/* ---------- settings sheet ---------- */
.scrim{
position:fixed;inset:0;
background:rgba(0,0,0,.55);
opacity:0;pointer-events:none;
transition:opacity .2s ease;
z-index:10;
}
.scrim.on{opacity:1;pointer-events:auto}
.sheet{
position:fixed;left:0;right:0;bottom:0;
z-index:11;
background:var(--sheet);
color:var(--sheet-ink);
border-radius:20px 20px 0 0;
padding:8px 22px max(22px,env(safe-area-inset-bottom));
transform:translateY(101%);
transition:transform .26s cubic-bezier(.32,.72,0,1);
max-height:88vh;max-height:88dvh;overflow-y:auto;
overscroll-behavior:contain; /* scrolling the sheet can't rubber-band the app */
box-shadow:0 -18px 50px rgba(0,0,0,.45);
}
.sheet.on{transform:translateY(0)}
.grab{width:38px;height:4px;border-radius:99px;background:#4A4644;margin:6px auto 18px}
.row{
display:flex;align-items:center;justify-content:space-between;
gap:16px;
padding:16px 0;
border-top:1px solid var(--sheet-line);
}
.row:first-of-type{border-top:0}
.row h2{font-size:15px;font-weight:600;letter-spacing:.01em}
.row p{font-size:12.5px;color:var(--sheet-dim);margin-top:3px;line-height:1.35}
/* a minutes+seconds stepper is 302px wide, so it can't share a line with a
label on any phone — stack it under one instead of overflowing the sheet */
@media (max-width:420px){
.row--wide{flex-direction:column;align-items:stretch;gap:10px}
.row--wide .stepper{align-self:flex-end}
.row--wide .val{min-width:46px}
}
.row--presets{gap:11px}
.preset{
flex:1;
padding:13px 6px;
border-radius:11px;border:1px solid var(--sheet-line);
background:#2C2A28;color:var(--sheet-ink);
font-family:var(--ui);font-size:15px;font-weight:700;
letter-spacing:.01em;white-space:nowrap;
cursor:pointer;
transition:background-color .16s ease,color .16s ease;
}
.preset:active{background:#3A3735}
.preset[aria-pressed="true"]{
background:var(--accent);color:var(--accent-ink);
border-color:transparent;
}
.stepper{display:flex;align-items:center;gap:2px}
.step{
width:38px;height:38px;flex:none;
border-radius:11px;border:1px solid var(--sheet-line);
background:#2C2A28;color:var(--sheet-ink);
font-size:19px;font-weight:600;line-height:1;
cursor:pointer;
}
.step:active{background:#3A3735}
.val{
min-width:66px;text-align:center;
font-size:19px;font-weight:700;
}
.unit{font-size:11px;color:var(--sheet-dim);display:block;font-weight:600;letter-spacing:.06em;text-transform:uppercase;margin-top:2px}
.swatches{display:flex;gap:11px}
.sw{
width:36px;height:36px;border-radius:50%;
border:2px solid transparent;
box-shadow:inset 0 0 0 2px var(--sheet);
cursor:pointer;padding:0;
}
.sw[aria-checked="true"]{border-color:var(--sheet-ink)}
.toggle{
width:52px;height:31px;flex:none;
border-radius:99px;border:0;
background:#4A4644;
position:relative;cursor:pointer;
transition:background-color .18s ease;
}
.toggle[aria-checked="true"]{background:var(--accent)}
.toggle::after{
content:"";position:absolute;top:3px;left:3px;
width:25px;height:25px;border-radius:50%;
background:#fff;
transition:transform .18s cubic-bezier(.32,.72,0,1);
}
.toggle[aria-checked="true"]::after{transform:translateX(21px)}
.done{
width:100%;margin-top:20px;
padding:15px;border:0;border-radius:13px;
background:var(--accent);color:var(--accent-ink);
font-family:var(--ui);font-size:15.5px;font-weight:700;
cursor:pointer;
}
.hint{
font-size:12px;color:var(--sheet-dim);
text-align:center;margin-top:13px;line-height:1.45;
}
@media (prefers-reduced-motion:reduce){
*{transition-duration:.01ms!important}
}
</style>
</head>
<body>
<div class="app">
<section class="panel panel--top" data-side="0" data-state="idle">
<div class="inner">
<div class="head">
<div class="score">Score: <span data-score>0 - 0</span>, out of <span data-target>1</span></div>
<div class="moves">Moves: <span data-moves>0</span></div>
</div>
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
</div>
</div>
</section>
<div class="bar">
<button class="btn" id="reset" aria-label="Reset clock">
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
</svg>
</button>
<button class="btn" id="playpause" aria-label="Pause clock">
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="pause">
<rect x="6" y="4.5" width="4.4" height="15" rx="1.4"/><rect x="13.6" y="4.5" width="4.4" height="15" rx="1.4"/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="play" hidden>
<path d="M7.5 4.7a1 1 0 0 1 1.53-.85l9 6.3a1 1 0 0 1 0 1.7l-9 6.3A1 1 0 0 1 7.5 17.3z"/>
</svg>
</button>
<span class="gap"></span>
<button class="btn" id="score" aria-label="Score">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="3" width="16" height="18" rx="2.6"/>
<path d="M12 7.5v10"/>
<path d="M7.6 10.6h2.6"/><path d="M13.8 10.6h2.6"/>
<path d="M7.6 14.6h2.6"/><path d="M13.8 14.6h2.6"/>
</svg>
</button>
<span class="gap"></span>
<button class="btn" id="settings" aria-label="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.3"/>
<path d="M19.6 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 9.1 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.7 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.7 9.1a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.7h.08A1.7 1.7 0 0 0 10.1 3.14V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.3 9v.08a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.56 1.03z"/>
</svg>
</button>
<button class="btn" id="sound" aria-label="Sound" aria-pressed="true">
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="on">
<path d="M4 9.2h3.3L12 5.1a.9.9 0 0 1 1.5.7v12.4a.9.9 0 0 1-1.5.7L7.3 14.8H4a.9.9 0 0 1-.9-.9v-3.8a.9.9 0 0 1 .9-.9z"/>
<path d="M16.4 8.6a.95.95 0 0 1 1.34.06 5.1 5.1 0 0 1 0 6.68.95.95 0 1 1-1.4-1.28 3.2 3.2 0 0 0 0-4.12.95.95 0 0 1 .06-1.34z"/>
<path d="M19.1 5.6a.95.95 0 0 1 1.34.05 9.2 9.2 0 0 1 0 12.7.95.95 0 1 1-1.39-1.3 7.3 7.3 0 0 0 0-10.1.95.95 0 0 1 .05-1.35z"/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="off" hidden>
<path d="M4 9.2h3.3L12 5.1a.9.9 0 0 1 1.5.7v12.4a.9.9 0 0 1-1.5.7L7.3 14.8H4a.9.9 0 0 1-.9-.9v-3.8a.9.9 0 0 1 .9-.9z"/>
<path d="M16.5 9.3a1 1 0 0 1 1.42 0L19.6 11l1.68-1.7a1 1 0 1 1 1.42 1.42L21 12.4l1.7 1.7a1 1 0 0 1-1.42 1.42L19.6 13.8l-1.68 1.7a1 1 0 0 1-1.42-1.42l1.7-1.68-1.7-1.68a1 1 0 0 1 0-1.42z"/>
</svg>
</button>
</div>
<section class="panel panel--bottom" data-side="1" data-state="idle">
<div class="inner">
<div class="head">
<div class="score">Score: <span data-score>0 - 0</span>, out of <span data-target>1</span></div>
<div class="moves">Moves: <span data-moves>0</span></div>
</div>
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
</div>
</div>
</section>
</div>
<div class="scrim" id="scrim"></div>
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Settings">
<div class="grab"></div>
<div class="row">
<div>
<h2>Match</h2>
<p>Points to play to.</p>
</div>
<div class="stepper">
<button class="step" data-points-adj="-2"></button>
<span class="val"><span id="v-points">1</span><span class="unit">pts</span></span>
<button class="step" data-points-adj="2">+</button>
</div>
</div>
<div class="row row--presets">
<button class="preset" data-base="120000" data-preset-delay="12000" aria-pressed="false">2m / 12s</button>
<button class="preset" data-base="180000" data-preset-delay="12000" aria-pressed="false">3m / 12s</button>
<button class="preset" data-base="180000" data-preset-delay="15000" aria-pressed="false">3m / 15s</button>
</div>
<div class="row row--wide">
<div>
<h2>Time</h2>
<p id="p-time">Reserve time per point.</p>
</div>
<div class="stepper">
<button class="step" data-time-adj="-60"></button>
<span class="val"><span id="v-min">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60">+</button>
<button class="step" data-time-adj="-15" style="margin-left:8px"></button>
<span class="val"><span id="v-sec">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15">+</button>
</div>
</div>
<div class="row">
<div>
<h2>Delay</h2>
<p>Free seconds each turn. Never carries over.</p>
</div>
<div class="stepper">
<button class="step" data-delay-adj="-1"></button>
<span class="val"><span id="v-delay">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1">+</button>
</div>
</div>
<div class="row">
<div>
<h2>Colour</h2>
<p>Marks the player on turn.</p>
</div>
<div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div>
</div>
<div class="row">
<div>
<h2>Sound</h2>
<p>Turn change, delay expiry, time out.</p>
</div>
<button class="toggle" id="t-sound" role="switch" aria-checked="true" aria-label="Sound"></button>
</div>
<button class="done" id="done">Done</button>
<p class="hint">Changing match, time or delay resets the clocks. The score is left alone.</p>
</div>
<div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score">
<div class="grab"></div>
<div class="row">
<div>
<h2>You</h2>
<p>Points won, this end of the phone.</p>
</div>
<div class="stepper">
<button class="step" data-score-adj="-1" data-who="1"></button>
<span class="val"><span id="v-you">0</span><span class="unit">pts</span></span>
<button class="step" data-score-adj="1" data-who="1">+</button>
</div>
</div>
<div class="row">
<div>
<h2>Them</h2>
<p>Points won, far end.</p>
</div>
<div class="stepper">
<button class="step" data-score-adj="-1" data-who="0"></button>
<span class="val"><span id="v-them">0</span><span class="unit">pts</span></span>
<button class="step" data-score-adj="1" data-who="0">+</button>
</div>
</div>
<div class="row row--wide">
<div>
<h2>Your clock</h2>
</div>
<div class="stepper">
<button class="step" data-clock-adj="-60" data-who="1"></button>
<span class="val"><span id="v-you-min">3</span><span class="unit">min</span></span>
<button class="step" data-clock-adj="60" data-who="1">+</button>
<button class="step" data-clock-adj="-1" data-who="1" style="margin-left:8px"></button>
<span class="val"><span id="v-you-sec">00</span><span class="unit">sec</span></span>
<button class="step" data-clock-adj="1" data-who="1">+</button>
</div>
</div>
<div class="row row--wide">
<div>
<h2>Their clock</h2>
</div>
<div class="stepper">
<button class="step" data-clock-adj="-60" data-who="0"></button>
<span class="val"><span id="v-them-min">3</span><span class="unit">min</span></span>
<button class="step" data-clock-adj="60" data-who="0">+</button>
<button class="step" data-clock-adj="-1" data-who="0" style="margin-left:8px"></button>
<span class="val"><span id="v-them-sec">00</span><span class="unit">sec</span></span>
<button class="step" data-clock-adj="1" data-who="0">+</button>
</div>
</div>
<button class="done" id="score-done">Done</button>
<p class="hint">Changing the score starts a new game: moves back to zero, turn back to
the first tap. The clocks keep their times.</p>
</div>
<script>
(function(){
"use strict";
/* ============ persistence (degrades to memory if blocked) ============ */
var store = {
get: function(k, d){ try{ var v = localStorage.getItem(k); return v === null ? d : v; }catch(e){ return d; } },
set: function(k, v){ try{ localStorage.setItem(k, v); }catch(e){} },
num: function(k, d){ var n = parseInt(store.get(k, ""), 10); return isFinite(n) ? n : d; }
};
// ask the browser not to evict us when the device is low on space
try{ if(navigator.storage && navigator.storage.persist) navigator.storage.persist(); }catch(e){}
var THEMES = [
{ id:"sage", accent:"#7E9A79", ink:"#FFFFFF" },
{ id:"brass", accent:"#C3A153", ink:"#FFFFFF" },
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF" },
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF" },
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF" }
];
var cfg = {
base: store.num("bg.base", 180000), // reserve per point
delay: store.num("bg.delay", 15000),
points: store.num("bg.points", 1), // match length, odd, 1..29
theme: store.get("bg.theme", "sage"),
sound: store.get("bg.sound", "1") === "1"
};
if(!(cfg.points >= 1 && cfg.points <= 29 && cfg.points % 2 === 1)) cfg.points = 1;
// a match to N points is worth N times the per-point reserve
function matchTime(){ return cfg.base * cfg.points; }
/* ============ state ============ */
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
var st = {
phase: IDLE,
active: -1,
reserve: [matchTime(), matchTime()], // reserve banked at the start of the current turn
score: [0, 0], // match points, by side
moves: [0, 0],
turnAt: 0, // performance.now() when the turn began
held: 0, // frozen elapsed while paused
ticked: false // subtle sound already played this turn
};
/* ============ dom ============ */
var panels = [].slice.call(document.querySelectorAll(".panel"));
var el = panels.map(function(p){
return {
root: p,
time: p.querySelector("[data-time]"),
moves: p.querySelector("[data-moves]"),
score: p.querySelector("[data-score]"),
target: p.querySelector("[data-target]"),
delay: p.querySelector("[data-delay]"),
fill: p.querySelector("[data-fill]"),
dnum: p.querySelector("[data-dnum]")
};
});
var btnPlay = document.getElementById("playpause");
var btnSound = document.getElementById("sound");
var btnReset = document.getElementById("reset");
var sheet = document.getElementById("sheet");
var scoreSheet = document.getElementById("scoresheet");
var scrim = document.getElementById("scrim");
/* ============ audio (synthesised — no files, works offline) ============ */
var ac = null;
function audio(){
if(!ac){
var C = window.AudioContext || window.webkitAudioContext;
if(!C) return null;
ac = new C();
}
if(ac.state === "suspended") ac.resume();
return ac;
}
// iOS silences Web Audio through the ringer switch unless the page claims a
// playback session. Trade-off: it also interrupts whatever else is playing,
// so it's only claimed the first time the clock actually makes a sound.
var claimedSession = false;
function claimAudioSession(){
if(claimedSession) return;
claimedSession = true;
try{ if(navigator.audioSession) navigator.audioSession.type = "playback"; }catch(e){}
}
function tone(freq, at, dur, peak, type, partial){
var c = audio(); if(!c) return;
claimAudioSession();
var t0 = c.currentTime + at;
var o = c.createOscillator(), g = c.createGain();
o.type = type || "sine";
o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(c.destination);
o.start(t0); o.stop(t0 + dur + 0.05);
if(partial){
var o2 = c.createOscillator(), g2 = c.createGain();
o2.type = "sine";
o2.frequency.setValueAtTime(freq * 2, t0);
g2.gain.setValueAtTime(0.0001, t0);
g2.gain.exponentialRampToValueAtTime(peak * 0.22, t0 + 0.01);
g2.gain.exponentialRampToValueAtTime(0.0001, t0 + dur * 0.5);
o2.connect(g2); g2.connect(c.destination);
o2.start(t0); o2.stop(t0 + dur);
}
}
// warm rising fifth, marimba-ish — the handover
function sndSwap(){ if(!cfg.sound) return; tone(440, 0, 0.34, 0.20, "sine", true); tone(659.25, 0.075, 0.42, 0.17, "sine", true); }
// barely-there wooden tick — delay just ran out, reserve is now moving
function sndTick(){ if(!cfg.sound) return; tone(300, 0, 0.075, 0.035, "sine", false); }
// unmistakable falling triad — flag
function sndFlag(){
if(!cfg.sound) return;
tone(880, 0.00, 0.20, 0.24, "triangle", false);
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
tone(440, 0.32, 0.55, 0.26, "triangle", true);
}
function buzz(ms){ try{ if(navigator.vibrate) navigator.vibrate(ms); }catch(e){} }
/* ============ helpers ============ */
function elapsed(){
if(st.phase === PAUSE) return st.held;
return performance.now() - st.turnAt;
}
function fmt(ms){
if(ms < 0) ms = 0;
var t = Math.ceil(ms / 100) / 10;
if(ms < 10000) return t.toFixed(1);
var s = Math.ceil(ms / 1000);
var m = Math.floor(s / 60);
s = s % 60;
return m + ":" + (s < 10 ? "0" : "") + s;
}
// is this side's clock the one the turn is on?
function isLive(i){
return i === st.active && (st.phase === RUN || st.phase === PAUSE);
}
// how much of this turn has already come off the reserve (0 while the delay runs)
function consumed(i){
return isLive(i) ? Math.max(0, elapsed() - cfg.delay) : 0;
}
// the number the panel is showing, which is what the score sheet edits
function shownTime(i){
return Math.max(0, st.reserve[i] - consumed(i));
}
/* ============ render ============ */
function render(){
var e = elapsed();
for(var i = 0; i < 2; i++){
var live = isLive(i);
var main = st.reserve[i], dLeft = 0;
if(live){
if(e < cfg.delay){
dLeft = cfg.delay - e;
}else{
main = st.reserve[i] - (e - cfg.delay);
if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
}
}
if(live && main <= 0 && st.phase === RUN){ flag(i); return; }
el[i].time.textContent = fmt(main);
el[i].moves.textContent = st.moves[i];
el[i].score.textContent = st.score[i] + " - " + st.score[1 - i];
el[i].target.textContent = cfg.points;
var showDelay = live && cfg.delay > 0 && dLeft > 0;
el[i].delay.classList.toggle("on", showDelay);
if(showDelay){
el[i].fill.style.transform = "scaleX(" + (dLeft / cfg.delay) + ")";
el[i].dnum.textContent = Math.ceil(dLeft / 1000);
}
if(st.phase === FLAG && i === st.active) el[i].root.dataset.state = "flagged";
else el[i].root.dataset.state = (i === st.active && st.phase !== IDLE) ? "active" : "idle";
}
}
function loop(){ render(); requestAnimationFrame(loop); }
/* ============ actions ============ */
function flag(side){
st.reserve[side] = 0;
st.phase = FLAG;
paintPlay();
render();
sndFlag();
buzz([90, 70, 90]);
saveGame();
}
function tap(side){
if(st.phase === FLAG) return;
if(st.phase === PAUSE) return;
if(st.phase === IDLE){
st.active = 1 - side; // you tap your own side to start your opponent
st.turnAt = performance.now();
st.ticked = false;
st.phase = RUN;
paintPlay(); // the bar has to show what the clock is doing
sndSwap(); buzz(14);
saveGame();
return;
}
if(side !== st.active) return; // the waiting player can't stop the clock
var e = elapsed();
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
st.moves[side]++;
st.active = 1 - side;
st.turnAt = performance.now();
st.ticked = false;
sndSwap(); buzz(14);
saveGame();
}
function pauseNow(){
if(st.phase !== RUN) return;
st.held = elapsed();
st.phase = PAUSE;
paintPlay();
saveGame();
}
function togglePause(){
if(st.phase === RUN){
pauseNow();
return;
}else if(st.phase === PAUSE){
st.turnAt = performance.now() - st.held;
st.phase = RUN;
}else return;
paintPlay();
saveGame();
}
/* SVG elements aren't HTMLElements, so they have no .hidden property —
setting it only makes a JS expando. The attribute has to be set by hand. */
function showIcon(btn, name, on){
var svg = btn.querySelector('[data-icon="' + name + '"]');
if(on) svg.removeAttribute("hidden"); else svg.setAttribute("hidden", "");
}
function paintPlay(){
var showPlay = st.phase !== RUN;
showIcon(btnPlay, "pause", !showPlay);
showIcon(btnPlay, "play", showPlay);
btnPlay.setAttribute("aria-label", showPlay ? "Start clock" : "Pause clock");
}
// back to the first tap of a game, clocks untouched
function newGame(){
st.phase = IDLE; st.active = -1;
st.moves = [0, 0];
st.held = 0; st.ticked = false;
paintPlay();
render();
}
// clocks back to full for the match length; the score is not this function's business
function resetClocks(){
disarm();
st.reserve = [matchTime(), matchTime()];
newGame();
saveGame();
}
// the Reset button — a whole new match
function resetAll(){
st.score = [0, 0];
resetClocks();
}
function clampScore(){
st.score = [Math.min(cfg.points, st.score[0]), Math.min(cfg.points, st.score[1])];
}
/* ============ the game survives a force-quit ============ */
// Time that passes while the app is dead can't be charged to anyone, so a
// running clock is stored as paused and comes back on the play button.
function saveGame(){
var mid = (st.phase === RUN || st.phase === PAUSE);
try{
store.set("bg.game", JSON.stringify({
v: 1,
score: st.score, reserve: st.reserve, moves: st.moves,
active: st.active,
held: mid ? elapsed() : 0,
phase: st.phase === RUN ? PAUSE : st.phase
}));
}catch(e){}
}
function loadGame(){
var raw = store.get("bg.game", null), g;
if(!raw) return;
try{ g = JSON.parse(raw); }catch(e){ return; }
// anything malformed is ignored rather than half-applied
function pair(a){
return a && a.length === 2 && isFinite(a[0]) && isFinite(a[1]) && a[0] >= 0 && a[1] >= 0;
}
if(!g || g.v !== 1 || !pair(g.score) || !pair(g.reserve) || !pair(g.moves)) return;
if(!isFinite(g.held) || g.held < 0) return;
if([IDLE, RUN, PAUSE, FLAG].indexOf(g.phase) < 0) return;
if([-1, 0, 1].indexOf(g.active) < 0) return;
st.score = [g.score[0], g.score[1]];
clampScore(); // the match may have been shortened since
st.reserve = [g.reserve[0], g.reserve[1]];
st.moves = [g.moves[0], g.moves[1]];
st.active = g.active;
st.phase = g.phase === RUN ? PAUSE : g.phase;
st.held = st.phase === PAUSE ? g.held : 0;
st.ticked = st.held > cfg.delay; // don't re-tick a delay that already expired
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
}
// the only hooks iOS reliably fires before killing an app
document.addEventListener("visibilitychange", function(){
if(document.visibilityState === "hidden") saveGame();
});
window.addEventListener("pagehide", saveGame);
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
var armT = null;
function disarm(){
if(armT){ clearTimeout(armT); armT = null; }
btnReset.classList.remove("armed");
btnReset.setAttribute("aria-label", "Reset clock");
}
function armReset(){
if(armT){ disarm(); resetAll(); buzz(20); return; } // second tap inside the window
btnReset.classList.remove("armed");
void btnReset.offsetWidth; // restart the drain animation
btnReset.classList.add("armed");
btnReset.setAttribute("aria-label", "Tap again to reset");
armT = setTimeout(disarm, 2000);
}
/* ============ theme ============ */
function applyTheme(){
var t = THEMES.filter(function(x){ return x.id === cfg.theme; })[0] || THEMES[0];
document.documentElement.style.setProperty("--accent", t.accent);
document.documentElement.style.setProperty("--accent-ink", t.ink);
[].forEach.call(document.querySelectorAll(".sw"), function(s){
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
});
}
/* ============ settings ui ============ */
var swWrap = document.getElementById("swatches");
THEMES.forEach(function(t){
var b = document.createElement("button");
b.className = "sw";
b.dataset.id = t.id;
b.style.background = t.accent;
b.setAttribute("role", "radio");
b.setAttribute("aria-label", t.id);
b.setAttribute("aria-checked", "false");
b.addEventListener("click", function(){
cfg.theme = t.id; store.set("bg.theme", t.id); applyTheme();
});
swWrap.appendChild(b);
});
function clock(ms){ // m:ss, for copy rather than the big readout
var s = Math.round(ms / 1000);
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}
function paintSettings(){
var s = Math.round(cfg.base / 1000);
document.getElementById("v-min").textContent = Math.floor(s / 60);
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000);
document.getElementById("v-points").textContent = cfg.points;
document.getElementById("p-time").textContent = cfg.points === 1
? "Reserve time per point."
: "Reserve time per point. " + cfg.points + " × " + clock(cfg.base) +
" = " + clock(matchTime()) + " each.";
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
showIcon(btnSound, "on", cfg.sound);
showIcon(btnSound, "off", !cfg.sound);
paintPresets();
}
/* highlight is derived from cfg, so it clears when you step away from a
preset and comes back by itself when you step onto one */
function paintPresets(){
[].forEach.call(document.querySelectorAll(".preset"), function(b){
var on = (+b.dataset.base === cfg.base && +b.dataset.presetDelay === cfg.delay);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
[].forEach.call(document.querySelectorAll(".preset"), function(b){
b.addEventListener("click", function(){
cfg.base = parseInt(b.dataset.base, 10);
cfg.delay = parseInt(b.dataset.presetDelay, 10);
store.set("bg.base", String(cfg.base));
store.set("bg.delay", String(cfg.delay));
paintSettings(); resetClocks();
});
});
[].forEach.call(document.querySelectorAll("[data-points-adj]"), function(b){
b.addEventListener("click", function(){
// stepping by 2 from 1 keeps it odd without any parity juggling
var next = cfg.points + parseInt(b.dataset.pointsAdj, 10);
cfg.points = Math.min(29, Math.max(1, next));
store.set("bg.points", String(cfg.points));
clampScore(); // a shorter match can't hold a bigger score
paintSettings(); resetClocks();
});
});
[].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){
b.addEventListener("click", function(){
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
store.set("bg.base", String(cfg.base));
paintSettings(); resetClocks();
});
});
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
b.addEventListener("click", function(){
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
cfg.delay = Math.min(60000, Math.max(0, next));
store.set("bg.delay", String(cfg.delay));
paintSettings(); resetClocks();
});
});
function setSound(on){
cfg.sound = on;
store.set("bg.sound", on ? "1" : "0");
paintSettings();
if(on){ audio(); sndTick(); }
}
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
/* ============ sheets ============ */
var openNode = null;
function openSheet(node){
openNode = node;
sheet.classList.toggle("on", node === sheet);
scoreSheet.classList.toggle("on", node === scoreSheet);
scrim.classList.toggle("on", !!node);
}
function closeSheet(){ openSheet(null); }
document.getElementById("settings").addEventListener("click", function(){
pauseNow(); // nobody's clock runs while a sheet is open
disarm();
paintSettings();
openSheet(sheet);
});
document.getElementById("done").addEventListener("click", closeSheet);
// tapping outside the score sheet discards the staged edits rather than
// committing them — a stray tap must never restart a game
scrim.addEventListener("click", closeSheet);
/* ============ score sheet ============ */
// Edits are staged here and only reach `st` on Done, so opening the sheet,
// fiddling and tapping away changes nothing.
var edit = null, before = null;
function paintScore(){
document.getElementById("v-you").textContent = edit.score[1];
document.getElementById("v-them").textContent = edit.score[0];
[[1, "you"], [0, "them"]].forEach(function(p){
var s = Math.round(edit.time[p[0]] / 1000);
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2);
});
}
function openScore(){
pauseNow();
disarm();
// seed from what the panels are showing, not from the banked reserve — for
// the interrupted turn those differ by the delay already spent
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
before = { score: edit.score.slice(), time: edit.time.slice() };
paintScore();
openSheet(scoreSheet);
}
function applyScore(){
if(!edit) return;
var scoreChanged = edit.score[0] !== before.score[0] || edit.score[1] !== before.score[1];
var timeChanged = edit.time[0] !== before.time[0] || edit.time[1] !== before.time[1];
if(!scoreChanged && !timeChanged) return; // Done with no edits changes nothing
if(scoreChanged){
// the displayed times become the reserves, which banks the interrupted
// turn's spend — "the clocks keep their value"
st.score = edit.score.slice();
st.reserve = [edit.time[0], edit.time[1]];
newGame();
}else{
// a correction mid-game: put the spent delay back on so the panel shows
// exactly what was typed, and leave the turn and its delay alone
st.reserve = [edit.time[0] + consumed(0), edit.time[1] + consumed(1)];
render(); // every other mutator repaints; don't wait a frame
}
saveGame();
}
[].forEach.call(document.querySelectorAll("[data-score-adj]"), function(b){
b.addEventListener("click", function(){
if(!edit) return;
var who = parseInt(b.dataset.who, 10);
var next = edit.score[who] + parseInt(b.dataset.scoreAdj, 10);
edit.score[who] = Math.min(cfg.points, Math.max(0, next));
paintScore();
});
});
[].forEach.call(document.querySelectorAll("[data-clock-adj]"), function(b){
b.addEventListener("click", function(){
if(!edit) return;
var who = parseInt(b.dataset.who, 10);
var next = edit.time[who] + parseInt(b.dataset.clockAdj, 10) * 1000;
edit.time[who] = Math.min(99 * 60000, Math.max(0, next));
paintScore();
});
});
document.getElementById("score").addEventListener("click", openScore);
document.getElementById("score-done").addEventListener("click", function(){
applyScore();
closeSheet();
});
btnReset.addEventListener("click", armReset);
btnPlay.addEventListener("click", togglePause);
/* ============ panel taps ============ */
var wantFullscreen = true;
panels.forEach(function(p){
p.addEventListener("pointerdown", function(ev){
ev.preventDefault();
audio();
if(wantFullscreen){
wantFullscreen = false;
if(!window.matchMedia("(display-mode: fullscreen)").matches && document.documentElement.requestFullscreen){
document.documentElement.requestFullscreen().catch(function(){});
}
}
tap(parseInt(p.dataset.side, 10));
});
});
/* ============ keep the screen awake ============ */
var lock = null;
function keepAwake(){
if(!navigator.wakeLock || lock) return;
if(document.visibilityState !== "visible") return; // request() rejects when hidden
navigator.wakeLock.request("screen").then(function(l){
lock = l;
// the OS drops the lock whenever the app goes to the background; without
// clearing it here the stale handle blocks every later re-request
l.addEventListener("release", function(){ lock = null; });
}).catch(function(){});
}
document.addEventListener("visibilitychange", function(){
if(document.visibilityState !== "visible") return;
keepAwake();
// iOS interrupts the audio context on backgrounding; nudge it back so the
// flag still sounds even if nobody has tapped since returning
// (old webkitAudioContext.resume() returns undefined, so don't chain on it)
if(ac && ac.state === "suspended"){ try{ ac.resume(); }catch(e){} }
});
keepAwake();
/* ============ go ============ */
applyTheme();
paintSettings();
loadGame(); // restores a match in progress; leaves the fresh state if there's none
paintPlay();
render();
loop();
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){
navigator.serviceWorker.register("sw.js").catch(function(){});
}
})();
</script>
</body>
</html>