Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6653541bb9 | ||
|
|
7095e2b86e | ||
|
|
5ad69c6ff2 | ||
|
|
a34d6058a1 | ||
|
|
e207478e79 | ||
|
|
3f6fe5abf1 | ||
|
|
7f1b246679 | ||
|
|
3a7eb8c612 | ||
|
|
bf3f565dd0 | ||
|
|
ec1a65c89c | ||
|
|
b31087223a | ||
|
|
404fdb5538 |
@@ -6,3 +6,6 @@ android/local.properties
|
|||||||
# editor leftovers
|
# editor leftovers
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# python leftovers
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -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
|
stray thumb can't wipe a live match. It's absent on a fresh match, where there
|
||||||
is nothing yet to wipe.
|
is nothing yet to wipe.
|
||||||
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay
|
- **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
|
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
|
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
|
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,
|
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
|
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:
|
Each half reads the state from that player's own side of the board:
|
||||||
|
|
||||||
@@ -133,17 +137,33 @@ score down to fit.
|
|||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Under **Delay** are three presets — **2m / 12s**, **3m / 12s** and **3m / 15s**.
|
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
|
Tapping one puts both the time and the delay there and lights it up. Change
|
||||||
hand and the highlight goes out; land back on a preset's numbers and it comes
|
either by hand and the highlight goes out; land back on a preset's numbers and it
|
||||||
back on.
|
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 |
|
| Setting | Range | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Match | 1 – 29 points, odd | 1 |
|
| Match | 1 – 29 points, odd | 1 |
|
||||||
| Time | 0:15 – 99:00 per point | 3:00 |
|
| Time | 0:15 – 99:00 per point, per player | 3:00 |
|
||||||
| Delay | 0 – 60 s | 12 s |
|
| Delay | 0 – 60 s, per player | 12 s |
|
||||||
|
| Different time per player | on / off | off |
|
||||||
| Colour | Sage, Brass, Slate, Teal, Plum | Sage |
|
| 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.
|
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
|
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
|
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).
|
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
|
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
|
the app was closed, so it comes back paused rather than charging anyone for the
|
||||||
gap: a crash shouldn't decide a game.
|
gap: a crash shouldn't decide a game.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
should come back, paused. That is the check that proves the app's storage is
|
||||||
working, and it fails silently rather than loudly.
|
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
|
## 5. Commit, tag, push
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -110,10 +118,29 @@ Separate from all of the above, and manual: upload the six files in
|
|||||||
|
|
||||||
## If the screenshots need redoing
|
## If the screenshots need redoing
|
||||||
|
|
||||||
`fastlane/metadata/android/en-US/images/phoneScreenshots/` holds five shots at
|
```bash
|
||||||
1170×2532. They are generated from `public_html/index.html` itself with headless
|
python3 tools/screenshots.py
|
||||||
Chromium, not taken on a phone, so they can be regenerated exactly. Two traps if
|
```
|
||||||
you do it by hand:
|
|
||||||
|
That rewrites all five in
|
||||||
|
`fastlane/metadata/android/en-US/images/phoneScreenshots/` at 1170×2532. They
|
||||||
|
are rendered from `public_html/index.html` with headless Chromium rather than
|
||||||
|
taken on a phone, so anything that changes the look of the app is a reason to
|
||||||
|
re-run it.
|
||||||
|
|
||||||
|
**Near-exact, not exact.** Two runs of identical code differ by a few bytes in
|
||||||
|
shots 1 and 4: a 4×13 pixel sliver at the delay bar's fill edge, where the
|
||||||
|
`scaleX` boundary lands mid-pixel and the rasteriser rounds it differently from
|
||||||
|
one run to the next. Max channel difference 11, invisible. So a `git diff` after
|
||||||
|
re-running is not evidence that anything changed — compare the pixels before
|
||||||
|
believing it, and `git checkout` the files back if that sliver is all it is.
|
||||||
|
|
||||||
|
The script sets each scene by seeding `localStorage` before the app boots, so
|
||||||
|
the app renders its own saved state rather than having the DOM poked from
|
||||||
|
outside. Editing a scene means editing the `SCENES` table at the top.
|
||||||
|
|
||||||
|
Three traps, all of which fail quietly, and all of which the script already
|
||||||
|
handles — they are written down here because they cost an afternoon each:
|
||||||
|
|
||||||
- The capture lands whenever the virtual-time budget runs out, not when your
|
- The capture lands whenever the virtual-time budget runs out, not when your
|
||||||
script finishes — so freeze the render loop (`requestAnimationFrame = () => 0`)
|
script finishes — so freeze the render loop (`requestAnimationFrame = () => 0`)
|
||||||
@@ -121,6 +148,10 @@ you do it by hand:
|
|||||||
- CSS transitions don't advance under `--virtual-time-budget`, so a panel caught
|
- CSS transitions don't advance under `--virtual-time-budget`, so a panel caught
|
||||||
mid-transition photographs in its *old* colour. Disable transitions in the
|
mid-transition photographs in its *old* colour. Disable transitions in the
|
||||||
screenshot build.
|
screenshot build.
|
||||||
|
- Snap-confined Chromium cannot write into hidden directories — not `~/.cache`,
|
||||||
|
not any dot-directory, not the private `/tmp` it is handed. It fails with a
|
||||||
|
bare "Permission denied" and no hint as to why. Both the HTML it reads and the
|
||||||
|
PNG it writes have to sit in a plainly-named directory.
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "nl.hansdezwart.bgclock"
|
applicationId = "nl.hansdezwart.bgclock"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 2
|
versionCode = 8
|
||||||
versionName = "1.1"
|
versionName = "1.7"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -121,8 +121,15 @@ public class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// forwarded so the page's visibilitychange fires — that is what calls
|
// Forwarded so the WebView stops its timers, animations and audio while the
|
||||||
// saveGame() and nudges the audio context back after backgrounding
|
// 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
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
super.onPause();
|
super.onPause();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Improved the contrast to be compliant with accessibility guidelines.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Resetting the clock is now a press and hold rather than two taps. The ring
|
||||||
|
around the button fills while you hold it, and springs back if you let go
|
||||||
|
early, so the button explains itself. A short sound confirms the reset when
|
||||||
|
sound is on.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Change the score icon to a tally.
|
||||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 76 KiB |
@@ -14,16 +14,18 @@
|
|||||||
<link rel="apple-touch-icon" href="icon-192.png">
|
<link rel="apple-touch-icon" href="icon-192.png">
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--accent:#7E9A79;
|
--accent:#7C9777;
|
||||||
--accent-ink:#FFFFFF;
|
--accent-ink:#FFFFFF;
|
||||||
--accent-mute:rgba(0,0,0,.34);
|
--accent-mute:rgba(0,0,0,.66); /* replaced per theme by applyTheme() */
|
||||||
|
--track:rgba(0,0,0,.34); /* the delay bar, not text — see .track */
|
||||||
--idle:#9A9A9A;
|
--idle:#9A9A9A;
|
||||||
--idle-ink:#2E2E2E;
|
--idle-ink:#2E2E2E;
|
||||||
--idle-mute:rgba(0,0,0,.36);
|
--idle-mute:rgba(0,0,0,.63);
|
||||||
--bar:#332E2B;
|
--bar:#332E2B;
|
||||||
--icon:#8E8880;
|
--icon:#8E8880;
|
||||||
--icon-hot:#DFD9D1;
|
--icon-hot:#DFD9D1;
|
||||||
--flag:#A6564E;
|
--flag:#A6564E;
|
||||||
|
--flag-mute:rgba(255,255,255,.82);
|
||||||
--sheet:#232120;
|
--sheet:#232120;
|
||||||
--sheet-line:rgba(255,255,255,.09);
|
--sheet-line:rgba(255,255,255,.09);
|
||||||
--sheet-ink:#EDE9E4;
|
--sheet-ink:#EDE9E4;
|
||||||
@@ -90,7 +92,11 @@
|
|||||||
lines up while both measure exactly the same */
|
lines up while both measure exactly the same */
|
||||||
gap:3px 8px;
|
gap:3px 8px;
|
||||||
order:-1;
|
order:-1;
|
||||||
font-size:clamp(13px,3.7vw,20px);
|
/* the 19px floor is load-bearing, not taste: WCAG's large-text threshold is
|
||||||
|
14pt bold = 18.66px, and below it these labels need 4.5:1 rather than 3:1
|
||||||
|
— which no ink still readable as "muted" can reach on a mid-tone accent.
|
||||||
|
Lower this and the contrast stops being conformant, silently. */
|
||||||
|
font-size:clamp(19px,3.9vw,24px);
|
||||||
font-weight:700;
|
font-weight:700;
|
||||||
letter-spacing:.01em;
|
letter-spacing:.01em;
|
||||||
color:var(--idle-mute);
|
color:var(--idle-mute);
|
||||||
@@ -103,9 +109,8 @@
|
|||||||
The guard drops the space when there's no score line above to separate from. */
|
The guard drops the space when there's no score line above to separate from. */
|
||||||
.line:not([hidden]) + .line--moves > *{margin-top:7px}
|
.line:not([hidden]) + .line--moves > *{margin-top:7px}
|
||||||
|
|
||||||
.panel[data-state="active"] .head,
|
.panel[data-state="active"] .head{color:var(--accent-mute)}
|
||||||
.panel[data-state="flagged"] .head{color:var(--accent-mute)}
|
.panel[data-state="flagged"] .head{color:var(--flag-mute)}
|
||||||
.panel[data-state="flagged"] .head{color:rgba(255,255,255,.55)}
|
|
||||||
|
|
||||||
.stack{
|
.stack{
|
||||||
flex:1;
|
flex:1;
|
||||||
@@ -151,7 +156,9 @@
|
|||||||
width:clamp(72px,20vw,140px);
|
width:clamp(72px,20vw,140px);
|
||||||
height:clamp(4px,1.1vw,7px);
|
height:clamp(4px,1.1vw,7px);
|
||||||
border-radius:99px;
|
border-radius:99px;
|
||||||
background:var(--accent-mute);
|
/* its own token: --accent-mute is text ink now and far darker than this bar
|
||||||
|
wants, and the two only ever agreed by accident */
|
||||||
|
background:var(--track);
|
||||||
opacity:.45;
|
opacity:.45;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
}
|
}
|
||||||
@@ -162,11 +169,62 @@
|
|||||||
transform-origin:left center;
|
transform-origin:left center;
|
||||||
}
|
}
|
||||||
.dnum{
|
.dnum{
|
||||||
font-size:clamp(15px,4.4vw,26px);
|
font-size:clamp(19px,4.4vw,26px); /* 19px for the same reason as .head */
|
||||||
font-weight:700;
|
font-weight:700;
|
||||||
min-width:1.6em;
|
/* Exactly two digits — the delay caps at 60s, so it never needs a third —
|
||||||
|
and right-aligned, which does two things at once: the bar and the number
|
||||||
|
together sit dead centre of the panel whatever the count, and the digit
|
||||||
|
that changes every second stays put instead of sliding left when the
|
||||||
|
count drops out of double figures. The gap to the bar opens up by one
|
||||||
|
digit instead. 2ch is the digit advance, so with tabular figures this is
|
||||||
|
the exact width and not an approximation of it. */
|
||||||
|
min-width:2ch;
|
||||||
|
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 ---------- */
|
/* ---------- control bar ---------- */
|
||||||
.bar{
|
.bar{
|
||||||
background:var(--bar);
|
background:var(--bar);
|
||||||
@@ -184,6 +242,9 @@
|
|||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
}
|
}
|
||||||
.btn:active{color:var(--icon-hot)}
|
.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[aria-pressed="false"]{color:#5E5954}
|
||||||
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
|
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
|
||||||
.btn svg[hidden]{display:none}
|
.btn svg[hidden]{display:none}
|
||||||
@@ -194,26 +255,73 @@
|
|||||||
.btn--vacant{visibility:hidden}
|
.btn--vacant{visibility:hidden}
|
||||||
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
|
.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 confirms two different ways, because the two input methods can't share
|
||||||
|
one. A finger holds the button and the ring fills under it; lifting early
|
||||||
|
springs the ring back, which is the only way to say "keep going" without
|
||||||
|
words — and the bar carries no words by design, since it is shared by two
|
||||||
|
players sitting opposite each other. Keyboard and assistive activation
|
||||||
|
can't hold, so they keep the older two-presses-within-2s, where the ring
|
||||||
|
drains to show the window closing. */
|
||||||
#reset{position:relative}
|
#reset{position:relative}
|
||||||
#reset.armed{color:var(--icon-hot)}
|
#reset.armed,#reset.holding{color:var(--icon-hot)}
|
||||||
#reset .ring{
|
#reset .ring{
|
||||||
position:absolute;
|
position:absolute;
|
||||||
top:50%;left:50%;
|
top:50%;left:50%;
|
||||||
width:clamp(36px,9.6vw,48px);height:clamp(36px,9.6vw,48px);
|
/* As big as the bar will take, so a thumb can't cover it: the button is the
|
||||||
|
bar's full height, leaving 4px of dark above and below. width:auto is
|
||||||
|
load-bearing — .btn svg sets a width for the icons, and inheriting it here
|
||||||
|
would give the ring that width against this height and draw an ellipse.
|
||||||
|
It ends up a few px wider than the button's own slot, which is only ever
|
||||||
|
empty space; the neighbouring glyph stays a good 12px clear. */
|
||||||
|
height:calc(100% - 8px);
|
||||||
|
width:auto;
|
||||||
|
aspect-ratio:1;
|
||||||
transform:translate(-50%,-50%) rotate(-90deg); /* start the arc at 12 o'clock */
|
transform:translate(-50%,-50%) rotate(-90deg); /* start the arc at 12 o'clock */
|
||||||
display:none;
|
opacity:0;
|
||||||
|
/* Hiding waits out the spring-back, then fades. display:none here would cut
|
||||||
|
the retreat off at the instant the finger lifts, and that retreat is the
|
||||||
|
whole reason the gesture teaches itself. The 120ms is a real duration on
|
||||||
|
purpose: a 0s transition with a delay is allowed to be treated as no
|
||||||
|
transition at all, which would take the delay with it. */
|
||||||
|
transition:opacity 120ms linear 180ms;
|
||||||
pointer-events:none;
|
pointer-events:none;
|
||||||
}
|
}
|
||||||
#reset.armed .ring{display:block}
|
/* appearing is instant — no duration, no delay */
|
||||||
|
#reset.armed .ring,#reset.holding .ring{opacity:1;transition:none}
|
||||||
|
|
||||||
#reset .ring circle{
|
#reset .ring circle{
|
||||||
fill:none;
|
fill:none;
|
||||||
stroke:currentColor; /* the same flash the buttons use */
|
/* user units, so it scales with the ring: 2 of 40 lands between 2.5 and
|
||||||
stroke-width:2.6;
|
3.4 real px across phone sizes, a shade heavier than the icon strokes
|
||||||
|
because this one has to read past a thumb */
|
||||||
|
stroke-width:2;
|
||||||
|
}
|
||||||
|
/* the whole circle, dim, there from the moment the finger lands: it shows how
|
||||||
|
far there is to go, which an arc growing out of nothing cannot */
|
||||||
|
#reset .ring-track{stroke:var(--icon)}
|
||||||
|
/* and the bright arc that covers it, from 12 o'clock clockwise */
|
||||||
|
#reset .ring-fill{
|
||||||
|
stroke:var(--icon-hot);
|
||||||
stroke-linecap:round;
|
stroke-linecap:round;
|
||||||
stroke-dasharray:113.1; /* 2πr, r=18 */
|
stroke-dasharray:113.1; /* 2πr, r=18 */
|
||||||
animation:unwind 2s linear forwards;
|
stroke-dashoffset:113.1; /* empty */
|
||||||
|
/* The spring back. 180ms is the ceiling, not the figure: reversing a
|
||||||
|
transition part-way shortens it in proportion, so letting go at 41% takes
|
||||||
|
74ms, and the arc never spends longer retreating than it spent filling.
|
||||||
|
The dim track outlasts it either way — the ring holds for 180ms and then
|
||||||
|
fades, so even a stray tap shows a whole circle appear and go, which is
|
||||||
|
what says there was something to finish. */
|
||||||
|
transition:stroke-dashoffset 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
#reset.holding .ring-fill{
|
||||||
|
stroke-dashoffset:0;
|
||||||
|
transition-duration:975ms; /* keep in step with HOLD_MS */
|
||||||
|
transition-timing-function:linear;
|
||||||
|
}
|
||||||
|
/* the keyboard path: a full ring draining over the 2s window. An animation
|
||||||
|
beats a transition on the same property, which is fine here only because
|
||||||
|
.armed and .holding are never set at once. */
|
||||||
|
#reset.armed .ring-fill{animation:unwind 2s linear forwards}
|
||||||
@keyframes unwind{
|
@keyframes unwind{
|
||||||
from{stroke-dashoffset:0}
|
from{stroke-dashoffset:0}
|
||||||
to{stroke-dashoffset:113.1}
|
to{stroke-dashoffset:113.1}
|
||||||
@@ -261,8 +369,33 @@
|
|||||||
border-top:1px solid var(--sheet-line);
|
border-top:1px solid var(--sheet-line);
|
||||||
}
|
}
|
||||||
.row:first-of-type{border-top:0}
|
.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 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}
|
.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
|
/* 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 */
|
label on any phone — stack it under one instead of overflowing the sheet */
|
||||||
@@ -347,6 +480,13 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion:reduce){
|
@media (prefers-reduced-motion:reduce){
|
||||||
*{transition-duration:.01ms!important}
|
*{transition-duration:.01ms!important}
|
||||||
|
/* ...except the reset ring, which reports how far through the hold you are.
|
||||||
|
Collapsing it would fill the ring the instant you touched the button and
|
||||||
|
tell you it was done 650ms before it was. This one is state, not
|
||||||
|
decoration. Higher specificity than the * above, so it wins despite both
|
||||||
|
being !important. */
|
||||||
|
#reset .ring-fill{transition-duration:180ms!important}
|
||||||
|
#reset.holding .ring-fill{transition-duration:975ms!important}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -363,17 +503,24 @@
|
|||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="time" data-time>3:00</div>
|
<div class="time" data-time>3:00</div>
|
||||||
<div class="out" data-out hidden>Out of<br>time</div>
|
<div class="out" data-out hidden>Out of<br>time</div>
|
||||||
|
<div class="foot">
|
||||||
<div class="delay" data-delay>
|
<div class="delay" data-delay>
|
||||||
<span class="track"><i class="fill" data-fill></i></span>
|
<span class="track"><i class="fill" data-fill></i></span>
|
||||||
<span class="dnum" data-dnum>15</span>
|
<span class="dnum" data-dnum>15</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="paused" data-paused>Paused</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<button class="btn" id="reset" aria-label="Reset clock">
|
<button class="btn" id="reset" aria-label="Reset clock, hold to confirm">
|
||||||
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
|
<!-- ring-track/ring-fill, not track/fill: those two are the delay bar's -->
|
||||||
|
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true">
|
||||||
|
<circle class="ring-track" cx="20" cy="20" r="18"/>
|
||||||
|
<circle class="ring-fill" 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">
|
<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"/>
|
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -389,10 +536,13 @@
|
|||||||
<span class="gap"></span>
|
<span class="gap"></span>
|
||||||
<button class="btn" id="score" aria-label="Score" hidden>
|
<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">
|
<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"/>
|
<!-- a tally: three uprights and the fifth-mark slash. Three rather than
|
||||||
<path d="M12 7.5v10"/>
|
four, and the slash kept inside their span, because at 23px a fourth
|
||||||
<path d="M7.6 10.6h2.6"/><path d="M13.8 10.6h2.6"/>
|
upright closes the gaps and the whole thing reads as a picket fence.
|
||||||
<path d="M7.6 14.6h2.6"/><path d="M13.8 14.6h2.6"/>
|
It is the one score mark that needs no box around it to explain
|
||||||
|
itself, and it stays a tally seen from the far side of the board. -->
|
||||||
|
<path d="M6.5 6v12"/><path d="M12 6v12"/><path d="M17.5 6v12"/>
|
||||||
|
<path d="M4 18.5L20 5.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="gap"></span>
|
<span class="gap"></span>
|
||||||
@@ -425,10 +575,13 @@
|
|||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="time" data-time>3:00</div>
|
<div class="time" data-time>3:00</div>
|
||||||
<div class="out" data-out hidden>Out of<br>time</div>
|
<div class="out" data-out hidden>Out of<br>time</div>
|
||||||
|
<div class="foot">
|
||||||
<div class="delay" data-delay>
|
<div class="delay" data-delay>
|
||||||
<span class="track"><i class="fill" data-fill></i></span>
|
<span class="track"><i class="fill" data-fill></i></span>
|
||||||
<span class="dnum" data-dnum>15</span>
|
<span class="dnum" data-dnum>15</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="paused" data-paused>Paused</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -451,34 +604,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 class="row row--wide">
|
||||||
<div>
|
<div>
|
||||||
<h2>Time</h2>
|
<h2>Time</h2>
|
||||||
<p id="p-time">Reserve time per point.</p>
|
<p id="p-time">Reserve time per point.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sides">
|
||||||
|
<div class="side">
|
||||||
|
<span class="who">You</span>
|
||||||
<div class="stepper">
|
<div class="stepper">
|
||||||
<button class="step" data-time-adj="-60">–</button>
|
<button class="step" data-time-adj="-60" data-who="1">–</button>
|
||||||
<span class="val"><span id="v-min">3</span><span class="unit">min</span></span>
|
<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">+</button>
|
<button class="step" data-time-adj="60" data-who="1">+</button>
|
||||||
<button class="step" data-time-adj="-15" style="margin-left:8px">–</button>
|
<button class="step" data-time-adj="-15" data-who="1" style="margin-left:8px">–</button>
|
||||||
<span class="val"><span id="v-sec">00</span><span class="unit">sec</span></span>
|
<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">+</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row row--delay">
|
||||||
<div>
|
<div>
|
||||||
<h2>Delay</h2>
|
<h2>Delay</h2>
|
||||||
<p>Free seconds each turn. Never carries over.</p>
|
<p>Free seconds each turn. Never carries over.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sides">
|
||||||
|
<div class="side">
|
||||||
|
<span class="who">You</span>
|
||||||
<div class="stepper">
|
<div class="stepper">
|
||||||
<button class="step" data-delay-adj="-1">–</button>
|
<button class="step" data-delay-adj="-1" data-who="1">–</button>
|
||||||
<span class="val"><span id="v-delay">15</span><span class="unit">sec</span></span>
|
<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">+</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="row row--presets">
|
<div class="row row--presets" id="row-presets">
|
||||||
<div>
|
<div>
|
||||||
<h2>Preset time limits</h2>
|
<h2>Preset time limits</h2>
|
||||||
<p>Quickly pick a setting.</p>
|
<p>Quickly pick a setting.</p>
|
||||||
@@ -498,16 +692,7 @@
|
|||||||
<div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div>
|
<div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div>
|
||||||
</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>
|
<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>
|
||||||
|
|
||||||
<div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score">
|
<div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score">
|
||||||
@@ -566,8 +751,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="done" id="score-done">Done</button>
|
<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
|
<p class="hint" id="p-score-hint"></p>
|
||||||
the first tap. The clocks keep their times.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -584,32 +768,59 @@
|
|||||||
// ask the browser not to evict us when the device is low on space
|
// 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){}
|
try{ if(navigator.storage && navigator.storage.persist) navigator.storage.persist(); }catch(e){}
|
||||||
|
|
||||||
|
/* mute is the alpha the head labels need over that accent to clear 4:1. It
|
||||||
|
can't be one shared value: the accents differ in lightness by more than the
|
||||||
|
muting does, and a single alpha either fails on plum or blackens brass.
|
||||||
|
sage and brass are a touch darker than they look elsewhere — white on them
|
||||||
|
has to clear 3:1 for the clock digits, and the old brass managed 2.46. */
|
||||||
var THEMES = [
|
var THEMES = [
|
||||||
{ id:"sage", accent:"#7E9A79", ink:"#FFFFFF" },
|
{ id:"sage", accent:"#7C9777", ink:"#FFFFFF", mute:.66 },
|
||||||
{ id:"brass", accent:"#C3A153", ink:"#FFFFFF" },
|
{ id:"brass", accent:"#AA8C47", ink:"#FFFFFF", mute:.66 },
|
||||||
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF" },
|
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF", mute:.72 },
|
||||||
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF" },
|
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF", mute:.71 },
|
||||||
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF" }
|
{ 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 = {
|
var cfg = {
|
||||||
base: store.num("bg.base", 180000), // reserve per point
|
base: [split ? clampTimer("base", store.num("bg.base.them", yBase)) : yBase, yBase],
|
||||||
delay: store.num("bg.delay", 12000),
|
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
|
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;
|
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
|
// a match to N points is worth N times that side's per-point reserve
|
||||||
function matchTime(){ return cfg.base * cfg.points; }
|
function matchTime(i){ return cfg.base[i] * 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: [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
|
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
|
||||||
@@ -631,7 +842,8 @@
|
|||||||
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
|
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
|
||||||
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]"),
|
||||||
|
paused: p.querySelector("[data-paused]")
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
var btnPlay = document.getElementById("playpause");
|
var btnPlay = document.getElementById("playpause");
|
||||||
@@ -697,6 +909,15 @@
|
|||||||
tone(659.25, 0.16, 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);
|
tone(440, 0.32, 0.55, 0.26, "triangle", true);
|
||||||
}
|
}
|
||||||
|
// the flag's mirror: the same triad rising, and quicker — a fresh start.
|
||||||
|
// Deliberately not another rising fifth; sndSwap owns that shape and plays on
|
||||||
|
// every single handover, so nothing else may sound like it.
|
||||||
|
function sndReset(){
|
||||||
|
if(!cfg.sound) return;
|
||||||
|
tone(523.25, 0.00, 0.16, 0.20, "triangle", false);
|
||||||
|
tone(659.25, 0.07, 0.16, 0.20, "triangle", false);
|
||||||
|
tone(783.99, 0.14, 0.40, 0.22, "triangle", true);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ helpers ============ */
|
/* ============ helpers ============ */
|
||||||
function elapsed(){
|
function elapsed(){
|
||||||
@@ -721,7 +942,7 @@
|
|||||||
}
|
}
|
||||||
// how much of this turn has already come off the reserve (0 while the delay runs)
|
// how much of this turn has already come off the reserve (0 while the delay runs)
|
||||||
function consumed(i){
|
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
|
// the number the panel is showing, which is what the score sheet edits
|
||||||
function shownTime(i){
|
function shownTime(i){
|
||||||
@@ -737,7 +958,7 @@
|
|||||||
return st.phase === IDLE && st.active === -1 &&
|
return st.phase === IDLE && st.active === -1 &&
|
||||||
st.moves[0] === 0 && st.moves[1] === 0 &&
|
st.moves[0] === 0 && st.moves[1] === 0 &&
|
||||||
st.score[0] === 0 && st.score[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 ----
|
/* ---- write only on change ----
|
||||||
@@ -769,10 +990,10 @@
|
|||||||
var main = st.reserve[i], dLeft = 0;
|
var main = st.reserve[i], dLeft = 0;
|
||||||
|
|
||||||
if(live){
|
if(live){
|
||||||
if(e < cfg.delay){
|
if(e < cfg.delay[i]){
|
||||||
dLeft = cfg.delay - e;
|
dLeft = cfg.delay[i] - e;
|
||||||
}else{
|
}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(); }
|
if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,14 +1012,19 @@
|
|||||||
setText(el[i].them, st.score[1 - i]);
|
setText(el[i].them, st.score[1 - i]);
|
||||||
setText(el[i].target, cfg.points);
|
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
|
el[i].delay.classList.toggle("on", showDelay); // no-op when already right
|
||||||
if(showDelay){
|
if(showDelay){
|
||||||
// the bar really does move every frame, but transform is compositor-only
|
// 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));
|
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",
|
setAttr(el[i].root, "data-state",
|
||||||
(st.phase === FLAG && i === st.active) ? "flagged"
|
(st.phase === FLAG && i === st.active) ? "flagged"
|
||||||
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
|
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
|
||||||
@@ -814,6 +1040,7 @@
|
|||||||
// nothing running to pause: before a game's first tap, and after a flag,
|
// 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
|
// 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("btn--vacant", st.phase === IDLE || st.phase === FLAG);
|
||||||
|
btnPlay.classList.toggle("lit", st.phase === PAUSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loop(){ render(); requestAnimationFrame(loop); }
|
function loop(){ render(); requestAnimationFrame(loop); }
|
||||||
@@ -850,7 +1077,7 @@
|
|||||||
// the same instant charges the outgoing turn and starts the incoming one,
|
// the same instant charges the outgoing turn and starts the incoming one,
|
||||||
// so nothing falls between the two and goes to neither player
|
// so nothing falls between the two and goes to neither player
|
||||||
var e = now - st.turnAt;
|
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.moves[side]++;
|
||||||
st.active = 1 - side;
|
st.active = 1 - side;
|
||||||
st.turnAt = now;
|
st.turnAt = now;
|
||||||
@@ -905,16 +1132,19 @@
|
|||||||
|
|
||||||
// clocks back to full for the match length; the score is not this function's business
|
// clocks back to full for the match length; the score is not this function's business
|
||||||
function resetClocks(){
|
function resetClocks(){
|
||||||
disarm();
|
cancelHold();
|
||||||
st.reserve = [matchTime(), matchTime()];
|
st.reserve = [matchTime(0), matchTime(1)];
|
||||||
newGame();
|
newGame();
|
||||||
saveGame();
|
saveGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
// the Reset button — a whole new match
|
// the Reset button — a whole new match. The sound lives here rather than in
|
||||||
|
// resetClocks(), which the settings sheet also calls: changing the match
|
||||||
|
// length shouldn't sound like a reset.
|
||||||
function resetAll(){
|
function resetAll(){
|
||||||
st.score = [0, 0];
|
st.score = [0, 0];
|
||||||
resetClocks();
|
resetClocks();
|
||||||
|
sndReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampScore(){
|
function clampScore(){
|
||||||
@@ -957,30 +1187,94 @@
|
|||||||
st.active = g.active;
|
st.active = g.active;
|
||||||
st.phase = g.phase === RUN ? PAUSE : g.phase;
|
st.phase = g.phase === RUN ? PAUSE : g.phase;
|
||||||
st.held = st.phase === PAUSE ? g.held : 0;
|
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
|
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(){
|
document.addEventListener("visibilitychange", function(){
|
||||||
if(document.visibilityState === "hidden") saveGame();
|
if(document.visibilityState === "hidden") saveGame();
|
||||||
});
|
});
|
||||||
window.addEventListener("pagehide", 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);
|
||||||
|
|
||||||
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
|
// and the one that doesn't depend on being told anything: a second is the
|
||||||
var armT = null;
|
// most a force-quit can now cost, which is well inside the delay nobody is
|
||||||
function disarm(){
|
// 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
|
||||||
|
the old feedback invited, since a draining ring reads as "wait", not "press
|
||||||
|
me again". There is no wordless way to say "do that again"; there is a very
|
||||||
|
well-worn one for "keep holding", and letting go early springs the ring
|
||||||
|
back, which teaches the gesture without a word in the bar.
|
||||||
|
|
||||||
|
A stray 650ms hold is also far less likely than two stray taps in 2s, so
|
||||||
|
the live game it was guarding is better guarded than before. */
|
||||||
|
var HOLD_MS = 975; // keep in step with the .holding transition
|
||||||
|
var SLOP = 12; // px a press may drift and still count; also the panels'
|
||||||
|
var holdT = null, armT = null, holdDown = null;
|
||||||
|
|
||||||
|
function cancelHold(){
|
||||||
|
if(holdT){ clearTimeout(holdT); holdT = null; }
|
||||||
if(armT){ clearTimeout(armT); armT = null; }
|
if(armT){ clearTimeout(armT); armT = null; }
|
||||||
|
holdDown = null;
|
||||||
|
btnReset.classList.remove("holding");
|
||||||
btnReset.classList.remove("armed");
|
btnReset.classList.remove("armed");
|
||||||
btnReset.setAttribute("aria-label", "Reset clock");
|
btnReset.setAttribute("aria-label", "Reset clock, hold to confirm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
btnReset.addEventListener("pointerdown", function(ev){
|
||||||
|
ev.preventDefault();
|
||||||
|
audio(); // unlocking on the press is a valid gesture, and earlier is better
|
||||||
|
cancelHold();
|
||||||
|
holdDown = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
|
||||||
|
// so the lift still lands here if the finger drifts off the button
|
||||||
|
try{ btnReset.setPointerCapture(ev.pointerId); }catch(e){}
|
||||||
|
btnReset.classList.add("holding");
|
||||||
|
holdT = setTimeout(function(){
|
||||||
|
cancelHold(); // before, not after: resetAll() hides this button
|
||||||
|
resetAll();
|
||||||
|
}, HOLD_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
btnReset.addEventListener("pointermove", function(ev){
|
||||||
|
var d = holdDown;
|
||||||
|
if(!d || ev.pointerId !== d.id) return;
|
||||||
|
// a finger that has travelled this far was on its way somewhere else
|
||||||
|
if(Math.abs(ev.clientX - d.x) > SLOP || Math.abs(ev.clientY - d.y) > SLOP) cancelHold();
|
||||||
|
});
|
||||||
|
|
||||||
|
function endHold(ev){
|
||||||
|
if(holdDown && ev.pointerId !== holdDown.id) return;
|
||||||
|
cancelHold(); // the spring back is the whole lesson
|
||||||
|
}
|
||||||
|
btnReset.addEventListener("pointerup", endHold);
|
||||||
|
btnReset.addEventListener("pointercancel", endHold);
|
||||||
|
|
||||||
|
/* The keyboard and switch path, which can't hold: two presses inside 2s, as
|
||||||
|
the whole button used to work. detail is 0 only for activation that didn't
|
||||||
|
come from a pointer — touch and mouse were dealt with above, and the click
|
||||||
|
trailing their press must not arm anything. */
|
||||||
function armReset(){
|
function armReset(){
|
||||||
if(armT){ disarm(); resetAll(); return; } // second tap inside the window
|
if(armT){ cancelHold(); resetAll(); return; } // second press 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");
|
||||||
btnReset.setAttribute("aria-label", "Tap again to reset");
|
btnReset.setAttribute("aria-label", "Press again to reset");
|
||||||
armT = setTimeout(disarm, 2000);
|
armT = setTimeout(cancelHold, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============ theme ============ */
|
/* ============ theme ============ */
|
||||||
@@ -988,6 +1282,7 @@
|
|||||||
var t = THEMES.filter(function(x){ return x.id === cfg.theme; })[0] || THEMES[0];
|
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", t.accent);
|
||||||
document.documentElement.style.setProperty("--accent-ink", t.ink);
|
document.documentElement.style.setProperty("--accent-ink", t.ink);
|
||||||
|
document.documentElement.style.setProperty("--accent-mute", "rgba(0,0,0," + t.mute + ")");
|
||||||
[].forEach.call(document.querySelectorAll(".sw"), function(s){
|
[].forEach.call(document.querySelectorAll(".sw"), function(s){
|
||||||
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
|
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
|
||||||
});
|
});
|
||||||
@@ -1025,43 +1320,103 @@
|
|||||||
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
|
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(){
|
function paintSettings(){
|
||||||
var s = Math.round(cfg.base / 1000);
|
for(var i = 0; i < 2; i++){
|
||||||
document.getElementById("v-min").textContent = Math.floor(s / 60);
|
var s = Math.round(cfg.base[i] / 1000);
|
||||||
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
|
vals[i].min.textContent = Math.floor(s / 60);
|
||||||
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000);
|
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("v-points").textContent = cfg.points;
|
||||||
document.getElementById("p-time").textContent = cfg.points === 1
|
/* At a single point the match total is the per-point time, so there is no
|
||||||
? "Reserve time per point."
|
sum to introduce and the colon would dangle. Split, the two sums are put
|
||||||
: "Reserve time per point. " + cfg.points + " × " + clock(cfg.base) +
|
on their own line — together they are too long to sit after the label
|
||||||
" = " + clock(matchTime()) + " each.";
|
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();
|
paintMatchChrome();
|
||||||
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);
|
||||||
showIcon(btnSound, "off", !cfg.sound);
|
showIcon(btnSound, "off", !cfg.sound);
|
||||||
paintPresets();
|
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
|
/* 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(){
|
function paintPresets(){
|
||||||
[].forEach.call(document.querySelectorAll(".preset"), function(b){
|
[].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");
|
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){
|
[].forEach.call(document.querySelectorAll(".preset"), function(b){
|
||||||
b.addEventListener("click", function(){
|
b.addEventListener("click", function(){
|
||||||
cfg.base = parseInt(b.dataset.base, 10);
|
// only reachable while shared, so both sides take the preset
|
||||||
cfg.delay = parseInt(b.dataset.presetDelay, 10);
|
var base = parseInt(b.dataset.base, 10), d = parseInt(b.dataset.presetDelay, 10);
|
||||||
store.set("bg.base", String(cfg.base));
|
cfg.base = [base, base];
|
||||||
store.set("bg.delay", String(cfg.delay));
|
cfg.delay = [d, d];
|
||||||
|
saveTimers();
|
||||||
paintSettings(); resetClocks();
|
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){
|
[].forEach.call(document.querySelectorAll("[data-points-adj]"), function(b){
|
||||||
b.addEventListener("click", function(){
|
b.addEventListener("click", function(){
|
||||||
// stepping by 2 from 1 keeps it odd without any parity juggling
|
// stepping by 2 from 1 keeps it odd without any parity juggling
|
||||||
@@ -1075,18 +1430,14 @@
|
|||||||
|
|
||||||
[].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){
|
[].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){
|
||||||
b.addEventListener("click", function(){
|
b.addEventListener("click", function(){
|
||||||
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
|
var who = parseInt(b.dataset.who, 10);
|
||||||
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
|
setTimer("base", who, cfg.base[who] + parseInt(b.dataset.timeAdj, 10) * 1000);
|
||||||
store.set("bg.base", String(cfg.base));
|
|
||||||
paintSettings(); resetClocks();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
|
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
|
||||||
b.addEventListener("click", function(){
|
b.addEventListener("click", function(){
|
||||||
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
|
var who = parseInt(b.dataset.who, 10);
|
||||||
cfg.delay = Math.min(60000, Math.max(0, next));
|
setTimer("delay", who, cfg.delay[who] + parseInt(b.dataset.delayAdj, 10) * 1000);
|
||||||
store.set("bg.delay", String(cfg.delay));
|
|
||||||
paintSettings(); resetClocks();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1096,7 +1447,6 @@
|
|||||||
paintSettings();
|
paintSettings();
|
||||||
if(on){ audio(); sndTick(); }
|
if(on){ audio(); sndTick(); }
|
||||||
}
|
}
|
||||||
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
|
|
||||||
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
|
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
|
||||||
|
|
||||||
/* ============ sheets ============ */
|
/* ============ sheets ============ */
|
||||||
@@ -1114,7 +1464,7 @@
|
|||||||
// can reach the timer controls once the match is under way
|
// can reach the timer controls once the match is under way
|
||||||
if(!matchPristine()) return;
|
if(!matchPristine()) return;
|
||||||
pauseNow(); // nobody's clock runs while a sheet is open
|
pauseNow(); // nobody's clock runs while a sheet is open
|
||||||
disarm();
|
cancelHold();
|
||||||
paintSettings();
|
paintSettings();
|
||||||
openSheet(sheet);
|
openSheet(sheet);
|
||||||
});
|
});
|
||||||
@@ -1147,6 +1497,10 @@
|
|||||||
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
|
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
|
||||||
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2);
|
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(){
|
function openScore(){
|
||||||
@@ -1154,7 +1508,7 @@
|
|||||||
// set themselves survives
|
// set themselves survives
|
||||||
resumeOnClose = (st.phase === RUN);
|
resumeOnClose = (st.phase === RUN);
|
||||||
pauseNow();
|
pauseNow();
|
||||||
disarm();
|
cancelHold();
|
||||||
// seed from what the panels are showing, not from the banked reserve — for
|
// seed from what the panels are showing, not from the banked reserve — for
|
||||||
// the interrupted turn those differ by the delay already spent
|
// the interrupted turn those differ by the delay already spent
|
||||||
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
||||||
@@ -1209,7 +1563,10 @@
|
|||||||
closeScore();
|
closeScore();
|
||||||
});
|
});
|
||||||
|
|
||||||
btnReset.addEventListener("click", armReset);
|
btnReset.addEventListener("click", function(ev){
|
||||||
|
if(ev.detail !== 0) return; // the tail of a press we already handled
|
||||||
|
armReset();
|
||||||
|
});
|
||||||
btnPlay.addEventListener("click", togglePause);
|
btnPlay.addEventListener("click", togglePause);
|
||||||
|
|
||||||
/* ============ panel taps ============ */
|
/* ============ panel taps ============ */
|
||||||
@@ -1217,8 +1574,8 @@
|
|||||||
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
|
// 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.
|
// 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
|
// 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.
|
// instead of a lift. Either way there's no turn change. (SLOP is declared
|
||||||
var SLOP = 12; // px of travel a press may drift and still count
|
// with the reset hold, which measures drift the same way.)
|
||||||
var wantFullscreen = true;
|
var wantFullscreen = true;
|
||||||
panels.forEach(function(p){
|
panels.forEach(function(p){
|
||||||
var down = null;
|
var down = null;
|
||||||
|
|||||||
@@ -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-v17";
|
const CACHE = "bgclock-v23";
|
||||||
const FILES = [
|
const FILES = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate the five F-Droid phone screenshots from public_html/index.html.
|
||||||
|
|
||||||
|
python3 tools/screenshots.py
|
||||||
|
|
||||||
|
They are rendered rather than photographed, so they can be redone whenever the
|
||||||
|
UI changes. Output is 1170x2532 — 390x844 CSS pixels at device scale 3, an
|
||||||
|
iPhone-sized frame that F-Droid is happy with.
|
||||||
|
|
||||||
|
Near-exact, not exact: two runs of identical code differ by a 4x13 pixel sliver
|
||||||
|
at the delay bar's fill edge, where the scaleX boundary lands mid-pixel and the
|
||||||
|
rasteriser rounds it differently. Invisible, but it means a non-empty git diff
|
||||||
|
after re-running proves nothing on its own.
|
||||||
|
|
||||||
|
Three things bite, and all three fail quietly rather than loudly:
|
||||||
|
|
||||||
|
1. The capture lands when Chromium's virtual-time budget runs out, not when
|
||||||
|
this script finishes. So the page is frozen deliberately — the app's render
|
||||||
|
loop is `render(); requestAnimationFrame(loop)`, and replacing
|
||||||
|
requestAnimationFrame after the first synchronous render pins the frame.
|
||||||
|
|
||||||
|
2. CSS transitions don't advance under --virtual-time-budget. A panel caught
|
||||||
|
mid-transition photographs in its *old* colour, so transitions are disabled
|
||||||
|
outright in the copy being shot.
|
||||||
|
|
||||||
|
3. Snap-confined Chromium cannot write into hidden directories — not ~/.cache,
|
||||||
|
not a dot-directory anywhere, and not the private /tmp it gets given. It
|
||||||
|
fails with a bare "Permission denied" and no hint. Both the HTML it reads
|
||||||
|
and the PNG it writes therefore live in a plain directory in the repo.
|
||||||
|
|
||||||
|
The scenes are set up by seeding localStorage before the app boots, so the app
|
||||||
|
renders its own state from its own save format rather than having the DOM
|
||||||
|
poked from outside. Only the final clicks (open a sheet, start the clock) are
|
||||||
|
driven through the real buttons.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SOURCE = ROOT / "public_html" / "index.html"
|
||||||
|
OUT = ROOT / "fastlane" / "metadata" / "android" / "en-US" / "images" / "phoneScreenshots"
|
||||||
|
WORK = ROOT / "build-screenshots" # plain name: see note 3 above
|
||||||
|
|
||||||
|
WIDTH, HEIGHT, SCALE = 390, 844, 3 # -> 1170x2532
|
||||||
|
|
||||||
|
CHROMIUM = ("chromium-browser", "chromium", "google-chrome", "google-chrome-stable")
|
||||||
|
|
||||||
|
# st.phase in the app; a running clock is saved as PAUSE, because time that
|
||||||
|
# passes while the app is dead can't be charged to anyone
|
||||||
|
IDLE, RUN, PAUSE, FLAG = 0, 1, 2, 3
|
||||||
|
|
||||||
|
MIN = 60_000
|
||||||
|
|
||||||
|
# A five-point match, mid-game, from the bottom player's side. Panel i shows
|
||||||
|
# score[i] as "You" and score[1-i] as "Them", so one array serves both ends.
|
||||||
|
MATCH = dict(
|
||||||
|
cfg=dict(points=5, base=3 * MIN, delay=12_000),
|
||||||
|
game=dict(score=[1, 2], reserve=[14 * MIN, 13 * MIN + 15_000],
|
||||||
|
moves=[7, 6], active=1, held=5_000, phase=PAUSE),
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENES = [
|
||||||
|
# 1 — the clock itself, mid-match, delay running down on the bottom player
|
||||||
|
dict(name="1", click="playpause", **MATCH),
|
||||||
|
|
||||||
|
# 2 — the score sheet, where a finished game is written down
|
||||||
|
dict(name="2", click="score", **MATCH),
|
||||||
|
|
||||||
|
# 3 — the settings sheet. It only opens on a pristine match (the app refuses
|
||||||
|
# once a game is under way), so this one starts from a fresh state and
|
||||||
|
# seeds no saved game at all.
|
||||||
|
dict(name="3", click="settings",
|
||||||
|
cfg=dict(points=5, base=3 * MIN, delay=12_000), game=None),
|
||||||
|
|
||||||
|
# 4 — a single game: no match score, so the score lines aren't there
|
||||||
|
dict(name="4", click="playpause",
|
||||||
|
cfg=dict(points=1, base=3 * MIN, delay=12_000),
|
||||||
|
game=dict(score=[0, 0], reserve=[3 * MIN, 3 * MIN],
|
||||||
|
moves=[4, 3], active=1, held=5_000, phase=PAUSE)),
|
||||||
|
|
||||||
|
# 5 — a flagged clock: the top player's time is gone
|
||||||
|
dict(name="5", click=None,
|
||||||
|
cfg=dict(points=5, base=3 * MIN, delay=12_000),
|
||||||
|
game=dict(score=[1, 2], reserve=[0, 42_000],
|
||||||
|
moves=[7, 6], active=0, held=0, phase=FLAG)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Runs in <head>, before the app's own script, so the app boots into this state
|
||||||
|
# and renders it itself.
|
||||||
|
SEED = """
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try{ localStorage.clear(); }catch(e){}
|
||||||
|
var seed = %s;
|
||||||
|
for(var k in seed) localStorage.setItem(k, seed[k]);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Runs after the app's script, which ends with render(); loop().
|
||||||
|
FREEZE = """
|
||||||
|
<style>
|
||||||
|
/* transitions don't advance under --virtual-time-budget, so a panel caught
|
||||||
|
mid-transition would photograph in its old colour */
|
||||||
|
*, *::before, *::after{transition:none !important; animation:none !important}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// the first render has already happened synchronously; pin it
|
||||||
|
window.requestAnimationFrame = function(){ return 0; };
|
||||||
|
var click = %s;
|
||||||
|
if(click){
|
||||||
|
var b = document.getElementById(click);
|
||||||
|
if(!b) throw new Error("no such button: " + click);
|
||||||
|
b.click();
|
||||||
|
}
|
||||||
|
document.title = "shot-ready";
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def find_chromium():
|
||||||
|
for name in CHROMIUM:
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
sys.exit("no chromium found; tried: " + ", ".join(CHROMIUM))
|
||||||
|
|
||||||
|
|
||||||
|
def build_page(scene, source):
|
||||||
|
"""A copy of the app with the scene seeded and the render loop pinned."""
|
||||||
|
seed = {
|
||||||
|
"bg.points": str(scene["cfg"]["points"]),
|
||||||
|
"bg.base": str(scene["cfg"]["base"]),
|
||||||
|
"bg.delay": str(scene["cfg"]["delay"]),
|
||||||
|
"bg.theme": scene["cfg"].get("theme", "sage"),
|
||||||
|
"bg.sound": "1",
|
||||||
|
}
|
||||||
|
if scene["game"] is not None:
|
||||||
|
seed["bg.game"] = json.dumps(dict(v=1, **scene["game"]))
|
||||||
|
|
||||||
|
html = source.replace("</head>", SEED % json.dumps(seed) + "</head>", 1)
|
||||||
|
html = html.replace("</body>", FREEZE % json.dumps(scene["click"]) + "</body>", 1)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
chromium = find_chromium()
|
||||||
|
source = SOURCE.read_text()
|
||||||
|
WORK.mkdir(exist_ok=True)
|
||||||
|
try:
|
||||||
|
for scene in SCENES:
|
||||||
|
page = WORK / ("scene-%s.html" % scene["name"])
|
||||||
|
page.write_text(build_page(scene, source))
|
||||||
|
target = OUT / ("%s.png" % scene["name"])
|
||||||
|
shot = WORK / target.name
|
||||||
|
|
||||||
|
subprocess.run([
|
||||||
|
chromium,
|
||||||
|
"--headless", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
|
||||||
|
"--window-size=%d,%d" % (WIDTH, HEIGHT),
|
||||||
|
"--force-device-scale-factor=%d" % SCALE,
|
||||||
|
"--virtual-time-budget=4000",
|
||||||
|
"--screenshot=%s" % shot,
|
||||||
|
page.as_uri(),
|
||||||
|
], check=True, capture_output=True)
|
||||||
|
|
||||||
|
if not shot.exists():
|
||||||
|
sys.exit("chromium wrote nothing for scene %s" % scene["name"])
|
||||||
|
shutil.move(str(shot), str(target))
|
||||||
|
print("%s %d bytes" % (target.relative_to(ROOT), target.stat().st_size))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(WORK, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||