diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..36d2faa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.github +tests +tools +*.md +LICENSE +Dockerfile +.dockerignore diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..ae94342 --- /dev/null +++ b/.github/workflows/docker.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..290d7db --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index c75c726..f8de698 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,156 @@ -# picker +# Picker -A simple web app to select who pays the bill \ No newline at end of file +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://: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://: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//: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//: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`. diff --git a/css/app.css b/css/app.css new file mode 100644 index 0000000..4bdfaa9 --- /dev/null +++ b/css/app.css @@ -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; } +} diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png new file mode 100644 index 0000000..59e4912 Binary files /dev/null and b/icons/apple-touch-icon.png differ diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000..02f9e68 Binary files /dev/null and b/icons/icon-192.png differ diff --git a/icons/icon-512.png b/icons/icon-512.png new file mode 100644 index 0000000..505991e Binary files /dev/null and b/icons/icon-512.png differ diff --git a/icons/icon-maskable-512.png b/icons/icon-maskable-512.png new file mode 100644 index 0000000..cad649c Binary files /dev/null and b/icons/icon-maskable-512.png differ diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..d59d8aa --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..16f7cbf --- /dev/null +++ b/index.html @@ -0,0 +1,101 @@ + + + + + + + + + + + + + Picker + + + + + + + + + + +
+

+

+
+ + + + + + + + + diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..1b36fb6 --- /dev/null +++ b/js/audio.js @@ -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 }); + }, +}; diff --git a/js/device.js b/js/device.js new file mode 100644 index 0000000..54472aa --- /dev/null +++ b/js/device.js @@ -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); +} diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..63dd87a --- /dev/null +++ b/js/game.js @@ -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); + } +} diff --git a/js/i18n.js b/js/i18n.js new file mode 100644 index 0000000..e594521 --- /dev/null +++ b/js/i18n.js @@ -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; +} diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..d3e4bfe --- /dev/null +++ b/js/input.js @@ -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); +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..fca46d4 --- /dev/null +++ b/js/main.js @@ -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 }); diff --git a/js/modes.js b/js/modes.js new file mode 100644 index 0000000..e726a70 --- /dev/null +++ b/js/modes.js @@ -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 }; + }); +} diff --git a/js/random.js b/js/random.js new file mode 100644 index 0000000..5cfd9e3 --- /dev/null +++ b/js/random.js @@ -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)]; +} diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..5dde596 --- /dev/null +++ b/js/renderer.js @@ -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); + } +} diff --git a/js/reveals.js b/js/reveals.js new file mode 100644 index 0000000..fa4a4c5 --- /dev/null +++ b/js/reveals.js @@ -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; + }, + }; +} diff --git a/js/settings.js b/js/settings.js new file mode 100644 index 0000000..89c6dac --- /dev/null +++ b/js/settings.js @@ -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); + }, + }; +} diff --git a/js/themes.js b/js/themes.js new file mode 100644 index 0000000..9f7d1c5 --- /dev/null +++ b/js/themes.js @@ -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; +} diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..69d9100 --- /dev/null +++ b/js/ui.js @@ -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:
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; + }, + }; +} diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..9a09a99 --- /dev/null +++ b/manifest.webmanifest @@ -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" } + ] +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ee7f093 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..a2d4bea --- /dev/null +++ b/sw.js @@ -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); + }, + ); + }); +} diff --git a/tests/logic.html b/tests/logic.html new file mode 100644 index 0000000..f59d2c9 --- /dev/null +++ b/tests/logic.html @@ -0,0 +1,291 @@ + + + + +Picker logic tests + + + +

Picker logic tests

+
running…
+ + + diff --git a/tests/sim.html b/tests/sim.html new file mode 100644 index 0000000..518e8fd --- /dev/null +++ b/tests/sim.html @@ -0,0 +1,57 @@ + + + + + +Picker frame simulator + + + + + +
+ + + diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..64b2ef0 --- /dev/null +++ b/tools/make_icons.py @@ -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()