Compare commits
No commits in common. "35a4dc2dbfd5dd5de0d87c76339e0330d34424f3" and "ad890adcf1a6864357184f394a7adf51f6a3ed8d" have entirely different histories.
35a4dc2dbf
...
ad890adcf1
30 changed files with 2 additions and 3558 deletions
|
|
@ -1,8 +0,0 @@
|
||||||
.git
|
|
||||||
.github
|
|
||||||
tests
|
|
||||||
tools
|
|
||||||
*.md
|
|
||||||
LICENSE
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
57
.github/workflows/docker.yml
vendored
57
.github/workflows/docker.yml
vendored
|
|
@ -1,57 +0,0 @@
|
||||||
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
13
Dockerfile
|
|
@ -1,13 +0,0 @@
|
||||||
# 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,156 +1,3 @@
|
||||||
# Picker
|
# picker
|
||||||
|
|
||||||
A simple web app to select who pays the bills: everyone puts a finger on the
|
A simple web app to select who pays the bill
|
||||||
phone screen, and after a suspenseful roulette one of you is chosen.
|
|
||||||
|
|
||||||
Plain HTML, CSS and vanilla JS modules. No build step and no dependencies.
|
|
||||||
Works on any static host and installs as an offline PWA.
|
|
||||||
|
|
||||||
## Modes
|
|
||||||
|
|
||||||
- **Pick one**: one finger is chosen.
|
|
||||||
- **Pick several**: N fingers are chosen.
|
|
||||||
- **Teams**: everyone is split into 2–4 balanced teams.
|
|
||||||
- **Order**: everyone gets a number, revealed one at a time.
|
|
||||||
- **Elimination**: one finger is knocked out per round until one remains.
|
|
||||||
Letting go early counts as being knocked out.
|
|
||||||
|
|
||||||
Settings (gear button on the idle screen) also cover the stakes text
|
|
||||||
("pay the bill" → "You pay the bill!"), theme (neon, pastel, or high
|
|
||||||
contrast with a distinct shape per finger), language (English/Dutch), sound
|
|
||||||
and vibration. Settings are saved in `localStorage`.
|
|
||||||
|
|
||||||
## How a round works
|
|
||||||
|
|
||||||
1. Put two or more fingers down. Once the set of fingers has been stable for
|
|
||||||
2.5 s, a three-beat countdown starts. Adding a finger restarts the wait.
|
|
||||||
Lifting one shows a "chickened out" message.
|
|
||||||
2. The outcome is drawn with `crypto.getRandomValues` *before* the animation
|
|
||||||
starts. The roulette is only presentation and always lands on the
|
|
||||||
pre-drawn result.
|
|
||||||
3. The result stays on screen after fingers lift. Once everyone has let go,
|
|
||||||
tap to play again (the tapping finger joins the next round).
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
| File | What |
|
|
||||||
| --- | --- |
|
|
||||||
| `js/main.js` | wiring + frame loop |
|
|
||||||
| `js/input.js` | Pointer Events per `pointerId`, gesture blocking, debug fake fingers |
|
|
||||||
| `js/game.js` | state machine (idle → gathering → countdown → suspense → result) |
|
|
||||||
| `js/modes.js` | outcome planning and roulette schedules for each mode |
|
|
||||||
| `js/random.js` | unbiased crypto randomness |
|
|
||||||
| `js/renderer.js` | canvas rendering (pre-rendered glow sprites) |
|
|
||||||
| `js/reveals.js` | reveal animations: flood, confetti, spotlight, shockwave, calm |
|
|
||||||
| `js/audio.js` | Web Audio sound effects + vibration |
|
|
||||||
| `js/ui.js` | overlay text, toolbar, settings sheet |
|
|
||||||
| `js/settings.js` | persisted settings |
|
|
||||||
| `js/i18n.js` | all strings (en, nl) |
|
|
||||||
| `js/themes.js` | colour themes |
|
|
||||||
| `js/device.js` | wake lock, fullscreen |
|
|
||||||
| `sw.js` | offline caching (network-first, cache fallback) |
|
|
||||||
| `tools/make_icons.py` | regenerates the PNG icons (Python stdlib only) |
|
|
||||||
|
|
||||||
## Testing on a desktop
|
|
||||||
|
|
||||||
Open `http://localhost:8000/?debug=1`:
|
|
||||||
|
|
||||||
- **Click** to place a fake finger, **click it again** to remove it, **drag** to move it.
|
|
||||||
- **A** adds a fake finger at a random spot, **C** clears them all.
|
|
||||||
- `?debug=1&fingers=4` starts with 4 fake fingers in a circle.
|
|
||||||
- `?debug=1&open=settings` opens the settings sheet on load.
|
|
||||||
- A HUD shows the phase, finger count and fps. The service worker is not
|
|
||||||
registered in debug mode, so you always get fresh files.
|
|
||||||
|
|
||||||
Automated checks (serve the folder, then open in any browser):
|
|
||||||
|
|
||||||
- `tests/logic.html`: state machine and fairness tests.
|
|
||||||
- `tests/sim.html?mode=order&n=5&t=11500`: renders one simulated frame at a
|
|
||||||
given time (`mode`, `n`, `t`, `theme`, `style`, `k`, `teams`, `reduced`).
|
|
||||||
|
|
||||||
## Testing on your phone (local network)
|
|
||||||
|
|
||||||
Serve the folder from your PC:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python -m http.server 8000 --bind 0.0.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Find your PC's LAN IP (`ipconfig` on Windows, `ip addr` on Linux), then open
|
|
||||||
`http://<that-ip>:8000/` on the phone. On Windows, allow Python through the
|
|
||||||
firewall for private networks when asked.
|
|
||||||
|
|
||||||
Over plain `http://` on a LAN address, the page is **not a secure context**.
|
|
||||||
These features need HTTPS (or `localhost`):
|
|
||||||
|
|
||||||
| Feature | Plain http on LAN |
|
|
||||||
| --- | --- |
|
|
||||||
| Touch, canvas, crypto randomness, sound, vibration, fullscreen | works |
|
|
||||||
| Service worker / offline mode | **needs HTTPS** |
|
|
||||||
| Install as app (PWA) | **needs HTTPS** |
|
|
||||||
| Screen wake lock | **needs HTTPS** |
|
|
||||||
|
|
||||||
To test the secure-only features:
|
|
||||||
|
|
||||||
- **Android + USB (easiest):** enable USB debugging, open `chrome://inspect`
|
|
||||||
in desktop Chrome or Edge, click *Port forwarding* and map `8000` →
|
|
||||||
`localhost:8000`. Then open `http://localhost:8000/` on the phone.
|
|
||||||
`localhost` counts as secure, so everything works.
|
|
||||||
- **Android without USB:** on the phone, open
|
|
||||||
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` and add
|
|
||||||
`http://<your-ip>:8000`. Use this for testing only.
|
|
||||||
- **Anywhere, iOS included:** put it behind HTTPS (see Docker below, behind
|
|
||||||
your reverse proxy).
|
|
||||||
|
|
||||||
Browser notes:
|
|
||||||
|
|
||||||
- Browsers only allow sound and vibration after a tap or a finger *lift*.
|
|
||||||
A finger going down doesn't count. If the very first round after opening
|
|
||||||
is silent, tap the screen once (or use any toolbar button) first.
|
|
||||||
- iOS Safari has no vibration API and no fullscreen API on iPhone, so those
|
|
||||||
controls are hidden there. Add it to the home screen for a full-screen
|
|
||||||
experience.
|
|
||||||
- Devices cap simultaneous touches (iPhone: 5, most Android phones: 10).
|
|
||||||
The app handles whatever arrives.
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
The image is plain unprivileged nginx serving the static files on port
|
|
||||||
**8080**, with a `/healthz` endpoint.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker build -t picker .
|
|
||||||
docker run --rm -p 8080:8080 picker
|
|
||||||
```
|
|
||||||
|
|
||||||
On every push to `main`, the GitHub Actions workflow
|
|
||||||
(`.github/workflows/docker.yml`) publishes a multi-arch (amd64 + arm64)
|
|
||||||
image to GHCR:
|
|
||||||
|
|
||||||
- `ghcr.io/<owner>/<repo>:latest` on `main`
|
|
||||||
- `:1.2.3` and `:1.2` when you push a `v1.2.3` tag
|
|
||||||
- `:sha-abc1234` on every build
|
|
||||||
|
|
||||||
Example `docker-compose.yml` for a home server:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
picker:
|
|
||||||
image: ghcr.io/<owner>/<repo>:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
```
|
|
||||||
|
|
||||||
Point your reverse proxy (Caddy, Traefik, nginx proxy manager, …) at port
|
|
||||||
8080 for your subdomain and terminate HTTPS there. HTTPS is required for
|
|
||||||
offline mode, installing and wake lock. All paths are relative, so it also
|
|
||||||
works under a sub-path.
|
|
||||||
|
|
||||||
GHCR packages are private by default. Either make the package public
|
|
||||||
(GitHub → your profile → Packages → picker → Package settings), or run
|
|
||||||
`docker login ghcr.io` on the server with a token that has `read:packages`.
|
|
||||||
|
|
||||||
## Icons
|
|
||||||
|
|
||||||
`icons/*.png` are generated: `python tools/make_icons.py`.
|
|
||||||
272
css/app.css
272
css/app.css
|
|
@ -1,272 +0,0 @@
|
||||||
:root {
|
|
||||||
--bg: #05050b;
|
|
||||||
--fg: #f4f4fa;
|
|
||||||
--muted: #a3a3b8;
|
|
||||||
--panel: #161622;
|
|
||||||
--line: rgba(255, 255, 255, 0.12);
|
|
||||||
--accent: #00e5ff;
|
|
||||||
--accent-fg: #001318;
|
|
||||||
--safe-t: env(safe-area-inset-top, 0px);
|
|
||||||
--safe-b: env(safe-area-inset-bottom, 0px);
|
|
||||||
--safe-l: env(safe-area-inset-left, 0px);
|
|
||||||
--safe-r: env(safe-area-inset-right, 0px);
|
|
||||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
[data-theme="pastel"] { --bg: #1c1a26; --accent: #cdb4ff; --accent-fg: #1c1a26; --panel: #262334; }
|
|
||||||
[data-theme="contrast"] { --bg: #000; --accent: #ffd500; --accent-fg: #000; --panel: #000; --line: #fff; --muted: #e6e6e6; }
|
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: var(--font);
|
|
||||||
/* no scrolling, zooming, pull-to-refresh, selection or callouts */
|
|
||||||
touch-action: none;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#stage {
|
|
||||||
z-index: 0;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- overlay text ---- */
|
|
||||||
|
|
||||||
#overlay {
|
|
||||||
z-index: 1;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: calc(var(--safe-t) + 5rem) calc(var(--safe-r) + 2rem) calc(var(--safe-b) + 3rem) calc(var(--safe-l) + 2rem);
|
|
||||||
pointer-events: none;
|
|
||||||
text-align: center;
|
|
||||||
transition: opacity 200ms;
|
|
||||||
}
|
|
||||||
.sheet-open #overlay { opacity: 0; }
|
|
||||||
|
|
||||||
.msg {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 22ch;
|
|
||||||
font-size: clamp(1.4rem, 6.5vw, 2.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-wrap: balance;
|
|
||||||
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.75), 0 0 2px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
.msg.hint { color: var(--muted); font-weight: 600; font-size: clamp(1.2rem, 5.5vw, 1.8rem); }
|
|
||||||
.msg.soft { color: var(--muted); font-weight: 600; font-size: clamp(1rem, 4.5vw, 1.4rem); }
|
|
||||||
.msg.count { font-size: clamp(3rem, 18vw, 6rem); font-weight: 800; opacity: 0.35; }
|
|
||||||
.msg.alert { font-size: clamp(1.5rem, 7vw, 2.4rem); }
|
|
||||||
.msg.result { font-size: clamp(1.8rem, 9vw, 3.2rem); font-weight: 800; }
|
|
||||||
.msg.in { animation: msg-in 380ms cubic-bezier(0.2, 1.4, 0.4, 1); }
|
|
||||||
|
|
||||||
.msg-sub {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 1.4em;
|
|
||||||
max-width: 32ch;
|
|
||||||
white-space: pre-line;
|
|
||||||
color: var(--fg);
|
|
||||||
opacity: 0.75;
|
|
||||||
font-size: clamp(0.95rem, 4vw, 1.1rem);
|
|
||||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes msg-in {
|
|
||||||
from { transform: scale(0.7); opacity: 0; }
|
|
||||||
to { transform: none; opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- toolbar: top centre, away from the side-edge back gesture ---- */
|
|
||||||
|
|
||||||
#toolbar {
|
|
||||||
z-index: 2;
|
|
||||||
position: fixed;
|
|
||||||
top: calc(var(--safe-t) + 14px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
transition: opacity 200ms, transform 200ms;
|
|
||||||
}
|
|
||||||
#toolbar.hidden { opacity: 0; transform: translate(-50%, -8px); pointer-events: none; }
|
|
||||||
|
|
||||||
.tool {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
color: var(--fg);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
.tool:active { background: rgba(255, 255, 255, 0.16); }
|
|
||||||
.tool:focus-visible, .sheet-body button:focus-visible, .sheet-close:focus-visible, #stake-input:focus-visible {
|
|
||||||
outline: 3px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.tool svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
||||||
.tool .off { display: none; }
|
|
||||||
#btn-sound.is-muted .on, #btn-fullscreen.is-full .on { display: none; }
|
|
||||||
#btn-sound.is-muted .off, #btn-fullscreen.is-full .off { display: inline; }
|
|
||||||
|
|
||||||
/* ---- settings sheet ---- */
|
|
||||||
|
|
||||||
#sheet-backdrop {
|
|
||||||
z-index: 3;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 220ms;
|
|
||||||
}
|
|
||||||
.sheet-open #sheet-backdrop { opacity: 1; }
|
|
||||||
|
|
||||||
#sheet {
|
|
||||||
z-index: 4;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 520px;
|
|
||||||
max-height: calc(100% - var(--safe-t) - 24px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-bottom: 0;
|
|
||||||
border-radius: 22px 22px 0 0;
|
|
||||||
box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
transform: translateY(100%);
|
|
||||||
transition: transform 240ms cubic-bezier(0.2, 0.9, 0.3, 1);
|
|
||||||
user-select: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.sheet-open #sheet { transform: none; }
|
|
||||||
|
|
||||||
.sheet-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 24px 8px;
|
|
||||||
}
|
|
||||||
.sheet-head h2 { margin: 0; font-size: 1.15rem; }
|
|
||||||
.sheet-close {
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-fg);
|
|
||||||
background: var(--accent);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 0 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sheet-body {
|
|
||||||
overflow-y: auto;
|
|
||||||
touch-action: pan-y;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding: 4px 24px calc(var(--safe-b) + 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field { padding: 12px 0; border-top: 1px solid var(--line); }
|
|
||||||
.field:first-child { border-top: 0; }
|
|
||||||
.field h3 { margin: 0 0 10px; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
|
||||||
.field.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
||||||
.field.row h3 { margin: 0; }
|
|
||||||
.field[hidden] { display: none; }
|
|
||||||
.desc { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; }
|
|
||||||
|
|
||||||
.seg { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
||||||
.seg button, .chip, .stepper button {
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--fg);
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
.seg button[aria-pressed="true"], .chip[aria-pressed="true"] {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border-color: var(--accent);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepper { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.stepper button { width: 44px; padding: 0; font-size: 1.3rem; }
|
|
||||||
.stepper output { min-width: 2ch; text-align: center; font-size: 1.2rem; font-weight: 700; }
|
|
||||||
|
|
||||||
.stake-row { display: flex; gap: 8px; }
|
|
||||||
#stake-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 44px;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 1rem; /* ≥16px stops iOS zooming on focus */
|
|
||||||
color: var(--fg);
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0 14px;
|
|
||||||
user-select: text;
|
|
||||||
-webkit-user-select: text;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
#stake-input::placeholder { color: var(--muted); opacity: 0.7; }
|
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
||||||
.chip { min-height: 38px; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
[data-theme="contrast"] .seg button,
|
|
||||||
[data-theme="contrast"] .chip,
|
|
||||||
[data-theme="contrast"] .stepper button,
|
|
||||||
[data-theme="contrast"] #stake-input { border-width: 2px; background: #000; }
|
|
||||||
|
|
||||||
/* ---- debug ---- */
|
|
||||||
|
|
||||||
#debug-hud {
|
|
||||||
z-index: 5;
|
|
||||||
position: fixed;
|
|
||||||
bottom: calc(var(--safe-b) + 8px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font: 12px/1.4 ui-monospace, monospace;
|
|
||||||
color: #8f8;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
pointer-events: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.msg.in { animation-duration: 1ms; }
|
|
||||||
#sheet, #toolbar, #sheet-backdrop { transition-duration: 1ms; }
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 237 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
|
|
@ -1,14 +0,0 @@
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 583 B |
101
index.html
101
index.html
|
|
@ -1,101 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
|
||||||
<meta name="theme-color" content="#05050b">
|
|
||||||
<meta name="color-scheme" content="dark">
|
|
||||||
<meta name="description" content="Everyone puts a finger on the screen and Picker chooses one of you at random.">
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Picker">
|
|
||||||
<title>Picker</title>
|
|
||||||
<link rel="manifest" href="manifest.webmanifest">
|
|
||||||
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
|
|
||||||
<link rel="icon" href="icons/icon-192.png" type="image/png" sizes="192x192">
|
|
||||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
|
||||||
<link rel="stylesheet" href="css/app.css">
|
|
||||||
<script type="module" src="js/main.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="stage" aria-hidden="true"></canvas>
|
|
||||||
|
|
||||||
<div id="overlay" aria-live="polite">
|
|
||||||
<p id="msg-main" class="msg"></p>
|
|
||||||
<p id="msg-sub" class="msg-sub"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav id="toolbar" class="hidden">
|
|
||||||
<button id="btn-settings" class="tool" type="button" data-i18n-aria="settings">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg>
|
|
||||||
</button>
|
|
||||||
<button id="btn-sound" class="tool" type="button" data-i18n-aria="mute">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z"/><path class="on" d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11"/><path class="off" d="M16 9.5l5 5M21 9.5l-5 5"/></svg>
|
|
||||||
</button>
|
|
||||||
<button id="btn-fullscreen" class="tool" type="button" data-i18n-aria="fullscreen">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path class="on" d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/><path class="off" d="M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"/></svg>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="sheet-backdrop" hidden></div>
|
|
||||||
<section id="sheet" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="sheet-title" hidden>
|
|
||||||
<header class="sheet-head">
|
|
||||||
<h2 id="sheet-title" data-i18n="settings"></h2>
|
|
||||||
<button class="sheet-close" type="button" data-i18n="done"></button>
|
|
||||||
</header>
|
|
||||||
<div class="sheet-body">
|
|
||||||
<div class="field">
|
|
||||||
<h3 data-i18n="modeLabel"></h3>
|
|
||||||
<div id="seg-mode" class="seg seg-wrap" data-setting="mode"></div>
|
|
||||||
<p id="mode-desc" class="desc"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="row-pickn" class="field row" hidden>
|
|
||||||
<h3 data-i18n="pickNLabel"></h3>
|
|
||||||
<div class="stepper">
|
|
||||||
<button type="button" data-step="-1" data-i18n-aria="decrease">−</button>
|
|
||||||
<output id="pickn-value">2</output>
|
|
||||||
<button type="button" data-step="1" data-i18n-aria="increase">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="row-teams" class="field row" hidden>
|
|
||||||
<h3 data-i18n="teamsLabel"></h3>
|
|
||||||
<div id="seg-teams" class="seg" data-setting="teams"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<h3><label for="stake-input" data-i18n="stakesLabel"></label></h3>
|
|
||||||
<div class="stake-row">
|
|
||||||
<input id="stake-input" type="text" maxlength="60" autocomplete="off" enterkeyhint="done">
|
|
||||||
<button id="stake-clear" type="button" class="chip" data-i18n="stakesClear" hidden></button>
|
|
||||||
</div>
|
|
||||||
<div id="stake-presets" class="chips"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<h3 data-i18n="themeLabel"></h3>
|
|
||||||
<div id="seg-theme" class="seg" data-setting="theme"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<h3 data-i18n="languageLabel"></h3>
|
|
||||||
<div id="seg-lang" class="seg" data-setting="lang"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field row">
|
|
||||||
<h3 data-i18n="soundLabel"></h3>
|
|
||||||
<div id="seg-sound" class="seg" data-setting="muted"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="row-haptics" class="field row">
|
|
||||||
<h3 data-i18n="hapticsLabel"></h3>
|
|
||||||
<div id="seg-haptics" class="seg" data-setting="haptics"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div id="debug-hud" hidden></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
166
js/audio.js
166
js/audio.js
|
|
@ -1,166 +0,0 @@
|
||||||
// Sound effects synthesised with Web Audio (no audio files) + haptics.
|
|
||||||
//
|
|
||||||
// Browsers only allow audio and navigator.vibrate after a "user activation".
|
|
||||||
// A touch *down* doesn't count (only lifting a finger, a tap or a click), so
|
|
||||||
// we try to unlock on every interaction until it works.
|
|
||||||
|
|
||||||
let ctx = null;
|
|
||||||
let master = null;
|
|
||||||
let noiseBuf = null;
|
|
||||||
let muted = false;
|
|
||||||
let hapticsOn = true;
|
|
||||||
|
|
||||||
export function setMuted(m) {
|
|
||||||
muted = m;
|
|
||||||
if (master) master.gain.setTargetAtTime(m ? 0 : 0.8, ctx.currentTime, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setHaptics(on) {
|
|
||||||
hapticsOn = on;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const canVibrate = typeof navigator.vibrate === 'function';
|
|
||||||
|
|
||||||
export function buzz(pattern) {
|
|
||||||
if (!canVibrate || !hapticsOn) return;
|
|
||||||
try {
|
|
||||||
navigator.vibrate(pattern);
|
|
||||||
} catch {
|
|
||||||
/* blocked before user activation */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unlockAudio() {
|
|
||||||
try {
|
|
||||||
if (!ctx) {
|
|
||||||
const AC = window.AudioContext || window.webkitAudioContext;
|
|
||||||
if (!AC) return;
|
|
||||||
ctx = new AC();
|
|
||||||
master = ctx.createGain();
|
|
||||||
master.gain.value = muted ? 0 : 0.8;
|
|
||||||
const comp = ctx.createDynamicsCompressor();
|
|
||||||
master.connect(comp);
|
|
||||||
comp.connect(ctx.destination);
|
|
||||||
noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
|
||||||
const d = noiseBuf.getChannelData(0);
|
|
||||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
|
||||||
}
|
|
||||||
if (ctx.state !== 'running') {
|
|
||||||
ctx.resume().catch(() => {});
|
|
||||||
// Older iOS Safari only unlocks once a sound starts inside the gesture.
|
|
||||||
const src = ctx.createBufferSource();
|
|
||||||
src.buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
|
|
||||||
src.connect(ctx.destination);
|
|
||||||
src.start();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* audio unavailable */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installAudioUnlock() {
|
|
||||||
const opts = { capture: true, passive: true };
|
|
||||||
for (const t of ['pointerdown', 'pointerup', 'touchend', 'click', 'keydown']) window.addEventListener(t, unlockAudio, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** False until the browser has let us start audio (needs a tap, not just a touch down). */
|
|
||||||
export const audioUnlocked = () => !!ctx && ctx.state === 'running';
|
|
||||||
|
|
||||||
const live = () => audioUnlocked() && !muted;
|
|
||||||
|
|
||||||
function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) {
|
|
||||||
const t0 = ctx.currentTime + at;
|
|
||||||
const o = ctx.createOscillator();
|
|
||||||
const g = ctx.createGain();
|
|
||||||
o.type = type;
|
|
||||||
o.frequency.setValueAtTime(freq, t0);
|
|
||||||
if (freqEnd) o.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
|
||||||
g.gain.setValueAtTime(0.0001, t0);
|
|
||||||
g.gain.exponentialRampToValueAtTime(gain, t0 + attack);
|
|
||||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
||||||
let node = o;
|
|
||||||
if (filter) {
|
|
||||||
const f = ctx.createBiquadFilter();
|
|
||||||
f.type = 'lowpass';
|
|
||||||
f.frequency.value = filter;
|
|
||||||
o.connect(f);
|
|
||||||
node = f;
|
|
||||||
}
|
|
||||||
node.connect(g);
|
|
||||||
g.connect(master);
|
|
||||||
o.start(t0);
|
|
||||||
o.stop(t0 + dur + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
function noise({ at = 0, dur, gain = 0.2, type = 'bandpass', freq = 1000, freqEnd, q = 1 }) {
|
|
||||||
const t0 = ctx.currentTime + at;
|
|
||||||
const src = ctx.createBufferSource();
|
|
||||||
src.buffer = noiseBuf;
|
|
||||||
const f = ctx.createBiquadFilter();
|
|
||||||
f.type = type;
|
|
||||||
f.Q.value = q;
|
|
||||||
f.frequency.setValueAtTime(freq, t0);
|
|
||||||
if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur);
|
|
||||||
const g = ctx.createGain();
|
|
||||||
g.gain.setValueAtTime(0.0001, t0);
|
|
||||||
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01);
|
|
||||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
||||||
src.connect(f);
|
|
||||||
f.connect(g);
|
|
||||||
g.connect(master);
|
|
||||||
src.start(t0);
|
|
||||||
src.stop(t0 + dur + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24];
|
|
||||||
const note = (base, semis) => base * Math.pow(2, semis / 12);
|
|
||||||
|
|
||||||
export const sfx = {
|
|
||||||
join(i) {
|
|
||||||
if (!live()) return;
|
|
||||||
tone({ freq: note(523, PENTA[i % PENTA.length]), dur: 0.14, gain: 0.12 });
|
|
||||||
},
|
|
||||||
beat(i) {
|
|
||||||
if (!live()) return;
|
|
||||||
tone({ type: 'triangle', freq: note(392, i * 5), dur: 0.18, gain: 0.25 });
|
|
||||||
},
|
|
||||||
/** Roulette tick, rising in pitch as the wheel slows. */
|
|
||||||
tick(progress, final) {
|
|
||||||
if (!live()) return;
|
|
||||||
if (final) {
|
|
||||||
tone({ type: 'square', freq: 1320, dur: 0.12, gain: 0.08, filter: 4000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tone({ type: 'square', freq: 420 + progress * 900, dur: 0.035, gain: 0.07, filter: 3500 });
|
|
||||||
noise({ dur: 0.02, gain: 0.05, type: 'highpass', freq: 3000 });
|
|
||||||
},
|
|
||||||
reveal() {
|
|
||||||
if (!live()) return;
|
|
||||||
// whoosh + boom + bright major arpeggio
|
|
||||||
noise({ dur: 0.5, gain: 0.25, type: 'bandpass', freq: 400, freqEnd: 4000, q: 0.8 });
|
|
||||||
tone({ freq: 140, freqEnd: 45, dur: 0.6, gain: 0.5 });
|
|
||||||
[0, 4, 7, 12, 16].forEach((s, i) => {
|
|
||||||
tone({ type: 'triangle', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.9, gain: 0.16 });
|
|
||||||
tone({ type: 'sawtooth', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.7, gain: 0.04, filter: 2200 });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
crack() {
|
|
||||||
if (!live()) return;
|
|
||||||
noise({ dur: 0.3, gain: 0.45, type: 'highpass', freq: 1800 });
|
|
||||||
noise({ at: 0.04, dur: 0.18, gain: 0.25, type: 'bandpass', freq: 5000, q: 4 });
|
|
||||||
tone({ freq: 220, freqEnd: 55, dur: 0.35, gain: 0.35 });
|
|
||||||
tone({ type: 'triangle', freq: 2637, at: 0.02, dur: 0.25, gain: 0.05 });
|
|
||||||
tone({ type: 'triangle', freq: 3520, at: 0.07, dur: 0.2, gain: 0.04 });
|
|
||||||
},
|
|
||||||
/** Sad trombone. */
|
|
||||||
chicken() {
|
|
||||||
if (!live()) return;
|
|
||||||
const parts = [[392, 370, 0], [370, 349, 0.28], [349, 330, 0.56]];
|
|
||||||
for (const [a, b, at] of parts) tone({ type: 'sawtooth', freq: a, freqEnd: b, at, dur: 0.26, gain: 0.12, filter: 1200, attack: 0.03 });
|
|
||||||
tone({ type: 'sawtooth', freq: 330, freqEnd: 247, at: 0.84, dur: 0.75, gain: 0.12, filter: 1000, attack: 0.03 });
|
|
||||||
},
|
|
||||||
pop(n) {
|
|
||||||
if (!live()) return;
|
|
||||||
tone({ freq: note(660, PENTA[(n - 1) % PENTA.length]), freqEnd: note(330, PENTA[(n - 1) % PENTA.length]), dur: 0.14, gain: 0.25 });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
65
js/device.js
65
js/device.js
|
|
@ -1,65 +0,0 @@
|
||||||
// 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
369
js/game.js
|
|
@ -1,369 +0,0 @@
|
||||||
// Game state machine. Pure logic + timing; no DOM, no drawing.
|
|
||||||
//
|
|
||||||
// idle ──(≥2 fingers)──▶ gathering ──(stable)──▶ countdown ──▶ suspense
|
|
||||||
// ▲ ▲ │ join │ lift ▶ chicken ─┘
|
|
||||||
// │ └────┴─────────────────┘
|
|
||||||
// suspense ──▶ result (one, pickN, teams)
|
|
||||||
// suspense ──▶ ordering ──▶ result (order)
|
|
||||||
// suspense ──▶ elimPause ──▶ suspense … ──▶ result (elimination)
|
|
||||||
|
|
||||||
import { planRound, makeHighlightSchedule, makeTeamSchedule } from './modes.js';
|
|
||||||
import { randomInt } from './random.js';
|
|
||||||
|
|
||||||
export const TIMING = {
|
|
||||||
stableMs: 2500, // finger set must be unchanged this long before the countdown
|
|
||||||
beatMs: 520, // countdown beat
|
|
||||||
beats: 3,
|
|
||||||
suspenseMs: 2700,
|
|
||||||
elimSuspenseMs: 1800,
|
|
||||||
settleMs: 650, // pause on the final highlight before the reveal
|
|
||||||
chickenMs: 1700,
|
|
||||||
elimPauseMs: 1500,
|
|
||||||
orderStepMs: 650,
|
|
||||||
restartGraceMs: 1200, // ignore taps right after a reveal
|
|
||||||
ringFadeMs: 350,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const REVEAL_STYLES = ['flood', 'confetti', 'spotlight', 'shockwave'];
|
|
||||||
|
|
||||||
// Phases where the participants are locked in; new fingers are spectators.
|
|
||||||
const LOCKED = new Set(['suspense', 'elimPause', 'ordering', 'result']);
|
|
||||||
|
|
||||||
export class Game {
|
|
||||||
/**
|
|
||||||
* @param {{emit:(type:string,data?:object)=>void, getSettings:()=>object, isReduced:()=>boolean}} opts
|
|
||||||
*/
|
|
||||||
constructor({ emit, getSettings, isReduced }) {
|
|
||||||
this.emit = emit;
|
|
||||||
this.getSettings = getSettings;
|
|
||||||
this.isReduced = isReduced;
|
|
||||||
this.rings = new Map();
|
|
||||||
this.lastRevealStyle = null;
|
|
||||||
this.reset(performance.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(now) {
|
|
||||||
this.rings.clear();
|
|
||||||
this.phase = 'idle';
|
|
||||||
this.phaseAt = now;
|
|
||||||
this.lastChangeAt = now;
|
|
||||||
this.plan = null;
|
|
||||||
this.players = [];
|
|
||||||
this.schedule = null;
|
|
||||||
this.stepIndex = -1;
|
|
||||||
this.stepAt = 0;
|
|
||||||
this.highlight = null; // Set of highlighted ids
|
|
||||||
this.displayTeams = null; // Map id -> team shown during team suspense
|
|
||||||
this.beat = -1;
|
|
||||||
this.revealAt = 0;
|
|
||||||
this.revealStyle = null;
|
|
||||||
this.winners = [];
|
|
||||||
this.orderNums = new Map(); // id -> {n, at}
|
|
||||||
this.orderIdx = 0;
|
|
||||||
this.nextOrderAt = 0;
|
|
||||||
this.remaining = [];
|
|
||||||
this.lastOut = null; // {id, chickened}
|
|
||||||
this.chickenUntil = 0;
|
|
||||||
this.pauseUntil = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- queries -----------------------------------------------------------
|
|
||||||
|
|
||||||
/** Fingers currently down that count as players (not spectators). */
|
|
||||||
activeRings() {
|
|
||||||
const out = [];
|
|
||||||
for (const r of this.rings.values()) if (r.down && !r.goneAt && !r.late) out.push(r);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
anyDown() {
|
|
||||||
for (const r of this.rings.values()) if (r.down) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
canRestart(now) {
|
|
||||||
return this.phase === 'result' && now - this.revealAt > TIMING.restartGraceMs && !this.anyDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 0..1 progress of the stability wait. */
|
|
||||||
stableProgress(now) {
|
|
||||||
return this.phase === 'gathering' ? Math.min(1, (now - this.lastChangeAt) / TIMING.stableMs) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- input -------------------------------------------------------------
|
|
||||||
|
|
||||||
fingerDown(id, x, y, now) {
|
|
||||||
if (this.rings.has(id)) return;
|
|
||||||
if (this.phase === 'result' && this.canRestart(now)) {
|
|
||||||
this.reset(now);
|
|
||||||
this.emit('restart');
|
|
||||||
}
|
|
||||||
const late = LOCKED.has(this.phase);
|
|
||||||
const ring = { id, x, y, bornAt: now, down: true, goneAt: 0, late, colorIdx: late ? -1 : this.freeColor(), outAt: 0 };
|
|
||||||
this.rings.set(id, ring);
|
|
||||||
if (late) return;
|
|
||||||
|
|
||||||
this.lastChangeAt = now;
|
|
||||||
this.emit('join', { ring });
|
|
||||||
if (this.phase === 'countdown') this.setPhase('gathering', now);
|
|
||||||
}
|
|
||||||
|
|
||||||
fingerMove(id, x, y) {
|
|
||||||
const r = this.rings.get(id);
|
|
||||||
if (r && r.down) {
|
|
||||||
r.x = x;
|
|
||||||
r.y = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fingerUp(id, now) {
|
|
||||||
const r = this.rings.get(id);
|
|
||||||
if (!r || !r.down) return;
|
|
||||||
r.down = false;
|
|
||||||
r.upAt = now;
|
|
||||||
if (r.late) {
|
|
||||||
r.goneAt = now;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (this.phase) {
|
|
||||||
case 'idle':
|
|
||||||
case 'gathering':
|
|
||||||
case 'chicken':
|
|
||||||
r.goneAt = now;
|
|
||||||
this.lastChangeAt = now;
|
|
||||||
this.emit('leave', { ring: r });
|
|
||||||
break;
|
|
||||||
case 'countdown':
|
|
||||||
r.goneAt = now;
|
|
||||||
this.chicken(now);
|
|
||||||
break;
|
|
||||||
case 'suspense':
|
|
||||||
if (this.plan.mode === 'elimination') {
|
|
||||||
this.elimChicken(r, now);
|
|
||||||
} else {
|
|
||||||
r.goneAt = now;
|
|
||||||
this.chicken(now);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'elimPause':
|
|
||||||
this.elimChicken(r, now);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// ordering / result: the outcome is fixed, the ring just freezes in place.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Page hidden etc.: drop every touch without drama. */
|
|
||||||
cancelAll(now) {
|
|
||||||
if (this.phase === 'result' || this.phase === 'ordering') {
|
|
||||||
for (const r of this.rings.values()) r.down = false;
|
|
||||||
} else {
|
|
||||||
this.reset(now);
|
|
||||||
this.emit('phase', { phase: 'idle' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- update ------------------------------------------------------------
|
|
||||||
|
|
||||||
update(now) {
|
|
||||||
for (const [id, r] of this.rings) {
|
|
||||||
if (r.goneAt && now - r.goneAt > TIMING.ringFadeMs) this.rings.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (this.phase) {
|
|
||||||
case 'idle':
|
|
||||||
if (this.activeRings().length >= 2) this.setPhase('gathering', now);
|
|
||||||
break;
|
|
||||||
case 'gathering':
|
|
||||||
if (this.activeRings().length < 2) this.setPhase('idle', now);
|
|
||||||
else if (now - this.lastChangeAt >= TIMING.stableMs) {
|
|
||||||
this.beat = -1;
|
|
||||||
this.setPhase('countdown', now);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'countdown': {
|
|
||||||
const b = Math.floor((now - this.phaseAt) / TIMING.beatMs);
|
|
||||||
if (b >= TIMING.beats) this.startRound(now);
|
|
||||||
else if (b !== this.beat) {
|
|
||||||
this.beat = b;
|
|
||||||
this.emit('beat', { index: b, remaining: TIMING.beats - b });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'chicken':
|
|
||||||
if (now >= this.chickenUntil) {
|
|
||||||
this.lastChangeAt = now;
|
|
||||||
this.setPhase(this.activeRings().length >= 2 ? 'gathering' : 'idle', now);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'suspense':
|
|
||||||
this.advanceSchedule(now);
|
|
||||||
break;
|
|
||||||
case 'elimPause':
|
|
||||||
if (now >= this.pauseUntil) {
|
|
||||||
if (this.remaining.length <= 1) this.reveal(now, this.remaining);
|
|
||||||
else this.startElimSuspense(now, TIMING.elimSuspenseMs);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'ordering':
|
|
||||||
if (now >= this.nextOrderAt) {
|
|
||||||
if (this.orderIdx >= this.plan.order.length) this.reveal(now, [this.plan.order[0]]);
|
|
||||||
else this.assignNextOrder(now);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- transitions -------------------------------------------------------
|
|
||||||
|
|
||||||
setPhase(phase, now) {
|
|
||||||
this.phase = phase;
|
|
||||||
this.phaseAt = now;
|
|
||||||
this.emit('phase', { phase });
|
|
||||||
}
|
|
||||||
|
|
||||||
freeColor() {
|
|
||||||
const used = new Set();
|
|
||||||
for (const r of this.rings.values()) if (!r.goneAt && r.colorIdx >= 0) used.add(r.colorIdx);
|
|
||||||
let i = 0;
|
|
||||||
while (used.has(i)) i++;
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
chicken(now) {
|
|
||||||
this.plan = null;
|
|
||||||
this.schedule = null;
|
|
||||||
this.highlight = null;
|
|
||||||
this.displayTeams = null;
|
|
||||||
// Spectators who are still holding on become players for the next try.
|
|
||||||
for (const r of this.rings.values()) {
|
|
||||||
if (r.late && r.down) {
|
|
||||||
r.late = false;
|
|
||||||
r.colorIdx = this.freeColor();
|
|
||||||
r.bornAt = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.chickenUntil = now + TIMING.chickenMs;
|
|
||||||
this.setPhase('chicken', now);
|
|
||||||
this.emit('chicken');
|
|
||||||
}
|
|
||||||
|
|
||||||
startRound(now) {
|
|
||||||
const rings = this.activeRings();
|
|
||||||
const ids = rings.map((r) => r.id);
|
|
||||||
const settings = this.getSettings();
|
|
||||||
const reduced = this.isReduced();
|
|
||||||
this.players = ids;
|
|
||||||
this.plan = planRound(settings.mode, ids, settings);
|
|
||||||
|
|
||||||
switch (this.plan.mode) {
|
|
||||||
case 'elimination':
|
|
||||||
this.remaining = ids.slice();
|
|
||||||
this.startElimSuspense(now, TIMING.suspenseMs);
|
|
||||||
return;
|
|
||||||
case 'teams':
|
|
||||||
this.startSuspense(now, makeTeamSchedule(ids, this.plan.teams, this.plan.teamCount, TIMING.suspenseMs, reduced));
|
|
||||||
return;
|
|
||||||
case 'order':
|
|
||||||
this.startSuspense(now, makeHighlightSchedule(rings, [this.plan.order[0]], TIMING.suspenseMs, reduced));
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
this.startSuspense(now, makeHighlightSchedule(rings, this.plan.winners, TIMING.suspenseMs, reduced));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startSuspense(now, schedule) {
|
|
||||||
this.schedule = schedule;
|
|
||||||
this.stepIndex = -1;
|
|
||||||
this.highlight = null;
|
|
||||||
this.displayTeams = null;
|
|
||||||
this.setPhase('suspense', now);
|
|
||||||
this.emit('suspenseStart');
|
|
||||||
this.advanceSchedule(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
advanceSchedule(now) {
|
|
||||||
const steps = this.schedule;
|
|
||||||
const last = steps.length - 1;
|
|
||||||
const t = now - this.phaseAt;
|
|
||||||
while (this.stepIndex < last && steps[this.stepIndex + 1].t <= t) {
|
|
||||||
this.stepIndex++;
|
|
||||||
const step = steps[this.stepIndex];
|
|
||||||
if (step.teams) this.displayTeams = step.teams;
|
|
||||||
else this.highlight = new Set(step.ids);
|
|
||||||
this.stepAt = now;
|
|
||||||
this.emit('tick', { progress: last ? this.stepIndex / last : 1, final: this.stepIndex === last });
|
|
||||||
}
|
|
||||||
if (this.stepIndex === last && t >= steps[last].t + TIMING.settleMs) this.land(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
land(now) {
|
|
||||||
const plan = this.plan;
|
|
||||||
if (plan.mode === 'order') {
|
|
||||||
this.orderIdx = 0;
|
|
||||||
this.setPhase('ordering', now);
|
|
||||||
this.assignNextOrder(now);
|
|
||||||
} else if (plan.mode === 'elimination') {
|
|
||||||
this.eliminate(this.currentTarget, now, false);
|
|
||||||
} else if (plan.mode === 'teams') {
|
|
||||||
this.reveal(now, []);
|
|
||||||
} else {
|
|
||||||
this.reveal(now, plan.winners);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reveal(now, winners) {
|
|
||||||
this.winners = winners.slice();
|
|
||||||
this.highlight = new Set(winners);
|
|
||||||
this.revealAt = now;
|
|
||||||
if (this.plan.mode === 'teams') this.revealStyle = 'teams';
|
|
||||||
else if (this.isReduced()) this.revealStyle = 'calm';
|
|
||||||
else {
|
|
||||||
const options = REVEAL_STYLES.filter((s) => s !== this.lastRevealStyle);
|
|
||||||
this.revealStyle = options[randomInt(options.length)];
|
|
||||||
this.lastRevealStyle = this.revealStyle;
|
|
||||||
}
|
|
||||||
this.setPhase('result', now);
|
|
||||||
this.emit('reveal', { plan: this.plan, style: this.revealStyle, winners: this.winners });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- order mode ----
|
|
||||||
|
|
||||||
assignNextOrder(now) {
|
|
||||||
const id = this.plan.order[this.orderIdx];
|
|
||||||
const n = this.orderIdx + 1;
|
|
||||||
this.orderNums.set(id, { n, at: now });
|
|
||||||
this.highlight = new Set([id]);
|
|
||||||
this.stepAt = now;
|
|
||||||
this.orderIdx++;
|
|
||||||
this.nextOrderAt = now + TIMING.orderStepMs;
|
|
||||||
this.emit('orderStep', { id, n, total: this.plan.order.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- elimination mode ----
|
|
||||||
|
|
||||||
startElimSuspense(now, duration) {
|
|
||||||
const target = this.plan.eliminationOrder.find((id) => this.remaining.includes(id));
|
|
||||||
this.currentTarget = target;
|
|
||||||
const rings = this.remaining.map((id) => this.rings.get(id));
|
|
||||||
this.startSuspense(now, makeHighlightSchedule(rings, [target], duration, this.isReduced()));
|
|
||||||
}
|
|
||||||
|
|
||||||
eliminate(id, now, chickened) {
|
|
||||||
const r = this.rings.get(id);
|
|
||||||
if (r) r.outAt = now;
|
|
||||||
this.remaining = this.remaining.filter((x) => x !== id);
|
|
||||||
this.lastOut = { id, chickened };
|
|
||||||
this.highlight = null;
|
|
||||||
this.pauseUntil = now + (this.remaining.length <= 1 ? 900 : TIMING.elimPauseMs);
|
|
||||||
this.setPhase('elimPause', now);
|
|
||||||
this.emit('eliminate', { id, chickened, remaining: this.remaining.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A player let go mid-elimination: they're out. */
|
|
||||||
elimChicken(r, now) {
|
|
||||||
if (!this.remaining.includes(r.id)) return; // already knocked out, lifting as asked
|
|
||||||
this.eliminate(r.id, now, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
173
js/i18n.js
173
js/i18n.js
|
|
@ -1,173 +0,0 @@
|
||||||
// All user-facing strings live here.
|
|
||||||
// Stakes are phrased so they read naturally after "The chosen one has to…"
|
|
||||||
// (en, bare verb) / "De gekozene…" (nl, conjugated verb), and slot into
|
|
||||||
// "You {stake}!" / "Jij {stake}!" on the reveal.
|
|
||||||
|
|
||||||
export const translations = {
|
|
||||||
en: {
|
|
||||||
appTitle: 'Picker',
|
|
||||||
hint: 'Everyone put a finger on the screen',
|
|
||||||
needMore: 'Waiting for more fingers…',
|
|
||||||
holdStill: 'Hold still…',
|
|
||||||
chicken: [
|
|
||||||
'Someone chickened out! 🐔',
|
|
||||||
'Cold feet? Someone let go! 🐔',
|
|
||||||
'Bawk bawk! Who let go? 🐔',
|
|
||||||
],
|
|
||||||
chickenOut: 'Chickened out! 🐔',
|
|
||||||
eliminated: 'Knocked out!',
|
|
||||||
liftFinger: 'Lift your finger',
|
|
||||||
youreIt: "You're it!",
|
|
||||||
youStake: 'You {stake}!',
|
|
||||||
teamsReady: 'Teams are set!',
|
|
||||||
orderDone: "That's the order!",
|
|
||||||
lastStanding: 'Last one standing!',
|
|
||||||
tapRestart: 'Tap to play again',
|
|
||||||
desktopHint: 'On a computer? Add ?debug=1 to the address to test with a mouse.',
|
|
||||||
tapForSound: '🔈 Tap the screen once to turn on sound',
|
|
||||||
team: 'Team {name}',
|
|
||||||
|
|
||||||
modeLabel: 'Mode',
|
|
||||||
mode: {
|
|
||||||
one: 'Pick one',
|
|
||||||
pickN: 'Pick several',
|
|
||||||
teams: 'Teams',
|
|
||||||
order: 'Order',
|
|
||||||
elimination: 'Elimination',
|
|
||||||
},
|
|
||||||
modeDesc: {
|
|
||||||
one: 'One finger is chosen.',
|
|
||||||
pickN: 'Several fingers are chosen.',
|
|
||||||
teams: 'Everyone is split into teams.',
|
|
||||||
order: 'Everyone gets a number, one by one.',
|
|
||||||
elimination: 'One finger is knocked out each round until one remains.',
|
|
||||||
},
|
|
||||||
pickNLabel: 'How many to pick',
|
|
||||||
teamsLabel: 'Number of teams',
|
|
||||||
stakesLabel: 'The chosen one has to…',
|
|
||||||
stakesPlaceholder: 'e.g. pay the bill',
|
|
||||||
stakesClear: 'Clear',
|
|
||||||
stakePresets: {
|
|
||||||
bill: 'pay the bill',
|
|
||||||
dishes: 'do the dishes',
|
|
||||||
first: 'go first',
|
|
||||||
round: 'get the next round',
|
|
||||||
movie: 'pick the movie',
|
|
||||||
},
|
|
||||||
themeLabel: 'Theme',
|
|
||||||
theme: { neon: 'Neon', pastel: 'Pastel', contrast: 'High contrast' },
|
|
||||||
languageLabel: 'Language',
|
|
||||||
soundLabel: 'Sound',
|
|
||||||
hapticsLabel: 'Vibration',
|
|
||||||
on: 'On',
|
|
||||||
off: 'Off',
|
|
||||||
done: 'Done',
|
|
||||||
settings: 'Settings',
|
|
||||||
mute: 'Mute sound',
|
|
||||||
unmute: 'Unmute sound',
|
|
||||||
fullscreen: 'Full screen',
|
|
||||||
exitFullscreen: 'Exit full screen',
|
|
||||||
decrease: 'Fewer',
|
|
||||||
increase: 'More',
|
|
||||||
},
|
|
||||||
|
|
||||||
nl: {
|
|
||||||
appTitle: 'Picker',
|
|
||||||
hint: 'Iedereen een vinger op het scherm',
|
|
||||||
needMore: 'Wachten op meer vingers…',
|
|
||||||
holdStill: 'Stil houden…',
|
|
||||||
chicken: [
|
|
||||||
'Iemand is er tussenuit geknepen! 🐔',
|
|
||||||
'Koudwatervrees? Iemand liet los! 🐔',
|
|
||||||
'Tok tok! Wie liet er los? 🐔',
|
|
||||||
],
|
|
||||||
chickenOut: 'Laf losgelaten! 🐔',
|
|
||||||
eliminated: 'Uitgeschakeld!',
|
|
||||||
liftFinger: 'Haal je vinger weg',
|
|
||||||
youreIt: 'Jij bent het!',
|
|
||||||
youStake: 'Jij {stake}!',
|
|
||||||
teamsReady: 'De teams staan!',
|
|
||||||
orderDone: 'Dat is de volgorde!',
|
|
||||||
lastStanding: 'Laatste overlevende!',
|
|
||||||
tapRestart: 'Tik om opnieuw te spelen',
|
|
||||||
desktopHint: 'Op een computer? Zet ?debug=1 achter het adres om met de muis te testen.',
|
|
||||||
tapForSound: '🔈 Tik één keer op het scherm voor geluid',
|
|
||||||
team: 'Team {name}',
|
|
||||||
|
|
||||||
modeLabel: 'Modus',
|
|
||||||
mode: {
|
|
||||||
one: 'Kies één',
|
|
||||||
pickN: 'Kies meerdere',
|
|
||||||
teams: 'Teams',
|
|
||||||
order: 'Volgorde',
|
|
||||||
elimination: 'Afvalrace',
|
|
||||||
},
|
|
||||||
modeDesc: {
|
|
||||||
one: 'Eén vinger wordt gekozen.',
|
|
||||||
pickN: 'Meerdere vingers worden gekozen.',
|
|
||||||
teams: 'Iedereen wordt in teams verdeeld.',
|
|
||||||
order: 'Iedereen krijgt een nummer, één voor één.',
|
|
||||||
elimination: 'Elke ronde valt er één af, tot er één overblijft.',
|
|
||||||
},
|
|
||||||
pickNLabel: 'Hoeveel kiezen',
|
|
||||||
teamsLabel: 'Aantal teams',
|
|
||||||
stakesLabel: 'De gekozene…',
|
|
||||||
stakesPlaceholder: 'bijv. betaalt de rekening',
|
|
||||||
stakesClear: 'Wissen',
|
|
||||||
stakePresets: {
|
|
||||||
bill: 'betaalt de rekening',
|
|
||||||
dishes: 'doet de afwas',
|
|
||||||
first: 'mag beginnen',
|
|
||||||
round: 'haalt het volgende rondje',
|
|
||||||
movie: 'kiest de film',
|
|
||||||
},
|
|
||||||
themeLabel: 'Thema',
|
|
||||||
theme: { neon: 'Neon', pastel: 'Pastel', contrast: 'Hoog contrast' },
|
|
||||||
languageLabel: 'Taal',
|
|
||||||
soundLabel: 'Geluid',
|
|
||||||
hapticsLabel: 'Trillen',
|
|
||||||
on: 'Aan',
|
|
||||||
off: 'Uit',
|
|
||||||
done: 'Klaar',
|
|
||||||
settings: 'Instellingen',
|
|
||||||
mute: 'Geluid uit',
|
|
||||||
unmute: 'Geluid aan',
|
|
||||||
fullscreen: 'Volledig scherm',
|
|
||||||
exitFullscreen: 'Volledig scherm sluiten',
|
|
||||||
decrease: 'Minder',
|
|
||||||
increase: 'Meer',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LANGS = ['en', 'nl'];
|
|
||||||
let current = 'en';
|
|
||||||
|
|
||||||
export function detectLang() {
|
|
||||||
const prefs = navigator.languages?.length ? navigator.languages : [navigator.language || 'en'];
|
|
||||||
for (const p of prefs) {
|
|
||||||
const base = String(p).toLowerCase().split('-')[0];
|
|
||||||
if (LANGS.includes(base)) return base;
|
|
||||||
}
|
|
||||||
return 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLang(lang) {
|
|
||||||
current = LANGS.includes(lang) ? lang : 'en';
|
|
||||||
document.documentElement.lang = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLang() {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up a dotted key ("mode.one"); interpolate {vars}. Falls back to English. */
|
|
||||||
export function t(key, vars) {
|
|
||||||
const lookup = (lang) => key.split('.').reduce((o, k) => (o == null ? o : o[k]), translations[lang]);
|
|
||||||
let s = lookup(current);
|
|
||||||
if (s == null) s = lookup('en');
|
|
||||||
if (s == null) return key;
|
|
||||||
if (typeof s === 'string' && vars) {
|
|
||||||
s = s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
156
js/input.js
156
js/input.js
|
|
@ -1,156 +0,0 @@
|
||||||
// Pointer input. Fingers are tracked by pointerId; pointercancel counts as
|
|
||||||
// a lift (Android cancels touches it thinks are palms or system gestures).
|
|
||||||
// Browsers may recycle pointerIds, while the game keeps lifted fingers on
|
|
||||||
// screen after a round, so every touch gets its own never-reused finger id.
|
|
||||||
//
|
|
||||||
// Debug mode (?debug=1): mouse clicks place persistent fake fingers.
|
|
||||||
// Click a fake finger again to remove it; drag it to move it.
|
|
||||||
// Keys: A adds a fake finger at a random spot, C clears all fake fingers.
|
|
||||||
|
|
||||||
const DRAG_THRESHOLD = 6;
|
|
||||||
|
|
||||||
export function createInput(canvas, { debug, onDown, onMove, onUp }) {
|
|
||||||
const active = new Map(); // pointerId -> finger id, for touches currently down
|
|
||||||
let touchSeq = 0;
|
|
||||||
const fakes = new Map(); // fake id -> {x, y}
|
|
||||||
let fakeSeq = 0;
|
|
||||||
let mouse = null; // {pointerId, fakeId, startX, startY, moved, created}
|
|
||||||
|
|
||||||
const now = () => performance.now();
|
|
||||||
const hitRadius = () => Math.max(40, Math.min(64, Math.min(innerWidth, innerHeight) * 0.115));
|
|
||||||
|
|
||||||
function release(pointerId) {
|
|
||||||
const id = active.get(pointerId);
|
|
||||||
if (id == null) return;
|
|
||||||
active.delete(pointerId);
|
|
||||||
onUp(id, now());
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener('pointerdown', (e) => {
|
|
||||||
if (e.pointerType === 'mouse') {
|
|
||||||
if (debug && e.button === 0) debugDown(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
canvas.setPointerCapture(e.pointerId);
|
|
||||||
} catch {
|
|
||||||
/* not fatal */
|
|
||||||
}
|
|
||||||
release(e.pointerId); // a recycled id whose lift we never saw
|
|
||||||
const id = `t${++touchSeq}`;
|
|
||||||
active.set(e.pointerId, id);
|
|
||||||
onDown(id, e.clientX, e.clientY, now());
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('pointermove', (e) => {
|
|
||||||
if (mouse && e.pointerId === mouse.pointerId) return debugMove(e);
|
|
||||||
const id = active.get(e.pointerId);
|
|
||||||
if (id != null) onMove(id, e.clientX, e.clientY);
|
|
||||||
});
|
|
||||||
|
|
||||||
const up = (e) => {
|
|
||||||
if (mouse && e.pointerId === mouse.pointerId) return debugUp(e);
|
|
||||||
release(e.pointerId);
|
|
||||||
};
|
|
||||||
canvas.addEventListener('pointerup', up);
|
|
||||||
canvas.addEventListener('pointercancel', up);
|
|
||||||
// Safety net: if capture is lost without an up/cancel, treat it as a lift.
|
|
||||||
canvas.addEventListener('lostpointercapture', (e) => {
|
|
||||||
if (!mouse || e.pointerId !== mouse.pointerId) release(e.pointerId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- debug: fake fingers with the mouse ----
|
|
||||||
|
|
||||||
function fakeAt(x, y) {
|
|
||||||
const r = hitRadius();
|
|
||||||
for (const [id, f] of fakes) if (Math.hypot(f.x - x, f.y - y) < r) return id;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFake(x, y) {
|
|
||||||
const id = `fake-${++fakeSeq}`;
|
|
||||||
fakes.set(id, { x, y });
|
|
||||||
onDown(id, x, y, now());
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFake(id) {
|
|
||||||
if (!fakes.delete(id)) return;
|
|
||||||
onUp(id, now());
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugDown(e) {
|
|
||||||
const hit = fakeAt(e.clientX, e.clientY);
|
|
||||||
const fakeId = hit ?? addFake(e.clientX, e.clientY);
|
|
||||||
mouse = { pointerId: e.pointerId, fakeId, startX: e.clientX, startY: e.clientY, moved: false, created: !hit };
|
|
||||||
try {
|
|
||||||
canvas.setPointerCapture(e.pointerId);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugMove(e) {
|
|
||||||
if (!mouse.moved && Math.hypot(e.clientX - mouse.startX, e.clientY - mouse.startY) > DRAG_THRESHOLD) mouse.moved = true;
|
|
||||||
if (mouse.moved && fakes.has(mouse.fakeId)) {
|
|
||||||
fakes.set(mouse.fakeId, { x: e.clientX, y: e.clientY });
|
|
||||||
onMove(mouse.fakeId, e.clientX, e.clientY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugUp() {
|
|
||||||
const m = mouse;
|
|
||||||
mouse = null;
|
|
||||||
if (!m.created && !m.moved) removeFake(m.fakeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
window.addEventListener('keydown', (e) => {
|
|
||||||
if (e.target instanceof HTMLInputElement) return;
|
|
||||||
if (e.key === 'a' || e.key === 'A') {
|
|
||||||
const m = 80;
|
|
||||||
addFake(m + Math.random() * (innerWidth - 2 * m), m + Math.random() * (innerHeight - 2 * m));
|
|
||||||
} else if (e.key === 'c' || e.key === 'C') {
|
|
||||||
for (const id of [...fakes.keys()]) removeFake(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
/** Forget all touches (page hidden). Fake fingers are kept. */
|
|
||||||
clearReal() {
|
|
||||||
active.clear();
|
|
||||||
},
|
|
||||||
/** Place fake fingers in a circle (debug ?fingers=N). */
|
|
||||||
addFakeCircle(n) {
|
|
||||||
const cx = innerWidth / 2;
|
|
||||||
const cy = innerHeight / 2;
|
|
||||||
const rad = Math.min(innerWidth, innerHeight) * 0.32;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
|
|
||||||
addFake(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fakeCount: () => fakes.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the browser from scrolling, zooming, selecting or opening menus. */
|
|
||||||
export function blockBrowserGestures(canvas) {
|
|
||||||
const opts = { passive: false };
|
|
||||||
// iOS ignores touch-action for some gestures; cancel touches on the canvas.
|
|
||||||
canvas.addEventListener('touchstart', (e) => e.preventDefault(), opts);
|
|
||||||
canvas.addEventListener('touchmove', (e) => e.preventDefault(), opts);
|
|
||||||
canvas.addEventListener('touchend', (e) => e.preventDefault(), opts);
|
|
||||||
// iOS pinch-zoom
|
|
||||||
for (const t of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(t, (e) => e.preventDefault(), opts);
|
|
||||||
// No long-press menus or text selection anywhere except form fields.
|
|
||||||
document.addEventListener('contextmenu', (e) => {
|
|
||||||
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
|
||||||
});
|
|
||||||
document.addEventListener('selectstart', (e) => {
|
|
||||||
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
|
||||||
});
|
|
||||||
document.addEventListener('dblclick', (e) => e.preventDefault(), opts);
|
|
||||||
}
|
|
||||||
136
js/main.js
136
js/main.js
|
|
@ -1,136 +0,0 @@
|
||||||
// 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
111
js/modes.js
|
|
@ -1,111 +0,0 @@
|
||||||
// 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
29
js/random.js
|
|
@ -1,29 +0,0 @@
|
||||||
// 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
468
js/renderer.js
|
|
@ -1,468 +0,0 @@
|
||||||
// Canvas renderer. Reads game state every frame; never mutates it.
|
|
||||||
// Glowing rings are pre-rendered sprites (shadowBlur is far too slow to
|
|
||||||
// run per frame on phones), scaled with drawImage.
|
|
||||||
|
|
||||||
import { TIMING } from './game.js';
|
|
||||||
import { SHAPES } from './themes.js';
|
|
||||||
import { TEAM_NAMES } from './modes.js';
|
|
||||||
import { createReveal, createRipple, clamp01, easeOutCubic } from './reveals.js';
|
|
||||||
|
|
||||||
const TAU = Math.PI * 2;
|
|
||||||
const SPRITE_SCALE = 1.7; // sprites are drawn at the largest ring size, then scaled down
|
|
||||||
|
|
||||||
function easeOutBack(t) {
|
|
||||||
const c1 = 1.70158;
|
|
||||||
const c3 = c1 + 1;
|
|
||||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Renderer {
|
|
||||||
constructor(canvas) {
|
|
||||||
this.canvas = canvas;
|
|
||||||
this.ctx = canvas.getContext('2d');
|
|
||||||
this.sprites = new Map();
|
|
||||||
this.ripples = [];
|
|
||||||
this.reveal = null;
|
|
||||||
this.shards = new Map();
|
|
||||||
this.theme = null;
|
|
||||||
this.resize();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(theme) {
|
|
||||||
this.theme = theme;
|
|
||||||
this.sprites.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
resize() {
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 3);
|
|
||||||
const w = this.canvas.clientWidth || window.innerWidth;
|
|
||||||
const h = this.canvas.clientHeight || window.innerHeight;
|
|
||||||
this.dpr = dpr;
|
|
||||||
this.w = w;
|
|
||||||
this.h = h;
|
|
||||||
this.canvas.width = Math.round(w * dpr);
|
|
||||||
this.canvas.height = Math.round(h * dpr);
|
|
||||||
this.R = Math.max(40, Math.min(64, Math.min(w, h) * 0.115));
|
|
||||||
this.sprites.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
colorOf(ring) {
|
|
||||||
return ring.colorIdx < 0 ? this.theme.spectator : this.theme.colors[ring.colorIdx % this.theme.colors.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
lineWidth() {
|
|
||||||
return this.R * 0.13 * this.theme.lineScale;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- events from the game ----------------------------------------------
|
|
||||||
|
|
||||||
onEvent(type, data, now, game) {
|
|
||||||
const R = this.R;
|
|
||||||
switch (type) {
|
|
||||||
case 'join':
|
|
||||||
this.ripples.push(createRipple(data.ring.x, data.ring.y, this.colorOf(data.ring), now, R * 1.8, 450, 3));
|
|
||||||
break;
|
|
||||||
case 'beat':
|
|
||||||
for (const r of game.activeRings()) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.1, 500, 3));
|
|
||||||
break;
|
|
||||||
case 'orderStep': {
|
|
||||||
const r = game.rings.get(data.id);
|
|
||||||
if (r) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.6, 600, 5));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'reveal': {
|
|
||||||
const targets =
|
|
||||||
data.style === 'teams'
|
|
||||||
? game.players.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.theme.team[game.plan.teams.get(ring.id)] }))
|
|
||||||
: data.winners.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.colorOf(ring) }));
|
|
||||||
this.reveal = createReveal(data.style, { targets, view: this, start: now, palette: this.theme.colors });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'phase':
|
|
||||||
if (data.phase !== 'result') this.reveal = null;
|
|
||||||
if (data.phase === 'idle' || data.phase === 'gathering') this.shards.clear();
|
|
||||||
break;
|
|
||||||
case 'restart':
|
|
||||||
this.reveal = null;
|
|
||||||
this.shards.clear();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- sprites -----------------------------------------------------------
|
|
||||||
|
|
||||||
sprite(color) {
|
|
||||||
let s = this.sprites.get(color);
|
|
||||||
if (s) return s;
|
|
||||||
const { dpr, theme } = this;
|
|
||||||
const r = this.R * SPRITE_SCALE;
|
|
||||||
const lw = this.lineWidth() * SPRITE_SCALE;
|
|
||||||
const pad = r * 0.55;
|
|
||||||
const half = r + pad;
|
|
||||||
const c = document.createElement('canvas');
|
|
||||||
c.width = c.height = Math.ceil(half * 2 * dpr);
|
|
||||||
const g = c.getContext('2d');
|
|
||||||
g.scale(dpr, dpr);
|
|
||||||
|
|
||||||
// faint inner fill
|
|
||||||
const fill = g.createRadialGradient(half, half, r * 0.3, half, half, r);
|
|
||||||
fill.addColorStop(0, 'rgba(0,0,0,0)');
|
|
||||||
fill.addColorStop(1, color + '38');
|
|
||||||
g.fillStyle = fill;
|
|
||||||
g.beginPath();
|
|
||||||
g.arc(half, half, r, 0, TAU);
|
|
||||||
g.fill();
|
|
||||||
|
|
||||||
g.strokeStyle = color;
|
|
||||||
g.lineWidth = lw;
|
|
||||||
if (theme.glow) {
|
|
||||||
g.shadowColor = color;
|
|
||||||
g.shadowBlur = r * 0.5 * theme.glow * dpr;
|
|
||||||
g.beginPath();
|
|
||||||
g.arc(half, half, r, 0, TAU);
|
|
||||||
g.stroke();
|
|
||||||
}
|
|
||||||
g.beginPath();
|
|
||||||
g.arc(half, half, r, 0, TAU);
|
|
||||||
g.stroke();
|
|
||||||
g.shadowBlur = 0;
|
|
||||||
if (theme.additive) {
|
|
||||||
// white-hot core, neon tube style
|
|
||||||
g.strokeStyle = 'rgba(255,255,255,0.7)';
|
|
||||||
g.lineWidth = lw * 0.3;
|
|
||||||
g.stroke();
|
|
||||||
}
|
|
||||||
s = { canvas: c, half, base: r };
|
|
||||||
this.sprites.set(color, s);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- drawing -----------------------------------------------------------
|
|
||||||
|
|
||||||
draw(game, now) {
|
|
||||||
// Cheaper and more reliable than trusting every resize/rotation event.
|
|
||||||
const c = this.canvas;
|
|
||||||
if (c.clientWidth !== this.w || c.clientHeight !== this.h || Math.min(window.devicePixelRatio || 1, 3) !== this.dpr) this.resize();
|
|
||||||
const { ctx, dpr, w, h, theme } = this;
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
const shake = this.reveal?.shake?.(now);
|
|
||||||
if (shake) ctx.translate(shake[0], shake[1]);
|
|
||||||
|
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
ctx.fillStyle = theme.bg;
|
|
||||||
ctx.fillRect(-20, -20, w + 40, h + 40);
|
|
||||||
|
|
||||||
if (this.reveal) this.reveal.drawBack(ctx, now);
|
|
||||||
|
|
||||||
// Two passes so highlighted rings sit on top.
|
|
||||||
if (theme.additive) ctx.globalCompositeOperation = 'lighter';
|
|
||||||
const hl = game.highlight;
|
|
||||||
for (const ring of game.rings.values()) if (!hl?.has(ring.id)) this.drawRing(ctx, ring, game, now);
|
|
||||||
for (const ring of game.rings.values()) if (hl?.has(ring.id)) this.drawRing(ctx, ring, game, now);
|
|
||||||
|
|
||||||
this.ripples = this.ripples.filter((r) => !r.done(now));
|
|
||||||
for (const r of this.ripples) r.draw(ctx, now);
|
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
|
||||||
|
|
||||||
if (this.reveal) this.reveal.drawFront(ctx, now);
|
|
||||||
this.drawLabels(ctx, game, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawRing(ctx, ring, game, now) {
|
|
||||||
const { theme } = this;
|
|
||||||
const R = this.R;
|
|
||||||
let scale = 1;
|
|
||||||
let alpha = 1;
|
|
||||||
let color = this.colorOf(ring);
|
|
||||||
let shapeIdx = ring.colorIdx;
|
|
||||||
|
|
||||||
const appear = clamp01((now - ring.bornAt) / 320);
|
|
||||||
if (appear < 1) scale *= easeOutBack(appear);
|
|
||||||
if (ring.goneAt) {
|
|
||||||
const k = clamp01((now - ring.goneAt) / TIMING.ringFadeMs);
|
|
||||||
scale *= 1 - 0.5 * k;
|
|
||||||
alpha *= 1 - k;
|
|
||||||
}
|
|
||||||
if (ring.late) {
|
|
||||||
this.drawShape(ctx, ring.x, ring.y, R * 0.75 * scale, theme.spectator, alpha * 0.6, -1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ring.outAt) {
|
|
||||||
this.drawCrack(ctx, ring, color, now);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const phase = game.phase;
|
|
||||||
const mode = game.plan?.mode;
|
|
||||||
const hl = game.highlight;
|
|
||||||
const isHl = !!hl?.has(ring.id);
|
|
||||||
const pop = Math.exp(-(now - game.stepAt) / 90);
|
|
||||||
|
|
||||||
if (phase === 'idle' || phase === 'gathering' || phase === 'chicken') {
|
|
||||||
scale *= 1 + 0.05 * Math.sin(((now - ring.bornAt) / 1000) * TAU * 0.7);
|
|
||||||
if (phase === 'chicken') alpha *= 0.55;
|
|
||||||
} else if (phase === 'countdown') {
|
|
||||||
const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs;
|
|
||||||
scale *= 1 + 0.2 * Math.exp(-bp * 6);
|
|
||||||
} else if (mode === 'teams') {
|
|
||||||
const team = phase === 'result' ? game.plan.teams.get(ring.id) : game.displayTeams?.get(ring.id);
|
|
||||||
if (team != null) {
|
|
||||||
color = theme.team[team];
|
|
||||||
shapeIdx = team;
|
|
||||||
}
|
|
||||||
scale *= phase === 'result' ? 1.1 + 0.04 * Math.sin(now / 300) : 1 + 0.1 * pop;
|
|
||||||
} else if (phase === 'result') {
|
|
||||||
if (mode === 'order') {
|
|
||||||
if (isHl) scale *= 1.3 + 0.05 * Math.sin(now / 250);
|
|
||||||
} else if (isHl) {
|
|
||||||
scale *= 1.35 + 0.05 * Math.sin(now / 250);
|
|
||||||
if (this.reveal?.winnerWhite) color = '#ffffff';
|
|
||||||
} else {
|
|
||||||
const k = clamp01((now - game.revealAt) / 650);
|
|
||||||
if (k >= 1) return;
|
|
||||||
scale *= 1 - k * k;
|
|
||||||
alpha *= 1 - k;
|
|
||||||
}
|
|
||||||
} else if (phase === 'ordering') {
|
|
||||||
const num = game.orderNums.get(ring.id);
|
|
||||||
if (isHl) scale *= 1.2 + 0.15 * pop;
|
|
||||||
else if (!num) {
|
|
||||||
scale *= 0.85;
|
|
||||||
alpha *= 0.4;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// suspense / elimPause
|
|
||||||
if (isHl) scale *= 1.18 + 0.14 * pop;
|
|
||||||
else if (hl) {
|
|
||||||
scale *= 0.86;
|
|
||||||
alpha *= 0.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.drawShape(ctx, ring.x, ring.y, R * scale, color, alpha, theme.shapes ? shapeIdx : -1);
|
|
||||||
|
|
||||||
if (phase === 'gathering') {
|
|
||||||
const p = game.stableProgress(now);
|
|
||||||
if (p > 0.08) this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.55, p);
|
|
||||||
} else if (phase === 'countdown') {
|
|
||||||
const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs;
|
|
||||||
this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.3 + 0.6 * Math.exp(-bp * 5), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawArc(ctx, x, y, r, color, alpha, p) {
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = Math.max(2, this.R * 0.05);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, r, -Math.PI / 2, -Math.PI / 2 + TAU * p);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A ring (sprite) or, in high-contrast, a shape outline. */
|
|
||||||
drawShape(ctx, x, y, r, color, alpha, shapeIdx) {
|
|
||||||
if (r <= 0.5 || alpha <= 0.01) return;
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
if (shapeIdx >= 0) {
|
|
||||||
const lw = this.lineWidth();
|
|
||||||
tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r);
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.strokeStyle = '#000';
|
|
||||||
ctx.lineWidth = lw + 6;
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = lw;
|
|
||||||
ctx.stroke();
|
|
||||||
// second, inner outline doubles as a pattern cue
|
|
||||||
tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r * 0.72);
|
|
||||||
ctx.lineWidth = Math.max(2, lw * 0.35);
|
|
||||||
ctx.stroke();
|
|
||||||
} else {
|
|
||||||
const s = this.sprite(color);
|
|
||||||
const k = r / s.base;
|
|
||||||
const size = s.half * 2 * k;
|
|
||||||
ctx.drawImage(s.canvas, x - s.half * k, y - s.half * k, size, size);
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Elimination: the ring cracks, splits into shards that fall and fade. */
|
|
||||||
drawCrack(ctx, ring, color, now) {
|
|
||||||
const R = this.R;
|
|
||||||
const t = (now - ring.outAt) / 1000;
|
|
||||||
if (t > 1.3) return;
|
|
||||||
let shards = this.shards.get(ring.id);
|
|
||||||
if (!shards) {
|
|
||||||
shards = [];
|
|
||||||
const n = 7;
|
|
||||||
let a = Math.random() * TAU;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const span = (TAU / n) * (0.7 + Math.random() * 0.6);
|
|
||||||
const mid = a + span / 2;
|
|
||||||
shards.push({ a0: a, a1: a + span * 0.92, dx: Math.cos(mid), dy: Math.sin(mid), v: 70 + Math.random() * 110, spin: (Math.random() - 0.5) * 4 });
|
|
||||||
a += span;
|
|
||||||
}
|
|
||||||
const cracks = [];
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const ang = Math.random() * TAU;
|
|
||||||
const pts = [];
|
|
||||||
for (let j = 0; j <= 4; j++) {
|
|
||||||
const rr = R * (0.55 + j * 0.2);
|
|
||||||
const jit = (Math.random() - 0.5) * 0.35;
|
|
||||||
pts.push([Math.cos(ang + jit) * rr, Math.sin(ang + jit) * rr]);
|
|
||||||
}
|
|
||||||
cracks.push(pts);
|
|
||||||
}
|
|
||||||
shards.cracks = cracks;
|
|
||||||
this.shards.set(ring.id, shards);
|
|
||||||
}
|
|
||||||
const lw = this.lineWidth();
|
|
||||||
if (t < 0.18) {
|
|
||||||
// intact, flashing white and trembling, cracks spreading
|
|
||||||
const j = 3 * (1 - t / 0.18);
|
|
||||||
const x = ring.x + (Math.random() - 0.5) * j;
|
|
||||||
const y = ring.y + (Math.random() - 0.5) * j;
|
|
||||||
this.drawShape(ctx, x, y, R * 1.1, t < 0.06 ? '#ffffff' : color, 1, this.theme.shapes ? ring.colorIdx : -1);
|
|
||||||
ctx.strokeStyle = '#fff';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
const k = t / 0.18;
|
|
||||||
for (const pts of shards.cracks) {
|
|
||||||
ctx.beginPath();
|
|
||||||
const m = Math.max(2, Math.ceil(pts.length * k));
|
|
||||||
for (let i = 0; i < m && i < pts.length; i++) ctx[i ? 'lineTo' : 'moveTo'](x + pts[i][0], y + pts[i][1]);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tt = t - 0.18;
|
|
||||||
const fade = clamp01(1 - tt / 1.0);
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
for (const s of shards) {
|
|
||||||
const d = s.v * tt;
|
|
||||||
const x = ring.x + s.dx * d;
|
|
||||||
const y = ring.y + s.dy * d + 500 * tt * tt;
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(x, y);
|
|
||||||
ctx.rotate(s.spin * tt);
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.globalAlpha = fade * 0.35;
|
|
||||||
ctx.lineWidth = lw * 2.4;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, R * 1.1, s.a0, s.a1);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.globalAlpha = fade;
|
|
||||||
ctx.lineWidth = lw;
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
ctx.lineCap = 'butt';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Number / team badges, placed on the side of the finger facing the
|
|
||||||
* screen centre, since the hand covers the other side. */
|
|
||||||
drawLabels(ctx, game, now) {
|
|
||||||
const mode = game.plan?.mode;
|
|
||||||
const showOrder = mode === 'order' && (game.phase === 'ordering' || game.phase === 'result');
|
|
||||||
const showTeams = mode === 'teams' && game.phase === 'result';
|
|
||||||
if (!showOrder && !showTeams) return;
|
|
||||||
const R = this.R;
|
|
||||||
const br = R * 0.42;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.font = `800 ${Math.round(br * 1.1)}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
|
|
||||||
for (const ring of game.rings.values()) {
|
|
||||||
let text;
|
|
||||||
let color;
|
|
||||||
let at;
|
|
||||||
if (showOrder) {
|
|
||||||
const o = game.orderNums.get(ring.id);
|
|
||||||
if (!o) continue;
|
|
||||||
text = String(o.n);
|
|
||||||
color = this.colorOf(ring);
|
|
||||||
at = o.at;
|
|
||||||
} else {
|
|
||||||
const team = game.plan.teams.get(ring.id);
|
|
||||||
if (team == null) continue;
|
|
||||||
text = TEAM_NAMES[team];
|
|
||||||
color = this.theme.team[team];
|
|
||||||
at = game.revealAt;
|
|
||||||
}
|
|
||||||
let dx = this.w / 2 - ring.x;
|
|
||||||
let dy = this.h / 2 - ring.y;
|
|
||||||
const len = Math.hypot(dx, dy);
|
|
||||||
if (len < R) {
|
|
||||||
dx = 0;
|
|
||||||
dy = -1;
|
|
||||||
} else {
|
|
||||||
dx /= len;
|
|
||||||
dy /= len;
|
|
||||||
}
|
|
||||||
const s = easeOutBack(clamp01((now - at) / 350));
|
|
||||||
const dist = R * 1.95;
|
|
||||||
const x = ring.x + dx * dist;
|
|
||||||
const y = ring.y + dy * dist;
|
|
||||||
ctx.globalAlpha = clamp01(s);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, br * s, 0, TAU);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.strokeStyle = '#000';
|
|
||||||
ctx.stroke();
|
|
||||||
if (s > 0.3) {
|
|
||||||
ctx.fillStyle = '#000';
|
|
||||||
ctx.fillText(text, x, y + br * 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tracePath(ctx, shape, x, y, r) {
|
|
||||||
ctx.beginPath();
|
|
||||||
const poly = (n, rot, rr = r) => {
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const a = rot + (i / n) * TAU;
|
|
||||||
ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
};
|
|
||||||
switch (shape) {
|
|
||||||
case 'square':
|
|
||||||
poly(4, Math.PI / 4, r * 1.05);
|
|
||||||
break;
|
|
||||||
case 'triangle':
|
|
||||||
poly(3, -Math.PI / 2, r * 1.2);
|
|
||||||
break;
|
|
||||||
case 'diamond':
|
|
||||||
poly(4, 0, r * 1.15);
|
|
||||||
break;
|
|
||||||
case 'hexagon':
|
|
||||||
poly(6, 0);
|
|
||||||
break;
|
|
||||||
case 'pentagon':
|
|
||||||
poly(5, -Math.PI / 2, r * 1.05);
|
|
||||||
break;
|
|
||||||
case 'star':
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const a = -Math.PI / 2 + (i / 10) * TAU;
|
|
||||||
const rr = i % 2 ? r * 0.62 : r * 1.2;
|
|
||||||
ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
break;
|
|
||||||
case 'cross': {
|
|
||||||
const a = r * 0.42;
|
|
||||||
const b = r * 1.05;
|
|
||||||
const pts = [[-a, -b], [a, -b], [a, -a], [b, -a], [b, a], [a, a], [a, b], [-a, b], [-a, a], [-b, a], [-b, -a], [-a, -a]];
|
|
||||||
pts.forEach(([px, py], i) => ctx[i ? 'lineTo' : 'moveTo'](x + px, y + py));
|
|
||||||
ctx.closePath();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
ctx.arc(x, y, r, 0, TAU);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
271
js/reveals.js
271
js/reveals.js
|
|
@ -1,271 +0,0 @@
|
||||||
// Reveal animations, drawn by the renderer on top of / behind the rings.
|
|
||||||
// Each reveal gets live ring objects so it follows fingers that still move.
|
|
||||||
|
|
||||||
export const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
||||||
export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
|
||||||
export const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
||||||
|
|
||||||
export function hexToRgba(hex, a) {
|
|
||||||
const n = parseInt(hex.slice(1), 16);
|
|
||||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function farthestCorner(x, y, w, h) {
|
|
||||||
return Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} style flood | confetti | spotlight | shockwave | calm | teams
|
|
||||||
* @param {{targets:{ring:{x:number,y:number}, color:string}[], view:{w:number,h:number,R:number}, start:number, palette:string[]}} o
|
|
||||||
* `view` is read every frame so the effect follows resizes (e.g. entering fullscreen).
|
|
||||||
*/
|
|
||||||
export function createReveal(style, o) {
|
|
||||||
const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm;
|
|
||||||
return { winnerWhite: false, drawBack() {}, drawFront() {}, shake: null, ...make(o) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function flood({ targets, view, start }) {
|
|
||||||
const single = targets.length === 1;
|
|
||||||
return {
|
|
||||||
winnerWhite: true,
|
|
||||||
drawBack(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const k = easeInOutCubic(clamp01((now - start) / 850));
|
|
||||||
g.globalAlpha = single ? 1 : 0.8;
|
|
||||||
for (const { ring, color } of targets) {
|
|
||||||
const maxR = farthestCorner(ring.x, ring.y, w, h) * (single ? 1 : 0.5);
|
|
||||||
g.fillStyle = color;
|
|
||||||
g.beginPath();
|
|
||||||
g.arc(ring.x, ring.y, Math.max(0, maxR * k), 0, Math.PI * 2);
|
|
||||||
g.fill();
|
|
||||||
}
|
|
||||||
g.globalAlpha = 1;
|
|
||||||
if (single && k >= 1) {
|
|
||||||
// Soft vignette so the flooded screen keeps some depth.
|
|
||||||
const { ring } = targets[0];
|
|
||||||
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, farthestCorner(ring.x, ring.y, w, h));
|
|
||||||
grad.addColorStop(0, 'rgba(0,0,0,0)');
|
|
||||||
grad.addColorStop(1, 'rgba(0,0,0,0.4)');
|
|
||||||
g.fillStyle = grad;
|
|
||||||
g.fillRect(0, 0, w, h);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function confetti({ targets, view, start, palette }) {
|
|
||||||
const parts = [];
|
|
||||||
const per = Math.round(170 / targets.length);
|
|
||||||
for (const { ring, color } of targets) {
|
|
||||||
for (let i = 0; i < per; i++) {
|
|
||||||
const a = Math.random() * Math.PI * 2;
|
|
||||||
const speed = 250 + Math.random() * 850;
|
|
||||||
parts.push({
|
|
||||||
ring,
|
|
||||||
delay: i < per * 0.6 ? 0 : 0.22,
|
|
||||||
vx: Math.cos(a) * speed,
|
|
||||||
vy: Math.sin(a) * speed - 300,
|
|
||||||
size: 5 + Math.random() * 7,
|
|
||||||
color: Math.random() < 0.45 ? color : palette[(Math.random() * palette.length) | 0],
|
|
||||||
rot: Math.random() * Math.PI,
|
|
||||||
vr: (Math.random() - 0.5) * 14,
|
|
||||||
flip: 4 + Math.random() * 8,
|
|
||||||
life: 2.4 + Math.random() * 1.2,
|
|
||||||
round: Math.random() < 0.3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Freeze burst origin at reveal time.
|
|
||||||
for (const p of parts) {
|
|
||||||
p.x0 = p.ring.x;
|
|
||||||
p.y0 = p.ring.y;
|
|
||||||
}
|
|
||||||
const tau = 0.75;
|
|
||||||
const grav = 1100;
|
|
||||||
return {
|
|
||||||
drawBack(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const k = clamp01((now - start) / 700);
|
|
||||||
for (const { ring, color } of targets) {
|
|
||||||
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h));
|
|
||||||
grad.addColorStop(0, hexToRgba(color, 0.45 * k));
|
|
||||||
grad.addColorStop(1, hexToRgba(color, 0.08 * k));
|
|
||||||
g.fillStyle = grad;
|
|
||||||
g.fillRect(0, 0, w, h);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
drawFront(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const T = (now - start) / 1000;
|
|
||||||
for (const p of parts) {
|
|
||||||
const t = T - p.delay;
|
|
||||||
if (t <= 0 || t > p.life) continue;
|
|
||||||
const e = tau * (1 - Math.exp(-t / tau));
|
|
||||||
const x = p.x0 + p.vx * e + Math.sin(t * 3 + p.rot) * 12 * t;
|
|
||||||
const y = p.y0 + (p.vy + grav * tau) * e - grav * tau * t * 0.55;
|
|
||||||
if (y > h + 20) continue;
|
|
||||||
g.globalAlpha = clamp01((p.life - t) / 0.5);
|
|
||||||
g.fillStyle = p.color;
|
|
||||||
g.save();
|
|
||||||
g.translate(x, y);
|
|
||||||
g.rotate(p.rot + p.vr * t);
|
|
||||||
g.scale(1, Math.cos(t * p.flip));
|
|
||||||
if (p.round) {
|
|
||||||
g.beginPath();
|
|
||||||
g.arc(0, 0, p.size * 0.5, 0, Math.PI * 2);
|
|
||||||
g.fill();
|
|
||||||
} else g.fillRect(-p.size / 2, -p.size * 0.3, p.size, p.size * 0.6);
|
|
||||||
g.restore();
|
|
||||||
}
|
|
||||||
g.globalAlpha = 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function spotlight({ targets, view, start }) {
|
|
||||||
// Darkness is drawn at half resolution: it's all soft gradients anyway.
|
|
||||||
const scale = 0.5;
|
|
||||||
const off = document.createElement('canvas');
|
|
||||||
const o = off.getContext('2d');
|
|
||||||
const spots = targets.map(() => ({ sx: Math.random() * view.w, sy: Math.random() * view.h * 0.6, ph: Math.random() * 6 }));
|
|
||||||
return {
|
|
||||||
drawBack(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const k = clamp01((now - start - 900) / 500);
|
|
||||||
if (!k) return;
|
|
||||||
for (const { ring, color } of targets) {
|
|
||||||
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, R * 3);
|
|
||||||
grad.addColorStop(0, hexToRgba(color, 0.55 * k));
|
|
||||||
grad.addColorStop(1, hexToRgba(color, 0));
|
|
||||||
g.fillStyle = grad;
|
|
||||||
g.fillRect(ring.x - R * 3, ring.y - R * 3, R * 6, R * 6);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
drawFront(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const t = (now - start) / 1000;
|
|
||||||
const ow = Math.ceil(w * scale);
|
|
||||||
const oh = Math.ceil(h * scale);
|
|
||||||
if (off.width !== ow || off.height !== oh) {
|
|
||||||
off.width = ow;
|
|
||||||
off.height = oh;
|
|
||||||
}
|
|
||||||
o.setTransform(scale, 0, 0, scale, 0, 0);
|
|
||||||
o.globalCompositeOperation = 'source-over';
|
|
||||||
o.clearRect(0, 0, w, h);
|
|
||||||
o.fillStyle = `rgba(0,0,0,${0.9 * clamp01(t / 0.25)})`;
|
|
||||||
o.fillRect(0, 0, w, h);
|
|
||||||
o.globalCompositeOperation = 'destination-out';
|
|
||||||
targets.forEach(({ ring }, i) => {
|
|
||||||
const s = spots[i];
|
|
||||||
const m = easeOutCubic(clamp01(t / 1.1));
|
|
||||||
const wob = (1 - m) * 60;
|
|
||||||
const x = s.sx + (ring.x - s.sx) * m + Math.sin(t * 9 + s.ph) * wob;
|
|
||||||
const y = s.sy + (ring.y - s.sy) * m + Math.cos(t * 7 + s.ph) * wob;
|
|
||||||
const r = Math.max(w, h) * 0.22 + (R * 2.3 - Math.max(w, h) * 0.22) * easeInOutCubic(clamp01((t - 0.3) / 0.9));
|
|
||||||
const grad = o.createRadialGradient(x, y, r * 0.7, x, y, r * 1.2);
|
|
||||||
grad.addColorStop(0, 'rgba(0,0,0,1)');
|
|
||||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
||||||
o.fillStyle = grad;
|
|
||||||
o.fillRect(x - r * 1.2, y - r * 1.2, r * 2.4, r * 2.4);
|
|
||||||
});
|
|
||||||
g.drawImage(off, 0, 0, w, h);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shockwave({ targets, view, start }) {
|
|
||||||
const delays = [0, 0.14, 0.3];
|
|
||||||
return {
|
|
||||||
shake(now) {
|
|
||||||
const t = (now - start) / 1000;
|
|
||||||
const amp = 16 * Math.exp(-t * 7);
|
|
||||||
if (amp < 0.3) return null;
|
|
||||||
return [Math.sin(now * 0.09) * amp, Math.cos(now * 0.113) * amp];
|
|
||||||
},
|
|
||||||
drawBack(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const t = (now - start) / 1000;
|
|
||||||
for (const { ring, color } of targets) {
|
|
||||||
g.fillStyle = hexToRgba(color, 0.25 * clamp01((t - 0.2) / 0.6));
|
|
||||||
g.fillRect(-20, -20, w + 40, h + 40);
|
|
||||||
const maxR = farthestCorner(ring.x, ring.y, w, h);
|
|
||||||
for (const d of delays) {
|
|
||||||
const k = (t - d) / 1.0;
|
|
||||||
if (k <= 0 || k >= 1) continue;
|
|
||||||
const r = easeOutCubic(k) * maxR;
|
|
||||||
g.globalAlpha = 1 - k;
|
|
||||||
g.strokeStyle = color;
|
|
||||||
g.lineWidth = R * 0.6 * (1 - k) + 2;
|
|
||||||
g.beginPath();
|
|
||||||
g.arc(ring.x, ring.y, r, 0, Math.PI * 2);
|
|
||||||
g.stroke();
|
|
||||||
g.strokeStyle = '#fff';
|
|
||||||
g.lineWidth = Math.max(1, R * 0.12 * (1 - k));
|
|
||||||
g.stroke();
|
|
||||||
}
|
|
||||||
g.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
drawFront(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const t = (now - start) / 1000;
|
|
||||||
if (t < 0.15) {
|
|
||||||
g.fillStyle = `rgba(255,255,255,${0.55 * (1 - t / 0.15)})`;
|
|
||||||
g.fillRect(-20, -20, w + 40, h + 40);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** prefers-reduced-motion: a slow, gentle colour fade. */
|
|
||||||
function calm({ targets, view, start }) {
|
|
||||||
return {
|
|
||||||
drawBack(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const k = easeInOutCubic(clamp01((now - start) / 1200));
|
|
||||||
for (const { ring, color } of targets) {
|
|
||||||
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h) * 0.9);
|
|
||||||
grad.addColorStop(0, hexToRgba(color, 0.5 * k));
|
|
||||||
grad.addColorStop(1, hexToRgba(color, 0.15 * k));
|
|
||||||
g.fillStyle = grad;
|
|
||||||
g.fillRect(0, 0, w, h);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function teams({ targets, view, start }) {
|
|
||||||
return {
|
|
||||||
drawBack(g, now) {
|
|
||||||
const { w, h, R } = view;
|
|
||||||
const k = easeOutCubic(clamp01((now - start) / 700));
|
|
||||||
for (const { ring, color } of targets) {
|
|
||||||
const r = R * (1.5 + 2.5 * k);
|
|
||||||
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, r);
|
|
||||||
grad.addColorStop(0, hexToRgba(color, 0.5));
|
|
||||||
grad.addColorStop(1, hexToRgba(color, 0));
|
|
||||||
g.fillStyle = grad;
|
|
||||||
g.fillRect(ring.x - r, ring.y - r, r * 2, r * 2);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small expanding ring (joins, countdown beats, order steps). */
|
|
||||||
export function createRipple(x, y, color, start, maxR, dur = 600, width = 4) {
|
|
||||||
return {
|
|
||||||
done: (now) => now - start > dur,
|
|
||||||
draw(g, now) {
|
|
||||||
const k = (now - start) / dur;
|
|
||||||
if (k < 0 || k > 1) return;
|
|
||||||
g.globalAlpha = (1 - k) * 0.8;
|
|
||||||
g.strokeStyle = color;
|
|
||||||
g.lineWidth = width * (1 - k) + 0.5;
|
|
||||||
g.beginPath();
|
|
||||||
g.arc(x, y, maxR * easeOutCubic(k), 0, Math.PI * 2);
|
|
||||||
g.stroke();
|
|
||||||
g.globalAlpha = 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
// 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
42
js/themes.js
|
|
@ -1,42 +0,0 @@
|
||||||
// Visual themes. `colors` are per-finger, `team` per team (A–D).
|
|
||||||
// The high-contrast theme also gives every finger/team its own shape,
|
|
||||||
// so nothing depends on telling colours apart.
|
|
||||||
|
|
||||||
export const SHAPES = ['circle', 'square', 'triangle', 'diamond', 'star', 'hexagon', 'cross', 'pentagon'];
|
|
||||||
|
|
||||||
export const THEMES = {
|
|
||||||
neon: {
|
|
||||||
bg: '#05050b',
|
|
||||||
glow: 1,
|
|
||||||
additive: true,
|
|
||||||
shapes: false,
|
|
||||||
lineScale: 1,
|
|
||||||
spectator: '#4a4a58',
|
|
||||||
colors: ['#ff2e63', '#00e5ff', '#ffe500', '#3dff5a', '#b45cff', '#ff8a1f', '#2f6bff', '#ff4fd8', '#00ffb2', '#c8ff00', '#ff5b3a', '#9fb4ff'],
|
|
||||||
team: ['#ff2e63', '#00e5ff', '#ffe500', '#3dff5a'],
|
|
||||||
},
|
|
||||||
pastel: {
|
|
||||||
bg: '#1c1a26',
|
|
||||||
glow: 0.45,
|
|
||||||
additive: false,
|
|
||||||
shapes: false,
|
|
||||||
lineScale: 1.15,
|
|
||||||
spectator: '#4b4858',
|
|
||||||
colors: ['#ff9aa2', '#9ee3e0', '#ffd6a5', '#b9f2c4', '#cdb4ff', '#ffc6e7', '#a0c4ff', '#fdffb6', '#f7b0be', '#b8e0d2', '#e2c2ff', '#ffcfa3'],
|
|
||||||
team: ['#ff9aa2', '#a0c4ff', '#fdffb6', '#b9f2c4'],
|
|
||||||
},
|
|
||||||
contrast: {
|
|
||||||
bg: '#000000',
|
|
||||||
glow: 0,
|
|
||||||
additive: false,
|
|
||||||
shapes: true,
|
|
||||||
lineScale: 1.3,
|
|
||||||
spectator: '#6b6b6b',
|
|
||||||
colors: ['#ffffff', '#ffd500', '#00d9ff', '#ff4fd8', '#4dff4d', '#ff8c00', '#a98bff', '#ff4545'],
|
|
||||||
team: ['#ffd500', '#00d9ff', '#ff4fd8', '#ffffff'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getTheme(name) {
|
|
||||||
return THEMES[name] || THEMES.neon;
|
|
||||||
}
|
|
||||||
286
js/ui.js
286
js/ui.js
|
|
@ -1,286 +0,0 @@
|
||||||
// DOM layer: overlay messages, toolbar and the settings sheet.
|
|
||||||
|
|
||||||
import { t, setLang, getLang, detectLang, LANGS } from './i18n.js';
|
|
||||||
import { MODES } from './modes.js';
|
|
||||||
import { TIMING } from './game.js';
|
|
||||||
import { pick } from './random.js';
|
|
||||||
import { canVibrate, audioUnlocked } from './audio.js';
|
|
||||||
import { fullscreenSupported, isFullscreen, toggleFullscreen, onFullscreenChange } from './device.js';
|
|
||||||
|
|
||||||
const $ = (sel) => document.querySelector(sel);
|
|
||||||
|
|
||||||
export function stakeText(s) {
|
|
||||||
return s.stakeId ? t(`stakePresets.${s.stakeId}`) : s.stakeText.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createUI({ settings, debug, showDesktopHint }) {
|
|
||||||
const msgMain = $('#msg-main');
|
|
||||||
const msgSub = $('#msg-sub');
|
|
||||||
const toolbar = $('#toolbar');
|
|
||||||
const sheet = $('#sheet');
|
|
||||||
const backdrop = $('#sheet-backdrop');
|
|
||||||
const btnSettings = $('#btn-settings');
|
|
||||||
const btnSound = $('#btn-sound');
|
|
||||||
const btnFull = $('#btn-fullscreen');
|
|
||||||
const stakeInput = $('#stake-input');
|
|
||||||
const hud = $('#debug-hud');
|
|
||||||
|
|
||||||
let chickenMsg = '';
|
|
||||||
let shownMain = null;
|
|
||||||
let shownSub = null;
|
|
||||||
let shownClass = null;
|
|
||||||
let toolbarShown = null;
|
|
||||||
|
|
||||||
// ---- toolbar ----
|
|
||||||
|
|
||||||
btnSettings.addEventListener('click', openSheet);
|
|
||||||
btnSound.addEventListener('click', () => settings.set({ muted: !settings.get().muted }));
|
|
||||||
const standaloneFull = matchMedia('(display-mode: fullscreen)').matches;
|
|
||||||
btnFull.hidden = !fullscreenSupported() || standaloneFull;
|
|
||||||
btnFull.addEventListener('click', toggleFullscreen);
|
|
||||||
onFullscreenChange(syncToolbar);
|
|
||||||
|
|
||||||
// ---- sheet ----
|
|
||||||
|
|
||||||
function openSheet() {
|
|
||||||
sheet.hidden = false;
|
|
||||||
backdrop.hidden = false;
|
|
||||||
requestAnimationFrame(() => document.body.classList.add('sheet-open'));
|
|
||||||
sheet.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSheet() {
|
|
||||||
document.body.classList.remove('sheet-open');
|
|
||||||
stakeInput.blur();
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.body.classList.contains('sheet-open')) {
|
|
||||||
sheet.hidden = true;
|
|
||||||
backdrop.hidden = true;
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
btnSettings.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
backdrop.addEventListener('click', closeSheet);
|
|
||||||
sheet.querySelector('.sheet-close').addEventListener('click', closeSheet);
|
|
||||||
window.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && !sheet.hidden) closeSheet();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Segmented controls: <div class="seg" data-setting="mode"> buttons with data-value.
|
|
||||||
function buildSeg(el, values, labelFn) {
|
|
||||||
el.replaceChildren(
|
|
||||||
...values.map((v) => {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.type = 'button';
|
|
||||||
b.dataset.value = String(v);
|
|
||||||
b.textContent = labelFn(v);
|
|
||||||
return b;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
sheet.addEventListener('click', (e) => {
|
|
||||||
const btn = e.target.closest('button');
|
|
||||||
if (!btn) return;
|
|
||||||
const seg = btn.closest('.seg');
|
|
||||||
if (seg && btn.dataset.value != null) {
|
|
||||||
const key = seg.dataset.setting;
|
|
||||||
let v = btn.dataset.value;
|
|
||||||
if (key === 'teams') v = Number(v);
|
|
||||||
if (key === 'muted') v = v === 'true';
|
|
||||||
if (key === 'haptics') v = v === 'true';
|
|
||||||
if (key === 'lang') setLang(v);
|
|
||||||
settings.set({ [key]: v });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (btn.dataset.step) {
|
|
||||||
const n = Math.max(1, Math.min(9, settings.get().pickN + Number(btn.dataset.step)));
|
|
||||||
settings.set({ pickN: n });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (btn.dataset.preset) {
|
|
||||||
const id = btn.dataset.preset;
|
|
||||||
settings.set(settings.get().stakeId === id ? { stakeId: null, stakeText: '' } : { stakeId: id, stakeText: '' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (btn.id === 'stake-clear') settings.set({ stakeId: null, stakeText: '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
stakeInput.addEventListener('input', () => settings.set({ stakeId: null, stakeText: stakeInput.value.slice(0, 60) }));
|
|
||||||
stakeInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') stakeInput.blur();
|
|
||||||
});
|
|
||||||
|
|
||||||
function applyTranslations() {
|
|
||||||
document.title = t('appTitle');
|
|
||||||
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
|
|
||||||
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
|
|
||||||
stakeInput.placeholder = t('stakesPlaceholder');
|
|
||||||
buildSeg($('#seg-mode'), MODES, (m) => t(`mode.${m}`));
|
|
||||||
buildSeg($('#seg-teams'), [2, 3, 4], String);
|
|
||||||
buildSeg($('#seg-theme'), ['neon', 'pastel', 'contrast'], (v) => t(`theme.${v}`));
|
|
||||||
buildSeg($('#seg-lang'), LANGS, (v) => ({ en: 'English', nl: 'Nederlands' })[v]);
|
|
||||||
buildSeg($('#seg-sound'), [false, true], (v) => t(v ? 'off' : 'on'));
|
|
||||||
buildSeg($('#seg-haptics'), [true, false], (v) => t(v ? 'on' : 'off'));
|
|
||||||
const presets = $('#stake-presets');
|
|
||||||
presets.replaceChildren(
|
|
||||||
...Object.keys(t('stakePresets')).map((id) => {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.type = 'button';
|
|
||||||
b.className = 'chip';
|
|
||||||
b.dataset.preset = id;
|
|
||||||
b.textContent = t(`stakePresets.${id}`);
|
|
||||||
return b;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSheet() {
|
|
||||||
const s = settings.get();
|
|
||||||
const lang = getLang();
|
|
||||||
const sel = { mode: s.mode, teams: String(s.teams), theme: s.theme, lang, muted: String(s.muted), haptics: String(s.haptics) };
|
|
||||||
for (const seg of sheet.querySelectorAll('.seg')) {
|
|
||||||
const cur = sel[seg.dataset.setting];
|
|
||||||
for (const b of seg.children) b.setAttribute('aria-pressed', String(b.dataset.value === cur));
|
|
||||||
}
|
|
||||||
$('#mode-desc').textContent = t(`modeDesc.${s.mode}`);
|
|
||||||
$('#row-pickn').hidden = s.mode !== 'pickN';
|
|
||||||
$('#row-teams').hidden = s.mode !== 'teams';
|
|
||||||
$('#row-haptics').hidden = !canVibrate;
|
|
||||||
$('#pickn-value').textContent = String(s.pickN);
|
|
||||||
for (const b of $('#stake-presets').children) b.setAttribute('aria-pressed', String(b.dataset.preset === s.stakeId));
|
|
||||||
if (document.activeElement !== stakeInput) stakeInput.value = s.stakeId ? '' : s.stakeText;
|
|
||||||
$('#stake-clear').hidden = !s.stakeId && !s.stakeText;
|
|
||||||
syncToolbar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncToolbar() {
|
|
||||||
const s = settings.get();
|
|
||||||
btnSound.classList.toggle('is-muted', s.muted);
|
|
||||||
btnSound.setAttribute('aria-label', t(s.muted ? 'unmute' : 'mute'));
|
|
||||||
btnSound.setAttribute('aria-pressed', String(s.muted));
|
|
||||||
const fs = isFullscreen();
|
|
||||||
btnFull.classList.toggle('is-full', fs);
|
|
||||||
btnFull.setAttribute('aria-label', t(fs ? 'exitFullscreen' : 'fullscreen'));
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.subscribe((s, patch) => {
|
|
||||||
if ('lang' in patch) {
|
|
||||||
setLang(s.lang || detectLang());
|
|
||||||
applyTranslations();
|
|
||||||
}
|
|
||||||
syncSheet();
|
|
||||||
});
|
|
||||||
|
|
||||||
applyTranslations();
|
|
||||||
syncSheet();
|
|
||||||
if (debug && new URLSearchParams(location.search).get('open') === 'settings') openSheet();
|
|
||||||
|
|
||||||
// ---- overlay messages ----
|
|
||||||
|
|
||||||
function modeSummary(s) {
|
|
||||||
let out = t(`mode.${s.mode}`);
|
|
||||||
if (s.mode === 'pickN') out += ` · ${s.pickN}`;
|
|
||||||
if (s.mode === 'teams') out += ` · ${s.teams}`;
|
|
||||||
const stake = stakeText(s);
|
|
||||||
if (stake && s.mode !== 'teams') out += ` · “${stake}”`;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultMessage(game, s) {
|
|
||||||
const stake = stakeText(s);
|
|
||||||
switch (game.plan?.mode) {
|
|
||||||
case 'teams':
|
|
||||||
return t('teamsReady');
|
|
||||||
case 'order':
|
|
||||||
return stake ? `#1: ${t('youStake', { stake })}` : t('orderDone');
|
|
||||||
case 'elimination':
|
|
||||||
return stake ? t('youStake', { stake }) : t('lastStanding');
|
|
||||||
default:
|
|
||||||
return stake ? t('youStake', { stake }) : t('youreIt');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function messages(game, now) {
|
|
||||||
const s = settings.get();
|
|
||||||
const count = game.activeRings().length;
|
|
||||||
switch (game.phase) {
|
|
||||||
case 'idle':
|
|
||||||
if (count === 0) {
|
|
||||||
if (showDesktopHint) return [t('hint'), t('desktopHint'), 'hint'];
|
|
||||||
// Browsers only allow sound after a tap (a finger *lift*), so ask for one.
|
|
||||||
const soundHint = !s.muted && !audioUnlocked() ? `
|
|
||||||
${t('tapForSound')}` : '';
|
|
||||||
return [t('hint'), modeSummary(s) + soundHint, 'hint'];
|
|
||||||
}
|
|
||||||
return [t('needMore'), modeSummary(s), 'hint'];
|
|
||||||
case 'gathering':
|
|
||||||
return [t('holdStill'), '', 'soft'];
|
|
||||||
case 'countdown':
|
|
||||||
return [String(TIMING.beats - Math.max(0, game.beat)), '', 'count'];
|
|
||||||
case 'chicken':
|
|
||||||
return [chickenMsg, '', 'alert'];
|
|
||||||
case 'elimPause': {
|
|
||||||
const out = game.lastOut;
|
|
||||||
const ring = out && game.rings.get(out.id);
|
|
||||||
if (!out) return ['', '', ''];
|
|
||||||
return [t(out.chickened ? 'chickenOut' : 'eliminated'), ring?.down ? t('liftFinger') : '', 'alert'];
|
|
||||||
}
|
|
||||||
case 'result': {
|
|
||||||
const sub = game.canRestart(now) ? t('tapRestart') : '';
|
|
||||||
return [resultMessage(game, s), sub, 'result'];
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return ['', '', ''];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
onEvent(type) {
|
|
||||||
if (type === 'chicken') chickenMsg = pick(t('chicken'));
|
|
||||||
},
|
|
||||||
|
|
||||||
update(game, now) {
|
|
||||||
const [main, sub, cls] = messages(game, now);
|
|
||||||
if (main !== shownMain || cls !== shownClass) {
|
|
||||||
msgMain.textContent = main;
|
|
||||||
msgMain.className = `msg ${cls}`;
|
|
||||||
// restart the entrance animation
|
|
||||||
void msgMain.offsetWidth;
|
|
||||||
msgMain.classList.add('in');
|
|
||||||
shownMain = main;
|
|
||||||
shownClass = cls;
|
|
||||||
}
|
|
||||||
if (sub !== shownSub) {
|
|
||||||
msgSub.textContent = sub;
|
|
||||||
shownSub = sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showTools = (game.phase === 'idle' && !game.anyDown()) || (game.phase === 'result' && !game.anyDown());
|
|
||||||
if (showTools !== toolbarShown) {
|
|
||||||
toolbar.classList.toggle('hidden', !showTools);
|
|
||||||
toolbar.inert = !showTools;
|
|
||||||
toolbarShown = showTools;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
const fps = this._fps(now);
|
|
||||||
hud.textContent = `${game.phase} · ${game.activeRings().length} fingers · ${fps} fps`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_last: 0,
|
|
||||||
_frames: 0,
|
|
||||||
_fpsVal: 0,
|
|
||||||
_fps(now) {
|
|
||||||
this._frames++;
|
|
||||||
if (now - this._last > 500) {
|
|
||||||
this._fpsVal = Math.round((this._frames * 1000) / (now - this._last));
|
|
||||||
this._frames = 0;
|
|
||||||
this._last = now;
|
|
||||||
}
|
|
||||||
return this._fpsVal;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"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
45
nginx.conf
|
|
@ -1,45 +0,0 @@
|
||||||
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
83
sw.js
|
|
@ -1,83 +0,0 @@
|
||||||
// Service worker: precache the app shell, serve network-first with a
|
|
||||||
// cache fallback so updates show up immediately when online and the app
|
|
||||||
// still works fully offline. Bump VERSION when the file list changes.
|
|
||||||
|
|
||||||
const VERSION = 'picker-v2';
|
|
||||||
const SHELL = [
|
|
||||||
'./',
|
|
||||||
'./index.html',
|
|
||||||
'./manifest.webmanifest',
|
|
||||||
'./css/app.css',
|
|
||||||
'./js/main.js',
|
|
||||||
'./js/game.js',
|
|
||||||
'./js/modes.js',
|
|
||||||
'./js/random.js',
|
|
||||||
'./js/renderer.js',
|
|
||||||
'./js/reveals.js',
|
|
||||||
'./js/input.js',
|
|
||||||
'./js/audio.js',
|
|
||||||
'./js/device.js',
|
|
||||||
'./js/settings.js',
|
|
||||||
'./js/i18n.js',
|
|
||||||
'./js/themes.js',
|
|
||||||
'./js/ui.js',
|
|
||||||
'./icons/icon.svg',
|
|
||||||
'./icons/icon-192.png',
|
|
||||||
'./icons/icon-512.png',
|
|
||||||
'./icons/icon-maskable-512.png',
|
|
||||||
'./icons/apple-touch-icon.png',
|
|
||||||
];
|
|
||||||
const NETWORK_TIMEOUT_MS = 2500;
|
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches
|
|
||||||
.open(VERSION)
|
|
||||||
.then((cache) => cache.addAll(SHELL.map((u) => new Request(u, { cache: 'reload' }))))
|
|
||||||
.then(() => self.skipWaiting()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches
|
|
||||||
.keys()
|
|
||||||
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
|
|
||||||
.then(() => self.clients.claim()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const req = event.request;
|
|
||||||
if (req.method !== 'GET' || new URL(req.url).origin !== self.location.origin) return;
|
|
||||||
event.respondWith(networkFirst(req, event));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function networkFirst(req, event) {
|
|
||||||
const cache = await caches.open(VERSION);
|
|
||||||
try {
|
|
||||||
const res = await withTimeout(fetch(req), NETWORK_TIMEOUT_MS);
|
|
||||||
if (res.ok) event.waitUntil(cache.put(req, res.clone()));
|
|
||||||
return res;
|
|
||||||
} catch {
|
|
||||||
// ignoreSearch so ?debug=1 etc. still resolve offline
|
|
||||||
const hit = (await cache.match(req, { ignoreSearch: true })) || (req.mode === 'navigate' && (await cache.match('./index.html')));
|
|
||||||
return hit || Response.error();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout(promise, ms) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const t = setTimeout(() => reject(new Error('timeout')), ms);
|
|
||||||
promise.then(
|
|
||||||
(v) => {
|
|
||||||
clearTimeout(t);
|
|
||||||
resolve(v);
|
|
||||||
},
|
|
||||||
(e) => {
|
|
||||||
clearTimeout(t);
|
|
||||||
reject(e);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
334
tests/logic.html
334
tests/logic.html
|
|
@ -1,334 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Picker logic tests</title>
|
|
||||||
<style>body{font:14px monospace;background:#111;color:#ddd;padding:1rem}.pass{color:#6f6}.fail{color:#f66}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Picker logic tests</h1>
|
|
||||||
<pre id="out">running…</pre>
|
|
||||||
<script type="module">
|
|
||||||
// Drives the state machine with simulated time. Open in a browser
|
|
||||||
// (served over http) or headless: results are written into #out.
|
|
||||||
import { Game, TIMING } from '../js/game.js';
|
|
||||||
import { planRound, makeHighlightSchedule } from '../js/modes.js';
|
|
||||||
import { randomInt } from '../js/random.js';
|
|
||||||
import { createInput } from '../js/input.js';
|
|
||||||
import { createReveal } from '../js/reveals.js';
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
function test(name, fn) {
|
|
||||||
try {
|
|
||||||
fn();
|
|
||||||
results.push(['pass', name]);
|
|
||||||
} catch (e) {
|
|
||||||
results.push(['fail', `${name}: ${e.message}`]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function assert(cond, msg) {
|
|
||||||
if (!cond) throw new Error(msg || 'assertion failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setup(settings = {}) {
|
|
||||||
const events = [];
|
|
||||||
const s = { mode: 'one', pickN: 2, teams: 2, ...settings };
|
|
||||||
const game = new Game({ emit: (type, data) => events.push({ type, data }), getSettings: () => s, isReduced: () => false });
|
|
||||||
let now = performance.now() + 10;
|
|
||||||
const h = {
|
|
||||||
game,
|
|
||||||
events,
|
|
||||||
get now() { return now; },
|
|
||||||
down(id, x = 100 + Math.random() * 200, y = 100 + Math.random() * 300) { game.fingerDown(id, x, y, now); },
|
|
||||||
up(id) { game.fingerUp(id, now); },
|
|
||||||
run(ms) {
|
|
||||||
const end = now + ms;
|
|
||||||
while (now < end) {
|
|
||||||
now = Math.min(end, now + 16);
|
|
||||||
game.update(now);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
until(phase, max = 30000) {
|
|
||||||
const end = now + max;
|
|
||||||
while (game.phase !== phase && now < end) {
|
|
||||||
now += 16;
|
|
||||||
game.update(now);
|
|
||||||
}
|
|
||||||
assert(game.phase === phase, `expected phase ${phase}, got ${game.phase}`);
|
|
||||||
},
|
|
||||||
count: (type) => events.filter((e) => e.type === type).length,
|
|
||||||
};
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('pick one: full flow reaches a result with one valid winner', () => {
|
|
||||||
const h = setup();
|
|
||||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
|
||||||
h.run(100);
|
|
||||||
assert(h.game.phase === 'gathering', 'gathering');
|
|
||||||
h.run(TIMING.stableMs);
|
|
||||||
assert(h.game.phase === 'countdown', 'countdown after stable period, got ' + h.game.phase);
|
|
||||||
h.until('result');
|
|
||||||
assert(h.count('beat') === TIMING.beats, 'beats');
|
|
||||||
assert(h.count('tick') > 8, 'ticks');
|
|
||||||
assert(h.game.winners.length === 1 && ['a', 'b', 'c'].includes(h.game.winners[0]), 'winner');
|
|
||||||
const reveal = h.events.find((e) => e.type === 'reveal');
|
|
||||||
assert(['flood', 'confetti', 'spotlight', 'shockwave'].includes(reveal.data.style), 'style ' + reveal.data.style);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('outcome is decided before the animation and the animation lands on it', () => {
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
const h = setup();
|
|
||||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
|
||||||
h.until('suspense');
|
|
||||||
const planned = h.game.plan.winners[0];
|
|
||||||
const last = h.game.schedule[h.game.schedule.length - 1];
|
|
||||||
assert(last.ids[0] === planned, 'schedule ends on planned winner');
|
|
||||||
assert(h.game.schedule[h.game.schedule.length - 2].ids[0] !== planned, 'penultimate step differs');
|
|
||||||
h.until('result');
|
|
||||||
assert(h.game.winners[0] === planned, 'revealed winner = planned');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('one finger is not enough', () => {
|
|
||||||
const h = setup();
|
|
||||||
h.down('a');
|
|
||||||
h.run(6000);
|
|
||||||
assert(h.game.phase === 'idle', h.game.phase);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('joining during the countdown resets to gathering without chicken', () => {
|
|
||||||
const h = setup();
|
|
||||||
h.down('a');
|
|
||||||
h.down('b');
|
|
||||||
h.until('countdown');
|
|
||||||
h.down('c');
|
|
||||||
assert(h.game.phase === 'gathering', h.game.phase);
|
|
||||||
assert(h.count('chicken') === 0, 'no chicken');
|
|
||||||
h.run(TIMING.stableMs - 200);
|
|
||||||
assert(h.game.phase === 'gathering', 'stable timer restarted');
|
|
||||||
h.until('result');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lifting during the countdown triggers chicken, then resumes', () => {
|
|
||||||
const h = setup();
|
|
||||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
|
||||||
h.until('countdown');
|
|
||||||
h.up('b');
|
|
||||||
assert(h.game.phase === 'chicken', h.game.phase);
|
|
||||||
assert(h.count('chicken') === 1, 'chicken event');
|
|
||||||
h.run(TIMING.chickenMs + 50);
|
|
||||||
assert(h.game.phase === 'gathering', 'back to gathering, got ' + h.game.phase);
|
|
||||||
h.until('result');
|
|
||||||
assert(['a', 'c'].includes(h.game.winners[0]), 'winner among remaining');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lifting during suspense aborts with chicken', () => {
|
|
||||||
const h = setup();
|
|
||||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
|
||||||
h.until('suspense');
|
|
||||||
h.up('a');
|
|
||||||
assert(h.game.phase === 'chicken' && h.game.plan === null, h.game.phase);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('late joiners during suspense are spectators', () => {
|
|
||||||
const h = setup();
|
|
||||||
['a', 'b'].forEach((id) => h.down(id));
|
|
||||||
h.until('suspense');
|
|
||||||
h.down('late');
|
|
||||||
assert(h.game.rings.get('late').late, 'late flag');
|
|
||||||
h.until('result');
|
|
||||||
assert(h.game.winners[0] !== 'late', 'spectator never wins');
|
|
||||||
assert(!h.game.players.includes('late'), 'not a player');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('result persists after lifting; restart only after grace and all fingers up', () => {
|
|
||||||
const h = setup();
|
|
||||||
['a', 'b'].forEach((id) => h.down(id));
|
|
||||||
h.until('result');
|
|
||||||
h.up('a');
|
|
||||||
h.run(3000);
|
|
||||||
assert(h.game.phase === 'result', 'still result while b holds');
|
|
||||||
h.down('x');
|
|
||||||
assert(h.game.phase === 'result' && h.game.rings.get('x').late, 'tap while someone holds is ignored');
|
|
||||||
h.up('x');
|
|
||||||
h.up('b');
|
|
||||||
assert(h.game.phase === 'result', 'stays after all lift');
|
|
||||||
assert(h.game.canRestart(h.now), 'can restart');
|
|
||||||
h.down('n1');
|
|
||||||
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'restarted');
|
|
||||||
assert(h.game.rings.size === 1 && !h.game.rings.get('n1').late, 'tapping finger joins the next round');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no instant restart right after the reveal', () => {
|
|
||||||
const h = setup();
|
|
||||||
['a', 'b'].forEach((id) => h.down(id));
|
|
||||||
h.until('result');
|
|
||||||
h.up('a');
|
|
||||||
h.up('b');
|
|
||||||
h.down('a2');
|
|
||||||
assert(h.game.phase === 'result', 'grace period protects the result');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pick N clamps to fingers - 1 and picks distinct winners', () => {
|
|
||||||
const h = setup({ mode: 'pickN', pickN: 5 });
|
|
||||||
['a', 'b', 'c'].forEach((id) => h.down(id));
|
|
||||||
h.until('result');
|
|
||||||
assert(h.game.winners.length === 2, 'clamped to 2, got ' + h.game.winners.length);
|
|
||||||
assert(new Set(h.game.winners).size === 2, 'distinct');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('teams are balanced', () => {
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
const plan = planRound('teams', ['a', 'b', 'c', 'd', 'e', 'f', 'g'], { teams: 3 });
|
|
||||||
const sizes = [0, 0, 0];
|
|
||||||
for (const t of plan.teams.values()) sizes[t]++;
|
|
||||||
assert(Math.max(...sizes) - Math.min(...sizes) <= 1, 'sizes ' + sizes);
|
|
||||||
}
|
|
||||||
const small = planRound('teams', ['a', 'b'], { teams: 4 });
|
|
||||||
assert(small.teamCount === 2, 'team count capped by fingers');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('teams mode reaches result', () => {
|
|
||||||
const h = setup({ mode: 'teams', teams: 2 });
|
|
||||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
|
||||||
h.until('result');
|
|
||||||
assert(h.game.revealStyle === 'teams', 'teams style');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('order assigns 1..N exactly once, #1 first', () => {
|
|
||||||
const h = setup({ mode: 'order' });
|
|
||||||
['a', 'b', 'c', 'd', 'e'].forEach((id) => h.down(id));
|
|
||||||
h.until('suspense');
|
|
||||||
const first = h.game.plan.order[0];
|
|
||||||
h.until('result');
|
|
||||||
const nums = [...h.game.orderNums.values()].map((o) => o.n).sort();
|
|
||||||
assert(nums.join() === '1,2,3,4,5', nums.join());
|
|
||||||
assert(h.game.orderNums.get(first).n === 1, 'planned #1');
|
|
||||||
assert(h.count('orderStep') === 5, 'steps');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('elimination knocks out N-1 and the planned survivor remains', () => {
|
|
||||||
const h = setup({ mode: 'elimination' });
|
|
||||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
|
||||||
h.until('suspense');
|
|
||||||
const order = h.game.plan.eliminationOrder.slice();
|
|
||||||
// Knocked-out players lift their finger, as asked.
|
|
||||||
h.game.emit = ((orig) => (type, data) => { orig(type, data); if (type === 'eliminate') h.game.fingerUp(data.id, h.now); })(h.game.emit);
|
|
||||||
h.until('result');
|
|
||||||
const outs = h.events.filter((e) => e.type === 'eliminate').map((e) => e.data.id);
|
|
||||||
assert(outs.join() === order.slice(0, 3).join(), `outs ${outs} vs ${order}`);
|
|
||||||
assert(h.game.winners[0] === order[3], 'survivor');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('elimination: a remaining player who lets go is out', () => {
|
|
||||||
const h = setup({ mode: 'elimination' });
|
|
||||||
['a', 'b', 'c', 'd'].forEach((id) => h.down(id));
|
|
||||||
h.until('suspense');
|
|
||||||
const survivor = h.game.plan.eliminationOrder[3];
|
|
||||||
const coward = h.game.plan.eliminationOrder[2];
|
|
||||||
h.up(coward);
|
|
||||||
assert(h.game.phase === 'elimPause', h.game.phase);
|
|
||||||
assert(h.events.find((e) => e.type === 'eliminate').data.chickened, 'chickened');
|
|
||||||
h.until('result');
|
|
||||||
assert(h.game.winners[0] === survivor, 'survivor still wins');
|
|
||||||
assert(h.count('eliminate') === 3, 'three outs');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two players: highlight alternates and lands on the planned one', () => {
|
|
||||||
const rings = [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 100, y: 0 }];
|
|
||||||
for (const target of ['a', 'b']) {
|
|
||||||
const s = makeHighlightSchedule(rings, [target], 2700);
|
|
||||||
assert(s[s.length - 1].ids[0] === target, 'lands');
|
|
||||||
for (let i = 1; i < s.length; i++) assert(s[i].ids[0] !== s[i - 1].ids[0], 'alternates');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('randomInt is uniform (chi-square, 5 buckets)', () => {
|
|
||||||
const N = 50000;
|
|
||||||
const k = 5;
|
|
||||||
const c = new Array(k).fill(0);
|
|
||||||
for (let i = 0; i < N; i++) c[randomInt(k)]++;
|
|
||||||
const e = N / k;
|
|
||||||
const chi = c.reduce((s, x) => s + ((x - e) ** 2) / e, 0);
|
|
||||||
assert(chi < 18.47, `chi² ${chi.toFixed(2)} (p<0.001 threshold 18.47) counts ${c}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pick one is fair across fingers', () => {
|
|
||||||
const ids = ['a', 'b', 'c'];
|
|
||||||
const c = { a: 0, b: 0, c: 0 };
|
|
||||||
for (let i = 0; i < 30000; i++) c[planRound('one', ids, {}).winners[0]]++;
|
|
||||||
for (const id of ids) assert(Math.abs(c[id] / 30000 - 1 / 3) < 0.015, JSON.stringify(c));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown pointer ids and double lifts are harmless', () => {
|
|
||||||
const h = setup();
|
|
||||||
h.up('nope');
|
|
||||||
h.down('a');
|
|
||||||
h.up('a');
|
|
||||||
h.up('a');
|
|
||||||
h.run(500);
|
|
||||||
assert(h.game.phase === 'idle', h.game.phase);
|
|
||||||
assert(h.game.rings.size === 0, 'pruned');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recycled pointerIds still restart after a round (input -> game)', () => {
|
|
||||||
const h = setup();
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
document.body.append(canvas);
|
|
||||||
createInput(canvas, {
|
|
||||||
debug: false,
|
|
||||||
onDown: (id, x, y) => h.game.fingerDown(id, x, y, h.now),
|
|
||||||
onMove: (id, x, y) => h.game.fingerMove(id, x, y),
|
|
||||||
onUp: (id) => h.game.fingerUp(id, h.now),
|
|
||||||
});
|
|
||||||
const fire = (type, pointerId, x = 50, y = 50) =>
|
|
||||||
canvas.dispatchEvent(new PointerEvent(type, { pointerId, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, cancelable: true }));
|
|
||||||
fire('pointerdown', 1, 50, 50);
|
|
||||||
fire('pointerdown', 2, 250, 250);
|
|
||||||
h.until('result');
|
|
||||||
fire('pointerup', 1);
|
|
||||||
fire('pointerup', 2);
|
|
||||||
h.run(TIMING.restartGraceMs + 100);
|
|
||||||
assert(h.game.canRestart(h.now), 'can restart');
|
|
||||||
fire('pointerdown', 1); // the browser reuses pointerId 1 for the tap
|
|
||||||
assert(h.game.phase === 'idle' && h.count('restart') === 1, 'tap with a recycled pointerId restarts, phase ' + h.game.phase);
|
|
||||||
fire('pointerup', 1);
|
|
||||||
h.run(500);
|
|
||||||
assert(h.game.rings.size === 0, 'tap ring removed');
|
|
||||||
canvas.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reveal follows a resize (entering fullscreen) without leaving an unpainted strip', () => {
|
|
||||||
const c = document.createElement('canvas');
|
|
||||||
c.width = 100;
|
|
||||||
c.height = 200;
|
|
||||||
const g = c.getContext('2d');
|
|
||||||
const view = { w: 100, h: 100, R: 10 };
|
|
||||||
const ring = { x: 50, y: 50 };
|
|
||||||
const fx = createReveal('flood', { targets: [{ ring, color: '#ff0000' }], view, start: 0, palette: ['#ff0000'] });
|
|
||||||
view.h = 200; // screen grew after the reveal started
|
|
||||||
fx.drawBack(g, 5000);
|
|
||||||
const px = g.getImageData(50, 190, 1, 1).data;
|
|
||||||
assert(px[0] > 100 && px[3] === 255, 'bottom painted, got ' + Array.from(px));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Service worker: parses, and every precached file exists.
|
|
||||||
try {
|
|
||||||
const src = await (await fetch('../sw.js')).text();
|
|
||||||
new Function(src); // syntax check only, not executed
|
|
||||||
const shell = [...src.matchAll(/'(\.\/[^']*)'/g)].map((m) => m[1]);
|
|
||||||
const missing = [];
|
|
||||||
for (const u of shell) if (!(await fetch(new URL('../' + u, location.href), { cache: 'no-store' })).ok) missing.push(u);
|
|
||||||
results.push(missing.length ? ['fail', 'sw.js precache missing: ' + missing] : ['pass', `sw.js parses; all ${shell.length} precached files exist`]);
|
|
||||||
} catch (e) {
|
|
||||||
results.push(['fail', 'sw.js: ' + e.message]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = document.getElementById('out');
|
|
||||||
const failed = results.filter((r) => r[0] === 'fail').length;
|
|
||||||
out.innerHTML = results.map(([cls, msg]) => `<span class="${cls}">${cls.toUpperCase()}</span> ${msg}`).join('\n') + `\n\n${results.length - failed}/${results.length} passed`;
|
|
||||||
document.title = failed ? `FAIL ${failed}` : 'ALL PASS';
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"""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