first app

This commit is contained in:
David 2026-10-09 13:45:54 +02:00
parent ad890adcf1
commit ac33e2fec6
30 changed files with 3475 additions and 2 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git
.github
tests
tools
*.md
LICENSE
Dockerfile
.dockerignore

57
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,57 @@
name: Publish Docker image
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# arm64 too, for Raspberry Pi style home servers
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

13
Dockerfile Normal file
View file

@ -0,0 +1,13 @@
# Picker: static files served by unprivileged nginx on port 8080.
FROM nginxinc/nginx-unprivileged:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html manifest.webmanifest sw.js /usr/share/nginx/html/
COPY css /usr/share/nginx/html/css
COPY js /usr/share/nginx/html/js
COPY icons /usr/share/nginx/html/icons
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1

157
README.md
View file

@ -1,3 +1,156 @@
# picker # Picker
A simple web app to select who pays the bill A simple web app to select who pays the bills: everyone puts a finger on the
phone screen, and after a suspenseful roulette one of you is chosen.
Plain HTML, CSS and vanilla JS modules. No build step and no dependencies.
Works on any static host and installs as an offline PWA.
## Modes
- **Pick one**: one finger is chosen.
- **Pick several**: N fingers are chosen.
- **Teams**: everyone is split into 2–4 balanced teams.
- **Order**: everyone gets a number, revealed one at a time.
- **Elimination**: one finger is knocked out per round until one remains.
Letting go early counts as being knocked out.
Settings (gear button on the idle screen) also cover the stakes text
("pay the bill" → "You pay the bill!"), theme (neon, pastel, or high
contrast with a distinct shape per finger), language (English/Dutch), sound
and vibration. Settings are saved in `localStorage`.
## How a round works
1. Put two or more fingers down. Once the set of fingers has been stable for
2.5 s, a three-beat countdown starts. Adding a finger restarts the wait.
Lifting one shows a "chickened out" message.
2. The outcome is drawn with `crypto.getRandomValues` *before* the animation
starts. The roulette is only presentation and always lands on the
pre-drawn result.
3. The result stays on screen after fingers lift. Once everyone has let go,
tap to play again (the tapping finger joins the next round).
## Project layout
| File | What |
| --- | --- |
| `js/main.js` | wiring + frame loop |
| `js/input.js` | Pointer Events per `pointerId`, gesture blocking, debug fake fingers |
| `js/game.js` | state machine (idle → gathering → countdown → suspense → result) |
| `js/modes.js` | outcome planning and roulette schedules for each mode |
| `js/random.js` | unbiased crypto randomness |
| `js/renderer.js` | canvas rendering (pre-rendered glow sprites) |
| `js/reveals.js` | reveal animations: flood, confetti, spotlight, shockwave, calm |
| `js/audio.js` | Web Audio sound effects + vibration |
| `js/ui.js` | overlay text, toolbar, settings sheet |
| `js/settings.js` | persisted settings |
| `js/i18n.js` | all strings (en, nl) |
| `js/themes.js` | colour themes |
| `js/device.js` | wake lock, fullscreen |
| `sw.js` | offline caching (network-first, cache fallback) |
| `tools/make_icons.py` | regenerates the PNG icons (Python stdlib only) |
## Testing on a desktop
Open `http://localhost:8000/?debug=1`:
- **Click** to place a fake finger, **click it again** to remove it, **drag** to move it.
- **A** adds a fake finger at a random spot, **C** clears them all.
- `?debug=1&fingers=4` starts with 4 fake fingers in a circle.
- `?debug=1&open=settings` opens the settings sheet on load.
- A HUD shows the phase, finger count and fps. The service worker is not
registered in debug mode, so you always get fresh files.
Automated checks (serve the folder, then open in any browser):
- `tests/logic.html`: state machine and fairness tests.
- `tests/sim.html?mode=order&n=5&t=11500`: renders one simulated frame at a
given time (`mode`, `n`, `t`, `theme`, `style`, `k`, `teams`, `reduced`).
## Testing on your phone (local network)
Serve the folder from your PC:
```sh
python -m http.server 8000 --bind 0.0.0.0
```
Find your PC's LAN IP (`ipconfig` on Windows, `ip addr` on Linux), then open
`http://<that-ip>:8000/` on the phone. On Windows, allow Python through the
firewall for private networks when asked.
Over plain `http://` on a LAN address, the page is **not a secure context**.
These features need HTTPS (or `localhost`):
| Feature | Plain http on LAN |
| --- | --- |
| Touch, canvas, crypto randomness, sound, vibration, fullscreen | works |
| Service worker / offline mode | **needs HTTPS** |
| Install as app (PWA) | **needs HTTPS** |
| Screen wake lock | **needs HTTPS** |
To test the secure-only features:
- **Android + USB (easiest):** enable USB debugging, open `chrome://inspect`
in desktop Chrome or Edge, click *Port forwarding* and map `8000` →
`localhost:8000`. Then open `http://localhost:8000/` on the phone.
`localhost` counts as secure, so everything works.
- **Android without USB:** on the phone, open
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` and add
`http://<your-ip>:8000`. Use this for testing only.
- **Anywhere, iOS included:** put it behind HTTPS (see Docker below, behind
your reverse proxy).
Browser notes:
- Browsers only allow sound and vibration after a tap or a finger *lift*.
A finger going down doesn't count. If the very first round after opening
is silent, tap the screen once (or use any toolbar button) first.
- iOS Safari has no vibration API and no fullscreen API on iPhone, so those
controls are hidden there. Add it to the home screen for a full-screen
experience.
- Devices cap simultaneous touches (iPhone: 5, most Android phones: 10).
The app handles whatever arrives.
## Docker
The image is plain unprivileged nginx serving the static files on port
**8080**, with a `/healthz` endpoint.
```sh
docker build -t picker .
docker run --rm -p 8080:8080 picker
```
On every push to `main`, the GitHub Actions workflow
(`.github/workflows/docker.yml`) publishes a multi-arch (amd64 + arm64)
image to GHCR:
- `ghcr.io/<owner>/<repo>:latest` on `main`
- `:1.2.3` and `:1.2` when you push a `v1.2.3` tag
- `:sha-abc1234` on every build
Example `docker-compose.yml` for a home server:
```yaml
services:
picker:
image: ghcr.io/<owner>/<repo>:latest
restart: unless-stopped
ports:
- "8080:8080"
```
Point your reverse proxy (Caddy, Traefik, nginx proxy manager, …) at port
8080 for your subdomain and terminate HTTPS there. HTTPS is required for
offline mode, installing and wake lock. All paths are relative, so it also
works under a sub-path.
GHCR packages are private by default. Either make the package public
(GitHub → your profile → Packages → picker → Package settings), or run
`docker login ghcr.io` on the server with a token that has `read:packages`.
## Icons
`icons/*.png` are generated: `python tools/make_icons.py`.

271
css/app.css Normal file
View file

@ -0,0 +1,271 @@
:root {
--bg: #05050b;
--fg: #f4f4fa;
--muted: #a3a3b8;
--panel: #161622;
--line: rgba(255, 255, 255, 0.12);
--accent: #00e5ff;
--accent-fg: #001318;
--safe-t: env(safe-area-inset-top, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-l: env(safe-area-inset-left, 0px);
--safe-r: env(safe-area-inset-right, 0px);
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
[data-theme="pastel"] { --bg: #1c1a26; --accent: #cdb4ff; --accent-fg: #1c1a26; --panel: #262334; }
[data-theme="contrast"] { --bg: #000; --accent: #ffd500; --accent-fg: #000; --panel: #000; --line: #fff; --muted: #e6e6e6; }
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--fg);
font-family: var(--font);
/* no scrolling, zooming, pull-to-refresh, selection or callouts */
touch-action: none;
overscroll-behavior: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
-webkit-text-size-adjust: 100%;
}
#stage {
z-index: 0;
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
/* ---- overlay text ---- */
#overlay {
z-index: 1;
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: calc(var(--safe-t) + 5rem) calc(var(--safe-r) + 2rem) calc(var(--safe-b) + 3rem) calc(var(--safe-l) + 2rem);
pointer-events: none;
text-align: center;
transition: opacity 200ms;
}
.sheet-open #overlay { opacity: 0; }
.msg {
margin: 0;
max-width: 22ch;
font-size: clamp(1.4rem, 6.5vw, 2.2rem);
font-weight: 700;
line-height: 1.2;
text-wrap: balance;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.75), 0 0 2px rgba(0, 0, 0, 0.9);
}
.msg.hint { color: var(--muted); font-weight: 600; font-size: clamp(1.2rem, 5.5vw, 1.8rem); }
.msg.soft { color: var(--muted); font-weight: 600; font-size: clamp(1rem, 4.5vw, 1.4rem); }
.msg.count { font-size: clamp(3rem, 18vw, 6rem); font-weight: 800; opacity: 0.35; }
.msg.alert { font-size: clamp(1.5rem, 7vw, 2.4rem); }
.msg.result { font-size: clamp(1.8rem, 9vw, 3.2rem); font-weight: 800; }
.msg.in { animation: msg-in 380ms cubic-bezier(0.2, 1.4, 0.4, 1); }
.msg-sub {
margin: 0;
min-height: 1.4em;
max-width: 32ch;
color: var(--fg);
opacity: 0.75;
font-size: clamp(0.95rem, 4vw, 1.1rem);
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.9);
}
@keyframes msg-in {
from { transform: scale(0.7); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* ---- toolbar: top centre, away from the side-edge back gesture ---- */
#toolbar {
z-index: 2;
position: fixed;
top: calc(var(--safe-t) + 14px);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
transition: opacity 200ms, transform 200ms;
}
#toolbar.hidden { opacity: 0; transform: translate(-50%, -8px); pointer-events: none; }
.tool {
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.06);
color: var(--fg);
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
touch-action: manipulation;
}
.tool:active { background: rgba(255, 255, 255, 0.16); }
.tool:focus-visible, .sheet-body button:focus-visible, .sheet-close:focus-visible, #stake-input:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
.tool svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.tool .off { display: none; }
#btn-sound.is-muted .on, #btn-fullscreen.is-full .on { display: none; }
#btn-sound.is-muted .off, #btn-fullscreen.is-full .off { display: inline; }
/* ---- settings sheet ---- */
#sheet-backdrop {
z-index: 3;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 220ms;
}
.sheet-open #sheet-backdrop { opacity: 1; }
#sheet {
z-index: 4;
position: fixed;
left: 0;
right: 0;
bottom: 0;
margin: 0 auto;
max-width: 520px;
max-height: calc(100% - var(--safe-t) - 24px);
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--line);
border-bottom: 0;
border-radius: 22px 22px 0 0;
box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.5);
transform: translateY(100%);
transition: transform 240ms cubic-bezier(0.2, 0.9, 0.3, 1);
user-select: none;
outline: none;
}
.sheet-open #sheet { transform: none; }
.sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px 8px;
}
.sheet-head h2 { margin: 0; font-size: 1.15rem; }
.sheet-close {
font: inherit;
font-weight: 700;
color: var(--accent-fg);
background: var(--accent);
border: 0;
border-radius: 999px;
min-height: 40px;
padding: 0 18px;
cursor: pointer;
}
.sheet-body {
overflow-y: auto;
touch-action: pan-y;
overscroll-behavior: contain;
padding: 4px 24px calc(var(--safe-b) + 20px);
}
.field { padding: 12px 0; border-top: 1px solid var(--line); }
.field:first-child { border-top: 0; }
.field h3 { margin: 0 0 10px; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.field.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.field.row h3 { margin: 0; }
.field[hidden] { display: none; }
.desc { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; }
.seg { display: flex; gap: 6px; flex-wrap: wrap; }
.seg button, .chip, .stepper button {
font: inherit;
font-size: 0.95rem;
color: var(--fg);
background: rgba(255, 255, 255, 0.07);
border: 1px solid var(--line);
border-radius: 999px;
min-height: 44px;
padding: 0 16px;
cursor: pointer;
touch-action: manipulation;
}
.seg button[aria-pressed="true"], .chip[aria-pressed="true"] {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
font-weight: 700;
}
.stepper { display: flex; align-items: center; gap: 6px; }
.stepper button { width: 44px; padding: 0; font-size: 1.3rem; }
.stepper output { min-width: 2ch; text-align: center; font-size: 1.2rem; font-weight: 700; }
.stake-row { display: flex; gap: 8px; }
#stake-input {
flex: 1;
min-width: 0;
min-height: 44px;
font: inherit;
font-size: 1rem; /* ≥16px stops iOS zooming on focus */
color: var(--fg);
background: rgba(255, 255, 255, 0.07);
border: 1px solid var(--line);
border-radius: 12px;
padding: 0 14px;
user-select: text;
-webkit-user-select: text;
touch-action: manipulation;
}
#stake-input::placeholder { color: var(--muted); opacity: 0.7; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.chip { min-height: 38px; font-size: 0.9rem; }
[data-theme="contrast"] .seg button,
[data-theme="contrast"] .chip,
[data-theme="contrast"] .stepper button,
[data-theme="contrast"] #stake-input { border-width: 2px; background: #000; }
/* ---- debug ---- */
#debug-hud {
z-index: 5;
position: fixed;
bottom: calc(var(--safe-b) + 8px);
left: 50%;
transform: translateX(-50%);
font: 12px/1.4 ui-monospace, monospace;
color: #8f8;
background: rgba(0, 0, 0, 0.6);
padding: 4px 10px;
border-radius: 6px;
pointer-events: none;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.msg.in { animation-duration: 1ms; }
#sheet, #toolbar, #sheet-backdrop { transition-duration: 1ms; }
}

