|
|
|
@@ -209,26 +209,73 @@
|
|
|
|
|
.btn--vacant{visibility:hidden}
|
|
|
|
|
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
|
|
|
|
|
|
|
|
|
|
/* reset armed for a second tap — the ring unwinds over the same 2s as the timeout */
|
|
|
|
|
/* Reset 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.armed{color:var(--icon-hot)}
|
|
|
|
|
#reset.armed,#reset.holding{color:var(--icon-hot)}
|
|
|
|
|
#reset .ring{
|
|
|
|
|
position:absolute;
|
|
|
|
|
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 */
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
#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{
|
|
|
|
|
fill:none;
|
|
|
|
|
stroke:currentColor; /* the same flash the buttons use */
|
|
|
|
|
stroke-width:2.6;
|
|
|
|
|
/* user units, so it scales with the ring: 2 of 40 lands between 2.5 and
|
|
|
|
|
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-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{
|
|
|
|
|
from{stroke-dashoffset:0}
|
|
|
|
|
to{stroke-dashoffset:113.1}
|
|
|
|
@@ -362,6 +409,13 @@
|
|
|
|
|
|
|
|
|
|
@media (prefers-reduced-motion:reduce){
|
|
|
|
|
*{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>
|
|
|
|
|
</head>
|
|
|
|
@@ -387,8 +441,12 @@
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<div class="bar">
|
|
|
|
|
<button class="btn" id="reset" aria-label="Reset clock">
|
|
|
|
|
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
|
|
|
|
|
<button class="btn" id="reset" aria-label="Reset clock, hold to confirm">
|
|
|
|
|
<!-- 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">
|
|
|
|
|
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
|
|
|
|
|
</svg>
|
|
|
|
@@ -717,6 +775,15 @@
|
|
|
|
|
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
|
|
|
|
|
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 ============ */
|
|
|
|
|
function elapsed(){
|
|
|
|
@@ -925,16 +992,19 @@
|
|
|
|
|
|
|
|
|
|
// clocks back to full for the match length; the score is not this function's business
|
|
|
|
|
function resetClocks(){
|
|
|
|
|
disarm();
|
|
|
|
|
cancelHold();
|
|
|
|
|
st.reserve = [matchTime(), matchTime()];
|
|
|
|
|
newGame();
|
|
|
|
|
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(){
|
|
|
|
|
st.score = [0, 0];
|
|
|
|
|
resetClocks();
|
|
|
|
|
sndReset();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clampScore(){
|
|
|
|
@@ -987,20 +1057,67 @@
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("pagehide", saveGame);
|
|
|
|
|
|
|
|
|
|
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
|
|
|
|
|
var armT = null;
|
|
|
|
|
function disarm(){
|
|
|
|
|
/* 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; }
|
|
|
|
|
holdDown = null;
|
|
|
|
|
btnReset.classList.remove("holding");
|
|
|
|
|
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(){
|
|
|
|
|
if(armT){ disarm(); resetAll(); return; } // second tap inside the window
|
|
|
|
|
if(armT){ cancelHold(); resetAll(); return; } // second press inside the window
|
|
|
|
|
btnReset.classList.remove("armed");
|
|
|
|
|
void btnReset.offsetWidth; // restart the drain animation
|
|
|
|
|
btnReset.classList.add("armed");
|
|
|
|
|
btnReset.setAttribute("aria-label", "Tap again to reset");
|
|
|
|
|
armT = setTimeout(disarm, 2000);
|
|
|
|
|
btnReset.setAttribute("aria-label", "Press again to reset");
|
|
|
|
|
armT = setTimeout(cancelHold, 2000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============ theme ============ */
|
|
|
|
@@ -1135,7 +1252,7 @@
|
|
|
|
|
// can reach the timer controls once the match is under way
|
|
|
|
|
if(!matchPristine()) return;
|
|
|
|
|
pauseNow(); // nobody's clock runs while a sheet is open
|
|
|
|
|
disarm();
|
|
|
|
|
cancelHold();
|
|
|
|
|
paintSettings();
|
|
|
|
|
openSheet(sheet);
|
|
|
|
|
});
|
|
|
|
@@ -1175,7 +1292,7 @@
|
|
|
|
|
// set themselves survives
|
|
|
|
|
resumeOnClose = (st.phase === RUN);
|
|
|
|
|
pauseNow();
|
|
|
|
|
disarm();
|
|
|
|
|
cancelHold();
|
|
|
|
|
// seed from what the panels are showing, not from the banked reserve — for
|
|
|
|
|
// the interrupted turn those differ by the delay already spent
|
|
|
|
|
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
|
|
|
@@ -1230,7 +1347,10 @@
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
/* ============ panel taps ============ */
|
|
|
|
@@ -1238,8 +1358,8 @@
|
|
|
|
|
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
|
|
|
|
|
// bottom panel owns that edge, and starting a swipe home is a finger landing.
|
|
|
|
|
// A swipe travels past SLOP, or the system claims it and we get pointercancel
|
|
|
|
|
// instead of a lift. Either way there's no turn change.
|
|
|
|
|
var SLOP = 12; // px of travel a press may drift and still count
|
|
|
|
|
// instead of a lift. Either way there's no turn change. (SLOP is declared
|
|
|
|
|
// with the reset hold, which measures drift the same way.)
|
|
|
|
|
var wantFullscreen = true;
|
|
|
|
|
panels.forEach(function(p){
|
|
|
|
|
var down = null;
|
|
|
|
|