From f9aa382f300abfcbeab8ac84fff338c81c524a0c Mon Sep 17 00:00:00 2001 From: ryanfitzpatrickio Date: Mon, 3 Aug 2026 10:41:13 -0500 Subject: [PATCH] Merge goalie-tester worktree: modes, 3v3 goalies, OOB faceoffs, CF deploy. Bring main menu (1v1/3v3), scrimmage with nets and goalies, dead-puck whistles at the nearest faceoff circle, skater board re-entry, and Cloudflare Workers/Pages deploy config. Keep main jersey gear stack and compact goalie floaters. --- .gitignore | 4 + DEPLOY.md | 119 +++ LICENSE | 18 + README.md | 3 + SPORTS_FRAMEWORK.md | 606 ++++++++++++++ index.html | 65 +- package-lock.json | 1542 ++++++++++++++++++++++++++++++++++- package.json | 9 +- public/_headers | 25 + shared/rink.js | 59 ++ src/character/goalieGear.js | 19 +- src/game/match.js | 108 ++- src/game/scrimmage.js | 188 +++++ src/main.js | 427 +++++++--- test/rink.mjs | 27 +- tools/capture.mjs | 7 +- vite.config.js | 8 + wrangler.jsonc | 17 + 18 files changed, 3121 insertions(+), 130 deletions(-) create mode 100644 DEPLOY.md create mode 100644 LICENSE create mode 100644 SPORTS_FRAMEWORK.md create mode 100644 public/_headers create mode 100644 src/game/scrimmage.js create mode 100644 wrangler.jsonc diff --git a/.gitignore b/.gitignore index 8b68537..fff1c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ dist/ shots/ .DS_Store +.wrangler/ +.dev.vars +.dev.vars.* +!.dev.vars.example diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..7399da5 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,119 @@ +# Deploying tilt + +tilt is a **static Vite front-end** (Three.js + Box3D WASM). There is no API +server yet. Cloudflare **Workers Static Assets** is the primary target; +**Pages** is a one-liner alternative if you prefer Git/dashboard deploys. + +## Prerequisites + +1. Cloudflare account +2. Auth once on this machine: + +```bash +npx wrangler login +# or: npm run cf:whoami +``` + +## Preferred: Workers (static assets) + +Config lives in [`wrangler.jsonc`](wrangler.jsonc). Assets come from `dist/` +after a Vite production build. + +```bash +npm install +npm run deploy:dry # validate upload without publishing +npm run deploy # build + wrangler deploy +``` + +That publishes a Worker named **`tilt`** serving: + +| Path | App | +|------|-----| +| `/` | Game (menu → 1v1 / 3v3) | +| `/character.html` | Gear / animation studio | + +URL shape after first deploy: `https://tilt..workers.dev` + +### Custom domain + +Dashboard → Workers & Pages → **tilt** → Settings → Domains, or: + +```bash +npx wrangler domains add tilt.example.com +``` + +(Exact CLI may vary by Wrangler version; dashboard is fine.) + +## Alternative: Cloudflare Pages + +Same build output, Pages project instead of a Worker: + +```bash +npm run pages:deploy +# → wrangler pages deploy dist --project-name=tilt +``` + +Or connect the Git remote in the dashboard: + +| Setting | Value | +|---------|--------| +| Production branch | `main` | +| Build command | `npm run build` | +| Build output directory | `dist` | +| Root directory | `/` (repo root) | +| Node version | 20+ | + +`public/_headers` is copied into `dist/` by Vite and applies on both Workers +assets and Pages. + +## Local production check + +```bash +npm run build +npm run preview # Vite static server on dist/ +# or, after wrangler is installed: +npx wrangler dev # serves assets via Workers runtime locally +``` + +## What is *not* deployed + +- `test/`, `tools/` (Node harnesses, Puppeteer captures) +- `shots/`, `node_modules/`, source maps (unless you opt in) +- Server/netcode — still pure client sim + +When you add an API later, keep this assets config and introduce a Worker +`main` (or Pages Functions) beside it; SPA/static hosting stays the same. + +## CI sketch + +```yaml +# e.g. Gitea Actions / GitHub Actions +- run: npm ci +- run: npm test +- run: npm run build +- run: npx wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +``` + +Create an API token with **Workers Scripts:Edit** (and **Account:Read**). + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `wrangler login` / auth errors | `npx wrangler login` or set `CLOUDFLARE_API_TOKEN` | +| Blank page, missing WASM | Confirm `dist/assets/*.wasm` exists after build; hard-refresh | +| 404 on `/character` | Use `/character.html` (multi-page, not SPA rewrite) | +| Huge first load | Expected (~0.7 MB JS + ~0.8 MB WASM); cached after first visit | +| `node:module` warning in build | Harmless browser stub for box3d’s Node path; WASM path is used | + +## Files + +| File | Role | +|------|------| +| `wrangler.jsonc` | Workers name, assets directory, observability | +| `public/_headers` | Cache + WASM content-type hints | +| `vite.config.js` | Multi-page entries (`index` + `character`) | +| `package.json` | `deploy`, `deploy:dry`, `pages:deploy` scripts | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ff83e89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 ryan + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index e0fa5eb..6484f5b 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,11 @@ npm install npm run dev # http://localhost:5174 npm test # headless: sim, rink, AI, pose, input, physics, hits npm run capture # boots the app headless and screenshots it into shots/ +npm run deploy # vite build + Cloudflare Workers static assets ``` +Cloudflare setup, Pages alternative, and CI notes: **[DEPLOY.md](DEPLOY.md)**. + In the browser: | Xbox | keyboard | | diff --git a/SPORTS_FRAMEWORK.md b/SPORTS_FRAMEWORK.md new file mode 100644 index 0000000..12c1a25 --- /dev/null +++ b/SPORTS_FRAMEWORK.md @@ -0,0 +1,606 @@ +# Sports Framework — Expansion Plan + +What you already have is not a hockey demo with spare parts. It is a **character + +physics + intent** stack that happens to be wired as ice hockey. The goal of this +document is to name the seams honestly, list what still has to become *sport-agnostic*, +and outline what would remain as **sport packs** so the same building blocks can +ship football, basketball, soccer, fighting, racing-on-foot, and contact sports +without forking the engine. + +This is complementary to [ROADMAP.md](ROADMAP.md) (hockey product depth) and +[README.md](README.md) (current architecture). Here the question is: + +> What would it take for *this package* to be the framework under *any* sport game? + +--- + +## 1. The thesis + +A general sports framework is not “one locomotion model that does everything.” +It is a **small set of contracts** plus **pluggable sport modules**: + +| Layer | Owns | Sport-specific? | +|-------|------|-----------------| +| **Body stack** | skeleton, mesh, skinning, body style, ragdoll, proxy | mostly shared | +| **Equipment stack** | gear builders, sockets, coverage, colliders | shared builders; sport kits | +| **Motion stack** | pose buffer, crossfade, IK, override layers, clips later | shared runtime; sport poses | +| **Locomotion** | intent → velocity → footing | **one model per surface/gait family** | +| **Impact** | did hit / what kind / severity / handoff limp↔driven | shared core; sport rules | +| **Object play** | ball/puck possession, tool use, goals | sport packs | +| **AI** | roles, intent writers, formation | shared brain shell; sport tactics | +| **Arena** | bounds, surfaces, markings, static colliders | sport packs | +| **Rules / match** | clock, score, phases, sanctions | sport packs | +| **Studio / harness** | shot sheets, drills, capture, regression | shared tooling; sport catalogs | + +Hockey already forces the hard problems (anisotropic friction, limb-level contact, +tool-in-hand IK, pure sim in `shared/`). That is why a framework *can* fall out of +this — but only if hockey stops being the type system. + +--- + +## 2. What you already have (framework seeds) + +These are reusable with little or no redesign. + +### 2.1 Character body pipeline +- **23-bone A-pose skeleton** (`skeleton.js`) with regions, bone radii, segment graph +- **Procedural lofted body** (`body.js`) + **distance-field skinning** (`skinning.js`) +- **Body style morphs** mass / muscle / fat (`bodyStyle.js`) — already sport-neutral +- **Materials / team tints** (`materials.js`) — team identity, not hockey identity + +### 2.2 Equipment geometry kit +- **`gearMesh.js`**: `loft`, `carvedShell`, `tube` — sport-agnostic mesh grammar +- **Socket + skinned cloth pattern** (hard shells on bones, cloth skinned with `aPart`/`aT`) +- **`hideCoveredBody`** coverage masks so kit replaces flesh + +Hockey gear (`skaterGear`, `goalieGear`, `stick`) is content *on top of* that grammar. + +### 2.3 Animation runtime +- **Pose buffer + state crossfade + override layers** (`skateAnimator.js`) +- **Two-bone analytic IK** (legs; goalie legs; stick grip reach partially) +- **Override layers that multiply spine** while replacing arms (stickwork) — correct + pattern for “keep locomoting while acting” +- **Animator does not own movement** — sim/proxy own position (critical for all sports) + +### 2.4 Physics character loop +- **Proxy capsule for standing presence** + **kinematic ragdoll for pose/hits** +- **Limp handoff** with root re-expression so get-up does not double-apply slide +- **Limb-pair hit classification** from posed capsules (`hits.js`) — the right + separation of *did hit* vs *what hit* +- **Collision layers / tags** (`bridge.js`) — extensible KIND system + +### 2.5 Intent seam +- **`applyIntent`** clamped entry (`skaterSim.js`) — controller, AI, and netcode all + write the same shape +- **Camera-relative stick** (`input.js`) — gamepad/keyboard already abstracted + +### 2.6 Pure sim island +- **`shared/`** free of three.js, headless-testable, server-ready +- **Headless test culture** (pose, AI, physics, hits) + **capture / img2mesh** tools + +### 2.7 Studio / reference loop (seed) +- **Character studio** (`character.html` + `img2mesh`) with pose presets, fixed views, + shot sheets, optional live drill (puck machine) +- Pattern: *author → capture sheet → drop refs → re-capture → diff by eye (or later pixel)* + +--- + +## 3. What is still hockey-shaped (must peel off) + +These block “any sport” until they become interfaces + plugins. + +| Hardcoded today | Why it blocks generality | +|-----------------|---------------------------| +| `shared/skaterSim.js` is *the* locomotion | Running, plant-and-cut, swim, bike, skate all need different footing models | +| `shared/rink.js` is the arena | Rect + corner radius is not a pitch, court, ring, or track | +| `shared/ai.js` puck roles + rink half | Roles are puck/carrier/chaser; no ball/goal/zone abstraction | +| `possession.js` + stick blade | Magnetism dial is excellent, but assume blade + cylinder puck | +| `stick.js` grip-space tool | Need generic **held tool / ball socket / two-hand implement** | +| `hits.js` CAN_DELIVER / ice thresholds | Sport contact rules differ (tackle vs check vs foul vs strike) | +| `goalie` as special entity type | “Keeper” should be a role/locomotion profile, not only a hockey class | +| `match.js` / `shootout.js` | Match loop is sport session; not a generic phase machine | +| `input.js` Skill Stick / hockey stop | Sport action maps differ; need bindable action sets | +| Pose catalogs only skate / stick / goalie | Need gait families + sport action libraries | +| Physics world assumes ice plane + boards | Surfaces (friction, bounce), goals, posts, glass, hoops, cages | +| Package name / scripts assume hockey MVP | Cosmetics; real issue is import graph coupling | + +--- + +## 4. Target architecture + +``` +packages/ (or src/ layered) + core/ math, rng, time, determinism helpers + body/ skeleton, body loft, skinning, bodyStyle, ragdoll, proxy + gear/ loft/shell/tube builders, socket attach, coverage + anim/ pose buffer, blend, IK solvers, layer stack, clip hooks + locomotion/ interface + models: skate, run, plant, crouch-shuffle, … + impact/ contact resolution, limb pairs, severity curves, handoff + object/ free bodies, attachment/magnetism, tools, projectiles + arena/ surfaces, bounds, static colliders, markings API + brain/ intent writer, roles, blackboard, formation helpers + match/ phase machine, clock, score, events (sport-agnostic shell) + input/ device → action set → intent + render/ materials, camera presets, arena mesh helpers + studio/ pose/view catalogs, capture API, ref sheets, drills + harness/ headless scenarios, golden metrics, capture regression + +sports/ + hockey/ rink, skate loco, stick, puck, check rules, shootout, AI + (future packs) + football/ + basketball/ + soccer/ + fighting/ (Ludus-adjacent — already half-solved) + … +``` + +**Rule:** a sport pack may depend on the framework. The framework must not import a sport pack. + +Hockey becomes the first pack that proves the contracts. Ludus fighting is the second +pack if contact + minigame reuse is the priority (see ROADMAP fighting note). + +--- + +## 5. Contracts to extract (the real work) + +### 5.1 Locomotion model (`ILocomotion`) + +**Have:** carve/glide blade model + intent fields (`ix, iz, sprint, brake`). + +**Need:** + +``` +createState(spawn, profile) → state +applyIntent(state, msg) // clamped, never trusts client +step(state, dt, env) // env: surface, contacts, clamps +readout(state) → { speed, gaitPhase, bank, plantL, plantR, … } +``` + +**Locomotion families to support eventually:** + +| Family | Examples | Key physics | +|--------|----------|-------------| +| **Blade / edge** | ice hockey, figure, speed skate | anisotropic friction, carve | +| **Run plant** | soccer, basketball, football | foot plant, cut friction, acceleration | +| **Crouch / shuffle** | goalies, catchers, linemen | lateral step without full gait | +| **Contact-limited** | rugby ruck, board pin | reduced mobility under constraint | +| **Aerial / jump** | basketball, volleyball, headers | ballistic + land recovery | +| **Vehicle-assisted** | cycling, wheelchair, luge | separate later; not v1 | + +**Left to build:** +- Shared `env` (surface µ, max slope, out-of-bounds clamp hooks) +- Foot plant bookkeeping for run gaits (world-planted feet vs mover-local skate feet) +- Profile data (top speed, accel, turn radius by sport / position / fatigue) +- Unit tests per model mirroring `test/skaterSim.mjs` + +### 5.2 Footing & IK policy + +**Have:** mover-local foot targets for skating; world-height leg IK for goalie pads. + +**Need:** +- **`IFootPolicy`**: glide | plant | pivot | kick | slide +- World-space plant IK for walk/run (Ludus-style), without regressing skate glide +- Arm IK generic: **two-hand implement**, **one-hand hold**, **catch**, **block** +- Spine/look-at IK for tracking ball / opponent (broadcast + AI readability) +- Hand-to-object solve that reports *reach error* (you already see this on stick grip) + +**Left to build:** +- Unified IK module (two-bone + simple FABRIK/pole vectors), shared by sports +- Constraint priorities (feet > implement > look) so layers don’t fight +- Authoring helpers: target sockets in mover-local vs world + +### 5.3 Pose / animation system generalization + +**Have:** states + stick override layers + goalie stance set. + +**Need:** +- **Locomotion graph** (idle, walk, run, sprint, cut, stop, jump, land, fall, getup) +- **Action layers** (shoot, pass, tackle, catch, block, celebrate) with blend masks +- **Clip adapter** interface early — procedural remains default; mocap/clips plug in + without rewriting sports (ROADMAP already names motion matching as the ceiling) +- Shared **get-up / knockdown / stagger** presentation states (physics-driven entry) + +**Left to build:** +- Pose catalog format (JSON or pure JS) keyed by sport + role +- Layer stack API (replace vs multiply vs additive per bone set) +- Phase drivers from locomotion readout (gait phase, effort, bank) +- Optional motion-match slot (even if empty) so content pipeline isn’t a rewrite later + +### 5.4 Equipment framework + +**Have:** builders + hockey kits + stick as special tool. + +**Need:** + +``` +EquipmentDef { + id, sockets[], skinnedParts[], rigidParts[], + colliders[], coverage[], massAdd, tags[] +} +ToolDef extends Equipment // stick, bat, racket, gloves-as-weapons +WearableDef extends Equipment // helmet, pads, jersey, cleats +``` + +**Left to build:** +- Data-driven attach (bone name + local TRS + optional skin weights) +- Size from physique (you already scale gear off `physique` — generalize) +- Collider generation from mesh bounds or authored capsules +- Damage/armor hooks by `REGION` (skeleton already has regions “from GDD”) +- Asset path: procedural first, imported glTF meshes later behind same sockets +- **Reference-driven iteration**: shot sheet per equipment part (front/side/3q/detail) + +### 5.5 Player assembly (`createAthlete`) + +**Have:** `createSkater`, `createGoalie` — two assembly paths. + +**Need:** one factory: + +``` +createAthlete({ + seed, bodyStyle, team, + locomotion: 'skate' | 'run' | …, + role: 'skater' | 'keeper' | 'striker' | …, + loadout: EquipmentDef[], + animProfile, + impactProfile, +}) +``` + +Roles swap locomotion, gear, AI profile, and action map — not hard forks of the character. + +### 5.6 Impact / contact sports core + +**Have:** proxy collision + limb pair + severity tiers + limp handoff + get-up fix. + +**Need:** +- **Impact profiles** (thresholds, limbShare, bonuses, legal deliverers) per sport +- Legal / illegal contact classification as *rules callback*, not hardcoded hockey +- Multi-body pile stability (proxy equilibrium already soft under pressure) +- Grapple / pin / clinch states (football tackle finish, wrestling, board battle) +- Strike profiles for combat sports (Ludus) reusing limb pairs + reaction curve +- Surface hits (wall, boards, post) feeding same severity pipeline +- Injury / fatigue hooks off region + severity (data only; presentation later) + +**Left to build:** +- `createImpactSystem({ profile, rules })` replacing hockey-tuned `HIT` constants +- Deliverer allow-lists as data +- Contested body state (pinned, held, blocked) separate from limp +- Harness scenarios: open ice hit, wall pin, tackle wrap, pile-up + +### 5.7 Object / ball / tool play + +**Have:** puck body, blade collider, magnetism possession dial, release step-clear. + +**Need:** +- Generic **`PlayObject`**: sphere / cylinder / prolate (ball types), mass, restitution +- **Attachment models**: hard parent, soft magnetism, carry socket, two-hand cradle +- **Release models**: impulse from tool velocity, throw arc, kick from foot bone +- Contested possession (you already noted nearest-index-wins is too coarse) +- Multi-object (only one puck today) + +**Left to build:** +- `object/possession.js` parameterized (radius, catch cone, break force, magnetism) +- Tool velocity sampling at release (stick already aims; generalize to foot/hand) +- Interaction matrix: body part × object × surface +- Sport rules for out-of-bounds / last touch (thin event layer) + +### 5.8 Arena / surface system + +**Have:** NHL rink numbers, board outline, ice clamp, markings texture, nets. + +**Need:** + +``` +ArenaDef { + bounds, // polygon or compound + surfaces[], // friction, restitution, name + staticColliders[], + goals[] | targets[], + spawns[], + markings, // render only +} +``` + +**Left to build:** +- Containment for rect, rounded-rect, circle, custom polyline +- Surface queries under feet (ice vs turf vs hardwood changes loco) +- Goal/score volumes as triggers (net is hockey-specific geometry) +- Camera volumes / broadcast rails optional metadata + +### 5.9 AI brain shell + +**Have:** waypoint steer, personal space, board lookahead, carry/chase/support/defend +via nearest-to-puck, intent-only outputs. + +**Need:** + +``` +Brain { + sense(world) → facts + rolePolicy(facts) → role + tactic(role, facts) → goal + motor(goal) → intent + actions +} +``` + +**Left to build:** +- Blackboard / facts schema (ball, goals, teammates, opponents, clock, score) +- Role set as data per sport (not four hockey enums only) +- Formation / spacing utilities (slots relative to ball and own goal) +- Difficulty as parameter on reaction lag, aim cone, decision rate +- Keeper brain as role using shuffle locomotion (generalize goalie) +- Headless scenario tests: “defend lead”, “press high”, “box out” per pack + +Hockey AI depth (forecheck systems, etc.) stays in the hockey pack / ROADMAP. + +### 5.10 Match / rules shell + +**Have:** `createMatch` loop, shootout phase machine, goal detection helpers. + +**Need:** +- Generic **phase machine**: warmup → live → stoppage → restart → end +- Event bus: goal, foul, out, period, possession change, hit +- Score / clock / period as configurable +- Restart spawns from arena + rules (faceoff, kickoff, freethrow, scrum) + +**Left to build:** +- `match/runtime.js` with sport rules plugin +- Deterministic event log (gates replay + netcode later) +- Minimal HUD adapter (score/clock) sport-agnostic + +### 5.11 Input / action maps + +**Have:** Xbox + keyboard, Skill Stick gestures, rumble on hit. + +**Need:** +- Action set per sport (`move`, `sprint`, `primary`, `secondary`, `skillAxis`, …) +- Gesture recognizers as plugins (Skill Stick shoot is one plugin) +- Rebind + dual-input last-wins (keep current behavior) + +### 5.12 Studio, reference sheets, training harness + +This is the differentiator you called out. Today it is hockey-shaped but the **loop** +is correct. + +**Have:** +- Live studio with pose/view catalogs +- CLI shot sheet (`img2mesh`) +- Puck machine drill for goalie +- Headless tests + `capture` screenshots +- `hitprobe` for contact + +**Need for framework-grade training harness:** + +| Capability | Purpose | +|------------|---------| +| **Subject registry** | any athlete role + loadout, not only player/goalie | +| **Pose/view catalogs as data** | sport packs register entries | +| **Reference slots** | `ref///_.png` side-by-side | +| **Diff mode** | flip / onion-skin / histogram vs previous capture | +| **Drill factory** | spawn scenario (1v1, shooting gallery, tackle pad, PK) | +| **Metric probes** | blade height, foot plant error, reach error, joint limits, timing | +| **Scenario packs** | headless scripts asserting metrics, not only “no NaN” | +| **Optional photo-guided authoring** | import ref image as camera-aligned overlay (not auto-mesh) | +| **Later: mesh-from-image assist** | silhouette / part labels → loft section hints (research; not blocking) | + +**Important scope note:** true **image → production mesh** (full photogrammetry / +generative retopo) is a product of its own. The framework should treat **reference +photos and sheets as the iteration contract**, with procedural gear remaining the +authoritative runtime asset until a mesh pipeline earns its keep. + +**Left to build (practical order):** +1. Registry + data-driven catalogs (so new sports don’t edit `img2mesh.mjs` internals) +2. Side-by-side ref overlay in studio + CLI compare report +3. Metric HUD (contact points, IK targets, capsule overlays — debug already half there) +4. Drill API used by shootout machine, then tackle/shot drills for other packs +5. Golden metric tests (blade height style) per sport pack +6. Optional: capture matrix in CI on PR (you already have the capture tool) + +--- + +## 6. Sport packs — what each still needs on top of the framework + +Once contracts exist, each sport is mostly **content + rules + AI policy**, not a new engine. + +### 6.1 Ice hockey (first pack — mostly present) +**Done enough:** skate loco, rink, stick, puck, checks, shootout, basic AI, goalie MVP. +**Pack backlog:** see ROADMAP (full 5v5 rules, goalie depth, positional AI, possession feel). + +### 6.2 Fighting / combat (highest reuse from Ludus + impact core) +- Strike / block / grab action layers +- Ring/cage arena +- Stamina and limb disable via `REGION` +- Existing fighting system port (ROADMAP already flags this) + +### 6.3 Soccer +- Run plant locomotion + ball foot/chest/head contacts +- Large pitch arena, goals, out/throw-in/corner rules +- No (or soft) limb impact; shoulder challenge profile +- AI: formations, offside line fact, keeper dive (shuffle + dive loco) + +### 6.4 Basketball +- Run + jump + plant, ball carry/dribble magnetism variant +- Court, hoop trigger, shot arc from release +- Screen / box-out contact (low severity impact profile) +- AI: pick-and-roll roles, spacing + +### 6.5 American football / rugby +- Run + explosive cut; tackle as grapple-to-limp pipeline +- Field, downs/ruck rules plugin +- Heavy impact profile; pile-up stability critical +- AI: playbooks as scripted formations + read trees + +### 6.6 Basketball-adjacent / volleyball / handball +- Shared run + jump + catch/throw object model +- Different arenas and score targets + +### 6.7 Non-goals for v1 framework +- Full vehicle sports, water sports, golf swing fidelity, licensed broadcast packages +- Complete mocap library (architecture must allow it; content is separate) + +--- + +## 7. Gap list — prioritized build order + +Phased so each phase ships a **usable contract** and keeps hockey working as the +proving pack (strangler pattern: extract behind interfaces, re-home hockey). + +### Phase 0 — Inventory & seams (days) +- [ ] Draw import graph: mark `shared/*` vs sport-coupled modules +- [ ] Freeze contracts in a short `docs/contracts.md` (or this file §5 as source) +- [ ] Prove Box3D determinism (ROADMAP) — gates multiplayer *and* harness replay +- [ ] Decide monorepo layout (`sports/hockey` vs stay single package with folders) + +### Phase 1 — Athlete + gear core (1–2 weeks) +- [ ] `createAthlete` + loadout list; skater/goalie become configs +- [ ] EquipmentDef schema; move stick/pads to data-ish modules +- [ ] Physique-driven sizing API documented +- [ ] Studio subject registry reads loadouts + +### Phase 2 — Locomotion interface (1–2 weeks) +- [ ] Extract `ILocomotion` from `skaterSim` +- [ ] Keep skate model as implementation #1 +- [ ] Stub `runPlant` model good enough for a mannequin jog (even ugly) +- [ ] Foot policy switch (glide vs plant) wired to animator + +### Phase 3 — Anim / IK platform (2–3 weeks) +- [ ] Shared IK solvers module +- [ ] Layer stack API (locomotion / action / reaction / getup) +- [ ] Reaction/stagger as layers driven by impact system +- [ ] Clip adapter interface (null implementation OK) + +### Phase 4 — Impact + object cores (2–3 weeks) +- [ ] Impact profiles + rules callbacks +- [ ] Generic play object + possession magnetism +- [ ] Tool release sampling +- [ ] Contested capture v1 + +### Phase 5 — Arena + match shell (1–2 weeks) +- [ ] ArenaDef; rink becomes hockey arena pack +- [ ] Phase machine + event log +- [ ] Score/clock/HUD adapters + +### Phase 6 — Brain shell (2+ weeks, ongoing content) +- [ ] Facts blackboard + role policy +- [ ] Formation helpers +- [ ] Keeper role using shared pieces +- [ ] Scenario harness for AI regressions + +### Phase 7 — Studio / training harness productize (parallel after Phase 1) +- [ ] Data-driven pose/view catalogs +- [ ] Reference overlay + capture matrix +- [ ] Metric probes + golden tests +- [ ] Drill factory (shooting, tackling, 1v1) +- [ ] Optional ref-image camera plate + +### Phase 8 — Second sport pack (proof) +Pick **one** of: +- **Fighting** (max reuse of impact + Ludus), or +- **Soccer prototype** (max pressure on run loco + ball object) + +Success criterion: second sport does **not** fork `skeleton`, `ragdoll`, `gearMesh`, +`hits` core, or studio capture — only pack content + profiles. + +--- + +## 8. What “done” looks like for the framework (not for a sport) + +You can claim a sports framework when **all** of these are true: + +1. **New athlete** = skeleton + bodyStyle + loadout + loco profile + anim profile +2. **New equipment piece** = sections/sockets (+ optional collider) + studio views +3. **New contact rule** = impact profile + rules callback, no hit-system fork +4. **New ball sport** = PlayObject + possession params + arena goals + AI facts +5. **New locomotion** = one module implementing `ILocomotion` + foot policy +6. **New sport AI** = roles writing intents/actions into the same seam as the pad +7. **Regression** = headless metrics + shot sheets vs reference photos for that pack +8. **Hockey still runs** as a pack on top of the same cores + +Not required for “framework done”: full NHL parity, licensing, netcode, franchise modes +(those are product; see ROADMAP). + +--- + +## 9. Risks & non-goals + +| Risk | Mitigation | +|------|------------| +| Over-abstract before second sport | Extract only when hockey + one other pack demand the seam | +| Run gait looks like skating forever | Foot plant policy is a hard gate for sport #2 | +| Proxy pile-ups | Dedicated multi-body scenarios before rugby/football | +| Mocap FOMO | Keep procedural + layer API; add clips when content exists | +| img2mesh becomes “AI will make the gear” | Refs guide humans/tools; runtime assets stay authored procedural/glTF | +| Determinism ignored | Prove early; harness replays depend on it | +| God-object `match.js` | Event log + rules plugin from the start of Phase 5 | + +**Non-goals:** replacing Unity/Unreal; full photogrammetry pipeline; every Olympic sport +in year one; network-complete framework before single-player packs feel good. + +--- + +## 10. Suggested near-term decisions + +1. **Keep hockey playable on main** while extracting — no big-bang rewrite. +2. **Name the framework** (e.g. still `tilt` core + `sports/hockey`, or promote Ludus-lineage + body stack to a shared package both Ludus and tilt consume). +3. **Second pack choice** determines Phase 2–4 emphasis: + - Fighting → impact, reaction layers, stamina + - Soccer → run plant, ball object, large arena +4. **Studio first among tools** — equipment and poses improve only as fast as the + ref-sheet loop; that loop is already the right shape. +5. **Do not wait for mocap** to generalize animation runtime; wait for mocap to *fill* it. + +--- + +## 11. One-page checklist — “left to expand” + +**Core extractions** +- [ ] Locomotion interface + run/plant model +- [ ] Foot / arm / look IK module +- [ ] Anim layer stack + clip adapter +- [ ] Athlete factory + data loadouts +- [ ] EquipmentDef / ToolDef schema +- [ ] Impact profiles + rules hooks +- [ ] PlayObject + generic possession +- [ ] ArenaDef + surfaces +- [ ] Brain blackboard + roles +- [ ] Match phase machine + events +- [ ] Action-map input + +**Harness / content pipeline** +- [ ] Sport-registered studio catalogs +- [ ] Reference photo overlay + capture matrix +- [ ] Metric probes + golden pose/contact tests +- [ ] Drill factory for training scenarios + +**Proof** +- [ ] Hockey re-homed as pack +- [ ] Second sport pack without core forks +- [ ] Determinism + replay of harness scenarios + +**Explicitly still sport content (never “framework only”)** +- Poses, gear shapes, AI tactics, rules edge cases, audio, broadcast, modes, licensing + +--- + +## 12. Bottom line + +You are closer than a greenfield sports engine because the **hard abstractions are +already proven in anger**: + +- intent-driven locomotion +- animator subordinate to physics +- limb-level contact from posed ragdolls +- limp ↔ driven handoff without teleports +- tool-in-hand dependency order +- possession as a dial +- pure sim + headless tests + reference shot sheets + +What remains is not “invent sports tech.” It is **peeling hockey off the type system**, +turning each proven spike into a **contract + pack content**, and making the +**studio/harness** the way every new sport earns visual and mechanical correctness +from reference photos and scenario metrics. + +Hockey ROADMAP remains the depth plan for pack #1. This document is the breadth plan +for the chassis underneath packs #1…N. diff --git a/index.html b/index.html index 5763556..04b2e2f 100644 --- a/index.html +++ b/index.html @@ -21,13 +21,76 @@ #hud { position:absolute; left:14px; top:12px; color:#8fb4d4; font-size:12px; line-height:1.5; white-space:pre; pointer-events:none; text-shadow:0 1px 2px #000; } #boot { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; - color:#6ea8dc; letter-spacing:5px; font-size:13px; background:#0a0e14; z-index:10; } + color:#6ea8dc; letter-spacing:5px; font-size:13px; background:#0a0e14; z-index:20; } + #menu { + position:absolute; inset:0; z-index:15; + display:flex; flex-direction:column; align-items:center; justify-content:center; + background:radial-gradient(ellipse at 50% 35%, #121a26 0%, #0a0e14 70%); + color:#c8dcea; pointer-events:auto; + } + #menu[hidden] { display:none; } + #menu .brand { + letter-spacing:0.55em; font-size:13px; color:#6ea8dc; margin:0 0 10px; + text-indent:0.55em; + } + #menu h1 { + margin:0 0 8px; font-size:42px; font-weight:600; letter-spacing:0.08em; + color:#e8f2fa; text-shadow:0 2px 24px rgba(80,140,200,0.35); + } + #menu .tag { + margin:0 0 36px; font-size:12px; color:#6a849c; letter-spacing:0.12em; + } + #menu .choices { + display:flex; flex-direction:column; gap:12px; width:min(360px, 86vw); + } + #menu button.choice { + appearance:none; border:1px solid #2a4058; background:#121c28; + color:#d4e6f5; font:inherit; font-size:15px; letter-spacing:0.06em; + padding:16px 20px; text-align:left; cursor:pointer; + border-radius:4px; transition:border-color .12s, background .12s, transform .08s; + } + #menu button.choice:hover, #menu button.choice:focus-visible { + outline:none; border-color:#5a9fd4; background:#172433; + } + #menu button.choice:active { transform:scale(0.99); } + #menu button.choice .label { + display:block; font-size:16px; color:#eef6fc; margin-bottom:4px; + } + #menu button.choice .hint { + display:block; font-size:11px; color:#6a849c; letter-spacing:0.04em; + } + #menu button.choice .key { + float:right; margin-top:2px; color:#5a9fd4; font-size:12px; + border:1px solid #2a5070; border-radius:3px; padding:2px 7px; + } + #menu .foot { + margin-top:28px; font-size:11px; color:#4a6074; letter-spacing:0.08em; + }
+
TILT…
+ diff --git a/package-lock.json b/package-lock.json index 76e7dd8..bd9cd3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,132 @@ }, "devDependencies": { "puppeteer-core": "^25.4.0", - "vite": "^8.1.5" + "vite": "^8.1.5", + "wrangler": "^4.118.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz", + "integrity": "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260730.1.tgz", + "integrity": "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz", + "integrity": "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz", + "integrity": "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz", + "integrity": "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" } }, "node_modules/@emnapi/core": { @@ -50,6 +175,1062 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", @@ -82,6 +1263,35 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, "node_modules/@puppeteer/browsers": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz", @@ -390,6 +1600,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.23.tgz", + "integrity": "sha512-iRoq6i6JDJP6Mt2A5JaPvzw0pgYHH6k92ij+yXiTrB7T2y9N789aWE3EHWj/5ztlJBokcCBja3iYLVdu5wgnkg==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -427,6 +1657,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, "node_modules/box3d.js": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/box3d.js/-/box3d.js-0.0.2.tgz", @@ -483,6 +1720,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -507,6 +1758,58 @@ "dev": true, "license": "MIT" }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -573,6 +1876,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -846,6 +2159,46 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/miniflare": { + "version": "5.20260730.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260730.0-alpha.tgz", + "integrity": "sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260730.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -882,6 +2235,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -983,6 +2350,64 @@ "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1026,6 +2451,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/three": { "version": "0.185.1", "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", @@ -1064,6 +2502,26 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -1149,6 +2607,63 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/workerd": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260730.1.tgz", + "integrity": "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260730.1", + "@cloudflare/workerd-darwin-arm64": "1.20260730.1", + "@cloudflare/workerd-linux-64": "1.20260730.1", + "@cloudflare/workerd-linux-arm64": "1.20260730.1", + "@cloudflare/workerd-windows-64": "1.20260730.1" + } + }, + "node_modules/wrangler": { + "version": "4.118.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.118.0.tgz", + "integrity": "sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260730.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260730.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260730.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -1245,6 +2760,31 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index e47f23d..c579aad 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,11 @@ "capture": "node tools/capture.mjs", "img2mesh": "node tools/img2mesh.mjs", "img2mesh:player": "node tools/img2mesh.mjs --subject player", - "img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie" + "img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie", + "deploy": "npm run build && wrangler deploy", + "deploy:dry": "npm run build && wrangler deploy --dry-run", + "pages:deploy": "npm run build && wrangler pages deploy dist --project-name=tilt", + "cf:whoami": "wrangler whoami" }, "dependencies": { "box3d.js": "^0.0.2", @@ -20,6 +24,7 @@ }, "devDependencies": { "puppeteer-core": "^25.4.0", - "vite": "^8.1.5" + "vite": "^8.1.5", + "wrangler": "^4.118.0" } } diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..e3c8021 --- /dev/null +++ b/public/_headers @@ -0,0 +1,25 @@ +# Cloudflare Pages / Workers static asset headers. +# Vite copies everything under public/ into dist/ on build. + +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: interest-cohort=() + +# Hashed Vite assets: long cache. WASM must load as application/wasm. +/assets/* + Cache-Control: public, max-age=31536000, immutable + +/*.wasm + Content-Type: application/wasm + Cache-Control: public, max-age=31536000, immutable + +# HTML entry points stay fresh so deploys pick up new asset hashes quickly. +/ + Cache-Control: public, max-age=0, must-revalidate + +/index.html + Cache-Control: public, max-age=0, must-revalidate + +/character.html + Cache-Control: public, max-age=0, must-revalidate diff --git a/shared/rink.js b/shared/rink.js index 886b77a..8c8ddee 100644 --- a/shared/rink.js +++ b/shared/rink.js @@ -32,6 +32,65 @@ export const MARKINGS = Object.freeze({ zoneDotX: 20.2, }); +/** + * All nine faceoff dots: centre, four neutral-zone, four end-zone. + * Order is stable so tests and HUD labels can index if they want. + */ +export const FACEOFF_DOTS = Object.freeze([ + Object.freeze({ id: 'centre', x: 0, z: 0 }), + Object.freeze({ id: 'nz-pp', x: MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }), + Object.freeze({ id: 'nz-pm', x: MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }), + Object.freeze({ id: 'nz-mp', x: -MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }), + Object.freeze({ id: 'nz-mm', x: -MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }), + Object.freeze({ id: 'ez-pp', x: MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }), + Object.freeze({ id: 'ez-pm', x: MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }), + Object.freeze({ id: 'ez-mp', x: -MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }), + Object.freeze({ id: 'ez-mm', x: -MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }), +]); + +/** Nearest faceoff dot to a world point — where a whistle drops the next draw. */ +export function nearestFaceoffDot(x, z) { + let best = FACEOFF_DOTS[0]; + let bestD = Infinity; + for (const d of FACEOFF_DOTS) { + const dd = (d.x - x) * (d.x - x) + (d.z - z) * (d.z - z); + if (dd < bestD) { + bestD = dd; + best = d; + } + } + return best; +} + +/** + * Is the puck still in play? + * + * Horizontal: must be on the ice surface (small inset so "on the boards" is + * still playable, but over the glass / past the outline is dead). + * Vertical: above the glass, under the slab, or impossibly high is unplayable. + */ +export function puckPlayable(x, y, z, radius = 0.0381) { + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { + return { ok: false, reason: 'nan' }; + } + // Far outside the barn entirely (escaped continuous collision). + if (Math.abs(x) > RINK.halfX + 4 || Math.abs(z) > RINK.halfZ + 4) { + return { ok: false, reason: 'escaped' }; + } + // Under the ice or stuck in the slab. + if (y < -0.15) return { ok: false, reason: 'under' }; + // Over the glass. Boards are ~1.07 m; glass is visual only above that. + if (y > RINK.boardHeight + RINK.glassHeight * 0.55) { + return { ok: false, reason: 'over' }; + } + // Centre past the board line — the puck has left the playing surface. + // Tiny slack so a rattle against the boards does not whistle every contact. + if (rinkPenetration(x, z, 0).dist > radius * 0.75) { + return { ok: false, reason: 'out' }; + } + return { ok: true, reason: '' }; +} + /** * Centre of the corner arc nearest (x, z), and the sign of the quadrant. * Points outside the straight sections belong to exactly one corner. diff --git a/src/character/goalieGear.js b/src/character/goalieGear.js index 2c8e75f..c3a6511 100644 --- a/src/character/goalieGear.js +++ b/src/character/goalieGear.js @@ -698,23 +698,20 @@ export function buildGoalieGear(mats, skelData, phys) { ); // Shoulder floaters — parented to the upper arms, aligned to the A-pose axis - // so they actually sit on the arm instead of hovering beside it. + // so they actually sit on the arm instead of hovering beside it. Keep these + // compact, like the skater's deltoid caps: the sleeve supplies the dressed + // silhouette, while a long rigid arm pad tears through the skinned cloth in + // butterfly and reach poses. function makeFloater(side) { const g = new THREE.Group(); g.name = `floater${side}`; const cap = loft([ - S(V(0, 0.05, 0.01), 0.068, 0.065, 3, PAL.base), - S(V(0, -0.02, 0.012), 0.084, 0.078, 4), - S(V(0, -0.08, 0.01), 0.079, 0.072, 4), - S(V(0, -0.145, 0.008), 0.068, 0.061, 3), + S(V(0, 0.04, 0.008), 0.058, 0.054, 3, PAL.base), + S(V(0, -0.025, 0.01), 0.068, 0.064, 4), + S(V(0, -0.09, 0.008), 0.062, 0.058, 4), + S(V(0, -0.14, 0.006), 0.05, 0.046, 3), ], { radial: 16, sub: 4 }); g.add(mesh(cap, mats.painted, `floater${side}Cap`)); - const arm = loft([ - S(V(0, -0.16, 0.006), 0.064, 0.059, 3, PAL.base), - S(V(0, -0.26, 0.004), 0.058, 0.053, 3), - S(V(0, -0.315, 0.002), 0.046, 0.042, 3, PAL.trim), - ], { radial: 14, sub: 4 }); - g.add(mesh(arm, mats.painted, `floater${side}Arm`)); alignTo(g, ARM_DIR[side]); pieces.push(g); return g; diff --git a/src/game/match.js b/src/game/match.js index f19ca43..a024cfa 100644 --- a/src/game/match.js +++ b/src/game/match.js @@ -1,7 +1,7 @@ import * as THREE from 'three'; import { createSkater } from '../character/skater.js'; import { createBrain, spawnLineup, steer } from '../../shared/ai.js'; -import { applyIntent, createSkaterState, stepSkater } from '../../shared/skaterSim.js'; +import { applyIntent, createSkaterState, stepSkater, SKATE } from '../../shared/skaterSim.js'; import { stickToWorld } from './input.js'; import { createHitResolver } from './hits.js'; import { PUCK, createPuck } from '../physics/puck.js'; @@ -9,6 +9,7 @@ import { NET, goalLineX } from '../../shared/net.js'; import { createPossession } from './possession.js'; import { makeRng } from '../core/rng.js'; import { clamp, wrapAngle } from '../../shared/scalar.js'; +import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js'; /** * The match loop. @@ -248,6 +249,41 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202 chaser: new Array(teams).fill(null), }; + /** + * One-way board hop: outside → ice only. Boards still block leaving. + * @returns {boolean} + */ + function reenterSkaters() { + let any = false; + for (let i = 0; i < count; i++) { + const s = states[i]; + // Limp bodies ride the ragdoll; get-up re-homes the proxy to the pelvis. + if (skaters[i].limp) continue; + const pen = rinkPenetration(s.x, s.z, SKATE.radius * 0.85); + if (pen.dist <= 0.02) continue; + any = true; + const inset = pen.dist + 0.08; + s.x += pen.nx * inset; + s.z += pen.nz * inset; + // Kill velocity going further out of the rink. + const outward = s.vx * -pen.nx + s.vz * -pen.nz; + if (outward > 0) { + s.vx += pen.nx * outward; + s.vz += pen.nz * outward; + } + // Hop inward so they clear the wall instead of grinding it. + s.vx += pen.nx * 1.2; + s.vz += pen.nz * 1.2; + const hopX = s.vx; + const hopZ = s.vz; + skaters[i].proxy?.teleport(s.x, s.z); + s.vx = hopX; + s.vz = hopZ; + skaters[i].proxy?.write(s); + } + return any; + } + /** @param {number} dt */ function update(dt) { const pp = puck.position(); @@ -373,6 +409,10 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202 puck.setVelocity(v.x * k, v.y * k, v.z * k); } + // Skaters who end up outside (over the boards, tunnel, get-up glitch) hop + // back onto the ice. One-way only: boards still block leaving the normal way. + reenterSkaters(); + // ---- 3. hits, knockdowns and getting up -------------------------------- hits.tick(dt); for (let i = 0; i < count; i++) { @@ -481,24 +521,66 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202 return states.filter((s) => s.team === index); }, - /** Drop everyone back on their spawn, momentum cleared. */ - reset() { - for (let i = 0; i < count; i++) { - const spawn = spawns[i]; - // Anyone lying on the ice has to be stood up before being placed, or - // their proxy stays disabled and they spawn as a corpse. - if (skaters[i].limp) skaters[i].getUp(states[i]); - Object.assign(states[i], { x: spawn.x, z: spawn.z, vx: 0, vz: 0, yaw: spawn.yaw }); - skaters[i].proxy?.teleport(spawn.x, spawn.z); - brains[i].target = null; + /** + * Drop everyone for a faceoff at a given dot (default: centre ice). + * Accepts a faceoff-dot object or raw `{x,z}`. + */ + faceoffAt(spot = FACEOFF_DOTS[0]) { + const fx = spot?.x ?? 0; + const fz = spot?.z ?? 0; + // Team 0 stands on the −X side of the puck, team 1 on +X — each faces in. + const byTeam = [[], []]; + for (let i = 0; i < count; i++) byTeam[states[i].team]?.push(i); + + for (let t = 0; t < teams; t++) { + const side = t === 0 ? -1 : 1; + const ids = byTeam[t] ?? []; + for (let k = 0; k < ids.length; k++) { + const i = ids[k]; + // First skater is the draw; the rest fan back and wide. + const along = k === 0 ? 1.05 : 2.4 + (k - 1) * 1.1; + const lateral = k === 0 ? 0 : ((k % 2 === 1 ? 1 : -1) * (0.9 + Math.floor((k - 1) / 2) * 1.2)); + let x = fx + side * along; + let z = fz + lateral; + // Keep the lineup on the ice if the dot is near the boards. + const pen = rinkPenetration(x, z, SKATE.radius + 0.15); + if (pen.dist > 0) { + x += pen.nx * (pen.dist + 0.05); + z += pen.nz * (pen.dist + 0.05); + } + if (skaters[i].limp) skaters[i].getUp(states[i]); + Object.assign(states[i], { + x, z, vx: 0, vz: 0, + yaw: side > 0 ? -Math.PI / 2 : Math.PI / 2, + ix: 0, iz: 0, sprint: false, brake: false, + }); + skaters[i].proxy?.teleport(x, z); + brains[i].target = null; + } } + recentHits.length = 0; recentPlays.length = 0; - // Faceoff: puck at centre ice, dead. possession.reset(); - puck.place(0, 0.05, 0); + puck.place(fx, PUCK.thickness / 2 + 0.01, fz); }, + /** Centre-ice faceoff — the default restart. */ + reset() { + this.faceoffAt(FACEOFF_DOTS[0]); + }, + + reenterSkaters, + + /** True when a skater is clearly outside the playing surface. */ + skaterOutOfBounds(i) { + const s = states[i]; + if (!s) return false; + return !insideRink(s.x, s.z, -0.15); + }, + + nearestFaceoffDot, + destroy() { hits.destroy(); puck.destroy(); diff --git a/src/game/scrimmage.js b/src/game/scrimmage.js new file mode 100644 index 0000000..cda6fed --- /dev/null +++ b/src/game/scrimmage.js @@ -0,0 +1,188 @@ +import * as THREE from 'three'; +import { createGoalie } from '../character/goalie.js'; +import { buildNetMesh, createNet } from '../physics/net.js'; +import { isGoal } from '../../shared/net.js'; +import { FACEOFF_DOTS, nearestFaceoffDot, puckPlayable } from '../../shared/rink.js'; +import { PUCK } from '../physics/puck.js'; + +/** + * 3-on-3 with nets and goalies. + * + * Continuous play: goals, goalie covers, and dead pucks (out of bounds / + * unplayable) all whistle and restart at a faceoff. Goals restart at centre; + * OOB restarts at the nearest faceoff circle. + */ + +export const SCRIMMAGE = { + /** Hold the scoreboard message before the faceoff, seconds. */ + resultTime: 2.0, + /** + * Goalie covers the puck and freezes play when it is this slow, m/s. + * Matches the shootout idea: a sealed catch ends the rush. + */ + coverSpeed: 2.8, + /** + * How long a skater can sit clearly outside the boards before we whistle + * (they normally hop back in on their own). + */ + skaterOobGrace: 0.55, +}; + +/** + * @param {{ scene: import('three').Scene, physics: object, match: object }} opts + */ +export function createScrimmage({ scene, physics, match }) { + const { puck, possession, states, skaters } = match; + + const nets = [createNet(physics, 1), createNet(physics, -1)]; + const netMeshes = [buildNetMesh(scene, 1), buildNetMesh(scene, -1)]; + // end +1 (+X) is defended by team 1; end −1 by team 0. + const goalies = { + 1: createGoalie(physics, scene, { end: 1, index: 40, team: 1 }), + '-1': createGoalie(physics, scene, { end: -1, index: 41, team: 0 }), + }; + + const state = { + /** 'live' | 'goal' | 'cover' | 'oob' | 'skater_oob' */ + phase: 'live', + score: [0, 0], + /** Last stoppage, for the HUD. */ + last: null, + clock: 0, + /** Where the next faceoff drops after this stoppage. */ + nextFaceoff: FACEOFF_DOTS[0], + }; + + /** Per-skater time spent outside the ice. */ + const oobAge = new Float64Array(states.length); + + const _puckPos = new THREE.Vector3(); + + function faceoff() { + const spot = state.nextFaceoff ?? FACEOFF_DOTS[0]; + match.faceoffAt(spot); + goalies[1].reset(); + goalies[-1].reset(); + state.phase = 'live'; + state.clock = 0; + oobAge.fill(0); + } + + /** + * @param {'goal'|'cover'|'oob'|'skater_oob'} kind + * @param {string} detail + * @param {{ x: number, z: number } | null} faceoffSpot + */ + function stoppage(kind, detail = '', faceoffSpot = null) { + let team = null; + if (kind === 'goal') { + // Goal at +X end is team 0; at −X is team 1. + team = detail === '+x' ? 0 : 1; + state.score[team]++; + state.nextFaceoff = FACEOFF_DOTS[0]; + } else if (faceoffSpot) { + state.nextFaceoff = nearestFaceoffDot(faceoffSpot.x, faceoffSpot.z); + } else { + state.nextFaceoff = FACEOFF_DOTS[0]; + } + + state.phase = kind; + state.clock = SCRIMMAGE.resultTime; + state.last = { + kind, + detail, + team, + score: [...state.score], + faceoff: state.nextFaceoff, + }; + // Kill puck motion so it does not rattle around during the hold. + puck.setVelocity(0, 0, 0); + possession.reset(); + } + + function update(dt) { + _puckPos.copy(puck.position()); + + goalies[1].update(dt, _puckPos); + goalies[-1].update(dt, _puckPos); + + if (state.phase !== 'live') { + state.clock -= dt; + if (state.clock <= 0) faceoff(); + return; + } + + // Goals first — a covered puck that also crossed still counts as a goal. + if (isGoal(_puckPos, 1, PUCK.radius)) { + stoppage('goal', '+x'); + return; + } + if (isGoal(_puckPos, -1, PUCK.radius)) { + stoppage('goal', '-x'); + return; + } + + // Dead puck: out of bounds or unplayable → whistle, nearest circle. + const play = puckPlayable(_puckPos.x, _puckPos.y, _puckPos.z, PUCK.radius); + if (!play.ok) { + stoppage('oob', play.reason, { x: _puckPos.x, z: _puckPos.z }); + return; + } + + // Either goalie freezes a slow puck in the body — whistle, faceoff. + const speed = puck.speed(); + if (speed < SCRIMMAGE.coverSpeed) { + if (goalies[1].covers(_puckPos)) { + stoppage('cover', 'away goalie', { x: _puckPos.x, z: _puckPos.z }); + return; + } + if (goalies[-1].covers(_puckPos)) { + stoppage('cover', 'home goalie', { x: _puckPos.x, z: _puckPos.z }); + return; + } + } + + // Skaters hop back over the boards themselves (match.reenterSkaters). If + // someone is still clearly outside after a short grace — wrong side of the + // glass and stuck — whistle and draw at the nearest circle. + for (let i = 0; i < states.length; i++) { + if (skaters[i].limp) { + oobAge[i] = 0; + continue; + } + if (match.skaterOutOfBounds(i)) { + oobAge[i] += dt; + if (oobAge[i] >= SCRIMMAGE.skaterOobGrace) { + stoppage('skater_oob', states[i].name, { x: states[i].x, z: states[i].z }); + return; + } + } else { + oobAge[i] = 0; + } + } + } + + return { + state, + goalies, + nets, + netMeshes, + update, + faceoff, + + /** Full reset of score + ice (centre faceoff). */ + reset() { + state.score = [0, 0]; + state.last = null; + state.nextFaceoff = FACEOFF_DOTS[0]; + faceoff(); + }, + + destroy() { + for (const n of nets) n.destroy(); + for (const m of netMeshes) scene.remove(m); + goalies[1].destroy(); + goalies[-1].destroy(); + }, + }; +} diff --git a/src/main.js b/src/main.js index ae23670..ac9a60f 100644 --- a/src/main.js +++ b/src/main.js @@ -7,18 +7,24 @@ import { createMatch } from './game/match.js'; import { createInput } from './game/input.js'; import { describeHit } from './game/hits.js'; import { createShootout } from './game/shootout.js'; +import { createScrimmage } from './game/scrimmage.js'; import { RINK } from '../shared/rink.js'; /** - * Spike 1 boot: three AI skaters on a rink. + * Shell: renderer, lights, main menu, and the active mode loop. * - * Everything gameplay-shaped lives in `game/match.js`; this file is the shell — - * renderer, lights, resize, the frame loop and a small debug HUD. + * Modes: + * 1v1 — shootout (one shooter vs goalie, alternating teams) + * 3v3 — six skaters + nets + goalies, open play with scoring + * + * Everything gameplay-shaped lives in `game/`; this file wires the shell and + * tears modes down cleanly so Esc can return to the menu. */ const canvas = document.getElementById('stage'); const boot = document.getElementById('boot'); const hud = document.getElementById('hud'); +const menuEl = document.getElementById('menu'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); renderer.shadowMap.enabled = true; @@ -82,62 +88,221 @@ resize(); const stats = { fps: 0, steps: 0, top: 0 }; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); -async function boot3() { +/** @typedef {'1v1' | '3v3'} GameMode */ + +const RUMBLE = { + knockdown: [1.0, 0.7, 260], + stagger: [0.55, 0.35, 150], + bump: [0.22, 0.12, 70], +}; + +function parseModeFromUrl() { + const raw = new URLSearchParams(location.search).get('mode'); + if (raw === '1v1' || raw === '1on1' || raw === 'shootout') return '1v1'; + if (raw === '3v3' || raw === '3on3' || raw === 'scrimmage') return '3v3'; + return null; +} + +async function bootApp() { await initPhysics(); const physics = createPhysicsWorld(); buildRink(scene); - const match = createMatch({ scene, physics, perTeam: 3, teams: 2 }); - const puckView = buildPuckMesh(scene, PUCK); - - // The shootout owns the nets and the goalies, and drives its own kinematic - // bodies inside the physics substep. - const shootout = createShootout({ scene, physics, match }); - match.addSubstepSync((fixedDt) => { - shootout.goalies[1].syncPhysics(fixedDt); - shootout.goalies[-1].syncPhysics(fixedDt); - }); - shootout.reset(); const input = createInput(window); - // One live input object, refreshed each frame and read by the match. const stick = input.state; - // Rumble on contact the player is part of. Strength tracks the outcome, so - // the pad tells you whether you laid someone out or just brushed them, and - // taking one buzzes harder than giving one. - const RUMBLE = { - knockdown: [1.0, 0.7, 260], - stagger: [0.55, 0.35, 150], - bump: [0.22, 0.12, 70], - }; + /** @type {GameMode | null} */ + let mode = null; + /** @type {ReturnType | null} */ + let match = null; + /** @type {ReturnType | null} */ + let shootout = null; + /** @type {ReturnType | null} */ + let scrimmage = null; + /** @type {ReturnType | null} */ + let puckView = null; + /** @type {(() => void) | null} */ + let removeSubstepSync = null; + + let playerDriving = false; let lastHitSeen = -1; + function clearMode() { + if (removeSubstepSync) { + removeSubstepSync(); + removeSubstepSync = null; + } + if (shootout) { + shootout.destroy(); + shootout = null; + } + if (scrimmage) { + scrimmage.destroy(); + scrimmage = null; + } + if (puckView) { + // Ring is parented under the mesh. + scene.remove(puckView.mesh); + puckView.mesh.geometry?.dispose?.(); + puckView.mesh.material?.dispose?.(); + puckView.ring.geometry?.dispose?.(); + puckView.ring.material?.dispose?.(); + puckView = null; + } + if (match) { + match.destroy(); + match = null; + } + mode = null; + playerDriving = false; + lastHitSeen = -1; + hud.textContent = ''; + Object.assign(cam.state, { + mode: 'broadcast', + followIndex: 0, + distance: 34, + pitch: 0.62, + }); + } + /** * Take control of a skater, or give them back. * - * Taking control snaps the camera onto whoever you just grabbed — driving a - * skater you cannot see is the kind of thing that reads as a broken build. - */ - let playerShooting = false; - /** - * Take the shooter, or hand them back. In a shootout there is only one - * skater worth driving, and which one it is changes every attempt — so - * control follows the shooter rather than being pinned to an index. + * 1v1: control follows whoever is shooting this attempt. + * 3v3: control follows the camera's follow target (or Home 1). */ function toggleControl() { - playerShooting = !playerShooting; - shootout.setShooterControl(playerShooting ? stick : null); - if (playerShooting) { - cam.state.mode = 'follow'; - cam.state.followIndex = shootout.state.shooter; - cam.state.distance = 9; - cam.state.pitch = 0.3; + if (!match) return; + playerDriving = !playerDriving; + + if (mode === '1v1' && shootout) { + shootout.setShooterControl(playerDriving ? stick : null); + if (playerDriving) { + cam.state.mode = 'follow'; + cam.state.followIndex = shootout.state.shooter; + cam.state.distance = 9; + cam.state.pitch = 0.3; + } + return; } + + // 3v3 scrimmage + if (!playerDriving) { + if (match.playerIndex !== null) match.setControl(match.playerIndex, null); + return; + } + const idx = cam.state.mode === 'follow' + ? cam.state.followIndex + : 0; + match.setControl(idx, stick); + cam.state.mode = 'follow'; + cam.state.followIndex = idx; + cam.state.distance = 9; + cam.state.pitch = 0.3; } + function showMenu() { + clearMode(); + menuEl.hidden = false; + menuEl.querySelector('button.choice')?.focus(); + } + + /** + * Start a mode. Safe to call again — tears down whatever was running. + * @param {GameMode} next + */ + function startMode(next) { + if (next !== '1v1' && next !== '3v3') { + throw new Error(`unknown mode: ${next}`); + } + clearMode(); + menuEl.hidden = true; + mode = next; + + match = createMatch({ scene, physics, perTeam: 3, teams: 2 }); + puckView = buildPuckMesh(scene, PUCK); + + if (next === '1v1') { + // The shootout owns the nets and the goalies, and drives its own + // kinematic bodies inside the physics substep. + shootout = createShootout({ scene, physics, match }); + removeSubstepSync = match.addSubstepSync((fixedDt) => { + shootout.goalies[1].syncPhysics(fixedDt); + shootout.goalies[-1].syncPhysics(fixedDt); + }); + shootout.reset(); + playerDriving = false; + } else { + // Full ice: nets + goalies at both ends, continuous play with scoring. + scrimmage = createScrimmage({ scene, physics, match }); + removeSubstepSync = match.addSubstepSync((fixedDt) => { + scrimmage.goalies[1].syncPhysics(fixedDt); + scrimmage.goalies[-1].syncPhysics(fixedDt); + }); + scrimmage.reset(); + // Scrimmage opens under AI; press P to jump in. + playerDriving = false; + } + + Object.assign(cam.state, { + mode: 'broadcast', + distance: next === '1v1' ? 34 : 40, + pitch: next === '1v1' ? 0.62 : 0.72, + followIndex: 0, + }); + } + + // ---- menu wiring -------------------------------------------------------- + menuEl.addEventListener('click', (e) => { + const btn = e.target.closest('button.choice'); + if (!btn) return; + startMode(/** @type {GameMode} */ (btn.dataset.mode)); + }); + window.addEventListener('keydown', (e) => { - if (e.key === 'c' || e.key === 'C') cam.cycleMode(match.skaters.length); - if (e.key === 'r' || e.key === 'R') shootout.reset(); + // Menu: 1 / 2 / Enter on focused choice. + if (!menuEl.hidden) { + if (e.key === '1') { + e.preventDefault(); + startMode('1v1'); + } else if (e.key === '2') { + e.preventDefault(); + startMode('3v3'); + } else if (e.key === 'Enter') { + const active = document.activeElement; + if (active?.dataset?.mode) { + e.preventDefault(); + startMode(/** @type {GameMode} */ (active.dataset.mode)); + } + } + return; + } + + // In-game. + if (e.key === 'Escape') { + e.preventDefault(); + showMenu(); + return; + } + if (!match) return; + + if (e.key === 'c' || e.key === 'C') { + cam.cycleMode(match.skaters.length); + // If the player is driving in 3v3, keep control on whoever the camera + // is following so C can swap bodies. + if (mode === '3v3' && playerDriving && cam.state.mode === 'follow') { + const prev = match.playerIndex; + if (prev !== null && prev !== cam.state.followIndex) { + match.setControl(prev, null); + match.setControl(cam.state.followIndex, stick); + } + } + } + if (e.key === 'r' || e.key === 'R') { + if (mode === '1v1' && shootout) shootout.reset(); + else if (mode === '3v3' && scrimmage) scrimmage.reset(); + else match.reset(); + } if (e.key === 'p' || e.key === 'P' || e.code === 'Tab') { e.preventDefault(); toggleControl(); @@ -153,10 +318,29 @@ async function boot3() { // Debug handle. The capture tool drives the camera through this to frame // repeatable shots, and it is the fastest way to poke at a skater from the // console while tuning. - window.tilt = { match, shootout, cam, physics, scene, renderer, stats, input, toggleControl }; + window.tilt = { + get match() { return match; }, + get shootout() { return shootout; }, + get scrimmage() { return scrimmage; }, + get mode() { return mode; }, + cam, + physics, + scene, + renderer, + stats, + input, + startMode, + showMenu, + toggleControl, + }; boot.remove(); + // Deep link / capture: ?mode=1v1|3v3 skips the menu. + const auto = parseModeFromUrl(); + if (auto) startMode(auto); + else showMenu(); + let last = performance.now(); let fpsAccum = 0; let fpsFrames = 0; @@ -167,61 +351,70 @@ async function boot3() { const dt = Math.min(0.05, (now - last) / 1000); last = now; - // The camera yaw rides along with the stick so the match can turn a - // screen-space push into a world direction. Sampled before the update so - // input and simulation are one frame consistent. input.read(dt); stick.cameraYaw = cam.state.yaw; - match.update(dt); - shootout.update(dt); - // Follow whoever is shooting, so the camera never has to be told. - if (cam.state.mode === 'follow') cam.state.followIndex = shootout.state.shooter; + if (match) { + match.update(dt); + if (shootout) shootout.update(dt); + if (scrimmage) scrimmage.update(dt); - // Haptics for anything the player was part of. - const newest = match.recentHits[0]; - if (newest && newest.at !== lastHitSeen) { - lastHitSeen = newest.at; - const me = match.playerIndex; - if (me !== null && (newest.attacker === me || newest.victim === me)) { - const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump; - // Taking a hit shakes harder than landing one. - const k = newest.victim === me ? 1 : 0.7; - input.rumble(strong * k, weak * k, ms); + // Follow whoever is shooting, so the camera never has to be told. + if (mode === '1v1' && shootout && cam.state.mode === 'follow') { + cam.state.followIndex = shootout.state.shooter; } + + // Haptics for anything the player was part of. + const newest = match.recentHits[0]; + if (newest && newest.at !== lastHitSeen) { + lastHitSeen = newest.at; + const me = match.playerIndex; + if (me !== null && (newest.attacker === me || newest.victim === me)) { + const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump; + // Taking a hit shakes harder than landing one. + const k = newest.victim === me ? 1 : 0.7; + input.rumble(strong * k, weak * k, ms); + } + } + + if (puckView) { + puckView.mesh.position.copy(match.puck.position()); + puckView.mesh.quaternion.copy(match.puck.rotation()); + puckView.ring.visible = match.possession.loose; + } + + cam.update(dt, match.states); + + fpsAccum += dt; + fpsFrames++; + if (fpsAccum >= 0.5) { + stats.fps = Math.round(fpsFrames / fpsAccum); + stats.steps = physics.stepCount; + stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0); + fpsAccum = 0; + fpsFrames = 0; + } + drawHud(); + } else { + // Idle rink under the menu — slow orbit so the ice is not a still photo. + cam.state.yaw += dt * 0.08; + cam.update(dt, []); + hud.textContent = ''; } - puckView.mesh.position.copy(match.puck.position()); - puckView.mesh.quaternion.copy(match.puck.rotation()); - puckView.ring.visible = match.possession.loose; - - cam.update(dt, match.states); renderer.render(scene, cam.camera); - - fpsAccum += dt; - fpsFrames++; - if (fpsAccum >= 0.5) { - stats.fps = Math.round(fpsFrames / fpsAccum); - stats.steps = physics.stepCount; - stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0); - fpsAccum = 0; - fpsFrames = 0; - } - // Drawn every frame, not on the half-second tick: the hustle and shot - // meters are feedback, and feedback at 2 Hz is worse than none. - drawHud(); - requestAnimationFrame(frame); } const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯'); function drawHud() { + if (!match) return; + const watching = cam.state.mode === 'follow' ? match.states[cam.state.followIndex]?.name ?? 'broadcast' : 'broadcast'; const player = match.playerIndex !== null ? match.states[match.playerIndex] : null; - const down = match.skaters.filter((s) => s.limp).length; const feed = match.recentHits .filter((h) => h.outcome !== 'bump') .slice(0, 3) @@ -232,44 +425,78 @@ async function boot3() { ? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}` : 'pad: none — keyboard'; - const so = shootout.state; - const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY'); - const scoreLine = `${teamName(0)} ${so.score[0]} — ${so.score[1]} ${teamName(1)}` - + ` round ${so.round}`; - const phaseLine = so.phase === 'ready' - ? `${teamName(so.shootingTeam)} to shoot…` - : so.phase === 'result' - ? (so.last?.result === 'goal' - ? `GOAL — ${teamName(so.last.team)}` - : `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`) - : `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`; - const carrier = match.possession.carrier; const puckLine = carrier === null ? `puck: loose ${match.puck.speed().toFixed(1)} m/s` : `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`; const mag = match.possession.tuning.magnetism; - hud.textContent = `${scoreLine}` + const controlsHint = player + ? (input.connected + ? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke' + : '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke') + + `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}` + : ''; + + if (mode === '1v1' && shootout) { + const so = shootout.state; + const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY'); + const scoreLine = `${teamName(0)} ${so.score[0]} — ${so.score[1]} ${teamName(1)}` + + ` round ${so.round}`; + const phaseLine = so.phase === 'ready' + ? `${teamName(so.shootingTeam)} to shoot…` + : so.phase === 'result' + ? (so.last?.result === 'goal' + ? `GOAL — ${teamName(so.last.team)}` + : `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`) + : `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`; + + hud.textContent = `1-on-1 shootout` + + `\n${scoreLine}` + + `\n${phaseLine}` + + `\n` + + `\n${stats.fps} fps · ${puckLine}` + + `\n${pad}` + + `\n[P] ${playerDriving ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart [Esc] menu` + + `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune` + + controlsHint + + (feed ? `\n\nhits:\n${feed}` : ''); + return; + } + + // 3v3 with nets and goalies + const sc = scrimmage?.state; + const scoreLine = sc + ? `HOME ${sc.score[0]} — ${sc.score[1]} AWAY` + : 'HOME 0 — 0 AWAY'; + let phaseLine = 'live'; + if (sc?.phase === 'goal') { + const who = sc.last?.team === 0 ? 'HOME' : 'AWAY'; + phaseLine = `GOAL — ${who}`; + } else if (sc?.phase === 'cover') { + phaseLine = `covered (${sc.last?.detail ?? 'goalie'})`; + } else if (sc?.phase === 'oob') { + phaseLine = `whistle — puck ${sc.last?.detail || 'out'} · faceoff ${sc.last?.faceoff?.id ?? ''}`; + } else if (sc?.phase === 'skater_oob') { + phaseLine = `whistle — ${sc.last?.detail || 'skater'} over boards · faceoff ${sc.last?.faceoff?.id ?? ''}`; + } + + hud.textContent = `3-on-3 · cam ${watching}` + + `\n${scoreLine}` + `\n${phaseLine}` + `\n` + `\n${stats.fps} fps · ${puckLine}` + `\n${pad}` - + `\n[P] ${playerShooting ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart` + + `\n[P] ${playerDriving ? 'hand back to AI' : 'take control'} [C] camera [R] faceoff [Esc] menu` + `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune` - + (player - ? (input.connected - ? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke' - : '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke') - + `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}` - : '') + + controlsHint + (feed ? `\n\nhits:\n${feed}` : ''); } requestAnimationFrame(frame); } -boot3().catch((err) => { +bootApp().catch((err) => { console.error(err); boot.textContent = 'FAILED TO START — ' + (err?.message ?? err); }); diff --git a/test/rink.mjs b/test/rink.mjs index 84de54d..c10197e 100644 --- a/test/rink.mjs +++ b/test/rink.mjs @@ -1,4 +1,8 @@ -import { RINK, clampToRink, insideRink, randomIcePoint, rinkOutline, rinkPenetration } from '../shared/rink.js'; +import { + FACEOFF_DOTS, MARKINGS, RINK, + clampToRink, insideRink, nearestFaceoffDot, puckPlayable, + randomIcePoint, rinkOutline, rinkPenetration, +} from '../shared/rink.js'; import { done, near, ok, section } from './harness.mjs'; section('penetration on the straights'); @@ -74,4 +78,25 @@ section('random points land on the ice'); } } +section('faceoff dots'); +{ + ok(FACEOFF_DOTS.length === 9, 'nine faceoff dots'); + ok(nearestFaceoffDot(0, 0).id === 'centre', 'centre ice → centre dot'); + ok(nearestFaceoffDot(MARKINGS.zoneDotX, MARKINGS.faceoffDotZ).id === 'ez-pp', 'end-zone corner maps to itself'); + const nearSide = nearestFaceoffDot(RINK.halfX + 2, MARKINGS.faceoffDotZ * 0.9); + ok(nearSide.id.startsWith('ez-p'), `puck past +X boards nearest end-zone (+ got ${nearSide.id})`); + const mid = nearestFaceoffDot(1, MARKINGS.faceoffDotZ); + ok(mid.id === 'nz-pp' || mid.id === 'centre', `near neutral +Z maps sensibly (${mid.id})`); +} + +section('puck playable'); +{ + ok(puckPlayable(0, 0.02, 0).ok, 'centre ice is playable'); + ok(!puckPlayable(RINK.halfX + 1, 0.02, 0).ok, 'past the end boards is dead'); + ok(!puckPlayable(0, 3.5, 0).ok, 'over the glass is dead'); + ok(!puckPlayable(0, -0.5, 0).ok, 'under the ice is dead'); + ok(puckPlayable(0, 0.5, 0).ok, 'a lifted puck still on the ice plane is live'); +} + done('rink'); + diff --git a/tools/capture.mjs b/tools/capture.mjs index 904c1fb..687c07e 100644 --- a/tools/capture.mjs +++ b/tools/capture.mjs @@ -83,10 +83,15 @@ try { }); page.on('pageerror', (err) => errors.push(String(err?.stack ?? err))); - await page.goto(URL, { waitUntil: 'domcontentloaded' }); + // 3v3 scrimmage is what the lineup / skating shots need; skip the menu. + const captureUrl = new URL(URL); + if (!captureUrl.searchParams.has('mode')) captureUrl.searchParams.set('mode', '3v3'); + await page.goto(captureUrl.href, { waitUntil: 'domcontentloaded' }); // The boot overlay is removed once physics is up and the first frame ran. await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 }); + // Mode auto-starts from ?mode=; wait until the match handle is live. + await page.waitForFunction(() => window.tilt?.match, { timeout: 15000 }); // The canvas must fill the window exactly, at whatever pixel ratio. Checked // at two window sizes so a resize path that only works on first load fails diff --git a/vite.config.js b/vite.config.js index b6fd93e..87df60c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,12 +1,20 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vite'; export default defineConfig({ server: { port: 5174, open: true }, + // Multi-page: main game + character studio both ship as real HTML entries. build: { target: 'es2022', // The body/skin generators are one deterministic chunk; splitting them buys // nothing and costs a round trip before anything can render. chunkSizeWarningLimit: 2500, + rollupOptions: { + input: { + main: resolve(import.meta.dirname, 'index.html'), + character: resolve(import.meta.dirname, 'character.html'), + }, + }, }, // box3d.js ships an Emscripten bundle that resolves its .wasm via // import.meta.url. Pre-bundling rewrites that URL and breaks the lookup, so diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..cb03c20 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + // tilt — static front-end on Workers (assets only, no server Worker code). + // Build first: `npm run build` → uploads `dist/`. + "name": "tilt", + "compatibility_date": "2026-08-03", + "assets": { + "directory": "./dist", + // Multi-page site (index.html + character.html), not a client-side router SPA. + // Missing paths return 404 rather than rewriting everything to index.html. + "not_found_handling": "404-page", + "html_handling": "auto-trailing-slash" + }, + "observability": { + "enabled": true + } +}