BIN
icons/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
icons/icon-maskable-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

14
icons/icon.svg Normal file
View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<filter id="g" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect width="100" height="100" rx="22" fill="#05050b"/>
<g fill="none" stroke-width="3.2" filter="url(#g)">
<circle cx="34" cy="40" r="17" stroke="#ff2e63"/>
<circle cx="66" cy="40" r="17" stroke="#00e5ff"/>
<circle cx="50" cy="68" r="17" stroke="#ffe500"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 583 B

101
index.html Normal file
View file

@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#05050b">
<meta name="color-scheme" content="dark">
<meta name="description" content="Everyone puts a finger on the screen and Picker chooses one of you at random.">
<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">
<meta name="apple-mobile-web-app-title" content="Picker">
<title>Picker</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
<link rel="icon" href="icons/icon-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="stylesheet" href="css/app.css">
<script type="module" src="js/main.js"></script>
</head>
<body>
<canvas id="stage" aria-hidden="true"></canvas>
<div id="overlay" aria-live="polite">
<p id="msg-main" class="msg"></p>
<p id="msg-sub" class="msg-sub"></p>
</div>
<nav id="toolbar" class="hidden">
<button id="btn-settings" class="tool" type="button" data-i18n-aria="settings">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg>
</button>
<button id="btn-sound" class="tool" type="button" data-i18n-aria="mute">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z"/><path class="on" d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11"/><path class="off" d="M16 9.5l5 5M21 9.5l-5 5"/></svg>
</button>
<button id="btn-fullscreen" class="tool" type="button" data-i18n-aria="fullscreen">
<svg viewBox="0 0 24 24" aria-hidden="true"><path class="on" d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/><path class="off" d="M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"/></svg>
</button>
</nav>
<div id="sheet-backdrop" hidden></div>
<section id="sheet" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="sheet-title" hidden>
<header class="sheet-head">
<h2 id="sheet-title" data-i18n="settings"></h2>
<button class="sheet-close" type="button" data-i18n="done"></button>
</header>
<div class="sheet-body">
<div class="field">
<h3 data-i18n="modeLabel"></h3>
<div id="seg-mode" class="seg seg-wrap" data-setting="mode"></div>
<p id="mode-desc" class="desc"></p>
</div>
<div id="row-pickn" class="field row" hidden>
<h3 data-i18n="pickNLabel"></h3>
<div class="stepper">
<button type="button" data-step="-1" data-i18n-aria="decrease">−</button>
<output id="pickn-value">2</output>
<button type="button" data-step="1" data-i18n-aria="increase">+</button>
</div>
</div>
<div id="row-teams" class="field row" hidden>
<h3 data-i18n="teamsLabel"></h3>
<div id="seg-teams" class="seg" data-setting="teams"></div>
</div>
<div class="field">
<h3><label for="stake-input" data-i18n="stakesLabel"></label></h3>
<div class="stake-row">
<input id="stake-input" type="text" maxlength="60" autocomplete="off" enterkeyhint="done">
<button id="stake-clear" type="button" class="chip" data-i18n="stakesClear" hidden></button>
</div>
<div id="stake-presets" class="chips"></div>
</div>
<div class="field">
<h3 data-i18n="themeLabel"></h3>
<div id="seg-theme" class="seg" data-setting="theme"></div>
</div>
<div class="field">
<h3 data-i18n="languageLabel"></h3>
<div id="seg-lang" class="seg" data-setting="lang"></div>
</div>
<div class="field row">
<h3 data-i18n="soundLabel"></h3>
<div id="seg-sound" class="seg" data-setting="muted"></div>
</div>
<div id="row-haptics" class="field row">
<h3 data-i18n="hapticsLabel"></h3>
<div id="seg-haptics" class="seg" data-setting="haptics"></div>
</div>
</div>
</section>
<div id="debug-hud" hidden></div>
</body>
</html>

156
js/audio.js Normal file
View file

@ -0,0 +1,156 @@
// Sound effects synthesised with Web Audio (no audio files) + haptics.
//
// Browsers only allow audio and navigator.vibrate after a "user activation".
// A touch *down* doesn't count (only lifting a finger, a tap or a click), so
// we try to unlock on every interaction until it works.
let ctx = null;
let master = null;
let noiseBuf = null;
let muted = false;
let hapticsOn = true;
export function setMuted(m) {
muted = m;
if (master) master.gain.setTargetAtTime(m ? 0 : 0.8, ctx.currentTime, 0.02);
}
export function setHaptics(on) {
hapticsOn = on;
}
export const canVibrate = typeof navigator.vibrate === 'function';
export function buzz(pattern) {
if (!canVibrate || !hapticsOn) return;
try {
navigator.vibrate(pattern);
} catch {
/* blocked before user activation */
}
}
export function unlockAudio() {
try {
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
ctx = new AC();
master = ctx.createGain();
master.gain.value = muted ? 0 : 0.8;
const comp = ctx.createDynamicsCompressor();
master.connect(comp);
comp.connect(ctx.destination);
noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
if (ctx.state !== 'running') ctx.resume().catch(() => {});
} catch {
/* audio unavailable */
}
}
export function installAudioUnlock() {
const opts = { capture: true, passive: true };
for (const t of ['pointerdown', 'pointerup', 'touchend', 'click', 'keydown']) window.addEventListener(t, unlockAudio, opts);
}
const live = () => ctx && ctx.state === 'running' && !muted;
function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) {
const t0 = ctx.currentTime + at;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t0);
if (freqEnd) o.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(gain, t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
let node = o;
if (filter) {
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.value = filter;
o.connect(f);
node = f;
}
node.connect(g);
g.connect(master);
o.start(t0);
o.stop(t0 + dur + 0.05);
}
function noise({ at = 0, dur, gain = 0.2, type = 'bandpass', freq = 1000, freqEnd, q = 1 }) {
const t0 = ctx.currentTime + at;
const src = ctx.createBufferSource();
src.buffer = noiseBuf;
const f = ctx.createBiquadFilter();
f.type = type;
f.Q.value = q;
f.frequency.setValueAtTime(freq, t0);
if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(f);
f.connect(g);
g.connect(master);
src.start(t0);
src.stop(t0 + dur + 0.05);
}
const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24];
const note = (base, semis) => base * Math.pow(2, semis / 12);
export const sfx = {
join(i) {
if (!live()) return;
tone({ freq: note(523, PENTA[i % PENTA.length]), dur: 0.14, gain: 0.12 });
},
beat(i) {
if (!live()) return;
tone({ type: 'triangle', freq: note(392, i * 5), dur: 0.18, gain: 0.25 });
},
/** Roulette tick, rising in pitch as the wheel slows. */
tick(progress, final) {
if (!live()) return;
if (final) {
tone({ type: 'square', freq: 1320, dur: 0.12, gain: 0.08, filter: 4000 });
return;
}
tone({ type: 'square', freq: 420 + progress * 900, dur: 0.035, gain: 0.07, filter: 3500 });
noise({ dur: 0.02, gain: 0.05, type: 'highpass', freq: 3000 });
},
reveal() {
if (!live()) return;
// whoosh + boom + bright major arpeggio
noise({ dur: 0.5, gain: 0.25, type: 'bandpass', freq: 400, freqEnd: 4000, q: 0.8 });
tone({ freq: 140, freqEnd: 45, dur: 0.6, gain: 0.5 });
[0, 4, 7, 12, 16].forEach((s, i) => {
tone({ type: 'triangle', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.9, gain: 0.16 });
tone({ type: 'sawtooth', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.7, gain: 0.04, filter: 2200 });
});
},
crack() {
if (!live()) return;
noise({ dur: 0.3, gain: 0.45, type: 'highpass', freq: 1800 });
noise({ at: 0.04, dur: 0.18, gain: 0.25, type: 'bandpass', freq: 5000, q: 4 });
tone({ freq: 220, freqEnd: 55, dur: 0.35, gain: 0.35 });
tone({ type: 'triangle', freq: 2637, at: 0.02, dur: 0.25, gain: 0.05 });
tone({ type: 'triangle', freq: 3520, at: 0.07, dur: 0.2, gain: 0.04 });
},
/** Sad trombone. */
chicken() {
if (!live()) return;
const parts = [[392, 370, 0], [370, 349, 0.28], [349, 330, 0.56]];
for (const [a, b, at] of parts) tone({ type: 'sawtooth', freq: a, freqEnd: b, at, dur: 0.26, gain: 0.12, filter: 1200, attack: 0.03 });
tone({ type: 'sawtooth', freq: 330, freqEnd: 247, at: 0.84, dur: 0.75, gain: 0.12, filter: 1000, attack: 0.03 });
},
pop(n) {
if (!live()) return;
tone({ freq: note(660, PENTA[(n - 1) % PENTA.length]), freqEnd: note(330, PENTA[(n - 1) % PENTA.length]), dur: 0.14, gain: 0.25 });
},
};

65
js/device.js Normal file
View file

@ -0,0 +1,65 @@
// Screen wake lock and fullscreen helpers.
export function createWakeLock() {
let sentinel = null;
let pending = false;
let wanted = false;
async function acquire() {
if (!('wakeLock' in navigator) || sentinel || pending || document.visibilityState !== 'visible') return;
pending = true;
try {
sentinel = await navigator.wakeLock.request('screen');
sentinel.addEventListener('release', () => {
sentinel = null;
});
if (!wanted) release();
} catch {
/* denied, insecure context or low battery: no big deal */
} finally {
pending = false;
}
}
function release() {
const s = sentinel;
sentinel = null;
s?.release().catch(() => {});
}
// The lock is dropped automatically when the page is hidden.
document.addEventListener('visibilitychange', () => {
if (wanted && document.visibilityState === 'visible') acquire();
});
return {
set(on) {
if (on === wanted) return;
wanted = on;
if (on) acquire();
else release();
},
};
}
export const fullscreenSupported = () => !!(document.fullscreenEnabled || document.webkitFullscreenEnabled);
export const isFullscreen = () => !!(document.fullscreenElement || document.webkitFullscreenElement);
export async function toggleFullscreen() {
try {
if (isFullscreen()) {
await (document.exitFullscreen || document.webkitExitFullscreen).call(document);
} else {
const el = document.documentElement;
if (el.requestFullscreen) await el.requestFullscreen({ navigationUI: 'hide' });
else el.webkitRequestFullscreen?.();
}
} catch {
/* user agent refused */
}
}
export function onFullscreenChange(fn) {
document.addEventListener('fullscreenchange', fn);
document.addEventListener('webkitfullscreenchange', fn);
}

369
js/game.js Normal file
View file

