Add main menu, 3v3 with goalies, and dead-puck faceoffs.

Players pick 1-on-1 shootout or 3-on-3 scrimmage; 3v3 gets nets,
goalies, scoring, OOB whistles to the nearest faceoff circle, and
one-way board re-entry for skaters who leave the ice.
This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:28:11 -05:00
parent 94d24205dc
commit dd819e991d
8 changed files with 1371 additions and 116 deletions
+606
View File
@@ -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 dont 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 isnt 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 dont 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 (12 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 (12 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 (23 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 (23 weeks)
- [ ] Impact profiles + rules callbacks
- [ ] Generic play object + possession magnetism
- [ ] Tool release sampling
- [ ] Contested capture v1
### Phase 5 — Arena + match shell (12 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 24 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
View File
@@ -21,13 +21,76 @@
#hud { position:absolute; left:14px; top:12px; color:#8fb4d4; font-size:12px;
line-height:1.5; white-space:pre; pointer-events:none; text-shadow:0 1px 2px #000; }
#boot { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:#6ea8dc; letter-spacing:5px; font-size:13px; background:#0a0e14; z-index:10; }
color:#6ea8dc; letter-spacing:5px; font-size:13px; background:#0a0e14; z-index:20; }
#menu {
position:absolute; inset:0; z-index:15;
display:flex; flex-direction:column; align-items:center; justify-content:center;
background:radial-gradient(ellipse at 50% 35%, #121a26 0%, #0a0e14 70%);
color:#c8dcea; pointer-events:auto;
}
#menu[hidden] { display:none; }
#menu .brand {
letter-spacing:0.55em; font-size:13px; color:#6ea8dc; margin:0 0 10px;
text-indent:0.55em;
}
#menu h1 {
margin:0 0 8px; font-size:42px; font-weight:600; letter-spacing:0.08em;
color:#e8f2fa; text-shadow:0 2px 24px rgba(80,140,200,0.35);
}
#menu .tag {
margin:0 0 36px; font-size:12px; color:#6a849c; letter-spacing:0.12em;
}
#menu .choices {
display:flex; flex-direction:column; gap:12px; width:min(360px, 86vw);
}
#menu button.choice {
appearance:none; border:1px solid #2a4058; background:#121c28;
color:#d4e6f5; font:inherit; font-size:15px; letter-spacing:0.06em;
padding:16px 20px; text-align:left; cursor:pointer;
border-radius:4px; transition:border-color .12s, background .12s, transform .08s;
}
#menu button.choice:hover, #menu button.choice:focus-visible {
outline:none; border-color:#5a9fd4; background:#172433;
}
#menu button.choice:active { transform:scale(0.99); }
#menu button.choice .label {
display:block; font-size:16px; color:#eef6fc; margin-bottom:4px;
}
#menu button.choice .hint {
display:block; font-size:11px; color:#6a849c; letter-spacing:0.04em;
}
#menu button.choice .key {
float:right; margin-top:2px; color:#5a9fd4; font-size:12px;
border:1px solid #2a5070; border-radius:3px; padding:2px 7px;
}
#menu .foot {
margin-top:28px; font-size:11px; color:#4a6074; letter-spacing:0.08em;
}
</style>
</head>
<body>
<canvas id="stage"></canvas>
<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&rsquo;re in</p>
</div>
<div id="boot">TILT&hellip;</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+59
View File
@@ -32,6 +32,65 @@ export const MARKINGS = Object.freeze({
zoneDotX: 20.2,
});
/**
* All nine faceoff dots: centre, four neutral-zone, four end-zone.
* Order is stable so tests and HUD labels can index if they want.
*/
export const FACEOFF_DOTS = Object.freeze([
Object.freeze({ id: 'centre', x: 0, z: 0 }),
Object.freeze({ id: 'nz-pp', x: MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-pm', x: MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-mp', x: -MARKINGS.faceoffDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'nz-mm', x: -MARKINGS.faceoffDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-pp', x: MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-pm', x: MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-mp', x: -MARKINGS.zoneDotX, z: MARKINGS.faceoffDotZ }),
Object.freeze({ id: 'ez-mm', x: -MARKINGS.zoneDotX, z: -MARKINGS.faceoffDotZ }),
]);
/** Nearest faceoff dot to a world point — where a whistle drops the next draw. */
export function nearestFaceoffDot(x, z) {
let best = FACEOFF_DOTS[0];
let bestD = Infinity;
for (const d of FACEOFF_DOTS) {
const dd = (d.x - x) * (d.x - x) + (d.z - z) * (d.z - z);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
return best;
}
/**
* Is the puck still in play?
*
* Horizontal: must be on the ice surface (small inset so "on the boards" is
* still playable, but over the glass / past the outline is dead).
* Vertical: above the glass, under the slab, or impossibly high is unplayable.
*/
export function puckPlayable(x, y, z, radius = 0.0381) {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return { ok: false, reason: 'nan' };
}
// Far outside the barn entirely (escaped continuous collision).
if (Math.abs(x) > RINK.halfX + 4 || Math.abs(z) > RINK.halfZ + 4) {
return { ok: false, reason: 'escaped' };
}
// Under the ice or stuck in the slab.
if (y < -0.15) return { ok: false, reason: 'under' };
// Over the glass. Boards are ~1.07 m; glass is visual only above that.
if (y > RINK.boardHeight + RINK.glassHeight * 0.55) {
return { ok: false, reason: 'over' };
}
// Centre past the board line — the puck has left the playing surface.
// Tiny slack so a rattle against the boards does not whistle every contact.
if (rinkPenetration(x, z, 0).dist > radius * 0.75) {
return { ok: false, reason: 'out' };
}
return { ok: true, reason: '' };
}
/**
* Centre of the corner arc nearest (x, z), and the sign of the quadrant.
* Points outside the straight sections belong to exactly one corner.
+93 -11
View File
@@ -1,7 +1,7 @@
import * as THREE from 'three';
import { createSkater } from '../character/skater.js';
import { createBrain, spawnLineup, steer } from '../../shared/ai.js';
import { applyIntent, createSkaterState, stepSkater } from '../../shared/skaterSim.js';
import { applyIntent, createSkaterState, stepSkater, SKATE } from '../../shared/skaterSim.js';
import { stickToWorld } from './input.js';
import { createHitResolver } from './hits.js';
import { PUCK, createPuck } from '../physics/puck.js';
@@ -9,6 +9,7 @@ import { NET, goalLineX } from '../../shared/net.js';
import { createPossession } from './possession.js';
import { makeRng } from '../core/rng.js';
import { clamp, wrapAngle } from '../../shared/scalar.js';
import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js';
/**
* The match loop.
@@ -248,6 +249,41 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
chaser: new Array(teams).fill(null),
};
/**
* One-way board hop: outside → ice only. Boards still block leaving.
* @returns {boolean}
*/
function reenterSkaters() {
let any = false;
for (let i = 0; i < count; i++) {
const s = states[i];
// Limp bodies ride the ragdoll; get-up re-homes the proxy to the pelvis.
if (skaters[i].limp) continue;
const pen = rinkPenetration(s.x, s.z, SKATE.radius * 0.85);
if (pen.dist <= 0.02) continue;
any = true;
const inset = pen.dist + 0.08;
s.x += pen.nx * inset;
s.z += pen.nz * inset;
// Kill velocity going further out of the rink.
const outward = s.vx * -pen.nx + s.vz * -pen.nz;
if (outward > 0) {
s.vx += pen.nx * outward;
s.vz += pen.nz * outward;
}
// Hop inward so they clear the wall instead of grinding it.
s.vx += pen.nx * 1.2;
s.vz += pen.nz * 1.2;
const hopX = s.vx;
const hopZ = s.vz;
skaters[i].proxy?.teleport(s.x, s.z);
s.vx = hopX;
s.vz = hopZ;
skaters[i].proxy?.write(s);
}
return any;
}
/** @param {number} dt */
function update(dt) {
const pp = puck.position();
@@ -373,6 +409,10 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
puck.setVelocity(v.x * k, v.y * k, v.z * k);
}
// Skaters who end up outside (over the boards, tunnel, get-up glitch) hop
// back onto the ice. One-way only: boards still block leaving the normal way.
reenterSkaters();
// ---- 3. hits, knockdowns and getting up --------------------------------
hits.tick(dt);
for (let i = 0; i < count; i++) {
@@ -481,24 +521,66 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
return states.filter((s) => s.team === index);
},
/** Drop everyone back on their spawn, momentum cleared. */
reset() {
for (let i = 0; i < count; i++) {
const spawn = spawns[i];
// Anyone lying on the ice has to be stood up before being placed, or
// their proxy stays disabled and they spawn as a corpse.
/**
* Drop everyone for a faceoff at a given dot (default: centre ice).
* Accepts a faceoff-dot object or raw `{x,z}`.
*/
faceoffAt(spot = FACEOFF_DOTS[0]) {
const fx = spot?.x ?? 0;
const fz = spot?.z ?? 0;
// Team 0 stands on the X side of the puck, team 1 on +X — each faces in.
const byTeam = [[], []];
for (let i = 0; i < count; i++) byTeam[states[i].team]?.push(i);
for (let t = 0; t < teams; t++) {
const side = t === 0 ? -1 : 1;
const ids = byTeam[t] ?? [];
for (let k = 0; k < ids.length; k++) {
const i = ids[k];
// First skater is the draw; the rest fan back and wide.
const along = k === 0 ? 1.05 : 2.4 + (k - 1) * 1.1;
const lateral = k === 0 ? 0 : ((k % 2 === 1 ? 1 : -1) * (0.9 + Math.floor((k - 1) / 2) * 1.2));
let x = fx + side * along;
let z = fz + lateral;
// Keep the lineup on the ice if the dot is near the boards.
const pen = rinkPenetration(x, z, SKATE.radius + 0.15);
if (pen.dist > 0) {
x += pen.nx * (pen.dist + 0.05);
z += pen.nz * (pen.dist + 0.05);
}
if (skaters[i].limp) skaters[i].getUp(states[i]);
Object.assign(states[i], { x: spawn.x, z: spawn.z, vx: 0, vz: 0, yaw: spawn.yaw });
skaters[i].proxy?.teleport(spawn.x, spawn.z);
Object.assign(states[i], {
x, z, vx: 0, vz: 0,
yaw: side > 0 ? -Math.PI / 2 : Math.PI / 2,
ix: 0, iz: 0, sprint: false, brake: false,
});
skaters[i].proxy?.teleport(x, z);
brains[i].target = null;
}
}
recentHits.length = 0;
recentPlays.length = 0;
// Faceoff: puck at centre ice, dead.
possession.reset();
puck.place(0, 0.05, 0);
puck.place(fx, PUCK.thickness / 2 + 0.01, fz);
},
/** Centre-ice faceoff — the default restart. */
reset() {
this.faceoffAt(FACEOFF_DOTS[0]);
},
reenterSkaters,
/** True when a skater is clearly outside the playing surface. */
skaterOutOfBounds(i) {
const s = states[i];
if (!s) return false;
return !insideRink(s.x, s.z, -0.15);
},
nearestFaceoffDot,
destroy() {
hits.destroy();
puck.destroy();
+188
View File
@@ -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();
},
};
}
+290 -63
View File
@@ -7,18 +7,24 @@ import { createMatch } from './game/match.js';
import { createInput } from './game/input.js';
import { describeHit } from './game/hits.js';
import { createShootout } from './game/shootout.js';
import { createScrimmage } from './game/scrimmage.js';
import { RINK } from '../shared/rink.js';
/**
* Spike 1 boot: three AI skaters on a rink.
* Shell: renderer, lights, main menu, and the active mode loop.
*
* Everything gameplay-shaped lives in `game/match.js`; this file is the shell —
* renderer, lights, resize, the frame loop and a small debug HUD.
* Modes:
* 1v1 — shootout (one shooter vs goalie, alternating teams)
* 3v3 — six skaters + nets + goalies, open play with scoring
*
* Everything gameplay-shaped lives in `game/`; this file wires the shell and
* tears modes down cleanly so Esc can return to the menu.
*/
const canvas = document.getElementById('stage');
const boot = document.getElementById('boot');
const hud = document.getElementById('hud');
const menuEl = document.getElementById('menu');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.shadowMap.enabled = true;
@@ -82,62 +88,221 @@ resize();
const stats = { fps: 0, steps: 0, top: 0 };
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
async function boot3() {
await initPhysics();
const physics = createPhysicsWorld();
buildRink(scene);
const match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
const puckView = buildPuckMesh(scene, PUCK);
/** @typedef {'1v1' | '3v3'} GameMode */
// The shootout owns the nets and the goalies, and drives its own kinematic
// bodies inside the physics substep.
const shootout = createShootout({ scene, physics, match });
match.addSubstepSync((fixedDt) => {
shootout.goalies[1].syncPhysics(fixedDt);
shootout.goalies[-1].syncPhysics(fixedDt);
});
shootout.reset();
const input = createInput(window);
// One live input object, refreshed each frame and read by the match.
const stick = input.state;
// Rumble on contact the player is part of. Strength tracks the outcome, so
// the pad tells you whether you laid someone out or just brushed them, and
// taking one buzzes harder than giving one.
const RUMBLE = {
knockdown: [1.0, 0.7, 260],
stagger: [0.55, 0.35, 150],
bump: [0.22, 0.12, 70],
};
function parseModeFromUrl() {
const raw = new URLSearchParams(location.search).get('mode');
if (raw === '1v1' || raw === '1on1' || raw === 'shootout') return '1v1';
if (raw === '3v3' || raw === '3on3' || raw === 'scrimmage') return '3v3';
return null;
}
async function bootApp() {
await initPhysics();
const physics = createPhysicsWorld();
buildRink(scene);
const input = createInput(window);
const stick = input.state;
/** @type {GameMode | null} */
let mode = null;
/** @type {ReturnType<typeof createMatch> | null} */
let match = null;
/** @type {ReturnType<typeof createShootout> | null} */
let shootout = null;
/** @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;
function clearMode() {
if (removeSubstepSync) {
removeSubstepSync();
removeSubstepSync = null;
}
if (shootout) {
shootout.destroy();
shootout = null;
}
if (scrimmage) {
scrimmage.destroy();
scrimmage = null;
}
if (puckView) {
// Ring is parented under the mesh.
scene.remove(puckView.mesh);
puckView.mesh.geometry?.dispose?.();
puckView.mesh.material?.dispose?.();
puckView.ring.geometry?.dispose?.();
puckView.ring.material?.dispose?.();
puckView = null;
}
if (match) {
match.destroy();
match = null;
}
mode = null;
playerDriving = false;
lastHitSeen = -1;
hud.textContent = '';
Object.assign(cam.state, {
mode: 'broadcast',
followIndex: 0,
distance: 34,
pitch: 0.62,
});
}
/**
* Take control of a skater, or give them back.
*
* Taking control snaps the camera onto whoever you just grabbed — driving a
* skater you cannot see is the kind of thing that reads as a broken build.
*/
let playerShooting = false;
/**
* Take the shooter, or hand them back. In a shootout there is only one
* skater worth driving, and which one it is changes every attempt — so
* control follows the shooter rather than being pinned to an index.
* 1v1: control follows whoever is shooting this attempt.
* 3v3: control follows the camera's follow target (or Home 1).
*/
function toggleControl() {
playerShooting = !playerShooting;
shootout.setShooterControl(playerShooting ? stick : null);
if (playerShooting) {
if (!match) return;
playerDriving = !playerDriving;
if (mode === '1v1' && shootout) {
shootout.setShooterControl(playerDriving ? stick : null);
if (playerDriving) {
cam.state.mode = 'follow';
cam.state.followIndex = shootout.state.shooter;
cam.state.distance = 9;
cam.state.pitch = 0.3;
}
return;
}
// 3v3 scrimmage
if (!playerDriving) {
if (match.playerIndex !== null) match.setControl(match.playerIndex, null);
return;
}
const idx = cam.state.mode === 'follow'
? cam.state.followIndex
: 0;
match.setControl(idx, stick);
cam.state.mode = 'follow';
cam.state.followIndex = idx;
cam.state.distance = 9;
cam.state.pitch = 0.3;
}
function showMenu() {
clearMode();
menuEl.hidden = false;
menuEl.querySelector('button.choice')?.focus();
}
/**
* Start a mode. Safe to call again — tears down whatever was running.
* @param {GameMode} next
*/
function startMode(next) {
if (next !== '1v1' && next !== '3v3') {
throw new Error(`unknown mode: ${next}`);
}
clearMode();
menuEl.hidden = true;
mode = next;
match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
puckView = buildPuckMesh(scene, PUCK);
if (next === '1v1') {
// The shootout owns the nets and the goalies, and drives its own
// kinematic bodies inside the physics substep.
shootout = createShootout({ scene, physics, match });
removeSubstepSync = match.addSubstepSync((fixedDt) => {
shootout.goalies[1].syncPhysics(fixedDt);
shootout.goalies[-1].syncPhysics(fixedDt);
});
shootout.reset();
playerDriving = false;
} else {
// Full ice: nets + goalies at both ends, continuous play with scoring.
scrimmage = createScrimmage({ scene, physics, match });
removeSubstepSync = match.addSubstepSync((fixedDt) => {
scrimmage.goalies[1].syncPhysics(fixedDt);
scrimmage.goalies[-1].syncPhysics(fixedDt);
});
scrimmage.reset();
// Scrimmage opens under AI; press P to jump in.
playerDriving = false;
}
Object.assign(cam.state, {
mode: 'broadcast',
distance: next === '1v1' ? 34 : 40,
pitch: next === '1v1' ? 0.62 : 0.72,
followIndex: 0,
});
}
// ---- menu wiring --------------------------------------------------------
menuEl.addEventListener('click', (e) => {
const btn = e.target.closest('button.choice');
if (!btn) return;
startMode(/** @type {GameMode} */ (btn.dataset.mode));
});
window.addEventListener('keydown', (e) => {
if (e.key === 'c' || e.key === 'C') cam.cycleMode(match.skaters.length);
if (e.key === 'r' || e.key === 'R') shootout.reset();
// Menu: 1 / 2 / Enter on focused choice.
if (!menuEl.hidden) {
if (e.key === '1') {
e.preventDefault();
startMode('1v1');
} else if (e.key === '2') {
e.preventDefault();
startMode('3v3');
} else if (e.key === 'Enter') {
const active = document.activeElement;
if (active?.dataset?.mode) {
e.preventDefault();
startMode(/** @type {GameMode} */ (active.dataset.mode));
}
}
return;
}
// In-game.
if (e.key === 'Escape') {
e.preventDefault();
showMenu();
return;
}
if (!match) return;
if (e.key === 'c' || e.key === 'C') {
cam.cycleMode(match.skaters.length);
// If the player is driving in 3v3, keep control on whoever the camera
// is following so C can swap bodies.
if (mode === '3v3' && playerDriving && cam.state.mode === 'follow') {
const prev = match.playerIndex;
if (prev !== null && prev !== cam.state.followIndex) {
match.setControl(prev, null);
match.setControl(cam.state.followIndex, stick);
}
}
}
if (e.key === 'r' || e.key === 'R') {
if (mode === '1v1' && shootout) shootout.reset();
else if (mode === '3v3' && scrimmage) scrimmage.reset();
else match.reset();
}
if (e.key === 'p' || e.key === 'P' || e.code === 'Tab') {
e.preventDefault();
toggleControl();
@@ -153,10 +318,29 @@ async function boot3() {
// Debug handle. The capture tool drives the camera through this to frame
// repeatable shots, and it is the fastest way to poke at a skater from the
// console while tuning.
window.tilt = { match, shootout, cam, physics, scene, renderer, stats, input, toggleControl };
window.tilt = {
get match() { return match; },
get shootout() { return shootout; },
get scrimmage() { return scrimmage; },
get mode() { return mode; },
cam,
physics,
scene,
renderer,
stats,
input,
startMode,
showMenu,
toggleControl,
};
boot.remove();
// Deep link / capture: ?mode=1v1|3v3 skips the menu.
const auto = parseModeFromUrl();
if (auto) startMode(auto);
else showMenu();
let last = performance.now();
let fpsAccum = 0;
let fpsFrames = 0;
@@ -167,16 +351,18 @@ async function boot3() {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
// The camera yaw rides along with the stick so the match can turn a
// screen-space push into a world direction. Sampled before the update so
// input and simulation are one frame consistent.
input.read(dt);
stick.cameraYaw = cam.state.yaw;
if (match) {
match.update(dt);
shootout.update(dt);
if (shootout) shootout.update(dt);
if (scrimmage) scrimmage.update(dt);
// Follow whoever is shooting, so the camera never has to be told.
if (cam.state.mode === 'follow') cam.state.followIndex = shootout.state.shooter;
if (mode === '1v1' && shootout && cam.state.mode === 'follow') {
cam.state.followIndex = shootout.state.shooter;
}
// Haptics for anything the player was part of.
const newest = match.recentHits[0];
@@ -191,12 +377,13 @@ async function boot3() {
}
}
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);
renderer.render(scene, cam.camera);
fpsAccum += dt;
fpsFrames++;
@@ -207,21 +394,27 @@ async function boot3() {
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();
} 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 = '';
}
renderer.render(scene, cam.camera);
requestAnimationFrame(frame);
}
const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯');
function drawHud() {
if (!match) return;
const watching = cam.state.mode === 'follow'
? match.states[cam.state.followIndex]?.name ?? 'broadcast'
: 'broadcast';
const player = match.playerIndex !== null ? match.states[match.playerIndex] : null;
const down = match.skaters.filter((s) => s.limp).length;
const feed = match.recentHits
.filter((h) => h.outcome !== 'bump')
.slice(0, 3)
@@ -232,6 +425,20 @@ async function boot3() {
? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}`
: 'pad: none — keyboard';
const carrier = match.possession.carrier;
const puckLine = carrier === null
? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
: `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
const mag = match.possession.tuning.magnetism;
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)}`
@@ -244,32 +451,52 @@ async function boot3() {
: `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`)
: `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`;
const carrier = match.possession.carrier;
const puckLine = carrier === null
? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
: `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
const mag = match.possession.tuning.magnetism;
hud.textContent = `${scoreLine}`
hud.textContent = `1-on-1 shootout`
+ `\n${scoreLine}`
+ `\n${phaseLine}`
+ `\n`
+ `\n${stats.fps} fps · ${puckLine}`
+ `\n${pad}`
+ `\n[P] ${playerShooting ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart`
+ `\n[P] ${playerDriving ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart [Esc] menu`
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ (player
? (input.connected
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
: '')
+ controlsHint
+ (feed ? `\n\nhits:\n${feed}` : '');
return;
}
// 3v3 with nets and goalies
const sc = scrimmage?.state;
const scoreLine = sc
? `HOME ${sc.score[0]}${sc.score[1]} AWAY`
: 'HOME 0 — 0 AWAY';
let phaseLine = 'live';
if (sc?.phase === 'goal') {
const who = sc.last?.team === 0 ? 'HOME' : 'AWAY';
phaseLine = `GOAL — ${who}`;
} else if (sc?.phase === 'cover') {
phaseLine = `covered (${sc.last?.detail ?? 'goalie'})`;
} else if (sc?.phase === 'oob') {
phaseLine = `whistle — puck ${sc.last?.detail || 'out'} · faceoff ${sc.last?.faceoff?.id ?? ''}`;
} else if (sc?.phase === 'skater_oob') {
phaseLine = `whistle — ${sc.last?.detail || 'skater'} over boards · faceoff ${sc.last?.faceoff?.id ?? ''}`;
}
hud.textContent = `3-on-3 · cam ${watching}`
+ `\n${scoreLine}`
+ `\n${phaseLine}`
+ `\n`
+ `\n${stats.fps} fps · ${puckLine}`
+ `\n${pad}`
+ `\n[P] ${playerDriving ? 'hand back to AI' : 'take control'} [C] camera [R] faceoff [Esc] menu`
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ controlsHint
+ (feed ? `\n\nhits:\n${feed}` : '');
}
requestAnimationFrame(frame);
}
boot3().catch((err) => {
bootApp().catch((err) => {
console.error(err);
boot.textContent = 'FAILED TO START — ' + (err?.message ?? err);
});
+26 -1
View File
@@ -1,4 +1,8 @@
import { RINK, clampToRink, insideRink, randomIcePoint, rinkOutline, rinkPenetration } from '../shared/rink.js';
import {
FACEOFF_DOTS, MARKINGS, RINK,
clampToRink, insideRink, nearestFaceoffDot, puckPlayable,
randomIcePoint, rinkOutline, rinkPenetration,
} from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
section('penetration on the straights');
@@ -74,4 +78,25 @@ section('random points land on the ice');
}
}
section('faceoff dots');
{
ok(FACEOFF_DOTS.length === 9, 'nine faceoff dots');
ok(nearestFaceoffDot(0, 0).id === 'centre', 'centre ice → centre dot');
ok(nearestFaceoffDot(MARKINGS.zoneDotX, MARKINGS.faceoffDotZ).id === 'ez-pp', 'end-zone corner maps to itself');
const nearSide = nearestFaceoffDot(RINK.halfX + 2, MARKINGS.faceoffDotZ * 0.9);
ok(nearSide.id.startsWith('ez-p'), `puck past +X boards nearest end-zone (+ got ${nearSide.id})`);
const mid = nearestFaceoffDot(1, MARKINGS.faceoffDotZ);
ok(mid.id === 'nz-pp' || mid.id === 'centre', `near neutral +Z maps sensibly (${mid.id})`);
}
section('puck playable');
{
ok(puckPlayable(0, 0.02, 0).ok, 'centre ice is playable');
ok(!puckPlayable(RINK.halfX + 1, 0.02, 0).ok, 'past the end boards is dead');
ok(!puckPlayable(0, 3.5, 0).ok, 'over the glass is dead');
ok(!puckPlayable(0, -0.5, 0).ok, 'under the ice is dead');
ok(puckPlayable(0, 0.5, 0).ok, 'a lifted puck still on the ice plane is live');
}
done('rink');
+6 -1
View File
@@ -83,10 +83,15 @@ try {
});
page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
await page.goto(URL, { waitUntil: 'domcontentloaded' });
// 3v3 scrimmage is what the lineup / skating shots need; skip the menu.
const captureUrl = new URL(URL);
if (!captureUrl.searchParams.has('mode')) captureUrl.searchParams.set('mode', '3v3');
await page.goto(captureUrl.href, { waitUntil: 'domcontentloaded' });
// The boot overlay is removed once physics is up and the first frame ran.
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 });
// Mode auto-starts from ?mode=; wait until the match handle is live.
await page.waitForFunction(() => window.tilt?.match, { timeout: 15000 });
// The canvas must fill the window exactly, at whatever pixel ratio. Checked
// at two window sizes so a resize path that only works on first load fails