Adding the presets heading pushed the sheet past the bottom of the screen. It was closer to the edge than it looked: at 390x844 the old content cleared the bottom by 10px, so 49px of new row tipped it over. Height alone couldn't fix it. Measured in real viewports the content was 733px at 390 wide and 750px at 375 — and a 375x667 phone is 667px tall, so on that screen no max-height reaches the content at all. It had to get shorter. Raising the cap 88dvh -> 92dvh does the rest, and stops there: the scrim is what you tap to dismiss (scrim's click handler closes whichever sheet is open), and the sheet's top padding is a flat 8px with no safe-area inset, so a full-height sheet would tuck the grab handle under the notch. Trimmed the row padding 16 -> 14, the grab handle, the Done margin and padding, the hint margin and the presets gap: content down to 685px at 390 wide, which clears the top of the screen by 159px. The two smallest phone sizes still scroll, by 88px at 375x667 — the rest of the fat there is description text wrapping to three lines in the narrow left column, which needs the copy or the row layout changed, not more spacing. The score sheet shares .row and got the same trim: 466px, fits at every size tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1263 lines
48 KiB
HTML
1263 lines
48 KiB
HTML
<!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;
|
||
/* there's nothing to pan in a 100dvh frame, and this keeps a slow drag from
|
||
being cancelled by the browser — it ends in a lift we can measure instead */
|
||
touch-action:none;
|
||
}
|
||
.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))}
|
||
|
||
/* the label column sizes itself to the widest label, so the three values
|
||
line up whatever the system font turns out to be */
|
||
.head{
|
||
display:grid;
|
||
grid-template-columns:max-content max-content;
|
||
justify-content:start;
|
||
align-items:baseline; /* a label and its number share a baseline whatever
|
||
the system font does to the two cells' metrics —
|
||
the default stretches them instead, which only
|
||
lines up while both measure exactly the same */
|
||
gap:3px 8px;
|
||
order:-1;
|
||
font-size:clamp(13px,3.7vw,20px);
|
||
font-weight:700;
|
||
letter-spacing:.01em;
|
||
color:var(--idle-mute);
|
||
white-space:nowrap;
|
||
}
|
||
.line{display:contents}
|
||
.line[hidden]{display:none}
|
||
/* set the moves line apart from the score — a display:contents row can't take
|
||
a margin itself, so both its cells carry it and the row shifts down evenly.
|
||
The guard drops the space when there's no score line above to separate from. */
|
||
.line:not([hidden]) + .line--moves > *{margin-top:7px}
|
||
|
||
.panel[data-state="active"] .head,
|
||
.panel[data-state="flagged"] .head{color:var(--accent-mute)}
|
||
.panel[data-state="flagged"] .head{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[hidden]{display:none} /* .btn's own display would otherwise win */
|
||
/* not [hidden]: that would collapse the slot and shift the whole bar. This
|
||
keeps the space, and still drops the button out of the tab order, the
|
||
accessibility tree and the path of a stray tap. */
|
||
.btn--vacant{visibility:hidden}
|
||
.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:92vh;max-height:92dvh;overflow-y:auto; /* the scroll is a safety net for the smallest screens, not the normal case */
|
||
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:5px auto 11px}
|
||
|
||
/* the sheet is meant to be read without scrolling, and it grew a row; the
|
||
spacing here, on .grab, .done and .hint is set so the whole thing lands
|
||
inside a phone screen rather than running off the bottom */
|
||
.row{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
gap:16px;
|
||
padding:14px 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}
|
||
}
|
||
|
||
/* three buttons wide enough to read can't share a line with a label, so the
|
||
row stacks at every width rather than only under the .row--wide breakpoint */
|
||
.row--presets{flex-direction:column;align-items:stretch;gap:10px}
|
||
.presets{display:flex;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:14px;
|
||
padding:13px;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:9px;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">
|
||
<span class="line" hidden><span>You:</span><span><span data-you>0</span> out of <span data-target>1</span></span></span>
|
||
<span class="line" hidden><span>Them:</span><span data-them>0</span></span>
|
||
<span class="line line--moves"><span>Moves:</span><span data-moves>0</span></span>
|
||
</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" hidden>
|
||
<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">
|
||
<span class="line" hidden><span>You:</span><span><span data-you>0</span> out of <span data-target>1</span></span></span>
|
||
<span class="line" hidden><span>Them:</span><span data-them>0</span></span>
|
||
<span class="line line--moves"><span>Moves:</span><span data-moves>0</span></span>
|
||
</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--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 row--presets">
|
||
<div>
|
||
<h2>Preset time limits</h2>
|
||
<p>Quickly pick a setting.</p>
|
||
</div>
|
||
<div class="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>
|
||
|
||
<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", 12000),
|
||
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]"),
|
||
you: p.querySelector("[data-you]"),
|
||
them: p.querySelector("[data-them]"),
|
||
target: p.querySelector("[data-target]"),
|
||
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
|
||
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 btnScore = document.getElementById("score");
|
||
var btnSettings = document.getElementById("settings");
|
||
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;
|
||
// <= so 10000ms renders as "10.0" rather than "0:10": both branches round up,
|
||
// so the coarse one reads a second high and 0:10 would flash for 1ms
|
||
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));
|
||
}
|
||
|
||
/* Every timer control in the settings resets both clocks, so the sheet is only
|
||
safe to reach in exactly the state resetAll() leaves behind. Checking the
|
||
phase alone would not do: a score change runs newGame(), which returns to
|
||
IDLE waiting for the next game's first tap, with the match still very much
|
||
on. The score and reserve checks are what cover that. */
|
||
function matchPristine(){
|
||
return st.phase === IDLE && st.active === -1 &&
|
||
st.moves[0] === 0 && st.moves[1] === 0 &&
|
||
st.score[0] === 0 && st.score[1] === 0 &&
|
||
st.reserve[0] === matchTime() && st.reserve[1] === matchTime();
|
||
}
|
||
|
||
/* ---- write only on change ----
|
||
render() runs 60 times a second but the digits turn over once a second, and
|
||
.time is a ~190px glyph run: rewriting its text node every frame forces a
|
||
style recalc and relayout for nothing, which is what makes the readout
|
||
stutter. The cached marker avoids reading the DOM back, which would flush
|
||
style by itself. */
|
||
function setText(node, value){
|
||
var s = String(value);
|
||
if(node.__v !== s){ node.__v = s; node.textContent = s; }
|
||
}
|
||
function setAttr(node, name, value){
|
||
if(node.getAttribute(name) !== value) node.setAttribute(name, value);
|
||
}
|
||
function setStyle(node, prop, value){
|
||
var k = "__s_" + prop; // per property, so a second one can't alias
|
||
if(node[k] !== value){ node[k] = value; node.style[prop] = value; }
|
||
}
|
||
|
||
/* ============ 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; }
|
||
|
||
setText(el[i].time, fmt(main));
|
||
setText(el[i].moves, st.moves[i]);
|
||
setText(el[i].you, st.score[i]); // each panel is "You" to its own player
|
||
setText(el[i].them, st.score[1 - i]);
|
||
setText(el[i].target, cfg.points);
|
||
|
||
var showDelay = live && cfg.delay > 0 && dLeft > 0;
|
||
el[i].delay.classList.toggle("on", showDelay); // no-op when already right
|
||
if(showDelay){
|
||
// the bar really does move every frame, but transform is compositor-only
|
||
setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay).toFixed(4) + ")");
|
||
setText(el[i].dnum, Math.ceil(dLeft / 1000));
|
||
}
|
||
|
||
setAttr(el[i].root, "data-state",
|
||
(st.phase === FLAG && i === st.active) ? "flagged"
|
||
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
|
||
}
|
||
|
||
// here rather than in the mutators, so it can never go stale; the
|
||
// two-argument toggle leaves the DOM alone when the state already matches.
|
||
// Settings and Reset are exact opposites: before the match you can change
|
||
// the setup and have nothing to reset, and once it's running, the reverse.
|
||
var pristine = matchPristine();
|
||
btnSettings.classList.toggle("btn--vacant", !pristine);
|
||
btnReset.classList.toggle("btn--vacant", pristine); // nothing played to reset
|
||
btnPlay.classList.toggle("btn--vacant", st.phase === IDLE); // nothing running to pause
|
||
}
|
||
|
||
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;
|
||
// the lift, not the press: your clock runs for as long as your finger is
|
||
// down, so a held thumb costs you exactly the time it was seen counting off
|
||
var now = performance.now();
|
||
|
||
if(st.phase === IDLE){
|
||
st.active = 1 - side; // you tap your own side to start your opponent
|
||
st.turnAt = 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
|
||
|
||
// the same instant charges the outgoing turn and starts the incoming one,
|
||
// so nothing falls between the two and goes to neither player
|
||
var e = now - st.turnAt;
|
||
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
|
||
st.moves[side]++;
|
||
st.active = 1 - side;
|
||
st.turnAt = now;
|
||
st.ticked = false;
|
||
sndSwap(); buzz(14);
|
||
saveGame();
|
||
}
|
||
|
||
function pauseNow(){
|
||
if(st.phase !== RUN) return;
|
||
st.held = elapsed();
|
||
st.phase = PAUSE;
|
||
paintPlay();
|
||
saveGame();
|
||
}
|
||
|
||
function resumeNow(){
|
||
if(st.phase !== PAUSE) return;
|
||
st.turnAt = performance.now() - st.held; // pick the turn up where it stopped
|
||
st.phase = RUN;
|
||
paintPlay();
|
||
saveGame();
|
||
}
|
||
|
||
function togglePause(){
|
||
if(st.phase === RUN) pauseNow();
|
||
else resumeNow();
|
||
}
|
||
|
||
/* 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);
|
||
});
|
||
|
||
// a single game has no match score, so the score line and the sheet that
|
||
// edits it are both out of the way until the match is longer than a point
|
||
function paintMatchChrome(){
|
||
var many = cfg.points > 1;
|
||
function show(node){
|
||
if(many) node.removeAttribute("hidden"); else node.setAttribute("hidden", "");
|
||
}
|
||
show(btnScore);
|
||
el.forEach(function(e){ e.scoreLines.forEach(show); });
|
||
}
|
||
|
||
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.";
|
||
paintMatchChrome();
|
||
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); }
|
||
|
||
btnSettings.addEventListener("click", function(){
|
||
// the CSS only hides the button; the rule itself lives here, so no route in
|
||
// can reach the timer controls once the match is under way
|
||
if(!matchPristine()) return;
|
||
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", function(){
|
||
if(openNode === scoreSheet) closeScore(); else 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, resumeOnClose = false;
|
||
|
||
// leaving the sheet with the turn still live carries straight on. After a score
|
||
// change there is no turn to resume — newGame() has already moved us to IDLE,
|
||
// which the PAUSE check below quietly skips.
|
||
function closeScore(){
|
||
if(resumeOnClose) resumeNow();
|
||
resumeOnClose = false;
|
||
closeSheet();
|
||
}
|
||
|
||
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(){
|
||
// only the sheet's own pause gets undone on the way out; a pause the players
|
||
// set themselves survives
|
||
resumeOnClose = (st.phase === RUN);
|
||
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();
|
||
closeScore();
|
||
});
|
||
|
||
btnReset.addEventListener("click", armReset);
|
||
btnPlay.addEventListener("click", togglePause);
|
||
|
||
/* ============ panel taps ============ */
|
||
// A tap is a press and a lift in the same place. Acting on the press alone
|
||
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
|
||
// bottom panel owns that edge, and starting a swipe home is a finger landing.
|
||
// A swipe travels past SLOP, or the system claims it and we get pointercancel
|
||
// instead of a lift. Either way there's no turn change.
|
||
var SLOP = 12; // px of travel a press may drift and still count
|
||
var wantFullscreen = true;
|
||
panels.forEach(function(p){
|
||
var down = null;
|
||
var side = parseInt(p.dataset.side, 10);
|
||
|
||
p.addEventListener("pointerdown", function(ev){
|
||
ev.preventDefault();
|
||
audio(); // unlocking on the press is a valid gesture and earlier is better
|
||
down = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
|
||
// so the lift still lands here if the finger drifts onto the other panel
|
||
try{ p.setPointerCapture(ev.pointerId); }catch(e){}
|
||
});
|
||
|
||
p.addEventListener("pointerup", function(ev){
|
||
var d = down;
|
||
// only the pointer we're tracking clears it: with two fingers on one panel
|
||
// the second overwrites the first, and the first's lift mustn't wipe it
|
||
if(!d || ev.pointerId !== d.id) return;
|
||
down = null;
|
||
if(Math.abs(ev.clientX - d.x) > SLOP || Math.abs(ev.clientY - d.y) > SLOP) return;
|
||
|
||
// on the lift, not the press, so a swipe can't force fullscreen either
|
||
if(wantFullscreen){
|
||
wantFullscreen = false;
|
||
if(!window.matchMedia("(display-mode: fullscreen)").matches && document.documentElement.requestFullscreen){
|
||
document.documentElement.requestFullscreen().catch(function(){});
|
||
}
|
||
}
|
||
tap(side);
|
||
});
|
||
|
||
// the branch Android's home gesture takes
|
||
p.addEventListener("pointercancel", function(ev){
|
||
if(down && ev.pointerId === down.id) down = null;
|
||
});
|
||
});
|
||
|
||
/* ============ 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>
|