@ -0,0 +1,369 @@
// Game state machine. Pure logic + timing; no DOM, no drawing.
//
// idle ──(≥2 fingers)──▶ gathering ──(stable)──▶ countdown ──▶ suspense
// ▲ ▲ │ join │ lift ▶ chicken ─┘
// │ └────┴─────────────────┘
// suspense ──▶ result (one, pickN, teams)
// suspense ──▶ ordering ──▶ result (order)
// suspense ──▶ elimPause ──▶ suspense … ──▶ result (elimination)
import { planRound, makeHighlightSchedule, makeTeamSchedule } from './modes.js';
import { randomInt } from './random.js';
export const TIMING = {
stableMs: 2500, // finger set must be unchanged this long before the countdown
beatMs: 520, // countdown beat
beats: 3,
suspenseMs: 2700,
elimSuspenseMs: 1800,
settleMs: 650, // pause on the final highlight before the reveal
chickenMs: 1700,
elimPauseMs: 1500,
orderStepMs: 650,
restartGraceMs: 1200, // ignore taps right after a reveal
ringFadeMs: 350,
};
export const REVEAL_STYLES = ['flood', 'confetti', 'spotlight', 'shockwave'];
// Phases where the participants are locked in; new fingers are spectators.
const LOCKED = new Set(['suspense', 'elimPause', 'ordering', 'result']);
export class Game {
/**
* @param {{emit:(type:string,data?:object)=>void, getSettings:()=>object, isReduced:()=>boolean}} opts
*/
constructor({ emit, getSettings, isReduced }) {
this.emit = emit;
this.getSettings = getSettings;
this.isReduced = isReduced;
this.rings = new Map();
this.lastRevealStyle = null;
this.reset(performance.now());
}
reset(now) {
this.rings.clear();
this.phase = 'idle';
this.phaseAt = now;
this.lastChangeAt = now;
this.plan = null;
this.players = [];
this.schedule = null;
this.stepIndex = -1;
this.stepAt = 0;
this.highlight = null; // Set of highlighted ids
this.displayTeams = null; // Map id -> team shown during team suspense
this.beat = -1;
this.revealAt = 0;
this.revealStyle = null;
this.winners = [];
this.orderNums = new Map(); // id -> {n, at}
this.orderIdx = 0;
this.nextOrderAt = 0;
this.remaining = [];
this.lastOut = null; // {id, chickened}
this.chickenUntil = 0;
this.pauseUntil = 0;
}
// ---- queries -----------------------------------------------------------
/** Fingers currently down that count as players (not spectators). */
activeRings() {
const out = [];
for (const r of this.rings.values()) if (r.down && !r.goneAt && !r.late) out.push(r);
return out;
}
anyDown() {
for (const r of this.rings.values()) if (r.down) return true;
return false;
}
canRestart(now) {
return this.phase === 'result' && now - this.revealAt > TIMING.restartGraceMs && !this.anyDown();
}
/** 0..1 progress of the stability wait. */
stableProgress(now) {
return this.phase === 'gathering' ? Math.min(1, (now - this.lastChangeAt) / TIMING.stableMs) : 0;
}
// ---- input -------------------------------------------------------------
fingerDown(id, x, y, now) {
if (this.rings.has(id)) return;
if (this.phase === 'result' && this.canRestart(now)) {
this.reset(now);
this.emit('restart');
}
const late = LOCKED.has(this.phase);
const ring = { id, x, y, bornAt: now, down: true, goneAt: 0, late, colorIdx: late ? -1 : this.freeColor(), outAt: 0 };
this.rings.set(id, ring);
if (late) return;
this.lastChangeAt = now;
this.emit('join', { ring });
if (this.phase === 'countdown') this.setPhase('gathering', now);
}
fingerMove(id, x, y) {
const r = this.rings.get(id);
if (r && r.down) {
r.x = x;
r.y = y;
}
}
fingerUp(id, now) {
const r = this.rings.get(id);
if (!r || !r.down) return;
r.down = false;
r.upAt = now;
if (r.late) {
r.goneAt = now;
return;
}
switch (this.phase) {
case 'idle':
case 'gathering':
case 'chicken':
r.goneAt = now;
this.lastChangeAt = now;
this.emit('leave', { ring: r });
break;
case 'countdown':
r.goneAt = now;
this.chicken(now);
break;
case 'suspense':
if (this.plan.mode === 'elimination') {
this.elimChicken(r, now);
} else {
r.goneAt = now;
this.chicken(now);
}
break;
case 'elimPause':
this.elimChicken(r, now);
break;
default:
// ordering / result: the outcome is fixed, the ring just freezes in place.
break;
}
}
/** Page hidden etc.: drop every touch without drama. */
cancelAll(now) {
if (this.phase === 'result' || this.phase === 'ordering') {
for (const r of this.rings.values()) r.down = false;
} else {
this.reset(now);
this.emit('phase', { phase: 'idle' });
}
}
// ---- update ------------------------------------------------------------
update(now) {
for (const [id, r] of this.rings) {
if (r.goneAt && now - r.goneAt > TIMING.ringFadeMs) this.rings.delete(id);
}
switch (this.phase) {
case 'idle':
if (this.activeRings().length >= 2) this.setPhase('gathering', now);
break;
case 'gathering':
if (this.activeRings().length < 2) this.setPhase('idle', now);
else if (now - this.lastChangeAt >= TIMING.stableMs) {
this.beat = -1;
this.setPhase('countdown', now);
}
break;
case 'countdown': {
const b = Math.floor((now - this.phaseAt) / TIMING.beatMs);
if (b >= TIMING.beats) this.startRound(now);
else if (b !== this.beat) {
this.beat = b;
this.emit('beat', { index: b, remaining: TIMING.beats - b });
}
break;
}
case 'chicken':
if (now >= this.chickenUntil) {
this.lastChangeAt = now;
this.setPhase(this.activeRings().length >= 2 ? 'gathering' : 'idle', now);
}
break;
case 'suspense':
this.advanceSchedule(now);
break;
case 'elimPause':
if (now >= this.pauseUntil) {
if (this.remaining.length <= 1) this.reveal(now, this.remaining);
else this.startElimSuspense(now, TIMING.elimSuspenseMs);
}
break;
case 'ordering':
if (now >= this.nextOrderAt) {
if (this.orderIdx >= this.plan.order.length) this.reveal(now, [this.plan.order[0]]);
else this.assignNextOrder(now);
}
break;
}
}
// ---- transitions -------------------------------------------------------
setPhase(phase, now) {
this.phase = phase;
this.phaseAt = now;
this.emit('phase', { phase });
}
freeColor() {
const used = new Set();
for (const r of this.rings.values()) if (!r.goneAt && r.colorIdx >= 0) used.add(r.colorIdx);
let i = 0;
while (used.has(i)) i++;
return i;
}
chicken(now) {
this.plan = null;
this.schedule = null;
this.highlight = null;
this.displayTeams = null;
// Spectators who are still holding on become players for the next try.
for (const r of this.rings.values()) {
if (r.late && r.down) {
r.late = false;
r.colorIdx = this.freeColor();
r.bornAt = now;
}
}
this.chickenUntil = now + TIMING.chickenMs;
this.setPhase('chicken', now);
this.emit('chicken');
}
startRound(now) {
const rings = this.activeRings();
const ids = rings.map((r) => r.id);
const settings = this.getSettings();
const reduced = this.isReduced();
this.players = ids;
this.plan = planRound(settings.mode, ids, settings);
switch (this.plan.mode) {
case 'elimination':
this.remaining = ids.slice();
this.startElimSuspense(now, TIMING.suspenseMs);
return;
case 'teams':
this.startSuspense(now, makeTeamSchedule(ids, this.plan.teams, this.plan.teamCount, TIMING.suspenseMs, reduced));
return;
case 'order':
this.startSuspense(now, makeHighlightSchedule(rings, [this.plan.order[0]], TIMING.suspenseMs, reduced));
return;
default:
this.startSuspense(now, makeHighlightSchedule(rings, this.plan.winners, TIMING.suspenseMs, reduced));
}
}
startSuspense(now, schedule) {
this.schedule = schedule;
this.stepIndex = -1;
this.highlight = null;
this.displayTeams = null;
this.setPhase('suspense', now);
this.emit('suspenseStart');
this.advanceSchedule(now);
}
advanceSchedule(now) {
const steps = this.schedule;
const last = steps.length - 1;
const t = now - this.phaseAt;
while (this.stepIndex < last && steps[this.stepIndex + 1].t <= t) {
this.stepIndex++;
const step = steps[this.stepIndex];
if (step.teams) this.displayTeams = step.teams;
else this.highlight = new Set(step.ids);
this.stepAt = now;
this.emit('tick', { progress: last ? this.stepIndex / last : 1, final: this.stepIndex === last });
}
if (this.stepIndex === last && t >= steps[last].t + TIMING.settleMs) this.land(now);
}
land(now) {
const plan = this.plan;
if (plan.mode === 'order') {
this.orderIdx = 0;
this.setPhase('ordering', now);
this.assignNextOrder(now);
} else if (plan.mode === 'elimination') {
this.eliminate(this.currentTarget, now, false);
} else if (plan.mode === 'teams') {
this.reveal(now, []);
} else {
this.reveal(now, plan.winners);
}
}
reveal(now, winners) {
this.winners = winners.slice();
this.highlight = new Set(winners);
this.revealAt = now;
if (this.plan.mode === 'teams') this.revealStyle = 'teams';
else if (this.isReduced()) this.revealStyle = 'calm';
else {
const options = REVEAL_STYLES.filter((s) => s !== this.lastRevealStyle);
this.revealStyle = options[randomInt(options.length)];
this.lastRevealStyle = this.revealStyle;
}
this.setPhase('result', now);
this.emit('reveal', { plan: this.plan, style: this.revealStyle, winners: this.winners });
}
// ---- order mode ----
assignNextOrder(now) {
const id = this.plan.order[this.orderIdx];
const n = this.orderIdx + 1;
this.orderNums.set(id, { n, at: now });
this.highlight = new Set([id]);
this.stepAt = now;
this.orderIdx++;
this.nextOrderAt = now + TIMING.orderStepMs;
this.emit('orderStep', { id, n, total: this.plan.order.length });
}
// ---- elimination mode ----
startElimSuspense(now, duration) {
const target = this.plan.eliminationOrder.find((id) => this.remaining.includes(id));
this.currentTarget = target;
const rings = this.remaining.map((id) => this.rings.get(id));
this.startSuspense(now, makeHighlightSchedule(rings, [target], duration, this.isReduced()));
}
eliminate(id, now, chickened) {
const r = this.rings.get(id);
if (r) r.outAt = now;
this.remaining = this.remaining.filter((x) => x !== id);
this.lastOut = { id, chickened };
this.highlight = null;
this.pauseUntil = now + (this.remaining.length <= 1 ? 900 : TIMING.elimPauseMs);
this.setPhase('elimPause', now);
this.emit('eliminate', { id, chickened, remaining: this.remaining.length });
}
/** A player let go mid-elimination: they're out. */
elimChicken(r, now) {
if (!this.remaining.includes(r.id)) return; // already knocked out, lifting as asked
this.eliminate(r.id, now, true);
}
}

171
js/i18n.js Normal file
View file

@ -0,0 +1,171 @@
// All user-facing strings live here.
// Stakes are phrased so they read naturally after "The chosen one has to…"
// (en, bare verb) / "De gekozene…" (nl, conjugated verb), and slot into
// "You {stake}!" / "Jij {stake}!" on the reveal.
export const translations = {
en: {
appTitle: 'Picker',
hint: 'Everyone put a finger on the screen',
needMore: 'Waiting for more fingers…',
holdStill: 'Hold still…',
chicken: [
'Someone chickened out! 🐔',
'Cold feet? Someone let go! 🐔',
'Bawk bawk! Who let go? 🐔',
],
chickenOut: 'Chickened out! 🐔',
eliminated: 'Knocked out!',
liftFinger: 'Lift your finger',
youreIt: "You're it!",
youStake: 'You {stake}!',
teamsReady: 'Teams are set!',
orderDone: "That's the order!",
lastStanding: 'Last one standing!',
tapRestart: 'Tap to play again',
desktopHint: 'On a computer? Add ?debug=1 to the address to test with a mouse.',
team: 'Team {name}',
modeLabel: 'Mode',
mode: {
one: 'Pick one',
pickN: 'Pick several',
teams: 'Teams',
order: 'Order',
elimination: 'Elimination',
},
modeDesc: {
one: 'One finger is chosen.',
pickN: 'Several fingers are chosen.',
teams: 'Everyone is split into teams.',
order: 'Everyone gets a number, one by one.',
elimination: 'One finger is knocked out each round until one remains.',
},
pickNLabel: 'How many to pick',
teamsLabel: 'Number of teams',
stakesLabel: 'The chosen one has to…',
stakesPlaceholder: 'e.g. pay the bill',
stakesClear: 'Clear',
stakePresets: {
bill: 'pay the bill',
dishes: 'do the dishes',
first: 'go first',
round: 'get the next round',
movie: 'pick the movie',
},
themeLabel: 'Theme',
theme: { neon: 'Neon', pastel: 'Pastel', contrast: 'High contrast' },
languageLabel: 'Language',
soundLabel: 'Sound',
hapticsLabel: 'Vibration',
on: 'On',
off: 'Off',
done: 'Done',
settings: 'Settings',
mute: 'Mute sound',
unmute: 'Unmute sound',
fullscreen: 'Full screen',
exitFullscreen: 'Exit full screen',
decrease: 'Fewer',
increase: 'More',
},
nl: {
appTitle: 'Picker',
hint: 'Iedereen een vinger op het scherm',
needMore: 'Wachten op meer vingers…',
holdStill: 'Stil houden…',
chicken: [
'Iemand is er tussenuit geknepen! 🐔',
'Koudwatervrees? Iemand liet los! 🐔',
'Tok tok! Wie liet er los? 🐔',
],
chickenOut: 'Laf losgelaten! 🐔',
eliminated: 'Uitgeschakeld!',
liftFinger: 'Haal je vinger weg',
youreIt: 'Jij bent het!',
youStake: 'Jij {stake}!',
teamsReady: 'De teams staan!',
orderDone: 'Dat is de volgorde!',
lastStanding: 'Laatste overlevende!',
tapRestart: 'Tik om opnieuw te spelen',
desktopHint: 'Op een computer? Zet ?debug=1 achter het adres om met de muis te testen.',
team: 'Team {name}',
modeLabel: 'Modus',
mode: {
one: 'Kies één',
pickN: 'Kies meerdere',
teams: 'Teams',
order: 'Volgorde',
elimination: 'Afvalrace',
},
modeDesc: {
one: 'Eén vinger wordt gekozen.',
pickN: 'Meerdere vingers worden gekozen.',
teams: 'Iedereen wordt in teams verdeeld.',
order: 'Iedereen krijgt een nummer, één voor één.',
elimination: 'Elke ronde valt er één af, tot er één overblijft.',
},
pickNLabel: 'Hoeveel kiezen',
teamsLabel: 'Aantal teams',
stakesLabel: 'De gekozene…',
stakesPlaceholder: 'bijv. betaalt de rekening',
stakesClear: 'Wissen',
stakePresets: {
bill: 'betaalt de rekening',
dishes: 'doet de afwas',
first: 'mag beginnen',
round: 'haalt het volgende rondje',
movie: 'kiest de film',
},
themeLabel: 'Thema',
theme: { neon: 'Neon', pastel: 'Pastel', contrast: 'Hoog contrast' },
languageLabel: 'Taal',
soundLabel: 'Geluid',
hapticsLabel: 'Trillen',
on: 'Aan',
off: 'Uit',
done: 'Klaar',
settings: 'Instellingen',
mute: 'Geluid uit',
unmute: 'Geluid aan',
fullscreen: 'Volledig scherm',
exitFullscreen: 'Volledig scherm sluiten',
decrease: 'Minder',
increase: 'Meer',
},
};
export const LANGS = ['en', 'nl'];
let current = 'en';
export function detectLang() {
const prefs = navigator.languages?.length ? navigator.languages : [navigator.language || 'en'];
for (const p of prefs) {
const base = String(p).toLowerCase().split('-')[0];
if (LANGS.includes(base)) return base;
}
return 'en';
}
export function setLang(lang) {
current = LANGS.includes(lang) ? lang : 'en';
document.documentElement.lang = current;
}
export function getLang() {
return current;
}
/** Look up a dotted key ("mode.one"); interpolate {vars}. Falls back to English. */
export function t(key, vars) {
const lookup = (lang) => key.split('.').reduce((o, k) => (o == null ? o : o[k]), translations[lang]);
let s = lookup(current);
if (s == null) s = lookup('en');
if (s == null) return key;
if (typeof s === 'string' && vars) {
s = s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
}
return s;
}

