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.
This commit is contained in:
@@ -2,3 +2,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
shots/
|
shots/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
.dev.vars.*
|
||||||
|
!.dev.vars.example
|
||||||
|
|||||||
@@ -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.<your-subdomain>.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 |
|
||||||
@@ -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.
|
||||||
@@ -24,8 +24,11 @@ npm install
|
|||||||
npm run dev # http://localhost:5174
|
npm run dev # http://localhost:5174
|
||||||
npm test # headless: sim, rink, AI, pose, input, physics, hits
|
npm test # headless: sim, rink, AI, pose, input, physics, hits
|
||||||
npm run capture # boots the app headless and screenshots it into shots/
|
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:
|
In the browser:
|
||||||
|
|
||||||
| Xbox | keyboard | |
|
| Xbox | keyboard | |
|
||||||
|
|||||||
@@ -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/<sport>/<subject>/<pose>_<view>.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.
|
||||||
+64
-1
@@ -21,13 +21,76 @@
|
|||||||
#hud { position:absolute; left:14px; top:12px; color:#8fb4d4; font-size:12px;
|
#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; }
|
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;
|
#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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="stage"></canvas>
|
<canvas id="stage"></canvas>
|
||||||
<div id="hud"></div>
|
<div id="hud"></div>
|
||||||
|
<div id="menu" hidden>
|
||||||
|
<div class="brand">TILT</div>
|
||||||
|
<h1>HOCKEY</h1>
|
||||||
|
<p class="tag">pick a mode</p>
|
||||||
|
<div class="choices">
|
||||||
|
<button type="button" class="choice" data-mode="1v1" id="mode-1v1">
|
||||||
|
<span class="key">1</span>
|
||||||
|
<span class="label">1-on-1</span>
|
||||||
|
<span class="hint">Shootout — one shooter, one goalie, alternating ends</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="choice" data-mode="3v3" id="mode-3v3">
|
||||||
|
<span class="key">2</span>
|
||||||
|
<span class="label">3-on-3</span>
|
||||||
|
<span class="hint">Full ice — nets, goalies, hits, pass, shoot for a score</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="foot">Esc returns here · pad or keyboard once you’re in</p>
|
||||||
|
</div>
|
||||||
<div id="boot">TILT…</div>
|
<div id="boot">TILT…</div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Generated
+1541
-1
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -12,7 +12,11 @@
|
|||||||
"capture": "node tools/capture.mjs",
|
"capture": "node tools/capture.mjs",
|
||||||
"img2mesh": "node tools/img2mesh.mjs",
|
"img2mesh": "node tools/img2mesh.mjs",
|
||||||
"img2mesh:player": "node tools/img2mesh.mjs --subject player",
|
"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": {
|
"dependencies": {
|
||||||
"box3d.js": "^0.0.2",
|
"box3d.js": "^0.0.2",
|
||||||
@@ -20,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"puppeteer-core": "^25.4.0",
|
"puppeteer-core": "^25.4.0",
|
||||||
"vite": "^8.1.5"
|
"vite": "^8.1.5",
|
||||||
|
"wrangler": "^4.118.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -32,6 +32,65 @@ export const MARKINGS = Object.freeze({
|
|||||||
zoneDotX: 20.2,
|
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.
|
* Centre of the corner arc nearest (x, z), and the sign of the quadrant.
|
||||||
* Points outside the straight sections belong to exactly one corner.
|
* Points outside the straight sections belong to exactly one corner.
|
||||||
|
|||||||
@@ -698,23 +698,20 @@ export function buildGoalieGear(mats, skelData, phys) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Shoulder floaters — parented to the upper arms, aligned to the A-pose axis
|
// 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) {
|
function makeFloater(side) {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
g.name = `floater${side}`;
|
g.name = `floater${side}`;
|
||||||
const cap = loft([
|
const cap = loft([
|
||||||
S(V(0, 0.05, 0.01), 0.068, 0.065, 3, PAL.base),
|
S(V(0, 0.04, 0.008), 0.058, 0.054, 3, PAL.base),
|
||||||
S(V(0, -0.02, 0.012), 0.084, 0.078, 4),
|
S(V(0, -0.025, 0.01), 0.068, 0.064, 4),
|
||||||
S(V(0, -0.08, 0.01), 0.079, 0.072, 4),
|
S(V(0, -0.09, 0.008), 0.062, 0.058, 4),
|
||||||
S(V(0, -0.145, 0.008), 0.068, 0.061, 3),
|
S(V(0, -0.14, 0.006), 0.05, 0.046, 3),
|
||||||
], { radial: 16, sub: 4 });
|
], { radial: 16, sub: 4 });
|
||||||
g.add(mesh(cap, mats.painted, `floater${side}Cap`));
|
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]);
|
alignTo(g, ARM_DIR[side]);
|
||||||
pieces.push(g);
|
pieces.push(g);
|
||||||
return g;
|
return g;
|
||||||
|
|||||||
+95
-13
@@ -1,7 +1,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { createSkater } from '../character/skater.js';
|
import { createSkater } from '../character/skater.js';
|
||||||
import { createBrain, spawnLineup, steer } from '../../shared/ai.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 { stickToWorld } from './input.js';
|
||||||
import { createHitResolver } from './hits.js';
|
import { createHitResolver } from './hits.js';
|
||||||
import { PUCK, createPuck } from '../physics/puck.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 { createPossession } from './possession.js';
|
||||||
import { makeRng } from '../core/rng.js';
|
import { makeRng } from '../core/rng.js';
|
||||||
import { clamp, wrapAngle } from '../../shared/scalar.js';
|
import { clamp, wrapAngle } from '../../shared/scalar.js';
|
||||||
|
import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The match loop.
|
* The match loop.
|
||||||
@@ -248,6 +249,41 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
chaser: new Array(teams).fill(null),
|
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 */
|
/** @param {number} dt */
|
||||||
function update(dt) {
|
function update(dt) {
|
||||||
const pp = puck.position();
|
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);
|
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 --------------------------------
|
// ---- 3. hits, knockdowns and getting up --------------------------------
|
||||||
hits.tick(dt);
|
hits.tick(dt);
|
||||||
for (let i = 0; i < count; i++) {
|
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);
|
return states.filter((s) => s.team === index);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Drop everyone back on their spawn, momentum cleared. */
|
/**
|
||||||
reset() {
|
* Drop everyone for a faceoff at a given dot (default: centre ice).
|
||||||
for (let i = 0; i < count; i++) {
|
* Accepts a faceoff-dot object or raw `{x,z}`.
|
||||||
const spawn = spawns[i];
|
*/
|
||||||
// Anyone lying on the ice has to be stood up before being placed, or
|
faceoffAt(spot = FACEOFF_DOTS[0]) {
|
||||||
// their proxy stays disabled and they spawn as a corpse.
|
const fx = spot?.x ?? 0;
|
||||||
if (skaters[i].limp) skaters[i].getUp(states[i]);
|
const fz = spot?.z ?? 0;
|
||||||
Object.assign(states[i], { x: spawn.x, z: spawn.z, vx: 0, vz: 0, yaw: spawn.yaw });
|
// Team 0 stands on the −X side of the puck, team 1 on +X — each faces in.
|
||||||
skaters[i].proxy?.teleport(spawn.x, spawn.z);
|
const byTeam = [[], []];
|
||||||
brains[i].target = null;
|
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;
|
recentHits.length = 0;
|
||||||
recentPlays.length = 0;
|
recentPlays.length = 0;
|
||||||
// Faceoff: puck at centre ice, dead.
|
|
||||||
possession.reset();
|
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() {
|
destroy() {
|
||||||
hits.destroy();
|
hits.destroy();
|
||||||
puck.destroy();
|
puck.destroy();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+327
-100
@@ -7,18 +7,24 @@ import { createMatch } from './game/match.js';
|
|||||||
import { createInput } from './game/input.js';
|
import { createInput } from './game/input.js';
|
||||||
import { describeHit } from './game/hits.js';
|
import { describeHit } from './game/hits.js';
|
||||||
import { createShootout } from './game/shootout.js';
|
import { createShootout } from './game/shootout.js';
|
||||||
|
import { createScrimmage } from './game/scrimmage.js';
|
||||||
import { RINK } from '../shared/rink.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 —
|
* Modes:
|
||||||
* renderer, lights, resize, the frame loop and a small debug HUD.
|
* 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 canvas = document.getElementById('stage');
|
||||||
const boot = document.getElementById('boot');
|
const boot = document.getElementById('boot');
|
||||||
const hud = document.getElementById('hud');
|
const hud = document.getElementById('hud');
|
||||||
|
const menuEl = document.getElementById('menu');
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
||||||
renderer.shadowMap.enabled = true;
|
renderer.shadowMap.enabled = true;
|
||||||
@@ -82,62 +88,221 @@ resize();
|
|||||||
const stats = { fps: 0, steps: 0, top: 0 };
|
const stats = { fps: 0, steps: 0, top: 0 };
|
||||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
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();
|
await initPhysics();
|
||||||
const physics = createPhysicsWorld();
|
const physics = createPhysicsWorld();
|
||||||
buildRink(scene);
|
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);
|
const input = createInput(window);
|
||||||
// One live input object, refreshed each frame and read by the match.
|
|
||||||
const stick = input.state;
|
const stick = input.state;
|
||||||
|
|
||||||
// Rumble on contact the player is part of. Strength tracks the outcome, so
|
/** @type {GameMode | null} */
|
||||||
// the pad tells you whether you laid someone out or just brushed them, and
|
let mode = null;
|
||||||
// taking one buzzes harder than giving one.
|
/** @type {ReturnType<typeof createMatch> | null} */
|
||||||
const RUMBLE = {
|
let match = null;
|
||||||
knockdown: [1.0, 0.7, 260],
|
/** @type {ReturnType<typeof createShootout> | null} */
|
||||||
stagger: [0.55, 0.35, 150],
|
let shootout = null;
|
||||||
bump: [0.22, 0.12, 70],
|
/** @type {ReturnType<typeof createScrimmage> | null} */
|
||||||
};
|
let scrimmage = null;
|
||||||
|
/** @type {ReturnType<typeof buildPuckMesh> | null} */
|
||||||
|
let puckView = null;
|
||||||
|
/** @type {(() => void) | null} */
|
||||||
|
let removeSubstepSync = null;
|
||||||
|
|
||||||
|
let playerDriving = false;
|
||||||
let lastHitSeen = -1;
|
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.
|
* Take control of a skater, or give them back.
|
||||||
*
|
*
|
||||||
* Taking control snaps the camera onto whoever you just grabbed — driving a
|
* 1v1: control follows whoever is shooting this attempt.
|
||||||
* skater you cannot see is the kind of thing that reads as a broken build.
|
* 3v3: control follows the camera's follow target (or Home 1).
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
function toggleControl() {
|
function toggleControl() {
|
||||||
playerShooting = !playerShooting;
|
if (!match) return;
|
||||||
shootout.setShooterControl(playerShooting ? stick : null);
|
playerDriving = !playerDriving;
|
||||||
if (playerShooting) {
|
|
||||||
cam.state.mode = 'follow';
|
if (mode === '1v1' && shootout) {
|
||||||
cam.state.followIndex = shootout.state.shooter;
|
shootout.setShooterControl(playerDriving ? stick : null);
|
||||||
cam.state.distance = 9;
|
if (playerDriving) {
|
||||||
cam.state.pitch = 0.3;
|
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) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'c' || e.key === 'C') cam.cycleMode(match.skaters.length);
|
// Menu: 1 / 2 / Enter on focused choice.
|
||||||
if (e.key === 'r' || e.key === 'R') shootout.reset();
|
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') {
|
if (e.key === 'p' || e.key === 'P' || e.code === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleControl();
|
toggleControl();
|
||||||
@@ -153,10 +318,29 @@ async function boot3() {
|
|||||||
// Debug handle. The capture tool drives the camera through this to frame
|
// 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
|
// repeatable shots, and it is the fastest way to poke at a skater from the
|
||||||
// console while tuning.
|
// 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();
|
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 last = performance.now();
|
||||||
let fpsAccum = 0;
|
let fpsAccum = 0;
|
||||||
let fpsFrames = 0;
|
let fpsFrames = 0;
|
||||||
@@ -167,61 +351,70 @@ async function boot3() {
|
|||||||
const dt = Math.min(0.05, (now - last) / 1000);
|
const dt = Math.min(0.05, (now - last) / 1000);
|
||||||
last = now;
|
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);
|
input.read(dt);
|
||||||
stick.cameraYaw = cam.state.yaw;
|
stick.cameraYaw = cam.state.yaw;
|
||||||
match.update(dt);
|
|
||||||
shootout.update(dt);
|
|
||||||
|
|
||||||
// Follow whoever is shooting, so the camera never has to be told.
|
if (match) {
|
||||||
if (cam.state.mode === 'follow') cam.state.followIndex = shootout.state.shooter;
|
match.update(dt);
|
||||||
|
if (shootout) shootout.update(dt);
|
||||||
|
if (scrimmage) scrimmage.update(dt);
|
||||||
|
|
||||||
// Haptics for anything the player was part of.
|
// Follow whoever is shooting, so the camera never has to be told.
|
||||||
const newest = match.recentHits[0];
|
if (mode === '1v1' && shootout && cam.state.mode === 'follow') {
|
||||||
if (newest && newest.at !== lastHitSeen) {
|
cam.state.followIndex = shootout.state.shooter;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
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);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯');
|
const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯');
|
||||||
|
|
||||||
function drawHud() {
|
function drawHud() {
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
const watching = cam.state.mode === 'follow'
|
const watching = cam.state.mode === 'follow'
|
||||||
? match.states[cam.state.followIndex]?.name ?? 'broadcast'
|
? match.states[cam.state.followIndex]?.name ?? 'broadcast'
|
||||||
: 'broadcast';
|
: 'broadcast';
|
||||||
const player = match.playerIndex !== null ? match.states[match.playerIndex] : null;
|
const player = match.playerIndex !== null ? match.states[match.playerIndex] : null;
|
||||||
const down = match.skaters.filter((s) => s.limp).length;
|
|
||||||
const feed = match.recentHits
|
const feed = match.recentHits
|
||||||
.filter((h) => h.outcome !== 'bump')
|
.filter((h) => h.outcome !== 'bump')
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
@@ -232,44 +425,78 @@ async function boot3() {
|
|||||||
? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}`
|
? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}`
|
||||||
: 'pad: none — keyboard';
|
: '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 carrier = match.possession.carrier;
|
||||||
const puckLine = carrier === null
|
const puckLine = carrier === null
|
||||||
? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
|
? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
|
||||||
: `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
|
: `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
|
||||||
const mag = match.possession.tuning.magnetism;
|
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${phaseLine}`
|
||||||
+ `\n`
|
+ `\n`
|
||||||
+ `\n${stats.fps} fps · ${puckLine}`
|
+ `\n${stats.fps} fps · ${puckLine}`
|
||||||
+ `\n${pad}`
|
+ `\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`
|
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
|
||||||
+ (player
|
+ controlsHint
|
||||||
? (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)}`
|
|
||||||
: '')
|
|
||||||
+ (feed ? `\n\nhits:\n${feed}` : '');
|
+ (feed ? `\n\nhits:\n${feed}` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
boot3().catch((err) => {
|
bootApp().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
boot.textContent = 'FAILED TO START — ' + (err?.message ?? err);
|
boot.textContent = 'FAILED TO START — ' + (err?.message ?? err);
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-1
@@ -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';
|
import { done, near, ok, section } from './harness.mjs';
|
||||||
|
|
||||||
section('penetration on the straights');
|
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');
|
done('rink');
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -83,10 +83,15 @@ try {
|
|||||||
});
|
});
|
||||||
page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
|
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.
|
// The boot overlay is removed once physics is up and the first frame ran.
|
||||||
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 });
|
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
|
// 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
|
// at two window sizes so a resize path that only works on first load fails
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: { port: 5174, open: true },
|
server: { port: 5174, open: true },
|
||||||
|
// Multi-page: main game + character studio both ship as real HTML entries.
|
||||||
build: {
|
build: {
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
// The body/skin generators are one deterministic chunk; splitting them buys
|
// The body/skin generators are one deterministic chunk; splitting them buys
|
||||||
// nothing and costs a round trip before anything can render.
|
// nothing and costs a round trip before anything can render.
|
||||||
chunkSizeWarningLimit: 2500,
|
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
|
// box3d.js ships an Emscripten bundle that resolves its .wasm via
|
||||||
// import.meta.url. Pre-bundling rewrites that URL and breaks the lookup, so
|
// import.meta.url. Pre-bundling rewrites that URL and breaks the lookup, so
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user