First commit
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
# Backgammon Clock
|
||||||
|
|
||||||
|
A two-player match clock with **simple delay you can actually see**. Reserve time
|
||||||
|
plus a fixed number of free seconds each turn that never carry over — the
|
||||||
|
Backgammon Galaxy control. Defaults to 3:00 + 15s.
|
||||||
|
|
||||||
|
Plain HTML, CSS and JavaScript. No frameworks, no fonts, no images, no network.
|
||||||
|
|
||||||
|
## Getting it onto your phone
|
||||||
|
|
||||||
|
Chrome will only offer a real install over HTTPS, so the file needs a host.
|
||||||
|
GitHub Pages is free and takes about two minutes:
|
||||||
|
|
||||||
|
1. Create a new public repository, e.g. `bgclock`.
|
||||||
|
2. Upload all six files to the root: `index.html`, `manifest.webmanifest`,
|
||||||
|
`sw.js`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`.
|
||||||
|
3. **Settings → Pages → Source: Deploy from a branch → `main` / `(root)` → Save.**
|
||||||
|
4. Wait a minute, then open `https://<you>.github.io/bgclock/` in Chrome on Android.
|
||||||
|
5. Chrome menu (⋮) → **Add to Home screen** → **Install**.
|
||||||
|
|
||||||
|
Open it from the home-screen icon and it runs fullscreen with no browser
|
||||||
|
chrome, offline, with the screen kept awake while a game is on.
|
||||||
|
|
||||||
|
If you're using a different path than `/bgclock/`, edit the `"id"` field in
|
||||||
|
`manifest.webmanifest` to match, or just delete that line.
|
||||||
|
|
||||||
|
To try it before hosting, open `index.html` in any browser — everything works
|
||||||
|
except installation and the service worker.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
- **Tap your own half** to end your turn and start your opponent's clock.
|
||||||
|
At the start, whoever taps first sets the *other* player going.
|
||||||
|
- The waiting player's taps are ignored, so a stray hand won't stop the clock.
|
||||||
|
- The bar under the big time is the delay draining. When it empties you get a
|
||||||
|
soft tick and your reserve starts moving. That's the moment worth hearing.
|
||||||
|
- **Reset** returns to the starting position. **Pause** freezes mid-turn and
|
||||||
|
resumes exactly where it stopped, delay included.
|
||||||
|
|
||||||
|
Backgammon clocks pause between games, and after a cocked die the delay is
|
||||||
|
normally restarted — use pause and reset for those.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Range | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Time | 0:15 – 99:00 | 3:00 |
|
||||||
|
| Delay | 0 – 60 s | 15 s |
|
||||||
|
| Colour | Sage, Brass, Slate, Teal, Plum | Sage |
|
||||||
|
| Sound | on / off | on |
|
||||||
|
|
||||||
|
Brass is the colour in the chess.com app, if you want the exact original look.
|
||||||
|
Changing time or delay resets the clock. Settings are remembered on the device.
|
||||||
|
|
||||||
|
## Sounds
|
||||||
|
|
||||||
|
All three are generated with the Web Audio API, so there are no audio files to
|
||||||
|
download and nothing to attribute.
|
||||||
|
|
||||||
|
- **Turn change** — a soft rising fifth with an octave partial, marimba-like.
|
||||||
|
- **Delay expiry** — a single quiet 300 Hz tick, deliberately easy to miss unless
|
||||||
|
you're listening for it.
|
||||||
|
- **Time out** — a falling triad, loud and unambiguous.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Do whatever you like with it.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,683 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||||
|
<title>Backgammon Clock</title>
|
||||||
|
<meta name="theme-color" content="#332e2b">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="icon" href="icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="icon-192.png">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--accent:#7E9A79;
|
||||||
|
--accent-ink:#FFFFFF;
|
||||||
|
--accent-mute:rgba(0,0,0,.34);
|
||||||
|
--idle:#9A9A9A;
|
||||||
|
--idle-ink:#2E2E2E;
|
||||||
|
--idle-mute:rgba(0,0,0,.36);
|
||||||
|
--bar:#332E2B;
|
||||||
|
--icon:#8E8880;
|
||||||
|
--icon-hot:#DFD9D1;
|
||||||
|
--flag:#A6564E;
|
||||||
|
--sheet:#232120;
|
||||||
|
--sheet-line:rgba(255,255,255,.09);
|
||||||
|
--sheet-ink:#EDE9E4;
|
||||||
|
--sheet-dim:#A29A92;
|
||||||
|
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
|
||||||
|
html,body{height:100%;overflow:hidden;background:var(--bar)}
|
||||||
|
|
||||||
|
body{
|
||||||
|
font-family:var(--ui);
|
||||||
|
font-variant-numeric:tabular-nums;
|
||||||
|
font-feature-settings:"tnum" 1;
|
||||||
|
-webkit-tap-highlight-color:transparent;
|
||||||
|
-webkit-user-select:none;user-select:none;
|
||||||
|
overscroll-behavior:none;
|
||||||
|
touch-action:manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- frame ---------- */
|
||||||
|
.app{
|
||||||
|
height:100dvh;height:100vh;
|
||||||
|
display:grid;
|
||||||
|
grid-template-rows:1fr auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- player panels ---------- */
|
||||||
|
.panel{
|
||||||
|
position:relative;
|
||||||
|
background:var(--idle);
|
||||||
|
color:var(--idle-ink);
|
||||||
|
overflow:hidden;
|
||||||
|
transition:background-color .18s ease;
|
||||||
|
}
|
||||||
|
.panel[data-state="active"]{background:var(--accent);color:var(--accent-ink)}
|
||||||
|
.panel[data-state="flagged"]{background:var(--flag);color:#fff}
|
||||||
|
|
||||||
|
.inner{
|
||||||
|
position:absolute;inset:0;
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
padding:max(14px,env(safe-area-inset-top)) 20px 14px;
|
||||||
|
}
|
||||||
|
.panel--top .inner{transform:rotate(180deg)}
|
||||||
|
.panel--top .inner{padding:14px 20px max(14px,env(safe-area-inset-top))}
|
||||||
|
.panel--bottom .inner{padding:14px 20px max(14px,env(safe-area-inset-bottom))}
|
||||||
|
|
||||||
|
.moves{
|
||||||
|
align-self:flex-end;
|
||||||
|
font-size:clamp(13px,3.7vw,20px);
|
||||||
|
font-weight:700;
|
||||||
|
letter-spacing:.01em;
|
||||||
|
color:var(--idle-mute);
|
||||||
|
order:-1;
|
||||||
|
}
|
||||||
|
.panel[data-state="active"] .moves,
|
||||||
|
.panel[data-state="flagged"] .moves{color:var(--accent-mute)}
|
||||||
|
.panel[data-state="flagged"] .moves{color:rgba(255,255,255,.55)}
|
||||||
|
|
||||||
|
.stack{
|
||||||
|
flex:1;
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
align-items:center;justify-content:center;
|
||||||
|
padding-bottom:16%;
|
||||||
|
gap:clamp(10px,2.4vw,18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time{
|
||||||
|
font-size:clamp(58px,24vw,190px);
|
||||||
|
line-height:.9;
|
||||||
|
font-weight:700;
|
||||||
|
letter-spacing:-.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- the delay readout (the bit chess.com doesn't have) ---------- */
|
||||||
|
.delay{
|
||||||
|
display:flex;align-items:center;
|
||||||
|
gap:clamp(8px,2.2vw,14px);
|
||||||
|
opacity:0;
|
||||||
|
transition:opacity .16s ease;
|
||||||
|
}
|
||||||
|
.delay.on{opacity:1}
|
||||||
|
|
||||||
|
.track{
|
||||||
|
width:clamp(72px,20vw,140px);
|
||||||
|
height:clamp(4px,1.1vw,7px);
|
||||||
|
border-radius:99px;
|
||||||
|
background:var(--accent-mute);
|
||||||
|
opacity:.45;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.fill{
|
||||||
|
display:block;height:100%;width:100%;
|
||||||
|
border-radius:99px;
|
||||||
|
background:var(--accent-ink);
|
||||||
|
transform-origin:left center;
|
||||||
|
}
|
||||||
|
.dnum{
|
||||||
|
font-size:clamp(15px,4.4vw,26px);
|
||||||
|
font-weight:700;
|
||||||
|
min-width:1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- control bar ---------- */
|
||||||
|
.bar{
|
||||||
|
background:var(--bar);
|
||||||
|
height:clamp(58px,8.2vh,86px);
|
||||||
|
display:flex;align-items:center;
|
||||||
|
padding:0 clamp(12px,4vw,28px);
|
||||||
|
}
|
||||||
|
.bar .gap{flex:1.5}
|
||||||
|
.btn{
|
||||||
|
flex:1;
|
||||||
|
height:100%;
|
||||||
|
display:grid;place-items:center;
|
||||||
|
background:none;border:0;
|
||||||
|
color:var(--icon);
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.btn:active{color:var(--icon-hot)}
|
||||||
|
.btn[aria-pressed="false"]{color:#5E5954}
|
||||||
|
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
|
||||||
|
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
|
||||||
|
|
||||||
|
/* ---------- settings sheet ---------- */
|
||||||
|
.scrim{
|
||||||
|
position:fixed;inset:0;
|
||||||
|
background:rgba(0,0,0,.55);
|
||||||
|
opacity:0;pointer-events:none;
|
||||||
|
transition:opacity .2s ease;
|
||||||
|
z-index:10;
|
||||||
|
}
|
||||||
|
.scrim.on{opacity:1;pointer-events:auto}
|
||||||
|
|
||||||
|
.sheet{
|
||||||
|
position:fixed;left:0;right:0;bottom:0;
|
||||||
|
z-index:11;
|
||||||
|
background:var(--sheet);
|
||||||
|
color:var(--sheet-ink);
|
||||||
|
border-radius:20px 20px 0 0;
|
||||||
|
padding:8px 22px max(22px,env(safe-area-inset-bottom));
|
||||||
|
transform:translateY(101%);
|
||||||
|
transition:transform .26s cubic-bezier(.32,.72,0,1);
|
||||||
|
max-height:88dvh;overflow-y:auto;
|
||||||
|
box-shadow:0 -18px 50px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.sheet.on{transform:translateY(0)}
|
||||||
|
|
||||||
|
.grab{width:38px;height:4px;border-radius:99px;background:#4A4644;margin:6px auto 18px}
|
||||||
|
|
||||||
|
.row{
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
gap:16px;
|
||||||
|
padding:16px 0;
|
||||||
|
border-top:1px solid var(--sheet-line);
|
||||||
|
}
|
||||||
|
.row:first-of-type{border-top:0}
|
||||||
|
.row h2{font-size:15px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.row p{font-size:12.5px;color:var(--sheet-dim);margin-top:3px;line-height:1.35}
|
||||||
|
|
||||||
|
.stepper{display:flex;align-items:center;gap:2px}
|
||||||
|
.step{
|
||||||
|
width:38px;height:38px;flex:none;
|
||||||
|
border-radius:11px;border:1px solid var(--sheet-line);
|
||||||
|
background:#2C2A28;color:var(--sheet-ink);
|
||||||
|
font-size:19px;font-weight:600;line-height:1;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.step:active{background:#3A3735}
|
||||||
|
.val{
|
||||||
|
min-width:66px;text-align:center;
|
||||||
|
font-size:19px;font-weight:700;
|
||||||
|
}
|
||||||
|
.unit{font-size:11px;color:var(--sheet-dim);display:block;font-weight:600;letter-spacing:.06em;text-transform:uppercase;margin-top:2px}
|
||||||
|
|
||||||
|
.swatches{display:flex;gap:11px}
|
||||||
|
.sw{
|
||||||
|
width:36px;height:36px;border-radius:50%;
|
||||||
|
border:2px solid transparent;
|
||||||
|
box-shadow:inset 0 0 0 2px var(--sheet);
|
||||||
|
cursor:pointer;padding:0;
|
||||||
|
}
|
||||||
|
.sw[aria-checked="true"]{border-color:var(--sheet-ink)}
|
||||||
|
|
||||||
|
.toggle{
|
||||||
|
width:52px;height:31px;flex:none;
|
||||||
|
border-radius:99px;border:0;
|
||||||
|
background:#4A4644;
|
||||||
|
position:relative;cursor:pointer;
|
||||||
|
transition:background-color .18s ease;
|
||||||
|
}
|
||||||
|
.toggle[aria-checked="true"]{background:var(--accent)}
|
||||||
|
.toggle::after{
|
||||||
|
content:"";position:absolute;top:3px;left:3px;
|
||||||
|
width:25px;height:25px;border-radius:50%;
|
||||||
|
background:#fff;
|
||||||
|
transition:transform .18s cubic-bezier(.32,.72,0,1);
|
||||||
|
}
|
||||||
|
.toggle[aria-checked="true"]::after{transform:translateX(21px)}
|
||||||
|
|
||||||
|
.done{
|
||||||
|
width:100%;margin-top:20px;
|
||||||
|
padding:15px;border:0;border-radius:13px;
|
||||||
|
background:var(--accent);color:var(--accent-ink);
|
||||||
|
font-family:var(--ui);font-size:15.5px;font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint{
|
||||||
|
font-size:12px;color:var(--sheet-dim);
|
||||||
|
text-align:center;margin-top:13px;line-height:1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
*{transition-duration:.01ms!important}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<section class="panel panel--top" data-side="0" data-state="idle">
|
||||||
|
<div class="inner">
|
||||||
|
<div class="moves">Moves: <span data-moves>0</span></div>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="time" data-time>3:00</div>
|
||||||
|
<div class="delay" data-delay>
|
||||||
|
<span class="track"><i class="fill" data-fill></i></span>
|
||||||
|
<span class="dnum" data-dnum>15</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<button class="btn" id="reset" aria-label="Reset clock">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 5v6h-6"/><path d="M20 11a8 8 0 1 0-2.3 5.3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="playpause" aria-label="Pause clock">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="pause">
|
||||||
|
<rect x="6" y="4.5" width="4.4" height="15" rx="1.4"/><rect x="13.6" y="4.5" width="4.4" height="15" rx="1.4"/>
|
||||||
|
</svg>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="play" hidden>
|
||||||
|
<path d="M7.5 4.7a1 1 0 0 1 1.53-.85l9 6.3a1 1 0 0 1 0 1.7l-9 6.3A1 1 0 0 1 7.5 17.3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="gap"></span>
|
||||||
|
<button class="btn" id="settings" aria-label="Settings">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="13.6" r="7.4"/><path d="M12 13.6V9.4"/><path d="M9.4 2.4h5.2"/><path d="M12 2.4v3.6"/><path d="m18.6 6.4 1.9-1.9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="sound" aria-label="Sound" aria-pressed="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="on">
|
||||||
|
<path d="M4 9.2h3.3L12 5.1a.9.9 0 0 1 1.5.7v12.4a.9.9 0 0 1-1.5.7L7.3 14.8H4a.9.9 0 0 1-.9-.9v-3.8a.9.9 0 0 1 .9-.9z"/>
|
||||||
|
<path d="M16.4 8.6a.95.95 0 0 1 1.34.06 5.1 5.1 0 0 1 0 6.68.95.95 0 1 1-1.4-1.28 3.2 3.2 0 0 0 0-4.12.95.95 0 0 1 .06-1.34z"/>
|
||||||
|
<path d="M19.1 5.6a.95.95 0 0 1 1.34.05 9.2 9.2 0 0 1 0 12.7.95.95 0 1 1-1.39-1.3 7.3 7.3 0 0 0 0-10.1.95.95 0 0 1 .05-1.35z"/>
|
||||||
|
</svg>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="off" hidden>
|
||||||
|
<path d="M4 9.2h3.3L12 5.1a.9.9 0 0 1 1.5.7v12.4a.9.9 0 0 1-1.5.7L7.3 14.8H4a.9.9 0 0 1-.9-.9v-3.8a.9.9 0 0 1 .9-.9z"/>
|
||||||
|
<path d="M16.5 9.3a1 1 0 0 1 1.42 0L19.6 11l1.68-1.7a1 1 0 1 1 1.42 1.42L21 12.4l1.7 1.7a1 1 0 0 1-1.42 1.42L19.6 13.8l-1.68 1.7a1 1 0 0 1-1.42-1.42l1.7-1.68-1.7-1.68a1 1 0 0 1 0-1.42z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel panel--bottom" data-side="1" data-state="idle">
|
||||||
|
<div class="inner">
|
||||||
|
<div class="moves">Moves: <span data-moves>0</span></div>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="time" data-time>3:00</div>
|
||||||
|
<div class="delay" data-delay>
|
||||||
|
<span class="track"><i class="fill" data-fill></i></span>
|
||||||
|
<span class="dnum" data-dnum>15</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scrim" id="scrim"></div>
|
||||||
|
|
||||||
|
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Settings">
|
||||||
|
<div class="grab"></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<h2>Time</h2>
|
||||||
|
<p>Reserve time per player.</p>
|
||||||
|
</div>
|
||||||
|
<div class="stepper">
|
||||||
|
<button class="step" data-time-adj="-60">–</button>
|
||||||
|
<span class="val"><span id="v-min">3</span><span class="unit">min</span></span>
|
||||||
|
<button class="step" data-time-adj="60">+</button>
|
||||||
|
<button class="step" data-time-adj="-15" style="margin-left:8px">–</button>
|
||||||
|
<span class="val"><span id="v-sec">00</span><span class="unit">sec</span></span>
|
||||||
|
<button class="step" data-time-adj="15">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<h2>Delay</h2>
|
||||||
|
<p>Free seconds each turn. Never carries over.</p>
|
||||||
|
</div>
|
||||||
|
<div class="stepper">
|
||||||
|
<button class="step" data-delay-adj="-1">–</button>
|
||||||
|
<span class="val"><span id="v-delay">15</span><span class="unit">sec</span></span>
|
||||||
|
<button class="step" data-delay-adj="1">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<h2>Colour</h2>
|
||||||
|
<p>Marks the player on turn.</p>
|
||||||
|
</div>
|
||||||
|
<div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<h2>Sound</h2>
|
||||||
|
<p>Turn change, delay expiry, time out.</p>
|
||||||
|
</div>
|
||||||
|
<button class="toggle" id="t-sound" role="switch" aria-checked="true" aria-label="Sound"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="done" id="done">Done</button>
|
||||||
|
<p class="hint">Changing time or delay resets the clock.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* ============ persistence (degrades to memory if blocked) ============ */
|
||||||
|
var store = {
|
||||||
|
get: function(k, d){ try{ var v = localStorage.getItem(k); return v === null ? d : v; }catch(e){ return d; } },
|
||||||
|
set: function(k, v){ try{ localStorage.setItem(k, v); }catch(e){} }
|
||||||
|
};
|
||||||
|
|
||||||
|
var THEMES = [
|
||||||
|
{ id:"sage", accent:"#7E9A79", ink:"#FFFFFF" },
|
||||||
|
{ id:"brass", accent:"#C3A153", ink:"#FFFFFF" },
|
||||||
|
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF" },
|
||||||
|
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF" },
|
||||||
|
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF" }
|
||||||
|
];
|
||||||
|
|
||||||
|
var cfg = {
|
||||||
|
base: parseInt(store.get("bg.base", "180000"), 10),
|
||||||
|
delay: parseInt(store.get("bg.delay", "15000"), 10),
|
||||||
|
theme: store.get("bg.theme", "sage"),
|
||||||
|
sound: store.get("bg.sound", "1") === "1"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============ state ============ */
|
||||||
|
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
|
||||||
|
var st = {
|
||||||
|
phase: IDLE,
|
||||||
|
active: -1,
|
||||||
|
reserve: [cfg.base, cfg.base], // reserve banked at the start of the current turn
|
||||||
|
moves: [0, 0],
|
||||||
|
turnAt: 0, // performance.now() when the turn began
|
||||||
|
held: 0, // frozen elapsed while paused
|
||||||
|
ticked: false // subtle sound already played this turn
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============ dom ============ */
|
||||||
|
var panels = [].slice.call(document.querySelectorAll(".panel"));
|
||||||
|
var el = panels.map(function(p){
|
||||||
|
return {
|
||||||
|
root: p,
|
||||||
|
time: p.querySelector("[data-time]"),
|
||||||
|
moves: p.querySelector("[data-moves]"),
|
||||||
|
delay: p.querySelector("[data-delay]"),
|
||||||
|
fill: p.querySelector("[data-fill]"),
|
||||||
|
dnum: p.querySelector("[data-dnum]")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var btnPlay = document.getElementById("playpause");
|
||||||
|
var btnSound = document.getElementById("sound");
|
||||||
|
var sheet = document.getElementById("sheet");
|
||||||
|
var scrim = document.getElementById("scrim");
|
||||||
|
|
||||||
|
/* ============ audio (synthesised — no files, works offline) ============ */
|
||||||
|
var ac = null;
|
||||||
|
function audio(){
|
||||||
|
if(!ac){
|
||||||
|
var C = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if(!C) return null;
|
||||||
|
ac = new C();
|
||||||
|
}
|
||||||
|
if(ac.state === "suspended") ac.resume();
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
|
function tone(freq, at, dur, peak, type, partial){
|
||||||
|
var c = audio(); if(!c) return;
|
||||||
|
var t0 = c.currentTime + at;
|
||||||
|
var o = c.createOscillator(), g = c.createGain();
|
||||||
|
o.type = type || "sine";
|
||||||
|
o.frequency.setValueAtTime(freq, t0);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(peak, t0 + 0.012);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
o.connect(g); g.connect(c.destination);
|
||||||
|
o.start(t0); o.stop(t0 + dur + 0.05);
|
||||||
|
if(partial){
|
||||||
|
var o2 = c.createOscillator(), g2 = c.createGain();
|
||||||
|
o2.type = "sine";
|
||||||
|
o2.frequency.setValueAtTime(freq * 2, t0);
|
||||||
|
g2.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g2.gain.exponentialRampToValueAtTime(peak * 0.22, t0 + 0.01);
|
||||||
|
g2.gain.exponentialRampToValueAtTime(0.0001, t0 + dur * 0.5);
|
||||||
|
o2.connect(g2); g2.connect(c.destination);
|
||||||
|
o2.start(t0); o2.stop(t0 + dur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// warm rising fifth, marimba-ish — the handover
|
||||||
|
function sndSwap(){ if(!cfg.sound) return; tone(440, 0, 0.34, 0.20, "sine", true); tone(659.25, 0.075, 0.42, 0.17, "sine", true); }
|
||||||
|
// barely-there wooden tick — delay just ran out, reserve is now moving
|
||||||
|
function sndTick(){ if(!cfg.sound) return; tone(300, 0, 0.075, 0.035, "sine", false); }
|
||||||
|
// unmistakable falling triad — flag
|
||||||
|
function sndFlag(){
|
||||||
|
if(!cfg.sound) return;
|
||||||
|
tone(880, 0.00, 0.20, 0.24, "triangle", false);
|
||||||
|
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
|
||||||
|
tone(440, 0.32, 0.55, 0.26, "triangle", true);
|
||||||
|
}
|
||||||
|
function buzz(ms){ try{ if(navigator.vibrate) navigator.vibrate(ms); }catch(e){} }
|
||||||
|
|
||||||
|
/* ============ helpers ============ */
|
||||||
|
function elapsed(){
|
||||||
|
if(st.phase === PAUSE) return st.held;
|
||||||
|
return performance.now() - st.turnAt;
|
||||||
|
}
|
||||||
|
function fmt(ms){
|
||||||
|
if(ms < 0) ms = 0;
|
||||||
|
var t = Math.ceil(ms / 100) / 10;
|
||||||
|
if(ms < 10000) return t.toFixed(1);
|
||||||
|
var s = Math.ceil(ms / 1000);
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
s = s % 60;
|
||||||
|
return m + ":" + (s < 10 ? "0" : "") + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ render ============ */
|
||||||
|
function render(){
|
||||||
|
var e = elapsed();
|
||||||
|
for(var i = 0; i < 2; i++){
|
||||||
|
var live = (i === st.active && (st.phase === RUN || st.phase === PAUSE));
|
||||||
|
var main = st.reserve[i], dLeft = 0;
|
||||||
|
|
||||||
|
if(live){
|
||||||
|
if(e < cfg.delay){
|
||||||
|
dLeft = cfg.delay - e;
|
||||||
|
}else{
|
||||||
|
main = st.reserve[i] - (e - cfg.delay);
|
||||||
|
if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(live && main <= 0 && st.phase === RUN){ flag(i); return; }
|
||||||
|
|
||||||
|
el[i].time.textContent = fmt(main);
|
||||||
|
el[i].moves.textContent = st.moves[i];
|
||||||
|
|
||||||
|
var showDelay = live && cfg.delay > 0 && dLeft > 0;
|
||||||
|
el[i].delay.classList.toggle("on", showDelay);
|
||||||
|
if(showDelay){
|
||||||
|
el[i].fill.style.transform = "scaleX(" + (dLeft / cfg.delay) + ")";
|
||||||
|
el[i].dnum.textContent = Math.ceil(dLeft / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(st.phase === FLAG && i === st.active) el[i].root.dataset.state = "flagged";
|
||||||
|
else el[i].root.dataset.state = (i === st.active && st.phase !== IDLE) ? "active" : "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(){ render(); requestAnimationFrame(loop); }
|
||||||
|
|
||||||
|
/* ============ actions ============ */
|
||||||
|
function flag(side){
|
||||||
|
st.reserve[side] = 0;
|
||||||
|
st.phase = FLAG;
|
||||||
|
render();
|
||||||
|
sndFlag();
|
||||||
|
buzz([90, 70, 90]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tap(side){
|
||||||
|
if(st.phase === FLAG) return;
|
||||||
|
if(st.phase === PAUSE) return;
|
||||||
|
|
||||||
|
if(st.phase === IDLE){
|
||||||
|
st.active = 1 - side; // you tap your own side to start your opponent
|
||||||
|
st.turnAt = performance.now();
|
||||||
|
st.ticked = false;
|
||||||
|
st.phase = RUN;
|
||||||
|
sndSwap(); buzz(14);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(side !== st.active) return; // the waiting player can't stop the clock
|
||||||
|
|
||||||
|
var e = elapsed();
|
||||||
|
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
|
||||||
|
st.moves[side]++;
|
||||||
|
st.active = 1 - side;
|
||||||
|
st.turnAt = performance.now();
|
||||||
|
st.ticked = false;
|
||||||
|
sndSwap(); buzz(14);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePause(){
|
||||||
|
if(st.phase === RUN){
|
||||||
|
st.held = elapsed();
|
||||||
|
st.phase = PAUSE;
|
||||||
|
}else if(st.phase === PAUSE){
|
||||||
|
st.turnAt = performance.now() - st.held;
|
||||||
|
st.phase = RUN;
|
||||||
|
}else return;
|
||||||
|
paintPlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintPlay(){
|
||||||
|
var showPlay = st.phase !== RUN;
|
||||||
|
btnPlay.querySelector('[data-icon="pause"]').hidden = showPlay;
|
||||||
|
btnPlay.querySelector('[data-icon="play"]').hidden = !showPlay;
|
||||||
|
btnPlay.setAttribute("aria-label", showPlay ? "Start clock" : "Pause clock");
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(){
|
||||||
|
st.phase = IDLE; st.active = -1;
|
||||||
|
st.reserve = [cfg.base, cfg.base];
|
||||||
|
st.moves = [0, 0];
|
||||||
|
st.held = 0; st.ticked = false;
|
||||||
|
paintPlay();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ theme ============ */
|
||||||
|
function applyTheme(){
|
||||||
|
var t = THEMES.filter(function(x){ return x.id === cfg.theme; })[0] || THEMES[0];
|
||||||
|
document.documentElement.style.setProperty("--accent", t.accent);
|
||||||
|
document.documentElement.style.setProperty("--accent-ink", t.ink);
|
||||||
|
[].forEach.call(document.querySelectorAll(".sw"), function(s){
|
||||||
|
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ settings ui ============ */
|
||||||
|
var swWrap = document.getElementById("swatches");
|
||||||
|
THEMES.forEach(function(t){
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.className = "sw";
|
||||||
|
b.dataset.id = t.id;
|
||||||
|
b.style.background = t.accent;
|
||||||
|
b.setAttribute("role", "radio");
|
||||||
|
b.setAttribute("aria-label", t.id);
|
||||||
|
b.setAttribute("aria-checked", "false");
|
||||||
|
b.addEventListener("click", function(){
|
||||||
|
cfg.theme = t.id; store.set("bg.theme", t.id); applyTheme();
|
||||||
|
});
|
||||||
|
swWrap.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
function paintSettings(){
|
||||||
|
var s = Math.round(cfg.base / 1000);
|
||||||
|
document.getElementById("v-min").textContent = Math.floor(s / 60);
|
||||||
|
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
|
||||||
|
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000);
|
||||||
|
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
|
||||||
|
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
|
||||||
|
btnSound.querySelector('[data-icon="on"]').hidden = !cfg.sound;
|
||||||
|
btnSound.querySelector('[data-icon="off"]').hidden = cfg.sound;
|
||||||
|
}
|
||||||
|
|
||||||
|
[].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){
|
||||||
|
b.addEventListener("click", function(){
|
||||||
|
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
|
||||||
|
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
|
||||||
|
store.set("bg.base", String(cfg.base));
|
||||||
|
paintSettings(); reset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
|
||||||
|
b.addEventListener("click", function(){
|
||||||
|
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
|
||||||
|
cfg.delay = Math.min(60000, Math.max(0, next));
|
||||||
|
store.set("bg.delay", String(cfg.delay));
|
||||||
|
paintSettings(); reset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setSound(on){
|
||||||
|
cfg.sound = on;
|
||||||
|
store.set("bg.sound", on ? "1" : "0");
|
||||||
|
paintSettings();
|
||||||
|
if(on){ audio(); sndTick(); }
|
||||||
|
}
|
||||||
|
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
|
||||||
|
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
|
||||||
|
|
||||||
|
function openSheet(on){
|
||||||
|
sheet.classList.toggle("on", on);
|
||||||
|
scrim.classList.toggle("on", on);
|
||||||
|
}
|
||||||
|
document.getElementById("settings").addEventListener("click", function(){ paintSettings(); openSheet(true); });
|
||||||
|
document.getElementById("done").addEventListener("click", function(){ openSheet(false); });
|
||||||
|
scrim.addEventListener("click", function(){ openSheet(false); });
|
||||||
|
|
||||||
|
document.getElementById("reset").addEventListener("click", reset);
|
||||||
|
btnPlay.addEventListener("click", togglePause);
|
||||||
|
|
||||||
|
/* ============ panel taps ============ */
|
||||||
|
var wantFullscreen = true;
|
||||||
|
panels.forEach(function(p){
|
||||||
|
p.addEventListener("pointerdown", function(ev){
|
||||||
|
ev.preventDefault();
|
||||||
|
audio();
|
||||||
|
if(wantFullscreen){
|
||||||
|
wantFullscreen = false;
|
||||||
|
if(!window.matchMedia("(display-mode: fullscreen)").matches && document.documentElement.requestFullscreen){
|
||||||
|
document.documentElement.requestFullscreen().catch(function(){});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tap(parseInt(p.dataset.side, 10));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ============ keep the screen awake ============ */
|
||||||
|
var lock = null;
|
||||||
|
function keepAwake(){
|
||||||
|
if(!navigator.wakeLock) return;
|
||||||
|
navigator.wakeLock.request("screen").then(function(l){ lock = l; }).catch(function(){});
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", function(){
|
||||||
|
if(document.visibilityState === "visible" && !lock) keepAwake();
|
||||||
|
});
|
||||||
|
keepAwake();
|
||||||
|
|
||||||
|
/* ============ go ============ */
|
||||||
|
applyTheme();
|
||||||
|
paintSettings();
|
||||||
|
paintPlay();
|
||||||
|
reset();
|
||||||
|
loop();
|
||||||
|
|
||||||
|
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){
|
||||||
|
navigator.serviceWorker.register("sw.js").catch(function(){});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "Backgammon Clock",
|
||||||
|
"short_name": "BG Clock",
|
||||||
|
"description": "Two-player match clock with a visible simple delay.",
|
||||||
|
"start_url": "./index.html",
|
||||||
|
"scope": "./",
|
||||||
|
"id": "/bgclock/",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"display_override": ["fullscreen", "standalone"],
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#332E2B",
|
||||||
|
"theme_color": "#332E2B",
|
||||||
|
"categories": ["games", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* Cache-first: once installed the clock never touches the network again. */
|
||||||
|
const CACHE = "bgclock-v1";
|
||||||
|
const FILES = [
|
||||||
|
"./",
|
||||||
|
"./index.html",
|
||||||
|
"./manifest.webmanifest",
|
||||||
|
"./icon-192.png",
|
||||||
|
"./icon-512.png",
|
||||||
|
"./icon-maskable-512.png"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (e) => {
|
||||||
|
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (e) => {
|
||||||
|
if (e.request.method !== "GET") return;
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(e.request, copy)).catch(() => {});
|
||||||
|
return res;
|
||||||
|
}).catch(() => caches.match("./index.html")))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user