149
js/input.js Normal file
View file

@ -0,0 +1,149 @@
// Pointer input. Fingers are tracked by pointerId; pointercancel counts as
// a lift (Android cancels touches it thinks are palms or system gestures).
//
// Debug mode (?debug=1): mouse clicks place persistent fake fingers.
// Click a fake finger again to remove it; drag it to move it.
// Keys: A adds a fake finger at a random spot, C clears all fake fingers.
const DRAG_THRESHOLD = 6;
export function createInput(canvas, { debug, onDown, onMove, onUp }) {
const active = new Set(); // real pointer ids currently down
const fakes = new Map(); // fake id -> {x, y}
let fakeSeq = 0;
let mouse = null; // {pointerId, fakeId, startX, startY, moved, created}
const now = () => performance.now();
const hitRadius = () => Math.max(40, Math.min(64, Math.min(innerWidth, innerHeight) * 0.115));
function release(pointerId) {
if (!active.delete(pointerId)) return;
onUp(String(pointerId), now());
}
canvas.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse') {
if (debug && e.button === 0) debugDown(e);
return;
}
e.preventDefault();
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* not fatal */
}
active.add(e.pointerId);
onDown(String(e.pointerId), e.clientX, e.clientY, now());
});
canvas.addEventListener('pointermove', (e) => {
if (mouse && e.pointerId === mouse.pointerId) return debugMove(e);
if (!active.has(e.pointerId)) return;
onMove(String(e.pointerId), e.clientX, e.clientY);
});
const up = (e) => {
if (mouse && e.pointerId === mouse.pointerId) return debugUp(e);
release(e.pointerId);
};
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
// Safety net: if capture is lost without an up/cancel, treat it as a lift.
canvas.addEventListener('lostpointercapture', (e) => {
if (!mouse || e.pointerId !== mouse.pointerId) release(e.pointerId);
});
// ---- debug: fake fingers with the mouse ----
function fakeAt(x, y) {
const r = hitRadius();
for (const [id, f] of fakes) if (Math.hypot(f.x - x, f.y - y) < r) return id;
return null;
}
function addFake(x, y) {
const id = `fake-${++fakeSeq}`;
fakes.set(id, { x, y });
onDown(id, x, y, now());
return id;
}
function removeFake(id) {
if (!fakes.delete(id)) return;
onUp(id, now());
}
function debugDown(e) {
const hit = fakeAt(e.clientX, e.clientY);
const fakeId = hit ?? addFake(e.clientX, e.clientY);
mouse = { pointerId: e.pointerId, fakeId, startX: e.clientX, startY: e.clientY, moved: false, created: !hit };
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
}
function debugMove(e) {
if (!mouse.moved && Math.hypot(e.clientX - mouse.startX, e.clientY - mouse.startY) > DRAG_THRESHOLD) mouse.moved = true;
if (mouse.moved && fakes.has(mouse.fakeId)) {
fakes.set(mouse.fakeId, { x: e.clientX, y: e.clientY });
onMove(mouse.fakeId, e.clientX, e.clientY);
}
}
function debugUp() {
const m = mouse;
mouse = null;
if (!m.created && !m.moved) removeFake(m.fakeId);
}
if (debug) {
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement) return;
if (e.key === 'a' || e.key === 'A') {
const m = 80;
addFake(m + Math.random() * (innerWidth - 2 * m), m + Math.random() * (innerHeight - 2 * m));
} else if (e.key === 'c' || e.key === 'C') {
for (const id of [...fakes.keys()]) removeFake(id);
}
});
}
return {
/** Forget all touches (page hidden). Fake fingers are kept. */
clearReal() {
active.clear();
},
/** Place fake fingers in a circle (debug ?fingers=N). */
addFakeCircle(n) {
const cx = innerWidth / 2;
const cy = innerHeight / 2;
const rad = Math.min(innerWidth, innerHeight) * 0.32;
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
addFake(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad);
}
},
fakeCount: () => fakes.size,
};
}
/** Stop the browser from scrolling, zooming, selecting or opening menus. */
export function blockBrowserGestures(canvas) {
const opts = { passive: false };
// iOS ignores touch-action for some gestures; cancel touches on the canvas.
canvas.addEventListener('touchstart', (e) => e.preventDefault(), opts);
canvas.addEventListener('touchmove', (e) => e.preventDefault(), opts);
canvas.addEventListener('touchend', (e) => e.preventDefault(), opts);
// iOS pinch-zoom
for (const t of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(t, (e) => e.preventDefault(), opts);
// No long-press menus or text selection anywhere except form fields.
document.addEventListener('contextmenu', (e) => {
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
});
document.addEventListener('selectstart', (e) => {
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
});
document.addEventListener('dblclick', (e) => e.preventDefault(), opts);
}

136
js/main.js Normal file
View file

@ -0,0 +1,136 @@
// Wires the modules together and runs the frame loop.
import { createSettings } from './settings.js';
import { setLang, detectLang } from './i18n.js';
import { Game } from './game.js';
import { Renderer } from './renderer.js';
import { createInput, blockBrowserGestures } from './input.js';
import { sfx, buzz, setMuted, setHaptics, installAudioUnlock } from './audio.js';
import { createWakeLock } from './device.js';
import { getTheme } from './themes.js';
import { createUI } from './ui.js';
const params = new URLSearchParams(location.search);
const DEBUG = params.has('debug') && params.get('debug') !== '0';
const WAKE_HOLD_MS = 2 * 60 * 1000; // keep the screen on this long after the last touch
const canvas = document.getElementById('stage');
const settings = createSettings();
setLang(settings.get().lang || detectLang());
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
const renderer = new Renderer(canvas);
const wakeLock = createWakeLock();
const game = new Game({
emit: onGameEvent,
getSettings: () => settings.get(),
isReduced: () => reducedMotion.matches,
});
const noTouch = navigator.maxTouchPoints === 0 && matchMedia('(pointer: fine)').matches;
const ui = createUI({ settings, debug: DEBUG, showDesktopHint: noTouch && !DEBUG });
function applySettings(s) {
const theme = getTheme(s.theme);
renderer.setTheme(theme);
document.documentElement.dataset.theme = s.theme;
document.querySelector('meta[name="theme-color"]').content = theme.bg;
setMuted(s.muted);
setHaptics(s.haptics);
}
applySettings(settings.get());
settings.subscribe(applySettings);
// ---- game events -> sound, haptics, visuals, text ----
function onGameEvent(type, data = {}) {
const now = performance.now();
renderer.onEvent(type, data, now, game);
ui.onEvent(type, data);
switch (type) {
case 'join':
sfx.join(data.ring.colorIdx);
buzz(8);
break;
case 'beat':
sfx.beat(data.index);
buzz(25);
break;
case 'tick':
sfx.tick(data.progress, data.final);
buzz(data.final ? 35 : 8);
break;
case 'reveal':
sfx.reveal();
buzz([90, 60, 450]);
break;
case 'eliminate':
sfx.crack();
if (data.chickened) sfx.chicken();
buzz([40, 30, 120]);
break;
case 'orderStep':
sfx.pop(data.n);
buzz(data.n === 1 ? 120 : 20);
break;
case 'chicken':
sfx.chicken();
buzz([60, 50, 60, 50, 60]);
break;
}
}
// ---- input ----
blockBrowserGestures(canvas);
installAudioUnlock();
const input = createInput(canvas, {
debug: DEBUG,
onDown: (id, x, y, now) => game.fingerDown(id, x, y, now),
onMove: (id, x, y) => game.fingerMove(id, x, y),
onUp: (id, now) => game.fingerUp(id, now),
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
input.clearReal();
game.cancelAll(performance.now());
}
});
if (DEBUG) {
document.body.classList.add('debug');
document.getElementById('debug-hud').hidden = false;
const n = Number(params.get('fingers'));
if (n > 0) input.addFakeCircle(Math.min(n, 12));
}
// ---- frame loop ----
let lastTouchAt = -Infinity;
function frame() {
const now = performance.now();
game.update(now);
renderer.draw(game, now);
ui.update(game, now);
if (game.anyDown()) lastTouchAt = now;
wakeLock.set(now - lastTouchAt < WAKE_HOLD_MS);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// ---- PWA ----
if ('serviceWorker' in navigator && !DEBUG) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(() => {});
});
}
// Handy in the console while testing.
if (DEBUG) Object.assign(window, { game, settings, renderer });

111
js/modes.js Normal file
View file

@ -0,0 +1,111 @@
// Outcome planning. Every outcome is decided here, up front, with
// crypto randomness; the suspense schedules only *present* it.
import { randomInt, shuffle } from './random.js';
export const MODES = ['one', 'pickN', 'teams', 'order', 'elimination'];
export const TEAM_NAMES = ['A', 'B', 'C', 'D'];
/**
* @param {string} mode
* @param {string[]} ids participating finger ids
* @param {{pickN:number, teams:number}} settings
*/
export function planRound(mode, ids, settings) {
const n = ids.length;
switch (mode) {
case 'pickN': {
const k = Math.max(1, Math.min(settings.pickN | 0, n - 1));
return { mode, winners: shuffle(ids).slice(0, k) };
}
case 'teams': {
const teamCount = Math.max(2, Math.min(settings.teams | 0, n));
const teams = new Map();
shuffle(ids).forEach((id, i) => teams.set(id, i % teamCount));
return { mode, teams, teamCount };
}
case 'order':
return { mode, order: shuffle(ids) };
case 'elimination': {
// Knock-out order; the last entry is the survivor.
const eliminationOrder = shuffle(ids);
return { mode, eliminationOrder };
}
case 'one':
default:
return { mode: 'one', winners: [ids[randomInt(n)]] };
}
}
/** Step intervals that start fast and slow down like a roulette wheel. */
function rouletteTimes(duration, reduced) {
let interval = reduced ? 170 : 55;
const growth = reduced ? 1.1 : 1.15;
const intervals = [];
let sum = 0;
while (sum < duration) {
intervals.push(interval);
sum += interval;
interval *= growth;
}
const scale = duration / sum;
const times = [];
let t = 0;
for (const iv of intervals) {
times.push(t);
t += iv * scale;
}
return times;
}
/**
* Highlight schedule landing on `target` (array of ids).
* Single target: the highlight walks clockwise around the group, like a
* roulette ball. Several targets: random subsets flash, ending on the target.
* @param {{id:string,x:number,y:number}[]} rings
* @returns {{t:number, ids:string[]}[]}
*/
export function makeHighlightSchedule(rings, target, duration, reduced = false) {
const times = rouletteTimes(duration, reduced);
const steps = times.length;
const ids = rings.map((r) => r.id);
if (target.length === 1) {
const cx = rings.reduce((s, r) => s + r.x, 0) / rings.length;
const cy = rings.reduce((s, r) => s + r.y, 0) / rings.length;
const ordered = rings
.slice()
.sort((a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx))
.map((r) => r.id);
const m = ordered.length;
const end = ordered.indexOf(target[0]);
const start = (((end - (steps - 1)) % m) + m) % m;
return times.map((t, i) => ({ t, ids: [ordered[(start + i) % m]] }));
}
// Build backwards from the target so every step differs from the next one.
const key = (a) => a.slice().sort().join('|');
const out = new Array(steps);
out[steps - 1] = target.slice();
for (let i = steps - 2; i >= 0; i--) {
const next = key(out[i + 1]);
let set;
for (let tries = 0; tries < 20; tries++) {
set = shuffle(ids).slice(0, target.length);
if (key(set) !== next) break;
}
out[i] = set;
}
return times.map((t, i) => ({ t, ids: out[i] }));
}
/** Team schedule: rings flicker through random balanced splits, ending on the real one. */
export function makeTeamSchedule(ids, teams, teamCount, duration, reduced = false) {
const times = rouletteTimes(duration, reduced);
return times.map((t, i) => {
if (i === times.length - 1) return { t, teams };
const m = new Map();
shuffle(ids).forEach((id, j) => m.set(id, j % teamCount));
return { t, teams: m };
});
}

29
js/random.js Normal file
View file

@ -0,0 +1,29 @@
// Unbiased randomness from crypto.getRandomValues (available on http too).
const buf = new Uint32Array(1);
/** Uniform integer in [0, n). Rejection sampling avoids modulo bias. */
export function randomInt(n) {
if (n <= 1) return 0;
const limit = Math.floor(0x100000000 / n) * n;
let x;
do {
crypto.getRandomValues(buf);
x = buf[0];
} while (x >= limit);
return x % n;
}
/** Fisher–Yates shuffle, returns a new array. */
export function shuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = randomInt(i + 1);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
export function pick(arr) {
return arr[randomInt(arr.length)];
}

468
js/renderer.js Normal file
View file

