Compare commits
2 commits
ad890adcf1
...
35a4dc2dbf
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a4dc2dbf | |||
| ac33e2fec6 |
30 changed files with 3558 additions and 2 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.git
|
||||
.github
|
||||
tests
|
||||
tools
|
||||
*.md
|
||||
LICENSE
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
57
.github/workflows/docker.yml
vendored
Normal file
57
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# arm64 too, for Raspberry Pi style home servers
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Picker: static files served by unprivileged nginx on port 8080.
|
||||
FROM nginxinc/nginx-unprivileged:1.28-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html manifest.webmanifest sw.js /usr/share/nginx/html/
|
||||
COPY css /usr/share/nginx/html/css
|
||||
COPY js /usr/share/nginx/html/js
|
||||
COPY icons /usr/share/nginx/html/icons
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
|
||||
157
README.md
157
README.md
|
|
@ -1,3 +1,156 @@
|
|||
# picker
|
||||
# Picker
|
||||
|
||||
A simple web app to select who pays the bill
|
||||
A simple web app to select who pays the bills: everyone puts a finger on the
|
||||
phone screen, and after a suspenseful roulette one of you is chosen.
|
||||
|
||||
Plain HTML, CSS and vanilla JS modules. No build step and no dependencies.
|
||||
Works on any static host and installs as an offline PWA.
|
||||
|
||||
## Modes
|
||||
|
||||
- **Pick one**: one finger is chosen.
|
||||
- **Pick several**: N fingers are chosen.
|
||||
- **Teams**: everyone is split into 2–4 balanced teams.
|
||||
- **Order**: everyone gets a number, revealed one at a time.
|
||||
- **Elimination**: one finger is knocked out per round until one remains.
|
||||
Letting go early counts as being knocked out.
|
||||
|
||||
Settings (gear button on the idle screen) also cover the stakes text
|
||||
("pay the bill" → "You pay the bill!"), theme (neon, pastel, or high
|
||||
contrast with a distinct shape per finger), language (English/Dutch), sound
|
||||
and vibration. Settings are saved in `localStorage`.
|
||||
|
||||
## How a round works
|
||||
|
||||
1. Put two or more fingers down. Once the set of fingers has been stable for
|
||||
2.5 s, a three-beat countdown starts. Adding a finger restarts the wait.
|
||||
Lifting one shows a "chickened out" message.
|
||||
2. The outcome is drawn with `crypto.getRandomValues` *before* the animation
|
||||
starts. The roulette is only presentation and always lands on the
|
||||
pre-drawn result.
|
||||
3. The result stays on screen after fingers lift. Once everyone has let go,
|
||||
tap to play again (the tapping finger joins the next round).
|
||||
|
||||
## Project layout
|
||||
|
||||
| File | What |
|
||||
| --- | --- |
|
||||
| `js/main.js` | wiring + frame loop |
|
||||
| `js/input.js` | Pointer Events per `pointerId`, gesture blocking, debug fake fingers |
|
||||
| `js/game.js` | state machine (idle → gathering → countdown → suspense → result) |
|
||||
| `js/modes.js` | outcome planning and roulette schedules for each mode |
|
||||
| `js/random.js` | unbiased crypto randomness |
|
||||
| `js/renderer.js` | canvas rendering (pre-rendered glow sprites) |
|
||||
| `js/reveals.js` | reveal animations: flood, confetti, spotlight, shockwave, calm |
|
||||
| `js/audio.js` | Web Audio sound effects + vibration |
|
||||
| `js/ui.js` | overlay text, toolbar, settings sheet |
|
||||
| `js/settings.js` | persisted settings |
|
||||
| `js/i18n.js` | all strings (en, nl) |
|
||||
| `js/themes.js` | colour themes |
|
||||
| `js/device.js` | wake lock, fullscreen |
|
||||
| `sw.js` | offline caching (network-first, cache fallback) |
|
||||
| `tools/make_icons.py` | regenerates the PNG icons (Python stdlib only) |
|
||||
|
||||
## Testing on a desktop
|
||||
|
||||
Open `http://localhost:8000/?debug=1`:
|
||||
|
||||
- **Click** to place a fake finger, **click it again** to remove it, **drag** to move it.
|
||||
- **A** adds a fake finger at a random spot, **C** clears them all.
|
||||
- `?debug=1&fingers=4` starts with 4 fake fingers in a circle.
|
||||
- `?debug=1&open=settings` opens the settings sheet on load.
|
||||
- A HUD shows the phase, finger count and fps. The service worker is not
|
||||
registered in debug mode, so you always get fresh files.
|
||||
|
||||
Automated checks (serve the folder, then open in any browser):
|
||||
|
||||
- `tests/logic.html`: state machine and fairness tests.
|
||||
- `tests/sim.html?mode=order&n=5&t=11500`: renders one simulated frame at a
|
||||
given time (`mode`, `n`, `t`, `theme`, `style`, `k`, `teams`, `reduced`).
|
||||
|
||||
## Testing on your phone (local network)
|
||||
|
||||
Serve the folder from your PC:
|
||||
|
||||
```sh
|
||||
python -m http.server 8000 --bind 0.0.0.0
|
||||
```
|
||||
|
||||
Find your PC's LAN IP (`ipconfig` on Windows, `ip addr` on Linux), then open
|
||||
`http://<that-ip>:8000/` on the phone. On Windows, allow Python through the
|
||||
firewall for private networks when asked.
|
||||
|
||||
Over plain `http://` on a LAN address, the page is **not a secure context**.
|
||||
These features need HTTPS (or `localhost`):
|
||||
|
||||
| Feature | Plain http on LAN |
|
||||
| --- | --- |
|
||||
| Touch, canvas, crypto randomness, sound, vibration, fullscreen | works |
|
||||
| Service worker / offline mode | **needs HTTPS** |
|
||||
| Install as app (PWA) | **needs HTTPS** |
|
||||
| Screen wake lock | **needs HTTPS** |
|
||||
|
||||
To test the secure-only features:
|
||||
|
||||
- **Android + USB (easiest):** enable USB debugging, open `chrome://inspect`
|
||||
in desktop Chrome or Edge, click *Port forwarding* and map `8000` →
|
||||
`localhost:8000`. Then open `http://localhost:8000/` on the phone.
|
||||
`localhost` counts as secure, so everything works.
|
||||
- **Android without USB:** on the phone, open
|
||||
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` and add
|
||||
`http://<your-ip>:8000`. Use this for testing only.
|
||||
- **Anywhere, iOS included:** put it behind HTTPS (see Docker below, behind
|
||||
your reverse proxy).
|
||||
|
||||
Browser notes:
|
||||
|
||||
- Browsers only allow sound and vibration after a tap or a finger *lift*.
|
||||
A finger going down doesn't count. If the very first round after opening
|
||||
is silent, tap the screen once (or use any toolbar button) first.
|
||||
- iOS Safari has no vibration API and no fullscreen API on iPhone, so those
|
||||
controls are hidden there. Add it to the home screen for a full-screen
|
||||
experience.
|
||||
- Devices cap simultaneous touches (iPhone: 5, most Android phones: 10).
|
||||
The app handles whatever arrives.
|
||||
|
||||
## Docker
|
||||
|
||||
The image is plain unprivileged nginx serving the static files on port
|
||||
**8080**, with a `/healthz` endpoint.
|
||||
|
||||
```sh
|
||||
docker build -t picker .
|
||||
docker run --rm -p 8080:8080 picker
|
||||
```
|
||||
|
||||
On every push to `main`, the GitHub Actions workflow
|
||||
(`.github/workflows/docker.yml`) publishes a multi-arch (amd64 + arm64)
|
||||
image to GHCR:
|
||||
|
||||
- `ghcr.io/<owner>/<repo>:latest` on `main`
|
||||
- `:1.2.3` and `:1.2` when you push a `v1.2.3` tag
|
||||
- `:sha-abc1234` on every build
|
||||
|
||||
Example `docker-compose.yml` for a home server:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
picker:
|
||||
image: ghcr.io/<owner>/<repo>:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
```
|
||||
|
||||
Point your reverse proxy (Caddy, Traefik, nginx proxy manager, …) at port
|
||||
8080 for your subdomain and terminate HTTPS there. HTTPS is required for
|
||||
offline mode, installing and wake lock. All paths are relative, so it also
|
||||
works under a sub-path.
|
||||
|
||||
GHCR packages are private by default. Either make the package public
|
||||
(GitHub → your profile → Packages → picker → Package settings), or run
|
||||
`docker login ghcr.io` on the server with a token that has `read:packages`.
|
||||
|
||||
## Icons
|
||||
|
||||
`icons/*.png` are generated: `python tools/make_icons.py`.
|
||||
|
|
|
|||
272
css/app.css
Normal file
272
css/app.css
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
: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;
|
||||
white-space: pre-line;
|
||||
color: var(--fg);
|
||||
opacity: 0.75;
|
||||
font-size: clamp(0.95rem, 4vw, 1.1rem);
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
@keyframes msg-in {
|
||||
from { transform: scale(0.7); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---- toolbar: top centre, away from the side-edge back gesture ---- */
|
||||
|
||||
#toolbar {
|
||||
z-index: 2;
|
||||
position: fixed;
|
||||
top: calc(var(--safe-t) + 14px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
transition: opacity 200ms, transform 200ms;
|
||||
}
|
||||
#toolbar.hidden { opacity: 0; transform: translate(-50%, -8px); pointer-events: none; }
|
||||
|
||||
.tool {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--fg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.tool:active { background: rgba(255, 255, 255, 0.16); }
|
||||
.tool:focus-visible, .sheet-body button:focus-visible, .sheet-close:focus-visible, #stake-input:focus-visible {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.tool svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.tool .off { display: none; }
|
||||
#btn-sound.is-muted .on, #btn-fullscreen.is-full .on { display: none; }
|
||||
#btn-sound.is-muted .off, #btn-fullscreen.is-full .off { display: inline; }
|
||||
|
||||
/* ---- settings sheet ---- */
|
||||
|
||||
#sheet-backdrop {
|
||||
z-index: 3;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms;
|
||||
}
|
||||
.sheet-open #sheet-backdrop { opacity: 1; }
|
||||
|
||||
#sheet {
|
||||
z-index: 4;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 0 auto;
|
||||
max-width: 520px;
|
||||
max-height: calc(100% - var(--safe-t) - 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
border-radius: 22px 22px 0 0;
|
||||
box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.5);
|
||||
transform: translateY(100%);
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
.sheet-open #sheet { transform: none; }
|
||||
|
||||
.sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px 8px;
|
||||
}
|
||||
.sheet-head h2 { margin: 0; font-size: 1.15rem; }
|
||||
.sheet-close {
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
min-height: 40px;
|
||||
padding: 0 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet-body {
|
||||
overflow-y: auto;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
padding: 4px 24px calc(var(--safe-b) + 20px);
|
||||
}
|
||||
|
||||
.field { padding: 12px 0; border-top: 1px solid var(--line); }
|
||||
.field:first-child { border-top: 0; }
|
||||
.field h3 { margin: 0 0 10px; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
||||
.field.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.field.row h3 { margin: 0; }
|
||||
.field[hidden] { display: none; }
|
||||
.desc { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; }
|
||||
|
||||
.seg { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.seg button, .chip, .stepper button {
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.seg button[aria-pressed="true"], .chip[aria-pressed="true"] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stepper { display: flex; align-items: center; gap: 6px; }
|
||||
.stepper button { width: 44px; padding: 0; font-size: 1.3rem; }
|
||||
.stepper output { min-width: 2ch; text-align: center; font-size: 1.2rem; font-weight: 700; }
|
||||
|
||||
.stake-row { display: flex; gap: 8px; }
|
||||
#stake-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
font: inherit;
|
||||
font-size: 1rem; /* ≥16px stops iOS zooming on focus */
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
#stake-input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.chip { min-height: 38px; font-size: 0.9rem; }
|
||||
|
||||
[data-theme="contrast"] .seg button,
|
||||
[data-theme="contrast"] .chip,
|
||||
[data-theme="contrast"] .stepper button,
|
||||
[data-theme="contrast"] #stake-input { border-width: 2px; background: #000; }
|
||||
|
||||
/* ---- debug ---- */
|
||||
|
||||
#debug-hud {
|
||||
z-index: 5;
|
||||
position: fixed;
|
||||
bottom: calc(var(--safe-b) + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font: 12px/1.4 ui-monospace, monospace;
|
||||
color: #8f8;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.msg.in { animation-duration: 1ms; }
|
||||
#sheet, #toolbar, #sheet-backdrop { transition-duration: 1ms; }
|
||||
}
|
||||
BIN
icons/apple-touch-icon.png
Normal file
BIN
icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
icons/icon-192.png
Normal file
BIN
icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
icons/icon-512.png
Normal file
BIN
icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
BIN
icons/icon-maskable-512.png
Normal file
BIN
icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
14
icons/icon.svg
Normal file
14
icons/icon.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<filter id="g" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.5" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="22" fill="#05050b"/>
|
||||
<g fill="none" stroke-width="3.2" filter="url(#g)">
|
||||
<circle cx="34" cy="40" r="17" stroke="#ff2e63"/>
|
||||
<circle cx="66" cy="40" r="17" stroke="#00e5ff"/>
|
||||
<circle cx="50" cy="68" r="17" stroke="#ffe500"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
101
index.html
Normal file
101
index.html
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#05050b">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="description" content="Everyone puts a finger on the screen and Picker chooses one of you at random.">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Picker">
|
||||
<title>Picker</title>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="icons/icon-192.png" type="image/png" sizes="192x192">
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="stage" aria-hidden="true"></canvas>
|
||||
|
||||
<div id="overlay" aria-live="polite">
|
||||
<p id="msg-main" class="msg"></p>
|
||||
<p id="msg-sub" class="msg-sub"></p>
|
||||
</div>
|
||||
|
||||
<nav id="toolbar" class="hidden">
|
||||
<button id="btn-settings" class="tool" type="button" data-i18n-aria="settings">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg>
|
||||
</button>
|
||||
<button id="btn-sound" class="tool" type="button" data-i18n-aria="mute">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z"/><path class="on" d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11"/><path class="off" d="M16 9.5l5 5M21 9.5l-5 5"/></svg>
|
||||
</button>
|
||||
<button id="btn-fullscreen" class="tool" type="button" data-i18n-aria="fullscreen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path class="on" d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/><path class="off" d="M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"/></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div id="sheet-backdrop" hidden></div>
|
||||
<section id="sheet" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="sheet-title" hidden>
|
||||
<header class="sheet-head">
|
||||
<h2 id="sheet-title" data-i18n="settings"></h2>
|
||||
<button class="sheet-close" type="button" data-i18n="done"></button>
|
||||
</header>
|
||||
<div class="sheet-body">
|
||||
<div class="field">
|
||||
<h3 data-i18n="modeLabel"></h3>
|
||||
<div id="seg-mode" class="seg seg-wrap" data-setting="mode"></div>
|
||||
<p id="mode-desc" class="desc"></p>
|
||||
</div>
|
||||
|
||||
<div id="row-pickn" class="field row" hidden>
|
||||
<h3 data-i18n="pickNLabel"></h3>
|
||||
<div class="stepper">
|
||||
<button type="button" data-step="-1" data-i18n-aria="decrease">−</button>
|
||||
<output id="pickn-value">2</output>
|
||||
<button type="button" data-step="1" data-i18n-aria="increase">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="row-teams" class="field row" hidden>
|
||||
<h3 data-i18n="teamsLabel"></h3>
|
||||
<div id="seg-teams" class="seg" data-setting="teams"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<h3><label for="stake-input" data-i18n="stakesLabel"></label></h3>
|
||||
<div class="stake-row">
|
||||
<input id="stake-input" type="text" maxlength="60" autocomplete="off" enterkeyhint="done">
|
||||
<button id="stake-clear" type="button" class="chip" data-i18n="stakesClear" hidden></button>
|
||||
</div>
|
||||
<div id="stake-presets" class="chips"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<h3 data-i18n="themeLabel"></h3>
|
||||
<div id="seg-theme" class="seg" data-setting="theme"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<h3 data-i18n="languageLabel"></h3>
|
||||
<div id="seg-lang" class="seg" data-setting="lang"></div>
|
||||
</div>
|
||||
|
||||
<div class="field row">
|
||||
<h3 data-i18n="soundLabel"></h3>
|
||||
<div id="seg-sound" class="seg" data-setting="muted"></div>
|
||||
</div>
|
||||
|
||||
<div id="row-haptics" class="field row">
|
||||
<h3 data-i18n="hapticsLabel"></h3>
|
||||
<div id="seg-haptics" class="seg" data-setting="haptics"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="debug-hud" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
166
js/audio.js
Normal file
166
js/audio.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// 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(() => {});
|
||||
// Older iOS Safari only unlocks once a sound starts inside the gesture.
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
|
||||
src.connect(ctx.destination);
|
||||
src.start();
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
/** False until the browser has let us start audio (needs a tap, not just a touch down). */
|
||||
export const audioUnlocked = () => !!ctx && ctx.state === 'running';
|
||||
|
||||
const live = () => audioUnlocked() && !muted;
|
||||
|
||||
function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) {
|
||||
const t0 = ctx.currentTime + at;
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.type = type;
|
||||
o.frequency.setValueAtTime(freq, t0);
|
||||
if (freqEnd) o.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(gain, t0 + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
let node = o;
|
||||
if (filter) {
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = 'lowpass';
|
||||
f.frequency.value = filter;
|
||||
o.connect(f);
|
||||
node = f;
|
||||
}
|
||||
node.connect(g);
|
||||
g.connect(master);
|
||||
o.start(t0);
|
||||
o.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
function noise({ at = 0, dur, gain = 0.2, type = 'bandpass', freq = 1000, freqEnd, q = 1 }) {
|
||||
const t0 = ctx.currentTime + at;
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = noiseBuf;
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = type;
|
||||
f.Q.value = q;
|
||||
f.frequency.setValueAtTime(freq, t0);
|
||||
if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
src.connect(f);
|
||||
f.connect(g);
|
||||
g.connect(master);
|
||||
src.start(t0);
|
||||
src.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24];
|
||||
const note = (base, semis) => base * Math.pow(2, semis / 12);
|
||||
|
||||
export const sfx = {
|
||||
join(i) {
|
||||
if (!live()) return;
|
||||
tone({ freq: note(523, PENTA[i % PENTA.length]), dur: 0.14, gain: 0.12 });
|
||||
},
|
||||
beat(i) {
|
||||
if (!live()) return;
|
||||
tone({ type: 'triangle', freq: note(392, i * 5), dur: 0.18, gain: 0.25 });
|
||||
},
|
||||
/** Roulette tick, rising in pitch as the wheel slows. */
|
||||
tick(progress, final) {
|
||||
if (!live()) return;
|
||||
if (final) {
|
||||
tone({ type: 'square', freq: 1320, dur: 0.12, gain: 0.08, filter: 4000 });
|
||||
return;
|
||||
}
|
||||
tone({ type: 'square', freq: 420 + progress * 900, dur: 0.035, gain: 0.07, filter: 3500 });
|
||||
noise({ dur: 0.02, gain: 0.05, type: 'highpass', freq: 3000 });
|
||||
},
|
||||
reveal() {
|
||||
if (!live()) return;
|
||||
// whoosh + boom + bright major arpeggio
|
||||
noise({ dur: 0.5, gain: 0.25, type: 'bandpass', freq: 400, freqEnd: 4000, q: 0.8 });
|
||||
tone({ freq: 140, freqEnd: 45, dur: 0.6, gain: 0.5 });
|
||||
[0, 4, 7, 12, 16].forEach((s, i) => {
|
||||
tone({ type: 'triangle', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.9, gain: 0.16 });
|
||||
tone({ type: 'sawtooth', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.7, gain: 0.04, filter: 2200 });
|
||||
});
|
||||
},
|
||||
crack() {
|
||||
if (!live()) return;
|
||||
noise({ dur: 0.3, gain: 0.45, type: 'highpass', freq: 1800 });
|
||||
noise({ at: 0.04, dur: 0.18, gain: 0.25, type: 'bandpass', freq: 5000, q: 4 });
|
||||
tone({ freq: 220, freqEnd: 55, dur: 0.35, gain: 0.35 });
|
||||
tone({ type: 'triangle', freq: 2637, at: 0.02, dur: 0.25, gain: 0.05 });
|
||||
tone({ type: 'triangle', freq: 3520, at: 0.07, dur: 0.2, gain: 0.04 });
|
||||
},
|
||||
/** Sad trombone. */
|
||||
chicken() {
|
||||
if (!live()) return;
|
||||
const parts = [[392, 370, 0], [370, 349, 0.28], [349, 330, 0.56]];
|
||||
for (const [a, b, at] of parts) tone({ type: 'sawtooth', freq: a, freqEnd: b, at, dur: 0.26, gain: 0.12, filter: 1200, attack: 0.03 });
|
||||
tone({ type: 'sawtooth', freq: 330, freqEnd: 247, at: 0.84, dur: 0.75, gain: 0.12, filter: 1000, attack: 0.03 });
|
||||
},
|
||||
pop(n) {
|
||||
if (!live()) return;
|
||||
tone({ freq: note(660, PENTA[(n - 1) % PENTA.length]), freqEnd: note(330, PENTA[(n - 1) % PENTA.length]), dur: 0.14, gain: 0.25 });
|
||||
},
|
||||
};
|
||||
65
js/device.js
Normal file
65
js/device.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Screen wake lock and fullscreen helpers.
|
||||
|
||||
export function createWakeLock() {
|
||||
let sentinel = null;
|
||||
let pending = false;
|
||||
let wanted = false;
|
||||
|
||||
async function acquire() {
|
||||
if (!('wakeLock' in navigator) || sentinel || pending || document.visibilityState !== 'visible') return;
|
||||
pending = true;
|
||||
try {
|
||||
sentinel = await navigator.wakeLock.request('screen');
|
||||
sentinel.addEventListener('release', () => {
|
||||
sentinel = null;
|
||||
});
|
||||
if (!wanted) release();
|
||||
} catch {
|
||||
/* denied, insecure context or low battery: no big deal */
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function release() {
|
||||
const s = sentinel;
|
||||
sentinel = null;
|
||||
s?.release().catch(() => {});
|
||||
}
|
||||
|
||||
// The lock is dropped automatically when the page is hidden.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (wanted && document.visibilityState === 'visible') acquire();
|
||||
});
|
||||
|
||||
return {
|
||||
set(on) {
|
||||
if (on === wanted) return;
|
||||
wanted = on;
|
||||
if (on) acquire();
|
||||
else release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const fullscreenSupported = () => !!(document.fullscreenEnabled || document.webkitFullscreenEnabled);
|
||||
export const isFullscreen = () => !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||
|
||||
export async function toggleFullscreen() {
|
||||
try {
|
||||
if (isFullscreen()) {
|
||||
await (document.exitFullscreen || document.webkitExitFullscreen).call(document);
|
||||
} else {
|
||||
const el = document.documentElement;
|
||||
if (el.requestFullscreen) await el.requestFullscreen({ navigationUI: 'hide' });
|
||||
else el.webkitRequestFullscreen?.();
|
||||
}
|
||||
} catch {
|
||||
/* user agent refused */
|
||||
}
|
||||
}
|
||||
|
||||
export function onFullscreenChange(fn) {
|
||||
document.addEventListener('fullscreenchange', fn);
|
||||
document.addEventListener('webkitfullscreenchange', fn);
|
||||
}
|
||||
369
js/game.js
Normal file
369
js/game.js
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
// Game state machine. Pure logic + timing; no DOM, no drawing.
|
||||
//
|
||||
// idle ──(≥2 fingers)──▶ gathering ──(stable)──▶ countdown ──▶ suspense
|
||||
// ▲ ▲ │ join │ lift ▶ chicken ─┘
|
||||
// │ └────┴─────────────────┘
|
||||
// suspense ──▶ result (one, pickN, teams)
|
||||
// suspense ──▶ ordering ──▶ result (order)
|
||||
// suspense ──▶ elimPause ──▶ suspense … ──▶ result (elimination)
|
||||
|
||||
import { planRound, makeHighlightSchedule, makeTeamSchedule } from './modes.js';
|
||||
import { randomInt } from './random.js';
|
||||
|
||||
export const TIMING = {
|
||||
stableMs: 2500, // finger set must be unchanged this long before the countdown
|
||||
beatMs: 520, // countdown beat
|
||||
beats: 3,
|
||||
suspenseMs: 2700,
|
||||
elimSuspenseMs: 1800,
|
||||
settleMs: 650, // pause on the final highlight before the reveal
|
||||
chickenMs: 1700,
|
||||
elimPauseMs: 1500,
|
||||
orderStepMs: 650,
|
||||
restartGraceMs: 1200, // ignore taps right after a reveal
|
||||
ringFadeMs: 350,
|
||||
};
|
||||
|
||||
export const REVEAL_STYLES = ['flood', 'confetti', 'spotlight', 'shockwave'];
|
||||
|
||||
// Phases where the participants are locked in; new fingers are spectators.
|
||||
const LOCKED = new Set(['suspense', 'elimPause', 'ordering', 'result']);
|
||||
|
||||
export class Game {
|
||||
/**
|
||||
* @param {{emit:(type:string,data?:object)=>void, getSettings:()=>object, isReduced:()=>boolean}} opts
|
||||
*/
|
||||
constructor({ emit, getSettings, isReduced }) {
|
||||
this.emit = emit;
|
||||
this.getSettings = getSettings;
|
||||
this.isReduced = isReduced;
|
||||
this.rings = new Map();
|
||||
this.lastRevealStyle = null;
|
||||
this.reset(performance.now());
|
||||
}
|
||||
|
||||
reset(now) {
|
||||
this.rings.clear();
|
||||
this.phase = 'idle';
|
||||
this.phaseAt = now;
|
||||
this.lastChangeAt = now;
|
||||
this.plan = null;
|
||||
this.players = [];
|
||||
this.schedule = null;
|
||||
this.stepIndex = -1;
|
||||
this.stepAt = 0;
|
||||
this.highlight = null; // Set of highlighted ids
|
||||
this.displayTeams = null; // Map id -> team shown during team suspense
|
||||
this.beat = -1;
|
||||
this.revealAt = 0;
|
||||
this.revealStyle = null;
|
||||
this.winners = [];
|
||||
this.orderNums = new Map(); // id -> {n, at}
|
||||
this.orderIdx = 0;
|
||||
this.nextOrderAt = 0;
|
||||
this.remaining = [];
|
||||
this.lastOut = null; // {id, chickened}
|
||||
this.chickenUntil = 0;
|
||||
this.pauseUntil = 0;
|
||||
}
|
||||
|
||||
// ---- queries -----------------------------------------------------------
|
||||
|
||||
/** Fingers currently down that count as players (not spectators). */
|
||||
activeRings() {
|
||||
const out = [];
|
||||
for (const r of this.rings.values()) if (r.down && !r.goneAt && !r.late) out.push(r);
|
||||
return out;
|
||||
}
|
||||
|
||||
anyDown() {
|
||||
for (const r of this.rings.values()) if (r.down) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
canRestart(now) {
|
||||
return this.phase === 'result' && now - this.revealAt > TIMING.restartGraceMs && !this.anyDown();
|
||||
}
|
||||
|
||||
/** 0..1 progress of the stability wait. */
|
||||
stableProgress(now) {
|
||||
return this.phase === 'gathering' ? Math.min(1, (now - this.lastChangeAt) / TIMING.stableMs) : 0;
|
||||
}
|
||||
|
||||
// ---- input -------------------------------------------------------------
|
||||
|
||||
fingerDown(id, x, y, now) {
|
||||
if (this.rings.has(id)) return;
|
||||
if (this.phase === 'result' && this.canRestart(now)) {
|
||||
this.reset(now);
|
||||
this.emit('restart');
|
||||
}
|
||||
const late = LOCKED.has(this.phase);
|
||||
const ring = { id, x, y, bornAt: now, down: true, goneAt: 0, late, colorIdx: late ? -1 : this.freeColor(), outAt: 0 };
|
||||
this.rings.set(id, ring);
|
||||
if (late) return;
|
||||
|
||||
this.lastChangeAt = now;
|
||||
this.emit('join', { ring });
|
||||
if (this.phase === 'countdown') this.setPhase('gathering', now);
|
||||
}
|
||||
|
||||
fingerMove(id, x, y) {
|
||||
const r = this.rings.get(id);
|
||||
if (r && r.down) {
|
||||
r.x = x;
|
||||
r.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
fingerUp(id, now) {
|
||||
const r = this.rings.get(id);
|
||||
if (!r || !r.down) return;
|
||||
r.down = false;
|
||||
r.upAt = now;
|
||||
if (r.late) {
|
||||
r.goneAt = now;
|
||||
return;
|
||||
}
|
||||
switch (this.phase) {
|
||||
case 'idle':
|
||||
case 'gathering':
|
||||
case 'chicken':
|
||||
r.goneAt = now;
|
||||
this.lastChangeAt = now;
|
||||
this.emit('leave', { ring: r });
|
||||
break;
|
||||
case 'countdown':
|
||||
r.goneAt = now;
|
||||
this.chicken(now);
|
||||
break;
|
||||
case 'suspense':
|
||||
if (this.plan.mode === 'elimination') {
|
||||
this.elimChicken(r, now);
|
||||
} else {
|
||||
r.goneAt = now;
|
||||
this.chicken(now);
|
||||
}
|
||||
break;
|
||||
case 'elimPause':
|
||||
this.elimChicken(r, now);
|
||||
break;
|
||||
default:
|
||||
// ordering / result: the outcome is fixed, the ring just freezes in place.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Page hidden etc.: drop every touch without drama. */
|
||||
cancelAll(now) {
|
||||
if (this.phase === 'result' || this.phase === 'ordering') {
|
||||
for (const r of this.rings.values()) r.down = false;
|
||||
} else {
|
||||
this.reset(now);
|
||||
this.emit('phase', { phase: 'idle' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- update ------------------------------------------------------------
|
||||
|
||||
update(now) {
|
||||
for (const [id, r] of this.rings) {
|
||||
if (r.goneAt && now - r.goneAt > TIMING.ringFadeMs) this.rings.delete(id);
|
||||
}
|
||||
|
||||
switch (this.phase) {
|
||||
case 'idle':
|
||||
if (this.activeRings().length >= 2) this.setPhase('gathering', now);
|
||||
break;
|
||||
case 'gathering':
|
||||
if (this.activeRings().length < 2) this.setPhase('idle', now);
|
||||
else if (now - this.lastChangeAt >= TIMING.stableMs) {
|
||||
this.beat = -1;
|
||||
this.setPhase('countdown', now);
|
||||
}
|
||||
break;
|
||||
case 'countdown': {
|
||||
const b = Math.floor((now - this.phaseAt) / TIMING.beatMs);
|
||||
if (b >= TIMING.beats) this.startRound(now);
|
||||
else if (b !== this.beat) {
|
||||
this.beat = b;
|
||||
this.emit('beat', { index: b, remaining: TIMING.beats - b });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'chicken':
|
||||
if (now >= this.chickenUntil) {
|
||||
this.lastChangeAt = now;
|
||||
this.setPhase(this.activeRings().length >= 2 ? 'gathering' : 'idle', now);
|
||||
}
|
||||
break;
|
||||
case 'suspense':
|
||||
this.advanceSchedule(now);
|
||||
break;
|
||||
case 'elimPause':
|
||||
if (now >= this.pauseUntil) {
|
||||
if (this.remaining.length <= 1) this.reveal(now, this.remaining);
|
||||
else this.startElimSuspense(now, TIMING.elimSuspenseMs);
|
||||
}
|
||||
break;
|
||||
case 'ordering':
|
||||
if (now >= this.nextOrderAt) {
|
||||
if (this.orderIdx >= this.plan.order.length) this.reveal(now, [this.plan.order[0]]);
|
||||
else this.assignNextOrder(now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transitions -------------------------------------------------------
|
||||
|
||||
setPhase(phase, now) {
|
||||
this.phase = phase;
|
||||
this.phaseAt = now;
|
||||
this.emit('phase', { phase });
|
||||
}
|
||||
|
||||
freeColor() {
|
||||
const used = new Set();
|
||||
for (const r of this.rings.values()) if (!r.goneAt && r.colorIdx >= 0) used.add(r.colorIdx);
|
||||
let i = 0;
|
||||
while (used.has(i)) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
chicken(now) {
|
||||
this.plan = null;
|
||||
this.schedule = null;
|
||||
this.highlight = null;
|
||||
this.displayTeams = null;
|
||||
// Spectators who are still holding on become players for the next try.
|
||||
for (const r of this.rings.values()) {
|
||||
if (r.late && r.down) {
|
||||
r.late = false;
|
||||
r.colorIdx = this.freeColor();
|
||||
r.bornAt = now;
|
||||
}
|
||||
}
|
||||
this.chickenUntil = now + TIMING.chickenMs;
|
||||
this.setPhase('chicken', now);
|
||||
this.emit('chicken');
|
||||
}
|
||||
|
||||
startRound(now) {
|
||||
const rings = this.activeRings();
|
||||
const ids = rings.map((r) => r.id);
|
||||
const settings = this.getSettings();
|
||||
const reduced = this.isReduced();
|
||||
this.players = ids;
|
||||
this.plan = planRound(settings.mode, ids, settings);
|
||||
|
||||
switch (this.plan.mode) {
|
||||
case 'elimination':
|
||||
this.remaining = ids.slice();
|
||||
this.startElimSuspense(now, TIMING.suspenseMs);
|
||||
return;
|
||||
case 'teams':
|
||||
this.startSuspense(now, makeTeamSchedule(ids, this.plan.teams, this.plan.teamCount, TIMING.suspenseMs, reduced));
|
||||
return;
|
||||
case 'order':
|
||||
this.startSuspense(now, makeHighlightSchedule(rings, [this.plan.order[0]], TIMING.suspenseMs, reduced));
|
||||
return;
|
||||
default:
|
||||
this.startSuspense(now, makeHighlightSchedule(rings, this.plan.winners, TIMING.suspenseMs, reduced));
|
||||
}
|
||||
}
|
||||
|
||||
startSuspense(now, schedule) {
|
||||
this.schedule = schedule;
|
||||
this.stepIndex = -1;
|
||||
this.highlight = null;
|
||||
this.displayTeams = null;
|
||||
this.setPhase('suspense', now);
|
||||
this.emit('suspenseStart');
|
||||
this.advanceSchedule(now);
|
||||
}
|
||||
|
||||
advanceSchedule(now) {
|
||||
const steps = this.schedule;
|
||||
const last = steps.length - 1;
|
||||
const t = now - this.phaseAt;
|
||||
while (this.stepIndex < last && steps[this.stepIndex + 1].t <= t) {
|
||||
this.stepIndex++;
|
||||
const step = steps[this.stepIndex];
|
||||
if (step.teams) this.displayTeams = step.teams;
|
||||
else this.highlight = new Set(step.ids);
|
||||
this.stepAt = now;
|
||||
this.emit('tick', { progress: last ? this.stepIndex / last : 1, final: this.stepIndex === last });
|
||||
}
|
||||
if (this.stepIndex === last && t >= steps[last].t + TIMING.settleMs) this.land(now);
|
||||
}
|
||||
|
||||
land(now) {
|
||||
const plan = this.plan;
|
||||
if (plan.mode === 'order') {
|
||||
this.orderIdx = 0;
|
||||
this.setPhase('ordering', now);
|
||||
this.assignNextOrder(now);
|
||||
} else if (plan.mode === 'elimination') {
|
||||
this.eliminate(this.currentTarget, now, false);
|
||||
} else if (plan.mode === 'teams') {
|
||||
this.reveal(now, []);
|
||||
} else {
|
||||
this.reveal(now, plan.winners);
|
||||
}
|
||||
}
|
||||
|
||||
reveal(now, winners) {
|
||||
this.winners = winners.slice();
|
||||
this.highlight = new Set(winners);
|
||||
this.revealAt = now;
|
||||
if (this.plan.mode === 'teams') this.revealStyle = 'teams';
|
||||
else if (this.isReduced()) this.revealStyle = 'calm';
|
||||
else {
|
||||
const options = REVEAL_STYLES.filter((s) => s !== this.lastRevealStyle);
|
||||
this.revealStyle = options[randomInt(options.length)];
|
||||
this.lastRevealStyle = this.revealStyle;
|
||||
}
|
||||
this.setPhase('result', now);
|
||||
this.emit('reveal', { plan: this.plan, style: this.revealStyle, winners: this.winners });
|
||||
}
|
||||
|
||||
// ---- order mode ----
|
||||
|
||||
assignNextOrder(now) {
|
||||
const id = this.plan.order[this.orderIdx];
|
||||
const n = this.orderIdx + 1;
|
||||
this.orderNums.set(id, { n, at: now });
|
||||
this.highlight = new Set([id]);
|
||||
this.stepAt = now;
|
||||
this.orderIdx++;
|
||||
this.nextOrderAt = now + TIMING.orderStepMs;
|
||||
this.emit('orderStep', { id, n, total: this.plan.order.length });
|
||||
}
|
||||
|
||||
// ---- elimination mode ----
|
||||
|
||||
startElimSuspense(now, duration) {
|
||||
const target = this.plan.eliminationOrder.find((id) => this.remaining.includes(id));
|
||||
this.currentTarget = target;
|
||||
const rings = this.remaining.map((id) => this.rings.get(id));
|
||||
this.startSuspense(now, makeHighlightSchedule(rings, [target], duration, this.isReduced()));
|
||||
}
|
||||
|
||||
eliminate(id, now, chickened) {
|
||||
const r = this.rings.get(id);
|
||||
if (r) r.outAt = now;
|
||||
this.remaining = this.remaining.filter((x) => x !== id);
|
||||
this.lastOut = { id, chickened };
|
||||
this.highlight = null;
|
||||
this.pauseUntil = now + (this.remaining.length <= 1 ? 900 : TIMING.elimPauseMs);
|
||||
this.setPhase('elimPause', now);
|
||||
this.emit('eliminate', { id, chickened, remaining: this.remaining.length });
|
||||
}
|
||||
|
||||
/** A player let go mid-elimination: they're out. */
|
||||
elimChicken(r, now) {
|
||||
if (!this.remaining.includes(r.id)) return; // already knocked out, lifting as asked
|
||||
this.eliminate(r.id, now, true);
|
||||
}
|
||||
}
|
||||
173
js/i18n.js
Normal file
173
js/i18n.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// 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.',
|
||||
tapForSound: '🔈 Tap the screen once to turn on sound',
|
||||
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.',
|
||||
tapForSound: '🔈 Tik één keer op het scherm voor geluid',
|
||||
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;
|
||||
}
|
||||
156
js/input.js
Normal file
156
js/input.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Pointer input. Fingers are tracked by pointerId; pointercancel counts as
|
||||
// a lift (Android cancels touches it thinks are palms or system gestures).
|
||||
// Browsers may recycle pointerIds, while the game keeps lifted fingers on
|
||||
// screen after a round, so every touch gets its own never-reused finger id.
|
||||
//
|
||||
// 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 Map(); // pointerId -> finger id, for touches currently down
|
||||
let touchSeq = 0;
|
||||
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) {
|
||||
const id = active.get(pointerId);
|
||||
if (id == null) return;
|
||||
active.delete(pointerId);
|
||||
onUp(id, 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 */
|
||||
}
|
||||
release(e.pointerId); // a recycled id whose lift we never saw
|
||||
const id = `t${++touchSeq}`;
|
||||
active.set(e.pointerId, id);
|
||||
onDown(id, e.clientX, e.clientY, now());
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (mouse && e.pointerId === mouse.pointerId) return debugMove(e);
|
||||
const id = active.get(e.pointerId);
|
||||
if (id != null) onMove(id, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
const up = (e) => {
|
||||
if (mouse && e.pointerId === mouse.pointerId) return debugUp(e);
|
||||
release(e.pointerId);
|
||||
};
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', up);
|
||||
// Safety net: if capture is lost without an up/cancel, treat it as a lift.
|
||||
canvas.addEventListener('lostpointercapture', (e) => {
|
||||
if (!mouse || e.pointerId !== mouse.pointerId) release(e.pointerId);
|
||||
});
|
||||
|
||||
// ---- debug: fake fingers with the mouse ----
|
||||
|
||||
function fakeAt(x, y) {
|
||||
const r = hitRadius();
|
||||
for (const [id, f] of fakes) if (Math.hypot(f.x - x, f.y - y) < r) return id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function addFake(x, y) {
|
||||
const id = `fake-${++fakeSeq}`;
|
||||
fakes.set(id, { x, y });
|
||||
onDown(id, x, y, now());
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeFake(id) {
|
||||
if (!fakes.delete(id)) return;
|
||||
onUp(id, now());
|
||||
}
|
||||
|
||||
function debugDown(e) {
|
||||
const hit = fakeAt(e.clientX, e.clientY);
|
||||
const fakeId = hit ?? addFake(e.clientX, e.clientY);
|
||||
mouse = { pointerId: e.pointerId, fakeId, startX: e.clientX, startY: e.clientY, moved: false, created: !hit };
|
||||
try {
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function debugMove(e) {
|
||||
if (!mouse.moved && Math.hypot(e.clientX - mouse.startX, e.clientY - mouse.startY) > DRAG_THRESHOLD) mouse.moved = true;
|
||||
if (mouse.moved && fakes.has(mouse.fakeId)) {
|
||||
fakes.set(mouse.fakeId, { x: e.clientX, y: e.clientY });
|
||||
onMove(mouse.fakeId, e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function debugUp() {
|
||||
const m = mouse;
|
||||
mouse = null;
|
||||
if (!m.created && !m.moved) removeFake(m.fakeId);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
const m = 80;
|
||||
addFake(m + Math.random() * (innerWidth - 2 * m), m + Math.random() * (innerHeight - 2 * m));
|
||||
} else if (e.key === 'c' || e.key === 'C') {
|
||||
for (const id of [...fakes.keys()]) removeFake(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/** Forget all touches (page hidden). Fake fingers are kept. */
|
||||
clearReal() {
|
||||
active.clear();
|
||||
},
|
||||
/** Place fake fingers in a circle (debug ?fingers=N). */
|
||||
addFakeCircle(n) {
|
||||
const cx = innerWidth / 2;
|
||||
const cy = innerHeight / 2;
|
||||
const rad = Math.min(innerWidth, innerHeight) * 0.32;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
|
||||
addFake(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad);
|
||||
}
|
||||
},
|
||||
fakeCount: () => fakes.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop the browser from scrolling, zooming, selecting or opening menus. */
|
||||
export function blockBrowserGestures(canvas) {
|
||||
const opts = { passive: false };
|
||||
// iOS ignores touch-action for some gestures; cancel touches on the canvas.
|
||||
canvas.addEventListener('touchstart', (e) => e.preventDefault(), opts);
|
||||
canvas.addEventListener('touchmove', (e) => e.preventDefault(), opts);
|
||||
canvas.addEventListener('touchend', (e) => e.preventDefault(), opts);
|
||||
// iOS pinch-zoom
|
||||
for (const t of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(t, (e) => e.preventDefault(), opts);
|
||||
// No long-press menus or text selection anywhere except form fields.
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
||||
});
|
||||
document.addEventListener('selectstart', (e) => {
|
||||
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
||||
});
|
||||
document.addEventListener('dblclick', (e) => e.preventDefault(), opts);
|
||||
}
|
||||
136
js/main.js
Normal file
136
js/main.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Wires the modules together and runs the frame loop.
|
||||
|
||||
import { createSettings } from './settings.js';
|
||||
import { setLang, detectLang } from './i18n.js';
|
||||
import { Game } from './game.js';
|
||||
import { Renderer } from './renderer.js';
|
||||
import { createInput, blockBrowserGestures } from './input.js';
|
||||
import { sfx, buzz, setMuted, setHaptics, installAudioUnlock } from './audio.js';
|
||||
import { createWakeLock } from './device.js';
|
||||
import { getTheme } from './themes.js';
|
||||
import { createUI } from './ui.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const DEBUG = params.has('debug') && params.get('debug') !== '0';
|
||||
const WAKE_HOLD_MS = 2 * 60 * 1000; // keep the screen on this long after the last touch
|
||||
|
||||
const canvas = document.getElementById('stage');
|
||||
const settings = createSettings();
|
||||
setLang(settings.get().lang || detectLang());
|
||||
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const renderer = new Renderer(canvas);
|
||||
const wakeLock = createWakeLock();
|
||||
|
||||
const game = new Game({
|
||||
emit: onGameEvent,
|
||||
getSettings: () => settings.get(),
|
||||
isReduced: () => reducedMotion.matches,
|
||||
});
|
||||
|
||||
const noTouch = navigator.maxTouchPoints === 0 && matchMedia('(pointer: fine)').matches;
|
||||
const ui = createUI({ settings, debug: DEBUG, showDesktopHint: noTouch && !DEBUG });
|
||||
|
||||
function applySettings(s) {
|
||||
const theme = getTheme(s.theme);
|
||||
renderer.setTheme(theme);
|
||||
document.documentElement.dataset.theme = s.theme;
|
||||
document.querySelector('meta[name="theme-color"]').content = theme.bg;
|
||||
setMuted(s.muted);
|
||||
setHaptics(s.haptics);
|
||||
}
|
||||
applySettings(settings.get());
|
||||
settings.subscribe(applySettings);
|
||||
|
||||
// ---- game events -> sound, haptics, visuals, text ----
|
||||
|
||||
function onGameEvent(type, data = {}) {
|
||||
const now = performance.now();
|
||||
renderer.onEvent(type, data, now, game);
|
||||
ui.onEvent(type, data);
|
||||
switch (type) {
|
||||
case 'join':
|
||||
sfx.join(data.ring.colorIdx);
|
||||
buzz(8);
|
||||
break;
|
||||
case 'beat':
|
||||
sfx.beat(data.index);
|
||||
buzz(25);
|
||||
break;
|
||||
case 'tick':
|
||||
sfx.tick(data.progress, data.final);
|
||||
buzz(data.final ? 35 : 8);
|
||||
break;
|
||||
case 'reveal':
|
||||
sfx.reveal();
|
||||
buzz([90, 60, 450]);
|
||||
break;
|
||||
case 'eliminate':
|
||||
sfx.crack();
|
||||
if (data.chickened) sfx.chicken();
|
||||
buzz([40, 30, 120]);
|
||||
break;
|
||||
case 'orderStep':
|
||||
sfx.pop(data.n);
|
||||
buzz(data.n === 1 ? 120 : 20);
|
||||
break;
|
||||
case 'chicken':
|
||||
sfx.chicken();
|
||||
buzz([60, 50, 60, 50, 60]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- input ----
|
||||
|
||||
blockBrowserGestures(canvas);
|
||||
installAudioUnlock();
|
||||
|
||||
const input = createInput(canvas, {
|
||||
debug: DEBUG,
|
||||
onDown: (id, x, y, now) => game.fingerDown(id, x, y, now),
|
||||
onMove: (id, x, y) => game.fingerMove(id, x, y),
|
||||
onUp: (id, now) => game.fingerUp(id, now),
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
input.clearReal();
|
||||
game.cancelAll(performance.now());
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) {
|
||||
document.body.classList.add('debug');
|
||||
document.getElementById('debug-hud').hidden = false;
|
||||
const n = Number(params.get('fingers'));
|
||||
if (n > 0) input.addFakeCircle(Math.min(n, 12));
|
||||
}
|
||||
|
||||
// ---- frame loop ----
|
||||
|
||||
let lastTouchAt = -Infinity;
|
||||
|
||||
function frame() {
|
||||
const now = performance.now();
|
||||
game.update(now);
|
||||
renderer.draw(game, now);
|
||||
ui.update(game, now);
|
||||
|
||||
if (game.anyDown()) lastTouchAt = now;
|
||||
wakeLock.set(now - lastTouchAt < WAKE_HOLD_MS);
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// ---- PWA ----
|
||||
|
||||
if ('serviceWorker' in navigator && !DEBUG) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// Handy in the console while testing.
|
||||
if (DEBUG) Object.assign(window, { game, settings, renderer });
|
||||
111
js/modes.js
Normal file
111
js/modes.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Outcome planning. Every outcome is decided here, up front, with
|
||||
// crypto randomness; the suspense schedules only *present* it.
|
||||
|
||||
import { randomInt, shuffle } from './random.js';
|
||||
|
||||
export const MODES = ['one', 'pickN', 'teams', 'order', 'elimination'];
|
||||
export const TEAM_NAMES = ['A', 'B', 'C', 'D'];
|
||||
|
||||
/**
|
||||
* @param {string} mode
|
||||
* @param {string[]} ids participating finger ids
|
||||
* @param {{pickN:number, teams:number}} settings
|
||||
*/
|
||||
export function planRound(mode, ids, settings) {
|
||||
const n = ids.length;
|
||||
switch (mode) {
|
||||
case 'pickN': {
|
||||
const k = Math.max(1, Math.min(settings.pickN | 0, n - 1));
|
||||
return { mode, winners: shuffle(ids).slice(0, k) };
|
||||
}
|
||||
case 'teams': {
|
||||
const teamCount = Math.max(2, Math.min(settings.teams | 0, n));
|
||||
const teams = new Map();
|
||||
shuffle(ids).forEach((id, i) => teams.set(id, i % teamCount));
|
||||
return { mode, teams, teamCount };
|
||||
}
|
||||
case 'order':
|
||||
return { mode, order: shuffle(ids) };
|
||||
case 'elimination': {
|
||||
// Knock-out order; the last entry is the survivor.
|
||||
const eliminationOrder = shuffle(ids);
|
||||
return { mode, eliminationOrder };
|
||||
}
|
||||
case 'one':
|
||||
default:
|
||||
return { mode: 'one', winners: [ids[randomInt(n)]] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Step intervals that start fast and slow down like a roulette wheel. */
|
||||
function rouletteTimes(duration, reduced) {
|
||||
let interval = reduced ? 170 : 55;
|
||||
const growth = reduced ? 1.1 : 1.15;
|
||||
const intervals = [];
|
||||
let sum = 0;
|
||||
while (sum < duration) {
|
||||
intervals.push(interval);
|
||||
sum += interval;
|
||||
interval *= growth;
|
||||
}
|
||||
const scale = duration / sum;
|
||||
const times = [];
|
||||
let t = 0;
|
||||
for (const iv of intervals) {
|
||||
times.push(t);
|
||||
t += iv * scale;
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight schedule landing on `target` (array of ids).
|
||||
* Single target: the highlight walks clockwise around the group, like a
|
||||
* roulette ball. Several targets: random subsets flash, ending on the target.
|
||||
* @param {{id:string,x:number,y:number}[]} rings
|
||||
* @returns {{t:number, ids:string[]}[]}
|
||||
*/
|
||||
export function makeHighlightSchedule(rings, target, duration, reduced = false) {
|
||||
const times = rouletteTimes(duration, reduced);
|
||||
const steps = times.length;
|
||||
const ids = rings.map((r) => r.id);
|
||||
|
||||
if (target.length === 1) {
|
||||
const cx = rings.reduce((s, r) => s + r.x, 0) / rings.length;
|
||||
const cy = rings.reduce((s, r) => s + r.y, 0) / rings.length;
|
||||
const ordered = rings
|
||||
.slice()
|
||||
.sort((a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx))
|
||||
.map((r) => r.id);
|
||||
const m = ordered.length;
|
||||
const end = ordered.indexOf(target[0]);
|
||||
const start = (((end - (steps - 1)) % m) + m) % m;
|
||||
return times.map((t, i) => ({ t, ids: [ordered[(start + i) % m]] }));
|
||||
}
|
||||
|
||||
// Build backwards from the target so every step differs from the next one.
|
||||
const key = (a) => a.slice().sort().join('|');
|
||||
const out = new Array(steps);
|
||||
out[steps - 1] = target.slice();
|
||||
for (let i = steps - 2; i >= 0; i--) {
|
||||
const next = key(out[i + 1]);
|
||||
let set;
|
||||
for (let tries = 0; tries < 20; tries++) {
|
||||
set = shuffle(ids).slice(0, target.length);
|
||||
if (key(set) !== next) break;
|
||||
}
|
||||
out[i] = set;
|
||||
}
|
||||
return times.map((t, i) => ({ t, ids: out[i] }));
|
||||
}
|
||||
|
||||
/** Team schedule: rings flicker through random balanced splits, ending on the real one. */
|
||||
export function makeTeamSchedule(ids, teams, teamCount, duration, reduced = false) {
|
||||
const times = rouletteTimes(duration, reduced);
|
||||
return times.map((t, i) => {
|
||||
if (i === times.length - 1) return { t, teams };
|
||||
const m = new Map();
|
||||
shuffle(ids).forEach((id, j) => m.set(id, j % teamCount));
|
||||
return { t, teams: m };
|
||||
});
|
||||
}
|
||||
29
js/random.js
Normal file
29
js/random.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Unbiased randomness from crypto.getRandomValues (available on http too).
|
||||
|
||||
const buf = new Uint32Array(1);
|
||||
|
||||
/** Uniform integer in [0, n). Rejection sampling avoids modulo bias. */
|
||||
export function randomInt(n) {
|
||||
if (n <= 1) return 0;
|
||||
const limit = Math.floor(0x100000000 / n) * n;
|
||||
let x;
|
||||
do {
|
||||
crypto.getRandomValues(buf);
|
||||
x = buf[0];
|
||||
} while (x >= limit);
|
||||
return x % n;
|
||||
}
|
||||
|
||||
/** Fisher–Yates shuffle, returns a new array. */
|
||||
export function shuffle(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = randomInt(i + 1);
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
export function pick(arr) {
|
||||
return arr[randomInt(arr.length)];
|
||||
}
|
||||
468
js/renderer.js
Normal file
468
js/renderer.js
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// Canvas renderer. Reads game state every frame; never mutates it.
|
||||
// Glowing rings are pre-rendered sprites (shadowBlur is far too slow to
|
||||
// run per frame on phones), scaled with drawImage.
|
||||
|
||||
import { TIMING } from './game.js';
|
||||
import { SHAPES } from './themes.js';
|
||||
import { TEAM_NAMES } from './modes.js';
|
||||
import { createReveal, createRipple, clamp01, easeOutCubic } from './reveals.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const SPRITE_SCALE = 1.7; // sprites are drawn at the largest ring size, then scaled down
|
||||
|
||||
function easeOutBack(t) {
|
||||
const c1 = 1.70158;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.sprites = new Map();
|
||||
this.ripples = [];
|
||||
this.reveal = null;
|
||||
this.shards = new Map();
|
||||
this.theme = null;
|
||||
this.resize();
|
||||
}
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
this.sprites.clear();
|
||||
}
|
||||
|
||||
resize() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3);
|
||||
const w = this.canvas.clientWidth || window.innerWidth;
|
||||
const h = this.canvas.clientHeight || window.innerHeight;
|
||||
this.dpr = dpr;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.canvas.width = Math.round(w * dpr);
|
||||
this.canvas.height = Math.round(h * dpr);
|
||||
this.R = Math.max(40, Math.min(64, Math.min(w, h) * 0.115));
|
||||
this.sprites.clear();
|
||||
}
|
||||
|
||||
colorOf(ring) {
|
||||
return ring.colorIdx < 0 ? this.theme.spectator : this.theme.colors[ring.colorIdx % this.theme.colors.length];
|
||||
}
|
||||
|
||||
lineWidth() {
|
||||
return this.R * 0.13 * this.theme.lineScale;
|
||||
}
|
||||
|
||||
// ---- events from the game ----------------------------------------------
|
||||
|
||||
onEvent(type, data, now, game) {
|
||||
const R = this.R;
|
||||
switch (type) {
|
||||
case 'join':
|
||||
this.ripples.push(createRipple(data.ring.x, data.ring.y, this.colorOf(data.ring), now, R * 1.8, 450, 3));
|
||||
break;
|
||||
case 'beat':
|
||||
for (const r of game.activeRings()) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.1, 500, 3));
|
||||
break;
|
||||
case 'orderStep': {
|
||||
const r = game.rings.get(data.id);
|
||||
if (r) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.6, 600, 5));
|
||||
break;
|
||||
}
|
||||
case 'reveal': {
|
||||
const targets =
|
||||
data.style === 'teams'
|
||||
? game.players.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.theme.team[game.plan.teams.get(ring.id)] }))
|
||||
: data.winners.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.colorOf(ring) }));
|
||||
this.reveal = createReveal(data.style, { targets, view: this, 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);
|
||||
}
|
||||
}
|
||||
271
js/reveals.js
Normal file
271
js/reveals.js
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
// 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}[], view:{w:number,h:number,R:number}, start:number, palette:string[]}} o
|
||||
* `view` is read every frame so the effect follows resizes (e.g. entering fullscreen).
|
||||
*/
|
||||
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, view, start }) {
|
||||
const single = targets.length === 1;
|
||||
return {
|
||||
winnerWhite: true,
|
||||
drawBack(g, now) {
|
||||
const { w, h, R } = view;
|
||||
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, view, 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 { w, h, R } = view;
|
||||
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 { w, h, R } = view;
|
||||
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, view, start }) {
|
||||
// Darkness is drawn at half resolution: it's all soft gradients anyway.
|
||||
const scale = 0.5;
|
||||
const off = document.createElement('canvas');
|
||||
const o = off.getContext('2d');
|
||||
const spots = targets.map(() => ({ sx: Math.random() * view.w, sy: Math.random() * view.h * 0.6, ph: Math.random() * 6 }));
|
||||
return {
|
||||
drawBack(g, now) {
|
||||
const { w, h, R } = view;
|
||||
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 { w, h, R } = view;
|
||||
const t = (now - start) / 1000;
|
||||
const ow = Math.ceil(w * scale);
|
||||
const oh = Math.ceil(h * scale);
|
||||
if (off.width !== ow || off.height !== oh) {
|
||||
off.width = ow;
|
||||
off.height = oh;
|
||||
}
|
||||
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, view, 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 { w, h, R } = view;
|
||||
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 { w, h, R } = view;
|
||||
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, view, start }) {
|
||||
return {
|
||||
drawBack(g, now) {
|
||||
const { w, h, R } = view;
|
||||
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, view, start }) {
|
||||
return {
|
||||
drawBack(g, now) {
|
||||
const { w, h, R } = view;
|
||||
const k = easeOutCubic(clamp01((now - start) / 700));
|
||||
for (const { ring, color } of targets) {
|
||||
const r = R * (1.5 + 2.5 * k);
|
||||
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, r);
|
||||
grad.addColorStop(0, hexToRgba(color, 0.5));
|
||||
grad.addColorStop(1, hexToRgba(color, 0));
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(ring.x - r, ring.y - r, r * 2, r * 2);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Small expanding ring (joins, countdown beats, order steps). */
|
||||
export function createRipple(x, y, color, start, maxR, dur = 600, width = 4) {
|
||||
return {
|
||||
done: (now) => now - start > dur,
|
||||
draw(g, now) {
|
||||
const k = (now - start) / dur;
|
||||
if (k < 0 || k > 1) return;
|
||||
g.globalAlpha = (1 - k) * 0.8;
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = width * (1 - k) + 0.5;
|
||||
g.beginPath();
|
||||
g.arc(x, y, maxR * easeOutCubic(k), 0, Math.PI * 2);
|
||||
g.stroke();
|
||||
g.globalAlpha = 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
56
js/settings.js
Normal file
56
js/settings.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Persisted settings with a tiny subscribe API. Storage can throw
|
||||
// (private mode, blocked site data), so every access is guarded.
|
||||
|
||||
const KEY = 'picker.settings.v1';
|
||||
|
||||
export const DEFAULTS = Object.freeze({
|
||||
mode: 'one', // one | pickN | teams | order | elimination
|
||||
pickN: 2,
|
||||
teams: 2,
|
||||
theme: 'neon', // neon | pastel | contrast
|
||||
lang: null, // null = follow the browser
|
||||
muted: false,
|
||||
haptics: true,
|
||||
stakeId: null, // preset key, translated at display time
|
||||
stakeText: '', // custom text, used when stakeId is null
|
||||
});
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return { ...DEFAULTS };
|
||||
const parsed = JSON.parse(raw);
|
||||
const out = { ...DEFAULTS };
|
||||
for (const k of Object.keys(DEFAULTS)) {
|
||||
if (k in parsed) out[k] = parsed[k];
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
function save(s) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* storage unavailable: settings just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
export function createSettings() {
|
||||
let state = load();
|
||||
const listeners = new Set();
|
||||
return {
|
||||
get: () => state,
|
||||
set(patch) {
|
||||
state = { ...state, ...patch };
|
||||
save(state);
|
||||
for (const fn of listeners) fn(state, patch);
|
||||
},
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
42
js/themes.js
Normal file
42
js/themes.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Visual themes. `colors` are per-finger, `team` per team (A–D).
|
||||
// The high-contrast theme also gives every finger/team its own shape,
|
||||
// so nothing depends on telling colours apart.
|
||||
|
||||
export const SHAPES = ['circle', 'square', 'triangle', 'diamond', 'star', 'hexagon', 'cross', 'pentagon'];
|
||||
|
||||
export const THEMES = {
|
||||
neon: {
|
||||
bg: '#05050b',
|
||||
glow: 1,
|
||||
additive: true,
|
||||
shapes: false,
|
||||
lineScale: 1,
|
||||
spectator: '#4a4a58',
|
||||
colors: ['#ff2e63', '#00e5ff', '#ffe500', '#3dff5a', '#b45cff', '#ff8a1f', '#2f6bff', '#ff4fd8', '#00ffb2', '#c8ff00', '#ff5b3a', '#9fb4ff'],
|
||||
team: ['#ff2e63', '#00e5ff', '#ffe500', '#3dff5a'],
|
||||
},
|
||||
pastel: {
|
||||
bg: '#1c1a26',
|
||||
glow: 0.45,
|
||||
additive: false,
|
||||
shapes: false,
|
||||
lineScale: 1.15,
|
||||
spectator: '#4b4858',
|
||||
colors: ['#ff9aa2', '#9ee3e0', '#ffd6a5', '#b9f2c4', '#cdb4ff', '#ffc6e7', '#a0c4ff', '#fdffb6', '#f7b0be', '#b8e0d2', '#e2c2ff', '#ffcfa3'],
|
||||
team: ['#ff9aa2', '#a0c4ff', '#fdffb6', '#b9f2c4'],
|
||||
},
|
||||
contrast: {
|
||||
bg: '#000000',
|
||||
glow: 0,
|
||||
additive: false,
|
||||
shapes: true,
|
||||
lineScale: 1.3,
|
||||
spectator: '#6b6b6b',
|
||||
colors: ['#ffffff', '#ffd500', '#00d9ff', '#ff4fd8', '#4dff4d', '#ff8c00', '#a98bff', '#ff4545'],
|
||||
team: ['#ffd500', '#00d9ff', '#ff4fd8', '#ffffff'],
|
||||
},
|
||||
};
|
||||
|
||||
export function getTheme(name) {
|
||||
return THEMES[name] || THEMES.neon;
|
||||
}
|
||||
286
js/ui.js
Normal file
286
js/ui.js
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// 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, audioUnlocked } from './audio.js';
|
||||
import { fullscreenSupported, isFullscreen, toggleFullscreen, onFullscreenChange } from './device.js';
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
export function stakeText(s) {
|
||||
return s.stakeId ? t(`stakePresets.${s.stakeId}`) : s.stakeText.trim();
|
||||
}
|
||||
|
||||
export function createUI({ settings, debug, showDesktopHint }) {
|
||||
const msgMain = $('#msg-main');
|
||||
const msgSub = $('#msg-sub');
|
||||
const toolbar = $('#toolbar');
|
||||
const sheet = $('#sheet');
|
||||
const backdrop = $('#sheet-backdrop');
|
||||
const btnSettings = $('#btn-settings');
|
||||
const btnSound = $('#btn-sound');
|
||||
const btnFull = $('#btn-fullscreen');
|
||||
const stakeInput = $('#stake-input');
|
||||
const hud = $('#debug-hud');
|
||||
|
||||
let chickenMsg = '';
|
||||
let shownMain = null;
|
||||
let shownSub = null;
|
||||
let shownClass = null;
|
||||
let toolbarShown = null;
|
||||
|
||||
// ---- toolbar ----
|
||||
|
||||
btnSettings.addEventListener('click', openSheet);
|
||||
btnSound.addEventListener('click', () => settings.set({ muted: !settings.get().muted }));
|
||||
const standaloneFull = matchMedia('(display-mode: fullscreen)').matches;
|
||||
btnFull.hidden = !fullscreenSupported() || standaloneFull;
|
||||
btnFull.addEventListener('click', toggleFullscreen);
|
||||
onFullscreenChange(syncToolbar);
|
||||
|
||||
// ---- sheet ----
|
||||
|
||||
function openSheet() {
|
||||
sheet.hidden = false;
|
||||
backdrop.hidden = false;
|
||||
requestAnimationFrame(() => document.body.classList.add('sheet-open'));
|
||||
sheet.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closeSheet() {
|
||||
document.body.classList.remove('sheet-open');
|
||||
stakeInput.blur();
|
||||
setTimeout(() => {
|
||||
if (!document.body.classList.contains('sheet-open')) {
|
||||
sheet.hidden = true;
|
||||
backdrop.hidden = true;
|
||||
}
|
||||
}, 250);
|
||||
btnSettings.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
backdrop.addEventListener('click', closeSheet);
|
||||
sheet.querySelector('.sheet-close').addEventListener('click', closeSheet);
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !sheet.hidden) closeSheet();
|
||||
});
|
||||
|
||||
// Segmented controls: <div class="seg" data-setting="mode"> buttons with data-value.
|
||||
function buildSeg(el, values, labelFn) {
|
||||
el.replaceChildren(
|
||||
...values.map((v) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.dataset.value = String(v);
|
||||
b.textContent = labelFn(v);
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
sheet.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const seg = btn.closest('.seg');
|
||||
if (seg && btn.dataset.value != null) {
|
||||
const key = seg.dataset.setting;
|
||||
let v = btn.dataset.value;
|
||||
if (key === 'teams') v = Number(v);
|
||||
if (key === 'muted') v = v === 'true';
|
||||
if (key === 'haptics') v = v === 'true';
|
||||
if (key === 'lang') setLang(v);
|
||||
settings.set({ [key]: v });
|
||||
return;
|
||||
}
|
||||
if (btn.dataset.step) {
|
||||
const n = Math.max(1, Math.min(9, settings.get().pickN + Number(btn.dataset.step)));
|
||||
settings.set({ pickN: n });
|
||||
return;
|
||||
}
|
||||
if (btn.dataset.preset) {
|
||||
const id = btn.dataset.preset;
|
||||
settings.set(settings.get().stakeId === id ? { stakeId: null, stakeText: '' } : { stakeId: id, stakeText: '' });
|
||||
return;
|
||||
}
|
||||
if (btn.id === 'stake-clear') settings.set({ stakeId: null, stakeText: '' });
|
||||
});
|
||||
|
||||
stakeInput.addEventListener('input', () => settings.set({ stakeId: null, stakeText: stakeInput.value.slice(0, 60) }));
|
||||
stakeInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') stakeInput.blur();
|
||||
});
|
||||
|
||||
function applyTranslations() {
|
||||
document.title = t('appTitle');
|
||||
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
|
||||
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
|
||||
stakeInput.placeholder = t('stakesPlaceholder');
|
||||
buildSeg($('#seg-mode'), MODES, (m) => t(`mode.${m}`));
|
||||
buildSeg($('#seg-teams'), [2, 3, 4], String);
|
||||
buildSeg($('#seg-theme'), ['neon', 'pastel', 'contrast'], (v) => t(`theme.${v}`));
|
||||
buildSeg($('#seg-lang'), LANGS, (v) => ({ en: 'English', nl: 'Nederlands' })[v]);
|
||||
buildSeg($('#seg-sound'), [false, true], (v) => t(v ? 'off' : 'on'));
|
||||
buildSeg($('#seg-haptics'), [true, false], (v) => t(v ? 'on' : 'off'));
|
||||
const presets = $('#stake-presets');
|
||||
presets.replaceChildren(
|
||||
...Object.keys(t('stakePresets')).map((id) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'chip';
|
||||
b.dataset.preset = id;
|
||||
b.textContent = t(`stakePresets.${id}`);
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function syncSheet() {
|
||||
const s = settings.get();
|
||||
const lang = getLang();
|
||||
const sel = { mode: s.mode, teams: String(s.teams), theme: s.theme, lang, muted: String(s.muted), haptics: String(s.haptics) };
|
||||
for (const seg of sheet.querySelectorAll('.seg')) {
|
||||
const cur = sel[seg.dataset.setting];
|
||||
for (const b of seg.children) b.setAttribute('aria-pressed', String(b.dataset.value === cur));
|
||||
}
|
||||
$('#mode-desc').textContent = t(`modeDesc.${s.mode}`);
|
||||
$('#row-pickn').hidden = s.mode !== 'pickN';
|
||||
$('#row-teams').hidden = s.mode !== 'teams';
|
||||
$('#row-haptics').hidden = !canVibrate;
|
||||
$('#pickn-value').textContent = String(s.pickN);
|
||||
for (const b of $('#stake-presets').children) b.setAttribute('aria-pressed', String(b.dataset.preset === s.stakeId));
|
||||
if (document.activeElement !== stakeInput) stakeInput.value = s.stakeId ? '' : s.stakeText;
|
||||
$('#stake-clear').hidden = !s.stakeId && !s.stakeText;
|
||||
syncToolbar();
|
||||
}
|
||||
|
||||
function syncToolbar() {
|
||||
const s = settings.get();
|
||||
btnSound.classList.toggle('is-muted', s.muted);
|
||||
btnSound.setAttribute('aria-label', t(s.muted ? 'unmute' : 'mute'));
|
||||
btnSound.setAttribute('aria-pressed', String(s.muted));
|
||||
const fs = isFullscreen();
|
||||
btnFull.classList.toggle('is-full', fs);
|
||||
btnFull.setAttribute('aria-label', t(fs ? 'exitFullscreen' : 'fullscreen'));
|
||||
}
|
||||
|
||||
settings.subscribe((s, patch) => {
|
||||
if ('lang' in patch) {
|
||||
setLang(s.lang || detectLang());
|
||||
applyTranslations();
|
||||
}
|
||||
syncSheet();
|
||||
});
|
||||
|
||||
applyTranslations();
|
||||
syncSheet();
|
||||
if (debug && new URLSearchParams(location.search).get('open') === 'settings') openSheet();
|
||||
|
||||
// ---- overlay messages ----
|
||||
|
||||
function modeSummary(s) {
|
||||
let out = t(`mode.${s.mode}`);
|
||||
if (s.mode === 'pickN') out += ` · ${s.pickN}`;
|
||||
if (s.mode === 'teams') out += ` · ${s.teams}`;
|
||||
const stake = stakeText(s);
|
||||
if (stake && s.mode !== 'teams') out += ` · “${stake}”`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function resultMessage(game, s) {
|
||||
const stake = stakeText(s);
|
||||
switch (game.plan?.mode) {
|
||||
case 'teams':
|
||||
return t('teamsReady');
|
||||
case 'order':
|
||||
return stake ? `#1: ${t('youStake', { stake })}` : t('orderDone');
|
||||
case 'elimination':
|
||||
return stake ? t('youStake', { stake }) : t('lastStanding');
|
||||
default:
|
||||
return stake ? t('youStake', { stake }) : t('youreIt');
|
||||
}
|
||||
}
|
||||
|
||||
function messages(game, now) {
|
||||
const s = settings.get();
|
||||
const count = game.activeRings().length;
|
||||
switch (game.phase) {
|
||||
case 'idle':
|
||||
if (count === 0) {
|
||||
if (showDesktopHint) return [t('hint'), t('desktopHint'), 'hint'];
|
||||
// Browsers only allow sound after a tap (a finger *lift*), so ask for one.
|
||||
const soundHint = !s.muted && !audioUnlocked() ? `
|
||||
${t('tapForSound')}` : '';
|
||||
return [t('hint'), modeSummary(s) + soundHint, 'hint'];
|
||||
}
|
||||
return [t('needMore'), modeSummary(s), 'hint'];
|
||||
case 'gathering':
|
||||
return [t('holdStill'), '', 'soft'];
|
||||
case 'countdown':
|
||||
return [String(TIMING.beats - Math.max(0, game.beat)), '', 'count'];
|
||||
case 'chicken':
|
||||
return [chickenMsg, '', 'alert'];
|
||||
case 'elimPause': {
|
||||
const out = game.lastOut;
|
||||
const ring = out && game.rings.get(out.id);
|
||||
if (!out) return ['', '', ''];
|
||||
return [t(out.chickened ? 'chickenOut' : 'eliminated'), ring?.down ? t('liftFinger') : '', 'alert'];
|
||||
}
|
||||
case 'result': {
|
||||
const sub = game.canRestart(now) ? t('tapRestart') : '';
|
||||
return [resultMessage(game, s), sub, 'result'];
|
||||
}
|
||||
default:
|
||||
return ['', '', ''];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onEvent(type) {
|
||||
if (type === 'chicken') chickenMsg = pick(t('chicken'));
|
||||
},
|
||||
|
||||
update(game, now) {
|
||||
const [main, sub, cls] = messages(game, now);
|
||||
if (main !== shownMain || cls !== shownClass) {
|
||||
msgMain.textContent = main;
|
||||
msgMain.className = `msg ${cls}`;
|
||||
// restart the entrance animation
|
||||
void msgMain.offsetWidth;
|
||||
msgMain.classList.add('in');
|
||||
shownMain = main;
|
||||
shownClass = cls;
|
||||
}
|
||||
if (sub !== shownSub) {
|
||||
msgSub.textContent = sub;
|
||||
shownSub = sub;
|
||||
}
|
||||
|
||||
const showTools = (game.phase === 'idle' && !game.anyDown()) || (game.phase === 'result' && !game.anyDown());
|
||||
if (showTools !== toolbarShown) {
|
||||
toolbar.classList.toggle('hidden', !showTools);
|
||||
toolbar.inert = !showTools;
|
||||
toolbarShown = showTools;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
const fps = this._fps(now);
|
||||
hud.textContent = `${game.phase} · ${game.activeRings().length} fingers · ${fps} fps`;
|
||||
}
|
||||
},
|
||||
|
||||
_last: 0,
|
||||
_frames: 0,
|
||||
_fpsVal: 0,
|
||||
_fps(now) {
|
||||
this._frames++;
|
||||
if (now - this._last > 500) {
|
||||
this._fpsVal = Math.round((this._frames * 1000) / (now - this._last));
|
||||
this._frames = 0;
|
||||
this._last = now;
|
||||
}
|
||||
return this._fpsVal;
|
||||
},
|
||||
};
|
||||
}
|
||||
20
manifest.webmanifest
Normal file
20
manifest.webmanifest
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "Picker",
|
||||
"short_name": "Picker",
|
||||
"description": "Everyone puts a finger on the screen and Picker chooses one of you at random.",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"orientation": "any",
|
||||
"background_color": "#05050b",
|
||||
"theme_color": "#05050b",
|
||||
"categories": ["games", "entertainment", "utilities"],
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
45
nginx.conf
Normal file
45
nginx.conf
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Behind a reverse proxy: don't leak the internal port in redirects.
|
||||
absolute_redirect off;
|
||||
server_tokens off;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/manifest+json image/svg+xml;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; worker-src 'self'; manifest-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# Always revalidate the entry points so updates roll out promptly.
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; worker-src 'self'; manifest-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always;
|
||||
}
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
83
sw.js
Normal file
83
sw.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Service worker: precache the app shell, serve network-first with a
|
||||
// cache fallback so updates show up immediately when online and the app
|
||||
// still works fully offline. Bump VERSION when the file list changes.
|
||||
|
||||
const VERSION = 'picker-v2';
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
334
tests/logic.html
Normal file
334
tests/logic.html
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Picker logic tests</title>
|
||||
<style>body{font:14px monospace;background:#111;color:#ddd;padding:1rem}.pass{color:#6f6}.fail{color:#f66}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Picker logic tests</h1>
|
||||
<pre id="out">running…</pre>
|
||||
<script type="module">
|
||||
// Drives the state machine with simulated time. Open in a browser
|
||||
// (served over http) or headless: results are written into #out.
|
||||
import { Game, TIMING } from '../js/game.js';
|
||||
import { planRound, makeHighlightSchedule } from '../js/modes.js';
|
||||
import { randomInt } from '../js/random.js';
|
||||
import { createInput } from '../js/input.js';
|
||||
import { createReveal } from '../js/reveals.js';
|
||||
|
||||
const results = [];
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(['pass', name]);
|
||||
} catch (e) {
|
||||
results.push(['fail', `${name}: ${e.message}`]);
|
||||
}
|
||||
}
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg || 'assertion failed');
|
||||
}
|
||||
|
||||
function setup(settings = {}) {
|
||||
const events = [];
|
||||
const s = { mode: 'one', pickN: 2, teams: 2, ...settings };
|
||||
const game = new Game({ emit: (type, data) => events.push({ type, data }), getSettings: () => s, isReduced: () => false });
|
||||
let now = performance.now() + 10;
|
||||
const h = {
|
||||
game,
|
||||
events,
|
||||
get now() { return now; },
|
||||
down(id, x = 100 + Math.random() * 200, y = 100 + Math.random() * 300) { game.fingerDown(id, x, y, now); },
|
||||
up(id) { game.fingerUp(id, now); },
|
||||
run(ms) {
|
||||
const end = now + ms;
|
||||
while (now < end) {
|
||||
now = Math.min(end, now + 16);
|
||||
game.update(now);
|
||||
}
|
||||
},
|
||||
until(phase, max = 30000) {
|
||||
const end = now + max;
|
||||
while (game.phase !== phase && now < end) {
|
||||
now += 16;
|
||||
game.update(now);
|
||||
}
|
||||
assert(game.phase === phase, `expected phase ${phase}, got ${game.phase}`);
|
||||
},
|
||||
count: (type) => events.filter((e) => e.type === type).length,
|
||||
};
|
||||
return h;
|
||||
}
|
||||
|
||||
test('pick one: full flow reaches a result with one valid winner', () => {
|
||||
const h = setup();
|
||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||
h.run(100);
|
||||
assert(h.game.phase === 'gathering', 'gathering');
|
||||
h.run(TIMING.stableMs);
|
||||
assert(h.game.phase === 'countdown', 'countdown after stable period, got ' + h.game.phase);
|
||||
h.until('result');
|
||||
assert(h.count('beat') === TIMING.beats, 'beats');
|
||||
assert(h.count('tick') > 8, 'ticks');
|
||||
assert(h.game.winners.length === 1 && ['a', 'b', 'c'].includes(h.game.winners[0]), 'winner');
|
||||
const reveal = h.events.find((e) => e.type === 'reveal');
|
||||
assert(['flood', 'confetti', 'spotlight', 'shockwave'].includes(reveal.data.style), 'style ' + reveal.data.style);
|
||||
});
|
||||
|
||||
test('outcome is decided before the animation and the animation lands on it', () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const h = setup();
|
||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||
h.until('suspense');
|
||||
const planned = h.game.plan.winners[0];
|
||||
const last = h.game.schedule[h.game.schedule.length - 1];
|
||||
assert(last.ids[0] === planned, 'schedule ends on planned winner');
|
||||
assert(h.game.schedule[h.game.schedule.length - 2].ids[0] !== planned, 'penultimate step differs');
|
||||
h.until('result');
|
||||
assert(h.game.winners[0] === planned, 'revealed winner = planned');
|
||||
}
|
||||
});
|
||||
|
||||
test('one finger is not enough', () => {
|
||||
const h = setup();
|
||||
h.down('a');
|
||||
h.run(6000);
|
||||
assert(h.game.phase === 'idle', h.game.phase);
|
||||
});
|
||||
|
||||
test('joining during the countdown resets to gathering without chicken', () => {
|
||||
const h = setup();
|
||||
h.down('a');
|
||||
h.down('b');
|
||||
h.until('countdown');
|
||||
h.down('c');
|
||||
assert(h.game.phase === 'gathering', h.game.phase);
|
||||
assert(h.count('chicken') === 0, 'no chicken');
|
||||
h.run(TIMING.stableMs - 200);
|
||||
assert(h.game.phase === 'gathering', 'stable timer restarted');
|
||||
h.until('result');
|
||||
});
|
||||
|
||||
test('lifting during the countdown triggers chicken, then resumes', () => {
|
||||
const h = setup();
|
||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||
h.until('countdown');
|
||||
h.up('b');
|
||||
assert(h.game.phase === 'chicken', h.game.phase);
|
||||
assert(h.count('chicken') === 1, 'chicken event');
|
||||
h.run(TIMING.chickenMs + 50);
|
||||
assert(h.game.phase === 'gathering', 'back to gathering, got ' + h.game.phase);
|
||||
h.until('result');
|
||||
assert(['a', 'c'].includes(h.game.winners[0]), 'winner among remaining');
|
||||
});
|
||||
|
||||
test('lifting during suspense aborts with chicken', () => {
|
||||
const h = setup();
|
||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||
h.until('suspense');
|
||||
h.up('a');
|
||||
assert(h.game.phase === 'chicken' && h.game.plan === null, h.game.phase);
|
||||
});
|
||||
|
||||
test('late joiners during suspense are spectators', () => {
|
||||
const h = setup();
|
||||
['a', 'b'].forEach((id) => h.down(id));
|
||||
h.until('suspense');
|
||||
h.down('late');
|
||||
assert(h.game.rings.get('late').late, 'late flag');
|
||||
h.until('result');
|
||||
assert(h.game.winners[0] !== 'late', 'spectator never wins');
|
||||
assert(!h.game.players.includes('late'), 'not a player');
|
||||
});
|
||||
|
||||
test('result persists after lifting; restart only after grace and all fingers up', () => {
|
||||
const h = setup();
|
||||
['a', 'b'].forEach((id) => h.down(id));
|
||||
h.until('result');
|
||||
h.up('a');
|
||||
h.run(3000);
|
||||
assert(h.game.phase === 'result', 'still result while b holds');
|
||||
h.down('x');
|
||||
assert(h.game.phase === 'result' && h.game.rings.get('x').late, 'tap while someone holds is ignored');
|
||||
h.up('x');
|
||||
h.up('b');
|
||||
assert(h.game.phase === 'result', 'stays after all lift');
|
||||
assert(h.game.canRestart(h.now), 'can restart');
|
||||
h.down('n1');
|
||||
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'restarted');
|
||||
assert(h.game.rings.size === 1 && !h.game.rings.get('n1').late, 'tapping finger joins the next round');
|
||||
});
|
||||
|
||||
test('no instant restart right after the reveal', () => {
|
||||
const h = setup();
|
||||
['a', 'b'].forEach((id) => h.down(id));
|
||||
h.until('result');
|
||||
h.up('a');
|
||||
h.up('b');
|
||||
h.down('a2');
|
||||
assert(h.game.phase === 'result', 'grace period protects the result');
|
||||
});
|
||||
|
||||
test('pick N clamps to fingers - 1 and picks distinct winners', () => {
|
||||
const h = setup({ mode: 'pickN', pickN: 5 });
|
||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||
h.until('result');
|
||||
assert(h.game.winners.length === 2, 'clamped to 2, got ' + h.game.winners.length);
|
||||
assert(new Set(h.game.winners).size === 2, 'distinct');
|
||||
});
|
||||
|
||||
test('teams are balanced', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const plan = planRound('teams', ['a', 'b', 'c', 'd', 'e', 'f', 'g'], { teams: 3 });
|
||||
const sizes = [0, 0, 0];
|
||||
for (const t of plan.teams.values()) sizes[t]++;
|
||||
assert(Math.max(...sizes) - Math.min(...sizes) <= 1, 'sizes ' + sizes);
|
||||
}
|
||||
const small = planRound('teams', ['a', 'b'], { teams: 4 });
|
||||
assert(small.teamCount === 2, 'team count capped by fingers');
|
||||
});
|
||||
|
||||
test('teams mode reaches result', () => {
|
||||
const h = setup({ mode: 'teams', teams: 2 });
|
||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||
h.until('result');
|
||||
assert(h.game.revealStyle === 'teams', 'teams style');
|
||||
});
|
||||
|
||||
test('order assigns 1..N exactly once, #1 first', () => {
|
||||
const h = setup({ mode: 'order' });
|
||||
['a', 'b', 'c', 'd', 'e'].forEach((id) => h.down(id));
|
||||
h.until('suspense');
|
||||
const first = h.game.plan.order[0];
|
||||
h.until('result');
|
||||
const nums = [...h.game.orderNums.values()].map((o) => o.n).sort();
|
||||
assert(nums.join() === '1,2,3,4,5', nums.join());
|
||||
assert(h.game.orderNums.get(first).n === 1, 'planned #1');
|
||||
assert(h.count('orderStep') === 5, 'steps');
|
||||
});
|
||||
|
||||
test('elimination knocks out N-1 and the planned survivor remains', () => {
|
||||
const h = setup({ mode: 'elimination' });
|
||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||
h.until('suspense');
|
||||
const order = h.game.plan.eliminationOrder.slice();
|
||||
// Knocked-out players lift their finger, as asked.
|
||||
h.game.emit = ((orig) => (type, data) => { orig(type, data); if (type === 'eliminate') h.game.fingerUp(data.id, h.now); })(h.game.emit);
|
||||
h.until('result');
|
||||
const outs = h.events.filter((e) => e.type === 'eliminate').map((e) => e.data.id);
|
||||
assert(outs.join() === order.slice(0, 3).join(), `outs ${outs} vs ${order}`);
|
||||
assert(h.game.winners[0] === order[3], 'survivor');
|
||||
});
|
||||
|
||||
test('elimination: a remaining player who lets go is out', () => {
|
||||
const h = setup({ mode: 'elimination' });
|
||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||
h.until('suspense');
|
||||
const survivor = h.game.plan.eliminationOrder[3];
|
||||
const coward = h.game.plan.eliminationOrder[2];
|
||||
h.up(coward);
|
||||
assert(h.game.phase === 'elimPause', h.game.phase);
|
||||
assert(h.events.find((e) => e.type === 'eliminate').data.chickened, 'chickened');
|
||||
h.until('result');
|
||||
assert(h.game.winners[0] === survivor, 'survivor still wins');
|
||||
assert(h.count('eliminate') === 3, 'three outs');
|
||||
});
|
||||
|
||||
test('two players: highlight alternates and lands on the planned one', () => {
|
||||
const rings = [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 100, y: 0 }];
|
||||
for (const target of ['a', 'b']) {
|
||||
const s = makeHighlightSchedule(rings, [target], 2700);
|
||||
assert(s[s.length - 1].ids[0] === target, 'lands');
|
||||
for (let i = 1; i < s.length; i++) assert(s[i].ids[0] !== s[i - 1].ids[0], 'alternates');
|
||||
}
|
||||
});
|
||||
|
||||
test('randomInt is uniform (chi-square, 5 buckets)', () => {
|
||||
const N = 50000;
|
||||
const k = 5;
|
||||
const c = new Array(k).fill(0);
|
||||
for (let i = 0; i < N; i++) c[randomInt(k)]++;
|
||||
const e = N / k;
|
||||
const chi = c.reduce((s, x) => s + ((x - e) ** 2) / e, 0);
|
||||
assert(chi < 18.47, `chi² ${chi.toFixed(2)} (p<0.001 threshold 18.47) counts ${c}`);
|
||||
});
|
||||
|
||||
test('pick one is fair across fingers', () => {
|
||||
const ids = ['a', 'b', 'c'];
|
||||
const c = { a: 0, b: 0, c: 0 };
|
||||
for (let i = 0; i < 30000; i++) c[planRound('one', ids, {}).winners[0]]++;
|
||||
for (const id of ids) assert(Math.abs(c[id] / 30000 - 1 / 3) < 0.015, JSON.stringify(c));
|
||||
});
|
||||
|
||||
test('unknown pointer ids and double lifts are harmless', () => {
|
||||
const h = setup();
|
||||
h.up('nope');
|
||||
h.down('a');
|
||||
h.up('a');
|
||||
h.up('a');
|
||||
h.run(500);
|
||||
assert(h.game.phase === 'idle', h.game.phase);
|
||||
assert(h.game.rings.size === 0, 'pruned');
|
||||
});
|
||||
|
||||
test('recycled pointerIds still restart after a round (input -> game)', () => {
|
||||
const h = setup();
|
||||
const canvas = document.createElement('canvas');
|
||||
document.body.append(canvas);
|
||||
createInput(canvas, {
|
||||
debug: false,
|
||||
onDown: (id, x, y) => h.game.fingerDown(id, x, y, h.now),
|
||||
onMove: (id, x, y) => h.game.fingerMove(id, x, y),
|
||||
onUp: (id) => h.game.fingerUp(id, h.now),
|
||||
});
|
||||
const fire = (type, pointerId, x = 50, y = 50) =>
|
||||
canvas.dispatchEvent(new PointerEvent(type, { pointerId, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, cancelable: true }));
|
||||
fire('pointerdown', 1, 50, 50);
|
||||
fire('pointerdown', 2, 250, 250);
|
||||
h.until('result');
|
||||
fire('pointerup', 1);
|
||||
fire('pointerup', 2);
|
||||
h.run(TIMING.restartGraceMs + 100);
|
||||
assert(h.game.canRestart(h.now), 'can restart');
|
||||
fire('pointerdown', 1); // the browser reuses pointerId 1 for the tap
|
||||
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'tap with a recycled pointerId restarts, phase ' + h.game.phase);
|
||||
fire('pointerup', 1);
|
||||
h.run(500);
|
||||
assert(h.game.rings.size === 0, 'tap ring removed');
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
test('reveal follows a resize (entering fullscreen) without leaving an unpainted strip', () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 100;
|
||||
c.height = 200;
|
||||
const g = c.getContext('2d');
|
||||
const view = { w: 100, h: 100, R: 10 };
|
||||
const ring = { x: 50, y: 50 };
|
||||
const fx = createReveal('flood', { targets: [{ ring, color: '#ff0000' }], view, start: 0, palette: ['#ff0000'] });
|
||||
view.h = 200; // screen grew after the reveal started
|
||||
fx.drawBack(g, 5000);
|
||||
const px = g.getImageData(50, 190, 1, 1).data;
|
||||
assert(px[0] > 100 && px[3] === 255, 'bottom painted, got ' + Array.from(px));
|
||||
});
|
||||
|
||||
// Service worker: parses, and every precached file exists.
|
||||
try {
|
||||
const src = await (await fetch('../sw.js')).text();
|
||||
new Function(src); // syntax check only, not executed
|
||||
const shell = [...src.matchAll(/'(\.\/[^']*)'/g)].map((m) => m[1]);
|
||||
const missing = [];
|
||||
for (const u of shell) if (!(await fetch(new URL('../' + u, location.href), { cache: 'no-store' })).ok) missing.push(u);
|
||||
results.push(missing.length ? ['fail', 'sw.js precache missing: ' + missing] : ['pass', `sw.js parses; all ${shell.length} precached files exist`]);
|
||||
} catch (e) {
|
||||
results.push(['fail', 'sw.js: ' + e.message]);
|
||||
}
|
||||
|
||||
const out = document.getElementById('out');
|
||||
const failed = results.filter((r) => r[0] === 'fail').length;
|
||||
out.innerHTML = results.map(([cls, msg]) => `<span class="${cls}">${cls.toUpperCase()}</span> ${msg}`).join('\n') + `\n\n${results.length - failed}/${results.length} passed`;
|
||||
document.title = failed ? `FAIL ${failed}` : 'ALL PASS';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
57
tests/sim.html
Normal file
57
tests/sim.html
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Picker frame simulator</title>
|
||||
<link rel="stylesheet" href="../css/app.css">
|
||||
<style>#info{position:fixed;left:8px;bottom:8px;font:12px monospace;color:#8f8;background:#000a;padding:4px 8px}</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="stage"></canvas>
|
||||
<div id="info"></div>
|
||||
<script type="module">
|
||||
// Renders a single frame of a simulated game at time ?t= (ms) without
|
||||
// relying on requestAnimationFrame, so headless screenshots are deterministic.
|
||||
// ?mode=one|pickN|teams|order|elimination &n=4 &t=9000 &theme=neon
|
||||
// &style=flood|confetti|spotlight|shockwave|calm (force reveal style)
|
||||
// &lift=ms (one finger lifts at this time)
|
||||
import { Game } from '../js/game.js';
|
||||
import { Renderer } from '../js/renderer.js';
|
||||
import { getTheme } from '../js/themes.js';
|
||||
|
||||
const p = new URLSearchParams(location.search);
|
||||
const settings = { mode: p.get('mode') || 'one', pickN: +(p.get('k') || 2), teams: +(p.get('teams') || 2) };
|
||||
const n = +(p.get('n') || 4);
|
||||
const T = +(p.get('t') || 0);
|
||||
const lift = p.has('lift') ? +p.get('lift') : null;
|
||||
const events = [];
|
||||
const renderer = new Renderer(document.getElementById('stage'));
|
||||
renderer.setTheme(getTheme(p.get('theme') || 'neon'));
|
||||
const game = new Game({
|
||||
emit: (type, data) => { events.push(type); renderer.onEvent(type, data || {}, now, game); },
|
||||
getSettings: () => settings,
|
||||
isReduced: () => p.has('reduced'),
|
||||
});
|
||||
if (p.get('style')) {
|
||||
const orig = game.reveal.bind(game);
|
||||
game.reveal = (t, w) => { orig(t, w); if (game.revealStyle !== 'teams') { game.revealStyle = p.get('style'); renderer.onEvent('reveal', { style: game.revealStyle, winners: w, plan: game.plan }, t, game); } };
|
||||
}
|
||||
const base = performance.now() + 10;
|
||||
let now = base;
|
||||
const w = innerWidth, h = innerHeight, rad = Math.min(w, h) * 0.32;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
|
||||
game.fingerDown('f' + i, w / 2 + Math.cos(a) * rad, h / 2 + Math.sin(a) * rad, now);
|
||||
}
|
||||
let lifted = false;
|
||||
for (let t = 0; t <= T; t += 16) {
|
||||
now = base + t;
|
||||
if (lift != null && !lifted && t >= lift) { game.fingerUp('f0', now); lifted = true; }
|
||||
game.update(now);
|
||||
renderer.draw(game, now);
|
||||
}
|
||||
document.getElementById("info").textContent = `${innerWidth}x${innerHeight} c=${renderer.canvas.clientWidth}x${renderer.canvas.clientHeight} px=${renderer.canvas.width}x${renderer.canvas.height} dpr=${devicePixelRatio} t=${T} phase=${game.phase} style=${game.revealStyle} events=${[...new Set(events)].join(',')}`;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
71
tools/make_icons.py
Normal file
71
tools/make_icons.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Generate the PNG app icons (stdlib only, no dependencies).
|
||||
|
||||
python tools/make_icons.py
|
||||
|
||||
Draws three glowing neon rings on a dark background, matching icons/icon.svg.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
BG = (5, 5, 11)
|
||||
# (cx, cy, r, colour) in a 0..1 unit square
|
||||
RINGS = [
|
||||
(0.34, 0.40, 0.17, (255, 46, 99)),
|
||||
(0.66, 0.40, 0.17, (0, 229, 255)),
|
||||
(0.50, 0.68, 0.17, (255, 229, 0)),
|
||||
]
|
||||
LINE = 0.032 # ring stroke width
|
||||
GLOW = 0.05 # glow falloff
|
||||
|
||||
|
||||
def render(size, scale=1.0):
|
||||
"""scale < 1 shrinks the artwork towards the centre (maskable safe zone)."""
|
||||
rows = []
|
||||
for py in range(size):
|
||||
row = bytearray([0]) # filter type 0
|
||||
for px in range(size):
|
||||
x = ((px + 0.5) / size - 0.5) / scale + 0.5
|
||||
y = ((py + 0.5) / size - 0.5) / scale + 0.5
|
||||
r, g, b = (c / 255 for c in BG)
|
||||
for cx, cy, rr, col in RINGS:
|
||||
d = abs(math.hypot(x - cx, y - cy) - rr)
|
||||
aa = 1.0 / (size * scale)
|
||||
core = min(1.0, max(0.0, (LINE / 2 - d) / aa + 0.5))
|
||||
glow = math.exp(-max(0.0, d - LINE / 2) / GLOW) * 0.55
|
||||
k = max(core, glow)
|
||||
cr, cg, cb = (c / 255 for c in col)
|
||||
# additive, like the neon theme
|
||||
r, g, b = r + cr * k, g + cg * k, b + cb * k
|
||||
if core > 0: # white-hot centre line
|
||||
w = max(0.0, 1 - d / (LINE * 0.18)) * 0.7
|
||||
r, g, b = r + w, g + w, b + w
|
||||
row += bytes(min(255, int(v * 255 + 0.5)) for v in (r, g, b))
|
||||
rows.append(bytes(row))
|
||||
return b"".join(rows)
|
||||
|
||||
|
||||
def write_png(path, size, raw):
|
||||
def chunk(tag, data):
|
||||
c = struct.pack(">I", len(data)) + tag + data
|
||||
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8-bit RGB
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
|
||||
with open(path, "wb") as f:
|
||||
f.write(png)
|
||||
print("wrote", path)
|
||||
|
||||
|
||||
def main():
|
||||
out = os.path.join(os.path.dirname(__file__), "..", "icons")
|
||||
os.makedirs(out, exist_ok=True)
|
||||
write_png(os.path.join(out, "icon-192.png"), 192, render(192))
|
||||
write_png(os.path.join(out, "icon-512.png"), 512, render(512))
|
||||
write_png(os.path.join(out, "icon-maskable-512.png"), 512, render(512, scale=0.78))
|
||||
write_png(os.path.join(out, "apple-touch-icon.png"), 180, render(180, scale=0.9))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue