6 Commits
Author SHA1 Message Date
hansdezwartandClaude Opus 5 5ad69c6ff2 Release 1.6
versionCode 7. public_html/ changed, so the service worker cache goes to
bgclock-v22.

Confirmed on device, from a debug APK over the top of 1.5: the settings kept
from 1.5 came back on the You row with the switch off; the gear stayed
reachable with the two bases set apart, which is the failure that would have
had no way out; each side ran on its own delay, including a player set to zero
starting on their reserve immediately; and a turn left running well past the
delay, force-quit and reopened, came back paused with the split still in force.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:55:33 +02:00
hansdezwartandClaude Opus 5 a34d6058a1 Set the time and the delay per player
A handicap match had no way to happen: one time control, one delay, both
players. The settings sheet now carries a switch between Match and Time —
"Different time per player" — and turning it on gives Time and Delay a second
row each, labelled You for the bottom half and Them for the far end, the same
way round as the score sheet. Them starts as a copy of You. Turning it off puts
both players back on You's value, so the row that survives is the one still on
screen; turning it on again re-copies rather than remembering what Them used to
say. The presets go while it is on, because a preset is one pair of numbers and
there are two players to give it to.

Underneath, cfg.base and cfg.delay are arrays indexed like st.reserve and the
panels, and they are arrays whether or not the switch is on. Split is a fact
about the sheet, not about the clock: render() and tap() read their own side's
number and never ask which mode they are in, and shared is simply the state
where the two entries are equal, kept that way by one line in the setter. The
line that mattered most was matchPristine(), which compares both sides now —
left alone it would have made the gear unreachable the moment the two bases
differed, silently and with no way back but clearing storage.

bg.base and bg.delay still mean what they always did, which is side 1, so an
install from before this lands symmetric and an older build reading the same
storage still finds its own settings. bg.split, bg.base.them and bg.delay.them
are the new keys, and all four are clamped on the way in — points was the only
setting ever validated at load, which was survivable while nothing but our own
steppers could write these.

The sheet loses two rows that earned nothing. Sound duplicated the speaker
button that is in the bar at all times, including mid-match when the sheet
cannot be opened at all; the hint under Done repeated what the sheet's own
behaviour teaches. The score sheet's hint now names the match it would restart
within, and the Time copy ends in a colon and puts its two sums on their own
line, where they fit without breaking mid-sum.

Two bugs worth recording, both invisible in the diff and both caught by
rendering rather than by asserting. The Them rows are .side elements and
.side{display:flex} beat the hidden attribute, so both stepper rows showed in
shared mode — the probe asked for the hidden property, which was set correctly,
and passed. And data-them was already the panels' opponent-score span, so
hiding the sheet's Them rows blanked the score on both halves and collapsed the
head grid; it is data-them-row now.

Screenshots 2 and 3 are redone. The other three are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:55:20 +02:00
hansdezwartandClaude Opus 5 e207478e79 Release 1.5
versionCode 6. public_html/ changed, so the service worker cache goes to
bgclock-v21.

Not confirmed on device: the force-quit-and-reopen check from RELEASING.md
step 4 has not been run for this build. Nothing in it touches storage, timing
or the state machine — the change is a label, an inherited colour and a lit
button, all written from render() — but the check is still owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:42:35 +02:00
hansdezwartandClaude Opus 5 3f6fe5abf1 Say Paused on both halves while the clock is stopped
A stopped clock looked exactly like a running one. The panel keeps its accent
while paused, and isLive() counts a paused side as live, so the delay bar just
freezes mid-drain — nothing about either half says the clocks aren't moving.
The only tell was the play glyph in the bar, which is small, grey, and upside
down for one of the two players. It matters most in the state the app is
designed to land in: a match restored from localStorage comes back paused on
purpose, and nothing said so.

So both halves say it in words, under the delay bar, sized to sit between the
bar and the clock: wider than the bar at every screen size, narrower than the
clock at m:ss. The band is tighter than it sounds, since "3:00" is only about
2.2x the bar, so part of the width comes from the tracking rather than the
glyph size. Measured from 320 to 1024px wide it lands at 1.3-1.4x the delay
row and 74-82% of the clock. It is wider than the clock in the last ten
seconds of a reserve, where the readout drops to three narrow characters —
unavoidable inside these bounds, and the two are never confusable at four
times the height.