@ -0,0 +1,468 @@
// Canvas renderer. Reads game state every frame; never mutates it.
// Glowing rings are pre-rendered sprites (shadowBlur is far too slow to
// run per frame on phones), scaled with drawImage.
import { TIMING } from './game.js';
import { SHAPES } from './themes.js';
import { TEAM_NAMES } from './modes.js';
import { createReveal, createRipple, clamp01, easeOutCubic } from './reveals.js';
const TAU = Math.PI * 2;
const SPRITE_SCALE = 1.7; // sprites are drawn at the largest ring size, then scaled down
function easeOutBack(t) {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
export class Renderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.sprites = new Map();
this.ripples = [];
this.reveal = null;
this.shards = new Map();
this.theme = null;
this.resize();
}
setTheme(theme) {
this.theme = theme;
this.sprites.clear();
}
resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 3);
const w = this.canvas.clientWidth || window.innerWidth;
const h = this.canvas.clientHeight || window.innerHeight;
this.dpr = dpr;
this.w = w;
this.h = h;
this.canvas.width = Math.round(w * dpr);
this.canvas.height = Math.round(h * dpr);
this.R = Math.max(40, Math.min(64, Math.min(w, h) * 0.115));
this.sprites.clear();
}
colorOf(ring) {
return ring.colorIdx < 0 ? this.theme.spectator : this.theme.colors[ring.colorIdx % this.theme.colors.length];
}
lineWidth() {
return this.R * 0.13 * this.theme.lineScale;
}
// ---- events from the game ----------------------------------------------
onEvent(type, data, now, game) {
const R = this.R;
switch (type) {
case 'join':
this.ripples.push(createRipple(data.ring.x, data.ring.y, this.colorOf(data.ring), now, R * 1.8, 450, 3));
break;
case 'beat':
for (const r of game.activeRings()) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.1, 500, 3));
break;
case 'orderStep': {
const r = game.rings.get(data.id);
if (r) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.6, 600, 5));
break;
}
case 'reveal': {
const targets =
data.style === 'teams'
? game.players.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.theme.team[game.plan.teams.get(ring.id)] }))
: data.winners.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.colorOf(ring) }));
this.reveal = createReveal(data.style, { targets, w: this.w, h: this.h, R, start: now, palette: this.theme.colors });
break;
}
case 'phase':
if (data.phase !== 'result') this.reveal = null;
if (data.phase === 'idle' || data.phase === 'gathering') this.shards.clear();
break;
case 'restart':
this.reveal = null;
this.shards.clear();
break;
}
}
// ---- sprites -----------------------------------------------------------
sprite(color) {
let s = this.sprites.get(color);
if (s) return s;
const { dpr, theme } = this;
const r = this.R * SPRITE_SCALE;
const lw = this.lineWidth() * SPRITE_SCALE;
const pad = r * 0.55;
const half = r + pad;
const c = document.createElement('canvas');
c.width = c.height = Math.ceil(half * 2 * dpr);
const g = c.getContext('2d');
g.scale(dpr, dpr);
// faint inner fill
const fill = g.createRadialGradient(half, half, r * 0.3, half, half, r);
fill.addColorStop(0, 'rgba(0,0,0,0)');
fill.addColorStop(1, color + '38');
g.fillStyle = fill;
g.beginPath();
g.arc(half, half, r, 0, TAU);
g.fill();
g.strokeStyle = color;
g.lineWidth = lw;
if (theme.glow) {
g.shadowColor = color;
g.shadowBlur = r * 0.5 * theme.glow * dpr;
g.beginPath();
g.arc(half, half, r, 0, TAU);
g.stroke();
}
g.beginPath();
g.arc(half, half, r, 0, TAU);
g.stroke();
g.shadowBlur = 0;
if (theme.additive) {
// white-hot core, neon tube style
g.strokeStyle = 'rgba(255,255,255,0.7)';
g.lineWidth = lw * 0.3;
g.stroke();
}
s = { canvas: c, half, base: r };
this.sprites.set(color, s);
return s;
}
// ---- drawing -----------------------------------------------------------
draw(game, now) {
// Cheaper and more reliable than trusting every resize/rotation event.
const c = this.canvas;
if (c.clientWidth !== this.w || c.clientHeight !== this.h || Math.min(window.devicePixelRatio || 1, 3) !== this.dpr) this.resize();
const { ctx, dpr, w, h, theme } = this;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const shake = this.reveal?.shake?.(now);
if (shake) ctx.translate(shake[0], shake[1]);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.fillStyle = theme.bg;
ctx.fillRect(-20, -20, w + 40, h + 40);
if (this.reveal) this.reveal.drawBack(ctx, now);
// Two passes so highlighted rings sit on top.
if (theme.additive) ctx.globalCompositeOperation = 'lighter';
const hl = game.highlight;
for (const ring of game.rings.values()) if (!hl?.has(ring.id)) this.drawRing(ctx, ring, game, now);
for (const ring of game.rings.values()) if (hl?.has(ring.id)) this.drawRing(ctx, ring, game, now);
this.ripples = this.ripples.filter((r) => !r.done(now));
for (const r of this.ripples) r.draw(ctx, now);
ctx.globalCompositeOperation = 'source-over';
if (this.reveal) this.reveal.drawFront(ctx, now);
this.drawLabels(ctx, game, now);
}
drawRing(ctx, ring, game, now) {
const { theme } = this;
const R = this.R;
let scale = 1;
let alpha = 1;
let color = this.colorOf(ring);
let shapeIdx = ring.colorIdx;
const appear = clamp01((now - ring.bornAt) / 320);
if (appear < 1) scale *= easeOutBack(appear);
if (ring.goneAt) {
const k = clamp01((now - ring.goneAt) / TIMING.ringFadeMs);
scale *= 1 - 0.5 * k;
alpha *= 1 - k;
}
if (ring.late) {
this.drawShape(ctx, ring.x, ring.y, R * 0.75 * scale, theme.spectator, alpha * 0.6, -1);
return;
}
if (ring.outAt) {
this.drawCrack(ctx, ring, color, now);
return;
}
const phase = game.phase;
const mode = game.plan?.mode;
const hl = game.highlight;
const isHl = !!hl?.has(ring.id);
const pop = Math.exp(-(now - game.stepAt) / 90);
if (phase === 'idle' || phase === 'gathering' || phase === 'chicken') {
scale *= 1 + 0.05 * Math.sin(((now - ring.bornAt) / 1000) * TAU * 0.7);
if (phase === 'chicken') alpha *= 0.55;
} else if (phase === 'countdown') {
const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs;
scale *= 1 + 0.2 * Math.exp(-bp * 6);
} else if (mode === 'teams') {
const team = phase === 'result' ? game.plan.teams.get(ring.id) : game.displayTeams?.get(ring.id);
if (team != null) {
color = theme.team[team];
shapeIdx = team;
}
scale *= phase === 'result' ? 1.1 + 0.04 * Math.sin(now / 300) : 1 + 0.1 * pop;
} else if (phase === 'result') {
if (mode === 'order') {
if (isHl) scale *= 1.3 + 0.05 * Math.sin(now / 250);
} else if (isHl) {
scale *= 1.35 + 0.05 * Math.sin(now / 250);
if (this.reveal?.winnerWhite) color = '#ffffff';
} else {
const k = clamp01((now - game.revealAt) / 650);
if (k >= 1) return;
scale *= 1 - k * k;
alpha *= 1 - k;
}
} else if (phase === 'ordering') {
const num = game.orderNums.get(ring.id);
if (isHl) scale *= 1.2 + 0.15 * pop;
else if (!num) {
scale *= 0.85;
alpha *= 0.4;
}
} else {
// suspense / elimPause
if (isHl) scale *= 1.18 + 0.14 * pop;
else if (hl) {
scale *= 0.86;
alpha *= 0.4;
}
}
this.drawShape(ctx, ring.x, ring.y, R * scale, color, alpha, theme.shapes ? shapeIdx : -1);
if (phase === 'gathering') {
const p = game.stableProgress(now);
if (p > 0.08) this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.55, p);
} else if (phase === 'countdown') {
const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs;
this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.3 + 0.6 * Math.exp(-bp * 5), 1);
}
}
drawArc(ctx, x, y, r, color, alpha, p) {
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, this.R * 0.05);
ctx.beginPath();
ctx.arc(x, y, r, -Math.PI / 2, -Math.PI / 2 + TAU * p);
ctx.stroke();
ctx.globalAlpha = 1;
}
/** A ring (sprite) or, in high-contrast, a shape outline. */
drawShape(ctx, x, y, r, color, alpha, shapeIdx) {
if (r <= 0.5 || alpha <= 0.01) return;
ctx.globalAlpha = alpha;
if (shapeIdx >= 0) {
const lw = this.lineWidth();
tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r);
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000';
ctx.lineWidth = lw + 6;
ctx.stroke();
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.stroke();
// second, inner outline doubles as a pattern cue
tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r * 0.72);
ctx.lineWidth = Math.max(2, lw * 0.35);
ctx.stroke();
} else {
const s = this.sprite(color);
const k = r / s.base;
const size = s.half * 2 * k;
ctx.drawImage(s.canvas, x - s.half * k, y - s.half * k, size, size);
}
ctx.globalAlpha = 1;
}
/** Elimination: the ring cracks, splits into shards that fall and fade. */
drawCrack(ctx, ring, color, now) {
const R = this.R;
const t = (now - ring.outAt) / 1000;
if (t > 1.3) return;
let shards = this.shards.get(ring.id);
if (!shards) {
shards = [];
const n = 7;
let a = Math.random() * TAU;
for (let i = 0; i < n; i++) {
const span = (TAU / n) * (0.7 + Math.random() * 0.6);
const mid = a + span / 2;
shards.push({ a0: a, a1: a + span * 0.92, dx: Math.cos(mid), dy: Math.sin(mid), v: 70 + Math.random() * 110, spin: (Math.random() - 0.5) * 4 });
a += span;
}
const cracks = [];
for (let i = 0; i < 3; i++) {
const ang = Math.random() * TAU;
const pts = [];
for (let j = 0; j <= 4; j++) {
const rr = R * (0.55 + j * 0.2);
const jit = (Math.random() - 0.5) * 0.35;
pts.push([Math.cos(ang + jit) * rr, Math.sin(ang + jit) * rr]);
}
cracks.push(pts);
}
shards.cracks = cracks;
this.shards.set(ring.id, shards);
}
const lw = this.lineWidth();
if (t < 0.18) {
// intact, flashing white and trembling, cracks spreading
const j = 3 * (1 - t / 0.18);
const x = ring.x + (Math.random() - 0.5) * j;
const y = ring.y + (Math.random() - 0.5) * j;
this.drawShape(ctx, x, y, R * 1.1, t < 0.06 ? '#ffffff' : color, 1, this.theme.shapes ? ring.colorIdx : -1);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
const k = t / 0.18;
for (const pts of shards.cracks) {
ctx.beginPath();
const m = Math.max(2, Math.ceil(pts.length * k));
for (let i = 0; i < m && i < pts.length; i++) ctx[i ? 'lineTo' : 'moveTo'](x + pts[i][0], y + pts[i][1]);
ctx.stroke();
}
return;
}
const tt = t - 0.18;
const fade = clamp01(1 - tt / 1.0);
ctx.lineCap = 'round';
for (const s of shards) {
const d = s.v * tt;
const x = ring.x + s.dx * d;
const y = ring.y + s.dy * d + 500 * tt * tt;
ctx.save();
ctx.translate(x, y);
ctx.rotate(s.spin * tt);
ctx.strokeStyle = color;
ctx.globalAlpha = fade * 0.35;
ctx.lineWidth = lw * 2.4;
ctx.beginPath();
ctx.arc(0, 0, R * 1.1, s.a0, s.a1);
ctx.stroke();
ctx.globalAlpha = fade;
ctx.lineWidth = lw;
ctx.stroke();
ctx.restore();
}
ctx.globalAlpha = 1;
ctx.lineCap = 'butt';
}
/** Number / team badges, placed on the side of the finger facing the
* screen centre, since the hand covers the other side. */
drawLabels(ctx, game, now) {
const mode = game.plan?.mode;
const showOrder = mode === 'order' && (game.phase === 'ordering' || game.phase === 'result');
const showTeams = mode === 'teams' && game.phase === 'result';
if (!showOrder && !showTeams) return;
const R = this.R;
const br = R * 0.42;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `800 ${Math.round(br * 1.1)}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
for (const ring of game.rings.values()) {
let text;
let color;
let at;
if (showOrder) {
const o = game.orderNums.get(ring.id);
if (!o) continue;
text = String(o.n);
color = this.colorOf(ring);
at = o.at;
} else {
const team = game.plan.teams.get(ring.id);
if (team == null) continue;
text = TEAM_NAMES[team];
color = this.theme.team[team];
at = game.revealAt;
}
let dx = this.w / 2 - ring.x;
let dy = this.h / 2 - ring.y;
const len = Math.hypot(dx, dy);
if (len < R) {
dx = 0;
dy = -1;
} else {
dx /= len;
dy /= len;
}
const s = easeOutBack(clamp01((now - at) / 350));
const dist = R * 1.95;
const x = ring.x + dx * dist;
const y = ring.y + dy * dist;
ctx.globalAlpha = clamp01(s);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, br * s, 0, TAU);
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = '#000';
ctx.stroke();
if (s > 0.3) {
ctx.fillStyle = '#000';
ctx.fillText(text, x, y + br * 0.05);
}
}
ctx.globalAlpha = 1;
}
}
function tracePath(ctx, shape, x, y, r) {
ctx.beginPath();
const poly = (n, rot, rr = r) => {
for (let i = 0; i < n; i++) {
const a = rot + (i / n) * TAU;
ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr);
}
ctx.closePath();
};
switch (shape) {
case 'square':
poly(4, Math.PI / 4, r * 1.05);
break;
case 'triangle':
poly(3, -Math.PI / 2, r * 1.2);
break;
case 'diamond':
poly(4, 0, r * 1.15);
break;
case 'hexagon':
poly(6, 0);
break;
case 'pentagon':
poly(5, -Math.PI / 2, r * 1.05);
break;
case 'star':
for (let i = 0; i < 10; i++) {
const a = -Math.PI / 2 + (i / 10) * TAU;
const rr = i % 2 ? r * 0.62 : r * 1.2;
ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr);
}
ctx.closePath();
break;
case 'cross': {
const a = r * 0.42;
const b = r * 1.05;
const pts = [[-a, -b], [a, -b], [a, -a], [b, -a], [b, a], [a, a], [a, b], [-a, b], [-a, a], [-b, a], [-b, -a], [-a, -a]];
pts.forEach(([px, py], i) => ctx[i ? 'lineTo' : 'moveTo'](x + px, y + py));
ctx.closePath();
break;
}
default:
ctx.arc(x, y, r, 0, TAU);
}
}

257
js/reveals.js Normal file
View file

@ -0,0 +1,257 @@
// Reveal animations, drawn by the renderer on top of / behind the rings.
// Each reveal gets live ring objects so it follows fingers that still move.
export const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
export const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export function hexToRgba(hex, a) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
function farthestCorner(x, y, w, h) {
return Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
}
/**
* @param {string} style flood | confetti | spotlight | shockwave | calm | teams
* @param {{targets:{ring:{x:number,y:number}, color:string}[], w:number, h:number, R:number, start:number, palette:string[]}} o
*/
export function createReveal(style, o) {
const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm;
return { winnerWhite: false, drawBack() {}, drawFront() {}, shake: null, ...make(o) };
}
function flood({ targets, w, h, start }) {
const single = targets.length === 1;
return {
winnerWhite: true,
drawBack(g, now) {
const k = easeInOutCubic(clamp01((now - start) / 850));
g.globalAlpha = single ? 1 : 0.8;
for (const { ring, color } of targets) {
const maxR = farthestCorner(ring.x, ring.y, w, h) * (single ? 1 : 0.5);
g.fillStyle = color;
g.beginPath();
g.arc(ring.x, ring.y, Math.max(0, maxR * k), 0, Math.PI * 2);
g.fill();
}
g.globalAlpha = 1;
if (single && k >= 1) {
// Soft vignette so the flooded screen keeps some depth.
const { ring } = targets[0];
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, farthestCorner(ring.x, ring.y, w, h));
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.4)');
g.fillStyle = grad;
g.fillRect(0, 0, w, h);
}
},
};
}
function confetti({ targets, w, h, R, start, palette }) {
const parts = [];
const per = Math.round(170 / targets.length);
for (const { ring, color } of targets) {
for (let i = 0; i < per; i++) {
const a = Math.random() * Math.PI * 2;
const speed = 250 + Math.random() * 850;
parts.push({
ring,
delay: i < per * 0.6 ? 0 : 0.22,
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed - 300,
size: 5 + Math.random() * 7,
color: Math.random() < 0.45 ? color : palette[(Math.random() * palette.length) | 0],
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 14,
flip: 4 + Math.random() * 8,
life: 2.4 + Math.random() * 1.2,
round: Math.random() < 0.3,
});
}
}
// Freeze burst origin at reveal time.
for (const p of parts) {
p.x0 = p.ring.x;
p.y0 = p.ring.y;
}
const tau = 0.75;
const grav = 1100;
return {
drawBack(g, now) {
const k = clamp01((now - start) / 700);
for (const { ring, color } of targets) {
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h));
grad.addColorStop(0, hexToRgba(color, 0.45 * k));
grad.addColorStop(1, hexToRgba(color, 0.08 * k));
g.fillStyle = grad;
g.fillRect(0, 0, w, h);
}
},
drawFront(g, now) {
const T = (now - start) / 1000;
for (const p of parts) {
const t = T - p.delay;
if (t <= 0 || t > p.life) continue;
const e = tau * (1 - Math.exp(-t / tau));
const x = p.x0 + p.vx * e + Math.sin(t * 3 + p.rot) * 12 * t;
const y = p.y0 + (p.vy + grav * tau) * e - grav * tau * t * 0.55;
if (y > h + 20) continue;
g.globalAlpha = clamp01((p.life - t) / 0.5);
g.fillStyle = p.color;
g.save();
g.translate(x, y);
g.rotate(p.rot + p.vr * t);
g.scale(1, Math.cos(t * p.flip));
if (p.round) {
g.beginPath();
g.arc(0, 0, p.size * 0.5, 0, Math.PI * 2);
g.fill();
} else g.fillRect(-p.size / 2, -p.size * 0.3, p.size, p.size * 0.6);
g.restore();
}
g.globalAlpha = 1;
},
};
}
function spotlight({ targets, w, h, R, start }) {
// Darkness is drawn at half resolution: it's all soft gradients anyway.
const scale = 0.5;
const off = document.createElement('canvas');
off.width = Math.ceil(w * scale);
off.height = Math.ceil(h * scale);
const o = off.getContext('2d');
const spots = targets.map(() => ({ sx: Math.random() * w, sy: Math.random() * h * 0.6, ph: Math.random() * 6 }));
return {
drawBack(g, now) {
const k = clamp01((now - start - 900) / 500);
if (!k) return;
for (const { ring, color } of targets) {
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, R * 3);
grad.addColorStop(0, hexToRgba(color, 0.55 * k));
grad.addColorStop(1, hexToRgba(color, 0));
g.fillStyle = grad;
g.fillRect(ring.x - R * 3, ring.y - R * 3, R * 6, R * 6);
}
},
drawFront(g, now) {
const t = (now - start) / 1000;
o.setTransform(scale, 0, 0, scale, 0, 0);
o.globalCompositeOperation = 'source-over';
o.clearRect(0, 0, w, h);
o.fillStyle = `rgba(0,0,0,${0.9 * clamp01(t / 0.25)})`;
o.fillRect(0, 0, w, h);
o.globalCompositeOperation = 'destination-out';
targets.forEach(({ ring }, i) => {
const s = spots[i];
const m = easeOutCubic(clamp01(t / 1.1));
const wob = (1 - m) * 60;
const x = s.sx + (ring.x - s.sx) * m + Math.sin(t * 9 + s.ph) * wob;
const y = s.sy + (ring.y - s.sy) * m + Math.cos(t * 7 + s.ph) * wob;
const r = Math.max(w, h) * 0.22 + (R * 2.3 - Math.max(w, h) * 0.22) * easeInOutCubic(clamp01((t - 0.3) / 0.9));
const grad = o.createRadialGradient(x, y, r * 0.7, x, y, r * 1.2);
grad.addColorStop(0, 'rgba(0,0,0,1)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
o.fillStyle = grad;
o.fillRect(x - r * 1.2, y - r * 1.2, r * 2.4, r * 2.4);
});
g.drawImage(off, 0, 0, w, h);
},
};
}
function shockwave({ targets, w, h, R, start }) {
const delays = [0, 0.14, 0.3];
return {
shake(now) {
const t = (now - start) / 1000;
const amp = 16 * Math.exp(-t * 7);
if (amp < 0.3) return null;
return [Math.sin(now * 0.09) * amp, Math.cos(now * 0.113) * amp];
},
drawBack(g, now) {
const t = (now - start) / 1000;
for (const { ring, color } of targets) {
g.fillStyle = hexToRgba(color, 0.25 * clamp01((t - 0.2) / 0.6));
g.fillRect(-20, -20, w + 40, h + 40);
const maxR = farthestCorner(ring.x, ring.y, w, h);
for (const d of delays) {
const k = (t - d) / 1.0;
if (k <= 0 || k >= 1) continue;
const r = easeOutCubic(k) * maxR;
g.globalAlpha = 1 - k;
g.strokeStyle = color;
g.lineWidth = R * 0.6 * (1 - k) + 2;
g.beginPath();
g.arc(ring.x, ring.y, r, 0, Math.PI * 2);
g.stroke();
g.strokeStyle = '#fff';
g.lineWidth = Math.max(1, R * 0.12 * (1 - k));
g.stroke();
}
g.globalAlpha = 1;
}
},
drawFront(g, now) {
const t = (now - start) / 1000;
if (t < 0.15) {
g.fillStyle = `rgba(255,255,255,${0.55 * (1 - t / 0.15)})`;
g.fillRect(-20, -20, w + 40, h + 40);
}
},
};
}
/** prefers-reduced-motion: a slow, gentle colour fade. */
function calm({ targets, w, h, R, start }) {
return {
drawBack(g, now) {
const k = easeInOutCubic(clamp01((now - start) / 1200));
for (const { ring, color } of targets) {
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h) * 0.9);
grad.addColorStop(0, hexToRgba(color, 0.5 * k));
grad.addColorStop(1, hexToRgba(color, 0.15 * k));
g.fillStyle = grad;
g.fillRect(0, 0, w, h);
}
},
};
}
function teams({ targets, R, start }) {
return {
drawBack(g, now) {
const k = easeOutCubic(clamp01((now - start) / 700));
for (const { ring, color } of targets) {
const r = R * (1.5 + 2.5 * k);
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, r);
grad.addColorStop(0, hexToRgba(color, 0.5));
grad.addColorStop(1, hexToRgba(color, 0));
g.fillStyle = grad;
g.fillRect(ring.x - r, ring.y - r, r * 2, r * 2);
}
},
};
}
/** Small expanding ring (joins, countdown beats, order steps). */
export function createRipple(x, y, color, start, maxR, dur = 600, width = 4) {
return {
done: (now) => now - start > dur,
draw(g, now) {
const k = (now - start) / dur;
if (k < 0 || k > 1) return;
g.globalAlpha = (1 - k) * 0.8;
g.strokeStyle = color;
g.lineWidth = width * (1 - k) + 0.5;
g.beginPath();
g.arc(x, y, maxR * easeOutCubic(k), 0, Math.PI * 2);
g.stroke();
g.globalAlpha = 1;
},
};
}

56
js/settings.js Normal file
View file

@ -0,0 +1,56 @@
// Persisted settings with a tiny subscribe API. Storage can throw
// (private mode, blocked site data), so every access is guarded.
const KEY = 'picker.settings.v1';
export const DEFAULTS = Object.freeze({
mode: 'one', // one | pickN | teams | order | elimination
pickN: 2,
teams: 2,
theme: 'neon', // neon | pastel | contrast
lang: null, // null = follow the browser
muted: false,
haptics: true,
stakeId: null, // preset key, translated at display time
stakeText: '', // custom text, used when stakeId is null
});
function load() {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...DEFAULTS };
const parsed = JSON.parse(raw);
const out = { ...DEFAULTS };
for (const k of Object.keys(DEFAULTS)) {
if (k in parsed) out[k] = parsed[k];
}
return out;
} catch {
return { ...DEFAULTS };
}
}
function save(s) {
try {
localStorage.setItem(KEY, JSON.stringify(s));
} catch {
/* storage unavailable: settings just won't persist */
}
}
export function createSettings() {
let state = load();
const listeners = new Set();
return {
get: () => state,
set(patch) {
state = { ...state, ...patch };
save(state);
for (const fn of listeners) fn(state, patch);
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}

42
js/themes.js Normal file
View file

@ -0,0 +1,42 @@
// Visual themes. `colors` are per-finger, `team` per team (A–D).
// The high-contrast theme also gives every finger/team its own shape,
// so nothing depends on telling colours apart.
export const SHAPES = ['circle', 'square', 'triangle', 'diamond', 'star', 'hexagon', 'cross', 'pentagon'];
export const THEMES = {
neon: {
bg: '#05050b',
glow: 1,
additive: true,
shapes: false,
lineScale: 1,
spectator: '#4a4a58',
colors: ['#ff2e63', '#00e5ff', '#ffe500', '#3dff5a', '#b45cff', '#ff8a1f', '#2f6bff', '#ff4fd8', '#00ffb2', '#c8ff00', '#ff5b3a', '#9fb4ff'],
team: ['#ff2e63', '#00e5ff', '#ffe500', '#3dff5a'],
},
pastel: {
bg: '#1c1a26',
glow: 0.45,
additive: false,
shapes: false,
lineScale: 1.15,
spectator: '#4b4858',
colors: ['#ff9aa2', '#9ee3e0', '#ffd6a5', '#b9f2c4', '#cdb4ff', '#ffc6e7', '#a0c4ff', '#fdffb6', '#f7b0be', '#b8e0d2', '#e2c2ff', '#ffcfa3'],
team: ['#ff9aa2', '#a0c4ff', '#fdffb6', '#b9f2c4'],
},
contrast: {
bg: '#000000',
glow: 0,
additive: false,
shapes: true,
lineScale: 1.3,
spectator: '#6b6b6b',
colors: ['#ffffff', '#ffd500', '#00d9ff', '#ff4fd8', '#4dff4d', '#ff8c00', '#a98bff', '#ff4545'],
team: ['#ffd500', '#00d9ff', '#ff4fd8', '#ffffff'],
},
};
export function getTheme(name) {
return THEMES[name] || THEMES.neon;
}

280
js/ui.js Normal file
View file

@ -0,0 +1,280 @@
// DOM layer: overlay messages, toolbar and the settings sheet.
import { t, setLang, getLang, detectLang, LANGS } from './i18n.js';
import { MODES } from './modes.js';
import { TIMING } from './game.js';
import { pick } from './random.js';
import { canVibrate } from './audio.js';
import { fullscreenSupported, isFullscreen, toggleFullscreen, onFullscreenChange } from './device.js';
const $ = (sel) => document.querySelector(sel);
export function stakeText(s) {
return s.stakeId ? t(`stakePresets.${s.stakeId}`) : s.stakeText.trim();
}
export function createUI({ settings, debug, showDesktopHint }) {
const msgMain = $('#msg-main');
const msgSub = $('#msg-sub');
const toolbar = $('#toolbar');
const sheet = $('#sheet');
const backdrop = $('#sheet-backdrop');
const btnSettings = $('#btn-settings');
const btnSound = $('#btn-sound');
const btnFull = $('#btn-fullscreen');
const stakeInput = $('#stake-input');
const hud = $('#debug-hud');
let chickenMsg = '';
let shownMain = null;
let shownSub = null;
let shownClass = null;
let toolbarShown = null;
// ---- toolbar ----
btnSettings.addEventListener('click', openSheet);
btnSound.addEventListener('click', () => settings.set({ muted: !settings.get().muted }));
const standaloneFull = matchMedia('(display-mode: fullscreen)').matches;
btnFull.hidden = !fullscreenSupported() || standaloneFull;
btnFull.addEventListener('click', toggleFullscreen);
onFullscreenChange(syncToolbar);
// ---- sheet ----
function openSheet() {
sheet.hidden = false;
backdrop.hidden = false;
requestAnimationFrame(() => document.body.classList.add('sheet-open'));
sheet.focus({ preventScroll: true });
}
function closeSheet() {
document.body.classList.remove('sheet-open');
stakeInput.blur();
setTimeout(() => {
if (!document.body.classList.contains('sheet-open')) {
sheet.hidden = true;
backdrop.hidden = true;
}
}, 250);
btnSettings.focus({ preventScroll: true });
}
backdrop.addEventListener('click', closeSheet);
sheet.querySelector('.sheet-close').addEventListener('click', closeSheet);
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !sheet.hidden) closeSheet();
});
// Segmented controls: <div class="seg" data-setting="mode"> buttons with data-value.
function buildSeg(el, values, labelFn) {
el.replaceChildren(
...values.map((v) => {
const b = document.createElement('button');
b.type = 'button';
b.dataset.value = String(v);
b.textContent = labelFn(v);
return b;
}),
);
}
sheet.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const seg = btn.closest('.seg');
if (seg && btn.dataset.value != null) {
const key = seg.dataset.setting;
let v = btn.dataset.value;
if (key === 'teams') v = Number(v);
if (key === 'muted') v = v === 'true';
if (key === 'haptics') v = v === 'true';
if (key === 'lang') setLang(v);
settings.set({ [key]: v });
return;
}
if (btn.dataset.step) {
const n = Math.max(1, Math.min(9, settings.get().pickN + Number(btn.dataset.step)));
settings.set({ pickN: n });
return;
}
if (btn.dataset.preset) {
const id = btn.dataset.preset;
settings.set(settings.get().stakeId === id ? { stakeId: null, stakeText: '' } : { stakeId: id, stakeText: '' });
return;
}
if (btn.id === 'stake-clear') settings.set({ stakeId: null, stakeText: '' });
});
stakeInput.addEventListener('input', () => settings.set({ stakeId: null, stakeText: stakeInput.value.slice(0, 60) }));
stakeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') stakeInput.blur();
});
function applyTranslations() {
document.title = t('appTitle');
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
stakeInput.placeholder = t('stakesPlaceholder');
buildSeg($('#seg-mode'), MODES, (m) => t(`mode.${m}`));
buildSeg($('#seg-teams'), [2, 3, 4], String);
buildSeg($('#seg-theme'), ['neon', 'pastel', 'contrast'], (v) => t(`theme.${v}`));
buildSeg($('#seg-lang'), LANGS, (v) => ({ en: 'English', nl: 'Nederlands' })[v]);
buildSeg($('#seg-sound'), [false, true], (v) => t(v ? 'off' : 'on'));
buildSeg($('#seg-haptics'), [true, false], (v) => t(v ? 'on' : 'off'));
const presets = $('#stake-presets');
presets.replaceChildren(
...Object.keys(t('stakePresets')).map((id) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'chip';
b.dataset.preset = id;
b.textContent = t(`stakePresets.${id}`);
return b;
}),
);
}
function syncSheet() {
const s = settings.get();
const lang = getLang();
const sel = { mode: s.mode, teams: String(s.teams), theme: s.theme, lang, muted: String(s.muted), haptics: String(s.haptics) };
for (const seg of sheet.querySelectorAll('.seg')) {
const cur = sel[seg.dataset.setting];
for (const b of seg.children) b.setAttribute('aria-pressed', String(b.dataset.value === cur));
}
$('#mode-desc').textContent = t(`modeDesc.${s.mode}`);
$('#row-pickn').hidden = s.mode !== 'pickN';
$('#row-teams').hidden = s.mode !== 'teams';
$('#row-haptics').hidden = !canVibrate;
$('#pickn-value').textContent = String(s.pickN);
for (const b of $('#stake-presets').children) b.setAttribute('aria-pressed', String(b.dataset.preset === s.stakeId));
if (document.activeElement !== stakeInput) stakeInput.value = s.stakeId ? '' : s.stakeText;
$('#stake-clear').hidden = !s.stakeId && !s.stakeText;
syncToolbar();
}
function syncToolbar() {
const s = settings.get();
btnSound.classList.toggle('is-muted', s.muted);
btnSound.setAttribute('aria-label', t(s.muted ? 'unmute' : 'mute'));
btnSound.setAttribute('aria-pressed', String(s.muted));
const fs = isFullscreen();
btnFull.classList.toggle('is-full', fs);
btnFull.setAttribute('aria-label', t(fs ? 'exitFullscreen' : 'fullscreen'));
}
settings.subscribe((s, patch) => {
if ('lang' in patch) {
setLang(s.lang || detectLang());
applyTranslations();
}
syncSheet();
});
applyTranslations();
syncSheet();
if (debug && new URLSearchParams(location.search).get('open') === 'settings') openSheet();
// ---- overlay messages ----
function modeSummary(s) {
let out = t(`mode.${s.mode}`);
if (s.mode === 'pickN') out += ` · ${s.pickN}`;
if (s.mode === 'teams') out += ` · ${s.teams}`;
const stake = stakeText(s);
if (stake && s.mode !== 'teams') out += ` · “${stake}”`;
return out;
}
function resultMessage(game, s) {
const stake = stakeText(s);
switch (game.plan?.mode) {
case 'teams':
return t('teamsReady');
case 'order':
return stake ? `#1: ${t('youStake', { stake })}` : t('orderDone');
case 'elimination':
return stake ? t('youStake', { stake }) : t('lastStanding');
default:
return stake ? t('youStake', { stake }) : t('youreIt');
}
}
function messages(game, now) {
const s = settings.get();
const count = game.activeRings().length;
switch (game.phase) {
case 'idle':
if (count === 0) return [t('hint'), showDesktopHint ? t('desktopHint') : modeSummary(s), 'hint'];
return [t('needMore'), modeSummary(s), 'hint'];
case 'gathering':
return [t('holdStill'), '', 'soft'];
case 'countdown':
return [String(TIMING.beats - Math.max(0, game.beat)), '', 'count'];
case 'chicken':
return [chickenMsg, '', 'alert'];
case 'elimPause': {
const out = game.lastOut;
const ring = out && game.rings.get(out.id);
if (!out) return ['', '', ''];
return [t(out.chickened ? 'chickenOut' : 'eliminated'), ring?.down ? t('liftFinger') : '', 'alert'];
}
case 'result': {
const sub = game.canRestart(now) ? t('tapRestart') : '';
return [resultMessage(game, s), sub, 'result'];
}
default:
return ['', '', ''];
}
}
return {
onEvent(type) {
if (type === 'chicken') chickenMsg = pick(t('chicken'));
},
update(game, now) {
const [main, sub, cls] = messages(game, now);
if (main !== shownMain || cls !== shownClass) {
msgMain.textContent = main;
msgMain.className = `msg ${cls}`;
// restart the entrance animation
void msgMain.offsetWidth;
msgMain.classList.add('in');
shownMain = main;
shownClass = cls;
}
if (sub !== shownSub) {
msgSub.textContent = sub;
shownSub = sub;
}
const showTools = (game.phase === 'idle' && !game.anyDown()) || (game.phase === 'result' && !game.anyDown());
if (showTools !== toolbarShown) {
toolbar.classList.toggle('hidden', !showTools);
toolbar.inert = !showTools;
toolbarShown = showTools;
}
if (debug) {
const fps = this._fps(now);
hud.textContent = `${game.phase} · ${game.activeRings().length} fingers · ${fps} fps`;
}
},
_last: 0,
_frames: 0,
_fpsVal: 0,
_fps(now) {
this._frames++;
if (now - this._last > 500) {
this._fpsVal = Math.round((this._frames * 1000) / (now - this._last));
this._frames = 0;
this._last = now;
}
return this._fpsVal;
},
};
}

20
manifest.webmanifest Normal file
View file

@ -0,0 +1,20 @@
{
"name": "Picker",
"short_name": "Picker",
"description": "Everyone puts a finger on the screen and Picker chooses one of you at random.",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone"],
"orientation": "any",
"background_color": "#05050b",
"theme_color": "#05050b",
"categories": ["games", "entertainment", "utilities"],
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
]
}

45
nginx.conf Normal file
View file

@ -0,0 +1,45 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Behind a reverse proxy: don't leak the internal port in redirects.
absolute_redirect off;
server_tokens off;
gzip on;
gzip_types text/css application/javascript application/manifest+json image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; worker-src 'self'; manifest-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# Always revalidate the entry points so updates roll out promptly.
location = /sw.js {
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
}
location = /index.html {
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; worker-src 'self'; manifest-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always;
}
location = /manifest.webmanifest {
default_type application/manifest+json;
add_header Cache-Control "no-cache" always;
}
location / {
add_header Cache-Control "public, max-age=3600" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri $uri/ =404;
}
}

83
sw.js Normal file
View file

@ -0,0 +1,83 @@
// Service worker: precache the app shell, serve network-first with a
// cache fallback so updates show up immediately when online and the app
// still works fully offline. Bump VERSION when the file list changes.
const VERSION = 'picker-v1';
const SHELL = [
'./',
'./index.html',
'./manifest.webmanifest',
'./css/app.css',
'./js/main.js',
'./js/game.js',
'./js/modes.js',
'./js/random.js',
'./js/renderer.js',
'./js/reveals.js',
'./js/input.js',
'./js/audio.js',
'./js/device.js',
'./js/settings.js',
'./js/i18n.js',
'./js/themes.js',
'./js/ui.js',
'./icons/icon.svg',
'./icons/icon-192.png',
'./icons/icon-512.png',
'./icons/icon-maskable-512.png',
'./icons/apple-touch-icon.png',
];
const NETWORK_TIMEOUT_MS = 2500;
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(VERSION)
.then((cache) => cache.addAll(SHELL.map((u) => new Request(u, { cache: 'reload' }))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET' || new URL(req.url).origin !== self.location.origin) return;
event.respondWith(networkFirst(req, event));
});
async function networkFirst(req, event) {
const cache = await caches.open(VERSION);
try {
const res = await withTimeout(fetch(req), NETWORK_TIMEOUT_MS);
if (res.ok) event.waitUntil(cache.put(req, res.clone()));
return res;
} catch {
// ignoreSearch so ?debug=1 etc. still resolve offline
const hit = (await cache.match(req, { ignoreSearch: true })) || (req.mode === 'navigate' && (await cache.match('./index.html')));
return hit || Response.error();
}
}
function withTimeout(promise, ms) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('timeout')), ms);
promise.then(
(v) => {
clearTimeout(t);
resolve(v);
},
(e) => {
clearTimeout(t);
reject(e);
},
);
});
}

