first app
This commit is contained in:
parent
ad890adcf1
commit
ac33e2fec6
30 changed files with 3475 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`.
|
||||||
|
|
|
||||||
271
css/app.css
Normal file
271
css/app.css
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
:root {
|
||||||
|
--bg: #05050b;
|
||||||
|
--fg: #f4f4fa;
|
||||||
|
--muted: #a3a3b8;
|
||||||
|
--panel: #161622;
|
||||||
|
--line: rgba(255, 255, 255, 0.12);
|
||||||
|
--accent: #00e5ff;
|
||||||
|
--accent-fg: #001318;
|
||||||
|
--safe-t: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-l: env(safe-area-inset-left, 0px);
|
||||||
|
--safe-r: env(safe-area-inset-right, 0px);
|
||||||
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
[data-theme="pastel"] { --bg: #1c1a26; --accent: #cdb4ff; --accent-fg: #1c1a26; --panel: #262334; }
|
||||||
|
[data-theme="contrast"] { --bg: #000; --accent: #ffd500; --accent-fg: #000; --panel: #000; --line: #fff; --muted: #e6e6e6; }
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font);
|
||||||
|
/* no scrolling, zooming, pull-to-refresh, selection or callouts */
|
||||||
|
touch-action: none;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage {
|
||||||
|
z-index: 0;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- overlay text ---- */
|
||||||
|
|
||||||
|
#overlay {
|
||||||
|
z-index: 1;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: calc(var(--safe-t) + 5rem) calc(var(--safe-r) + 2rem) calc(var(--safe-b) + 3rem) calc(var(--safe-l) + 2rem);
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: opacity 200ms;
|
||||||
|
}
|
||||||
|
.sheet-open #overlay { opacity: 0; }
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 22ch;
|
||||||
|
font-size: clamp(1.4rem, 6.5vw, 2.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-wrap: balance;
|
||||||
|
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.75), 0 0 2px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
.msg.hint { color: var(--muted); font-weight: 600; font-size: clamp(1.2rem, 5.5vw, 1.8rem); }
|
||||||
|
.msg.soft { color: var(--muted); font-weight: 600; font-size: clamp(1rem, 4.5vw, 1.4rem); }
|
||||||
|
.msg.count { font-size: clamp(3rem, 18vw, 6rem); font-weight: 800; opacity: 0.35; }
|
||||||
|
.msg.alert { font-size: clamp(1.5rem, 7vw, 2.4rem); }
|
||||||
|
.msg.result { font-size: clamp(1.8rem, 9vw, 3.2rem); font-weight: 800; }
|
||||||
|
.msg.in { animation: msg-in 380ms cubic-bezier(0.2, 1.4, 0.4, 1); }
|
||||||
|
|
||||||
|
.msg-sub {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 1.4em;
|
||||||
|
max-width: 32ch;
|
||||||
|
color: var(--fg);
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: clamp(0.95rem, 4vw, 1.1rem);
|
||||||
|
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes msg-in {
|
||||||
|
from { transform: scale(0.7); opacity: 0; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- toolbar: top centre, away from the side-edge back gesture ---- */
|
||||||
|
|
||||||
|
#toolbar {
|
||||||
|
z-index: 2;
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--safe-t) + 14px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
transition: opacity 200ms, transform 200ms;
|
||||||
|
}
|
||||||
|
#toolbar.hidden { opacity: 0; transform: translate(-50%, -8px); pointer-events: none; }
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--fg);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.tool:active { background: rgba(255, 255, 255, 0.16); }
|
||||||
|
.tool:focus-visible, .sheet-body button:focus-visible, .sheet-close:focus-visible, #stake-input:focus-visible {
|
||||||
|
outline: 3px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.tool svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.tool .off { display: none; }
|
||||||
|
#btn-sound.is-muted .on, #btn-fullscreen.is-full .on { display: none; }
|
||||||
|
#btn-sound.is-muted .off, #btn-fullscreen.is-full .off { display: inline; }
|
||||||
|
|
||||||
|
/* ---- settings sheet ---- */
|
||||||
|
|
||||||
|
#sheet-backdrop {
|
||||||
|
z-index: 3;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 220ms;
|
||||||
|
}
|
||||||
|
.sheet-open #sheet-backdrop { opacity: 1; }
|
||||||
|
|
||||||
|
#sheet {
|
||||||
|
z-index: 4;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: calc(100% - var(--safe-t) - 24px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 22px 22px 0 0;
|
||||||
|
box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 240ms cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||||
|
user-select: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.sheet-open #sheet { transform: none; }
|
||||||
|
|
||||||
|
.sheet-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px 8px;
|
||||||
|
}
|
||||||
|
.sheet-head h2 { margin: 0; font-size: 1.15rem; }
|
||||||
|
.sheet-close {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
touch-action: pan-y;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 4px 24px calc(var(--safe-b) + 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { padding: 12px 0; border-top: 1px solid var(--line); }
|
||||||
|
.field:first-child { border-top: 0; }
|
||||||
|
.field h3 { margin: 0 0 10px; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
.field.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.field.row h3 { margin: 0; }
|
||||||
|
.field[hidden] { display: none; }
|
||||||
|
.desc { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.seg { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.seg button, .chip, .stepper button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--fg);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.seg button[aria-pressed="true"], .chip[aria-pressed="true"] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.stepper button { width: 44px; padding: 0; font-size: 1.3rem; }
|
||||||
|
.stepper output { min-width: 2ch; text-align: center; font-size: 1.2rem; font-weight: 700; }
|
||||||
|
|
||||||
|
.stake-row { display: flex; gap: 8px; }
|
||||||
|
#stake-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem; /* ≥16px stops iOS zooming on focus */
|
||||||
|
color: var(--fg);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 14px;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
#stake-input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
|
.chip { min-height: 38px; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
[data-theme="contrast"] .seg button,
|
||||||
|
[data-theme="contrast"] .chip,
|
||||||
|
[data-theme="contrast"] .stepper button,
|
||||||
|
[data-theme="contrast"] #stake-input { border-width: 2px; background: #000; }
|
||||||
|
|
||||||
|
/* ---- debug ---- */
|
||||||
|
|
||||||
|
#debug-hud {
|
||||||
|
z-index: 5;
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--safe-b) + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font: 12px/1.4 ui-monospace, monospace;
|
||||||
|
color: #8f8;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.msg.in { animation-duration: 1ms; }
|
||||||
|
#sheet, #toolbar, #sheet-backdrop { transition-duration: 1ms; }
|
||||||
|
}
|
||||||
BIN
icons/apple-touch-icon.png
Normal file
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>
|
||||||
156
js/audio.js
Normal file
156
js/audio.js
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
// Sound effects synthesised with Web Audio (no audio files) + haptics.
|
||||||
|
//
|
||||||
|
// Browsers only allow audio and navigator.vibrate after a "user activation".
|
||||||
|
// A touch *down* doesn't count (only lifting a finger, a tap or a click), so
|
||||||
|
// we try to unlock on every interaction until it works.
|
||||||
|
|
||||||
|
let ctx = null;
|
||||||
|
let master = null;
|
||||||
|
let noiseBuf = null;
|
||||||
|
let muted = false;
|
||||||
|
let hapticsOn = true;
|
||||||
|
|
||||||
|
export function setMuted(m) {
|
||||||
|
muted = m;
|
||||||
|
if (master) master.gain.setTargetAtTime(m ? 0 : 0.8, ctx.currentTime, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHaptics(on) {
|
||||||
|
hapticsOn = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canVibrate = typeof navigator.vibrate === 'function';
|
||||||
|
|
||||||
|
export function buzz(pattern) {
|
||||||
|
if (!canVibrate || !hapticsOn) return;
|
||||||
|
try {
|
||||||
|
navigator.vibrate(pattern);
|
||||||
|
} catch {
|
||||||
|
/* blocked before user activation */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockAudio() {
|
||||||
|
try {
|
||||||
|
if (!ctx) {
|
||||||
|
const AC = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AC) return;
|
||||||
|
ctx = new AC();
|
||||||
|
master = ctx.createGain();
|
||||||
|
master.gain.value = muted ? 0 : 0.8;
|
||||||
|
const comp = ctx.createDynamicsCompressor();
|
||||||
|
master.connect(comp);
|
||||||
|
comp.connect(ctx.destination);
|
||||||
|
noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
||||||
|
const d = noiseBuf.getChannelData(0);
|
||||||
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
if (ctx.state !== 'running') ctx.resume().catch(() => {});
|
||||||
|
} catch {
|
||||||
|
/* audio unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installAudioUnlock() {
|
||||||
|
const opts = { capture: true, passive: true };
|
||||||
|
for (const t of ['pointerdown', 'pointerup', 'touchend', 'click', 'keydown']) window.addEventListener(t, unlockAudio, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = () => ctx && ctx.state === 'running' && !muted;
|
||||||
|
|
||||||
|
function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) {
|
||||||
|
const t0 = ctx.currentTime + at;
|
||||||
|
const o = ctx.createOscillator();
|
||||||
|
const g = ctx.createGain();
|
||||||
|
o.type = type;
|
||||||
|
o.frequency.setValueAtTime(freq, t0);
|
||||||
|
if (freqEnd) o.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t0 + attack);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
let node = o;
|
||||||
|
if (filter) {
|
||||||
|
const f = ctx.createBiquadFilter();
|
||||||
|
f.type = 'lowpass';
|
||||||
|
f.frequency.value = filter;
|
||||||
|
o.connect(f);
|
||||||
|
node = f;
|
||||||
|
}
|
||||||
|
node.connect(g);
|
||||||
|
g.connect(master);
|
||||||
|
o.start(t0);
|
||||||
|
o.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function noise({ at = 0, dur, gain = 0.2, type = 'bandpass', freq = 1000, freqEnd, q = 1 }) {
|
||||||
|
const t0 = ctx.currentTime + at;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noiseBuf;
|
||||||
|
const f = ctx.createBiquadFilter();
|
||||||
|
f.type = type;
|
||||||
|
f.Q.value = q;
|
||||||
|
f.frequency.setValueAtTime(freq, t0);
|
||||||
|
if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
src.connect(f);
|
||||||
|
f.connect(g);
|
||||||
|
g.connect(master);
|
||||||
|
src.start(t0);
|
||||||
|
src.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24];
|
||||||
|
const note = (base, semis) => base * Math.pow(2, semis / 12);
|
||||||
|
|
||||||
|
export const sfx = {
|
||||||
|
join(i) {
|
||||||
|
if (!live()) return;
|
||||||
|
tone({ freq: note(523, PENTA[i % PENTA.length]), dur: 0.14, gain: 0.12 });
|
||||||
|
},
|
||||||
|
beat(i) {
|
||||||
|
if (!live()) return;
|
||||||
|
tone({ type: 'triangle', freq: note(392, i * 5), dur: 0.18, gain: 0.25 });
|
||||||
|
},
|
||||||
|
/** Roulette tick, rising in pitch as the wheel slows. */
|
||||||
|
tick(progress, final) {
|
||||||
|
if (!live()) return;
|
||||||
|
if (final) {
|
||||||
|
tone({ type: 'square', freq: 1320, dur: 0.12, gain: 0.08, filter: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tone({ type: 'square', freq: 420 + progress * 900, dur: 0.035, gain: 0.07, filter: 3500 });
|
||||||
|
noise({ dur: 0.02, gain: 0.05, type: 'highpass', freq: 3000 });
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
if (!live()) return;
|
||||||
|
// whoosh + boom + bright major arpeggio
|
||||||
|
noise({ dur: 0.5, gain: 0.25, type: 'bandpass', freq: 400, freqEnd: 4000, q: 0.8 });
|
||||||
|
tone({ freq: 140, freqEnd: 45, dur: 0.6, gain: 0.5 });
|
||||||
|
[0, 4, 7, 12, 16].forEach((s, i) => {
|
||||||
|
tone({ type: 'triangle', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.9, gain: 0.16 });
|
||||||
|
tone({ type: 'sawtooth', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.7, gain: 0.04, filter: 2200 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
crack() {
|
||||||
|
if (!live()) return;
|
||||||
|
noise({ dur: 0.3, gain: 0.45, type: 'highpass', freq: 1800 });
|
||||||
|
noise({ at: 0.04, dur: 0.18, gain: 0.25, type: 'bandpass', freq: 5000, q: 4 });
|
||||||
|
tone({ freq: 220, freqEnd: 55, dur: 0.35, gain: 0.35 });
|
||||||
|
tone({ type: 'triangle', freq: 2637, at: 0.02, dur: 0.25, gain: 0.05 });
|
||||||
|
tone({ type: 'triangle', freq: 3520, at: 0.07, dur: 0.2, gain: 0.04 });
|
||||||
|
},
|
||||||
|
/** Sad trombone. */
|
||||||
|
chicken() {
|
||||||
|
if (!live()) return;
|
||||||
|
const parts = [[392, 370, 0], [370, 349, 0.28], [349, 330, 0.56]];
|
||||||
|
for (const [a, b, at] of parts) tone({ type: 'sawtooth', freq: a, freqEnd: b, at, dur: 0.26, gain: 0.12, filter: 1200, attack: 0.03 });
|
||||||
|
tone({ type: 'sawtooth', freq: 330, freqEnd: 247, at: 0.84, dur: 0.75, gain: 0.12, filter: 1000, attack: 0.03 });
|
||||||
|
},
|
||||||
|
pop(n) {
|
||||||
|
if (!live()) return;
|
||||||
|
tone({ freq: note(660, PENTA[(n - 1) % PENTA.length]), freqEnd: note(330, PENTA[(n - 1) % PENTA.length]), dur: 0.14, gain: 0.25 });
|
||||||
|
},
|
||||||
|
};
|
||||||
65
js/device.js
Normal file
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
js/i18n.js
Normal file
171
js/i18n.js
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
// All user-facing strings live here.
|
||||||
|
// Stakes are phrased so they read naturally after "The chosen one has to…"
|
||||||
|
// (en, bare verb) / "De gekozene…" (nl, conjugated verb), and slot into
|
||||||
|
// "You {stake}!" / "Jij {stake}!" on the reveal.
|
||||||
|
|
||||||
|
export const translations = {
|
||||||
|
en: {
|
||||||
|
appTitle: 'Picker',
|
||||||
|
hint: 'Everyone put a finger on the screen',
|
||||||
|
needMore: 'Waiting for more fingers…',
|
||||||
|
holdStill: 'Hold still…',
|
||||||
|
chicken: [
|
||||||
|
'Someone chickened out! 🐔',
|
||||||
|
'Cold feet? Someone let go! 🐔',
|
||||||
|
'Bawk bawk! Who let go? 🐔',
|
||||||
|
],
|
||||||
|
chickenOut: 'Chickened out! 🐔',
|
||||||
|
eliminated: 'Knocked out!',
|
||||||
|
liftFinger: 'Lift your finger',
|
||||||
|
youreIt: "You're it!",
|
||||||
|
youStake: 'You {stake}!',
|
||||||
|
teamsReady: 'Teams are set!',
|
||||||
|
orderDone: "That's the order!",
|
||||||
|
lastStanding: 'Last one standing!',
|
||||||
|
tapRestart: 'Tap to play again',
|
||||||
|
desktopHint: 'On a computer? Add ?debug=1 to the address to test with a mouse.',
|
||||||
|
team: 'Team {name}',
|
||||||
|
|
||||||
|
modeLabel: 'Mode',
|
||||||
|
mode: {
|
||||||
|
one: 'Pick one',
|
||||||
|
pickN: 'Pick several',
|
||||||
|
teams: 'Teams',
|
||||||
|
order: 'Order',
|
||||||
|
elimination: 'Elimination',
|
||||||
|
},
|
||||||
|
modeDesc: {
|
||||||
|
one: 'One finger is chosen.',
|
||||||
|
pickN: 'Several fingers are chosen.',
|
||||||
|
teams: 'Everyone is split into teams.',
|
||||||
|
order: 'Everyone gets a number, one by one.',
|
||||||
|
elimination: 'One finger is knocked out each round until one remains.',
|
||||||
|
},
|
||||||
|
pickNLabel: 'How many to pick',
|
||||||
|
teamsLabel: 'Number of teams',
|
||||||
|
stakesLabel: 'The chosen one has to…',
|
||||||
|
stakesPlaceholder: 'e.g. pay the bill',
|
||||||
|
stakesClear: 'Clear',
|
||||||
|
stakePresets: {
|
||||||
|
bill: 'pay the bill',
|
||||||
|
dishes: 'do the dishes',
|
||||||
|
first: 'go first',
|
||||||
|
round: 'get the next round',
|
||||||
|
movie: 'pick the movie',
|
||||||
|
},
|
||||||
|
themeLabel: 'Theme',
|
||||||
|
theme: { neon: 'Neon', pastel: 'Pastel', contrast: 'High contrast' },
|
||||||
|
languageLabel: 'Language',
|
||||||
|
soundLabel: 'Sound',
|
||||||
|
hapticsLabel: 'Vibration',
|
||||||
|
on: 'On',
|
||||||
|
off: 'Off',
|
||||||
|
done: 'Done',
|
||||||
|
settings: 'Settings',
|
||||||
|
mute: 'Mute sound',
|
||||||
|
unmute: 'Unmute sound',
|
||||||
|
fullscreen: 'Full screen',
|
||||||
|
exitFullscreen: 'Exit full screen',
|
||||||
|
decrease: 'Fewer',
|
||||||
|
increase: 'More',
|
||||||
|
},
|
||||||
|
|
||||||
|
nl: {
|
||||||
|
appTitle: 'Picker',
|
||||||
|
hint: 'Iedereen een vinger op het scherm',
|
||||||
|
needMore: 'Wachten op meer vingers…',
|
||||||
|
holdStill: 'Stil houden…',
|
||||||
|
chicken: [
|
||||||
|
'Iemand is er tussenuit geknepen! 🐔',
|
||||||
|
'Koudwatervrees? Iemand liet los! 🐔',
|
||||||
|
'Tok tok! Wie liet er los? 🐔',
|
||||||
|
],
|
||||||
|
chickenOut: 'Laf losgelaten! 🐔',
|
||||||
|
eliminated: 'Uitgeschakeld!',
|
||||||
|
liftFinger: 'Haal je vinger weg',
|
||||||
|
youreIt: 'Jij bent het!',
|
||||||
|
youStake: 'Jij {stake}!',
|
||||||
|
teamsReady: 'De teams staan!',
|
||||||
|
orderDone: 'Dat is de volgorde!',
|
||||||
|
lastStanding: 'Laatste overlevende!',
|
||||||
|
tapRestart: 'Tik om opnieuw te spelen',
|
||||||
|
desktopHint: 'Op een computer? Zet ?debug=1 achter het adres om met de muis te testen.',
|
||||||
|
team: 'Team {name}',
|
||||||
|
|
||||||
|
modeLabel: 'Modus',
|
||||||
|
mode: {
|
||||||
|
one: 'Kies één',
|
||||||
|
pickN: 'Kies meerdere',
|
||||||
|
teams: 'Teams',
|
||||||
|
order: 'Volgorde',
|
||||||
|
elimination: 'Afvalrace',
|
||||||
|
},
|
||||||
|
modeDesc: {
|
||||||
|
one: 'Eén vinger wordt gekozen.',
|
||||||
|
pickN: 'Meerdere vingers worden gekozen.',
|
||||||
|
teams: 'Iedereen wordt in teams verdeeld.',
|
||||||
|
order: 'Iedereen krijgt een nummer, één voor één.',
|
||||||
|
elimination: 'Elke ronde valt er één af, tot er één overblijft.',
|
||||||
|
},
|
||||||
|
pickNLabel: 'Hoeveel kiezen',
|
||||||
|
teamsLabel: 'Aantal teams',
|
||||||
|
stakesLabel: 'De gekozene…',
|
||||||
|
stakesPlaceholder: 'bijv. betaalt de rekening',
|
||||||
|
stakesClear: 'Wissen',
|
||||||
|
stakePresets: {
|
||||||
|
bill: 'betaalt de rekening',
|
||||||
|
dishes: 'doet de afwas',
|
||||||
|
first: 'mag beginnen',
|
||||||
|
round: 'haalt het volgende rondje',
|
||||||
|
movie: 'kiest de film',
|
||||||
|
},
|
||||||
|
themeLabel: 'Thema',
|
||||||
|
theme: { neon: 'Neon', pastel: 'Pastel', contrast: 'Hoog contrast' },
|
||||||
|
languageLabel: 'Taal',
|
||||||
|
soundLabel: 'Geluid',
|
||||||
|
hapticsLabel: 'Trillen',
|
||||||
|
on: 'Aan',
|
||||||
|
off: 'Uit',
|
||||||
|
done: 'Klaar',
|
||||||
|
settings: 'Instellingen',
|
||||||
|
mute: 'Geluid uit',
|
||||||
|
unmute: 'Geluid aan',
|
||||||
|
fullscreen: 'Volledig scherm',
|
||||||
|
exitFullscreen: 'Volledig scherm sluiten',
|
||||||
|
decrease: 'Minder',
|
||||||
|
increase: 'Meer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LANGS = ['en', 'nl'];
|
||||||
|
let current = 'en';
|
||||||
|
|
||||||
|
export function detectLang() {
|
||||||
|
const prefs = navigator.languages?.length ? navigator.languages : [navigator.language || 'en'];
|
||||||
|
for (const p of prefs) {
|
||||||
|
const base = String(p).toLowerCase().split('-')[0];
|
||||||
|
if (LANGS.includes(base)) return base;
|
||||||
|
}
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLang(lang) {
|
||||||
|
current = LANGS.includes(lang) ? lang : 'en';
|
||||||
|
document.documentElement.lang = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLang() {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a dotted key ("mode.one"); interpolate {vars}. Falls back to English. */
|
||||||
|
export function t(key, vars) {
|
||||||
|
const lookup = (lang) => key.split('.').reduce((o, k) => (o == null ? o : o[k]), translations[lang]);
|
||||||
|
let s = lookup(current);
|
||||||
|
if (s == null) s = lookup('en');
|
||||||
|
if (s == null) return key;
|
||||||
|
if (typeof s === 'string' && vars) {
|
||||||
|
s = s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
149
js/input.js
Normal file
149
js/input.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
// Pointer input. Fingers are tracked by pointerId; pointercancel counts as
|
||||||
|
// a lift (Android cancels touches it thinks are palms or system gestures).
|
||||||
|
//
|
||||||
|
// Debug mode (?debug=1): mouse clicks place persistent fake fingers.
|
||||||
|
// Click a fake finger again to remove it; drag it to move it.
|
||||||
|
// Keys: A adds a fake finger at a random spot, C clears all fake fingers.
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 6;
|
||||||
|
|
||||||
|
export function createInput(canvas, { debug, onDown, onMove, onUp }) {
|
||||||
|
const active = new Set(); // real pointer ids currently down
|
||||||
|
const fakes = new Map(); // fake id -> {x, y}
|
||||||
|
let fakeSeq = 0;
|
||||||
|
let mouse = null; // {pointerId, fakeId, startX, startY, moved, created}
|
||||||
|
|
||||||
|
const now = () => performance.now();
|
||||||
|
const hitRadius = () => Math.max(40, Math.min(64, Math.min(innerWidth, innerHeight) * 0.115));
|
||||||
|
|
||||||
|
function release(pointerId) {
|
||||||
|
if (!active.delete(pointerId)) return;
|
||||||
|
onUp(String(pointerId), now());
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
if (debug && e.button === 0) debugDown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* not fatal */
|
||||||
|
}
|
||||||
|
active.add(e.pointerId);
|
||||||
|
onDown(String(e.pointerId), e.clientX, e.clientY, now());
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (mouse && e.pointerId === mouse.pointerId) return debugMove(e);
|
||||||
|
if (!active.has(e.pointerId)) return;
|
||||||
|
onMove(String(e.pointerId), e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
const up = (e) => {
|
||||||
|
if (mouse && e.pointerId === mouse.pointerId) return debugUp(e);
|
||||||
|
release(e.pointerId);
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointerup', up);
|
||||||
|
canvas.addEventListener('pointercancel', up);
|
||||||
|
// Safety net: if capture is lost without an up/cancel, treat it as a lift.
|
||||||
|
canvas.addEventListener('lostpointercapture', (e) => {
|
||||||
|
if (!mouse || e.pointerId !== mouse.pointerId) release(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- debug: fake fingers with the mouse ----
|
||||||
|
|
||||||
|
function fakeAt(x, y) {
|
||||||
|
const r = hitRadius();
|
||||||
|
for (const [id, f] of fakes) if (Math.hypot(f.x - x, f.y - y) < r) return id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFake(x, y) {
|
||||||
|
const id = `fake-${++fakeSeq}`;
|
||||||
|
fakes.set(id, { x, y });
|
||||||
|
onDown(id, x, y, now());
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFake(id) {
|
||||||
|
if (!fakes.delete(id)) return;
|
||||||
|
onUp(id, now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugDown(e) {
|
||||||
|
const hit = fakeAt(e.clientX, e.clientY);
|
||||||
|
const fakeId = hit ?? addFake(e.clientX, e.clientY);
|
||||||
|
mouse = { pointerId: e.pointerId, fakeId, startX: e.clientX, startY: e.clientY, moved: false, created: !hit };
|
||||||
|
try {
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugMove(e) {
|
||||||
|
if (!mouse.moved && Math.hypot(e.clientX - mouse.startX, e.clientY - mouse.startY) > DRAG_THRESHOLD) mouse.moved = true;
|
||||||
|
if (mouse.moved && fakes.has(mouse.fakeId)) {
|
||||||
|
fakes.set(mouse.fakeId, { x: e.clientX, y: e.clientY });
|
||||||
|
onMove(mouse.fakeId, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugUp() {
|
||||||
|
const m = mouse;
|
||||||
|
mouse = null;
|
||||||
|
if (!m.created && !m.moved) removeFake(m.fakeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target instanceof HTMLInputElement) return;
|
||||||
|
if (e.key === 'a' || e.key === 'A') {
|
||||||
|
const m = 80;
|
||||||
|
addFake(m + Math.random() * (innerWidth - 2 * m), m + Math.random() * (innerHeight - 2 * m));
|
||||||
|
} else if (e.key === 'c' || e.key === 'C') {
|
||||||
|
for (const id of [...fakes.keys()]) removeFake(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Forget all touches (page hidden). Fake fingers are kept. */
|
||||||
|
clearReal() {
|
||||||
|
active.clear();
|
||||||
|
},
|
||||||
|
/** Place fake fingers in a circle (debug ?fingers=N). */
|
||||||
|
addFakeCircle(n) {
|
||||||
|
const cx = innerWidth / 2;
|
||||||
|
const cy = innerHeight / 2;
|
||||||
|
const rad = Math.min(innerWidth, innerHeight) * 0.32;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
|
||||||
|
addFake(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fakeCount: () => fakes.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the browser from scrolling, zooming, selecting or opening menus. */
|
||||||
|
export function blockBrowserGestures(canvas) {
|
||||||
|
const opts = { passive: false };
|
||||||
|
// iOS ignores touch-action for some gestures; cancel touches on the canvas.
|
||||||
|
canvas.addEventListener('touchstart', (e) => e.preventDefault(), opts);
|
||||||
|
canvas.addEventListener('touchmove', (e) => e.preventDefault(), opts);
|
||||||
|
canvas.addEventListener('touchend', (e) => e.preventDefault(), opts);
|
||||||
|
// iOS pinch-zoom
|
||||||
|
for (const t of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(t, (e) => e.preventDefault(), opts);
|
||||||
|
// No long-press menus or text selection anywhere except form fields.
|
||||||
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
||||||
|
});
|
||||||
|
document.addEventListener('selectstart', (e) => {
|
||||||
|
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
||||||
|
});
|
||||||
|
document.addEventListener('dblclick', (e) => e.preventDefault(), opts);
|
||||||
|
}
|
||||||
136
js/main.js
Normal file
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, w: this.w, h: this.h, R, start: now, palette: this.theme.colors });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'phase':
|
||||||
|
if (data.phase !== 'result') this.reveal = null;
|
||||||
|
if (data.phase === 'idle' || data.phase === 'gathering') this.shards.clear();
|
||||||
|
break;
|
||||||
|
case 'restart':
|
||||||
|
this.reveal = null;
|
||||||
|
this.shards.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sprites -----------------------------------------------------------
|
||||||
|
|
||||||
|
sprite(color) {
|
||||||
|
let s = this.sprites.get(color);
|
||||||
|
if (s) return s;
|
||||||
|
const { dpr, theme } = this;
|
||||||
|
const r = this.R * SPRITE_SCALE;
|
||||||
|
const lw = this.lineWidth() * SPRITE_SCALE;
|
||||||
|
const pad = r * 0.55;
|
||||||
|
const half = r + pad;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = Math.ceil(half * 2 * dpr);
|
||||||
|
const g = c.getContext('2d');
|
||||||
|
g.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// faint inner fill
|
||||||
|
const fill = g.createRadialGradient(half, half, r * 0.3, half, half, r);
|
||||||
|
fill.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
fill.addColorStop(1, color + '38');
|
||||||
|
g.fillStyle = fill;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(half, half, r, 0, TAU);
|
||||||
|
g.fill();
|
||||||
|
|
||||||
|
g.strokeStyle = color;
|
||||||
|
g.lineWidth = lw;
|
||||||
|
if (theme.glow) {
|
||||||
|
g.shadowColor = color;
|
||||||
|
g.shadowBlur = r * 0.5 * theme.glow * dpr;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(half, half, r, 0, TAU);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(half, half, r, 0, TAU);
|
||||||
|
g.stroke();
|
||||||
|
g.shadowBlur = 0;
|
||||||
|
if (theme.additive) {
|
||||||
|
// white-hot core, neon tube style
|
||||||
|
g.strokeStyle = 'rgba(255,255,255,0.7)';
|
||||||
|
g.lineWidth = lw * 0.3;
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
s = { canvas: c, half, base: r };
|
||||||
|
this.sprites.set(color, s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- drawing -----------------------------------------------------------
|
||||||
|
|
||||||
|
draw(game, now) {
|
||||||
|
// Cheaper and more reliable than trusting every resize/rotation event.
|
||||||
|
const c = this.canvas;
|
||||||
|
if (c.clientWidth !== this.w || c.clientHeight !== this.h || Math.min(window.devicePixelRatio || 1, 3) !== this.dpr) this.resize();
|
||||||
|
const { ctx, dpr, w, h, theme } = this;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
const shake = this.reveal?.shake?.(now);
|
||||||
|
if (shake) ctx.translate(shake[0], shake[1]);
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = theme.bg;
|
||||||
|
ctx.fillRect(-20, -20, w + 40, h + 40);
|
||||||
|
|
||||||
|
if (this.reveal) this.reveal.drawBack(ctx, now);
|
||||||
|
|
||||||
|
// Two passes so highlighted rings sit on top.
|
||||||
|
if (theme.additive) ctx.globalCompositeOperation = 'lighter';
|
||||||
|
const hl = game.highlight;
|
||||||
|
for (const ring of game.rings.values()) if (!hl?.has(ring.id)) this.drawRing(ctx, ring, game, now);
|
||||||
|
for (const ring of game.rings.values()) if (hl?.has(ring.id)) this.drawRing(ctx, ring, game, now);
|
||||||
|
|
||||||
|
this.ripples = this.ripples.filter((r) => !r.done(now));
|
||||||
|
for (const r of this.ripples) r.draw(ctx, now);
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
|
if (this.reveal) this.reveal.drawFront(ctx, now);
|
||||||
|
this.drawLabels(ctx, game, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRing(ctx, ring, game, now) {
|
||||||
|
const { theme } = this;
|
||||||
|
const R = this.R;
|
||||||
|
let scale = 1;
|
||||||
|
let alpha = 1;
|
||||||
|
let color = this.colorOf(ring);
|
||||||
|
let shapeIdx = ring.colorIdx;
|
||||||
|
|
||||||
|
const appear = clamp01((now - ring.bornAt) / 320);
|
||||||
|
if (appear < 1) scale *= easeOutBack(appear);
|
||||||
|
if (ring.goneAt) {
|
||||||
|
const k = clamp01((now - ring.goneAt) / TIMING.ringFadeMs);
|
||||||
|
scale *= 1 - 0.5 * k;
|
||||||
|
alpha *= 1 - k;
|
||||||
|
}
|
||||||
|
if (ring.late) {
|
||||||
|
this.drawShape(ctx, ring.x, ring.y, R * 0.75 * scale, theme.spectator, alpha * 0.6, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ring.outAt) {
|
||||||
|
this.drawCrack(ctx, ring, color, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = game.phase;
|
||||||
|
const mode = game.plan?.mode;
|
||||||
|
const hl = game.highlight;
|
||||||
|
const isHl = !!hl?.has(ring.id);
|
||||||
|
const pop = Math.exp(-(now - game.stepAt) / 90);
|
||||||
|
|
||||||
|
if (phase === 'idle' || phase === 'gathering' || phase === 'chicken') {
|
||||||
|
scale *= 1 + 0.05 * Math.sin(((now - ring.bornAt) / 1000) * TAU * 0.7);
|
||||||
|
if (phase === 'chicken') alpha *= 0.55;
|
||||||
|
} else if (phase === 'countdown') {
|
||||||
|
const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs;
|
||||||
|
scale *= 1 + 0.2 * Math.exp(-bp * 6);
|
||||||
|
} else if (mode === 'teams') {
|
||||||
|
const team = phase === 'result' ? game.plan.teams.get(ring.id) : game.displayTeams?.get(ring.id);
|
||||||
|
if (team != null) {
|
||||||
|
color = theme.team[team];
|
||||||
|
shapeIdx = team;
|
||||||
|
}
|
||||||
|
scale *= phase === 'result' ? 1.1 + 0.04 * Math.sin(now / 300) : 1 + 0.1 * pop;
|
||||||
|
} else if (phase === 'result') {
|
||||||
|
if (mode === 'order') {
|
||||||
|
if (isHl) scale *= 1.3 + 0.05 * Math.sin(now / 250);
|
||||||
|
} else if (isHl) {
|
||||||
|
scale *= 1.35 + 0.05 * Math.sin(now / 250);
|
||||||
|
if (this.reveal?.winnerWhite) color = '#ffffff';
|
||||||
|
} else {
|
||||||
|
const k = clamp01((now - game.revealAt) / 650);
|
||||||
|
if (k >= 1) return;
|
||||||
|
scale *= 1 - k * k;
|
||||||
|
alpha *= 1 - k;
|
||||||
|
}
|
||||||
|
} else if (phase === 'ordering') {
|
||||||
|
const num = game.orderNums.get(ring.id);
|
||||||
|
if (isHl) scale *= 1.2 + 0.15 * pop;
|
||||||
|
else if (!num) {
|
||||||
|
scale *= 0.85;
|
||||||
|
alpha *= 0.4;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// suspense / elimPause
|
||||||
|
if (isHl) scale *= 1.18 + 0.14 * pop;
|
||||||
|
else if (hl) {
|
||||||
|
scale *= 0.86;
|
||||||
|
alpha *= 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawShape(ctx, ring.x, ring.y, R * scale, color, alpha, theme.shapes ? shapeIdx : -1);
|
||||||
|
|
||||||
|
if (phase === 'gathering') {
|
||||||
|
const p = game.stableProgress(now);
|
||||||
|
if (p > 0.08) this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.55, p);
|
||||||
|
} else if (phase === 'countdown') {
|
||||||
|
const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs;
|
||||||
|
this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.3 + 0.6 * Math.exp(-bp * 5), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawArc(ctx, x, y, r, color, alpha, p) {
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = Math.max(2, this.R * 0.05);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, -Math.PI / 2, -Math.PI / 2 + TAU * p);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ring (sprite) or, in high-contrast, a shape outline. */
|
||||||
|
drawShape(ctx, x, y, r, color, alpha, shapeIdx) {
|
||||||
|
if (r <= 0.5 || alpha <= 0.01) return;
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
if (shapeIdx >= 0) {
|
||||||
|
const lw = this.lineWidth();
|
||||||
|
tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r);
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = '#000';
|
||||||
|
ctx.lineWidth = lw + 6;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = lw;
|
||||||
|
ctx.stroke();
|
||||||
|
// second, inner outline doubles as a pattern cue
|
||||||
|
tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r * 0.72);
|
||||||
|
ctx.lineWidth = Math.max(2, lw * 0.35);
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
const s = this.sprite(color);
|
||||||
|
const k = r / s.base;
|
||||||
|
const size = s.half * 2 * k;
|
||||||
|
ctx.drawImage(s.canvas, x - s.half * k, y - s.half * k, size, size);
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elimination: the ring cracks, splits into shards that fall and fade. */
|
||||||
|
drawCrack(ctx, ring, color, now) {
|
||||||
|
const R = this.R;
|
||||||
|
const t = (now - ring.outAt) / 1000;
|
||||||
|
if (t > 1.3) return;
|
||||||
|
let shards = this.shards.get(ring.id);
|
||||||
|
if (!shards) {
|
||||||
|
shards = [];
|
||||||
|
const n = 7;
|
||||||
|
let a = Math.random() * TAU;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const span = (TAU / n) * (0.7 + Math.random() * 0.6);
|
||||||
|
const mid = a + span / 2;
|
||||||
|
shards.push({ a0: a, a1: a + span * 0.92, dx: Math.cos(mid), dy: Math.sin(mid), v: 70 + Math.random() * 110, spin: (Math.random() - 0.5) * 4 });
|
||||||
|
a += span;
|
||||||
|
}
|
||||||
|
const cracks = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const ang = Math.random() * TAU;
|
||||||
|
const pts = [];
|
||||||
|
for (let j = 0; j <= 4; j++) {
|
||||||
|
const rr = R * (0.55 + j * 0.2);
|
||||||
|
const jit = (Math.random() - 0.5) * 0.35;
|
||||||
|
pts.push([Math.cos(ang + jit) * rr, Math.sin(ang + jit) * rr]);
|
||||||
|
}
|
||||||
|
cracks.push(pts);
|
||||||
|
}
|
||||||
|
shards.cracks = cracks;
|
||||||
|
this.shards.set(ring.id, shards);
|
||||||
|
}
|
||||||
|
const lw = this.lineWidth();
|
||||||
|
if (t < 0.18) {
|
||||||
|
// intact, flashing white and trembling, cracks spreading
|
||||||
|
const j = 3 * (1 - t / 0.18);
|
||||||
|
const x = ring.x + (Math.random() - 0.5) * j;
|
||||||
|
const y = ring.y + (Math.random() - 0.5) * j;
|
||||||
|
this.drawShape(ctx, x, y, R * 1.1, t < 0.06 ? '#ffffff' : color, 1, this.theme.shapes ? ring.colorIdx : -1);
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
const k = t / 0.18;
|
||||||
|
for (const pts of shards.cracks) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const m = Math.max(2, Math.ceil(pts.length * k));
|
||||||
|
for (let i = 0; i < m && i < pts.length; i++) ctx[i ? 'lineTo' : 'moveTo'](x + pts[i][0], y + pts[i][1]);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tt = t - 0.18;
|
||||||
|
const fade = clamp01(1 - tt / 1.0);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
for (const s of shards) {
|
||||||
|
const d = s.v * tt;
|
||||||
|
const x = ring.x + s.dx * d;
|
||||||
|
const y = ring.y + s.dy * d + 500 * tt * tt;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate(s.spin * tt);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.globalAlpha = fade * 0.35;
|
||||||
|
ctx.lineWidth = lw * 2.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, R * 1.1, s.a0, s.a1);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = fade;
|
||||||
|
ctx.lineWidth = lw;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number / team badges, placed on the side of the finger facing the
|
||||||
|
* screen centre, since the hand covers the other side. */
|
||||||
|
drawLabels(ctx, game, now) {
|
||||||
|
const mode = game.plan?.mode;
|
||||||
|
const showOrder = mode === 'order' && (game.phase === 'ordering' || game.phase === 'result');
|
||||||
|
const showTeams = mode === 'teams' && game.phase === 'result';
|
||||||
|
if (!showOrder && !showTeams) return;
|
||||||
|
const R = this.R;
|
||||||
|
const br = R * 0.42;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = `800 ${Math.round(br * 1.1)}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
|
||||||
|
for (const ring of game.rings.values()) {
|
||||||
|
let text;
|
||||||
|
let color;
|
||||||
|
let at;
|
||||||
|
if (showOrder) {
|
||||||
|
const o = game.orderNums.get(ring.id);
|
||||||
|
if (!o) continue;
|
||||||
|
text = String(o.n);
|
||||||
|
color = this.colorOf(ring);
|
||||||
|
at = o.at;
|
||||||
|
} else {
|
||||||
|
const team = game.plan.teams.get(ring.id);
|
||||||
|
if (team == null) continue;
|
||||||
|
text = TEAM_NAMES[team];
|
||||||
|
color = this.theme.team[team];
|
||||||
|
at = game.revealAt;
|
||||||
|
}
|
||||||
|
let dx = this.w / 2 - ring.x;
|
||||||
|
let dy = this.h / 2 - ring.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < R) {
|
||||||
|
dx = 0;
|
||||||
|
dy = -1;
|
||||||
|
} else {
|
||||||
|
dx /= len;
|
||||||
|
dy /= len;
|
||||||
|
}
|
||||||
|
const s = easeOutBack(clamp01((now - at) / 350));
|
||||||
|
const dist = R * 1.95;
|
||||||
|
const x = ring.x + dx * dist;
|
||||||
|
const y = ring.y + dy * dist;
|
||||||
|
ctx.globalAlpha = clamp01(s);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, br * s, 0, TAU);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeStyle = '#000';
|
||||||
|
ctx.stroke();
|
||||||
|
if (s > 0.3) {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillText(text, x, y + br * 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tracePath(ctx, shape, x, y, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const poly = (n, rot, rr = r) => {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = rot + (i / n) * TAU;
|
||||||
|
ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
};
|
||||||
|
switch (shape) {
|
||||||
|
case 'square':
|
||||||
|
poly(4, Math.PI / 4, r * 1.05);
|
||||||
|
break;
|
||||||
|
case 'triangle':
|
||||||
|
poly(3, -Math.PI / 2, r * 1.2);
|
||||||
|
break;
|
||||||
|
case 'diamond':
|
||||||
|
poly(4, 0, r * 1.15);
|
||||||
|
break;
|
||||||
|
case 'hexagon':
|
||||||
|
poly(6, 0);
|
||||||
|
break;
|
||||||
|
case 'pentagon':
|
||||||
|
poly(5, -Math.PI / 2, r * 1.05);
|
||||||
|
break;
|
||||||
|
case 'star':
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const a = -Math.PI / 2 + (i / 10) * TAU;
|
||||||
|
const rr = i % 2 ? r * 0.62 : r * 1.2;
|
||||||
|
ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
break;
|
||||||
|
case 'cross': {
|
||||||
|
const a = r * 0.42;
|
||||||
|
const b = r * 1.05;
|
||||||
|
const pts = [[-a, -b], [a, -b], [a, -a], [b, -a], [b, a], [a, a], [a, b], [-a, b], [-a, a], [-b, a], [-b, -a], [-a, -a]];
|
||||||
|
pts.forEach(([px, py], i) => ctx[i ? 'lineTo' : 'moveTo'](x + px, y + py));
|
||||||
|
ctx.closePath();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
ctx.arc(x, y, r, 0, TAU);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
js/reveals.js
Normal file
257
js/reveals.js
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
// Reveal animations, drawn by the renderer on top of / behind the rings.
|
||||||
|
// Each reveal gets live ring objects so it follows fingers that still move.
|
||||||
|
|
||||||
|
export const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||||
|
export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
||||||
|
export const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
||||||
|
|
||||||
|
export function hexToRgba(hex, a) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function farthestCorner(x, y, w, h) {
|
||||||
|
return Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} style flood | confetti | spotlight | shockwave | calm | teams
|
||||||
|
* @param {{targets:{ring:{x:number,y:number}, color:string}[], w:number, h:number, R:number, start:number, palette:string[]}} o
|
||||||
|
*/
|
||||||
|
export function createReveal(style, o) {
|
||||||
|
const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm;
|
||||||
|
return { winnerWhite: false, drawBack() {}, drawFront() {}, shake: null, ...make(o) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flood({ targets, w, h, start }) {
|
||||||
|
const single = targets.length === 1;
|
||||||
|
return {
|
||||||
|
winnerWhite: true,
|
||||||
|
drawBack(g, now) {
|
||||||
|
const k = easeInOutCubic(clamp01((now - start) / 850));
|
||||||
|
g.globalAlpha = single ? 1 : 0.8;
|
||||||
|
for (const { ring, color } of targets) {
|
||||||
|
const maxR = farthestCorner(ring.x, ring.y, w, h) * (single ? 1 : 0.5);
|
||||||
|
g.fillStyle = color;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(ring.x, ring.y, Math.max(0, maxR * k), 0, Math.PI * 2);
|
||||||
|
g.fill();
|
||||||
|
}
|
||||||
|
g.globalAlpha = 1;
|
||||||
|
if (single && k >= 1) {
|
||||||
|
// Soft vignette so the flooded screen keeps some depth.
|
||||||
|
const { ring } = targets[0];
|
||||||
|
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, farthestCorner(ring.x, ring.y, w, h));
|
||||||
|
grad.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
grad.addColorStop(1, 'rgba(0,0,0,0.4)');
|
||||||
|
g.fillStyle = grad;
|
||||||
|
g.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function confetti({ targets, w, h, R, start, palette }) {
|
||||||
|
const parts = [];
|
||||||
|
const per = Math.round(170 / targets.length);
|
||||||
|
for (const { ring, color } of targets) {
|
||||||
|
for (let i = 0; i < per; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const speed = 250 + Math.random() * 850;
|
||||||
|
parts.push({
|
||||||
|
ring,
|
||||||
|
delay: i < per * 0.6 ? 0 : 0.22,
|
||||||
|
vx: Math.cos(a) * speed,
|
||||||
|
vy: Math.sin(a) * speed - 300,
|
||||||
|
size: 5 + Math.random() * 7,
|
||||||
|
color: Math.random() < 0.45 ? color : palette[(Math.random() * palette.length) | 0],
|
||||||
|
rot: Math.random() * Math.PI,
|
||||||
|
vr: (Math.random() - 0.5) * 14,
|
||||||
|
flip: 4 + Math.random() * 8,
|
||||||
|
life: 2.4 + Math.random() * 1.2,
|
||||||
|
round: Math.random() < 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Freeze burst origin at reveal time.
|
||||||
|
for (const p of parts) {
|
||||||
|
p.x0 = p.ring.x;
|
||||||
|
p.y0 = p.ring.y;
|
||||||
|
}
|
||||||
|
const tau = 0.75;
|
||||||
|
const grav = 1100;
|
||||||
|
return {
|
||||||
|
drawBack(g, now) {
|
||||||
|
const k = clamp01((now - start) / 700);
|
||||||
|
for (const { ring, color } of targets) {
|
||||||
|
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h));
|
||||||
|
grad.addColorStop(0, hexToRgba(color, 0.45 * k));
|
||||||
|
grad.addColorStop(1, hexToRgba(color, 0.08 * k));
|
||||||
|
g.fillStyle = grad;
|
||||||
|
g.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drawFront(g, now) {
|
||||||
|
const T = (now - start) / 1000;
|
||||||
|
for (const p of parts) {
|
||||||
|
const t = T - p.delay;
|
||||||
|
if (t <= 0 || t > p.life) continue;
|
||||||
|
const e = tau * (1 - Math.exp(-t / tau));
|
||||||
|
const x = p.x0 + p.vx * e + Math.sin(t * 3 + p.rot) * 12 * t;
|
||||||
|
const y = p.y0 + (p.vy + grav * tau) * e - grav * tau * t * 0.55;
|
||||||
|
if (y > h + 20) continue;
|
||||||
|
g.globalAlpha = clamp01((p.life - t) / 0.5);
|
||||||
|
g.fillStyle = p.color;
|
||||||
|
g.save();
|
||||||
|
g.translate(x, y);
|
||||||
|
g.rotate(p.rot + p.vr * t);
|
||||||
|
g.scale(1, Math.cos(t * p.flip));
|
||||||
|
if (p.round) {
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(0, 0, p.size * 0.5, 0, Math.PI * 2);
|
||||||
|
g.fill();
|
||||||
|
} else g.fillRect(-p.size / 2, -p.size * 0.3, p.size, p.size * 0.6);
|
||||||
|
g.restore();
|
||||||
|
}
|
||||||
|
g.globalAlpha = 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spotlight({ targets, w, h, R, start }) {
|
||||||
|
// Darkness is drawn at half resolution: it's all soft gradients anyway.
|
||||||
|
const scale = 0.5;
|
||||||
|
const off = document.createElement('canvas');
|
||||||
|
off.width = Math.ceil(w * scale);
|
||||||
|
off.height = Math.ceil(h * scale);
|
||||||
|
const o = off.getContext('2d');
|
||||||
|
const spots = targets.map(() => ({ sx: Math.random() * w, sy: Math.random() * h * 0.6, ph: Math.random() * 6 }));
|
||||||
|
return {
|
||||||
|
drawBack(g, now) {
|
||||||
|
const k = clamp01((now - start - 900) / 500);
|
||||||
|
if (!k) return;
|
||||||
|
for (const { ring, color } of targets) {
|
||||||
|
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, R * 3);
|
||||||
|
grad.addColorStop(0, hexToRgba(color, 0.55 * k));
|
||||||
|
grad.addColorStop(1, hexToRgba(color, 0));
|
||||||
|
g.fillStyle = grad;
|
||||||
|
g.fillRect(ring.x - R * 3, ring.y - R * 3, R * 6, R * 6);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drawFront(g, now) {
|
||||||
|
const t = (now - start) / 1000;
|
||||||
|
o.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
o.globalCompositeOperation = 'source-over';
|
||||||
|
o.clearRect(0, 0, w, h);
|
||||||
|
o.fillStyle = `rgba(0,0,0,${0.9 * clamp01(t / 0.25)})`;
|
||||||
|
o.fillRect(0, 0, w, h);
|
||||||
|
o.globalCompositeOperation = 'destination-out';
|
||||||
|
targets.forEach(({ ring }, i) => {
|
||||||
|
const s = spots[i];
|
||||||
|
const m = easeOutCubic(clamp01(t / 1.1));
|
||||||
|
const wob = (1 - m) * 60;
|
||||||
|
const x = s.sx + (ring.x - s.sx) * m + Math.sin(t * 9 + s.ph) * wob;
|
||||||
|
const y = s.sy + (ring.y - s.sy) * m + Math.cos(t * 7 + s.ph) * wob;
|
||||||
|
const r = Math.max(w, h) * 0.22 + (R * 2.3 - Math.max(w, h) * 0.22) * easeInOutCubic(clamp01((t - 0.3) / 0.9));
|
||||||
|
const grad = o.createRadialGradient(x, y, r * 0.7, x, y, r * 1.2);
|
||||||
|
grad.addColorStop(0, 'rgba(0,0,0,1)');
|
||||||
|
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
o.fillStyle = grad;
|
||||||
|
o.fillRect(x - r * 1.2, y - r * 1.2, r * 2.4, r * 2.4);
|
||||||
|
});
|
||||||
|
g.drawImage(off, 0, 0, w, h);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shockwave({ targets, w, h, R, start }) {
|
||||||
|
const delays = [0, 0.14, 0.3];
|
||||||
|
return {
|
||||||
|
shake(now) {
|
||||||
|
const t = (now - start) / 1000;
|
||||||
|
const amp = 16 * Math.exp(-t * 7);
|
||||||
|
if (amp < 0.3) return null;
|
||||||
|
return [Math.sin(now * 0.09) * amp, Math.cos(now * 0.113) * amp];
|
||||||
|
},
|
||||||
|
drawBack(g, now) {
|
||||||
|
const t = (now - start) / 1000;
|
||||||
|
for (const { ring, color } of targets) {
|
||||||
|
g.fillStyle = hexToRgba(color, 0.25 * clamp01((t - 0.2) / 0.6));
|
||||||
|
g.fillRect(-20, -20, w + 40, h + 40);
|
||||||
|
const maxR = farthestCorner(ring.x, ring.y, w, h);
|
||||||
|
for (const d of delays) {
|
||||||
|
const k = (t - d) / 1.0;
|
||||||
|
if (k <= 0 || k >= 1) continue;
|
||||||
|
const r = easeOutCubic(k) * maxR;
|
||||||
|
g.globalAlpha = 1 - k;
|
||||||
|
g.strokeStyle = color;
|
||||||
|
g.lineWidth = R * 0.6 * (1 - k) + 2;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(ring.x, ring.y, r, 0, Math.PI * 2);
|
||||||
|
g.stroke();
|
||||||
|
g.strokeStyle = '#fff';
|
||||||
|
g.lineWidth = Math.max(1, R * 0.12 * (1 - k));
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
g.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drawFront(g, now) {
|
||||||
|
const t = (now - start) / 1000;
|
||||||
|
if (t < 0.15) {
|
||||||
|
g.fillStyle = `rgba(255,255,255,${0.55 * (1 - t / 0.15)})`;
|
||||||
|
g.fillRect(-20, -20, w + 40, h + 40);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** prefers-reduced-motion: a slow, gentle colour fade. */
|
||||||
|
function calm({ targets, w, h, R, start }) {
|
||||||
|
return {
|
||||||
|
drawBack(g, now) {
|
||||||
|
const k = easeInOutCubic(clamp01((now - start) / 1200));
|
||||||
|
for (const { ring, color } of targets) {
|
||||||
|
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h) * 0.9);
|
||||||
|
grad.addColorStop(0, hexToRgba(color, 0.5 * k));
|
||||||
|
grad.addColorStop(1, hexToRgba(color, 0.15 * k));
|
||||||
|
g.fillStyle = grad;
|
||||||
|
g.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function teams({ targets, R, start }) {
|
||||||
|
return {
|
||||||
|
drawBack(g, now) {
|
||||||
|
const k = easeOutCubic(clamp01((now - start) / 700));
|
||||||
|
for (const { ring, color } of targets) {
|
||||||
|
const r = R * (1.5 + 2.5 * k);
|
||||||
|
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, r);
|
||||||
|
grad.addColorStop(0, hexToRgba(color, 0.5));
|
||||||
|
grad.addColorStop(1, hexToRgba(color, 0));
|
||||||
|
g.fillStyle = grad;
|
||||||
|
g.fillRect(ring.x - r, ring.y - r, r * 2, r * 2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small expanding ring (joins, countdown beats, order steps). */
|
||||||
|
export function createRipple(x, y, color, start, maxR, dur = 600, width = 4) {
|
||||||
|
return {
|
||||||
|
done: (now) => now - start > dur,
|
||||||
|
draw(g, now) {
|
||||||
|
const k = (now - start) / dur;
|
||||||
|
if (k < 0 || k > 1) return;
|
||||||
|
g.globalAlpha = (1 - k) * 0.8;
|
||||||
|
g.strokeStyle = color;
|
||||||
|
g.lineWidth = width * (1 - k) + 0.5;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(x, y, maxR * easeOutCubic(k), 0, Math.PI * 2);
|
||||||
|
g.stroke();
|
||||||
|
g.globalAlpha = 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
56
js/settings.js
Normal file
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;
|
||||||
|
}
|
||||||
280
js/ui.js
Normal file
280
js/ui.js
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
// DOM layer: overlay messages, toolbar and the settings sheet.
|
||||||
|
|
||||||
|
import { t, setLang, getLang, detectLang, LANGS } from './i18n.js';
|
||||||
|
import { MODES } from './modes.js';
|
||||||
|
import { TIMING } from './game.js';
|
||||||
|
import { pick } from './random.js';
|
||||||
|
import { canVibrate } from './audio.js';
|
||||||
|
import { fullscreenSupported, isFullscreen, toggleFullscreen, onFullscreenChange } from './device.js';
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
|
export function stakeText(s) {
|
||||||
|
return s.stakeId ? t(`stakePresets.${s.stakeId}`) : s.stakeText.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUI({ settings, debug, showDesktopHint }) {
|
||||||
|
const msgMain = $('#msg-main');
|
||||||
|
const msgSub = $('#msg-sub');
|
||||||
|
const toolbar = $('#toolbar');
|
||||||
|
const sheet = $('#sheet');
|
||||||
|
const backdrop = $('#sheet-backdrop');
|
||||||
|
const btnSettings = $('#btn-settings');
|
||||||
|
const btnSound = $('#btn-sound');
|
||||||
|
const btnFull = $('#btn-fullscreen');
|
||||||
|
const stakeInput = $('#stake-input');
|
||||||
|
const hud = $('#debug-hud');
|
||||||
|
|
||||||
|
let chickenMsg = '';
|
||||||
|
let shownMain = null;
|
||||||
|
let shownSub = null;
|
||||||
|
let shownClass = null;
|
||||||
|
let toolbarShown = null;
|
||||||
|
|
||||||
|
// ---- toolbar ----
|
||||||
|
|
||||||
|
btnSettings.addEventListener('click', openSheet);
|
||||||
|
btnSound.addEventListener('click', () => settings.set({ muted: !settings.get().muted }));
|
||||||
|
const standaloneFull = matchMedia('(display-mode: fullscreen)').matches;
|
||||||
|
btnFull.hidden = !fullscreenSupported() || standaloneFull;
|
||||||
|
btnFull.addEventListener('click', toggleFullscreen);
|
||||||
|
onFullscreenChange(syncToolbar);
|
||||||
|
|
||||||
|
// ---- sheet ----
|
||||||
|
|
||||||
|
function openSheet() {
|
||||||
|
sheet.hidden = false;
|
||||||
|
backdrop.hidden = false;
|
||||||
|
requestAnimationFrame(() => document.body.classList.add('sheet-open'));
|
||||||
|
sheet.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSheet() {
|
||||||
|
document.body.classList.remove('sheet-open');
|
||||||
|
stakeInput.blur();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.body.classList.contains('sheet-open')) {
|
||||||
|
sheet.hidden = true;
|
||||||
|
backdrop.hidden = true;
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
btnSettings.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
backdrop.addEventListener('click', closeSheet);
|
||||||
|
sheet.querySelector('.sheet-close').addEventListener('click', closeSheet);
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && !sheet.hidden) closeSheet();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Segmented controls: <div class="seg" data-setting="mode"> buttons with data-value.
|
||||||
|
function buildSeg(el, values, labelFn) {
|
||||||
|
el.replaceChildren(
|
||||||
|
...values.map((v) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.dataset.value = String(v);
|
||||||
|
b.textContent = labelFn(v);
|
||||||
|
return b;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
const seg = btn.closest('.seg');
|
||||||
|
if (seg && btn.dataset.value != null) {
|
||||||
|
const key = seg.dataset.setting;
|
||||||
|
let v = btn.dataset.value;
|
||||||
|
if (key === 'teams') v = Number(v);
|
||||||
|
if (key === 'muted') v = v === 'true';
|
||||||
|
if (key === 'haptics') v = v === 'true';
|
||||||
|
if (key === 'lang') setLang(v);
|
||||||
|
settings.set({ [key]: v });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn.dataset.step) {
|
||||||
|
const n = Math.max(1, Math.min(9, settings.get().pickN + Number(btn.dataset.step)));
|
||||||
|
settings.set({ pickN: n });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn.dataset.preset) {
|
||||||
|
const id = btn.dataset.preset;
|
||||||
|
settings.set(settings.get().stakeId === id ? { stakeId: null, stakeText: '' } : { stakeId: id, stakeText: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn.id === 'stake-clear') settings.set({ stakeId: null, stakeText: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
stakeInput.addEventListener('input', () => settings.set({ stakeId: null, stakeText: stakeInput.value.slice(0, 60) }));
|
||||||
|
stakeInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') stakeInput.blur();
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyTranslations() {
|
||||||
|
document.title = t('appTitle');
|
||||||
|
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
|
||||||
|
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
|
||||||
|
stakeInput.placeholder = t('stakesPlaceholder');
|
||||||
|
buildSeg($('#seg-mode'), MODES, (m) => t(`mode.${m}`));
|
||||||
|
buildSeg($('#seg-teams'), [2, 3, 4], String);
|
||||||
|
buildSeg($('#seg-theme'), ['neon', 'pastel', 'contrast'], (v) => t(`theme.${v}`));
|
||||||
|
buildSeg($('#seg-lang'), LANGS, (v) => ({ en: 'English', nl: 'Nederlands' })[v]);
|
||||||
|
buildSeg($('#seg-sound'), [false, true], (v) => t(v ? 'off' : 'on'));
|
||||||
|
buildSeg($('#seg-haptics'), [true, false], (v) => t(v ? 'on' : 'off'));
|
||||||
|
const presets = $('#stake-presets');
|
||||||
|
presets.replaceChildren(
|
||||||
|
...Object.keys(t('stakePresets')).map((id) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'chip';
|
||||||
|
b.dataset.preset = id;
|
||||||
|
b.textContent = t(`stakePresets.${id}`);
|
||||||
|
return b;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSheet() {
|
||||||
|
const s = settings.get();
|
||||||
|
const lang = getLang();
|
||||||
|
const sel = { mode: s.mode, teams: String(s.teams), theme: s.theme, lang, muted: String(s.muted), haptics: String(s.haptics) };
|
||||||
|
for (const seg of sheet.querySelectorAll('.seg')) {
|
||||||
|
const cur = sel[seg.dataset.setting];
|
||||||
|
for (const b of seg.children) b.setAttribute('aria-pressed', String(b.dataset.value === cur));
|
||||||
|
}
|
||||||
|
$('#mode-desc').textContent = t(`modeDesc.${s.mode}`);
|
||||||
|
$('#row-pickn').hidden = s.mode !== 'pickN';
|
||||||
|
$('#row-teams').hidden = s.mode !== 'teams';
|
||||||
|
$('#row-haptics').hidden = !canVibrate;
|
||||||
|
$('#pickn-value').textContent = String(s.pickN);
|
||||||
|
for (const b of $('#stake-presets').children) b.setAttribute('aria-pressed', String(b.dataset.preset === s.stakeId));
|
||||||
|
if (document.activeElement !== stakeInput) stakeInput.value = s.stakeId ? '' : s.stakeText;
|
||||||
|
$('#stake-clear').hidden = !s.stakeId && !s.stakeText;
|
||||||
|
syncToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncToolbar() {
|
||||||
|
const s = settings.get();
|
||||||
|
btnSound.classList.toggle('is-muted', s.muted);
|
||||||
|
btnSound.setAttribute('aria-label', t(s.muted ? 'unmute' : 'mute'));
|
||||||
|
btnSound.setAttribute('aria-pressed', String(s.muted));
|
||||||
|
const fs = isFullscreen();
|
||||||
|
btnFull.classList.toggle('is-full', fs);
|
||||||
|
btnFull.setAttribute('aria-label', t(fs ? 'exitFullscreen' : 'fullscreen'));
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.subscribe((s, patch) => {
|
||||||
|
if ('lang' in patch) {
|
||||||
|
setLang(s.lang || detectLang());
|
||||||
|
applyTranslations();
|
||||||
|
}
|
||||||
|
syncSheet();
|
||||||
|
});
|
||||||
|
|
||||||
|
applyTranslations();
|
||||||
|
syncSheet();
|
||||||
|
if (debug && new URLSearchParams(location.search).get('open') === 'settings') openSheet();
|
||||||
|
|
||||||
|
// ---- overlay messages ----
|
||||||
|
|
||||||
|
function modeSummary(s) {
|
||||||
|
let out = t(`mode.${s.mode}`);
|
||||||
|
if (s.mode === 'pickN') out += ` · ${s.pickN}`;
|
||||||
|
if (s.mode === 'teams') out += ` · ${s.teams}`;
|
||||||
|
const stake = stakeText(s);
|
||||||
|
if (stake && s.mode !== 'teams') out += ` · “${stake}”`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultMessage(game, s) {
|
||||||
|
const stake = stakeText(s);
|
||||||
|
switch (game.plan?.mode) {
|
||||||
|
case 'teams':
|
||||||
|
return t('teamsReady');
|
||||||
|
case 'order':
|
||||||
|
return stake ? `#1: ${t('youStake', { stake })}` : t('orderDone');
|
||||||
|
case 'elimination':
|
||||||
|
return stake ? t('youStake', { stake }) : t('lastStanding');
|
||||||
|
default:
|
||||||
|
return stake ? t('youStake', { stake }) : t('youreIt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messages(game, now) {
|
||||||
|
const s = settings.get();
|
||||||
|
const count = game.activeRings().length;
|
||||||
|
switch (game.phase) {
|
||||||
|
case 'idle':
|
||||||
|
if (count === 0) return [t('hint'), showDesktopHint ? t('desktopHint') : modeSummary(s), 'hint'];
|
||||||
|
return [t('needMore'), modeSummary(s), 'hint'];
|
||||||
|
case 'gathering':
|
||||||
|
return [t('holdStill'), '', 'soft'];
|
||||||
|
case 'countdown':
|
||||||
|
return [String(TIMING.beats - Math.max(0, game.beat)), '', 'count'];
|
||||||
|
case 'chicken':
|
||||||
|
return [chickenMsg, '', 'alert'];
|
||||||
|
case 'elimPause': {
|
||||||
|
const out = game.lastOut;
|
||||||
|
const ring = out && game.rings.get(out.id);
|
||||||
|
if (!out) return ['', '', ''];
|
||||||
|
return [t(out.chickened ? 'chickenOut' : 'eliminated'), ring?.down ? t('liftFinger') : '', 'alert'];
|
||||||
|
}
|
||||||
|
case 'result': {
|
||||||
|
const sub = game.canRestart(now) ? t('tapRestart') : '';
|
||||||
|
return [resultMessage(game, s), sub, 'result'];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ['', '', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onEvent(type) {
|
||||||
|
if (type === 'chicken') chickenMsg = pick(t('chicken'));
|
||||||
|
},
|
||||||
|
|
||||||
|
update(game, now) {
|
||||||
|
const [main, sub, cls] = messages(game, now);
|
||||||
|
if (main !== shownMain || cls !== shownClass) {
|
||||||
|
msgMain.textContent = main;
|
||||||
|
msgMain.className = `msg ${cls}`;
|
||||||
|
// restart the entrance animation
|
||||||
|
void msgMain.offsetWidth;
|
||||||
|
msgMain.classList.add('in');
|
||||||
|
shownMain = main;
|
||||||
|
shownClass = cls;
|
||||||
|
}
|
||||||
|
if (sub !== shownSub) {
|
||||||
|
msgSub.textContent = sub;
|
||||||
|
shownSub = sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTools = (game.phase === 'idle' && !game.anyDown()) || (game.phase === 'result' && !game.anyDown());
|
||||||
|
if (showTools !== toolbarShown) {
|
||||||
|
toolbar.classList.toggle('hidden', !showTools);
|
||||||
|
toolbar.inert = !showTools;
|
||||||
|
toolbarShown = showTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
const fps = this._fps(now);
|
||||||
|
hud.textContent = `${game.phase} · ${game.activeRings().length} fingers · ${fps} fps`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_last: 0,
|
||||||
|
_frames: 0,
|
||||||
|
_fpsVal: 0,
|
||||||
|
_fps(now) {
|
||||||
|
this._frames++;
|
||||||
|
if (now - this._last > 500) {
|
||||||
|
this._fpsVal = Math.round((this._frames * 1000) / (now - this._last));
|
||||||
|
this._frames = 0;
|
||||||
|
this._last = now;
|
||||||
|
}
|
||||||
|
return this._fpsVal;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
20
manifest.webmanifest
Normal file
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-v1';
|
||||||
|
const SHELL = [
|
||||||
|
'./',
|
||||||
|
'./index.html',
|
||||||
|
'./manifest.webmanifest',
|
||||||
|
'./css/app.css',
|
||||||
|
'./js/main.js',
|
||||||
|
'./js/game.js',
|
||||||
|
'./js/modes.js',
|
||||||
|
'./js/random.js',
|
||||||
|
'./js/renderer.js',
|
||||||
|
'./js/reveals.js',
|
||||||
|
'./js/input.js',
|
||||||
|
'./js/audio.js',
|
||||||
|
'./js/device.js',
|
||||||
|
'./js/settings.js',
|
||||||
|
'./js/i18n.js',
|
||||||
|
'./js/themes.js',
|
||||||
|
'./js/ui.js',
|
||||||
|
'./icons/icon.svg',
|
||||||
|
'./icons/icon-192.png',
|
||||||
|
'./icons/icon-512.png',
|
||||||
|
'./icons/icon-maskable-512.png',
|
||||||
|
'./icons/apple-touch-icon.png',
|
||||||
|
];
|
||||||
|
const NETWORK_TIMEOUT_MS = 2500;
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(VERSION)
|
||||||
|
.then((cache) => cache.addAll(SHELL.map((u) => new Request(u, { cache: 'reload' }))))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET' || new URL(req.url).origin !== self.location.origin) return;
|
||||||
|
event.respondWith(networkFirst(req, event));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function networkFirst(req, event) {
|
||||||
|
const cache = await caches.open(VERSION);
|
||||||
|
try {
|
||||||
|
const res = await withTimeout(fetch(req), NETWORK_TIMEOUT_MS);
|
||||||
|
if (res.ok) event.waitUntil(cache.put(req, res.clone()));
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
// ignoreSearch so ?debug=1 etc. still resolve offline
|
||||||
|
const hit = (await cache.match(req, { ignoreSearch: true })) || (req.mode === 'navigate' && (await cache.match('./index.html')));
|
||||||
|
return hit || Response.error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(promise, ms) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => reject(new Error('timeout')), ms);
|
||||||
|
promise.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
reject(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
291
tests/logic.html
Normal file
291
tests/logic.html
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Picker logic tests</title>
|
||||||
|
<style>body{font:14px monospace;background:#111;color:#ddd;padding:1rem}.pass{color:#6f6}.fail{color:#f66}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Picker logic tests</h1>
|
||||||
|
<pre id="out">running…</pre>
|
||||||
|
<script type="module">
|
||||||
|
// Drives the state machine with simulated time. Open in a browser
|
||||||
|
// (served over http) or headless: results are written into #out.
|
||||||
|
import { Game, TIMING } from '../js/game.js';
|
||||||
|
import { planRound, makeHighlightSchedule } from '../js/modes.js';
|
||||||
|
import { randomInt } from '../js/random.js';
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
results.push(['pass', name]);
|
||||||
|
} catch (e) {
|
||||||
|
results.push(['fail', `${name}: ${e.message}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (!cond) throw new Error(msg || 'assertion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(settings = {}) {
|
||||||
|
const events = [];
|
||||||
|
const s = { mode: 'one', pickN: 2, teams: 2, ...settings };
|
||||||
|
const game = new Game({ emit: (type, data) => events.push({ type, data }), getSettings: () => s, isReduced: () => false });
|
||||||
|
let now = performance.now() + 10;
|
||||||
|
const h = {
|
||||||
|
game,
|
||||||
|
events,
|
||||||
|
get now() { return now; },
|
||||||
|
down(id, x = 100 + Math.random() * 200, y = 100 + Math.random() * 300) { game.fingerDown(id, x, y, now); },
|
||||||
|
up(id) { game.fingerUp(id, now); },
|
||||||
|
run(ms) {
|
||||||
|
const end = now + ms;
|
||||||
|
while (now < end) {
|
||||||
|
now = Math.min(end, now + 16);
|
||||||
|
game.update(now);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
until(phase, max = 30000) {
|
||||||
|
const end = now + max;
|
||||||
|
while (game.phase !== phase && now < end) {
|
||||||
|
now += 16;
|
||||||
|
game.update(now);
|
||||||
|
}
|
||||||
|
assert(game.phase === phase, `expected phase ${phase}, got ${game.phase}`);
|
||||||
|
},
|
||||||
|
count: (type) => events.filter((e) => e.type === type).length,
|
||||||
|
};
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pick one: full flow reaches a result with one valid winner', () => {
|
||||||
|
const h = setup();
|
||||||
|
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||||
|
h.run(100);
|
||||||
|
assert(h.game.phase === 'gathering', 'gathering');
|
||||||
|
h.run(TIMING.stableMs);
|
||||||
|
assert(h.game.phase === 'countdown', 'countdown after stable period, got ' + h.game.phase);
|
||||||
|
h.until('result');
|
||||||
|
assert(h.count('beat') === TIMING.beats, 'beats');
|
||||||
|
assert(h.count('tick') > 8, 'ticks');
|
||||||
|
assert(h.game.winners.length === 1 && ['a', 'b', 'c'].includes(h.game.winners[0]), 'winner');
|
||||||
|
const reveal = h.events.find((e) => e.type === 'reveal');
|
||||||
|
assert(['flood', 'confetti', 'spotlight', 'shockwave'].includes(reveal.data.style), 'style ' + reveal.data.style);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outcome is decided before the animation and the animation lands on it', () => {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const h = setup();
|
||||||
|
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||||
|
h.until('suspense');
|
||||||
|
const planned = h.game.plan.winners[0];
|
||||||
|
const last = h.game.schedule[h.game.schedule.length - 1];
|
||||||
|
assert(last.ids[0] === planned, 'schedule ends on planned winner');
|
||||||
|
assert(h.game.schedule[h.game.schedule.length - 2].ids[0] !== planned, 'penultimate step differs');
|
||||||
|
h.until('result');
|
||||||
|
assert(h.game.winners[0] === planned, 'revealed winner = planned');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one finger is not enough', () => {
|
||||||
|
const h = setup();
|
||||||
|
h.down('a');
|
||||||
|
h.run(6000);
|
||||||
|
assert(h.game.phase === 'idle', h.game.phase);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joining during the countdown resets to gathering without chicken', () => {
|
||||||
|
const h = setup();
|
||||||
|
h.down('a');
|
||||||
|
h.down('b');
|
||||||
|
h.until('countdown');
|
||||||
|
h.down('c');
|
||||||
|
assert(h.game.phase === 'gathering', h.game.phase);
|
||||||
|
assert(h.count('chicken') === 0, 'no chicken');
|
||||||
|
h.run(TIMING.stableMs - 200);
|
||||||
|
assert(h.game.phase === 'gathering', 'stable timer restarted');
|
||||||
|
h.until('result');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lifting during the countdown triggers chicken, then resumes', () => {
|
||||||
|
const h = setup();
|
||||||
|
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||||
|
h.until('countdown');
|
||||||
|
h.up('b');
|
||||||
|
assert(h.game.phase === 'chicken', h.game.phase);
|
||||||
|
assert(h.count('chicken') === 1, 'chicken event');
|
||||||
|
h.run(TIMING.chickenMs + 50);
|
||||||
|
assert(h.game.phase === 'gathering', 'back to gathering, got ' + h.game.phase);
|
||||||
|
h.until('result');
|
||||||
|
assert(['a', 'c'].includes(h.game.winners[0]), 'winner among remaining');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lifting during suspense aborts with chicken', () => {
|
||||||
|
const h = setup();
|
||||||
|
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||||
|
h.until('suspense');
|
||||||
|
h.up('a');
|
||||||
|
assert(h.game.phase === 'chicken' && h.game.plan === null, h.game.phase);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('late joiners during suspense are spectators', () => {
|
||||||
|
const h = setup();
|
||||||
|
['a', 'b'].forEach((id) => h.down(id));
|
||||||
|
h.until('suspense');
|
||||||
|
h.down('late');
|
||||||
|
assert(h.game.rings.get('late').late, 'late flag');
|
||||||
|
h.until('result');
|
||||||
|
assert(h.game.winners[0] !== 'late', 'spectator never wins');
|
||||||
|
assert(!h.game.players.includes('late'), 'not a player');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('result persists after lifting; restart only after grace and all fingers up', () => {
|
||||||
|
const h = setup();
|
||||||
|
['a', 'b'].forEach((id) => h.down(id));
|
||||||
|
h.until('result');
|
||||||
|
h.up('a');
|
||||||
|
h.run(3000);
|
||||||
|
assert(h.game.phase === 'result', 'still result while b holds');
|
||||||
|
h.down('x');
|
||||||
|
assert(h.game.phase === 'result' && h.game.rings.get('x').late, 'tap while someone holds is ignored');
|
||||||
|
h.up('x');
|
||||||
|
h.up('b');
|
||||||
|
assert(h.game.phase === 'result', 'stays after all lift');
|
||||||
|
assert(h.game.canRestart(h.now), 'can restart');
|
||||||
|
h.down('n1');
|
||||||
|
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'restarted');
|
||||||
|
assert(h.game.rings.size === 1 && !h.game.rings.get('n1').late, 'tapping finger joins the next round');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no instant restart right after the reveal', () => {
|
||||||
|
const h = setup();
|
||||||
|
['a', 'b'].forEach((id) => h.down(id));
|
||||||
|
h.until('result');
|
||||||
|
h.up('a');
|
||||||
|
h.up('b');
|
||||||
|
h.down('a2');
|
||||||
|
assert(h.game.phase === 'result', 'grace period protects the result');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pick N clamps to fingers - 1 and picks distinct winners', () => {
|
||||||
|
const h = setup({ mode: 'pickN', pickN: 5 });
|
||||||
|
['a', 'b', 'c'].forEach((id) => h.down(id));
|
||||||
|
h.until('result');
|
||||||
|
assert(h.game.winners.length === 2, 'clamped to 2, got ' + h.game.winners.length);
|
||||||
|
assert(new Set(h.game.winners).size === 2, 'distinct');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teams are balanced', () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const plan = planRound('teams', ['a', 'b', 'c', 'd', 'e', 'f', 'g'], { teams: 3 });
|
||||||
|
const sizes = [0, 0, 0];
|
||||||
|
for (const t of plan.teams.values()) sizes[t]++;
|
||||||
|
assert(Math.max(...sizes) - Math.min(...sizes) <= 1, 'sizes ' + sizes);
|
||||||
|
}
|
||||||
|
const small = planRound('teams', ['a', 'b'], { teams: 4 });
|
||||||
|
assert(small.teamCount === 2, 'team count capped by fingers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teams mode reaches result', () => {
|
||||||
|
const h = setup({ mode: 'teams', teams: 2 });
|
||||||
|
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||||
|
h.until('result');
|
||||||
|
assert(h.game.revealStyle === 'teams', 'teams style');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('order assigns 1..N exactly once, #1 first', () => {
|
||||||
|
const h = setup({ mode: 'order' });
|
||||||
|
['a', 'b', 'c', 'd', 'e'].forEach((id) => h.down(id));
|
||||||
|
h.until('suspense');
|
||||||
|
const first = h.game.plan.order[0];
|
||||||
|
h.until('result');
|
||||||
|
const nums = [...h.game.orderNums.values()].map((o) => o.n).sort();
|
||||||
|
assert(nums.join() === '1,2,3,4,5', nums.join());
|
||||||
|
assert(h.game.orderNums.get(first).n === 1, 'planned #1');
|
||||||
|
assert(h.count('orderStep') === 5, 'steps');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('elimination knocks out N-1 and the planned survivor remains', () => {
|
||||||
|
const h = setup({ mode: 'elimination' });
|
||||||
|
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||||
|
h.until('suspense');
|
||||||
|
const order = h.game.plan.eliminationOrder.slice();
|
||||||
|
// Knocked-out players lift their finger, as asked.
|
||||||
|
h.game.emit = ((orig) => (type, data) => { orig(type, data); if (type === 'eliminate') h.game.fingerUp(data.id, h.now); })(h.game.emit);
|
||||||
|
h.until('result');
|
||||||
|
const outs = h.events.filter((e) => e.type === 'eliminate').map((e) => e.data.id);
|
||||||
|
assert(outs.join() === order.slice(0, 3).join(), `outs ${outs} vs ${order}`);
|
||||||
|
assert(h.game.winners[0] === order[3], 'survivor');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('elimination: a remaining player who lets go is out', () => {
|
||||||
|
const h = setup({ mode: 'elimination' });
|
||||||
|
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
||||||
|
h.until('suspense');
|
||||||
|
const survivor = h.game.plan.eliminationOrder[3];
|
||||||
|
const coward = h.game.plan.eliminationOrder[2];
|
||||||
|
h.up(coward);
|
||||||
|
assert(h.game.phase === 'elimPause', h.game.phase);
|
||||||
|
assert(h.events.find((e) => e.type === 'eliminate').data.chickened, 'chickened');
|
||||||
|
h.until('result');
|
||||||
|
assert(h.game.winners[0] === survivor, 'survivor still wins');
|
||||||
|
assert(h.count('eliminate') === 3, 'three outs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two players: highlight alternates and lands on the planned one', () => {
|
||||||
|
const rings = [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 100, y: 0 }];
|
||||||
|
for (const target of ['a', 'b']) {
|
||||||
|
const s = makeHighlightSchedule(rings, [target], 2700);
|
||||||
|
assert(s[s.length - 1].ids[0] === target, 'lands');
|
||||||
|
for (let i = 1; i < s.length; i++) assert(s[i].ids[0] !== s[i - 1].ids[0], 'alternates');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('randomInt is uniform (chi-square, 5 buckets)', () => {
|
||||||
|
const N = 50000;
|
||||||
|
const k = 5;
|
||||||
|
const c = new Array(k).fill(0);
|
||||||
|
for (let i = 0; i < N; i++) c[randomInt(k)]++;
|
||||||
|
const e = N / k;
|
||||||
|
const chi = c.reduce((s, x) => s + ((x - e) ** 2) / e, 0);
|
||||||
|
assert(chi < 18.47, `chi² ${chi.toFixed(2)} (p<0.001 threshold 18.47) counts ${c}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pick one is fair across fingers', () => {
|
||||||
|
const ids = ['a', 'b', 'c'];
|
||||||
|
const c = { a: 0, b: 0, c: 0 };
|
||||||
|
for (let i = 0; i < 30000; i++) c[planRound('one', ids, {}).winners[0]]++;
|
||||||
|
for (const id of ids) assert(Math.abs(c[id] / 30000 - 1 / 3) < 0.015, JSON.stringify(c));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown pointer ids and double lifts are harmless', () => {
|
||||||
|
const h = setup();
|
||||||
|
h.up('nope');
|
||||||
|
h.down('a');
|
||||||
|
h.up('a');
|
||||||
|
h.up('a');
|
||||||
|
h.run(500);
|
||||||
|
assert(h.game.phase === 'idle', h.game.phase);
|
||||||
|
assert(h.game.rings.size === 0, 'pruned');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service worker: parses, and every precached file exists.
|
||||||
|
try {
|
||||||
|
const src = await (await fetch('../sw.js')).text();
|
||||||
|
new Function(src); // syntax check only, not executed
|
||||||
|
const shell = [...src.matchAll(/'(\.\/[^']*)'/g)].map((m) => m[1]);
|
||||||
|
const missing = [];
|
||||||
|
for (const u of shell) if (!(await fetch(new URL('../' + u, location.href), { cache: 'no-store' })).ok) missing.push(u);
|
||||||
|
results.push(missing.length ? ['fail', 'sw.js precache missing: ' + missing] : ['pass', `sw.js parses; all ${shell.length} precached files exist`]);
|
||||||
|
} catch (e) {
|
||||||
|
results.push(['fail', 'sw.js: ' + e.message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = document.getElementById('out');
|
||||||
|
const failed = results.filter((r) => r[0] === 'fail').length;
|
||||||
|
out.innerHTML = results.map(([cls, msg]) => `<span class="${cls}">${cls.toUpperCase()}</span> ${msg}`).join('\n') + `\n\n${results.length - failed}/${results.length} passed`;
|
||||||
|
document.title = failed ? `FAIL ${failed}` : 'ALL PASS';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
tests/sim.html
Normal file
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