The word hangs off the delay row rather than joining .stack, so the big clock
does not move by a pixel when the clock stops. It cannot live inside .delay
either: that is opacity:0 whenever no delay is running, and a child can never
be more opaque than its parent, which would lose the word on the waiting half,
on a spent delay, and at a delay of 0. It takes no colour of its own, so it
wears whatever ink the clock above it is wearing, in every theme.

The play button lights up while paused, the same --icon-hot a held Reset uses.
Both toggles live in render() rather than in paintPlay(), for the reason the
comment there already gives: state written from the render loop cannot go
stale.

Screenshot 2 is redone — the score sheet pauses, so the word shows behind the
scrim. The other four are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:42:25 +02:00
hansdezwartandClaude Opus 5 7f1b246679 Release 1.4
versionCode 5. public_html/ changed, so the service worker cache goes to
bgclock-v20.

Confirmed on device: a turn left running well past the delay, force-quit and
reopened, now comes back with the time it had actually spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:07:26 +02:00
hansdezwartandClaude Opus 5 3a7eb8c612 Save the match every second instead of waiting to be told
A force-quit deep into a turn came back at the clock's last-turn-switch value,
losing the whole interrupted turn. The tell was the delay counter returning at
a full 12: the newest saved state was the one tap() writes, and tap() sets
turnAt and calls saveGame() in the same breath, so it carries held ~ 0.
Restoring it reproduces the moment of the turn switch exactly.

The two triggers meant to save at quit time — visibilitychange and pagehide —
do not fire in Android's WebView. MainActivity's onPause() forwards to
web.onPause() under a comment claiming that is what makes visibilitychange
fire. It isn't: document visibility follows the WebView's view and window
visibility, not that call. The forwarding is still right for stopping timers
and audio, but the saved match should never have depended on it.

This is not new. It has almost certainly been broken since 1.0, and the release
check could not catch it, because for the first 12 seconds of a turn the delay
absorbs everything and render() never touches st.reserve. A clock that has lost
its place is indistinguishable from one that is correctly sitting still, so
every force-quit test that quit soon after a turn change passed. RELEASING.md
now says to let the turn run 40 seconds first, which is the only version of
that check worth running.