291
tests/logic.html Normal file
View file

@ -0,0 +1,291 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Picker logic tests</title>
<style>body{font:14px monospace;background:#111;color:#ddd;padding:1rem}.pass{color:#6f6}.fail{color:#f66}</style>
</head>
<body>
<h1>Picker logic tests</h1>
<pre id="out">running…</pre>
<script type="module">
// Drives the state machine with simulated time. Open in a browser
// (served over http) or headless: results are written into #out.
import { Game, TIMING } from '../js/game.js';
import { planRound, makeHighlightSchedule } from '../js/modes.js';
import { randomInt } from '../js/random.js';
const results = [];
function test(name, fn) {
try {
fn();
results.push(['pass', name]);
} catch (e) {
results.push(['fail', `${name}: ${e.message}`]);
}
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'assertion failed');
}
function setup(settings = {}) {
const events = [];
const s = { mode: 'one', pickN: 2, teams: 2, ...settings };
const game = new Game({ emit: (type, data) => events.push({ type, data }), getSettings: () => s, isReduced: () => false });
let now = performance.now() + 10;
const h = {
game,
events,
get now() { return now; },
down(id, x = 100 + Math.random() * 200, y = 100 + Math.random() * 300) { game.fingerDown(id, x, y, now); },
up(id) { game.fingerUp(id, now); },
run(ms) {
const end = now + ms;
while (now < end) {
now = Math.min(end, now + 16);
game.update(now);
}
},
until(phase, max = 30000) {
const end = now + max;
while (game.phase !== phase && now < end) {
now += 16;
game.update(now);
}
assert(game.phase === phase, `expected phase ${phase}, got ${game.phase}`);
},
count: (type) => events.filter((e) => e.type === type).length,
};
return h;
}
test('pick one: full flow reaches a result with one valid winner', () => {
const h = setup();
['a', 'b', 'c'].forEach((id) => h.down(id));
h.run(100);
assert(h.game.phase === 'gathering', 'gathering');
h.run(TIMING.stableMs);
assert(h.game.phase === 'countdown', 'countdown after stable period, got ' + h.game.phase);
h.until('result');
assert(h.count('beat') === TIMING.beats, 'beats');
assert(h.count('tick') > 8, 'ticks');
assert(h.game.winners.length === 1 && ['a', 'b', 'c'].includes(h.game.winners[0]), 'winner');
const reveal = h.events.find((e) => e.type === 'reveal');
assert(['flood', 'confetti', 'spotlight', 'shockwave'].includes(reveal.data.style), 'style ' + reveal.data.style);
});
test('outcome is decided before the animation and the animation lands on it', () => {
for (let i = 0; i < 30; i++) {
const h = setup();
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('suspense');
const planned = h.game.plan.winners[0];
const last = h.game.schedule[h.game.schedule.length - 1];
assert(last.ids[0] === planned, 'schedule ends on planned winner');
assert(h.game.schedule[h.game.schedule.length - 2].ids[0] !== planned, 'penultimate step differs');
h.until('result');
assert(h.game.winners[0] === planned, 'revealed winner = planned');
}
});
test('one finger is not enough', () => {
const h = setup();
h.down('a');
h.run(6000);
assert(h.game.phase === 'idle', h.game.phase);
});
test('joining during the countdown resets to gathering without chicken', () => {
const h = setup();
h.down('a');
h.down('b');
h.until('countdown');
h.down('c');
assert(h.game.phase === 'gathering', h.game.phase);
assert(h.count('chicken') === 0, 'no chicken');
h.run(TIMING.stableMs - 200);
assert(h.game.phase === 'gathering', 'stable timer restarted');
h.until('result');
});
test('lifting during the countdown triggers chicken, then resumes', () => {
const h = setup();
['a', 'b', 'c'].forEach((id) => h.down(id));
h.until('countdown');
h.up('b');
assert(h.game.phase === 'chicken', h.game.phase);
assert(h.count('chicken') === 1, 'chicken event');
h.run(TIMING.chickenMs + 50);
assert(h.game.phase === 'gathering', 'back to gathering, got ' + h.game.phase);
h.until('result');
assert(['a', 'c'].includes(h.game.winners[0]), 'winner among remaining');
});
test('lifting during suspense aborts with chicken', () => {
const h = setup();
['a', 'b', 'c'].forEach((id) => h.down(id));
h.until('suspense');
h.up('a');
assert(h.game.phase === 'chicken' && h.game.plan === null, h.game.phase);
});
test('late joiners during suspense are spectators', () => {
const h = setup();
['a', 'b'].forEach((id) => h.down(id));
h.until('suspense');
h.down('late');
assert(h.game.rings.get('late').late, 'late flag');
h.until('result');
assert(h.game.winners[0] !== 'late', 'spectator never wins');
assert(!h.game.players.includes('late'), 'not a player');
});
test('result persists after lifting; restart only after grace and all fingers up', () => {
const h = setup();
['a', 'b'].forEach((id) => h.down(id));
h.until('result');
h.up('a');
h.run(3000);
assert(h.game.phase === 'result', 'still result while b holds');
h.down('x');
assert(h.game.phase === 'result' && h.game.rings.get('x').late, 'tap while someone holds is ignored');
h.up('x');
h.up('b');
assert(h.game.phase === 'result', 'stays after all lift');
assert(h.game.canRestart(h.now), 'can restart');
h.down('n1');
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'restarted');
assert(h.game.rings.size === 1 && !h.game.rings.get('n1').late, 'tapping finger joins the next round');
});
test('no instant restart right after the reveal', () => {
const h = setup();
['a', 'b'].forEach((id) => h.down(id));
h.until('result');
h.up('a');
h.up('b');
h.down('a2');
assert(h.game.phase === 'result', 'grace period protects the result');
});
test('pick N clamps to fingers - 1 and picks distinct winners', () => {
const h = setup({ mode: 'pickN', pickN: 5 });
['a', 'b', 'c'].forEach((id) => h.down(id));
h.until('result');
assert(h.game.winners.length === 2, 'clamped to 2, got ' + h.game.winners.length);
assert(new Set(h.game.winners).size === 2, 'distinct');
});
test('teams are balanced', () => {
for (let i = 0; i < 20; i++) {
const plan = planRound('teams', ['a', 'b', 'c', 'd', 'e', 'f', 'g'], { teams: 3 });
const sizes = [0, 0, 0];
for (const t of plan.teams.values()) sizes[t]++;
assert(Math.max(...sizes) - Math.min(...sizes) <= 1, 'sizes ' + sizes);
}
const small = planRound('teams', ['a', 'b'], { teams: 4 });
assert(small.teamCount === 2, 'team count capped by fingers');
});
test('teams mode reaches result', () => {
const h = setup({ mode: 'teams', teams: 2 });
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('result');
assert(h.game.revealStyle === 'teams', 'teams style');
});
test('order assigns 1..N exactly once, #1 first', () => {
const h = setup({ mode: 'order' });
['a', 'b', 'c', 'd', 'e'].forEach((id) => h.down(id));
h.until('suspense');
const first = h.game.plan.order[0];
h.until('result');
const nums = [...h.game.orderNums.values()].map((o) => o.n).sort();
assert(nums.join() === '1,2,3,4,5', nums.join());
assert(h.game.orderNums.get(first).n === 1, 'planned #1');
assert(h.count('orderStep') === 5, 'steps');
});
test('elimination knocks out N-1 and the planned survivor remains', () => {
const h = setup({ mode: 'elimination' });
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('suspense');
const order = h.game.plan.eliminationOrder.slice();
// Knocked-out players lift their finger, as asked.
h.game.emit = ((orig) => (type, data) => { orig(type, data); if (type === 'eliminate') h.game.fingerUp(data.id, h.now); })(h.game.emit);
h.until('result');
const outs = h.events.filter((e) => e.type === 'eliminate').map((e) => e.data.id);
assert(outs.join() === order.slice(0, 3).join(), `outs ${outs} vs ${order}`);
assert(h.game.winners[0] === order[3], 'survivor');
});
test('elimination: a remaining player who lets go is out', () => {
const h = setup({ mode: 'elimination' });
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
h.until('suspense');
const survivor = h.game.plan.eliminationOrder[3];
const coward = h.game.plan.eliminationOrder[2];
h.up(coward);
assert(h.game.phase === 'elimPause', h.game.phase);
assert(h.events.find((e) => e.type === 'eliminate').data.chickened, 'chickened');
h.until('result');
assert(h.game.winners[0] === survivor, 'survivor still wins');
assert(h.count('eliminate') === 3, 'three outs');
});
test('two players: highlight alternates and lands on the planned one', () => {
const rings = [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 100, y: 0 }];
for (const target of ['a', 'b']) {
const s = makeHighlightSchedule(rings, [target], 2700);
assert(s[s.length - 1].ids[0] === target, 'lands');
for (let i = 1; i < s.length; i++) assert(s[i].ids[0] !== s[i - 1].ids[0], 'alternates');
}
});
test('randomInt is uniform (chi-square, 5 buckets)', () => {
const N = 50000;
const k = 5;
const c = new Array(k).fill(0);
for (let i = 0; i < N; i++) c[randomInt(k)]++;
const e = N / k;
const chi = c.reduce((s, x) => s + ((x - e) ** 2) / e, 0);
assert(chi < 18.47, `chi² ${chi.toFixed(2)} (p<0.001 threshold 18.47) counts ${c}`);
});
test('pick one is fair across fingers', () => {
const ids = ['a', 'b', 'c'];
const c = { a: 0, b: 0, c: 0 };
for (let i = 0; i < 30000; i++) c[planRound('one', ids, {}).winners[0]]++;
for (const id of ids) assert(Math.abs(c[id] / 30000 - 1 / 3) < 0.015, JSON.stringify(c));
});
test('unknown pointer ids and double lifts are harmless', () => {
const h = setup();
h.up('nope');
h.down('a');
h.up('a');
h.up('a');
h.run(500);
assert(h.game.phase === 'idle', h.game.phase);
assert(h.game.rings.size === 0, 'pruned');
});
// Service worker: parses, and every precached file exists.
try {
const src = await (await fetch('../sw.js')).text();
new Function(src); // syntax check only, not executed
const shell = [...src.matchAll(/'(\.\/[^']*)'/g)].map((m) => m[1]);
const missing = [];
for (const u of shell) if (!(await fetch(new URL('../' + u, location.href), { cache: 'no-store' })).ok) missing.push(u);
results.push(missing.length ? ['fail', 'sw.js precache missing: ' + missing] : ['pass', `sw.js parses; all ${shell.length} precached files exist`]);
} catch (e) {
results.push(['fail', 'sw.js: ' + e.message]);
}
const out = document.getElementById('out');
const failed = results.filter((r) => r[0] === 'fail').length;
out.innerHTML = results.map(([cls, msg]) => `<span class="${cls}">${cls.toUpperCase()}</span> ${msg}`).join('\n') + `\n\n${results.length - failed}/${results.length} passed`;
document.title = failed ? `FAIL ${failed}` : 'ALL PASS';
</script>
</body>
</html>

