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>
This commit is contained in:
+44
-10
@@ -51,16 +51,43 @@ except installation and the service worker.
|
|||||||
- The waiting player's taps are ignored, so a stray hand won't stop the clock.
|
- The waiting player's taps are ignored, so a stray hand won't stop the clock.
|
||||||
- The bar under the big time is the delay draining. When it empties you get a
|
- The bar under the big time is the delay draining. When it empties you get a
|
||||||
soft tick and your reserve starts moving. That's the moment worth hearing.
|
soft tick and your reserve starts moving. That's the moment worth hearing.
|
||||||
- **Reset** returns to the starting position. It takes two taps: the first arms
|
- **Reset** starts a whole new match: clocks back to full *and* the score back to
|
||||||
the button — it lights up and a ring unwinds around it — and a second tap
|
0–0. It takes two taps: the first arms the button — it lights up and a ring
|
||||||
within those two seconds does it. One stray thumb can't wipe a live game.
|
unwinds around it — and a second tap within those two seconds does it. One
|
||||||
|
stray thumb can't wipe a live match.
|
||||||
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay
|
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay
|
||||||
included. Opening settings pauses automatically, and leaves it paused so the
|
included. Opening either sheet pauses automatically, and leaves it paused so
|
||||||
clock only restarts when both players are ready.
|
the clock only restarts when both players are ready.
|
||||||
|
|
||||||
Backgammon clocks pause between games, and after a cocked die the delay is
|
Backgammon clocks pause between games, and after a cocked die the delay is
|
||||||
normally restarted — use pause and reset for those.
|
normally restarted — use pause and reset for those.
|
||||||
|
|
||||||
|
## Matches
|
||||||
|
|
||||||
|
Set **Match** in the settings to the number of points you're playing to — 1, 3,
|
||||||
|
5 and up to 29. The time you set is *per point*, so a 5-point match at 2:00 a
|
||||||
|
point puts 10:00 on each clock, and the settings sheet shows you that sum.
|
||||||
|
|
||||||
|
The score sheet is the middle icon in the bar. It reads **You** for whoever is
|
||||||
|
holding the phone — the bottom half — and **Them** for the far end, and each
|
||||||
|
panel shows its own score first, so both players read their own score on the
|
||||||
|
left: `Score: 1 - 3, out of 5`.
|
||||||
|
|
||||||
|
Nothing in that sheet takes effect until you press **Done**:
|
||||||
|
|
||||||
|
- **Done with nothing changed** leaves everything exactly as it was — same turn,
|
||||||
|
same delay part-spent, same move counts. Tapping outside the sheet is the same
|
||||||
|
as changing nothing, so a stray tap can't restart a game.
|
||||||
|
- **Change a score** and the game restarts when you press Done: move counters
|
||||||
|
back to zero and the turn back to the first tap, waiting on whoever rolls the
|
||||||
|
lower die. The clocks keep whatever they were showing.
|
||||||
|
- **Change only a clock** — the sheet also lets you correct either player's time
|
||||||
|
to the second — and the game carries on undisturbed: same turn, same delay
|
||||||
|
position, same moves.
|
||||||
|
|
||||||
|
Shortening the match to fewer points than someone has already won pulls their
|
||||||
|
score down to fit.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
At the top of the sheet are three presets — **2m / 12s**, **3m / 12s** and
|
At the top of the sheet are three presets — **2m / 12s**, **3m / 12s** and
|
||||||
@@ -70,17 +97,24 @@ numbers and it comes back on.
|
|||||||
|
|
||||||
| Setting | Range | Default |
|
| Setting | Range | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Time | 0:15 – 99:00 | 3:00 |
|
| Match | 1 – 29 points, odd | 1 |
|
||||||
|
| Time | 0:15 – 99:00 per point | 3:00 |
|
||||||
| Delay | 0 – 60 s | 15 s |
|
| Delay | 0 – 60 s | 15 s |
|
||||||
| Colour | Sage, Brass, Slate, Teal, Plum | Sage |
|
| Colour | Sage, Brass, Slate, Teal, Plum | Sage |
|
||||||
| Sound | on / off | on |
|
| Sound | on / off | on |
|
||||||
|
|
||||||
Brass is the colour in the chess.com app, if you want the exact original look.
|
Brass is the colour in the chess.com app, if you want the exact original look.
|
||||||
Changing time or delay resets the clock. Settings are kept in `localStorage`, so
|
Changing match, time or delay resets the clocks — the score is left alone, so you
|
||||||
they survive closing the tab, force-quitting the installed app and rebooting the
|
can fix the delay mid-match without losing where you are.
|
||||||
|
|
||||||
|
Settings **and the match in progress** are kept in `localStorage`, so they
|
||||||
|
survive closing the tab, force-quitting the installed app and rebooting the
|
||||||
phone — the app also asks for persistent storage so they aren't evicted when the
|
phone — the app also asks for persistent storage so they aren't evicted when the
|
||||||
device runs low on space (Chrome honours that request; Safari ignores it). Only
|
device runs low on space (Chrome honours that request; Safari ignores it).
|
||||||
the settings are stored; a game in progress isn't.
|
Reopening restores the score, both clocks, the move counts, whose turn it is and
|
||||||
|
how much delay was left. The one thing it can't restore is time passing while
|
||||||
|
the app was closed, so it comes back paused rather than charging anyone for the
|
||||||
|
gap: a crash shouldn't decide a game.
|
||||||
|
|
||||||
## Sounds
|
## Sounds
|
||||||
|
|
||||||
|
|||||||
+312
-24
@@ -75,16 +75,23 @@
|
|||||||
.panel--top .inner{padding:14px 20px max(14px,env(safe-area-inset-top))}
|
.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))}
|
.panel--bottom .inner{padding:14px 20px max(14px,env(safe-area-inset-bottom))}
|
||||||
|
|
||||||
.moves{
|
.head{
|
||||||
align-self:flex-end;
|
display:flex;flex-direction:column;align-items:flex-start;
|
||||||
|
gap:3px;
|
||||||
|
order:-1;
|
||||||
|
}
|
||||||
|
.score,.moves{
|
||||||
font-size:clamp(13px,3.7vw,20px);
|
font-size:clamp(13px,3.7vw,20px);
|
||||||
font-weight:700;
|
font-weight:700;
|
||||||
letter-spacing:.01em;
|
letter-spacing:.01em;
|
||||||
color:var(--idle-mute);
|
color:var(--idle-mute);
|
||||||
order:-1;
|
white-space:nowrap;
|
||||||
}
|
}
|
||||||
|
.panel[data-state="active"] .score,
|
||||||
.panel[data-state="active"] .moves,
|
.panel[data-state="active"] .moves,
|
||||||
|
.panel[data-state="flagged"] .score,
|
||||||
.panel[data-state="flagged"] .moves{color:var(--accent-mute)}
|
.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)}
|
.panel[data-state="flagged"] .moves{color:rgba(255,255,255,.55)}
|
||||||
|
|
||||||
.stack{
|
.stack{
|
||||||
@@ -138,7 +145,7 @@
|
|||||||
display:flex;align-items:center;
|
display:flex;align-items:center;
|
||||||
padding:0 clamp(12px,4vw,28px);
|
padding:0 clamp(12px,4vw,28px);
|
||||||
}
|
}
|
||||||
.bar .gap{flex:1.5}
|
.bar .gap{flex:.75}
|
||||||
.btn{
|
.btn{
|
||||||
flex:1;
|
flex:1;
|
||||||
height:100%;
|
height:100%;
|
||||||
@@ -303,7 +310,10 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<section class="panel panel--top" data-side="0" data-state="idle">
|
<section class="panel panel--top" data-side="0" data-state="idle">
|
||||||
<div class="inner">
|
<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 class="moves">Moves: <span data-moves>0</span></div>
|
||||||
|
</div>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="time" data-time>3:00</div>
|
<div class="time" data-time>3:00</div>
|
||||||
<div class="delay" data-delay>
|
<div class="delay" data-delay>
|
||||||
@@ -330,6 +340,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="gap"></span>
|
<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">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="3.3"/>
|
||||||
@@ -351,7 +370,10 @@
|
|||||||
|
|
||||||
<section class="panel panel--bottom" data-side="1" data-state="idle">
|
<section class="panel panel--bottom" data-side="1" data-state="idle">
|
||||||
<div class="inner">
|
<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 class="moves">Moves: <span data-moves>0</span></div>
|
||||||
|
</div>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="time" data-time>3:00</div>
|
<div class="time" data-time>3:00</div>
|
||||||
<div class="delay" data-delay>
|
<div class="delay" data-delay>
|
||||||
@@ -368,6 +390,18 @@
|
|||||||
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Settings">
|
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Settings">
|
||||||
<div class="grab"></div>
|
<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">
|
<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="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="12000" aria-pressed="false">3m / 12s</button>
|
||||||
@@ -377,7 +411,7 @@
|
|||||||
<div class="row row--wide">
|
<div class="row row--wide">
|
||||||
<div>
|
<div>
|
||||||
<h2>Time</h2>
|
<h2>Time</h2>
|
||||||
<p>Reserve time per player.</p>
|
<p id="p-time">Reserve time per point.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stepper">
|
<div class="stepper">
|
||||||
<button class="step" data-time-adj="-60">–</button>
|
<button class="step" data-time-adj="-60">–</button>
|
||||||
@@ -418,7 +452,67 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="done" id="done">Done</button>
|
<button class="done" id="done">Done</button>
|
||||||
<p class="hint">Changing time or delay resets the clock.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -444,18 +538,24 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
var cfg = {
|
var cfg = {
|
||||||
base: store.num("bg.base", 180000),
|
base: store.num("bg.base", 180000), // reserve per point
|
||||||
delay: store.num("bg.delay", 15000),
|
delay: store.num("bg.delay", 15000),
|
||||||
|
points: store.num("bg.points", 1), // match length, odd, 1..29
|
||||||
theme: store.get("bg.theme", "sage"),
|
theme: store.get("bg.theme", "sage"),
|
||||||
sound: store.get("bg.sound", "1") === "1"
|
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 ============ */
|
/* ============ state ============ */
|
||||||
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
|
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
|
||||||
var st = {
|
var st = {
|
||||||
phase: IDLE,
|
phase: IDLE,
|
||||||
active: -1,
|
active: -1,
|
||||||
reserve: [cfg.base, cfg.base], // reserve banked at the start of the current turn
|
reserve: [matchTime(), matchTime()], // reserve banked at the start of the current turn
|
||||||
|
score: [0, 0], // match points, by side
|
||||||
moves: [0, 0],
|
moves: [0, 0],
|
||||||
turnAt: 0, // performance.now() when the turn began
|
turnAt: 0, // performance.now() when the turn began
|
||||||
held: 0, // frozen elapsed while paused
|
held: 0, // frozen elapsed while paused
|
||||||
@@ -469,6 +569,8 @@
|
|||||||
root: p,
|
root: p,
|
||||||
time: p.querySelector("[data-time]"),
|
time: p.querySelector("[data-time]"),
|
||||||
moves: p.querySelector("[data-moves]"),
|
moves: p.querySelector("[data-moves]"),
|
||||||
|
score: p.querySelector("[data-score]"),
|
||||||
|
target: p.querySelector("[data-target]"),
|
||||||
delay: p.querySelector("[data-delay]"),
|
delay: p.querySelector("[data-delay]"),
|
||||||
fill: p.querySelector("[data-fill]"),
|
fill: p.querySelector("[data-fill]"),
|
||||||
dnum: p.querySelector("[data-dnum]")
|
dnum: p.querySelector("[data-dnum]")
|
||||||
@@ -478,6 +580,7 @@
|
|||||||
var btnSound = document.getElementById("sound");
|
var btnSound = document.getElementById("sound");
|
||||||
var btnReset = document.getElementById("reset");
|
var btnReset = document.getElementById("reset");
|
||||||
var sheet = document.getElementById("sheet");
|
var sheet = document.getElementById("sheet");
|
||||||
|
var scoreSheet = document.getElementById("scoresheet");
|
||||||
var scrim = document.getElementById("scrim");
|
var scrim = document.getElementById("scrim");
|
||||||
|
|
||||||
/* ============ audio (synthesised — no files, works offline) ============ */
|
/* ============ audio (synthesised — no files, works offline) ============ */
|
||||||
@@ -551,11 +654,24 @@
|
|||||||
return m + ":" + (s < 10 ? "0" : "") + s;
|
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 ============ */
|
/* ============ render ============ */
|
||||||
function render(){
|
function render(){
|
||||||
var e = elapsed();
|
var e = elapsed();
|
||||||
for(var i = 0; i < 2; i++){
|
for(var i = 0; i < 2; i++){
|
||||||
var live = (i === st.active && (st.phase === RUN || st.phase === PAUSE));
|
var live = isLive(i);
|
||||||
var main = st.reserve[i], dLeft = 0;
|
var main = st.reserve[i], dLeft = 0;
|
||||||
|
|
||||||
if(live){
|
if(live){
|
||||||
@@ -571,6 +687,8 @@
|
|||||||
|
|
||||||
el[i].time.textContent = fmt(main);
|
el[i].time.textContent = fmt(main);
|
||||||
el[i].moves.textContent = st.moves[i];
|
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;
|
var showDelay = live && cfg.delay > 0 && dLeft > 0;
|
||||||
el[i].delay.classList.toggle("on", showDelay);
|
el[i].delay.classList.toggle("on", showDelay);
|
||||||
@@ -594,6 +712,7 @@
|
|||||||
render();
|
render();
|
||||||
sndFlag();
|
sndFlag();
|
||||||
buzz([90, 70, 90]);
|
buzz([90, 70, 90]);
|
||||||
|
saveGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
function tap(side){
|
function tap(side){
|
||||||
@@ -607,6 +726,7 @@
|
|||||||
st.phase = RUN;
|
st.phase = RUN;
|
||||||
paintPlay(); // the bar has to show what the clock is doing
|
paintPlay(); // the bar has to show what the clock is doing
|
||||||
sndSwap(); buzz(14);
|
sndSwap(); buzz(14);
|
||||||
|
saveGame();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(side !== st.active) return; // the waiting player can't stop the clock
|
if(side !== st.active) return; // the waiting player can't stop the clock
|
||||||
@@ -618,6 +738,7 @@
|
|||||||
st.turnAt = performance.now();
|
st.turnAt = performance.now();
|
||||||
st.ticked = false;
|
st.ticked = false;
|
||||||
sndSwap(); buzz(14);
|
sndSwap(); buzz(14);
|
||||||
|
saveGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
function pauseNow(){
|
function pauseNow(){
|
||||||
@@ -625,6 +746,7 @@
|
|||||||
st.held = elapsed();
|
st.held = elapsed();
|
||||||
st.phase = PAUSE;
|
st.phase = PAUSE;
|
||||||
paintPlay();
|
paintPlay();
|
||||||
|
saveGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePause(){
|
function togglePause(){
|
||||||
@@ -636,6 +758,7 @@
|
|||||||
st.phase = RUN;
|
st.phase = RUN;
|
||||||
}else return;
|
}else return;
|
||||||
paintPlay();
|
paintPlay();
|
||||||
|
saveGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SVG elements aren't HTMLElements, so they have no .hidden property —
|
/* SVG elements aren't HTMLElements, so they have no .hidden property —
|
||||||
@@ -652,16 +775,79 @@
|
|||||||
btnPlay.setAttribute("aria-label", showPlay ? "Start clock" : "Pause clock");
|
btnPlay.setAttribute("aria-label", showPlay ? "Start clock" : "Pause clock");
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset(){
|
// back to the first tap of a game, clocks untouched
|
||||||
disarm();
|
function newGame(){
|
||||||
st.phase = IDLE; st.active = -1;
|
st.phase = IDLE; st.active = -1;
|
||||||
st.reserve = [cfg.base, cfg.base];
|
|
||||||
st.moves = [0, 0];
|
st.moves = [0, 0];
|
||||||
st.held = 0; st.ticked = false;
|
st.held = 0; st.ticked = false;
|
||||||
paintPlay();
|
paintPlay();
|
||||||
render();
|
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 */
|
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
|
||||||
var armT = null;
|
var armT = null;
|
||||||
function disarm(){
|
function disarm(){
|
||||||
@@ -670,7 +856,7 @@
|
|||||||
btnReset.setAttribute("aria-label", "Reset clock");
|
btnReset.setAttribute("aria-label", "Reset clock");
|
||||||
}
|
}
|
||||||
function armReset(){
|
function armReset(){
|
||||||
if(armT){ disarm(); reset(); buzz(20); return; } // second tap inside the window
|
if(armT){ disarm(); resetAll(); buzz(20); return; } // second tap inside the window
|
||||||
btnReset.classList.remove("armed");
|
btnReset.classList.remove("armed");
|
||||||
void btnReset.offsetWidth; // restart the drain animation
|
void btnReset.offsetWidth; // restart the drain animation
|
||||||
btnReset.classList.add("armed");
|
btnReset.classList.add("armed");
|
||||||
@@ -704,11 +890,21 @@
|
|||||||
swWrap.appendChild(b);
|
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(){
|
function paintSettings(){
|
||||||
var s = Math.round(cfg.base / 1000);
|
var s = Math.round(cfg.base / 1000);
|
||||||
document.getElementById("v-min").textContent = Math.floor(s / 60);
|
document.getElementById("v-min").textContent = Math.floor(s / 60);
|
||||||
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
|
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
|
||||||
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000);
|
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");
|
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
|
||||||
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
|
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
|
||||||
showIcon(btnSound, "on", cfg.sound);
|
showIcon(btnSound, "on", cfg.sound);
|
||||||
@@ -731,7 +927,18 @@
|
|||||||
cfg.delay = parseInt(b.dataset.presetDelay, 10);
|
cfg.delay = parseInt(b.dataset.presetDelay, 10);
|
||||||
store.set("bg.base", String(cfg.base));
|
store.set("bg.base", String(cfg.base));
|
||||||
store.set("bg.delay", String(cfg.delay));
|
store.set("bg.delay", String(cfg.delay));
|
||||||
paintSettings(); reset();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -740,7 +947,7 @@
|
|||||||
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
|
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
|
||||||
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
|
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
|
||||||
store.set("bg.base", String(cfg.base));
|
store.set("bg.base", String(cfg.base));
|
||||||
paintSettings(); reset();
|
paintSettings(); resetClocks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
|
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
|
||||||
@@ -748,7 +955,7 @@
|
|||||||
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
|
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
|
||||||
cfg.delay = Math.min(60000, Math.max(0, next));
|
cfg.delay = Math.min(60000, Math.max(0, next));
|
||||||
store.set("bg.delay", String(cfg.delay));
|
store.set("bg.delay", String(cfg.delay));
|
||||||
paintSettings(); reset();
|
paintSettings(); resetClocks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -761,18 +968,98 @@
|
|||||||
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
|
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
|
||||||
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
|
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
|
||||||
|
|
||||||
function openSheet(on){
|
/* ============ sheets ============ */
|
||||||
sheet.classList.toggle("on", on);
|
var openNode = null;
|
||||||
scrim.classList.toggle("on", on);
|
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(){
|
document.getElementById("settings").addEventListener("click", function(){
|
||||||
pauseNow(); // nobody's clock runs while the sheet is open
|
pauseNow(); // nobody's clock runs while a sheet is open
|
||||||
disarm();
|
disarm();
|
||||||
paintSettings();
|
paintSettings();
|
||||||
openSheet(true);
|
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();
|
||||||
});
|
});
|
||||||
document.getElementById("done").addEventListener("click", function(){ openSheet(false); });
|
|
||||||
scrim.addEventListener("click", function(){ openSheet(false); });
|
|
||||||
|
|
||||||
btnReset.addEventListener("click", armReset);
|
btnReset.addEventListener("click", armReset);
|
||||||
btnPlay.addEventListener("click", togglePause);
|
btnPlay.addEventListener("click", togglePause);
|
||||||
@@ -818,8 +1105,9 @@
|
|||||||
/* ============ go ============ */
|
/* ============ go ============ */
|
||||||
applyTheme();
|
applyTheme();
|
||||||
paintSettings();
|
paintSettings();
|
||||||
|
loadGame(); // restores a match in progress; leaves the fresh state if there's none
|
||||||
paintPlay();
|
paintPlay();
|
||||||
reset();
|
render();
|
||||||
loop();
|
loop();
|
||||||
|
|
||||||
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){
|
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
/* Cache-first: once installed the clock never touches the network again. */
|
/* Cache-first: once installed the clock never touches the network again. */
|
||||||
const CACHE = "bgclock-v3";
|
const CACHE = "bgclock-v4";
|
||||||
const FILES = [
|
const FILES = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|||||||
Reference in New Issue
Block a user