forked from eros/curltastic
Compare commits
No commits in common. "pm-board-spec" and "main" have entirely different histories.
pm-board-s
...
main
@ -1,22 +0,0 @@
|
|||||||
# Allow only the files required by the two builds. No credentials or host dependencies.
|
|
||||||
**
|
|
||||||
!Dockerfile
|
|
||||||
!docker/
|
|
||||||
!docker/nginx.conf
|
|
||||||
!frontend/
|
|
||||||
!frontend/package.json
|
|
||||||
!frontend/package-lock.json
|
|
||||||
!frontend/tsconfig.json
|
|
||||||
!frontend/index.html
|
|
||||||
!frontend/src/
|
|
||||||
!frontend/src/**
|
|
||||||
!frontend/public/
|
|
||||||
!frontend/public/**
|
|
||||||
!backend/
|
|
||||||
!backend/Cargo.toml
|
|
||||||
!backend/Cargo.lock
|
|
||||||
!backend/src/
|
|
||||||
!backend/src/**
|
|
||||||
**/.env*
|
|
||||||
**/*.pem
|
|
||||||
**/*.key
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,4 +11,3 @@ e2e/node_modules/
|
|||||||
# Misc
|
# Misc
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.omo/
|
|
||||||
|
|||||||
122
.pm/board.yaml
122
.pm/board.yaml
@ -1,122 +0,0 @@
|
|||||||
requirements:
|
|
||||||
- id: R1
|
|
||||||
title: Mobile 2D overhead curling
|
|
||||||
body: Players play curling in a mobile browser from a 2D overhead view.
|
|
||||||
- id: R2
|
|
||||||
title: Portrait sheet
|
|
||||||
body: The sheet is shown in portrait.
|
|
||||||
- id: R3
|
|
||||||
title: Two teams, unlimited clients
|
|
||||||
body: A room has two teams and an unlimited number of clients.
|
|
||||||
- id: R4
|
|
||||||
title: Team pick per device
|
|
||||||
body: Each client picks Team 1 or Team 2 on that device.
|
|
||||||
- id: R5
|
|
||||||
title: Anyone on turn may throw
|
|
||||||
body: Anyone on the team whose turn it is may throw.
|
|
||||||
- id: R6
|
|
||||||
title: Shareable room link
|
|
||||||
body: Players join a room from a shareable link.
|
|
||||||
- id: R7
|
|
||||||
title: Create room from empty link
|
|
||||||
body: If the link has no room, the client creates one and writes that room into the URL.
|
|
||||||
- id: R8
|
|
||||||
title: Ten ends, eight stones
|
|
||||||
body: A match has 10 ends. Each team has 8 stones per end.
|
|
||||||
- id: R9
|
|
||||||
title: Extra ends until a lead
|
|
||||||
body: Extra ends are played while the score is tied after 10 ends. The match ends when one team leads after an end.
|
|
||||||
- id: R10
|
|
||||||
title: Random hammer
|
|
||||||
body: Hammer is random at the start. The team without hammer throws first in the end.
|
|
||||||
- id: R11
|
|
||||||
title: Hammer after score or blank
|
|
||||||
body: The team that scores gives hammer away. A blank end keeps hammer.
|
|
||||||
- id: R12
|
|
||||||
title: Alternate throws
|
|
||||||
body: After a throw, that team has one fewer stone and the other team throws.
|
|
||||||
- id: R13
|
|
||||||
title: Score when stones are gone
|
|
||||||
body: When all throws have completed, the end is scored.
|
|
||||||
- id: R14
|
|
||||||
title: House only scores
|
|
||||||
body: Only stones touching the house can score.
|
|
||||||
- id: R15
|
|
||||||
title: Count to opponent nearest
|
|
||||||
body: The team closest to the tee scores one point for each of its stones nearer than the opponent's nearest stone.
|
|
||||||
- id: R16
|
|
||||||
title: Blank end is 0-0
|
|
||||||
body: If no stone can score, the end is 0-0.
|
|
||||||
- id: R17
|
|
||||||
title: Shared live state
|
|
||||||
body: Every client sees the current end, both scores, hammer, whose turn it is, stones remaining, stone positions, trajectories from all shots, and whether the match is over.
|
|
||||||
- id: R18
|
|
||||||
title: Winner on match end
|
|
||||||
body: When the match ends, both scores are shown with the winning team.
|
|
||||||
- id: R19
|
|
||||||
title: Hack to broom
|
|
||||||
body: A thrown stone starts at the hack and travels toward the broom. The player chooses initial speed and curl.
|
|
||||||
- id: R20
|
|
||||||
title: Clockwise is thrower's right
|
|
||||||
body: Clockwise curl moves the stone to the thrower's right.
|
|
||||||
- id: R21
|
|
||||||
title: Stone bounce, no line bounce
|
|
||||||
body: Stones bounce off other stones. Stones do not bounce back from a sideline or the backline.
|
|
||||||
- id: R22
|
|
||||||
title: Out of play rules
|
|
||||||
body: A stone is out if it touches a sideline. A stone must fully cross the backline to be out. A stone is out if it never fully crosses the hog line.
|
|
||||||
- id: R23
|
|
||||||
title: Sheet markings
|
|
||||||
body: The ice is a curling sheet with a house, hog line, sidelines, and a backline on the 12-foot ring.
|
|
||||||
- id: R24
|
|
||||||
title: Default house framing
|
|
||||||
body: The default view frames the house, sidelines, and backline above the controls.
|
|
||||||
- id: R25
|
|
||||||
title: Pan to hog
|
|
||||||
body: The player can pan up and down the sheet to the hog line.
|
|
||||||
- id: R26
|
|
||||||
title: Broom anywhere then throw
|
|
||||||
body: On turn, the player drags to place the broom anywhere on the sheet, then throws.
|
|
||||||
- id: R27
|
|
||||||
title: Discrete throw weights
|
|
||||||
body: The player sets throw weight on a discrete scale of draw weights 1 through 10, then named takeouts hack, board, control, normal, and peel.
|
|
||||||
- id: R28
|
|
||||||
title: Curl direction
|
|
||||||
body: The player sets curl clockwise or counterclockwise. Clockwise is the default.
|
|
||||||
- id: R29
|
|
||||||
title: Throw only on turn while still
|
|
||||||
body: Throw controls work only on that team's turn and only while stones are not moving.
|
|
||||||
- id: R30
|
|
||||||
title: Stones, hammer, scoreboard HUD
|
|
||||||
body: The HUD shows eight remaining-stone markers per team, a hammer marker, and a scoreboard with each end and a total.
|
|
||||||
- id: R31
|
|
||||||
title: End overlay
|
|
||||||
body: After the stones stop and the end is scored, an end-result overlay appears. The player dismisses it.
|
|
||||||
- id: R32
|
|
||||||
title: Shared motion, hide out stones
|
|
||||||
body: All clients see the same stone motion. A stone that leaves play is hidden when it leaves the sheet.
|
|
||||||
- id: R33
|
|
||||||
title: Copyable errors
|
|
||||||
body: Errors and the final result appear on the page as copyable text, not as a blocking alert.
|
|
||||||
- id: R34
|
|
||||||
title: No pinch-zoom, remember team
|
|
||||||
body: Pinch-zoom is off. The client remembers the last team choice.
|
|
||||||
choices:
|
|
||||||
- id: C1
|
|
||||||
title: Stack
|
|
||||||
body: Backend is Rust, Axum, WebSocket, Rapier2D, server-authoritative at 120 Hz. Frontend is TypeScript, Vite, Canvas2D, portrait-first touch.
|
|
||||||
- id: C2
|
|
||||||
title: Team colours
|
|
||||||
body: Team 1 is red. Team 2 is yellow.
|
|
||||||
- id: C3
|
|
||||||
title: Wire protocol
|
|
||||||
body: WebSocket JSON with snake_case tags. game_state carries totals, hammer, turn_team, per-end scoreboard, stones_remaining, and stones. Trajectories sample at 40 Hz from the 120 Hz sim. No room-full limit. No end_scored message.
|
|
||||||
- id: C4
|
|
||||||
title: Physics how
|
|
||||||
body: Ice friction is a server µ(v) table shared by linear and angular damping. Curl uses initial |ω| = 5 rot / 14 s with clockwise to the thrower's right. Stone-stone restitution is 0.9. Sidelines and backline are not colliders; out-of-play is applied after sim. Stone ids are { team, n } with n 1 through 8. Lengths use FEET_TO_METERS = 0.3048.
|
|
||||||
- id: C5
|
|
||||||
title: Weight mapping
|
|
||||||
body: Discrete UI weights map to calibrated open-ice stop speeds in SPEED_TABLE. The server takes velocity and curl, not a local friction scalar.
|
|
||||||
- id: C6
|
|
||||||
title: Out of scope
|
|
||||||
body: No accounts, persistence, anti-cheat, replay log, turn timer, or sweeping.
|
|
||||||
@ -1 +0,0 @@
|
|||||||
dirty: board.yaml
|
|
||||||
38
Dockerfile
38
Dockerfile
@ -1,38 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
FROM node:22-bookworm-slim AS frontend-build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY frontend/ ./
|
|
||||||
ARG VITE_BASE_PATH=/curltastic/
|
|
||||||
RUN npm test -- --pool=forks --poolOptions.forks.singleFork \
|
|
||||||
&& npm run build -- --base="${VITE_BASE_PATH}"
|
|
||||||
|
|
||||||
FROM rust:1.96-bookworm AS backend-build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY backend/Cargo.toml backend/Cargo.lock ./
|
|
||||||
COPY backend/src/ ./src/
|
|
||||||
RUN cargo build --locked --release
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS backend
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& groupadd --gid 10001 curltastic \
|
|
||||||
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin curltastic
|
|
||||||
COPY --from=backend-build /app/target/release/curltastic-backend /usr/local/bin/curltastic-backend
|
|
||||||
USER 10001:10001
|
|
||||||
EXPOSE 3000
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD curl --fail --silent --show-error http://127.0.0.1:3000/ || exit 1
|
|
||||||
ENTRYPOINT ["/usr/local/bin/curltastic-backend"]
|
|
||||||
|
|
||||||
FROM nginx:1.28-alpine AS frontend
|
|
||||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
|
||||||
COPY --from=frontend-build /app/dist/ /usr/share/nginx/html/
|
|
||||||
USER 101:101
|
|
||||||
EXPOSE 8080
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
|
|
||||||
ENTRYPOINT ["nginx"]
|
|
||||||
CMD ["-g", "daemon off;"]
|
|
||||||
17
README.md
17
README.md
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow.
|
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow.
|
||||||
|
|
||||||
Product must-haves and decided hows live in `.pm/board.yaml`.
|
|
||||||
|
|
||||||
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
||||||
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
||||||
|
|
||||||
@ -34,15 +32,15 @@ Product must-haves and decided hows live in `.pm/board.yaml`.
|
|||||||
- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house).
|
- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house).
|
||||||
- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line).
|
- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line).
|
||||||
- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2).
|
- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2).
|
||||||
- Weight slider: draws 1–10 then takeouts hack, board, control, normal, peel. Curl buttons.
|
- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.5–1.5** (local; multiplies ice µ(v) table).
|
||||||
- Tap **THROW**. Anyone joined as the current turn team may throw.
|
- Tap **THROW**. Anyone joined as the current turn team may throw.
|
||||||
- Parallel multi-stone trajectories; rotation θ comes from physics.
|
- Parallel multi-stone trajectories; rotation θ comes from physics.
|
||||||
- Skeuomorphic 2×8 stones-left HUD; end-of-end overlay with scoreboard (player dismisses).
|
- Skeuomorphic 2×8 stones-left HUD; end-of-end modal with scoreboard (auto ~5s + manual dismiss).
|
||||||
|
|
||||||
### Physics (high level)
|
### Physics (high level)
|
||||||
|
|
||||||
- Discrete weight labels map to calibrated release speeds (m/s) on the server.
|
- Pure initial **velocity** (m/s), not discrete weight.
|
||||||
- Ice friction µ(v) table (interpolated) on the server; linear and angular damping share the table.
|
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table.
|
||||||
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
|
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
|
||||||
- Stone–stone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking.
|
- Stone–stone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking.
|
||||||
- Back line and sidelines are **not** colliders — touch → out of play after sim.
|
- Back line and sidelines are **not** colliders — touch → out of play after sim.
|
||||||
@ -74,8 +72,13 @@ node load_test.cjs # optional concurrent rooms
|
|||||||
|
|
||||||
Unit tests: `cd backend && cargo test`, `cd frontend && npm test`.
|
Unit tests: `cd backend && cargo test`, `cd frontend && npm test`.
|
||||||
|
|
||||||
|
### Branches (this work)
|
||||||
|
|
||||||
|
- **`jasonlooked`** — PR-A refactor: multi-client, multi-path animation, free broom, camera, FEET_TO_METERS.
|
||||||
|
- **`pr2-features`** — PR-B features: physics rewrite, team1/2 wire, scoreboard, HUD, modal (branched from PR-A tip).
|
||||||
|
|
||||||
### Limitations
|
### Limitations
|
||||||
|
|
||||||
- No accounts, persistence, anti-cheat, replay log, or turn timer.
|
- No accounts, persistence, anti-cheat, replay log, or turn timer.
|
||||||
- Sweeping not implemented.
|
- Sweeping not implemented; friction/curl not shared-room state.
|
||||||
- Stones past back/sideline/hog rules are removed from play after simulation.
|
- Stones past back/sideline/hog rules are removed from play after simulation.
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
# Production images
|
|
||||||
|
|
||||||
Build from the repository root (no source mounts or runtime package installs):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker build --target frontend -t curltastic-frontend .
|
|
||||||
docker build --target backend -t curltastic-backend .
|
|
||||||
```
|
|
||||||
|
|
||||||
| Target | Listener | Runtime user | Healthcheck |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `frontend` (default final target) | HTTP `8080` | `101:101` | `GET /healthz` |
|
|
||||||
| `backend` | HTTP/WebSocket `3000` | `10001:10001` | `GET /` |
|
|
||||||
|
|
||||||
The frontend build argument `VITE_BASE_PATH` defaults to `/curltastic/` and is
|
|
||||||
passed to `vite build --base`. There are no required runtime environment
|
|
||||||
variables, credentials, writable application directories, or persistent volumes.
|
|
||||||
The Rust server keeps rooms in memory; rebuilding/restarting it loses games.
|
|
||||||
|
|
||||||
## Compose integration contract
|
|
||||||
|
|
||||||
- Publish/route only frontend port **8080**. Explicitly set Traefik's load-balancer
|
|
||||||
port to 8080; do not infer it from the upstream nginx image metadata.
|
|
||||||
- Traefik routes `/curltastic/` and strips `/curltastic` before forwarding.
|
|
||||||
- Attach frontend to Traefik's external `web` network and a private application
|
|
||||||
network. Put backend only on that private network, with the service/DNS name
|
|
||||||
**`curltastic-backend`**. Do not publish backend port 3000 or enable Traefik on it.
|
|
||||||
- The browser requests public `/curltastic/ws` using its existing host, port and
|
|
||||||
TLS scheme. nginx receives `/ws` and proxies the upgrade and query string to
|
|
||||||
`curltastic-backend:3000/ws`. It also proxies `POST /room`, exposed publicly as
|
|
||||||
`/curltastic/room`. The current frontend creates room IDs locally and does not
|
|
||||||
make HTTP room requests; there is no separate frontend HTTP API URL to set.
|
|
||||||
- Docker DNS is re-resolved every five seconds so backend replacement during
|
|
||||||
Compose watch does not leave nginx using a stale container address.
|
|
||||||
- Recommended runtime settings: `read_only: true`, `cap_drop: [ALL]`,
|
|
||||||
`security_opt: [no-new-privileges:true]`. Give frontend a writable `/tmp` tmpfs
|
|
||||||
(mode 1777) for nginx PID/temp files. Backend needs no writable mount.
|
|
||||||
- Never mount source code, a Docker socket, or host credentials. The build context
|
|
||||||
is allowlisted by `.dockerignore`; host dependencies and `.env*` are excluded.
|
|
||||||
- Use Compose watch `action: rebuild`: frontend changes rebuild the `frontend`
|
|
||||||
target, backend changes rebuild the `backend` target. Ignore node_modules,
|
|
||||||
dist, target, .git and other generated files. Watch Dockerfile for both services
|
|
||||||
and `docker/nginx.conf` for frontend.
|
|
||||||
|
|
||||||
The nginx listener expects already-stripped paths; a raw host-port request to
|
|
||||||
`/curltastic/` does not simulate Traefik. Test the public route through a
|
|
||||||
prefix-stripping test proxy, or request `/`, `/assets/...`, `/ws`, `/room` directly
|
|
||||||
when testing nginx alone.
|
|
||||||
|
|
||||||
## Development and verification
|
|
||||||
|
|
||||||
Local `npm run dev` still defaults to `/` and connects to the page hostname on
|
|
||||||
backend port 3000. Existing `VITE_WS_URL` overrides are preserved for custom local
|
|
||||||
setups; Vite variables are build-time settings, not runtime container settings.
|
|
||||||
Room navigation and share links retain the page path.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
(cd frontend && npm ci && npm test -- --pool=forks --poolOptions.forks.singleFork)
|
|
||||||
(cd frontend && npm run build -- --base=/curltastic/)
|
|
||||||
(cd backend && cargo test --locked)
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend Docker build runs its tests and TypeScript checking before producing
|
|
||||||
static assets. The backend build uses the committed Cargo lockfile.
|
|
||||||
|
|
||||||
Verification at implementation: frontend 26 tests passed, Rust 22 tests passed,
|
|
||||||
and the production subpath frontend build passed. Both Docker build attempts were
|
|
||||||
denied by the execution approval layer; image builds, nginx syntax, and live
|
|
||||||
HTTP/WebSocket proxy behavior therefore remain unverified. No servers were
|
|
||||||
started and no deployment was performed.
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
worker_processes auto;
|
|
||||||
pid /tmp/nginx.pid;
|
|
||||||
error_log /dev/stderr warn;
|
|
||||||
|
|
||||||
events { worker_connections 1024; }
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
access_log /dev/stdout;
|
|
||||||
server_tokens off;
|
|
||||||
sendfile on;
|
|
||||||
client_body_temp_path /tmp/client_temp;
|
|
||||||
proxy_temp_path /tmp/proxy_temp;
|
|
||||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
|
||||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
|
||||||
scgi_temp_path /tmp/scgi_temp;
|
|
||||||
|
|
||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Re-resolve the private service after Compose watch replaces its container.
|
|
||||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
client_max_body_size 16k;
|
|
||||||
set $curltastic_backend curltastic-backend:3000;
|
|
||||||
|
|
||||||
location = /healthz {
|
|
||||||
access_log off;
|
|
||||||
default_type text/plain;
|
|
||||||
return 200 "ok\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Traefik removes /curltastic before these requests reach nginx.
|
|
||||||
location = /ws {
|
|
||||||
proxy_pass http://$curltastic_backend$request_uri;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
proxy_buffering off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /room {
|
|
||||||
proxy_pass http://$curltastic_backend$request_uri;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
}
|
|
||||||
|
|
||||||
location /assets/ {
|
|
||||||
try_files $uri =404;
|
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -139,9 +139,7 @@ export class GameModel {
|
|||||||
if (thrownId !== null) {
|
if (thrownId !== null) {
|
||||||
const thrownPath =
|
const thrownPath =
|
||||||
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
|
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
|
||||||
const hogIdx = hogTrimStartIndex(thrownPath)
|
this.pathClockOffset = hogTrimStartIndex(thrownPath)
|
||||||
// Leave at least one segment after offset so hog start still plays.
|
|
||||||
this.pathClockOffset = Math.min(hogIdx, Math.max(0, thrownPath.length - 2))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathMap = new Map<
|
const pathMap = new Map<
|
||||||
@ -157,7 +155,9 @@ export class GameModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activePaths = pathMap
|
this.activePaths = pathMap
|
||||||
this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
|
this.state.animating = Array.from(pathMap.values()).some(
|
||||||
|
(p) => p.path.length > this.pathClockOffset + 1,
|
||||||
|
)
|
||||||
this.animationStartTime = performance.now()
|
this.animationStartTime = performance.now()
|
||||||
// Fresh throw: wait for the matching game_state.
|
// Fresh throw: wait for the matching game_state.
|
||||||
this.pendingStones = []
|
this.pendingStones = []
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import { afterEach, expect, it, vi } from 'vitest'
|
|
||||||
import { startGame } from './game'
|
|
||||||
import { connect } from './net'
|
|
||||||
import { createHud } from './hud'
|
|
||||||
|
|
||||||
vi.mock('./net', () => ({ connect: vi.fn(), sendThrow: vi.fn() }))
|
|
||||||
vi.mock('./renderer', () => ({ createRenderer: () => ({ draw: vi.fn() }) }))
|
|
||||||
vi.mock('./hud', () => ({
|
|
||||||
createHud: vi.fn(),
|
|
||||||
createVelocitySelector: () => ({ setEnabled: vi.fn() }),
|
|
||||||
createCurlSelector: () => ({ setEnabled: vi.fn() }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
|
|
||||||
|
|
||||||
it.each(['', '?room=INVITE'])('keeps room navigation and share links under the app path (%s)', (search) => {
|
|
||||||
const element = { addEventListener: vi.fn(), value: 'team1' }
|
|
||||||
const setShareLink = vi.fn()
|
|
||||||
vi.mocked(createHud).mockReturnValue({
|
|
||||||
root: element, velocityControl: element, curlSelector: element,
|
|
||||||
throwButton: element, teamSelect: element,
|
|
||||||
setShareLink, setTeam: vi.fn(), update: vi.fn(),
|
|
||||||
} as unknown as ReturnType<typeof createHud>)
|
|
||||||
vi.stubGlobal('document', { querySelector: () => ({
|
|
||||||
querySelector: () => element, appendChild: vi.fn(),
|
|
||||||
}) })
|
|
||||||
const location = new URL(`https://example.test/curltastic/${search}`)
|
|
||||||
const replaceState = vi.fn()
|
|
||||||
vi.stubGlobal('window', { location, history: { replaceState } })
|
|
||||||
vi.stubGlobal('localStorage', { getItem: () => null })
|
|
||||||
vi.stubGlobal('requestAnimationFrame', vi.fn())
|
|
||||||
startGame()
|
|
||||||
const room = vi.mocked(connect).mock.calls[0][0]
|
|
||||||
expect(setShareLink).toHaveBeenCalledWith(`https://example.test/curltastic/?room=${room}`)
|
|
||||||
if (!search) expect(replaceState).toHaveBeenCalledWith({}, '', `/curltastic/?room=${room}`)
|
|
||||||
else expect(replaceState).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
@ -27,10 +27,10 @@ export function startGame(): void {
|
|||||||
let room = params.get('room')
|
let room = params.get('room')
|
||||||
if (!room) {
|
if (!room) {
|
||||||
room = generateRoomCode()
|
room = generateRoomCode()
|
||||||
window.history.replaceState({}, '', `${window.location.pathname}?room=${room}`)
|
window.history.replaceState({}, '', `/?room=${room}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareLink = `${window.location.origin}${window.location.pathname}?room=${room}`
|
const shareLink = `${window.location.origin}/?room=${room}`
|
||||||
hud.setShareLink(shareLink)
|
hud.setShareLink(shareLink)
|
||||||
|
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
@ -43,8 +43,6 @@ export function startGame(): void {
|
|||||||
scoreboard: typeof model.state.scoreboard
|
scoreboard: typeof model.state.scoreboard
|
||||||
}
|
}
|
||||||
let deferredEndModal: EndModalPayload | null = null
|
let deferredEndModal: EndModalPayload | null = null
|
||||||
/** True after trajectories for the current throw until animation ends. */
|
|
||||||
let throwAnimPending = false
|
|
||||||
|
|
||||||
const stored = localStorage.getItem('curltastic-team')
|
const stored = localStorage.getItem('curltastic-team')
|
||||||
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
||||||
@ -80,7 +78,7 @@ export function startGame(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flushEndModal = () => {
|
const flushEndModal = () => {
|
||||||
if (!deferredEndModal || model.state.animating || throwAnimPending) return
|
if (!deferredEndModal || model.state.animating) return
|
||||||
hud.showEndModal(deferredEndModal)
|
hud.showEndModal(deferredEndModal)
|
||||||
deferredEndModal = null
|
deferredEndModal = null
|
||||||
}
|
}
|
||||||
@ -94,7 +92,6 @@ export function startGame(): void {
|
|||||||
const wasAnimating = model.state.animating
|
const wasAnimating = model.state.animating
|
||||||
const activeStonePos = model.tick(performance.now())
|
const activeStonePos = model.tick(performance.now())
|
||||||
if (wasAnimating && !model.state.animating) {
|
if (wasAnimating && !model.state.animating) {
|
||||||
throwAnimPending = false
|
|
||||||
flushEndModal()
|
flushEndModal()
|
||||||
updateControls()
|
updateControls()
|
||||||
}
|
}
|
||||||
@ -124,17 +121,14 @@ export function startGame(): void {
|
|||||||
onGameState: (msg) => {
|
onGameState: (msg) => {
|
||||||
model.updateGameState(msg)
|
model.updateGameState(msg)
|
||||||
queueEndModalIfNeeded()
|
queueEndModalIfNeeded()
|
||||||
// Only flush if this throw is not mid-animation (or never animated).
|
// Never flush here: game_state can arrive before/during throw anim.
|
||||||
flushEndModal()
|
// Modal is flushed when the trajectory animation ends (or traj is a no-op).
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onTrajectories: (stones) => {
|
onTrajectories: (stones) => {
|
||||||
model.startTrajectory(stones)
|
model.startTrajectory(stones)
|
||||||
// Hold end modal until playback finishes (even if sim paths were short).
|
// Short / empty paths finish immediately — show end modal if already queued.
|
||||||
throwAnimPending = model.state.animating
|
|
||||||
if (!throwAnimPending) {
|
|
||||||
flushEndModal()
|
flushEndModal()
|
||||||
}
|
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onGameOver: (scores, winner) => {
|
onGameOver: (scores, winner) => {
|
||||||
@ -208,8 +202,6 @@ export function startGame(): void {
|
|||||||
|
|
||||||
throwBtn.addEventListener('click', () => {
|
throwBtn.addEventListener('click', () => {
|
||||||
if (!model.isMyTurn) return
|
if (!model.isMyTurn) return
|
||||||
// Hold end-of-end modal until trajectories + playback complete.
|
|
||||||
throwAnimPending = true
|
|
||||||
sendThrow(
|
sendThrow(
|
||||||
hud.teamSelect.value as Team,
|
hud.teamSelect.value as Team,
|
||||||
model.broom.x,
|
model.broom.x,
|
||||||
|
|||||||
@ -71,25 +71,6 @@ const TEAM_LABELS: Record<Team, string> = {
|
|||||||
team2: 'Team 2',
|
team2: 'Team 2',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Team 1 stole 1!" / "Team 2 scored 4!" from the completed end row. */
|
|
||||||
function endResultHeadline(payload: {
|
|
||||||
team1: number
|
|
||||||
team2: number
|
|
||||||
scoreboard: readonly EndScore[]
|
|
||||||
}): string {
|
|
||||||
const entry = payload.scoreboard[payload.scoreboard.length - 1]
|
|
||||||
const hammer = entry?.hammer
|
|
||||||
const t1 = payload.team1
|
|
||||||
const t2 = payload.team2
|
|
||||||
if (t1 === 0 && t2 === 0) return 'Blank end!'
|
|
||||||
if (t1 > 0) {
|
|
||||||
const stole = hammer === 'team2'
|
|
||||||
return stole ? `Team 1 stole ${t1}!` : `Team 1 scored ${t1}!`
|
|
||||||
}
|
|
||||||
const stole = hammer === 'team1'
|
|
||||||
return stole ? `Team 2 stole ${t2}!` : `Team 2 scored ${t2}!`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStoneChipsHtml(team: Team): string {
|
function buildStoneChipsHtml(team: Team): string {
|
||||||
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
|
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
|
||||||
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
|
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
|
||||||
@ -98,22 +79,31 @@ function buildStoneChipsHtml(team: Team): string {
|
|||||||
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
|
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] {
|
||||||
|
let t1 = 0
|
||||||
|
let t2 = 0
|
||||||
|
for (const e of scoreboard) {
|
||||||
|
t1 += e.team1
|
||||||
|
t2 += e.team2
|
||||||
|
}
|
||||||
|
return [t1, t2]
|
||||||
|
}
|
||||||
|
|
||||||
export function createHud(): Hud {
|
export function createHud(): Hud {
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
root.id = 'hud'
|
root.id = 'hud'
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div id="hud-top-group">
|
<div id="hud-top-group">
|
||||||
<div id="hud-status-row" aria-live="polite">
|
<div class="hud-row" id="share-row">
|
||||||
<div id="stones-hud">
|
<div id="share"><button type="button">Copy share link</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="stones-hud" aria-live="polite">
|
||||||
${buildStoneChipsHtml('team1')}
|
${buildStoneChipsHtml('team1')}
|
||||||
${buildStoneChipsHtml('team2')}
|
${buildStoneChipsHtml('team2')}
|
||||||
</div>
|
</div>
|
||||||
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap"></div>
|
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap" aria-live="polite"></div>
|
||||||
</div>
|
<div class="hud-row">
|
||||||
<div class="hud-row" id="hud-meta-row">
|
<div id="end-info">End 1 · Waiting</div>
|
||||||
<div id="share"><button type="button">Copy share link</button></div>
|
|
||||||
<div id="turn-info">Waiting</div>
|
|
||||||
<select id="team-select" aria-label="Team">
|
<select id="team-select" aria-label="Team">
|
||||||
<option value="team1">Team 1</option>
|
<option value="team1">Team 1</option>
|
||||||
<option value="team2">Team 2</option>
|
<option value="team2">Team 2</option>
|
||||||
@ -130,7 +120,7 @@ export function createHud(): Hud {
|
|||||||
<div id="waiting">Waiting</div>
|
<div id="waiting">Waiting</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
const turnInfoEl = root.querySelector<HTMLDivElement>('#turn-info')!
|
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||||
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||||
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
||||||
@ -217,8 +207,9 @@ export function createHud(): Hud {
|
|||||||
state.phase === 'playing'
|
state.phase === 'playing'
|
||||||
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
||||||
: state.phase.replace(/_/g, ' ')
|
: state.phase.replace(/_/g, ' ')
|
||||||
turnInfoEl.textContent = phaseText
|
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
||||||
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
||||||
|
// Hammer class first so stones-remaining aria can mention it.
|
||||||
updateHammerBadge(state.hammer)
|
updateHammerBadge(state.hammer)
|
||||||
updateStonesRemaining(state.stonesRemaining)
|
updateStonesRemaining(state.stonesRemaining)
|
||||||
updateScoreboard(state.scoreboard, state.scores, {
|
updateScoreboard(state.scoreboard, state.scores, {
|
||||||
@ -233,11 +224,26 @@ export function createHud(): Hud {
|
|||||||
modal.setAttribute('role', 'dialog')
|
modal.setAttribute('role', 'dialog')
|
||||||
modal.setAttribute('aria-modal', 'true')
|
modal.setAttribute('aria-modal', 'true')
|
||||||
modal.setAttribute('aria-labelledby', 'end-modal-title')
|
modal.setAttribute('aria-labelledby', 'end-modal-title')
|
||||||
const headline = endResultHeadline(payload)
|
const boardHtml = renderBaseballScoreboard(
|
||||||
|
payload.scoreboard,
|
||||||
|
scoreboardTotals(payload.scoreboard),
|
||||||
|
{
|
||||||
|
ends: ENDS,
|
||||||
|
hammer: payload.nextHammer,
|
||||||
|
currentEnd: payload.end,
|
||||||
|
},
|
||||||
|
)
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="end-modal-backdrop" data-dismiss="1"></div>
|
<div class="end-modal-backdrop" data-dismiss="1"></div>
|
||||||
<div class="end-modal-card end-modal-card--compact">
|
<div class="end-modal-card">
|
||||||
<h2 id="end-modal-title">${headline}</h2>
|
<h2 id="end-modal-title">End ${payload.end} complete</h2>
|
||||||
|
<p class="end-modal-points">
|
||||||
|
<span class="end-modal-team end-modal-team--team1">Team 1 <strong>${payload.team1}</strong></span>
|
||||||
|
<span class="end-modal-vs">vs</span>
|
||||||
|
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
|
||||||
|
</p>
|
||||||
|
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
|
||||||
|
<div class="end-modal-board">${boardHtml}</div>
|
||||||
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
|
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@ -279,24 +285,57 @@ export function createVelocitySelector(
|
|||||||
const wrap = document.createElement('div')
|
const wrap = document.createElement('div')
|
||||||
wrap.className = 'velocity-control-inner'
|
wrap.className = 'velocity-control-inner'
|
||||||
|
|
||||||
|
const title = document.createElement('label')
|
||||||
|
title.textContent = 'Weight'
|
||||||
|
wrap.appendChild(title)
|
||||||
|
|
||||||
const slider = document.createElement('input')
|
const slider = document.createElement('input')
|
||||||
slider.type = 'range'
|
slider.type = 'range'
|
||||||
slider.min = '0'
|
slider.min = '0'
|
||||||
slider.max = '14'
|
slider.max = String(14) // 1..10 + hack/board/control/normal/peel
|
||||||
slider.step = '1'
|
slider.step = '1'
|
||||||
slider.value = String(state.index)
|
slider.value = String(state.index)
|
||||||
slider.className = 'velocity-slider'
|
slider.className = 'velocity-slider'
|
||||||
|
slider.setAttribute('list', 'weight-marks')
|
||||||
slider.ariaLabel = 'Throw weight'
|
slider.ariaLabel = 'Throw weight'
|
||||||
|
|
||||||
|
const marks = document.createElement('datalist')
|
||||||
|
marks.id = 'weight-marks'
|
||||||
|
const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const
|
||||||
|
for (const tick of tickLabels) {
|
||||||
|
const opt = document.createElement('option')
|
||||||
|
opt.value = String(indexOfLabel(tick))
|
||||||
|
opt.label = String(tick)
|
||||||
|
marks.appendChild(opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticks = document.createElement('div')
|
||||||
|
ticks.className = 'velocity-ticks'
|
||||||
|
ticks.setAttribute('aria-hidden', 'true')
|
||||||
|
const named = ['hack', 'board', 'control', 'normal', 'peel'] as const
|
||||||
|
ticks.innerHTML = named
|
||||||
|
.map((name) => `<span class="velocity-tick" data-label="${name}">${name}</span>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
const readout = document.createElement('div')
|
const readout = document.createElement('div')
|
||||||
readout.className = 'velocity-readout'
|
readout.className = 'velocity-readout'
|
||||||
wrap.appendChild(slider)
|
wrap.appendChild(slider)
|
||||||
|
wrap.appendChild(marks)
|
||||||
|
wrap.appendChild(ticks)
|
||||||
wrap.appendChild(readout)
|
wrap.appendChild(readout)
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
state.index = clampSpeedIndex(Number(slider.value))
|
state.index = clampSpeedIndex(Number(slider.value))
|
||||||
const label = labelAtIndex(state.index)
|
const label = labelAtIndex(state.index)
|
||||||
readout.textContent = formatSpeedLabel(label)
|
const v = velocityForLabel(label)
|
||||||
|
const name = formatSpeedLabel(label)
|
||||||
|
const isTakeout = typeof label === 'string'
|
||||||
|
readout.textContent = isTakeout
|
||||||
|
? `${name} · ${v.toFixed(3)} m/s`
|
||||||
|
: `${name} · ${v.toFixed(3)} m/s`
|
||||||
|
ticks.querySelectorAll<HTMLSpanElement>('.velocity-tick').forEach((el) => {
|
||||||
|
el.classList.toggle('velocity-tick--active', el.dataset.label === String(label))
|
||||||
|
})
|
||||||
onSelect()
|
onSelect()
|
||||||
}
|
}
|
||||||
slider.addEventListener('input', update)
|
slider.addEventListener('input', update)
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import { afterEach, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
const callbacks = {
|
|
||||||
onJoined: vi.fn(), onWaiting: vi.fn(), onGameState: vi.fn(),
|
|
||||||
onTrajectories: vi.fn(), onGameOver: vi.fn(), onError: vi.fn(), onClose: vi.fn(),
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
vi.unstubAllEnvs()
|
|
||||||
vi.resetModules()
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[false, 'https://example.test:8443/curltastic/', '/curltastic/', '', 'wss://example.test:8443/curltastic/ws'],
|
|
||||||
[false, 'http://localhost:4001/curltastic/', '/curltastic/', '', 'ws://localhost:4001/curltastic/ws'],
|
|
||||||
[false, 'https://example.test/', '/', '', 'wss://example.test/ws'],
|
|
||||||
[true, 'http://localhost:5173/', '/', '', 'ws://localhost:3000/ws'],
|
|
||||||
[true, 'https://example.test:5173/', '/', '', 'wss://example.test:3000/ws'],
|
|
||||||
[false, 'https://example.test/curltastic/', '/curltastic/', 'wss://custom.test/ws', 'wss://custom.test/ws'],
|
|
||||||
])('resolves the WebSocket endpoint (dev=%s, page=%s)', async (dev, page, base, override, expected) => {
|
|
||||||
vi.stubEnv('DEV', dev)
|
|
||||||
vi.stubEnv('BASE_URL', base)
|
|
||||||
vi.stubEnv('VITE_WS_URL', override)
|
|
||||||
vi.stubGlobal('window', { location: new URL(page) })
|
|
||||||
const WebSocket = vi.fn()
|
|
||||||
vi.stubGlobal('WebSocket', WebSocket)
|
|
||||||
const { connect } = await import('./net')
|
|
||||||
connect('A&B C', callbacks)
|
|
||||||
expect(WebSocket).toHaveBeenCalledWith(`${expected}?room=A%26B%20C`)
|
|
||||||
})
|
|
||||||
@ -10,11 +10,8 @@ const WS_URL = import.meta.env.VITE_WS_URL
|
|||||||
function resolveWsUrl(): string {
|
function resolveWsUrl(): string {
|
||||||
if (WS_URL) return WS_URL
|
if (WS_URL) return WS_URL
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
return `${protocol}//${window.location.hostname}:3000/ws`
|
return `${protocol}//${window.location.hostname}:3000/ws`
|
||||||
}
|
}
|
||||||
return `${protocol}//${window.location.host}${import.meta.env.BASE_URL}ws`
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetCallbacks {
|
export interface NetCallbacks {
|
||||||
onJoined: (room: string) => void
|
onJoined: (room: string) => void
|
||||||
|
|||||||
@ -178,6 +178,9 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||||
ctx.fillStyle = bodyGrad
|
ctx.fillStyle = bodyGrad
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.9)'
|
||||||
|
ctx.lineWidth = Math.max(1.5, scale() * 0.02)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
// Asymmetric handle: bright bar + dark toe so spin reads clearly.
|
// Asymmetric handle: bright bar + dark toe so spin reads clearly.
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
||||||
|
|||||||
@ -87,57 +87,29 @@ html, body {
|
|||||||
#hud-top-group {
|
#hud-top-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#hud-status-row {
|
/* —— Stones remaining (2×8 skeuomorphic chips) —— */
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#hud-meta-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#turn-info {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* —— Stones remaining (2×8 chips) —— */
|
|
||||||
#stones-hud {
|
#stones-hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 6px;
|
||||||
gap: 4px;
|
padding: 6px 10px;
|
||||||
flex: 1 1 50%;
|
margin: 0 auto 2px;
|
||||||
min-width: 0;
|
|
||||||
padding: 4px 6px;
|
|
||||||
margin: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
border-radius: 10px;
|
border-radius: 14px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.25);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stones-row {
|
.stones-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 12px repeat(8, 12px);
|
/* Fixed hammer column so both team chips align vertically under each other. */
|
||||||
column-gap: 3px;
|
grid-template-columns: 18px repeat(8, 18px);
|
||||||
|
column-gap: 5px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
@ -145,12 +117,12 @@ html, body {
|
|||||||
|
|
||||||
.hammer-badge {
|
.hammer-badge {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
width: 12px;
|
width: 18px;
|
||||||
height: 12px;
|
height: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
|
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
|
||||||
/* Keep layout space when the other team has hammer (no collapse / shift). */
|
/* Keep layout space when the other team has hammer (no collapse / shift). */
|
||||||
@ -162,14 +134,14 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stone-chip {
|
.stone-chip {
|
||||||
width: 12px;
|
width: 18px;
|
||||||
height: 12px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: 1.5px solid rgba(255, 255, 255, 0.85);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 2px rgba(255, 255, 255, 0.35),
|
inset 0 2px 3px rgba(255, 255, 255, 0.45),
|
||||||
inset 0 -1px 2px rgba(0, 0, 0, 0.35),
|
inset 0 -2px 3px rgba(0, 0, 0, 0.35),
|
||||||
0 1px 2px rgba(0, 0, 0, 0.35);
|
0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
|
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,19 +158,19 @@ html, body {
|
|||||||
transform: scale(0.72);
|
transform: scale(0.72);
|
||||||
filter: grayscale(0.6);
|
filter: grayscale(0.6);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— Baseball-style scoreboard matrix —— */
|
/* —— Baseball-style scoreboard matrix —— */
|
||||||
.scoreboard-baseball-wrap {
|
.scoreboard-baseball-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
flex: 1 1 50%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 0;
|
padding: 4px 0 6px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-baseball {
|
.scoreboard-baseball {
|
||||||
@ -207,49 +179,47 @@ html, body {
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
font-size: 9px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.15;
|
line-height: 1.25;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transform: scale(0.92);
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-baseball th,
|
.scoreboard-baseball th,
|
||||||
.scoreboard-baseball td {
|
.scoreboard-baseball td {
|
||||||
min-width: 1.1em;
|
min-width: 1.35em;
|
||||||
padding: 2px 3px;
|
padding: 3px 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-baseball thead th {
|
.scoreboard-baseball thead th {
|
||||||
font-size: 8px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(255, 255, 255, 0.72);
|
color: rgba(255, 255, 255, 0.72);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-baseball__corner {
|
.scoreboard-baseball__corner {
|
||||||
min-width: 2.6em;
|
min-width: 3.6em;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-baseball__label {
|
.scoreboard-baseball__label {
|
||||||
min-width: 2.6em;
|
min-width: 3.6em;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding-left: 4px;
|
padding-left: 6px;
|
||||||
padding-right: 4px;
|
padding-right: 6px;
|
||||||
font-size: 8px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
@ -440,7 +410,7 @@ html, body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#share-row, #hud-meta-row #share {
|
#share-row {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
@ -498,7 +468,7 @@ html, body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#velocity-control label { display: none;
|
#velocity-control label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -652,15 +622,3 @@ html, body {
|
|||||||
.velocity-tick--active {
|
.velocity-tick--active {
|
||||||
color: #7dffc0;
|
color: #7dffc0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.end-modal-card--compact {
|
|
||||||
max-width: min(360px, 92vw);
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.end-modal-card--compact h2 {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user