57
tests/sim.html Normal file
View file

@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Picker frame simulator</title>
<link rel="stylesheet" href="../css/app.css">
<style>#info{position:fixed;left:8px;bottom:8px;font:12px monospace;color:#8f8;background:#000a;padding:4px 8px}</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="info"></div>
<script type="module">
// Renders a single frame of a simulated game at time ?t= (ms) without
// relying on requestAnimationFrame, so headless screenshots are deterministic.
// ?mode=one|pickN|teams|order|elimination &n=4 &t=9000 &theme=neon
// &style=flood|confetti|spotlight|shockwave|calm (force reveal style)
// &lift=ms (one finger lifts at this time)
import { Game } from '../js/game.js';
import { Renderer } from '../js/renderer.js';
import { getTheme } from '../js/themes.js';
const p = new URLSearchParams(location.search);
const settings = { mode: p.get('mode') || 'one', pickN: +(p.get('k') || 2), teams: +(p.get('teams') || 2) };
const n = +(p.get('n') || 4);
const T = +(p.get('t') || 0);
const lift = p.has('lift') ? +p.get('lift') : null;
const events = [];
const renderer = new Renderer(document.getElementById('stage'));
renderer.setTheme(getTheme(p.get('theme') || 'neon'));
const game = new Game({
emit: (type, data) => { events.push(type); renderer.onEvent(type, data || {}, now, game); },
getSettings: () => settings,
isReduced: () => p.has('reduced'),
});
if (p.get('style')) {
const orig = game.reveal.bind(game);
game.reveal = (t, w) => { orig(t, w); if (game.revealStyle !== 'teams') { game.revealStyle = p.get('style'); renderer.onEvent('reveal', { style: game.revealStyle, winners: w, plan: game.plan }, t, game); } };
}
const base = performance.now() + 10;
let now = base;
const w = innerWidth, h = innerHeight, rad = Math.min(w, h) * 0.32;
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
game.fingerDown('f' + i, w / 2 + Math.cos(a) * rad, h / 2 + Math.sin(a) * rad, now);
}
let lifted = false;
for (let t = 0; t <= T; t += 16) {
now = base + t;
if (lift != null && !lifted && t >= lift) { game.fingerUp('f0', now); lifted = true; }
game.update(now);
renderer.draw(game, now);
}
document.getElementById("info").textContent = `${innerWidth}x${innerHeight} c=${renderer.canvas.clientWidth}x${renderer.canvas.clientHeight} px=${renderer.canvas.width}x${renderer.canvas.height} dpr=${devicePixelRatio} t=${T} phase=${game.phase} style=${game.revealStyle} events=${[...new Set(events)].join(',')}`;
</script>
</body>
</html>

71
tools/make_icons.py Normal file
View file

@ -0,0 +1,71 @@
"""Generate the PNG app icons (stdlib only, no dependencies).
python tools/make_icons.py
Draws three glowing neon rings on a dark background, matching icons/icon.svg.
"""
import math
import os
import struct
import zlib
BG = (5, 5, 11)
# (cx, cy, r, colour) in a 0..1 unit square
RINGS = [
(0.34, 0.40, 0.17, (255, 46, 99)),
(0.66, 0.40, 0.17, (0, 229, 255)),
(0.50, 0.68, 0.17, (255, 229, 0)),
]
LINE = 0.032 # ring stroke width
GLOW = 0.05 # glow falloff
def render(size, scale=1.0):
"""scale < 1 shrinks the artwork towards the centre (maskable safe zone)."""
rows = []
for py in range(size):
row = bytearray([0]) # filter type 0
for px in range(size):
x = ((px + 0.5) / size - 0.5) / scale + 0.5
y = ((py + 0.5) / size - 0.5) / scale + 0.5
r, g, b = (c / 255 for c in BG)
for cx, cy, rr, col in RINGS:
d = abs(math.hypot(x - cx, y - cy) - rr)
aa = 1.0 / (size * scale)
core = min(1.0, max(0.0, (LINE / 2 - d) / aa + 0.5))
glow = math.exp(-max(0.0, d - LINE / 2) / GLOW) * 0.55
k = max(core, glow)
cr, cg, cb = (c / 255 for c in col)
# additive, like the neon theme
r, g, b = r + cr * k, g + cg * k, b + cb * k
if core > 0: # white-hot centre line
w = max(0.0, 1 - d / (LINE * 0.18)) * 0.7
r, g, b = r + w, g + w, b + w
row += bytes(min(255, int(v * 255 + 0.5)) for v in (r, g, b))
rows.append(bytes(row))
return b"".join(rows)
def write_png(path, size, raw):
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8-bit RGB
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
with open(path, "wb") as f:
f.write(png)
print("wrote", path)
def main():
out = os.path.join(os.path.dirname(__file__), "..", "icons")
os.makedirs(out, exist_ok=True)
write_png(os.path.join(out, "icon-192.png"), 192, render(192))
write_png(os.path.join(out, "icon-512.png"), 512, render(512))
write_png(os.path.join(out, "icon-maskable-512.png"), 512, render(512, scale=0.78))
write_png(os.path.join(out, "apple-touch-icon.png"), 180, render(180, scale=0.9))
if __name__ == "__main__":
main()