So the page stops waiting to be told. A timer writes the match once a second
while a clock is running — a timer rather than a hook in loop(), because
requestAnimationFrame stops when the page is hidden, which is exactly when this
matters. blur joins the listeners too: a different signal, and one that does
fire when the task switcher opens. At most a second can now be lost, which is
well inside the delay nobody is charged for anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:07:18 +02:00
11 changed files with 339 additions and 85 deletions
+29 -9
View File
@@ -70,7 +70,10 @@ everything works except installation and the service worker.
stray thumb can't wipe a live match. It's absent on a fresh match, where there
is nothing yet to wipe.
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay
included. Opening either sheet pauses automatically. The settings sheet leaves
included. Both halves read **Paused** underneath the delay bar while it is, in
the same ink as the clock above it, and the play button lights up — so a
stopped clock can't be mistaken for a running one from either side of the
board. Opening either sheet pauses automatically. The settings sheet leaves
it paused — everything in there resets the clocks anyway — while the score
sheet picks the turn back up when you close it, so glancing at the score
doesn't cost you a tap on play. It's absent whenever there's no clock running
@@ -93,7 +96,8 @@ normally restarted — use pause and reset for those.
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.
point puts 10:00 on each clock, and the settings sheet shows you that sum — one
sum, or one for each player if you've set their times apart.
Each half reads the state from that player's own side of the board:
@@ -133,17 +137,33 @@ score down to fit.
## Settings
Under **Delay** are three presets — **2m / 12s**, **3m / 12s** and **3m / 15s**.
Tapping one jumps both timers there and lights it up. Change the time or delay by
hand and the highlight goes out; land back on a preset's numbers and it comes
back on.
Tapping one puts both the time and the delay there and lights it up. Change
either by hand and the highlight goes out; land back on a preset's numbers and it
comes back on.
**Different time per player** — the switch under the Time steppers — gives each
player their own time *and* their own delay. Turn it on and a second row appears
under both, labelled **You** for the bottom half of the phone and **Them** for
the far end, the same way round as the score sheet. Them starts as a copy of You;
change either from there. Turn it back off and both players go back to whatever
You says, so You is always the row that survives — set the shared time first and
split it afterwards, not the other way round.
The presets are gone while that switch is on. A preset is one pair of numbers and
there are two players to give it to, so there is nothing sensible for it to mean.
A handicap match is the point of the switch: give the stronger player less time,
or take one player's delay down to zero and leave the other's alone.
| Setting | Range | Default |
| --- | --- | --- |
| Match | 1 – 29 points, odd | 1 |
| Time | 0:15 – 99:00 per point | 3:00 |
| Delay | 0 – 60 s | 12 s |
| Time | 0:15 – 99:00 per point, per player | 3:00 |
| Delay | 0 – 60 s, per player | 12 s |
| Different time per player | on / off | off |
| Colour | Sage, Brass, Slate, Teal, Plum | Sage |
| Sound | on / off | on |
Sound isn't in the sheet — it's the speaker button in the bar, reachable at any
time, mid-match included.
Brass is the colour in the chess.com app, if you want the exact original look.
Changing match, time or delay resets the clocks — the score is left alone, so you
@@ -154,7 +174,7 @@ 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
device runs low on space (Chrome honours that request; Safari ignores it).
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
how much delay was left — each player's own delay, if you've set them apart. 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.
+8
View File
@@ -61,6 +61,14 @@ make some moves, force-quit from the task switcher and reopen — everything
should come back, paused. That is the check that proves the app's storage is
working, and it fails silently rather than loudly.
**Let the turn run at least 40 seconds before you quit.** This is the whole
check, and doing it any faster proves nothing: for the first 12 seconds of a
turn the delay absorbs everything and the reserve clock is *supposed* to sit
still, so a clock that has lost its place looks exactly like one that hasn't.
Quit well past the delay and the time must come back lower than it was at the
turn switch — and the delay counter must not be sitting at a full 12. That is
how a broken save hid from this check from 1.0 to 1.3.
## 5. Commit, tag, push
```bash
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "nl.hansdezwart.bgclock"
minSdk = 26
targetSdk = 36
versionCode = 4
versionName = "1.3"
versionCode = 7
versionName = "1.6"
}
compileOptions {
@@ -121,8 +121,15 @@ public class MainActivity extends Activity {
}
}
// forwarded so the page's visibilitychange fires — that is what calls
// saveGame() and nudges the audio context back after backgrounding
// Forwarded so the WebView stops its timers, animations and audio while the
// app is in the background, and picks them up again on return.
//
// This does NOT drive the page's visibilitychange — an earlier comment here
// claimed it did, and the saved match quietly depended on that being true.
// Document visibility follows the WebView's view and window visibility, not
// this call, so the page can be killed without ever hearing about it. The
// page no longer relies on being told: it writes the match to localStorage
// once a second while a clock is running.
@Override
protected void onPause() {
super.onPause();
@@ -0,0 +1,4 @@
Fixes a match not coming back correctly after the app is force-quit. The clock
returned to where it stood at the last turn change, losing the time spent on
the turn that was interrupted. The match is now written down once a second
while a clock is running, so at most a second can be lost.
@@ -0,0 +1 @@
Added a 'Paused' indicator when the clock is paused.
@@ -0,0 +1 @@
Rationalised the settings page and added the option to set a different time and delay per player.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 174 KiB

+284 -71
View File
@@ -182,6 +182,49 @@
text-align:right;
}
/* ---------- paused ----------
A stopped clock used to look exactly like a running one: the panel keeps its
accent, and the delay bar simply freezes mid-drain. The only tell was the
play glyph in the bar — small, grey, and upside down for one of the two
players. So both halves say it in words.
Hung off the delay row rather than added to .stack, so the big clock does
not move a pixel when the clock stops — the same promise .btn--vacant makes
in the bar, and the reason .delay fades rather than hides. It also cannot
live *inside* .delay: that is opacity:0 whenever no delay is running, and a
child can never be more opaque than its parent, which would lose the word on
the waiting half, on a spent delay, and at a delay of 0. */
.foot{position:relative}
.paused{
position:absolute;
top:100%;
left:50%;
/* the bar and its number sit dead centre of the panel (see .dnum), so
centring on that row centres on the panel, whatever the count reads */
transform:translateX(-50%);
/* a clear step below the bar rather than .stack's gap: near enough to belong
to the delay row, far enough not to crowd it */
margin-top:clamp(21px,5.8vw,41px);
white-space:nowrap;
line-height:1;
/* Wider than the delay bar, narrower than the clock — the band is tighter
than it sounds, since "3:00" is only about 2.2x the bar. The crossovers
are .track's own (20vw between 360 and 700px wide), so the word holds its
proportion to the bar at every size instead of drifting at the ends.
Part of the width comes from the tracking rather than the glyph size:
spaced letters carry "held" without shouting over the clock. */
font-size:clamp(34px,9.4vw,66px);
font-weight:700;
letter-spacing:.08em;
/* no colour of its own: it inherits the panel's, which is what the clock and
the delay count are already wearing. Each theme picks its own --accent-ink
(white on most of them), so the word follows the palette without a rule
per theme, and the paused half never has two different inks on it. */
opacity:0;
transition:opacity .16s ease;
}
.paused.on{opacity:1}
/* ---------- control bar ---------- */
.bar{
background:var(--bar);
@@ -199,6 +242,9 @@
cursor:pointer;
}
.btn:active{color:var(--icon-hot)}
/* the way back from a paused clock, lit like a held Reset: the bar is the only
thing either player can act on while the clocks are stopped */
#playpause.lit{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}
@@ -323,8 +369,33 @@
border-top:1px solid var(--sheet-line);
}
.row:first-of-type{border-top:0}
.row[hidden]{display:none} /* .row's own display would otherwise win */
.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}
/* the Time copy puts its two sums on a line of their own when the players are
set apart; everything else here has no newline in it and is unaffected */
#p-time{white-space:pre-line}
/* Time and Delay each hold one stepper, or two when the players are set
separately. The second row is added rather than the first relabelled, so
the row a player already knows keeps its place and its numbers. */
.sides{display:flex;flex-direction:column;gap:10px}
.side{display:flex;align-items:center;justify-content:flex-end;gap:10px}
.side[hidden]{display:none} /* .side's own display would otherwise win */
/* pushed left against the stepper's right edge, so both rows' steppers line
up whatever the two labels measure */
.who{font-size:15px;font-weight:600;color:var(--sheet-dim);margin-right:auto}
/* nobody to tell apart while there is one row, and the label would only be
one more word to read */
.sheet:not(.split) .who{display:none}
/* a labelled stepper pair can't share a line with the heading at any width;
scoped to .split so the shared sheet keeps exactly the layout it had */
.sheet.split .row--wide,.sheet.split .row--delay{flex-direction:column;align-items:stretch}
/* the switch sits above the two rows it governs, so it reads as the thing
that decides their shape rather than as one more Time control */
.onoff-ctl{display:flex;align-items:center;gap:10px}
.onoff-ctl span{font-size:13px;color:var(--sheet-dim)}
/* 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 */
@@ -432,9 +503,12 @@
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
<div class="foot">
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
<div class="paused" data-paused>Paused</div>
</div>
</div>
</div>
@@ -498,9 +572,12 @@
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
<div class="foot">
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
<div class="paused" data-paused>Paused</div>
</div>
</div>
</div>
@@ -524,34 +601,75 @@
</div>
</div>
<div class="row">
<div>
<h2>Different time per player</h2>
<p>Sets the delay separately too.</p>
</div>
<div class="onoff-ctl">
<span>Off</span>
<button class="toggle" id="t-split" role="switch" aria-checked="false" aria-label="Different time per player"></button>
<span>On</span>
</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 class="sides">
<div class="side">
<span class="who">You</span>
<div class="stepper">
<button class="step" data-time-adj="-60" data-who="1">–</button>
<span class="val"><span data-v="min" data-who="1">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60" data-who="1">+</button>
<button class="step" data-time-adj="-15" data-who="1" style="margin-left:8px">–</button>
<span class="val"><span data-v="sec" data-who="1">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15" data-who="1">+</button>
</div>
</div>
<div class="side" data-them-row hidden>
<span class="who">Them</span>
<div class="stepper">
<button class="step" data-time-adj="-60" data-who="0">–</button>
<span class="val"><span data-v="min" data-who="0">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60" data-who="0">+</button>
<button class="step" data-time-adj="-15" data-who="0" style="margin-left:8px">–</button>
<span class="val"><span data-v="sec" data-who="0">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15" data-who="0">+</button>
</div>
</div>
</div>
</div>
<div class="row">
<div class="row row--delay">
<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 class="sides">
<div class="side">
<span class="who">You</span>
<div class="stepper">
<button class="step" data-delay-adj="-1" data-who="1">–</button>
<span class="val"><span data-v="delay" data-who="1">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1" data-who="1">+</button>
</div>
</div>
<div class="side" data-them-row hidden>
<span class="who">Them</span>
<div class="stepper">
<button class="step" data-delay-adj="-1" data-who="0">–</button>
<span class="val"><span data-v="delay" data-who="0">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1" data-who="0">+</button>
</div>
</div>
</div>
</div>
<div class="row row--presets">
<div class="row row--presets" id="row-presets">
<div>
<h2>Preset time limits</h2>
<p>Quickly pick a setting.</p>
@@ -571,16 +689,7 @@
<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">
@@ -639,8 +748,7 @@
</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>
<p class="hint" id="p-score-hint"></p>
</div>
<script>
@@ -670,24 +778,46 @@
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF", mute:.77 }
];
/* The limits the steppers enforce, kept here because the load path needs them
too: bg.base.them and bg.delay.them can arrive from a half-upgraded install,
and a corrupt reserve makes a clock that can never flag. */
var LIMIT = { base: [15000, 99 * 60000], delay: [0, 60000] };
function clampTimer(which, ms){
var l = LIMIT[which];
return isFinite(ms) ? Math.min(l[1], Math.max(l[0], ms)) : l[0];
}
/* base and delay are per side, indexed like st.reserve and the panels: 1 is
the bottom half ("you"), 0 the far end ("them"), the same way round as the
score sheet. They are arrays whether or not the setting is split — split is
a fact about the sheet, not about the clock, so nothing downstream of here
has to ask. Shared is simply the state where the two entries are equal.
bg.base and bg.delay keep meaning what they always did, which is side 1, so
an older build reading this storage still finds its own settings, and an
install that predates the split lands symmetric. */
var split = store.get("bg.split", "0") === "1";
var yBase = clampTimer("base", store.num("bg.base", 180000));
var yDelay = clampTimer("delay", store.num("bg.delay", 12000));
var cfg = {
base: store.num("bg.base", 180000), // reserve per point
delay: store.num("bg.delay", 12000),
base: [split ? clampTimer("base", store.num("bg.base.them", yBase)) : yBase, yBase],
delay: [split ? clampTimer("delay", store.num("bg.delay.them", yDelay)) : yDelay, yDelay],
split: split,
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; }
// a match to N points is worth N times that side's per-point reserve
function matchTime(i){ return cfg.base[i] * 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
reserve: [matchTime(0), matchTime(1)], // 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
@@ -709,7 +839,8 @@
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
delay: p.querySelector("[data-delay]"),
fill: p.querySelector("[data-fill]"),
dnum: p.querySelector("[data-dnum]")
dnum: p.querySelector("[data-dnum]"),
paused: p.querySelector("[data-paused]")
};
});
var btnPlay = document.getElementById("playpause");
@@ -808,7 +939,7 @@
}
// 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;
return isLive(i) ? Math.max(0, elapsed() - cfg.delay[i]) : 0;
}
// the number the panel is showing, which is what the score sheet edits
function shownTime(i){
@@ -824,7 +955,7 @@
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();
st.reserve[0] === matchTime(0) && st.reserve[1] === matchTime(1);
}
/* ---- write only on change ----
@@ -856,10 +987,10 @@
var main = st.reserve[i], dLeft = 0;
if(live){
if(e < cfg.delay){
dLeft = cfg.delay - e;
if(e < cfg.delay[i]){
dLeft = cfg.delay[i] - e;
}else{
main = st.reserve[i] - (e - cfg.delay);
main = st.reserve[i] - (e - cfg.delay[i]);
if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
}
}
@@ -878,14 +1009,19 @@
setText(el[i].them, st.score[1 - i]);
setText(el[i].target, cfg.points);
var showDelay = live && cfg.delay > 0 && dLeft > 0;
var showDelay = live && cfg.delay[i] > 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) + ")");
setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay[i]).toFixed(4) + ")");
setText(el[i].dnum, Math.ceil(dLeft / 1000));
}
// both halves, not just the one whose turn it is: the waiting player's
// clock is stopped too, and they are the one who can't read the bar's
// play glyph the right way up
el[i].paused.classList.toggle("on", st.phase === PAUSE);
setAttr(el[i].root, "data-state",
(st.phase === FLAG && i === st.active) ? "flagged"
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
@@ -901,6 +1037,7 @@
// nothing running to pause: before a game's first tap, and after a flag,
// where togglePause is inert anyway — the game is over, Reset is the way out
btnPlay.classList.toggle("btn--vacant", st.phase === IDLE || st.phase === FLAG);
btnPlay.classList.toggle("lit", st.phase === PAUSE);
}
function loop(){ render(); requestAnimationFrame(loop); }
@@ -937,7 +1074,7 @@
// 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.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay[side]));
st.moves[side]++;
st.active = 1 - side;
st.turnAt = now;
@@ -993,7 +1130,7 @@
// clocks back to full for the match length; the score is not this function's business
function resetClocks(){
cancelHold();
st.reserve = [matchTime(), matchTime()];
st.reserve = [matchTime(0), matchTime(1)];
newGame();
saveGame();
}
@@ -1047,15 +1184,32 @@
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
// the delay that matters is the one belonging to whoever's turn it was;
// active is legitimately -1 here, which has no delay to compare against
st.ticked = st.active >= 0 && st.held > cfg.delay[st.active];
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
// The hooks iOS fires before killing an app. Android's WebView is the problem
// child: neither of these is guaranteed there, whatever MainActivity's
// onPause() forwarding suggests, and when they don't fire the newest save is
// the one tap() wrote — which carries held ≈ 0, because tap() sets turnAt and
// saves in the same breath. Reopening then hands back the entire turn, and
// the bug hides in plain sight: quit inside the delay window and a clock that
// correctly hasn't moved looks exactly like a clock that lost its place.
document.addEventListener("visibilitychange", function(){
if(document.visibilityState === "hidden") saveGame();
});
window.addEventListener("pagehide", saveGame);
// a different signal from visibilitychange, and it does fire when Android's
// task switcher opens. saveGame() is idempotent, so an extra call is free
window.addEventListener("blur", saveGame);
// and the one that doesn't depend on being told anything: a second is the
// most a force-quit can now cost, which is well inside the delay nobody is
// charged for. A timer rather than a hook in loop(), because rAF stops when
// the page is hidden — exactly when this matters most.
setInterval(function(){ if(st.phase === RUN) saveGame(); }, 1000);
/* Reset is a hold, not a tap. It used to be two taps inside 2s, and a test
user found the button, tapped it once and gave up — which is exactly what
@@ -1163,43 +1317,103 @@
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}
// the settings readouts, by side — the score sheet already owns the obvious
// v-you-min / v-them-min ids, and a side index is what data- is for here
function readout(name, who){
return document.querySelector('[data-v="' + name + '"][data-who="' + who + '"]');
}
var vals = [0, 1].map(function(who){
return { min: readout("min", who), sec: readout("sec", who), delay: readout("delay", who) };
});
// "5 × 3:00 = 15:00" for one side
function sum(i){
return cfg.points + " × " + clock(cfg.base[i]) + " = " + clock(matchTime(i));
}
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);
for(var i = 0; i < 2; i++){
var s = Math.round(cfg.base[i] / 1000);
vals[i].min.textContent = Math.floor(s / 60);
vals[i].sec.textContent = ("0" + (s % 60)).slice(-2);
vals[i].delay.textContent = Math.round(cfg.delay[i] / 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.";
/* At a single point the match total is the per-point time, so there is no
sum to introduce and the colon would dangle. Split, the two sums are put
on their own line — together they are too long to sit after the label
without breaking mid-sum, which reads as one number split in half.
The newline needs white-space:pre-line on #p-time. */
document.getElementById("p-time").textContent =
cfg.points === 1 ? "Reserve time per point."
: cfg.split ? "Reserve time per point:\n" + sum(1) + " you, " + sum(0) + " them."
: "Reserve time per point: " + sum(1) + " each.";
paintSplit();
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();
}
/* The presets can only mean one thing for both players, so they go while the
two sides can disagree. Everything else here is the sheet rearranging
itself; the clock never asks whether the setting is split. */
function paintSplit(){
sheet.classList.toggle("split", cfg.split);
document.getElementById("t-split").setAttribute("aria-checked", cfg.split ? "true" : "false");
[].forEach.call(document.querySelectorAll("[data-them-row]"), function(n){
if(cfg.split) n.removeAttribute("hidden"); else n.setAttribute("hidden", "");
});
document.getElementById("row-presets").hidden = cfg.split;
}
/* 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 */
preset and comes back by itself when you step onto one. Side 1 is enough:
the row is only on show while both sides hold the same numbers. */
function paintPresets(){
[].forEach.call(document.querySelectorAll(".preset"), function(b){
var on = (+b.dataset.base === cfg.base && +b.dataset.presetDelay === cfg.delay);
var on = (+b.dataset.base === cfg.base[1] && +b.dataset.presetDelay === cfg.delay[1]);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
/* the one writer for both timers. Shared mode is kept as an equality rather
than a special case: write one side, write the other. */
function setTimer(which, who, ms){
cfg[which][who] = clampTimer(which, ms);
if(!cfg.split) cfg[which][1 - who] = cfg[which][who];
saveTimers();
paintSettings(); resetClocks();
}
function saveTimers(){
store.set("bg.base", String(cfg.base[1]));
store.set("bg.delay", String(cfg.delay[1]));
store.set("bg.base.them", String(cfg.base[0]));
store.set("bg.delay.them", String(cfg.delay[0]));
}
[].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));
// only reachable while shared, so both sides take the preset
var base = parseInt(b.dataset.base, 10), d = parseInt(b.dataset.presetDelay, 10);
cfg.base = [base, base];
cfg.delay = [d, d];
saveTimers();
paintSettings(); resetClocks();
});
});
document.getElementById("t-split").addEventListener("click", function(){
cfg.split = !cfg.split;
// the same line both ways: Them copies You going in, You wins coming out
cfg.base[0] = cfg.base[1];
cfg.delay[0] = cfg.delay[1];
store.set("bg.split", cfg.split ? "1" : "0");
saveTimers();
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
@@ -1213,18 +1427,14 @@
[].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();
var who = parseInt(b.dataset.who, 10);
setTimer("base", who, cfg.base[who] + parseInt(b.dataset.timeAdj, 10) * 1000);
});
});
[].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();
var who = parseInt(b.dataset.who, 10);
setTimer("delay", who, cfg.delay[who] + parseInt(b.dataset.delayAdj, 10) * 1000);
});
});
@@ -1234,7 +1444,6 @@
paintSettings();
if(on){ audio(); sndTick(); }
}
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
/* ============ sheets ============ */
@@ -1285,6 +1494,10 @@
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2);
});
// the sheet only opens on a match of 3 points or more, so there is always a
// target worth naming
document.getElementById("p-score-hint").textContent =
"Changing the score will start a new game within this match to " + cfg.points + ".";
}
function openScore(){
+1 -1
View File
@@ -1,5 +1,5 @@
/* Cache-first: once installed the clock never touches the network again. */
const CACHE = "bgclock-v19";
const CACHE = "bgclock-v22";
const FILES = [
"./",
"./index.html",