animations
This commit is contained in:
Generated
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<module type="WEB_MODULE" version="4">
|
<module type="WEB_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$" />
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/.freemocap-venv/lib/python3.12/site-packages" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/.freemocap-venv/lib/python3.12/site-packages/jedi/inference/compiled" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/.freemocap-venv/lib/python3.12/site-packages/pandas/tests/extension" isTestSource="false" />
|
||||||
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ In the browser:
|
|||||||
| Xbox | keyboard | |
|
| Xbox | keyboard | |
|
||||||
|------|----------|-----|
|
|------|----------|-----|
|
||||||
| — | **P** / Tab | take control of the skater the camera is on, or hand back |
|
| — | **P** / Tab | take control of the skater the camera is on, or hand back |
|
||||||
| **left stick** | **WASD** | skate (relative to the camera, not to the skater) |
|
| **left stick** | **WASD** | skate (camera-relative); during a wind-up, aims across the net |
|
||||||
| **RT** | Shift | hustle — analog, so half-throttle is a real thing |
|
| **RT** | Shift | hustle — analog, so half-throttle is a real thing |
|
||||||
| **LT** | Space | hockey stop |
|
| **LT** | Space | contain / backskate — hold facing, moderated speed, stick reverse = retreat |
|
||||||
| **right stick** | arrows | Skill Stick: stickhandle, and pull back + push to shoot |
|
| **right stick** | arrows | Skill Stick: stickhandle, pull back to wind up, push to release; waggle while RB held |
|
||||||
|
| **RB** | E | stick spread — blade out on the ice, RS left/right to swat / intercept |
|
||||||
| **A** | J | pass to the nearest teammate |
|
| **A** | J | pass to the nearest teammate |
|
||||||
| **X** | K | shoot |
|
| **X** | K | shoot |
|
||||||
| **B** | L | poke check |
|
| **B** | L | poke check |
|
||||||
@@ -96,7 +97,9 @@ check that a change did not break the render path.
|
|||||||
dynamic, taking the impulse, hitting the ice and getting back up.
|
dynamic, taking the impulse, hitting the ice and getting back up.
|
||||||
- A regulation puck (76 mm, 170 g) as a continuous-collision cylinder, sticks
|
- A regulation puck (76 mm, 170 g) as a continuous-collision cylinder, sticks
|
||||||
with kinematic blade colliders, and a possession model on a runtime dial.
|
with kinematic blade colliders, and a possession model on a runtime dial.
|
||||||
- Shooting with wind-up power off the Skill Stick, passing, poke checks, and
|
- Shooting with wind-up power off the Skill Stick (placement from the movement
|
||||||
|
stick + how you skate during the wind-up — EA-style net aim), passing, poke
|
||||||
|
checks, and
|
||||||
contact knocking the puck off whoever is carrying it.
|
contact knocking the puck off whoever is carrying it.
|
||||||
|
|
||||||
Roster size is a parameter, not a constant: `createMatch({ perTeam, teams })`,
|
Roster size is a parameter, not a constant: `createMatch({ perTeam, teams })`,
|
||||||
|
|||||||
+2
-1
@@ -149,7 +149,8 @@ Hours to days each.
|
|||||||
spread. Needs hands on a pad, not test output.
|
spread. Needs hands on a pad, not test output.
|
||||||
|
|
||||||
### Controls / possession
|
### Controls / possession
|
||||||
- `LT` puck protection — the analog value is computed and discarded.
|
- `LT` puck protection when carrying — LT is now **backskating**; protection
|
||||||
|
can layer on top of the analog later without stealing the backskate feel.
|
||||||
- `RB` deke modifier.
|
- `RB` deke modifier.
|
||||||
- Bot stickhandling (`possession.handling` is human-only; bots leave it at 0).
|
- Bot stickhandling (`possession.handling` is human-only; bots leave it at 0).
|
||||||
- Forehand / backhand blade roll.
|
- Forehand / backhand blade roll.
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/animationClip.mjs && node test/freemocapImport.mjs && node test/input.mjs && node test/physics.mjs && node test/hits.mjs && node test/puck.mjs && node test/goalie.mjs && node test/shootout.mjs",
|
"test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/animationClip.mjs && node test/freemocapImport.mjs && node test/input.mjs && node test/shotAim.mjs && node test/physics.mjs && node test/hits.mjs && node test/puck.mjs && node test/goalie.mjs && node test/shootout.mjs",
|
||||||
"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",
|
||||||
|
|||||||
+132
-17
@@ -24,6 +24,13 @@ import { normalizePlayer } from './player.js';
|
|||||||
* lateral friction is what keeps momentum: turning redirects speed instead of
|
* lateral friction is what keeps momentum: turning redirects speed instead of
|
||||||
* destroying it, so a hard change of direction costs a little (`carveScrub`)
|
* destroying it, so a hard change of direction costs a little (`carveScrub`)
|
||||||
* and coasts out wide, which is the whole feel we are after.
|
* and coasts out wide, which is the whole feel we are after.
|
||||||
|
*
|
||||||
|
* **LT / L2 contain & backskate.** EA NHL style: holding LT does *not* spin
|
||||||
|
* you around. Facing stays put (eyes on the play) and the stick drives
|
||||||
|
* movement relative to that facing — push back to retreat heels-first, push
|
||||||
|
* forward for a careful crawl, or leave the stick centred to bleed speed and
|
||||||
|
* stay neutral instead of flying up-ice. Reverse-stick without LT is still a
|
||||||
|
* hockey stop; LT is the controlled/defensive gear.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const SKATE = Object.freeze({
|
export const SKATE = Object.freeze({
|
||||||
@@ -35,6 +42,27 @@ export const SKATE = Object.freeze({
|
|||||||
accel: 7.2,
|
accel: 7.2,
|
||||||
/** Extra push while sprinting. */
|
/** Extra push while sprinting. */
|
||||||
sprintAccel: 9.0,
|
sprintAccel: 9.0,
|
||||||
|
/**
|
||||||
|
* LT contain speeds (m/s). Forward stays a crawl so you don't fly the zone;
|
||||||
|
* reverse and lateral are quick so you can open up and gap-control.
|
||||||
|
*/
|
||||||
|
containForwardSpeed: 3.2,
|
||||||
|
containForwardSprint: 4.0,
|
||||||
|
containBackSpeed: 5.8,
|
||||||
|
containBackSprint: 6.8,
|
||||||
|
containLateralSpeed: 5.4,
|
||||||
|
containLateralSprint: 6.4,
|
||||||
|
/** Accel under LT (m/s²). Reverse / lateral / direction flips are snappy. */
|
||||||
|
containForwardAccel: 5.5,
|
||||||
|
containBackAccel: 16.0,
|
||||||
|
containLateralAccel: 15.0,
|
||||||
|
/** Extra accel when flipping fore↔aft or side under LT (offense ↔ defense). */
|
||||||
|
containPivotAccel: 20.0,
|
||||||
|
/**
|
||||||
|
* Soft decel when LT is held and the stick is centred (or nearly). Bleeds a
|
||||||
|
* rush without a full snowplow so you can sit neutral in the gap.
|
||||||
|
*/
|
||||||
|
containIdleDecel: 3.2,
|
||||||
/** Constant scrub from blade friction, m/s². Small — this is ice. */
|
/** Constant scrub from blade friction, m/s². Small — this is ice. */
|
||||||
glideDrag: 0.42,
|
glideDrag: 0.42,
|
||||||
/** Quadratic term so top speed is reached asymptotically, per (m/s)². */
|
/** Quadratic term so top speed is reached asymptotically, per (m/s)². */
|
||||||
@@ -49,6 +77,11 @@ export const SKATE = Object.freeze({
|
|||||||
turnRate: 5.2,
|
turnRate: 5.2,
|
||||||
/** Body yaw rate at top speed — you cannot pivot on a rail. */
|
/** Body yaw rate at top speed — you cannot pivot on a rail. */
|
||||||
turnRateFast: 1.9,
|
turnRateFast: 1.9,
|
||||||
|
/**
|
||||||
|
* Facing turn rate scale while LT is held. Zero: stick steers *travel* only,
|
||||||
|
* chest holds so you do not spin when retreating.
|
||||||
|
*/
|
||||||
|
containTurnScale: 0,
|
||||||
/** Proxy capsule radius, also used for board clamping. */
|
/** Proxy capsule radius, also used for board clamping. */
|
||||||
radius: 0.36,
|
radius: 0.36,
|
||||||
/** Never let a collision fling anyone faster than this. */
|
/** Never let a collision fling anyone faster than this. */
|
||||||
@@ -83,6 +116,11 @@ export function createSkaterState(id, spawn = {}, opts = {}) {
|
|||||||
sprint: false,
|
sprint: false,
|
||||||
/** Held brake — a hockey stop, independent of which way the stick points. */
|
/** Held brake — a hockey stop, independent of which way the stick points. */
|
||||||
brake: false,
|
brake: false,
|
||||||
|
/**
|
||||||
|
* LT / L2 contain & backskate. Facing holds; stick drives travel relative
|
||||||
|
* to facing (including reverse) at a moderated speed.
|
||||||
|
*/
|
||||||
|
backskate: false,
|
||||||
|
|
||||||
// ---- read-only outputs the animator and the AI read -------------------
|
// ---- read-only outputs the animator and the AI read -------------------
|
||||||
/** Signed speed along the blade. Negative means gliding backwards. */
|
/** Signed speed along the blade. Negative means gliding backwards. */
|
||||||
@@ -108,6 +146,7 @@ export function applyIntent(s, msg) {
|
|||||||
s.iz = iz;
|
s.iz = iz;
|
||||||
s.sprint = !!msg.sprint;
|
s.sprint = !!msg.sprint;
|
||||||
s.brake = !!msg.brake;
|
s.brake = !!msg.brake;
|
||||||
|
s.backskate = !!msg.backskate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unit forward for a yaw, in three.js's convention (+Z is forward at yaw 0). */
|
/** Unit forward for a yaw, in three.js's convention (+Z is forward at yaw 0). */
|
||||||
@@ -123,15 +162,17 @@ export const forwardZ = (yaw) => Math.cos(yaw);
|
|||||||
export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
||||||
const intentLen = Math.min(1, Math.hypot(s.ix, s.iz));
|
const intentLen = Math.min(1, Math.hypot(s.ix, s.iz));
|
||||||
const speed0 = Math.hypot(s.vx, s.vz);
|
const speed0 = Math.hypot(s.vx, s.vz);
|
||||||
|
const backskating = !!s.backskate;
|
||||||
|
|
||||||
// ---- 1. body yaw --------------------------------------------------------
|
// ---- 1. body yaw --------------------------------------------------------
|
||||||
// The skater turns their body toward the stick. How fast falls off with
|
// Normal skating: body turns toward the stick.
|
||||||
// speed: at a standstill you can spin on the spot, at full flight you have
|
// LT contain: facing *holds* — stick steers travel relative to the chest so
|
||||||
// to carve the turn.
|
// a reverse push is heels-first without spinning around.
|
||||||
if (intentLen > 0.05) {
|
if (intentLen > 0.05 && !(backskating && SKATE.containTurnScale <= 0)) {
|
||||||
const intentYaw = Math.atan2(s.ix, s.iz);
|
const intentYaw = Math.atan2(s.ix, s.iz);
|
||||||
const t = clamp(speed0 / SKATE.sprintSpeed, 0, 1);
|
const t = clamp(speed0 / SKATE.sprintSpeed, 0, 1);
|
||||||
const rate = SKATE.turnRate + (SKATE.turnRateFast - SKATE.turnRate) * t;
|
let rate = SKATE.turnRate + (SKATE.turnRateFast - SKATE.turnRate) * t;
|
||||||
|
if (backskating) rate *= SKATE.containTurnScale;
|
||||||
s.yaw = lerpAngle(s.yaw, intentYaw, Math.min(1, rate * dt));
|
s.yaw = lerpAngle(s.yaw, intentYaw, Math.min(1, rate * dt));
|
||||||
}
|
}
|
||||||
s.yaw = wrapAngle(s.yaw);
|
s.yaw = wrapAngle(s.yaw);
|
||||||
@@ -140,17 +181,22 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
|||||||
// Drag the momentum vector onto the blade line. The blade is a line, not a
|
// Drag the momentum vector onto the blade line. The blade is a line, not a
|
||||||
// ray, so a skater gliding backwards keeps gliding backwards instead of
|
// ray, so a skater gliding backwards keeps gliding backwards instead of
|
||||||
// being snapped through 180°.
|
// being snapped through 180°.
|
||||||
|
//
|
||||||
|
// Under LT we ease the grip a little so reverse and lateral shuffles are not
|
||||||
|
// immediately crushed onto a pure blade line.
|
||||||
s.carve = 0;
|
s.carve = 0;
|
||||||
if (speed0 > 1e-4) {
|
if (speed0 > 1e-4) {
|
||||||
const velYaw = Math.atan2(s.vx, s.vz);
|
const velYaw = Math.atan2(s.vx, s.vz);
|
||||||
const off = wrapAngle(s.yaw - velYaw);
|
const off = wrapAngle(s.yaw - velYaw);
|
||||||
const bladeOff = Math.abs(off) > Math.PI / 2 ? wrapAngle(off - Math.sign(off) * Math.PI) : off;
|
const bladeOff = Math.abs(off) > Math.PI / 2 ? wrapAngle(off - Math.sign(off) * Math.PI) : off;
|
||||||
const grip = 1 - Math.exp(-SKATE.edgeGrip * dt);
|
const edge = backskating ? SKATE.edgeGrip * 0.55 : SKATE.edgeGrip;
|
||||||
|
const grip = 1 - Math.exp(-edge * dt);
|
||||||
const turnBy = bladeOff * grip;
|
const turnBy = bladeOff * grip;
|
||||||
const newVelYaw = velYaw + turnBy;
|
const newVelYaw = velYaw + turnBy;
|
||||||
// Redirect preserves magnitude; the scrub below is the only speed cost, so
|
// Redirect preserves magnitude; the scrub below is the only speed cost, so
|
||||||
// a wide turn is nearly free and a hard one bleeds.
|
// a wide turn is nearly free and a hard one bleeds.
|
||||||
const kept = 1 - clamp(SKATE.carveScrub * Math.abs(turnBy), 0, 0.6);
|
const scrub = backskating ? SKATE.carveScrub * 0.45 : SKATE.carveScrub;
|
||||||
|
const kept = 1 - clamp(scrub * Math.abs(turnBy), 0, 0.6);
|
||||||
const speed = speed0 * kept;
|
const speed = speed0 * kept;
|
||||||
s.vx = Math.sin(newVelYaw) * speed;
|
s.vx = Math.sin(newVelYaw) * speed;
|
||||||
s.vz = Math.cos(newVelYaw) * speed;
|
s.vz = Math.cos(newVelYaw) * speed;
|
||||||
@@ -165,21 +211,86 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
|||||||
const rz = -Math.sin(s.yaw);
|
const rz = -Math.sin(s.yaw);
|
||||||
let vr = s.vx * rx + s.vz * rz;
|
let vr = s.vx * rx + s.vz * rz;
|
||||||
|
|
||||||
const maxSpeed = s.sprint ? SKATE.sprintSpeed : SKATE.cruiseSpeed;
|
// Stick relative to facing: +1 = stick ahead, −1 = stick behind the heels.
|
||||||
// How much of the stick points where the skater is facing. A stick pulled
|
|
||||||
// behind them is a request to turn (handled above) and, until the body comes
|
|
||||||
// round, a request to stop.
|
|
||||||
const align = intentLen > 0.05 ? (s.ix * fx + s.iz * fz) / intentLen : 0;
|
const align = intentLen > 0.05 ? (s.ix * fx + s.iz * fz) / intentLen : 0;
|
||||||
|
const side = intentLen > 0.05 ? (s.ix * rx + s.iz * rz) / intentLen : 0;
|
||||||
|
|
||||||
let effort = 0;
|
let effort = 0;
|
||||||
if (s.brake || (intentLen > 0.05 && align < -0.35 && vf > 0.4)) {
|
// Explicit brake, or reverse-stick hockey stop *while not* holding LT.
|
||||||
|
// With LT down, reverse stick means *skate* reverse — eyes stay on the play.
|
||||||
|
const reverseStop = !backskating
|
||||||
|
&& intentLen > 0.05
|
||||||
|
&& align < -0.35
|
||||||
|
&& vf > 0.4;
|
||||||
|
|
||||||
|
if (s.brake || reverseStop) {
|
||||||
// Hockey stop: both blades across the momentum.
|
// Hockey stop: both blades across the momentum.
|
||||||
const decel = SKATE.brakeDecel * dt;
|
const decel = SKATE.brakeDecel * dt;
|
||||||
vf = Math.abs(vf) <= decel ? 0 : vf - Math.sign(vf) * decel;
|
vf = Math.abs(vf) <= decel ? 0 : vf - Math.sign(vf) * decel;
|
||||||
effort = 1;
|
effort = 1;
|
||||||
|
} else if (backskating) {
|
||||||
|
// Contain gear: facing holds; stick drives travel relative to the chest.
|
||||||
|
// Forward is a crawl (don't fly the zone). Reverse and left/right are
|
||||||
|
// snappy so a D-man can open up, gap, and jump back into the play.
|
||||||
|
const fwdMax = s.sprint ? SKATE.containForwardSprint : SKATE.containForwardSpeed;
|
||||||
|
const backMax = s.sprint ? SKATE.containBackSprint : SKATE.containBackSpeed;
|
||||||
|
const latMax = s.sprint ? SKATE.containLateralSprint : SKATE.containLateralSpeed;
|
||||||
|
|
||||||
|
if (intentLen > 0.08) {
|
||||||
|
// Desired blade speed: stick fore → crawl, stick aft → reverse.
|
||||||
|
const alongMax = align >= 0 ? fwdMax : backMax;
|
||||||
|
const desiredVf = align * intentLen * alongMax;
|
||||||
|
const gap = desiredVf - vf;
|
||||||
|
// Near target ease off; when flipping direction, stay full-send.
|
||||||
|
const flipping = Math.abs(vf) > 0.4
|
||||||
|
&& Math.abs(desiredVf) > 0.2
|
||||||
|
&& Math.sign(desiredVf) !== Math.sign(vf);
|
||||||
|
const headroom = flipping
|
||||||
|
? 1
|
||||||
|
: (Math.abs(gap) > 1e-4
|
||||||
|
? clamp(Math.abs(gap) / Math.max(0.3, alongMax), 0, 1)
|
||||||
|
: 0);
|
||||||
|
let base;
|
||||||
|
if (flipping) base = SKATE.containPivotAccel;
|
||||||
|
else if (align < -0.08 || desiredVf < vf && desiredVf < 0) base = SKATE.containBackAccel;
|
||||||
|
else base = SKATE.containForwardAccel;
|
||||||
|
vf += Math.sign(gap || 1) * base * headroom * dt;
|
||||||
|
if (vf > fwdMax) vf = fwdMax;
|
||||||
|
if (vf < -backMax) vf = -backMax;
|
||||||
|
|
||||||
|
// Lateral shuffle: full side speed, high accel, snappy side-to-side cuts.
|
||||||
|
const desiredVr = side * intentLen * latMax;
|
||||||
|
const latGap = desiredVr - vr;
|
||||||
|
const latFlip = Math.abs(vr) > 0.35
|
||||||
|
&& Math.abs(desiredVr) > 0.15
|
||||||
|
&& Math.sign(desiredVr) !== Math.sign(vr);
|
||||||
|
const latHead = latFlip
|
||||||
|
? 1
|
||||||
|
: (Math.abs(latGap) > 1e-4
|
||||||
|
? clamp(Math.abs(latGap) / Math.max(0.25, latMax), 0, 1)
|
||||||
|
: 0);
|
||||||
|
const latBase = latFlip ? SKATE.containPivotAccel : SKATE.containLateralAccel;
|
||||||
|
vr += Math.sign(latGap || 1) * latBase * latHead * dt;
|
||||||
|
if (vr > latMax) vr = latMax;
|
||||||
|
if (vr < -latMax) vr = -latMax;
|
||||||
|
|
||||||
|
// Effort reads higher on reverse / cut so the legs keep moving.
|
||||||
|
const cut = Math.max(Math.abs(align), Math.abs(side));
|
||||||
|
effort = clamp(intentLen * (0.45 + 0.55 * cut), 0, 1);
|
||||||
|
} else {
|
||||||
|
// Stick centred under LT: sit in the gap. Bleed speed without snowplowing.
|
||||||
|
const decel = SKATE.containIdleDecel * dt;
|
||||||
|
if (Math.abs(vf) <= decel) vf = 0;
|
||||||
|
else vf -= Math.sign(vf) * decel;
|
||||||
|
if (Math.abs(vr) <= decel) vr = 0;
|
||||||
|
else vr -= Math.sign(vr) * decel;
|
||||||
|
effort = speed0 > 0.8 ? 0.35 : 0.08;
|
||||||
|
}
|
||||||
} else if (intentLen > 0.05 && align > 0.15) {
|
} else if (intentLen > 0.05 && align > 0.15) {
|
||||||
// Stride. The push weakens as the blade approaches the speed the legs can
|
// Forward stride. The push weakens as the blade approaches the speed the
|
||||||
// deliver, so top speed is a property of the stride rather than a clamp.
|
// legs can deliver, so top speed is a property of the stride rather than
|
||||||
|
// a clamp.
|
||||||
|
const maxSpeed = s.sprint ? SKATE.sprintSpeed : SKATE.cruiseSpeed;
|
||||||
const base = s.sprint ? SKATE.sprintAccel : SKATE.accel;
|
const base = s.sprint ? SKATE.sprintAccel : SKATE.accel;
|
||||||
const headroom = clamp(1 - vf / maxSpeed, 0, 1);
|
const headroom = clamp(1 - vf / maxSpeed, 0, 1);
|
||||||
vf += base * align * intentLen * headroom * dt;
|
vf += base * align * intentLen * headroom * dt;
|
||||||
@@ -195,9 +306,13 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
|
|||||||
const drag = (SKATE.glideDrag + SKATE.dragQuad * vf * vf) * dt;
|
const drag = (SKATE.glideDrag + SKATE.dragQuad * vf * vf) * dt;
|
||||||
vf = Math.abs(vf) <= drag ? 0 : vf - Math.sign(vf) * drag;
|
vf = Math.abs(vf) <= drag ? 0 : vf - Math.sign(vf) * drag;
|
||||||
}
|
}
|
||||||
// Whatever lateral slip survived the carve dies here — it is only ever a
|
// Lateral: normally killed hard. Under LT a shuffle is intentional — barely
|
||||||
// rounding remnant, but leaving it in lets a skater drift sideways forever.
|
// scrub so left/right accel actually sticks between steps.
|
||||||
vr *= Math.exp(-SKATE.edgeGrip * 2 * dt);
|
if (backskating) {
|
||||||
|
vr *= Math.exp(-SKATE.edgeGrip * 0.12 * dt);
|
||||||
|
} else {
|
||||||
|
vr *= Math.exp(-SKATE.edgeGrip * 2 * dt);
|
||||||
|
}
|
||||||
|
|
||||||
s.vx = fx * vf + rx * vr;
|
s.vx = fx * vf + rx * vr;
|
||||||
s.vz = fz * vf + rz * vr;
|
s.vz = fz * vf + rz * vr;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const SKATE_POSE = {
|
|||||||
* Foot targets are not written here; they are world-space and belong to the
|
* Foot targets are not written here; they are world-space and belong to the
|
||||||
* stepper.
|
* stepper.
|
||||||
*/
|
*/
|
||||||
export function poseSkate(P, { gait, speed, bank, phase, t }) {
|
export function poseSkate(P, { gait, speed, bank, phase, t, backskating = false }) {
|
||||||
const K = SKATE_POSE;
|
const K = SKATE_POSE;
|
||||||
const fast = clamp(speed / 7, 0, 1);
|
const fast = clamp(speed / 7, 0, 1);
|
||||||
const s1 = Math.sin(phase * Math.PI * 2);
|
const s1 = Math.sin(phase * Math.PI * 2);
|
||||||
@@ -60,8 +60,11 @@ export function poseSkate(P, { gait, speed, bank, phase, t }) {
|
|||||||
const idle = (1 - gait) * Math.sin(t * 1.6) * 0.02;
|
const idle = (1 - gait) * Math.sin(t * 1.6) * 0.02;
|
||||||
|
|
||||||
const crouch = lerp(K.crouchIdle, K.crouchStride, gait) + idle;
|
const crouch = lerp(K.crouchIdle, K.crouchStride, gait) + idle;
|
||||||
const pitch = lerp(K.leanIdle, K.leanFast, fast);
|
// Backskating is more upright — eyes on the rush, weight over the heels —
|
||||||
const twist = K.hipTwist * gait;
|
// not the deep forward lean of a full-speed stride.
|
||||||
|
const pitchScale = backskating ? 0.45 : 1;
|
||||||
|
const pitch = lerp(K.leanIdle, K.leanFast, fast) * pitchScale;
|
||||||
|
const twist = K.hipTwist * gait * (backskating ? 0.7 : 1);
|
||||||
|
|
||||||
// Pelvis rocks with the push — the hip on the pushing side drops and rotates
|
// Pelvis rocks with the push — the hip on the pushing side drops and rotates
|
||||||
// open, which is most of what makes a stride read as a stride and not a run.
|
// open, which is most of what makes a stride read as a stride and not a run.
|
||||||
|
|||||||
@@ -285,6 +285,76 @@ export function posePass(P, { phase = 0, aim = 0 }) {
|
|||||||
E(P.q.handL, -0.1, 0, -0.12);
|
E(P.q.handL, -0.1, 0, -0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stick spread (RB hold): dominant arm drives the blade on a *frontal* cone —
|
||||||
|
* centre is out in front of the chest, Skill Stick X (`waggle` −1..1) sweeps
|
||||||
|
* left↔right up to ±90°. Never behind the hips. Non-dominant arm is off the
|
||||||
|
* shaft, out to the side in a ready stance. Slight crouch.
|
||||||
|
*
|
||||||
|
* Authored for a right shot (top hand R). Left shots mirror.
|
||||||
|
*/
|
||||||
|
export function poseSpread(P, { waggle = 0, phase = 1 } = {}) {
|
||||||
|
const w = clamp(phase, 0, 1);
|
||||||
|
// Skill Stick +X = screen-right. Match the stick target's sign flip so the
|
||||||
|
// arm and blade travel the same way (see skateAnimator SPREAD_ARC).
|
||||||
|
const a = -clamp(waggle, -1, 1);
|
||||||
|
const ang = a * (Math.PI / 2);
|
||||||
|
const fwd = Math.cos(ang); // 1 centre, 0 at full left/right
|
||||||
|
const lat = Math.sin(ang); // body-right after the stick-sign flip
|
||||||
|
// Bone space: +Y twist that opens the right arm across the body is opposite
|
||||||
|
// of body-right, so lateral into the arm uses −lat.
|
||||||
|
const side = -lat;
|
||||||
|
|
||||||
|
// Sit into it — eyes up the ice; torso opens with the lateral sweep only.
|
||||||
|
E(P.q.spine1, 0.1 * w, -0.04 * w + side * 0.12, 0);
|
||||||
|
E(P.q.spine2, 0.12 * w, -0.05 * w + side * 0.14, 0);
|
||||||
|
E(P.q.spine3, 0.08 * w, -0.04 * w + side * 0.12, 0);
|
||||||
|
E(P.q.neck, -0.08 * w, 0.06 * w - side * 0.14, 0);
|
||||||
|
E(P.q.head, -0.06 * w, 0.06 * w - side * 0.16, 0);
|
||||||
|
|
||||||
|
// Dominant (right) arm: extended low. Centre = straight out in front;
|
||||||
|
// full stick swings the arm across the body front to each side (not
|
||||||
|
// forward/back along the midline).
|
||||||
|
E(P.q.clavicleR, 0.04 * w, -0.06 * w + side * 0.22, 0.05 * w);
|
||||||
|
E(
|
||||||
|
P.q.upperArmR,
|
||||||
|
// Pitch: reach low toward the ice; a bit more extended when out front.
|
||||||
|
lerp(-0.42, -0.28, w) - fwd * 0.12,
|
||||||
|
// Yaw: centre ≈ forward of the shoulder; lat swings ±90° in front.
|
||||||
|
lerp(0.42, 0.12, w) + side * 1.05,
|
||||||
|
lerp(0.68, 0.0, w) + Math.abs(lat) * 0.1,
|
||||||
|
);
|
||||||
|
E(
|
||||||
|
P.q.forearmR,
|
||||||
|
lerp(-1.35, -0.2, w) - fwd * 0.08,
|
||||||
|
lerp(0.02, 0.04, w) + side * 0.25,
|
||||||
|
lerp(0.32, 0.0, w),
|
||||||
|
);
|
||||||
|
E(
|
||||||
|
P.q.handR,
|
||||||
|
lerp(-0.12, -0.2, w),
|
||||||
|
lerp(0.12, 0.0, w) + side * 0.22,
|
||||||
|
lerp(0.04, 0.06, w),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-dominant (left) arm: detached, out to the side and slightly forward —
|
||||||
|
// ready stance, does not follow the waggle.
|
||||||
|
E(P.q.clavicleL, 0.05 * w, 0.12 * w, -0.06);
|
||||||
|
E(
|
||||||
|
P.q.upperArmL,
|
||||||
|
lerp(-0.38, -0.26, w),
|
||||||
|
lerp(0.1, 0.68, w), // open wide to the left
|
||||||
|
lerp(-0.48, 0.55, w), // rolled out, clear of the shaft
|
||||||
|
);
|
||||||
|
E(
|
||||||
|
P.q.forearmL,
|
||||||
|
lerp(-1.32, -0.92, w),
|
||||||
|
-0.05,
|
||||||
|
lerp(0.18, -0.14, w),
|
||||||
|
);
|
||||||
|
E(P.q.handL, -0.05, 0.04, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poke check: a stab. One hand, the whole arm extending forward with the body
|
* Poke check: a stab. One hand, the whole arm extending forward with the body
|
||||||
* reaching after it, back almost as fast as it went out.
|
* reaching after it, back almost as fast as it went out.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { poseSkate, poseStop } from './poses/skate.js';
|
|||||||
import {
|
import {
|
||||||
STICK_ARMS, STICK_BONES, STICK_SPINE,
|
STICK_ARMS, STICK_BONES, STICK_SPINE,
|
||||||
mirrorStickwork,
|
mirrorStickwork,
|
||||||
poseCarry, posePass, posePoke,
|
poseCarry, posePass, posePoke, poseSpread,
|
||||||
} from './poses/stickwork.js';
|
} from './poses/stickwork.js';
|
||||||
import { frameSpan, sampleTiltStick } from './clip.js';
|
import { frameSpan, sampleTiltStick } from './clip.js';
|
||||||
import { shot1 } from './clips/shot1.js';
|
import { shot1 } from './clips/shot1.js';
|
||||||
@@ -36,6 +36,21 @@ import { STICK } from '../character/stick.js';
|
|||||||
|
|
||||||
/** How far the Skill Stick can push the blade around the carrier, metres. */
|
/** How far the Skill Stick can push the blade around the carrier, metres. */
|
||||||
const STICK_REACH = { side: 0.5, fwd: 0.34 };
|
const STICK_REACH = { side: 0.5, fwd: 0.34 };
|
||||||
|
/**
|
||||||
|
* Stick-spread frontal cone on the ice.
|
||||||
|
*
|
||||||
|
* Built from the skater's *facing* (forward / right), not a local-axis guess:
|
||||||
|
* waggle 0 → straight ahead
|
||||||
|
* waggle +1 → 90° to the skater's right
|
||||||
|
* waggle −1 → 90° to the skater's left
|
||||||
|
* Always in the front half-plane (never behind the hips).
|
||||||
|
*/
|
||||||
|
const SPREAD_ARC = {
|
||||||
|
/** Blade distance from body centre on the ice, metres. */
|
||||||
|
reach: 1.20,
|
||||||
|
/** Half-arc at full stick, radians (π/2 = 90° each way). */
|
||||||
|
maxAngle: Math.PI / 2,
|
||||||
|
};
|
||||||
|
|
||||||
/** Foot joint height above the ice — boot plus blade. */
|
/** Foot joint height above the ice — boot plus blade. */
|
||||||
const FOOT_SOLE = 0.085;
|
const FOOT_SOLE = 0.085;
|
||||||
@@ -108,6 +123,8 @@ export function buildAnimator(skelData, mover) {
|
|||||||
/** Rate the velocity vector is turning, rad/s. Drives the bank. */
|
/** Rate the velocity vector is turning, rad/s. Drives the bank. */
|
||||||
yawRate: 0,
|
yawRate: 0,
|
||||||
braking: false,
|
braking: false,
|
||||||
|
/** LT backskate / reverse blade — more upright, smaller forward lean. */
|
||||||
|
backskating: false,
|
||||||
|
|
||||||
// ---- derived, smoothed --------------------------------------------------
|
// ---- derived, smoothed --------------------------------------------------
|
||||||
/** Stride amplitude, 0 (pure glide) .. 1 (digging in). */
|
/** Stride amplitude, 0 (pure glide) .. 1 (digging in). */
|
||||||
@@ -123,6 +140,12 @@ export function buildAnimator(skelData, mover) {
|
|||||||
// ---- stickwork ---------------------------------------------------------
|
// ---- stickwork ---------------------------------------------------------
|
||||||
/** True while this skater has the puck. Decides the resting grip. */
|
/** True while this skater has the puck. Decides the resting grip. */
|
||||||
hasPuck: false,
|
hasPuck: false,
|
||||||
|
/**
|
||||||
|
* Proximity grab: world-space point to IK the blade toward while fishing
|
||||||
|
* for a loose puck. `weight` 0..1. Null when not grabbing.
|
||||||
|
* @type {{ x: number, y: number, z: number, weight: number } | null}
|
||||||
|
*/
|
||||||
|
grabTarget: null,
|
||||||
/** Skill Stick, -1..1. Moves the hands, which moves the blade. */
|
/** Skill Stick, -1..1. Moves the hands, which moves the blade. */
|
||||||
handling: { x: 0, y: 0 },
|
handling: { x: 0, y: 0 },
|
||||||
/** Held wind-up charge from the Skill Stick, 0..1. */
|
/** Held wind-up charge from the Skill Stick, 0..1. */
|
||||||
@@ -130,13 +153,15 @@ export function buildAnimator(skelData, mover) {
|
|||||||
/** The stick, so the animator can drive its socket and IK onto its shaft. */
|
/** The stick, so the animator can drive its socket and IK onto its shaft. */
|
||||||
stick: null,
|
stick: null,
|
||||||
/**
|
/**
|
||||||
* Current stick action: null, 'windup', 'shoot', 'pass' or 'poke'.
|
* Current stick action: null, 'windup', 'spread', 'shoot', 'pass' or 'poke'.
|
||||||
* Wind-up is held; the other three run once and blend out.
|
* Wind-up and spread are held; the others run once and blend out.
|
||||||
*/
|
*/
|
||||||
action: null,
|
action: null,
|
||||||
actionTime: 0,
|
actionTime: 0,
|
||||||
actionPower: 1,
|
actionPower: 1,
|
||||||
actionAim: 0,
|
actionAim: 0,
|
||||||
|
/** Skill Stick X while stick-spreading, −1..1. */
|
||||||
|
spreadWaggle: 0,
|
||||||
/** A released held wind-up starts shot1 at its midpoint, not frame zero. */
|
/** A released held wind-up starts shot1 at its midpoint, not frame zero. */
|
||||||
actionFromWindup: false,
|
actionFromWindup: false,
|
||||||
/** Eased 0..1 between the settled grip and the one-handed dangle. */
|
/** Eased 0..1 between the settled grip and the one-handed dangle. */
|
||||||
@@ -338,6 +363,17 @@ export function buildAnimator(skelData, mover) {
|
|||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (anim.action === 'spread') {
|
||||||
|
// Held stick-spread. Full replace of the arms once blended in.
|
||||||
|
const w = Math.min(1, anim.actionTime / ACTION_BLEND);
|
||||||
|
poseSpread(overlay, {
|
||||||
|
waggle: anim.spreadWaggle,
|
||||||
|
phase: Math.min(1, anim.actionTime / 0.14),
|
||||||
|
});
|
||||||
|
if (anim.shotSign < 0) mirrorStickwork(overlay);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
const duration = ACTION_TIME[anim.action] ?? 0.3;
|
const duration = ACTION_TIME[anim.action] ?? 0.3;
|
||||||
const t = anim.actionTime / duration;
|
const t = anim.actionTime / duration;
|
||||||
if (t >= 1) {
|
if (t >= 1) {
|
||||||
@@ -369,6 +405,11 @@ export function buildAnimator(skelData, mover) {
|
|||||||
/** Which socket grip the stick should be using right now, and the blend. */
|
/** Which socket grip the stick should be using right now, and the blend. */
|
||||||
function gripFor() {
|
function gripFor() {
|
||||||
if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)];
|
if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)];
|
||||||
|
if (anim.action === 'spread') {
|
||||||
|
// Blend to full extension quickly, then hold. Waggle is applied after
|
||||||
|
// as a target offset so the blade sweeps on the ice.
|
||||||
|
return ['carry', 'spread', Math.min(1, anim.actionTime / 0.14)];
|
||||||
|
}
|
||||||
if (anim.action === 'shoot') {
|
if (anim.action === 'shoot') {
|
||||||
// Three beats matching poseShot: loaded → blade square on the ice →
|
// Three beats matching poseShot: loaded → blade square on the ice →
|
||||||
// follow-through. Contact is short and early so the bottom of the blade
|
// follow-through. Contact is short and early so the bottom of the blade
|
||||||
@@ -398,7 +439,13 @@ export function buildAnimator(skelData, mover) {
|
|||||||
bank: anim.bank,
|
bank: anim.bank,
|
||||||
phase: anim.stridePhase,
|
phase: anim.stridePhase,
|
||||||
t,
|
t,
|
||||||
|
backskating: anim.backskating,
|
||||||
});
|
});
|
||||||
|
// Stick-spread sits a little lower — reach out, weight over the skates.
|
||||||
|
if (anim.action === 'spread') {
|
||||||
|
const crouch = Math.min(1, anim.actionTime / 0.14) * 0.07;
|
||||||
|
P.rootOffset.y -= crouch;
|
||||||
|
}
|
||||||
for (const side of ['L', 'R']) {
|
for (const side of ['L', 'R']) {
|
||||||
const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1;
|
const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1;
|
||||||
const toe = strideLocal(side, p, anim.gait, _localFoot);
|
const toe = strideLocal(side, p, anim.gait, _localFoot);
|
||||||
@@ -643,9 +690,14 @@ export function buildAnimator(skelData, mover) {
|
|||||||
// Overwriting it was what stood everybody upright the moment they picked up
|
// Overwriting it was what stood everybody upright the moment they picked up
|
||||||
// a stick.
|
// a stick.
|
||||||
for (const n of STICK_BONES) overlay.q[n].identity();
|
for (const n of STICK_BONES) overlay.q[n].identity();
|
||||||
|
// While fishing for a loose puck, lean the carry a little more extended so
|
||||||
|
// the stick IK out to the puck does not fight a tucked rest pose.
|
||||||
|
const grabReach = anim.grabTarget
|
||||||
|
? clamp(anim.grabTarget.weight, 0, 1) * 0.85
|
||||||
|
: 0;
|
||||||
poseCarry(overlay, {
|
poseCarry(overlay, {
|
||||||
hustle: anim.hustleGrip,
|
hustle: Math.max(anim.hustleGrip, grabReach * 0.45),
|
||||||
reach: anim.handling.y,
|
reach: anim.handling.y + grabReach * 0.55,
|
||||||
lateral: anim.handling.x,
|
lateral: anim.handling.x,
|
||||||
});
|
});
|
||||||
// Authored for a right shot; left shots run the mirrored pose set.
|
// Authored for a right shot; left shots run the mirrored pose set.
|
||||||
@@ -724,7 +776,45 @@ export function buildAnimator(skelData, mover) {
|
|||||||
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
|
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
|
||||||
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
|
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
|
||||||
}
|
}
|
||||||
_stickTarget.applyMatrix4(mover.matrixWorld);
|
// Stick-spread: frontal left↔right cone in *world* space from the
|
||||||
|
// skater's facing. Using forward/right of originYaw (same basis as the
|
||||||
|
// feet) so the arc cannot get re-interpreted as local X = "front".
|
||||||
|
if (anim.action === 'spread') {
|
||||||
|
// Skill Stick +X is screen-right; body-right is the opposite sign on
|
||||||
|
// the forward/right basis used here (same flip as stickhandling).
|
||||||
|
const ang = -clamp(anim.spreadWaggle, -1, 1) * SPREAD_ARC.maxAngle;
|
||||||
|
// forward weight = cos(ang), lateral weight = sin(ang) on body-right.
|
||||||
|
const fwdW = Math.cos(ang);
|
||||||
|
const latW = Math.sin(ang);
|
||||||
|
const r = SPREAD_ARC.reach;
|
||||||
|
const yaw = anim.originYaw;
|
||||||
|
const fx = Math.sin(yaw);
|
||||||
|
const fz = Math.cos(yaw);
|
||||||
|
const rx = Math.cos(yaw);
|
||||||
|
const rz = -Math.sin(yaw);
|
||||||
|
_stickTarget.set(
|
||||||
|
anim.origin.x + (fx * fwdW + rx * latW) * r,
|
||||||
|
0.03,
|
||||||
|
anim.origin.z + (fz * fwdW + rz * latW) * r,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_stickTarget.applyMatrix4(mover.matrixWorld);
|
||||||
|
// Proximity grab: blend the blade aim toward the loose puck so the
|
||||||
|
// stick reaches out to snag it. Skipped while spreading / shooting.
|
||||||
|
const grab = anim.grabTarget;
|
||||||
|
if (
|
||||||
|
grab
|
||||||
|
&& !anim.hasPuck
|
||||||
|
&& anim.action !== 'shoot'
|
||||||
|
&& anim.action !== 'pass'
|
||||||
|
&& anim.action !== 'poke'
|
||||||
|
) {
|
||||||
|
const gw = clamp(grab.weight, 0, 1);
|
||||||
|
_stickTarget.x = lerp(_stickTarget.x, grab.x, gw);
|
||||||
|
_stickTarget.y = lerp(_stickTarget.y, grab.y ?? 0.03, gw);
|
||||||
|
_stickTarget.z = lerp(_stickTarget.z, grab.z, gw);
|
||||||
|
}
|
||||||
|
}
|
||||||
B[`hand${top}`].getWorldPosition(_handPos);
|
B[`hand${top}`].getWorldPosition(_handPos);
|
||||||
B[`hand${top}`].getWorldQuaternion(_handQuat);
|
B[`hand${top}`].getWorldQuaternion(_handQuat);
|
||||||
_handQuat.invert();
|
_handQuat.invert();
|
||||||
@@ -740,7 +830,9 @@ export function buildAnimator(skelData, mover) {
|
|||||||
// applying it on top of a blend from a limp pose yanks the lower hand
|
// applying it on top of a blend from a limp pose yanks the lower hand
|
||||||
// onto the shaft mid-rise — measured as a ~0.9 m jump on handR for a
|
// onto the shaft mid-rise — measured as a ~0.9 m jump on handR for a
|
||||||
// left shot, where the lower hand *is* the right.
|
// left shot, where the lower hand *is* the right.
|
||||||
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1)
|
// One-handed: poke and stick-spread (dominant hand only).
|
||||||
|
const oneHanded = anim.action === 'poke' || anim.action === 'spread';
|
||||||
|
const twoHanded = (1 - anim.hustleGrip) * (oneHanded ? 0.08 : 1)
|
||||||
* (anim.blend >= 1 ? 1 : 0);
|
* (anim.blend >= 1 ? 1 : 0);
|
||||||
if (twoHanded > 0.05 && !referenceHandsAligned) {
|
if (twoHanded > 0.05 && !referenceHandsAligned) {
|
||||||
// Preferred lower-hand grip is a bit down the shaft (hands apart, the
|
// Preferred lower-hand grip is a bit down the shaft (hands apart, the
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ export function createSkater({
|
|||||||
animator.bladeSpeed = s.bladeSpeed;
|
animator.bladeSpeed = s.bladeSpeed;
|
||||||
animator.effort = s.effort;
|
animator.effort = s.effort;
|
||||||
animator.yawRate = yawRate;
|
animator.yawRate = yawRate;
|
||||||
animator.braking = !!s.brake;
|
// Hockey-stop pose only for a true snowplow. Backskating uses the skate
|
||||||
|
// state with reverse blade speed (heels-first strides).
|
||||||
|
animator.braking = !!s.brake && !s.backskate;
|
||||||
|
animator.backskating = !!s.backskate || s.bladeSpeed < -0.35;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Advance animation, the reaction envelope, and the get-up timer. */
|
/** Advance animation, the reaction envelope, and the get-up timer. */
|
||||||
|
|||||||
+151
-72
@@ -1,4 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { clamp } from '../core/math.js';
|
||||||
import { PART } from './body.js';
|
import { PART } from './body.js';
|
||||||
import { JERSEY_COVERAGE, jerseyParts, skinnedFrom } from './jersey.js';
|
import { JERSEY_COVERAGE, jerseyParts, skinnedFrom } from './jersey.js';
|
||||||
import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
|
import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
|
||||||
@@ -50,21 +51,24 @@ const DOWN = new THREE.Vector3(0, -1, 0);
|
|||||||
export const KIT = {
|
export const KIT = {
|
||||||
helmet: {
|
helmet: {
|
||||||
/** Skull centre in head-bone-local space. */
|
/** Skull centre in head-bone-local space. */
|
||||||
riseY: 0.094,
|
riseY: 0.098,
|
||||||
pushZ: -0.004,
|
pushZ: 0.002,
|
||||||
rx: 0.114,
|
/** Half-widths of the shell ellipse (scaled by headF at build). */
|
||||||
ry: 0.148,
|
rx: 0.118,
|
||||||
rz: 0.125,
|
ry: 0.142,
|
||||||
|
rz: 0.132,
|
||||||
/**
|
/**
|
||||||
* Polar angle the shell starts at. This is the number that decides whether
|
* Polar angle the shell starts at. Lower = deeper bowl so the brim sits
|
||||||
* you get a helmet or a beanie: the bottom ring sits at
|
* under the ear line instead of reading as a beanie on the crown.
|
||||||
* riseY − ry·cos(phi0), so it has to come out *below* the ear line.
|
|
||||||
*/
|
*/
|
||||||
phi0: 0.36,
|
phi0: 0.28,
|
||||||
wall: 0.009,
|
wall: 0.011,
|
||||||
/** Brow line: everything in front of and below this is open face. */
|
/** Face port: open below the brow, full cage covers it. */
|
||||||
browY: 0.03,
|
browY: 0.018,
|
||||||
earY: -0.022,
|
portW: 0.078,
|
||||||
|
earY: -0.018,
|
||||||
|
/** Wire cage bar radius. */
|
||||||
|
barR: 0.0042,
|
||||||
},
|
},
|
||||||
/** Blade bottom, in foot-bone-local metres. Feet plant at y ≈ 0.09. */
|
/** Blade bottom, in foot-bone-local metres. Feet plant at y ≈ 0.09. */
|
||||||
bladeY: -0.09,
|
bladeY: -0.09,
|
||||||
@@ -414,10 +418,14 @@ export function buildSkaterGear(mats, skelData, phys) {
|
|||||||
const gloveR = makeGlove('R');
|
const gloveR = makeGlove('R');
|
||||||
|
|
||||||
// ---- 7. helmet ----------------------------------------------------------
|
// ---- 7. helmet ----------------------------------------------------------
|
||||||
// Same carved-shell builder as the goalie mask, cut differently: the whole
|
// White hard-shell player helmet with a full wire cage — not a team-coloured
|
||||||
// lower front is open face, with ear ports at the sides.
|
// beanie. Shape cues from a modern CCM/Bauer shell: flatter crown, defined
|
||||||
|
// brow, ear pockets, chin cup + strap, grid cage over the open face.
|
||||||
const H = KIT.helmet;
|
const H = KIT.helmet;
|
||||||
const skull = new THREE.Vector3(0, H.riseY, H.pushZ);
|
const skull = new THREE.Vector3(0, H.riseY, H.pushZ);
|
||||||
|
const WHITE = new THREE.Color(0xf4f6f8);
|
||||||
|
const WHITE_DIM = new THREE.Color(0xd8dde3);
|
||||||
|
const LINER = new THREE.Color(0x2a2e36);
|
||||||
|
|
||||||
function helmetSurface(theta, v, out) {
|
function helmetSurface(theta, v, out) {
|
||||||
const phi = H.phi0 + (Math.PI - H.phi0) * v;
|
const phi = H.phi0 + (Math.PI - H.phi0) * v;
|
||||||
@@ -427,55 +435,65 @@ export function buildSkaterGear(mats, skelData, phys) {
|
|||||||
const sx = Math.sin(theta);
|
const sx = Math.sin(theta);
|
||||||
const front = Math.max(0, f);
|
const front = Math.max(0, f);
|
||||||
const back = Math.max(0, -f);
|
const back = Math.max(0, -f);
|
||||||
|
const side = Math.abs(sx);
|
||||||
|
|
||||||
let rx = H.rx * headF;
|
// Flatten the crown slightly so it reads as a helmet, not a ball.
|
||||||
let rz = H.rz * headF;
|
const crownFlat = 1 - 0.14 * Math.max(0, 1 - v * 1.8) ** 2;
|
||||||
// Occipital shell carries out over the back of the skull.
|
let rx = H.rx * headF * crownFlat;
|
||||||
rz *= 1 + 0.10 * back * v;
|
let ry = H.ry * headF;
|
||||||
// Slight flat across the forehead.
|
let rz = H.rz * headF * crownFlat;
|
||||||
rz *= 1 - 0.06 * front * front * v;
|
// Occipital bowl + ear flair.
|
||||||
|
rz *= 1 + 0.12 * back * v;
|
||||||
|
rx *= 1 + 0.08 * side * (1 - v) * 0.6;
|
||||||
|
// Forehead flat / brow shelf.
|
||||||
|
rz *= 1 - 0.10 * front * front * clamp(1 - v * 1.4, 0, 1);
|
||||||
|
|
||||||
const x = rx * sp * sx;
|
const x = rx * sp * sx;
|
||||||
const y = -H.ry * headF * cp;
|
const y = -ry * cp;
|
||||||
let z = rz * sp * f;
|
let z = rz * sp * f;
|
||||||
// Brow lip juts forward over the eyes.
|
// Brow lip juts forward over the eyes.
|
||||||
const lip = Math.exp(-(((v - 0.08) / 0.12) ** 2)) * front ** 2;
|
const lip = Math.exp(-(((v - 0.1) / 0.11) ** 2)) * front ** 2;
|
||||||
z += 0.008 * lip;
|
z += 0.012 * lip * headF;
|
||||||
|
// Subtle centre ridge along the crown (vents live here visually).
|
||||||
return out.set(skull.x + x, skull.y + y, skull.z + z);
|
const ridge = Math.exp(-(sx * sx) / 0.08) * Math.max(0, 1 - v * 1.5) * 0.006 * headF;
|
||||||
|
return out.set(skull.x + x, skull.y + y + ridge, skull.z + z);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open face below the brow, plus a port over each ear. */
|
/** Open face below the brow + ear ports under the cups. */
|
||||||
const helmetPort = (p) => {
|
const helmetPort = (p) => {
|
||||||
const dy = p.y - skull.y;
|
const dy = p.y - skull.y;
|
||||||
const dz = p.z - skull.z;
|
const dz = p.z - skull.z;
|
||||||
const ax = Math.abs(p.x);
|
const ax = Math.abs(p.x);
|
||||||
// The face: front-centre below the brow. Narrow, so the shell keeps its
|
// Full face opening for the cage — wider and deeper than the old half-cap.
|
||||||
// cheek coverage instead of turning into a cap.
|
if (dz > 0.022 * headF && dy < H.browY && ax < H.portW * headF) return true;
|
||||||
if (dz > 0.028 && dy < H.browY && ax < 0.072) return true;
|
// Ear ports.
|
||||||
// Ear ports, covered by the cups.
|
if (
|
||||||
if (ax > 0.088 && dy < H.earY + 0.026 && dy > H.earY - 0.042 && Math.abs(dz + 0.014) < 0.038) {
|
ax > 0.09 * headF
|
||||||
|
&& dy < H.earY + 0.03
|
||||||
|
&& dy > H.earY - 0.048
|
||||||
|
&& Math.abs(dz + 0.01) < 0.042
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const helmetColor = (p, kind) => {
|
const helmetColor = (p, kind) => {
|
||||||
if (kind === 'inner') return PAL.pad;
|
if (kind === 'inner') return LINER;
|
||||||
const dy = p.y - skull.y;
|
const dy = p.y - skull.y;
|
||||||
// Dark brim around the bottom edge of the shell.
|
// Dark rubber brim / chin-strap mounts along the lower edge.
|
||||||
if (dy < -0.028) return PAL.trim;
|
if (dy < -0.055 * headF) return LINER;
|
||||||
// Centre stripe over the crown.
|
// Soft grey vents strip on the crown midline.
|
||||||
if (Math.abs(p.x) < 0.019 && dy > 0.03) return PAL.accent;
|
if (Math.abs(p.x) < 0.016 * headF && dy > 0.04 * headF) return WHITE_DIM;
|
||||||
return PAL.jersey;
|
return WHITE;
|
||||||
};
|
};
|
||||||
|
|
||||||
const helmet = new THREE.Group();
|
const helmet = new THREE.Group();
|
||||||
helmet.name = 'helmet';
|
helmet.name = 'helmet';
|
||||||
helmet.add(mesh(
|
helmet.add(mesh(
|
||||||
carvedShell({
|
carvedShell({
|
||||||
rows: 26,
|
rows: 32,
|
||||||
cols: 36,
|
cols: 44,
|
||||||
thickness: H.wall,
|
thickness: H.wall,
|
||||||
center: skull,
|
center: skull,
|
||||||
surface: helmetSurface,
|
surface: helmetSurface,
|
||||||
@@ -486,49 +504,104 @@ export function buildSkaterGear(mats, skelData, phys) {
|
|||||||
'helmetShell',
|
'helmetShell',
|
||||||
));
|
));
|
||||||
|
|
||||||
// Ear cups over the ports, on their own straps.
|
// Ear cups — white shells with dark rims over the ports.
|
||||||
for (const s of [1, -1]) {
|
for (const s of [1, -1]) {
|
||||||
const cup = loft([
|
const cup = loft([
|
||||||
S(V(s * 0.09, skull.y + H.earY, skull.z - 0.014), 0.028, 0.026, 3, PAL.trim),
|
S(V(s * 0.092 * headF, skull.y + H.earY, skull.z - 0.012), 0.032, 0.03, 3.2, WHITE),
|
||||||
S(V(s * 0.104, skull.y + H.earY, skull.z - 0.014), 0.03, 0.028, 3),
|
S(V(s * 0.108 * headF, skull.y + H.earY, skull.z - 0.012), 0.034, 0.032, 3.2, WHITE),
|
||||||
S(V(s * 0.111, skull.y + H.earY, skull.z - 0.014), 0.023, 0.021, 3),
|
S(V(s * 0.116 * headF, skull.y + H.earY, skull.z - 0.012), 0.026, 0.024, 3.5, WHITE_DIM),
|
||||||
], { radial: 12, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
|
], { radial: 14, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
|
||||||
helmet.add(mesh(cup, mats.hard, 'helmetEar'));
|
helmet.add(mesh(cup, mats.hard, 'helmetEar'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chin strap under the jaw.
|
// Chin cup + strap under the jaw.
|
||||||
|
{
|
||||||
|
const cup = loft([
|
||||||
|
S(V(0, skull.y - 0.128 * headF, skull.z + 0.048 * headF), 0.038, 0.018, 3.5, WHITE),
|
||||||
|
S(V(0, skull.y - 0.142 * headF, skull.z + 0.056 * headF), 0.042, 0.02, 3.5, WHITE),
|
||||||
|
S(V(0, skull.y - 0.152 * headF, skull.z + 0.05 * headF), 0.034, 0.016, 4, WHITE_DIM),
|
||||||
|
], { radial: 12, sub: 3 });
|
||||||
|
helmet.add(mesh(cup, mats.hard, 'helmetChinCup'));
|
||||||
|
}
|
||||||
helmet.add(mesh(
|
helmet.add(mesh(
|
||||||
tube([
|
tube([
|
||||||
V(-0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
|
V(-0.11 * headF, skull.y + H.earY - 0.01, skull.z - 0.008),
|
||||||
V(-0.07, skull.y - 0.12, skull.z + 0.03),
|
V(-0.072 * headF, skull.y - 0.118 * headF, skull.z + 0.028 * headF),
|
||||||
V(0, skull.y - 0.145, skull.z + 0.05),
|
V(0, skull.y - 0.14 * headF, skull.z + 0.052 * headF),
|
||||||
V(0.07, skull.y - 0.12, skull.z + 0.03),
|
V(0.072 * headF, skull.y - 0.118 * headF, skull.z + 0.028 * headF),
|
||||||
V(0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
|
V(0.11 * headF, skull.y + H.earY - 0.01, skull.z - 0.008),
|
||||||
], 0.006, { radial: 6 }),
|
], 0.0055, { radial: 6 }),
|
||||||
mats.strap,
|
mats.strap,
|
||||||
'helmetStrap',
|
'helmetStrap',
|
||||||
));
|
));
|
||||||
|
|
||||||
// Half visor: eye level only. Run it down over the whole face and the player
|
// Full wire cage over the face port — horizontal + vertical grid + rim.
|
||||||
// reads as a welder.
|
|
||||||
{
|
{
|
||||||
const arc = [];
|
const CAGE_W = H.portW * 1.22 * headF;
|
||||||
for (let i = 0; i <= 10; i++) {
|
const CAGE_H = 0.078 * headF;
|
||||||
const a = -0.82 + (1.64 * i) / 10;
|
const portCY = skull.y + H.browY - CAGE_H * 0.55;
|
||||||
arc.push(V(
|
const _probe = new THREE.Vector3();
|
||||||
Math.sin(a) * 0.106 * headF,
|
// Sample shell front at brow so the cage sits on the opening, not through it.
|
||||||
skull.y + 0.004,
|
helmetSurface(0, 0.12, _probe);
|
||||||
skull.z + Math.cos(a) * 0.116 * headF,
|
const baseZ = _probe.z - skull.z + 0.004;
|
||||||
));
|
const bulge = 0.038 * headF;
|
||||||
|
|
||||||
|
const cageAt = (x, dy) => {
|
||||||
|
const k = 1 - (x / CAGE_W) ** 2 - (dy / CAGE_H) ** 2;
|
||||||
|
const z = skull.z + baseZ + bulge * Math.sqrt(Math.max(0, k));
|
||||||
|
return V(x, portCY + dy, z);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bars = [];
|
||||||
|
// Horizontal bars (4).
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const dy = -CAGE_H * 0.72 + (CAGE_H * 1.44 * i) / 3;
|
||||||
|
const span = CAGE_W * Math.sqrt(Math.max(0, 1 - (dy / CAGE_H) ** 2));
|
||||||
|
if (span < 0.02) continue;
|
||||||
|
const pts = [];
|
||||||
|
for (let j = 0; j <= 10; j++) {
|
||||||
|
const x = -span + (2 * span * j) / 10;
|
||||||
|
pts.push(cageAt(clamp(x, -span * 0.995, span * 0.995), dy));
|
||||||
|
}
|
||||||
|
bars.push(tube(pts, H.barR, { radial: 5 }));
|
||||||
}
|
}
|
||||||
// The ring axes here are u = up, w = front-to-back, so `rx` is the shield's
|
// Vertical bars (3).
|
||||||
// height and `rz` is its thickness. Swap those two and you get a shelf
|
for (const fx of [-0.55, 0, 0.55]) {
|
||||||
// sticking out of the face instead of a shield hanging over the eyes.
|
const x = CAGE_W * fx;
|
||||||
const visor = loft(
|
const span = CAGE_H * Math.sqrt(Math.max(0, 1 - (x / Math.max(CAGE_W, 1e-4)) ** 2));
|
||||||
arc.map((c, i) => S(c, i === 0 || i === arc.length - 1 ? 0.026 : 0.038, 0.003, 3)),
|
if (span < 0.018) continue;
|
||||||
{ radial: 8, sub: 2, ref: new THREE.Vector3(0, 1, 0) },
|
const pts = [];
|
||||||
);
|
for (let j = 0; j <= 10; j++) {
|
||||||
helmet.add(mesh(visor, mats.visor, 'helmetVisor'));
|
const dy = -span + (2 * span * j) / 10;
|
||||||
|
pts.push(cageAt(x, clamp(dy, -span * 0.995, span * 0.995)));
|
||||||
|
}
|
||||||
|
bars.push(tube(pts, H.barR, { radial: 5 }));
|
||||||
|
}
|
||||||
|
// Perimeter rim, slightly thicker, anchored into the shell.
|
||||||
|
{
|
||||||
|
const ring = [];
|
||||||
|
for (let i = 0; i < 28; i++) {
|
||||||
|
const a = (i / 28) * Math.PI * 2;
|
||||||
|
const p = cageAt(CAGE_W * 1.04 * Math.cos(a), CAGE_H * 1.04 * Math.sin(a));
|
||||||
|
p.z -= 0.003;
|
||||||
|
ring.push(p);
|
||||||
|
}
|
||||||
|
bars.push(tube(ring, H.barR * 1.35, { radial: 6, closed: true, segments: 56 }));
|
||||||
|
}
|
||||||
|
helmet.add(mesh(mergeBars(bars), mats.cage, 'helmetCage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny crown vent ridges (read as vents from a distance).
|
||||||
|
for (const sx of [-0.028, 0.028]) {
|
||||||
|
helmet.add(mesh(
|
||||||
|
tube([
|
||||||
|
V(sx * headF, skull.y + 0.1 * headF, skull.z - 0.04 * headF),
|
||||||
|
V(sx * headF, skull.y + 0.118 * headF, skull.z + 0.01 * headF),
|
||||||
|
V(sx * headF, skull.y + 0.1 * headF, skull.z + 0.05 * headF),
|
||||||
|
], 0.0035, { radial: 5 }),
|
||||||
|
mats.hard,
|
||||||
|
'helmetVent',
|
||||||
|
));
|
||||||
}
|
}
|
||||||
pieces.push(helmet);
|
pieces.push(helmet);
|
||||||
|
|
||||||
@@ -583,8 +656,14 @@ export function buildSkaterGearMaterials(teamJersey, teamAccent = 0xf0e6d2) {
|
|||||||
hard: new THREE.MeshStandardMaterial({
|
hard: new THREE.MeshStandardMaterial({
|
||||||
color: 0xffffff,
|
color: 0xffffff,
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
roughness: 0.38,
|
roughness: 0.32,
|
||||||
metalness: 0.06,
|
metalness: 0.08,
|
||||||
|
}),
|
||||||
|
/** Wire face cage — dark steel, not painted. */
|
||||||
|
cage: new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x1a1c22,
|
||||||
|
roughness: 0.35,
|
||||||
|
metalness: 0.65,
|
||||||
}),
|
}),
|
||||||
steel: new THREE.MeshStandardMaterial({
|
steel: new THREE.MeshStandardMaterial({
|
||||||
color: 0xc8ccd4,
|
color: 0xc8ccd4,
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export const GRIP = {
|
|||||||
follow: { target: [0.28, 0.72, 0.88], roll: 0.42 },
|
follow: { target: [0.28, 0.72, 0.88], roll: 0.42 },
|
||||||
/** Poke: thrust out flat, as far ahead as the arm reaches. */
|
/** Poke: thrust out flat, as far ahead as the arm reaches. */
|
||||||
poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 },
|
poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 },
|
||||||
|
/**
|
||||||
|
* Stick spread (RB hold): blade on the ice out in front at centre.
|
||||||
|
* The live aim is a world-space frontal cone from the skater's facing
|
||||||
|
* (see skateAnimator SPREAD_ARC) — this GRIP is only the blend-in rest.
|
||||||
|
* +Z is forward, +X is left.
|
||||||
|
*/
|
||||||
|
spread: { target: [0.0, 0.03, 1.20], roll: 0.02 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Small fixed offset of the butt from the hand bone. */
|
/** Small fixed offset of the butt from the hand bone. */
|
||||||
|
|||||||
+106
-13
@@ -70,6 +70,10 @@ const TRIGGER_DEADZONE = 0.06;
|
|||||||
* Skill Stick shot gesture, as the NHL games do it: pull the right stick back,
|
* Skill Stick shot gesture, as the NHL games do it: pull the right stick back,
|
||||||
* then push it forward. How long and how far you pulled sets the power, so a
|
* then push it forward. How long and how far you pulled sets the power, so a
|
||||||
* flick is a wrist shot and a full wind-up is a slapshot.
|
* flick is a wrist shot and a full wind-up is a slapshot.
|
||||||
|
*
|
||||||
|
* Placement is not primarily the Skill Stick. EA-style aim uses the *movement*
|
||||||
|
* stick (and how you skate during the wind-up) to open the net; the right stick
|
||||||
|
* is power + release, with only a light lateral fine-tune. See `shotAim.js`.
|
||||||
*/
|
*/
|
||||||
const SHOT = {
|
const SHOT = {
|
||||||
/** Right stick Y below this counts as winding up. */
|
/** Right stick Y below this counts as winding up. */
|
||||||
@@ -82,6 +86,11 @@ const SHOT = {
|
|||||||
timeout: 1.6,
|
timeout: 1.6,
|
||||||
/** Floor so a quick snap still does something. */
|
/** Floor so a quick snap still does something. */
|
||||||
minPower: 0.25,
|
minPower: 0.25,
|
||||||
|
/**
|
||||||
|
* How fast the move-stick sample tracks during wind-up (1/s). Higher follows
|
||||||
|
* cuts immediately; lower averages "how you moved into the shot."
|
||||||
|
*/
|
||||||
|
moveTrack: 9,
|
||||||
};
|
};
|
||||||
|
|
||||||
const rising = () => ({
|
const rising = () => ({
|
||||||
@@ -116,8 +125,19 @@ export function createInput(target = window) {
|
|||||||
// Previous frame's button state, for edge detection.
|
// Previous frame's button state, for edge detection.
|
||||||
const wasDown = rising();
|
const wasDown = rising();
|
||||||
|
|
||||||
/** Wind-up state for the Skill Stick. */
|
/**
|
||||||
const wind = { active: false, t: 0, depth: 0, aim: 0, idle: 0 };
|
* Wind-up state for the Skill Stick.
|
||||||
|
* `moveX` / `moveY` are a smoothed sample of the left stick while charged —
|
||||||
|
* that is "how you moved during the wind-up," the primary aim input.
|
||||||
|
*/
|
||||||
|
const wind = {
|
||||||
|
active: false,
|
||||||
|
t: 0,
|
||||||
|
depth: 0,
|
||||||
|
skillAim: 0,
|
||||||
|
moveX: 0,
|
||||||
|
moveY: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
// ---- the movement contract the match consumes --------------------------
|
// ---- the movement contract the match consumes --------------------------
|
||||||
@@ -125,6 +145,13 @@ export function createInput(target = window) {
|
|||||||
y: 0,
|
y: 0,
|
||||||
sprint: false,
|
sprint: false,
|
||||||
brake: false,
|
brake: false,
|
||||||
|
/** LT / L2 — EA-style backskating (see skaterSim). */
|
||||||
|
backskate: false,
|
||||||
|
/**
|
||||||
|
* RB hold — stick spread / swat. Dominant hand fully extended, blade on
|
||||||
|
* the ice; Skill Stick X waggles the blade for intercepts.
|
||||||
|
*/
|
||||||
|
spread: false,
|
||||||
cameraYaw: 0,
|
cameraYaw: 0,
|
||||||
|
|
||||||
// ---- richer view for everything else -----------------------------------
|
// ---- richer view for everything else -----------------------------------
|
||||||
@@ -141,12 +168,19 @@ export function createInput(target = window) {
|
|||||||
pressed: rising(),
|
pressed: rising(),
|
||||||
/**
|
/**
|
||||||
* Set on the frame a Skill Stick wind-up is released, then cleared.
|
* Set on the frame a Skill Stick wind-up is released, then cleared.
|
||||||
* `{ power: 0..1, aim: -1..1 }` — aim is the stick's lateral position at
|
* `{ power, move: {x,y}, skillAim, aim }` —
|
||||||
* release, which is where the shot is being placed.
|
* move left stick sample over the wind-up (primary aim)
|
||||||
|
* skillAim Skill Stick X at release (light fine-tune)
|
||||||
|
* aim combined −1..1 hint for UI / anim before match resolves world yaw
|
||||||
*/
|
*/
|
||||||
shot: null,
|
shot: null,
|
||||||
/** How wound up the shot is right now, 0..1. Drives the wind-up pose. */
|
/** How wound up the shot is right now, 0..1. Drives the wind-up pose. */
|
||||||
charge: 0,
|
charge: 0,
|
||||||
|
/**
|
||||||
|
* Live aim hint while winding up (−1..1, + = right). Match uses the full
|
||||||
|
* move sample for world aim; this is for pose lean and HUD.
|
||||||
|
*/
|
||||||
|
aimHint: 0,
|
||||||
source: 'none',
|
source: 'none',
|
||||||
padId: null,
|
padId: null,
|
||||||
};
|
};
|
||||||
@@ -181,6 +215,22 @@ export function createInput(target = window) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pack a shot event from the current wind + stick samples. */
|
||||||
|
function packShot(power) {
|
||||||
|
const moveX = clamp(wind.moveX, -1, 1);
|
||||||
|
const moveY = clamp(wind.moveY, -1, 1);
|
||||||
|
const skillAim = clamp(state.skill.x, -1, 1);
|
||||||
|
// Combined hint: move is primary, skill is a light nudge (matches match).
|
||||||
|
const aim = clamp(moveX * 0.85 + skillAim * 0.25, -1, 1);
|
||||||
|
return {
|
||||||
|
power,
|
||||||
|
move: { x: moveX, y: moveY },
|
||||||
|
skillAim,
|
||||||
|
/** @deprecated prefer move + skillAim; kept as the combined anim hint */
|
||||||
|
aim,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance the shot gesture. Returns a shot on the frame of release.
|
* Advance the shot gesture. Returns a shot on the frame of release.
|
||||||
*
|
*
|
||||||
@@ -195,16 +245,27 @@ export function createInput(target = window) {
|
|||||||
wind.active = true;
|
wind.active = true;
|
||||||
wind.t = 0;
|
wind.t = 0;
|
||||||
wind.depth = Math.abs(y);
|
wind.depth = Math.abs(y);
|
||||||
wind.aim = state.skill.x;
|
wind.skillAim = state.skill.x;
|
||||||
|
// Seed move sample from where the stick already is — a cut into the
|
||||||
|
// wind-up should count, not wait a beat to register.
|
||||||
|
wind.moveX = state.move.x;
|
||||||
|
wind.moveY = state.move.y;
|
||||||
}
|
}
|
||||||
state.charge = 0;
|
state.charge = 0;
|
||||||
|
state.aimHint = 0;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
wind.t += dt;
|
wind.t += dt;
|
||||||
wind.depth = Math.max(wind.depth, Math.abs(Math.min(0, y)));
|
wind.depth = Math.max(wind.depth, Math.abs(Math.min(0, y)));
|
||||||
wind.aim = state.skill.x;
|
wind.skillAim = state.skill.x;
|
||||||
|
// Track left stick through the wind-up. Exponential follow so a late cut
|
||||||
|
// still lands, but a brief twitch does not fully rewrite the sample.
|
||||||
|
const k = 1 - Math.exp(-SHOT.moveTrack * dt);
|
||||||
|
wind.moveX += (state.move.x - wind.moveX) * k;
|
||||||
|
wind.moveY += (state.move.y - wind.moveY) * k;
|
||||||
state.charge = clamp(wind.t / SHOT.fullWind, 0, 1) * wind.depth;
|
state.charge = clamp(wind.t / SHOT.fullWind, 0, 1) * wind.depth;
|
||||||
|
state.aimHint = clamp(wind.moveX * 0.85 + wind.skillAim * 0.25, -1, 1);
|
||||||
|
|
||||||
if (y > SHOT.releaseAt) {
|
if (y > SHOT.releaseAt) {
|
||||||
const power = clamp(
|
const power = clamp(
|
||||||
@@ -212,14 +273,24 @@ export function createInput(target = window) {
|
|||||||
0,
|
0,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
// One last hard sample of where the sticks are on the release frame —
|
||||||
|
// that is the moment the player commits.
|
||||||
|
wind.moveX = state.move.x !== 0 || state.move.y !== 0
|
||||||
|
? wind.moveX * 0.35 + state.move.x * 0.65
|
||||||
|
: wind.moveX;
|
||||||
|
wind.moveY = state.move.x !== 0 || state.move.y !== 0
|
||||||
|
? wind.moveY * 0.35 + state.move.y * 0.65
|
||||||
|
: wind.moveY;
|
||||||
wind.active = false;
|
wind.active = false;
|
||||||
state.charge = 0;
|
state.charge = 0;
|
||||||
return { power, aim: clamp(state.skill.x, -1, 1) };
|
state.aimHint = 0;
|
||||||
|
return packShot(power);
|
||||||
}
|
}
|
||||||
// Held back forever without releasing: drop it rather than firing later.
|
// Held back forever without releasing: drop it rather than firing later.
|
||||||
if (wind.t > SHOT.timeout) {
|
if (wind.t > SHOT.timeout) {
|
||||||
wind.active = false;
|
wind.active = false;
|
||||||
state.charge = 0;
|
state.charge = 0;
|
||||||
|
state.aimHint = 0;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -323,15 +394,37 @@ export function createInput(target = window) {
|
|||||||
// Above half-throttle counts as the sprint stride. The sim takes a
|
// Above half-throttle counts as the sprint stride. The sim takes a
|
||||||
// boolean today; when it takes a throttle this is the line that changes.
|
// boolean today; when it takes a throttle this is the line that changes.
|
||||||
state.sprint = state.hustle > 0.5;
|
state.sprint = state.hustle > 0.5;
|
||||||
state.brake = state.protect > 0.5;
|
// LT is backskating (EA NHL). Keyboard Space matches. A dedicated brake
|
||||||
|
// is not on the pad; reverse-stick while *not* holding LT is still a
|
||||||
|
// hockey stop inside the sim. `brake` stays on the contract for AI /
|
||||||
|
// tests that want an explicit snowplow.
|
||||||
|
state.backskate = state.protect > 0.28;
|
||||||
|
state.brake = false;
|
||||||
|
// RB held = stick spread. Keyboard E matches the deke binding.
|
||||||
|
state.spread = !!state.held.deke;
|
||||||
state.source = source;
|
state.source = source;
|
||||||
|
|
||||||
// ---- Skill Stick -------------------------------------------------------
|
// ---- Skill Stick -------------------------------------------------------
|
||||||
state.shot = advanceShot(dt);
|
// While spreading, Skill Stick is the waggle — cancel any wind-up so a
|
||||||
// Pressing the shoot button is the same event as a stick release, so a
|
// lateral push does not bank a slapshot.
|
||||||
// player who never learns the Skill Stick can still shoot.
|
if (state.spread) {
|
||||||
if (!state.shot && state.pressed.shoot) {
|
wind.active = false;
|
||||||
state.shot = { power: 0.6, aim: clamp(state.skill.x, -1, 1) };
|
wind.t = 0;
|
||||||
|
state.charge = 0;
|
||||||
|
state.aimHint = 0;
|
||||||
|
state.shot = null;
|
||||||
|
} else {
|
||||||
|
state.shot = advanceShot(dt);
|
||||||
|
// Pressing the shoot button is the same event as a stick release, so a
|
||||||
|
// player who never learns the Skill Stick can still shoot. Move stick at
|
||||||
|
// the press is the aim sample (no wind-up history to average).
|
||||||
|
if (!state.shot && state.pressed.shoot) {
|
||||||
|
wind.moveX = state.move.x;
|
||||||
|
wind.moveY = state.move.y;
|
||||||
|
state.shot = packShot(0.6);
|
||||||
|
}
|
||||||
|
// Live aim hint when not winding.
|
||||||
|
if (!wind.active && !state.shot) state.aimHint = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
+75
-21
@@ -3,6 +3,7 @@ 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, SKATE } from '../../shared/skaterSim.js';
|
import { applyIntent, createSkaterState, stepSkater, SKATE } from '../../shared/skaterSim.js';
|
||||||
import { stickToWorld } from './input.js';
|
import { stickToWorld } from './input.js';
|
||||||
|
import { resolveShotAim } from './shotAim.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';
|
||||||
import { NET, goalLineX } from '../../shared/net.js';
|
import { NET, goalLineX } from '../../shared/net.js';
|
||||||
@@ -43,6 +44,8 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
const skaters = [];
|
const skaters = [];
|
||||||
/** Previous velocity heading per skater, for the animator's bank. */
|
/** Previous velocity heading per skater, for the animator's bank. */
|
||||||
const prevVelYaw = [];
|
const prevVelYaw = [];
|
||||||
|
/** Previous Skill Stick X while stick-spreading, for swat waggle rate. */
|
||||||
|
const prevSpreadWaggle = [];
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const spawn = spawns[i];
|
const spawn = spawns[i];
|
||||||
@@ -59,6 +62,7 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
states.push(s);
|
states.push(s);
|
||||||
brains.push(createBrain(rng.f, {}));
|
brains.push(createBrain(rng.f, {}));
|
||||||
prevVelYaw.push(spawn.yaw);
|
prevVelYaw.push(spawn.yaw);
|
||||||
|
prevSpreadWaggle.push(0);
|
||||||
skaters.push(createSkater({
|
skaters.push(createSkater({
|
||||||
seed: seed + i * 977,
|
seed: seed + i * 977,
|
||||||
scene,
|
scene,
|
||||||
@@ -125,24 +129,35 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
/**
|
/**
|
||||||
* Turn a controller's shot and pass buttons into puck events.
|
* Turn a controller's shot and pass buttons into puck events.
|
||||||
*
|
*
|
||||||
* Aim comes from where the skater is facing plus the Skill Stick's lateral
|
* Aim is EA-style: the movement stick (and how you skated during the wind-up)
|
||||||
* position, so you place a shot by holding the stick off to one side as you
|
* places the shot across the net; the Skill Stick is power + release with a
|
||||||
* release. A pass looks for the nearest teammate ahead instead.
|
* light lateral fine-tune. See `resolveShotAim`. A pass looks for the nearest
|
||||||
|
* teammate ahead instead.
|
||||||
*/
|
*/
|
||||||
function handleShooting(i, control) {
|
function handleShooting(i, control) {
|
||||||
|
// Stick-spread owns the Skill Stick; no shot / pass while RB is held.
|
||||||
|
if (control.spread) return;
|
||||||
if (possession.carrier !== i) return;
|
if (possession.carrier !== i) return;
|
||||||
const state = states[i];
|
const state = states[i];
|
||||||
|
|
||||||
if (control.shot) {
|
if (control.shot) {
|
||||||
// Up to ~35° of placement either side of where they are pointing.
|
const move = control.shot.move ?? control.move ?? { x: 0, y: 0 };
|
||||||
// Skill Stick +X is "push right"; positive yaw is a left turn in this
|
const resolved = resolveShotAim({
|
||||||
// frame, so aim subtracts — otherwise every placed shot went the wrong way.
|
x: state.x,
|
||||||
const stickAim = control.shot.aim ?? 0;
|
z: state.z,
|
||||||
const aimYaw = state.yaw - stickAim * 0.6;
|
yaw: state.yaw,
|
||||||
possession.shoot(control.shot.power, aimYaw);
|
vx: state.vx,
|
||||||
|
vz: state.vz,
|
||||||
|
team: state.team,
|
||||||
|
moveX: move.x,
|
||||||
|
moveY: move.y,
|
||||||
|
cameraYaw: control.cameraYaw ?? 0,
|
||||||
|
skillAim: control.shot.skillAim ?? 0,
|
||||||
|
});
|
||||||
|
possession.shoot(control.shot.power, resolved.aimYaw);
|
||||||
skaters[i].animator.playAction('shoot', {
|
skaters[i].animator.playAction('shoot', {
|
||||||
power: control.shot.power,
|
power: control.shot.power,
|
||||||
aim: stickAim,
|
aim: resolved.placement,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -334,8 +349,9 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
}
|
}
|
||||||
const control = controls.get(i);
|
const control = controls.get(i);
|
||||||
if (control) {
|
if (control) {
|
||||||
// The Skill Stick moves the puck, and only for whoever is carrying it.
|
// The Skill Stick moves the puck, and only for whoever is carrying it
|
||||||
if (possession.carrier === i && control.skill) {
|
// and not stick-spreading (RB owns the skill stick as a waggle then).
|
||||||
|
if (possession.carrier === i && control.skill && !control.spread) {
|
||||||
possession.handling.x = control.skill.x;
|
possession.handling.x = control.skill.x;
|
||||||
possession.handling.y = control.skill.y;
|
possession.handling.y = control.skill.y;
|
||||||
}
|
}
|
||||||
@@ -346,16 +362,40 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
iz: _worldIntent.iz,
|
iz: _worldIntent.iz,
|
||||||
sprint: control.sprint,
|
sprint: control.sprint,
|
||||||
brake: control.brake,
|
brake: control.brake,
|
||||||
|
backskate: control.backskate,
|
||||||
});
|
});
|
||||||
// Keep the brain's waypoint fresh so handing control back does not
|
// Keep the brain's waypoint fresh so handing control back does not
|
||||||
// send them skating off to somewhere chosen a minute ago.
|
// send them skating off to somewhere chosen a minute ago.
|
||||||
brains[i].target = null;
|
brains[i].target = null;
|
||||||
if (control.pressed?.poke) {
|
|
||||||
// The reach always animates, whether or not it connects — a poke
|
if (control.spread) {
|
||||||
// that only shows when it works gives the player no feedback on the
|
// Stick-spread: held pose + continuous swat check while the blade
|
||||||
// ones that miss, which is most of them.
|
// is out. Waggle rate powers active sweeps.
|
||||||
skaters[i].animator.playAction('poke');
|
const waggle = control.skill?.x ?? 0;
|
||||||
possession.poke(i);
|
const waggleRate = (waggle - prevSpreadWaggle[i]) / Math.max(1e-4, dt);
|
||||||
|
prevSpreadWaggle[i] = waggle;
|
||||||
|
const anim = skaters[i].animator;
|
||||||
|
if (anim.action !== 'spread' && anim.action !== 'shoot' && anim.action !== 'pass') {
|
||||||
|
anim.action = 'spread';
|
||||||
|
anim.actionTime = 0;
|
||||||
|
}
|
||||||
|
if (anim.action === 'spread') {
|
||||||
|
anim.spreadWaggle = waggle;
|
||||||
|
possession.swat(i, { waggle, waggleRate });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prevSpreadWaggle[i] = 0;
|
||||||
|
if (skaters[i].animator.action === 'spread') {
|
||||||
|
skaters[i].animator.action = null;
|
||||||
|
skaters[i].animator.spreadWaggle = 0;
|
||||||
|
}
|
||||||
|
if (control.pressed?.poke) {
|
||||||
|
// The reach always animates, whether or not it connects — a poke
|
||||||
|
// that only shows when it works gives the player no feedback on the
|
||||||
|
// ones that miss, which is most of them.
|
||||||
|
skaters[i].animator.playAction('poke');
|
||||||
|
possession.poke(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
steer(brains[i], states[i], states, dt, play);
|
steer(brains[i], states[i], states, dt, play);
|
||||||
@@ -454,6 +494,8 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
anim.hasPuck = possession.carrier === i;
|
anim.hasPuck = possession.carrier === i;
|
||||||
const ctrl = controls.get(i);
|
const ctrl = controls.get(i);
|
||||||
anim.charge = anim.hasPuck ? (ctrl?.charge ?? 0) : 0;
|
anim.charge = anim.hasPuck ? (ctrl?.charge ?? 0) : 0;
|
||||||
|
// Stick reaches for a loose puck in vicinity (proximity grab IK).
|
||||||
|
anim.grabTarget = anim.hasPuck ? null : possession.grabAim(i);
|
||||||
if (anim.hasPuck) {
|
if (anim.hasPuck) {
|
||||||
anim.handling.x = possession.handling.x;
|
anim.handling.x = possession.handling.x;
|
||||||
anim.handling.y = possession.handling.y;
|
anim.handling.y = possession.handling.y;
|
||||||
@@ -462,11 +504,23 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
|||||||
anim.handling.y *= Math.max(0, 1 - 8 * dt);
|
anim.handling.y *= Math.max(0, 1 - 8 * dt);
|
||||||
}
|
}
|
||||||
// Holding the Skill Stick back is a wind-up; letting it go ends one.
|
// Holding the Skill Stick back is a wind-up; letting it go ends one.
|
||||||
if (anim.hasPuck && anim.charge > 0.05 && anim.action === null) {
|
// actionAim follows the live move-stick aim hint so the body opens toward
|
||||||
|
// the corner you are skating into, not only the Skill Stick X.
|
||||||
|
// Stick-spread (RB) wins over wind-up — it already owns the skill stick.
|
||||||
|
if (anim.action === 'spread') {
|
||||||
|
// Held by the control loop above; keep waggle fresh if control dropped
|
||||||
|
// mid-frame edge cases leave it set.
|
||||||
|
if (ctrl?.spread) anim.spreadWaggle = ctrl.skill?.x ?? anim.spreadWaggle;
|
||||||
|
} else if (anim.hasPuck && anim.charge > 0.05 && anim.action === null) {
|
||||||
anim.action = 'windup';
|
anim.action = 'windup';
|
||||||
anim.actionTime = 0;
|
anim.actionTime = 0;
|
||||||
} else if (anim.action === 'windup' && (!anim.hasPuck || anim.charge <= 0.05)) {
|
anim.actionAim = ctrl?.aimHint ?? 0;
|
||||||
anim.action = null;
|
} else if (anim.action === 'windup') {
|
||||||
|
if (!anim.hasPuck || anim.charge <= 0.05 || ctrl?.spread) {
|
||||||
|
anim.action = null;
|
||||||
|
} else {
|
||||||
|
anim.actionAim = ctrl?.aimHint ?? anim.actionAim;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
skaters[i].applyState(s, yawRate);
|
skaters[i].applyState(s, yawRate);
|
||||||
|
|||||||
+366
-9
@@ -30,8 +30,40 @@ import { clamp, lerp } from '../../shared/scalar.js';
|
|||||||
export const CARRY = {
|
export const CARRY = {
|
||||||
/** Default dial position. Tuned by hand; see the note above. */
|
/** Default dial position. Tuned by hand; see the note above. */
|
||||||
magnetism: 0.72,
|
magnetism: 0.72,
|
||||||
/** A loose puck this close to the blade gets picked up. */
|
/** A loose puck this close to the blade gets picked up for free. */
|
||||||
captureRadius: 0.55,
|
captureRadius: 0.55,
|
||||||
|
/**
|
||||||
|
* Vicinity for a proximity grab attempt (body to puck). Outside this the
|
||||||
|
* stick does not reach; inside, intercept quality grows an effective capture
|
||||||
|
* radius and the stick IKs out toward the puck.
|
||||||
|
*/
|
||||||
|
grabRadius: 2.05,
|
||||||
|
/**
|
||||||
|
* Extra blade reach (metres) at full intercept quality — lining up a moving
|
||||||
|
* puck lets you snag it farther from the rest blade pose.
|
||||||
|
*/
|
||||||
|
grabReachBonus: 0.95,
|
||||||
|
/**
|
||||||
|
* How fast grab progress fills at quality 1, 1/s. A still puck at the feet
|
||||||
|
* is nearly instant; a hard pass needs a clean line and a beat of contact.
|
||||||
|
*/
|
||||||
|
grabRate: 2.4,
|
||||||
|
/** Soft suction of a loose puck onto the blade while grabbing, 1/s. */
|
||||||
|
grabPull: 7.5,
|
||||||
|
/**
|
||||||
|
* Puck speed (m/s) where proximity grab rate is about half. Faster biscuits
|
||||||
|
* are much harder to vacuum up without a clean intercept.
|
||||||
|
*/
|
||||||
|
grabSpeedHalf: 7.5,
|
||||||
|
/** Below this speed, blade-on-puck is still a free snag. */
|
||||||
|
grabAutoSpeed: 2.8,
|
||||||
|
/** Skaters within this of the puck count as contesting the scramble. */
|
||||||
|
grabCrowdRadius: 2.35,
|
||||||
|
/**
|
||||||
|
* Each extra contestant multiplies grab rate by 1/(1 + k*(n-1)).
|
||||||
|
* Two bodies ~0.70×, three ~0.54×, four ~0.44×.
|
||||||
|
*/
|
||||||
|
grabCrowdK: 0.42,
|
||||||
/**
|
/**
|
||||||
* Possession breaks if the puck gets this far from the blade.
|
* Possession breaks if the puck gets this far from the blade.
|
||||||
*
|
*
|
||||||
@@ -60,6 +92,17 @@ export const CARRY = {
|
|||||||
pokeRadius: 1.25,
|
pokeRadius: 1.25,
|
||||||
/** How hard a poke or a check knocks the puck away, m/s. */
|
/** How hard a poke or a check knocks the puck away, m/s. */
|
||||||
pokeSpeed: 5.5,
|
pokeSpeed: 5.5,
|
||||||
|
/**
|
||||||
|
* Stick-spread (RB) reach — longer than a poke because the blade is fully
|
||||||
|
* extended on the ice. Blade-to-puck metres.
|
||||||
|
*/
|
||||||
|
swatRadius: 1.7,
|
||||||
|
/** Base knock speed from a passive spread contact, m/s. */
|
||||||
|
swatSpeed: 6.5,
|
||||||
|
/** Extra knock from an active Skill Stick waggle (scaled by |waggleRate|). */
|
||||||
|
swatWaggleBoost: 9.0,
|
||||||
|
/** Minimum time between swat hits from the same skater, seconds. */
|
||||||
|
swatCooldown: 0.1,
|
||||||
/** How far the puck is stepped clear of the blade on release, metres. */
|
/** How far the puck is stepped clear of the blade on release, metres. */
|
||||||
releaseGap: 0.4,
|
releaseGap: 0.4,
|
||||||
};
|
};
|
||||||
@@ -71,6 +114,74 @@ const _puckPos = new THREE.Vector3();
|
|||||||
const _puckVel = new THREE.Vector3();
|
const _puckVel = new THREE.Vector3();
|
||||||
const _dir = new THREE.Vector3();
|
const _dir = new THREE.Vector3();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How well skater `s` is set to pick up a loose puck at `puckPos` with `puckVel`.
|
||||||
|
* 0..1 — distance, face/skate alignment, and intercept path on moving pucks.
|
||||||
|
* Raw puck speed is *not* folded in here; see `speedGrabFactor` so a lined-up
|
||||||
|
* intercept can still work on a hard pass while pure proximity does not.
|
||||||
|
*/
|
||||||
|
export function interceptQuality(s, puckPos, puckVel, grabRadius) {
|
||||||
|
const dx = puckPos.x - s.x;
|
||||||
|
const dz = puckPos.z - s.z;
|
||||||
|
const dist = Math.hypot(dx, dz);
|
||||||
|
if (dist > grabRadius || dist < 1e-6) return 0;
|
||||||
|
|
||||||
|
const toX = dx / dist;
|
||||||
|
const toZ = dz / dist;
|
||||||
|
const near = 1 - dist / grabRadius;
|
||||||
|
|
||||||
|
const fx = Math.sin(s.yaw);
|
||||||
|
const fz = Math.cos(s.yaw);
|
||||||
|
const face = fx * toX + fz * toZ; // facing the puck
|
||||||
|
|
||||||
|
const skateSpd = Math.hypot(s.vx, s.vz);
|
||||||
|
const skateAlign = skateSpd > 0.45
|
||||||
|
? (s.vx * toX + s.vz * toZ) / skateSpd
|
||||||
|
: face;
|
||||||
|
|
||||||
|
const puckSpd = Math.hypot(puckVel.x, puckVel.z);
|
||||||
|
const rvx = puckVel.x - s.vx;
|
||||||
|
const rvz = puckVel.z - s.vz;
|
||||||
|
// Positive when the gap is closing.
|
||||||
|
const closing = -(rvx * toX + rvz * toZ);
|
||||||
|
|
||||||
|
// Moving puck: is its path aimed through us? Low cross = on a collision line.
|
||||||
|
let path = 0;
|
||||||
|
if (puckSpd > 1.2) {
|
||||||
|
const cross = Math.abs(toX * puckVel.z - toZ * puckVel.x) / puckSpd;
|
||||||
|
const onLine = clamp(1 - cross * 1.35, 0, 1);
|
||||||
|
const approach = clamp(closing / 9, 0, 1);
|
||||||
|
path = onLine * (0.35 + 0.65 * approach);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still / soft puck: proximity + face. Hard puck: almost entirely path + line-up.
|
||||||
|
const soft = clamp(1 - puckSpd / 10, 0, 1);
|
||||||
|
const lineUp = clamp(skateAlign, 0, 1) * 0.28 + clamp(face, 0, 1) * 0.14;
|
||||||
|
const softScore = near * 0.4 + lineUp + clamp(closing / 14, 0, 0.1);
|
||||||
|
const hardScore = path * 0.72 + lineUp * 0.55 + near * 0.12 + clamp(closing / 10, 0, 0.15);
|
||||||
|
|
||||||
|
return clamp(lerp(hardScore, softScore, soft), 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiplier on proximity grab rate from puck speed alone.
|
||||||
|
* Still ≈ 1, half around `halfSpeed` m/s, very low on slapshots.
|
||||||
|
*/
|
||||||
|
export function speedGrabFactor(puckSpd, halfSpeed = 7.5) {
|
||||||
|
const s = Math.max(0, puckSpd);
|
||||||
|
return clamp(1 / (1 + (s / Math.max(0.5, halfSpeed)) ** 1.45), 0.06, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiplier when `n` skaters are contesting the same loose puck.
|
||||||
|
* One body → 1; each extra body cuts everyone's odds.
|
||||||
|
*/
|
||||||
|
export function crowdGrabFactor(contestants, k = 0.42) {
|
||||||
|
const n = Math.max(1, contestants | 0);
|
||||||
|
if (n <= 1) return 1;
|
||||||
|
return 1 / (1 + (n - 1) * k);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {object} opts.puck from createPuck
|
* @param {object} opts.puck from createPuck
|
||||||
@@ -85,6 +196,16 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
|||||||
/** Global cooldown after a deliberate release. */
|
/** Global cooldown after a deliberate release. */
|
||||||
let looseFor = 0;
|
let looseFor = 0;
|
||||||
const tuning = { ...CARRY };
|
const tuning = { ...CARRY };
|
||||||
|
/** Per-skater cooldown so a continuous spread does not re-hit every frame. */
|
||||||
|
const swatCool = new Array(skaters.length).fill(0);
|
||||||
|
/** Accumulated grab progress 0..1 while fishing for a loose puck. */
|
||||||
|
const grabProgress = new Array(skaters.length).fill(0);
|
||||||
|
/**
|
||||||
|
* Live grab aims for the animator — world blade targets while reaching for
|
||||||
|
* a loose puck. `null` when not grabbing.
|
||||||
|
* @type {({ x: number, y: number, z: number, weight: number, quality: number } | null)[]}
|
||||||
|
*/
|
||||||
|
const grabAim = new Array(skaters.length).fill(null);
|
||||||
|
|
||||||
/** Skill Stick offset applied to the carry point, -1..1 each. */
|
/** Skill Stick offset applied to the carry point, -1..1 each. */
|
||||||
const handling = { x: 0, y: 0 };
|
const handling = { x: 0, y: 0 };
|
||||||
@@ -161,6 +282,92 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stick-spread swat / intercept (RB hold).
|
||||||
|
*
|
||||||
|
* The blade is already fully extended on the ice; this tests blade-to-puck
|
||||||
|
* and knocks anything in range — a loose puck on a pass, a shot, or the
|
||||||
|
* biscuit on someone else's stick. Active Skill Stick waggle adds power and
|
||||||
|
* sets the sweep direction.
|
||||||
|
*
|
||||||
|
* @param {number} byIndex
|
||||||
|
* @param {{ waggle?: number, waggleRate?: number }} [opts]
|
||||||
|
* waggle −1..1 Skill Stick X; waggleRate ≈ stick X velocity (1/s)
|
||||||
|
*/
|
||||||
|
function swat(byIndex, { waggle = 0, waggleRate = 0 } = {}) {
|
||||||
|
if (skaters[byIndex]?.limp) return false;
|
||||||
|
if (swatCool[byIndex] > 0) return false;
|
||||||
|
|
||||||
|
bladePoint(byIndex, _carryWorld);
|
||||||
|
_puckPos.copy(puck.position());
|
||||||
|
if (_puckPos.distanceTo(_carryWorld) > tuning.swatRadius) return false;
|
||||||
|
|
||||||
|
// Don't swat your own carry — the blade is out wide, not on the puck.
|
||||||
|
if (carrier === byIndex) return false;
|
||||||
|
|
||||||
|
const state = states[byIndex];
|
||||||
|
const fx = Math.sin(state.yaw);
|
||||||
|
const fz = Math.cos(state.yaw);
|
||||||
|
const rx = Math.cos(state.yaw);
|
||||||
|
const rz = -Math.sin(state.yaw);
|
||||||
|
|
||||||
|
// Sweep direction: Skill Stick rate if actively waggling, else stick offset,
|
||||||
|
// else a mild shove away from the body along the blade-to-puck line.
|
||||||
|
const wag = clamp(waggleRate, -8, 8);
|
||||||
|
let sx;
|
||||||
|
let sz;
|
||||||
|
if (Math.abs(wag) > 0.6) {
|
||||||
|
// Active waggle: same sign flip as the spread arc (Skill Stick +X is
|
||||||
|
// screen-right; body-right basis needs the invert to match the blade).
|
||||||
|
const sign = -(Math.sign(wag) || 1);
|
||||||
|
sx = rx * sign;
|
||||||
|
sz = rz * sign;
|
||||||
|
} else if (Math.abs(waggle) > 0.2) {
|
||||||
|
const sign = -(Math.sign(waggle) || 1);
|
||||||
|
sx = rx * sign * 0.7 + fx * 0.3;
|
||||||
|
sz = rz * sign * 0.7 + fz * 0.3;
|
||||||
|
} else {
|
||||||
|
_dir.subVectors(_puckPos, _carryWorld).setY(0);
|
||||||
|
if (_dir.lengthSq() < 1e-6) {
|
||||||
|
sx = fx;
|
||||||
|
sz = fz;
|
||||||
|
} else {
|
||||||
|
_dir.normalize();
|
||||||
|
sx = _dir.x;
|
||||||
|
sz = _dir.z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const len = Math.hypot(sx, sz) || 1;
|
||||||
|
sx /= len;
|
||||||
|
sz /= len;
|
||||||
|
|
||||||
|
const power = clamp(
|
||||||
|
0.45 + Math.abs(wag) * 0.12 + Math.abs(waggle) * 0.25,
|
||||||
|
0.45,
|
||||||
|
1.8,
|
||||||
|
);
|
||||||
|
const knock = (tuning.swatSpeed + Math.abs(wag) * 0.35 * tuning.swatWaggleBoost) * power;
|
||||||
|
|
||||||
|
// Steal if someone else has it; otherwise just redirect a loose puck.
|
||||||
|
const stolenFrom = (carrier !== null && carrier !== byIndex) ? carrier : null;
|
||||||
|
if (stolenFrom !== null) release('swatted', tuning.reclaimDelay);
|
||||||
|
|
||||||
|
// Fast incoming pucks get killed first, then redirected — that is the
|
||||||
|
// "stop a hard pass / shot" feel, not a free rebound rocket.
|
||||||
|
_puckVel.copy(puck.velocity());
|
||||||
|
const incoming = Math.hypot(_puckVel.x, _puckVel.z);
|
||||||
|
const damp = incoming > 10 ? 0.18 : incoming > 5 ? 0.35 : 0.55;
|
||||||
|
puck.setVelocity(
|
||||||
|
_puckVel.x * damp + sx * knock + state.vx * 0.25,
|
||||||
|
0,
|
||||||
|
_puckVel.z * damp + sz * knock + state.vz * 0.25,
|
||||||
|
);
|
||||||
|
|
||||||
|
swatCool[byIndex] = tuning.swatCooldown;
|
||||||
|
emit('swat', { skater: byIndex, power, knock, waggle, from: stolenFrom });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contact dislodges the puck. Called when a check lands on the carrier —
|
* Contact dislodges the puck. Called when a check lands on the carrier —
|
||||||
* a stagger is enough, it does not need a knockdown.
|
* a stagger is enough, it does not need a knockdown.
|
||||||
@@ -217,10 +424,15 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
|||||||
get loose() { return carrier === null; },
|
get loose() { return carrier === null; },
|
||||||
shoot,
|
shoot,
|
||||||
poke,
|
poke,
|
||||||
|
swat,
|
||||||
jar,
|
jar,
|
||||||
release,
|
release,
|
||||||
capture,
|
capture,
|
||||||
bladePoint,
|
bladePoint,
|
||||||
|
/** World-space stick aim while reaching for a loose puck, or null. */
|
||||||
|
grabAim(i) {
|
||||||
|
return grabAim[i] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
/** Where the puck is being carried, in world space. Null if loose. */
|
/** Where the puck is being carried, in world space. Null if loose. */
|
||||||
carryPoint(out) {
|
carryPoint(out) {
|
||||||
@@ -237,6 +449,7 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
|||||||
*/
|
*/
|
||||||
update(dt) {
|
update(dt) {
|
||||||
for (let i = 0; i < cooldown.length; i++) cooldown[i] = Math.max(0, cooldown[i] - dt);
|
for (let i = 0; i < cooldown.length; i++) cooldown[i] = Math.max(0, cooldown[i] - dt);
|
||||||
|
for (let i = 0; i < swatCool.length; i++) swatCool[i] = Math.max(0, swatCool[i] - dt);
|
||||||
looseFor = Math.max(0, looseFor - dt);
|
looseFor = Math.max(0, looseFor - dt);
|
||||||
|
|
||||||
puck.position(); // refresh the cached vector
|
puck.position(); // refresh the cached vector
|
||||||
@@ -255,20 +468,161 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- capture ----------------------------------------------------------
|
// Clear grab aims every frame; capture re-fills who is fishing.
|
||||||
|
for (let i = 0; i < grabAim.length; i++) grabAim[i] = null;
|
||||||
|
|
||||||
|
// ---- capture / proximity grab -----------------------------------------
|
||||||
|
// Not "closest body wins." Each skater races grab progress weighted by
|
||||||
|
// intercept quality, puck speed (faster → harder), and how many others
|
||||||
|
// are contesting the same biscuit.
|
||||||
if (carrier === null && looseFor <= 0) {
|
if (carrier === null && looseFor <= 0) {
|
||||||
let best = null;
|
const puckSpd = Math.hypot(_puckVel.x, _puckVel.z);
|
||||||
let bestGap = tuning.captureRadius;
|
const speedF = speedGrabFactor(puckSpd, tuning.grabSpeedHalf);
|
||||||
|
|
||||||
|
// Who is in the scramble?
|
||||||
|
const contesting = [];
|
||||||
for (let i = 0; i < skaters.length; i++) {
|
for (let i = 0; i < skaters.length; i++) {
|
||||||
if (skaters[i].limp || cooldown[i] > 0) continue;
|
if (skaters[i].limp || cooldown[i] > 0) continue;
|
||||||
|
const s = states[i];
|
||||||
|
const bodyDist = Math.hypot(_puckPos.x - s.x, _puckPos.z - s.z);
|
||||||
|
if (bodyDist <= tuning.grabCrowdRadius) contesting.push(i);
|
||||||
|
}
|
||||||
|
const crowdF = crowdGrabFactor(contesting.length, tuning.grabCrowdK);
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
let pullI = -1;
|
||||||
|
let pullScore = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < skaters.length; i++) {
|
||||||
|
if (skaters[i].limp || cooldown[i] > 0) {
|
||||||
|
grabProgress[i] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = states[i];
|
||||||
|
const bodyDist = Math.hypot(_puckPos.x - s.x, _puckPos.z - s.z);
|
||||||
bladePoint(i, _carryWorld);
|
bladePoint(i, _carryWorld);
|
||||||
const gap = _puckPos.distanceTo(_carryWorld);
|
const bladeGap = Math.hypot(
|
||||||
if (gap < bestGap) {
|
_puckPos.x - _carryWorld.x,
|
||||||
bestGap = gap;
|
_puckPos.z - _carryWorld.z,
|
||||||
best = i;
|
);
|
||||||
|
|
||||||
|
if (bodyDist > tuning.grabRadius && bladeGap > tuning.grabRadius) {
|
||||||
|
grabProgress[i] = Math.max(0, grabProgress[i] - dt * 2.5);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quality = interceptQuality(s, _puckPos, _puckVel, tuning.grabRadius);
|
||||||
|
// Effective claim strength — speed and crowd punish pure proximity.
|
||||||
|
const claim = quality * speedF * crowdF;
|
||||||
|
const reach = tuning.captureRadius
|
||||||
|
+ tuning.grabReachBonus * quality * speedF;
|
||||||
|
const inReach = bladeGap <= reach || bodyDist <= tuning.grabRadius * 0.85;
|
||||||
|
|
||||||
|
// Blade already on a *slow* puck: free snag. On a rocket, still have
|
||||||
|
// to win the progress race — tickling a 30 m/s biscuit is not enough.
|
||||||
|
const bladeOn = bladeGap <= tuning.captureRadius;
|
||||||
|
const freeSnag = bladeOn && puckSpd <= tuning.grabAutoSpeed;
|
||||||
|
|
||||||
|
if (freeSnag) {
|
||||||
|
const score = 2 + claim + (1 - bladeGap / tuning.captureRadius);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
grabProgress[i] = 1;
|
||||||
|
grabAim[i] = {
|
||||||
|
x: _puckPos.x,
|
||||||
|
y: PUCK.thickness / 2,
|
||||||
|
z: _puckPos.z,
|
||||||
|
weight: 1,
|
||||||
|
quality: 1,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inReach || quality < 0.08) {
|
||||||
|
grabProgress[i] = Math.max(0, grabProgress[i] - dt * 1.8);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stick IK — still reach for hard pucks so the grab reads, even when
|
||||||
|
// claim rate is low.
|
||||||
|
const weight = clamp(
|
||||||
|
quality * 0.5
|
||||||
|
+ (1 - bladeGap / Math.max(reach, 0.01)) * 0.45
|
||||||
|
+ speedF * 0.1,
|
||||||
|
0.18,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
grabAim[i] = {
|
||||||
|
x: _puckPos.x,
|
||||||
|
y: PUCK.thickness / 2,
|
||||||
|
z: _puckPos.z,
|
||||||
|
weight,
|
||||||
|
quality: claim,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Progress rate: claim strength, plus a small bonus for blade contact
|
||||||
|
// on a moving puck (you're on it, but speed still gates the snag).
|
||||||
|
const bladeBonus = bladeOn ? 0.35 * speedF : 0;
|
||||||
|
grabProgress[i] = clamp(
|
||||||
|
grabProgress[i] + (claim + bladeBonus) * tuning.grabRate * dt,
|
||||||
|
0,
|
||||||
|
1.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (claim > pullScore) {
|
||||||
|
pullScore = claim;
|
||||||
|
pullI = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First to finish the race with a real claim — not closest body.
|
||||||
|
if (grabProgress[i] >= 1 && claim > 0.12) {
|
||||||
|
const score = claim + grabProgress[i] * 0.05
|
||||||
|
+ (bladeOn ? 0.15 * speedF : 0);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (best !== null) capture(best);
|
|
||||||
|
// Soft suction only when the puck is slow enough to settle and someone
|
||||||
|
// has a clear claim — no vacuuming slapshots through a crowd.
|
||||||
|
// Pull is a capped speed *toward* the blade (unit direction), never a
|
||||||
|
// raw gap×gain that could fling the puck across the rink.
|
||||||
|
if (pullI >= 0 && pullScore > 0.22 && best === null && speedF > 0.4 && crowdF > 0.55) {
|
||||||
|
bladePoint(pullI, _carryWorld);
|
||||||
|
_toTarget.subVectors(_carryWorld, _puckPos);
|
||||||
|
_toTarget.y = 0;
|
||||||
|
const gap = _toTarget.length();
|
||||||
|
if (gap > 0.05 && gap < tuning.grabRadius) {
|
||||||
|
const s = states[pullI];
|
||||||
|
const pullSpd = clamp(tuning.grabPull * pullScore * speedF * 0.55, 0, 6.5);
|
||||||
|
_toTarget.multiplyScalar(1 / gap);
|
||||||
|
_desired.set(
|
||||||
|
s.vx * 0.5 + _toTarget.x * pullSpd,
|
||||||
|
0,
|
||||||
|
s.vz * 0.5 + _toTarget.z * pullSpd,
|
||||||
|
);
|
||||||
|
const blend = clamp(pullScore * speedF * dt * 1.2, 0, 0.28);
|
||||||
|
puck.setVelocity(
|
||||||
|
lerp(_puckVel.x, _desired.x, blend),
|
||||||
|
lerp(_puckVel.y, 0, blend),
|
||||||
|
lerp(_puckVel.z, _desired.z, blend),
|
||||||
|
);
|
||||||
|
_puckVel.copy(puck.velocity());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best !== null) {
|
||||||
|
capture(best);
|
||||||
|
for (let i = 0; i < grabProgress.length; i++) grabProgress[i] = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < grabProgress.length; i++) grabProgress[i] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- carry ------------------------------------------------------------
|
// ---- carry ------------------------------------------------------------
|
||||||
@@ -306,6 +660,9 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
|||||||
carrier = null;
|
carrier = null;
|
||||||
looseFor = 0;
|
looseFor = 0;
|
||||||
cooldown.fill(0);
|
cooldown.fill(0);
|
||||||
|
swatCool.fill(0);
|
||||||
|
grabProgress.fill(0);
|
||||||
|
for (let i = 0; i < grabAim.length; i++) grabAim[i] = null;
|
||||||
handling.x = 0;
|
handling.x = 0;
|
||||||
handling.y = 0;
|
handling.y = 0;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { NET, goalLineX } from '../../shared/net.js';
|
||||||
|
import { clamp, lerpAngle, wrapAngle } from '../../shared/scalar.js';
|
||||||
|
import { stickToWorld } from './input.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player shot aim — EA NHL style.
|
||||||
|
*
|
||||||
|
* The Skill Stick winds up and releases power. Placement comes from how you
|
||||||
|
* skate into the shot: left stick steers the body during the wind-up, and that
|
||||||
|
* same stick acts as a soft aimer across the net when you are attacking.
|
||||||
|
*
|
||||||
|
* Layers, strongest first:
|
||||||
|
* 1. Body facing, blended toward skate velocity (you shoot where you are going)
|
||||||
|
* 2. Movement stick world direction (primary placement while winding up)
|
||||||
|
* 3. Net-relative corner pick when roughly facing the goal mouth
|
||||||
|
* 4. Skill Stick lateral as a light fine-tune only
|
||||||
|
*
|
||||||
|
* Pure function so headless tests can assert placement without a match loop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How hard velocity yaws the shot off facing. */
|
||||||
|
const VEL_BLEND_MAX = 0.48;
|
||||||
|
/** Stick magnitude below this is treated as neutral. */
|
||||||
|
const STICK_DEAD = 0.12;
|
||||||
|
/** How hard the move stick pulls world aim when fully held. */
|
||||||
|
const MOVE_PULL = 0.72;
|
||||||
|
/** Skill Stick max offset, radians (~10°). Was 0.6 (~34°) when it was primary. */
|
||||||
|
const SKILL_RAD = 0.18;
|
||||||
|
/** Blend toward a point across the mouth when the stick opens a corner. */
|
||||||
|
const NET_BLEND = 0.62;
|
||||||
|
/** How far past the posts a full stick can still aim (misses are allowed). */
|
||||||
|
const NET_OVERSHOOT = 1.15;
|
||||||
|
|
||||||
|
const _world = { ix: 0, iz: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attack end for a skater: home (team 0) shoots +X, away shoots −X.
|
||||||
|
* @param {number} team
|
||||||
|
*/
|
||||||
|
export function attackEndForTeam(team) {
|
||||||
|
return team === 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} opts.x
|
||||||
|
* @param {number} opts.z
|
||||||
|
* @param {number} opts.yaw
|
||||||
|
* @param {number} opts.vx
|
||||||
|
* @param {number} opts.vz
|
||||||
|
* @param {number} [opts.team]
|
||||||
|
* @param {number} [opts.moveX] movement stick, screen space (−1..1)
|
||||||
|
* @param {number} [opts.moveY]
|
||||||
|
* @param {number} [opts.cameraYaw]
|
||||||
|
* @param {number} [opts.skillAim] Skill Stick X at release (−1..1)
|
||||||
|
* @param {number} [opts.goalX] override attack goal line X
|
||||||
|
* @param {number} [opts.goalZ] goal centre Z (default 0)
|
||||||
|
* @param {number} [opts.netHalfWidth]
|
||||||
|
* @returns {{ aimYaw: number, placement: number, targetZ: number | null }}
|
||||||
|
* `placement` is body-relative aim for anim (−1..1, + = skater's right).
|
||||||
|
* `targetZ` is the net-mouth target when net aim engaged, else null.
|
||||||
|
*/
|
||||||
|
export function resolveShotAim({
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
yaw,
|
||||||
|
vx,
|
||||||
|
vz,
|
||||||
|
team = 0,
|
||||||
|
moveX = 0,
|
||||||
|
moveY = 0,
|
||||||
|
cameraYaw = 0,
|
||||||
|
skillAim = 0,
|
||||||
|
goalX = null,
|
||||||
|
goalZ = 0,
|
||||||
|
netHalfWidth = NET.width / 2,
|
||||||
|
} = {}) {
|
||||||
|
// ---- 1. body + skate velocity -------------------------------------------
|
||||||
|
const speed = Math.hypot(vx, vz);
|
||||||
|
let aimYaw = yaw;
|
||||||
|
if (speed > 0.55) {
|
||||||
|
const velYaw = Math.atan2(vx, vz);
|
||||||
|
const blend = clamp((speed - 0.55) / 5.5, 0, VEL_BLEND_MAX);
|
||||||
|
aimYaw = lerpAngle(yaw, velYaw, blend);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. movement stick world direction ----------------------------------
|
||||||
|
stickToWorld({ x: moveX, y: moveY }, cameraYaw, _world);
|
||||||
|
const stickMag = Math.hypot(_world.ix, _world.iz);
|
||||||
|
|
||||||
|
if (stickMag > STICK_DEAD) {
|
||||||
|
const stickYaw = Math.atan2(_world.ix, _world.iz);
|
||||||
|
// Stronger pull as the stick leaves centre — a full push commits the shot.
|
||||||
|
const pull = MOVE_PULL * clamp((stickMag - STICK_DEAD) / (1 - STICK_DEAD), 0, 1);
|
||||||
|
aimYaw = lerpAngle(aimYaw, stickYaw, pull);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3. net-relative aimer ----------------------------------------------
|
||||||
|
// When roughly attacking the mouth, left/right on the move stick opens posts
|
||||||
|
// rather than only rotating in world space. That is the "stick aims the net"
|
||||||
|
// feel: carve toward the far post on the wind-up, release, and the puck goes
|
||||||
|
// there.
|
||||||
|
const end = goalX != null ? Math.sign(goalX) || attackEndForTeam(team) : attackEndForTeam(team);
|
||||||
|
const mouthX = goalX != null ? goalX : goalLineX(end);
|
||||||
|
const toMouth = Math.atan2(mouthX - x, goalZ - z);
|
||||||
|
const facingGoal = Math.abs(wrapAngle(toMouth - yaw)) < 1.15;
|
||||||
|
// Only engage inside a reasonable shooting range so open-ice dumps still
|
||||||
|
// follow skate direction rather than magnetizing to a distant net.
|
||||||
|
const range = Math.hypot(mouthX - x, goalZ - z);
|
||||||
|
let targetZ = null;
|
||||||
|
|
||||||
|
if (facingGoal && range > 1.2 && range < 22 && stickMag > STICK_DEAD) {
|
||||||
|
// Lateral in skater space: how far is the stick pointing to the body right.
|
||||||
|
const fx = Math.sin(yaw);
|
||||||
|
const fz = Math.cos(yaw);
|
||||||
|
const rx = fz;
|
||||||
|
const rz = -fx;
|
||||||
|
const lateral = clamp(
|
||||||
|
(_world.ix * rx + _world.iz * rz) / Math.max(stickMag, 1e-6),
|
||||||
|
-1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Full stick reaches just past the post so you can miss wide on purpose.
|
||||||
|
targetZ = lateral * netHalfWidth * NET_OVERSHOOT;
|
||||||
|
const netYaw = Math.atan2(mouthX - x, targetZ - z);
|
||||||
|
// Stronger net pull when the stick is mostly lateral (opening a corner)
|
||||||
|
// rather than purely forward (driving the middle).
|
||||||
|
const corner = Math.abs(lateral);
|
||||||
|
aimYaw = lerpAngle(aimYaw, netYaw, NET_BLEND * (0.45 + 0.55 * corner));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. Skill Stick fine-tune -------------------------------------------
|
||||||
|
// Forward is (sin yaw, cos yaw); body right is (cos yaw, −sin yaw). Positive
|
||||||
|
// yaw therefore turns toward the skater's right, so Skill Stick +X (push
|
||||||
|
// right) adds to yaw.
|
||||||
|
aimYaw += clamp(skillAim, -1, 1) * SKILL_RAD;
|
||||||
|
|
||||||
|
// Body-relative placement for the wind-up / shot pose (−1..1).
|
||||||
|
// + means "to the skater's right" (Skill Stick +X), which is how
|
||||||
|
// `poseWindup` / `poseShot` read it.
|
||||||
|
const placement = clamp(wrapAngle(aimYaw - yaw) / 0.75, -1, 1);
|
||||||
|
|
||||||
|
return { aimYaw, placement, targetZ };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHOT_AIM = Object.freeze({
|
||||||
|
VEL_BLEND_MAX,
|
||||||
|
STICK_DEAD,
|
||||||
|
MOVE_PULL,
|
||||||
|
SKILL_RAD,
|
||||||
|
NET_BLEND,
|
||||||
|
NET_OVERSHOOT,
|
||||||
|
});
|
||||||
+44
-7
@@ -3,6 +3,7 @@ import { createPhysicsWorld, initPhysics } from './physics/world.js';
|
|||||||
import { buildPuckMesh, buildRink } from './render/rink.js';
|
import { buildPuckMesh, buildRink } from './render/rink.js';
|
||||||
import { PUCK } from './physics/puck.js';
|
import { PUCK } from './physics/puck.js';
|
||||||
import { createCamera } from './render/camera.js';
|
import { createCamera } from './render/camera.js';
|
||||||
|
import { createMarkers } from './render/markers.js';
|
||||||
import { createMatch } from './game/match.js';
|
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';
|
||||||
@@ -121,6 +122,8 @@ async function bootApp() {
|
|||||||
let scrimmage = null;
|
let scrimmage = null;
|
||||||
/** @type {ReturnType<typeof buildPuckMesh> | null} */
|
/** @type {ReturnType<typeof buildPuckMesh> | null} */
|
||||||
let puckView = null;
|
let puckView = null;
|
||||||
|
/** @type {ReturnType<typeof createMarkers> | null} */
|
||||||
|
let markers = null;
|
||||||
/** @type {(() => void) | null} */
|
/** @type {(() => void) | null} */
|
||||||
let removeSubstepSync = null;
|
let removeSubstepSync = null;
|
||||||
|
|
||||||
@@ -140,6 +143,10 @@ async function bootApp() {
|
|||||||
scrimmage.destroy();
|
scrimmage.destroy();
|
||||||
scrimmage = null;
|
scrimmage = null;
|
||||||
}
|
}
|
||||||
|
if (markers) {
|
||||||
|
markers.destroy();
|
||||||
|
markers = null;
|
||||||
|
}
|
||||||
if (puckView) {
|
if (puckView) {
|
||||||
// Ring is parented under the mesh.
|
// Ring is parented under the mesh.
|
||||||
scene.remove(puckView.mesh);
|
scene.remove(puckView.mesh);
|
||||||
@@ -180,8 +187,8 @@ async function bootApp() {
|
|||||||
if (playerDriving) {
|
if (playerDriving) {
|
||||||
cam.state.mode = 'follow';
|
cam.state.mode = 'follow';
|
||||||
cam.state.followIndex = shootout.state.shooter;
|
cam.state.followIndex = shootout.state.shooter;
|
||||||
cam.state.distance = 9;
|
// Dreamfall-style chase seeds distance/pitch itself on first follow frame.
|
||||||
cam.state.pitch = 0.3;
|
if (cam.chase) cam.chase.seeded = false;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -197,8 +204,7 @@ async function bootApp() {
|
|||||||
match.setControl(idx, stick);
|
match.setControl(idx, stick);
|
||||||
cam.state.mode = 'follow';
|
cam.state.mode = 'follow';
|
||||||
cam.state.followIndex = idx;
|
cam.state.followIndex = idx;
|
||||||
cam.state.distance = 9;
|
if (cam.chase) cam.chase.seeded = false;
|
||||||
cam.state.pitch = 0.3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMenu() {
|
function showMenu() {
|
||||||
@@ -221,6 +227,10 @@ async function bootApp() {
|
|||||||
|
|
||||||
match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
|
match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
|
||||||
puckView = buildPuckMesh(scene, PUCK);
|
puckView = buildPuckMesh(scene, PUCK);
|
||||||
|
// The old yellow puck disc ring is redundant once the arrow is up; keep the
|
||||||
|
// mesh but hide the ring so we do not stack two markers on the same puck.
|
||||||
|
puckView.ring.visible = false;
|
||||||
|
markers = createMarkers(scene);
|
||||||
|
|
||||||
if (next === '1v1') {
|
if (next === '1v1') {
|
||||||
// The shootout owns the nets and the goalies, and drives its own
|
// The shootout owns the nets and the goalies, and drives its own
|
||||||
@@ -380,7 +390,28 @@ async function bootApp() {
|
|||||||
if (puckView) {
|
if (puckView) {
|
||||||
puckView.mesh.position.copy(match.puck.position());
|
puckView.mesh.position.copy(match.puck.position());
|
||||||
puckView.mesh.quaternion.copy(match.puck.rotation());
|
puckView.mesh.quaternion.copy(match.puck.rotation());
|
||||||
puckView.ring.visible = match.possession.loose;
|
puckView.ring.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markers) {
|
||||||
|
const carrierIdx = match.possession.carrier;
|
||||||
|
const playerIdx = match.playerIndex;
|
||||||
|
const playerHasPuck = playerIdx !== null && carrierIdx === playerIdx;
|
||||||
|
const playerState = playerIdx !== null ? match.states[playerIdx] : null;
|
||||||
|
const carrierState = carrierIdx !== null ? match.states[carrierIdx] : null;
|
||||||
|
const carrierIsTeammate = !!(
|
||||||
|
playerState
|
||||||
|
&& carrierState
|
||||||
|
&& !playerHasPuck
|
||||||
|
&& playerState.team === carrierState.team
|
||||||
|
);
|
||||||
|
markers.update(dt, {
|
||||||
|
puck: match.puck.position(),
|
||||||
|
player: playerState,
|
||||||
|
carrier: carrierState,
|
||||||
|
playerHasPuck,
|
||||||
|
carrierIsTeammate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cam.update(dt, match.states);
|
cam.update(dt, match.states);
|
||||||
@@ -400,6 +431,7 @@ async function bootApp() {
|
|||||||
cam.state.yaw += dt * 0.08;
|
cam.state.yaw += dt * 0.08;
|
||||||
cam.update(dt, []);
|
cam.update(dt, []);
|
||||||
hud.textContent = '';
|
hud.textContent = '';
|
||||||
|
if (markers) markers.update(dt, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.render(scene, cam.camera);
|
renderer.render(scene, cam.camera);
|
||||||
@@ -433,9 +465,14 @@ async function bootApp() {
|
|||||||
|
|
||||||
const controlsHint = player
|
const controlsHint = player
|
||||||
? (input.connected
|
? (input.connected
|
||||||
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
|
? '\nL-stick skate + aim · RT hustle · LT contain · RB stick-spread · R-stick wind-up/waggle\nA pass · X shoot · B poke'
|
||||||
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
|
: '\nWASD skate + aim · Shift hustle · Space contain · E stick-spread · arrows wind-up/waggle\nJ pass · K shoot · L poke')
|
||||||
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
|
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
|
||||||
|
+ (stick.backskate ? ' LT' : '')
|
||||||
|
+ (stick.spread ? ' SPREAD' : '')
|
||||||
|
+ (stick.charge > 0.05
|
||||||
|
? ` aim ${bar((stick.aimHint + 1) * 0.5)}`
|
||||||
|
: '')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (mode === '1v1' && shootout) {
|
if (mode === '1v1' && shootout) {
|
||||||
|
|||||||
+278
-51
@@ -3,23 +3,106 @@ import { RINK } from '../../shared/rink.js';
|
|||||||
import { clamp, wrapAngle } from '../../shared/scalar.js';
|
import { clamp, wrapAngle } from '../../shared/scalar.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast camera.
|
* Broadcast + third-person chase camera.
|
||||||
*
|
*
|
||||||
* Two modes, because they answer different questions about the spike:
|
* Follow mode is ported from Dreamfall's rally chase cam
|
||||||
* 'broadcast' sits off the side boards and pans with the action — the view
|
* (`dreamfall/src/game/systems/CameraSystem.js` → `updateVehicleChaseCamera`):
|
||||||
* you judge whether the skating reads from.
|
|
||||||
* 'follow' rides behind one skater, which is the only way to tell whether
|
|
||||||
* the stride and the carve actually line up with the motion.
|
|
||||||
*
|
*
|
||||||
* Drag orbits, wheel zooms, and the target is smoothed rather than snapped so
|
* - Heading-locked yaw (not stick-coupled)
|
||||||
* a bot changing direction does not whip the camera.
|
* - Velocity-predictive look (leads into turns, angle-clamped)
|
||||||
|
* - Exp springs `1 - exp(-rate·dt)` — frame-rate independent, no overshoot
|
||||||
|
* - Planar tracking stiffens with speed so lag stays ~constant (lag ≈ v/λ)
|
||||||
|
* - Height filtered separately (no bob from every foot plant)
|
||||||
|
* - Smoothed look target + max yaw rate as a whip backstop
|
||||||
|
*
|
||||||
|
* Broadcast mode is unchanged: lazy centroid pan for the wide shot.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Chase tuning — Dreamfall comfort defaults, scaled for a skating body. */
|
||||||
|
const CHASE = Object.freeze({
|
||||||
|
/** Base distance behind the skater, metres. */
|
||||||
|
distance: 8.5,
|
||||||
|
/** Extra metres at full skate speed. */
|
||||||
|
speedDistanceBoost: 1.6,
|
||||||
|
/** Orbit pitch (radians) — mild high angle over the shoulder. */
|
||||||
|
pitch: 0.28,
|
||||||
|
/** Eye / look height on the body, metres. */
|
||||||
|
lookHeight: 1.15,
|
||||||
|
/** Base look-ahead along travel/heading, metres. */
|
||||||
|
lookAhead: 3.2,
|
||||||
|
/** Extra look-ahead at speed. */
|
||||||
|
speedLookAheadBoost: 2.4,
|
||||||
|
/** Yaw spring (1/s) toward desired look heading. */
|
||||||
|
yawSmoothing: 4.6,
|
||||||
|
/** Camera position spring (1/s). Soft at idle; raised with speed. */
|
||||||
|
positionSmoothing: 7.0,
|
||||||
|
/** Planar target spring base (1/s). */
|
||||||
|
targetSmoothing: 12,
|
||||||
|
/** Hard ceiling on planar tracking spring. */
|
||||||
|
maxTrackingSmoothing: 40,
|
||||||
|
/** Hard ceiling on camera position spring. */
|
||||||
|
maxPositionSmoothing: 28,
|
||||||
|
/** Target lag budget (m) — trackSpeed / this raises the spring rate. */
|
||||||
|
maxChaseLag: 1.1,
|
||||||
|
/** Snap if planar lag exceeds this (teleport / mode switch). */
|
||||||
|
maxTargetLag: 10,
|
||||||
|
/** Height spring (1/s) — softer than planar. */
|
||||||
|
heightSmoothing: 4.0,
|
||||||
|
/** Look-point spring (1/s). */
|
||||||
|
lookTargetSmoothing: 9,
|
||||||
|
/** Blend of travel direction into look yaw at full speed. */
|
||||||
|
velocityLookBlend: 0.32,
|
||||||
|
/** Max yaw bias from heading toward velocity, radians (~18°). */
|
||||||
|
velocityLookMaxAngle: 0.32,
|
||||||
|
/** Speed (m/s) that counts as "full" for ease curves. */
|
||||||
|
fullSpeed: 7.4,
|
||||||
|
/** Low-pass on measured skate speed (1/s). */
|
||||||
|
speedSmoothing: 4.5,
|
||||||
|
/** Cap view yaw change, rad/s — backstop against single-frame whips. */
|
||||||
|
maxYawRate: 1.8,
|
||||||
|
/** Blend live XZ into smoothed target at speed (never Y). */
|
||||||
|
speedLiveAnchor: 0.35,
|
||||||
|
/** Min horizontal speed before velocity influences look yaw. */
|
||||||
|
velocityLookMinSpeed: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
function lerpAngle(from, to, alpha) {
|
||||||
|
return from + wrapAngle(to - from) * alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep01(t) {
|
||||||
|
const c = clamp(t, 0, 1);
|
||||||
|
return c * c * (3 - 2 * c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desired look yaw: heading blended toward travel, clamped so a spin cannot
|
||||||
|
* whip the camera. Same idea as Dreamfall `_computeVelocityLookYaw`.
|
||||||
|
*/
|
||||||
|
function velocityLookYaw(headingYaw, vx, vz, tuning) {
|
||||||
|
const speed = Math.hypot(vx, vz);
|
||||||
|
if (speed < tuning.velocityLookMinSpeed || tuning.velocityLookBlend <= 0) {
|
||||||
|
return headingYaw;
|
||||||
|
}
|
||||||
|
const travelYaw = Math.atan2(vx, vz);
|
||||||
|
// If mostly reversing, keep heading (don't flip the chase 180° every cut).
|
||||||
|
const fx = Math.sin(headingYaw);
|
||||||
|
const fz = Math.cos(headingYaw);
|
||||||
|
const along = (vx * fx + vz * fz) / speed;
|
||||||
|
if (along < -0.25) return headingYaw;
|
||||||
|
|
||||||
|
let delta = wrapAngle(travelYaw - headingYaw);
|
||||||
|
delta = clamp(delta, -tuning.velocityLookMaxAngle, tuning.velocityLookMaxAngle);
|
||||||
|
const w = clamp(speed / tuning.fullSpeed, 0, 1) * tuning.velocityLookBlend;
|
||||||
|
return wrapAngle(headingYaw + delta * w);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCamera(canvas, aspect) {
|
export function createCamera(canvas, aspect) {
|
||||||
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 400);
|
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 400);
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
mode: 'broadcast',
|
mode: 'broadcast',
|
||||||
/** Orbit angles, radians. */
|
/** Orbit yaw: where the camera sits relative to the target (broadcast / drag). */
|
||||||
yaw: 0,
|
yaw: 0,
|
||||||
pitch: 0.62,
|
pitch: 0.62,
|
||||||
distance: 34,
|
distance: 34,
|
||||||
@@ -28,8 +111,28 @@ export function createCamera(canvas, aspect) {
|
|||||||
followIndex: null,
|
followIndex: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- chase internals (Dreamfall-style) ----------------------------------
|
||||||
|
const chase = {
|
||||||
|
/** Smoothed heading the camera is aligned to (skater face / travel). */
|
||||||
|
lookYaw: 0,
|
||||||
|
/** Planar / height-smoothed anchor under the skater. */
|
||||||
|
anchor: new THREE.Vector3(),
|
||||||
|
hasAnchor: false,
|
||||||
|
height: 0,
|
||||||
|
hasHeight: false,
|
||||||
|
/** Smoothed look-at point. */
|
||||||
|
lookAt: new THREE.Vector3(),
|
||||||
|
hasLookAt: false,
|
||||||
|
/** Filtered skate speed. */
|
||||||
|
speed: 0,
|
||||||
|
/** True after first follow frame so we don't ease from origin. */
|
||||||
|
seeded: false,
|
||||||
|
};
|
||||||
|
|
||||||
const _want = new THREE.Vector3();
|
const _want = new THREE.Vector3();
|
||||||
const _offset = new THREE.Vector3();
|
const _offset = new THREE.Vector3();
|
||||||
|
const _desiredPos = new THREE.Vector3();
|
||||||
|
const _look = new THREE.Vector3();
|
||||||
|
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
let lastX = 0;
|
let lastX = 0;
|
||||||
@@ -62,12 +165,165 @@ export function createCamera(canvas, aspect) {
|
|||||||
canvas.addEventListener('pointercancel', endDrag);
|
canvas.addEventListener('pointercancel', endDrag);
|
||||||
canvas.addEventListener('wheel', (e) => {
|
canvas.addEventListener('wheel', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 6, 90);
|
if (state.mode === 'follow') {
|
||||||
|
state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 4.5, 18);
|
||||||
|
} else {
|
||||||
|
state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 6, 90);
|
||||||
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
|
function seedChase(s) {
|
||||||
|
chase.lookYaw = s.yaw;
|
||||||
|
chase.anchor.set(s.x, 0, s.z);
|
||||||
|
chase.hasAnchor = true;
|
||||||
|
chase.height = CHASE.lookHeight;
|
||||||
|
chase.hasHeight = true;
|
||||||
|
chase.lookAt.set(s.x, CHASE.lookHeight, s.z);
|
||||||
|
chase.hasLookAt = true;
|
||||||
|
chase.speed = Math.hypot(s.vx ?? 0, s.vz ?? 0);
|
||||||
|
chase.seeded = true;
|
||||||
|
state.yaw = wrapAngle(s.yaw + Math.PI);
|
||||||
|
state.pitch = CHASE.pitch;
|
||||||
|
state.distance = CHASE.distance;
|
||||||
|
state.target.set(s.x, CHASE.lookHeight, s.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Third-person chase — Dreamfall rally model, skater-sized.
|
||||||
|
* @param {number} dt
|
||||||
|
* @param {{ x:number, z:number, yaw:number, vx?:number, vz?:number }} s
|
||||||
|
*/
|
||||||
|
function updateFollow(dt, s) {
|
||||||
|
if (!chase.seeded) seedChase(s);
|
||||||
|
|
||||||
|
const vx = s.vx ?? 0;
|
||||||
|
const vz = s.vz ?? 0;
|
||||||
|
const rawSpeed = Math.hypot(vx, vz);
|
||||||
|
|
||||||
|
// ---- filtered speed ---------------------------------------------------
|
||||||
|
const speedA = 1 - Math.exp(-CHASE.speedSmoothing * dt);
|
||||||
|
chase.speed += (rawSpeed - chase.speed) * speedA;
|
||||||
|
const speedRatio = clamp(chase.speed / CHASE.fullSpeed, 0, 1);
|
||||||
|
const speedEase = smoothstep01(speedRatio);
|
||||||
|
|
||||||
|
// ---- heading + velocity look yaw --------------------------------------
|
||||||
|
const desiredLook = velocityLookYaw(s.yaw, vx, vz, CHASE);
|
||||||
|
if (!dragging) {
|
||||||
|
// Exp yaw spring + rate cap (Dreamfall _applyYawSmoothing).
|
||||||
|
let next = lerpAngle(chase.lookYaw, desiredLook, 1 - Math.exp(-CHASE.yawSmoothing * dt));
|
||||||
|
let d = wrapAngle(next - chase.lookYaw);
|
||||||
|
const maxStep = CHASE.maxYawRate * dt;
|
||||||
|
if (d > maxStep) d = maxStep;
|
||||||
|
if (d < -maxStep) d = -maxStep;
|
||||||
|
chase.lookYaw = wrapAngle(chase.lookYaw + d);
|
||||||
|
}
|
||||||
|
// Camera sits *behind* the look heading; orbit yaw is what stickToWorld uses.
|
||||||
|
if (!dragging) {
|
||||||
|
state.yaw = wrapAngle(chase.lookYaw + Math.PI);
|
||||||
|
}
|
||||||
|
state.pitch = CHASE.pitch;
|
||||||
|
|
||||||
|
// ---- planar target + height -------------------------------------------
|
||||||
|
// Stiffness scales with speed so lag ≈ v/λ stays near maxChaseLag.
|
||||||
|
const targetRate = Math.min(
|
||||||
|
CHASE.maxTrackingSmoothing,
|
||||||
|
Math.max(CHASE.targetSmoothing, chase.speed / Math.max(CHASE.maxChaseLag, 1e-3)),
|
||||||
|
);
|
||||||
|
const posRate = Math.min(
|
||||||
|
CHASE.maxPositionSmoothing,
|
||||||
|
Math.max(CHASE.positionSmoothing, chase.speed / Math.max(CHASE.maxChaseLag * 1.15, 1e-3)),
|
||||||
|
);
|
||||||
|
|
||||||
|
_want.set(s.x, 0, s.z);
|
||||||
|
if (!chase.hasAnchor || chase.anchor.distanceTo(_want) > CHASE.maxTargetLag) {
|
||||||
|
chase.anchor.copy(_want);
|
||||||
|
chase.hasAnchor = true;
|
||||||
|
} else {
|
||||||
|
chase.anchor.lerp(_want, 1 - Math.exp(-targetRate * dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft live XZ anchor at speed — residual lag does not read as pull-out.
|
||||||
|
const live = speedEase * CHASE.speedLiveAnchor;
|
||||||
|
const ax = chase.anchor.x + (s.x - chase.anchor.x) * live;
|
||||||
|
const az = chase.anchor.z + (s.z - chase.anchor.z) * live;
|
||||||
|
|
||||||
|
if (!chase.hasHeight) {
|
||||||
|
chase.height = CHASE.lookHeight;
|
||||||
|
chase.hasHeight = true;
|
||||||
|
} else {
|
||||||
|
chase.height += (CHASE.lookHeight - chase.height)
|
||||||
|
* (1 - Math.exp(-CHASE.heightSmoothing * dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.target.set(ax, chase.height, az);
|
||||||
|
|
||||||
|
// ---- camera position --------------------------------------------------
|
||||||
|
const distance = (state.distance || CHASE.distance) + speedEase * CHASE.speedDistanceBoost;
|
||||||
|
// state.distance is user zoom; base framing uses CHASE.distance when first
|
||||||
|
// entering follow — wheel multiplies state.distance in place.
|
||||||
|
const dist = clamp(distance, 4.5, 20);
|
||||||
|
const cp = Math.cos(state.pitch);
|
||||||
|
// Sit along orbit yaw (behind look heading when not dragging).
|
||||||
|
_offset.set(
|
||||||
|
Math.sin(state.yaw) * cp,
|
||||||
|
Math.sin(state.pitch),
|
||||||
|
Math.cos(state.yaw) * cp,
|
||||||
|
).multiplyScalar(dist);
|
||||||
|
_desiredPos.copy(state.target).add(_offset);
|
||||||
|
camera.position.lerp(_desiredPos, 1 - Math.exp(-posRate * dt));
|
||||||
|
|
||||||
|
// ---- look-ahead -------------------------------------------------------
|
||||||
|
// Along smoothed look heading (face + velocity lead), not raw chassis.
|
||||||
|
const lookAhead = CHASE.lookAhead + speedEase * CHASE.speedLookAheadBoost;
|
||||||
|
const lx = Math.sin(chase.lookYaw);
|
||||||
|
const lz = Math.cos(chase.lookYaw);
|
||||||
|
_look.set(
|
||||||
|
ax + lx * lookAhead,
|
||||||
|
chase.height,
|
||||||
|
az + lz * lookAhead,
|
||||||
|
);
|
||||||
|
if (!chase.hasLookAt || chase.lookAt.distanceTo(_look) > 16) {
|
||||||
|
chase.lookAt.copy(_look);
|
||||||
|
chase.hasLookAt = true;
|
||||||
|
} else {
|
||||||
|
chase.lookAt.lerp(_look, 1 - Math.exp(-CHASE.lookTargetSmoothing * dt));
|
||||||
|
}
|
||||||
|
camera.lookAt(chase.lookAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBroadcast(dt, skaters) {
|
||||||
|
chase.seeded = false;
|
||||||
|
const chaseRate = 2.4;
|
||||||
|
_want.set(0, 0.8, 0);
|
||||||
|
if (skaters.length) {
|
||||||
|
let x = 0;
|
||||||
|
let z = 0;
|
||||||
|
for (const s of skaters) {
|
||||||
|
x += s.x;
|
||||||
|
z += s.z;
|
||||||
|
}
|
||||||
|
_want.set(x / skaters.length, 0.8, z / skaters.length);
|
||||||
|
}
|
||||||
|
_want.x = clamp(_want.x, -RINK.halfX * 0.6, RINK.halfX * 0.6);
|
||||||
|
_want.z = clamp(_want.z, -RINK.halfZ * 0.6, RINK.halfZ * 0.6);
|
||||||
|
state.target.lerp(_want, Math.min(1, chaseRate * dt));
|
||||||
|
|
||||||
|
const cp = Math.cos(state.pitch);
|
||||||
|
_offset.set(
|
||||||
|
Math.sin(state.yaw) * cp,
|
||||||
|
Math.sin(state.pitch),
|
||||||
|
Math.cos(state.yaw) * cp,
|
||||||
|
).multiplyScalar(state.distance);
|
||||||
|
camera.position.copy(state.target).add(_offset);
|
||||||
|
camera.lookAt(state.target);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
camera,
|
camera,
|
||||||
state,
|
state,
|
||||||
|
/** Exposed for tests / tuning probes. */
|
||||||
|
chase,
|
||||||
|
CHASE,
|
||||||
|
|
||||||
resize(w, h) {
|
resize(w, h) {
|
||||||
camera.aspect = w / h;
|
camera.aspect = w / h;
|
||||||
@@ -79,62 +335,33 @@ export function createCamera(canvas, aspect) {
|
|||||||
if (state.mode === 'broadcast') {
|
if (state.mode === 'broadcast') {
|
||||||
state.mode = 'follow';
|
state.mode = 'follow';
|
||||||
state.followIndex = 0;
|
state.followIndex = 0;
|
||||||
|
chase.seeded = false;
|
||||||
|
state.distance = CHASE.distance;
|
||||||
|
state.pitch = CHASE.pitch;
|
||||||
} else if (state.followIndex + 1 < count) {
|
} else if (state.followIndex + 1 < count) {
|
||||||
state.followIndex += 1;
|
state.followIndex += 1;
|
||||||
|
chase.seeded = false;
|
||||||
|
state.distance = CHASE.distance;
|
||||||
|
state.pitch = CHASE.pitch;
|
||||||
} else {
|
} else {
|
||||||
state.mode = 'broadcast';
|
state.mode = 'broadcast';
|
||||||
state.followIndex = null;
|
state.followIndex = null;
|
||||||
|
chase.seeded = false;
|
||||||
|
state.distance = 34;
|
||||||
|
state.pitch = 0.62;
|
||||||
}
|
}
|
||||||
state.distance = state.mode === 'follow' ? 9 : 34;
|
|
||||||
state.pitch = state.mode === 'follow' ? 0.3 : 0.62;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} dt
|
* @param {number} dt
|
||||||
* @param {{x:number,z:number,yaw:number}[]} skaters
|
* @param {{x:number,z:number,yaw:number,vx?:number,vz?:number}[]} skaters
|
||||||
*/
|
*/
|
||||||
update(dt, skaters) {
|
update(dt, skaters) {
|
||||||
// How hard the camera chases its target. Broadcast wants to be lazy;
|
|
||||||
// follow cannot be, because a skater doing 7 m/s outruns a soft lerp and
|
|
||||||
// ends up drifting to the edge of frame while the camera trails behind.
|
|
||||||
let chase = 2.4;
|
|
||||||
if (state.mode === 'follow' && skaters[state.followIndex]) {
|
if (state.mode === 'follow' && skaters[state.followIndex]) {
|
||||||
const s = skaters[state.followIndex];
|
updateFollow(dt, skaters[state.followIndex]);
|
||||||
chase = 11;
|
|
||||||
_want.set(s.x, 1.1, s.z);
|
|
||||||
// Ease the orbit around behind whoever we are following, but let a
|
|
||||||
// drag override it — the yaw chases only while the pointer is idle.
|
|
||||||
if (!dragging) {
|
|
||||||
const behind = s.yaw + Math.PI;
|
|
||||||
// wrapAngle, not `%`: see the pointermove note. The old
|
|
||||||
// `((d + 3π) % 2π) - π` form only works while yaw stays near zero.
|
|
||||||
state.yaw = wrapAngle(state.yaw + wrapAngle(behind - state.yaw) * Math.min(1, 1.6 * dt));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Centroid of everyone, clamped so the camera never leaves the barn.
|
updateBroadcast(dt, skaters);
|
||||||
_want.set(0, 0.8, 0);
|
|
||||||
if (skaters.length) {
|
|
||||||
let x = 0;
|
|
||||||
let z = 0;
|
|
||||||
for (const s of skaters) {
|
|
||||||
x += s.x;
|
|
||||||
z += s.z;
|
|
||||||
}
|
|
||||||
_want.set(x / skaters.length, 0.8, z / skaters.length);
|
|
||||||
}
|
|
||||||
_want.x = clamp(_want.x, -RINK.halfX * 0.6, RINK.halfX * 0.6);
|
|
||||||
_want.z = clamp(_want.z, -RINK.halfZ * 0.6, RINK.halfZ * 0.6);
|
|
||||||
}
|
}
|
||||||
state.target.lerp(_want, Math.min(1, chase * dt));
|
|
||||||
|
|
||||||
const cp = Math.cos(state.pitch);
|
|
||||||
_offset.set(
|
|
||||||
Math.sin(state.yaw) * cp,
|
|
||||||
Math.sin(state.pitch),
|
|
||||||
Math.cos(state.yaw) * cp,
|
|
||||||
).multiplyScalar(state.distance);
|
|
||||||
camera.position.copy(state.target).add(_offset);
|
|
||||||
camera.lookAt(state.target);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play readability markers: puck arrow + ground rings for the controlled
|
||||||
|
* skater and the puck carrier.
|
||||||
|
*
|
||||||
|
* - Red down-arrow floats just above the puck and draws over everything
|
||||||
|
* (depthTest off) so it stays readable from the broadcast camera.
|
||||||
|
* - Controlled player: blue ice ring, turns green while they have the puck.
|
||||||
|
* - Teammate with puck: green ice ring. Opponent with puck: red.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BLUE = 0x3b9eff;
|
||||||
|
const GREEN = 0x3dff7a;
|
||||||
|
const RED = 0xff2a2a;
|
||||||
|
|
||||||
|
const ARROW = {
|
||||||
|
/** Height of the cone tip above the puck centre, metres. */
|
||||||
|
tipAbove: 0.42,
|
||||||
|
/** Bob amplitude / frequency so the marker still reads when the puck is still. */
|
||||||
|
bob: 0.04,
|
||||||
|
bobHz: 2.4,
|
||||||
|
coneR: 0.11,
|
||||||
|
coneH: 0.26,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RING = {
|
||||||
|
inner: 0.52,
|
||||||
|
outer: 0.68,
|
||||||
|
y: 0.025,
|
||||||
|
opacity: 0.9,
|
||||||
|
};
|
||||||
|
|
||||||
|
function overlayMaterial(color, opacity = 1) {
|
||||||
|
return new THREE.MeshBasicMaterial({
|
||||||
|
color,
|
||||||
|
transparent: opacity < 1,
|
||||||
|
opacity,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groundRingMaterial(color) {
|
||||||
|
return new THREE.MeshBasicMaterial({
|
||||||
|
color,
|
||||||
|
transparent: true,
|
||||||
|
opacity: RING.opacity,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
toneMapped: false,
|
||||||
|
// Sit on top of ice markings without z-fighting.
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: -2,
|
||||||
|
polygonOffsetUnits: -2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeArrow() {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'puckArrow';
|
||||||
|
group.renderOrder = 1000;
|
||||||
|
|
||||||
|
// Cone tip points +Y by default; flip so it points at the puck.
|
||||||
|
const cone = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(ARROW.coneR, ARROW.coneH, 5),
|
||||||
|
overlayMaterial(RED),
|
||||||
|
);
|
||||||
|
cone.rotation.x = Math.PI;
|
||||||
|
cone.position.y = 0;
|
||||||
|
cone.renderOrder = 1000;
|
||||||
|
// Frustum culling off: depth-disabled overlays can get culled incorrectly
|
||||||
|
// when the bounding sphere is wrong relative to the camera.
|
||||||
|
cone.frustumCulled = false;
|
||||||
|
group.add(cone);
|
||||||
|
|
||||||
|
// Thin stem above the head so it reads as an arrow, not a floating diamond.
|
||||||
|
const stem = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(ARROW.coneR * 0.28, ARROW.coneR * 0.28, 0.14, 6),
|
||||||
|
overlayMaterial(RED),
|
||||||
|
);
|
||||||
|
stem.position.y = ARROW.coneH * 0.5 + 0.07;
|
||||||
|
stem.renderOrder = 1000;
|
||||||
|
stem.frustumCulled = false;
|
||||||
|
group.add(stem);
|
||||||
|
|
||||||
|
group.frustumCulled = false;
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGroundRing(color) {
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(RING.inner, RING.outer, 48),
|
||||||
|
groundRingMaterial(color),
|
||||||
|
);
|
||||||
|
mesh.rotation.x = -Math.PI / 2;
|
||||||
|
mesh.position.y = RING.y;
|
||||||
|
mesh.renderOrder = 3;
|
||||||
|
mesh.visible = false;
|
||||||
|
mesh.name = 'groundRing';
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {THREE.Scene} scene
|
||||||
|
*/
|
||||||
|
export function createMarkers(scene) {
|
||||||
|
const arrow = makeArrow();
|
||||||
|
const playerRing = makeGroundRing(BLUE);
|
||||||
|
const carrierRing = makeGroundRing(RED);
|
||||||
|
playerRing.name = 'playerRing';
|
||||||
|
carrierRing.name = 'carrierRing';
|
||||||
|
|
||||||
|
scene.add(arrow);
|
||||||
|
scene.add(playerRing);
|
||||||
|
scene.add(carrierRing);
|
||||||
|
|
||||||
|
let t = 0;
|
||||||
|
const _puck = new THREE.Vector3();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* @param {number} dt
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {{ x: number, y?: number, z: number } | THREE.Vector3 | null} opts.puck
|
||||||
|
* @param {{ x: number, z: number } | null} opts.player controlled skater feet
|
||||||
|
* @param {{ x: number, z: number } | null} opts.carrier whoever has the puck
|
||||||
|
* @param {boolean} opts.playerHasPuck
|
||||||
|
* @param {boolean} opts.carrierIsTeammate true when carrier shares the player's team
|
||||||
|
*/
|
||||||
|
update(dt, {
|
||||||
|
puck = null,
|
||||||
|
player = null,
|
||||||
|
carrier = null,
|
||||||
|
playerHasPuck = false,
|
||||||
|
carrierIsTeammate = false,
|
||||||
|
} = {}) {
|
||||||
|
t += dt;
|
||||||
|
|
||||||
|
if (puck) {
|
||||||
|
_puck.set(puck.x, puck.y ?? 0.05, puck.z);
|
||||||
|
const bob = Math.sin(t * Math.PI * 2 * ARROW.bobHz) * ARROW.bob;
|
||||||
|
arrow.position.set(
|
||||||
|
_puck.x,
|
||||||
|
_puck.y + ARROW.tipAbove + bob,
|
||||||
|
_puck.z,
|
||||||
|
);
|
||||||
|
// Keep the arrow upright in world space (puck can tumble).
|
||||||
|
arrow.quaternion.identity();
|
||||||
|
arrow.visible = true;
|
||||||
|
} else {
|
||||||
|
arrow.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controlled player: blue, or green while carrying.
|
||||||
|
if (player) {
|
||||||
|
playerRing.visible = true;
|
||||||
|
playerRing.position.x = player.x;
|
||||||
|
playerRing.position.z = player.z;
|
||||||
|
playerRing.material.color.setHex(playerHasPuck ? GREEN : BLUE);
|
||||||
|
} else {
|
||||||
|
playerRing.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone else has the puck: green for teammates, red for opponents.
|
||||||
|
// When the controlled player has it, only the player ring (now green) shows.
|
||||||
|
if (carrier && !playerHasPuck) {
|
||||||
|
carrierRing.visible = true;
|
||||||
|
carrierRing.position.x = carrier.x;
|
||||||
|
carrierRing.position.z = carrier.z;
|
||||||
|
carrierRing.material.color.setHex(carrierIsTeammate ? GREEN : RED);
|
||||||
|
} else {
|
||||||
|
carrierRing.visible = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
scene.remove(arrow);
|
||||||
|
scene.remove(playerRing);
|
||||||
|
scene.remove(carrierRing);
|
||||||
|
for (const obj of [arrow, playerRing, carrierRing]) {
|
||||||
|
obj.traverse((child) => {
|
||||||
|
child.geometry?.dispose?.();
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) child.material.forEach((m) => m.dispose());
|
||||||
|
else child.material.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BLUE as MARKER_BLUE, GREEN as MARKER_GREEN, RED as MARKER_RED };
|
||||||
+56
-4
@@ -228,7 +228,8 @@ section('triggers are analog, not boolean');
|
|||||||
|
|
||||||
h.press(PAD.LT, 1);
|
h.press(PAD.LT, 1);
|
||||||
s = h.input.read(1 / 60);
|
s = h.input.read(1 / 60);
|
||||||
ok(s.brake, 'the left trigger stops');
|
ok(s.backskate, 'the left trigger is backskating (EA LT)');
|
||||||
|
ok(!s.brake, 'and is not a hockey stop');
|
||||||
ok(s.protect > 0.9, `and reports analog (${s.protect.toFixed(2)})`);
|
ok(s.protect > 0.9, `and reports analog (${s.protect.toFixed(2)})`);
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
@@ -260,6 +261,29 @@ section('buttons report as actions, and only on the edge');
|
|||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
section('RB holds stick-spread and blocks the Skill Stick wind-up');
|
||||||
|
{
|
||||||
|
const h = harness();
|
||||||
|
const dt = 1 / 60;
|
||||||
|
h.input.read(dt);
|
||||||
|
|
||||||
|
h.press(PAD.RB);
|
||||||
|
let s = h.input.read(dt);
|
||||||
|
ok(s.spread, 'RB is stick-spread');
|
||||||
|
ok(s.held.deke, 'and is held on the deke binding');
|
||||||
|
|
||||||
|
// Skill Stick wound back while spreading must not charge a shot.
|
||||||
|
h.pad.axes = [0, 0, 0, 1];
|
||||||
|
for (let i = 0; i < 20; i++) s = h.input.read(dt);
|
||||||
|
near(s.charge, 0, 1e-9, 'no wind-up charge while spreading');
|
||||||
|
ok(s.shot === null, 'and no shot fires from the skill stick');
|
||||||
|
|
||||||
|
h.release(PAD.RB);
|
||||||
|
s = h.input.read(dt);
|
||||||
|
ok(!s.spread, 'releasing RB ends the spread');
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
|
||||||
section('the Skill Stick fires a shot on pull-back-and-push');
|
section('the Skill Stick fires a shot on pull-back-and-push');
|
||||||
{
|
{
|
||||||
const h = harness();
|
const h = harness();
|
||||||
@@ -322,17 +346,42 @@ section('an abandoned wind-up is forgotten, not banked');
|
|||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
section('the shot carries aim from the stick');
|
section('the shot carries aim from how you moved during the wind-up');
|
||||||
{
|
{
|
||||||
const h = harness();
|
const h = harness();
|
||||||
const dt = 1 / 60;
|
const dt = 1 / 60;
|
||||||
h.input.read(dt);
|
h.input.read(dt);
|
||||||
h.pad.axes = [0, 0, 0.8, 1]; // wound back, stick held to the right
|
// Wind up while holding left stick right — that is the aim sample.
|
||||||
|
// Right stick is pulled back (pad Y +) with no lateral skill aim.
|
||||||
|
h.pad.axes = [0.85, 0, 0, 1];
|
||||||
|
let s;
|
||||||
|
for (let i = 0; i < 20; i++) s = h.input.read(dt);
|
||||||
|
ok(s.charge > 0.2, 'winding up');
|
||||||
|
ok(s.aimHint > 0.4, `live aim hint follows the move stick (${s.aimHint.toFixed(2)})`);
|
||||||
|
|
||||||
|
// Release: still holding left-right, skill stick forward.
|
||||||
|
h.pad.axes = [0.85, 0, 0, -1];
|
||||||
|
s = h.input.read(dt);
|
||||||
|
ok(s.shot, 'the shot fired');
|
||||||
|
ok(s.shot.move.x > 0.5, `move sample is right (${s.shot.move.x.toFixed(2)})`);
|
||||||
|
ok(Math.abs(s.shot.skillAim) < 0.2, `skill lateral is not the aim (${s.shot.skillAim.toFixed(2)})`);
|
||||||
|
ok(s.shot.aim > 0.4, `combined aim hint is right (${s.shot.aim.toFixed(2)})`);
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
section('Skill Stick lateral is recorded as a fine-tune, not primary aim');
|
||||||
|
{
|
||||||
|
const h = harness();
|
||||||
|
const dt = 1 / 60;
|
||||||
|
h.input.read(dt);
|
||||||
|
// No move stick; Skill Stick wound back and held right.
|
||||||
|
h.pad.axes = [0, 0, 0.8, 1];
|
||||||
for (let i = 0; i < 20; i++) h.input.read(dt);
|
for (let i = 0; i < 20; i++) h.input.read(dt);
|
||||||
h.pad.axes = [0, 0, 0.8, -1];
|
h.pad.axes = [0, 0, 0.8, -1];
|
||||||
const s = h.input.read(dt);
|
const s = h.input.read(dt);
|
||||||
ok(s.shot, 'the shot fired');
|
ok(s.shot, 'the shot fired');
|
||||||
ok(s.shot.aim > 0.5, `and remembers it was aimed right (${s.shot.aim.toFixed(2)})`);
|
ok(s.shot.skillAim > 0.5, `skillAim remembers the right push (${s.shot.skillAim.toFixed(2)})`);
|
||||||
|
near(s.shot.move.x, 0, 0.15, 'move sample stayed near centre');
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,10 +389,13 @@ section('the shoot button works for anyone who never learns the Skill Stick');
|
|||||||
{
|
{
|
||||||
const h = harness();
|
const h = harness();
|
||||||
h.input.read(1 / 60);
|
h.input.read(1 / 60);
|
||||||
|
// Aim with left stick, fire with X.
|
||||||
|
h.pad.axes = [-0.9, 0, 0, 0];
|
||||||
h.press(PAD.X);
|
h.press(PAD.X);
|
||||||
const s = h.input.read(1 / 60);
|
const s = h.input.read(1 / 60);
|
||||||
ok(s.shot, 'X shoots');
|
ok(s.shot, 'X shoots');
|
||||||
ok(s.shot.power > 0 && s.shot.power <= 1, `at a sensible power (${s.shot.power.toFixed(2)})`);
|
ok(s.shot.power > 0 && s.shot.power <= 1, `at a sensible power (${s.shot.power.toFixed(2)})`);
|
||||||
|
ok(s.shot.move.x < -0.5, `and samples the move stick for aim (${s.shot.move.x.toFixed(2)})`);
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,58 @@ section('a dumped puck slides a long way but does stop');
|
|||||||
physics.destroy();
|
physics.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
section('proximity grab rewards lining up a moving puck');
|
||||||
|
{
|
||||||
|
const {
|
||||||
|
interceptQuality, speedGrabFactor, crowdGrabFactor, CARRY: C,
|
||||||
|
} = await import('../src/game/possession.js');
|
||||||
|
const s = { x: 0, z: 0, yaw: Math.PI / 2, vx: 4, vz: 0 };
|
||||||
|
// Puck ahead on the line (within grab bubble), coming toward the skater.
|
||||||
|
const onLine = interceptQuality(
|
||||||
|
s,
|
||||||
|
{ x: 1.5, y: 0.02, z: 0 },
|
||||||
|
{ x: -12, y: 0, z: 0 },
|
||||||
|
C.grabRadius,
|
||||||
|
);
|
||||||
|
// Same distance, puck flying wide past the skater.
|
||||||
|
const wide = interceptQuality(
|
||||||
|
s,
|
||||||
|
{ x: 1.5, y: 0.02, z: 1.4 },
|
||||||
|
{ x: -12, y: 0, z: 0 },
|
||||||
|
C.grabRadius,
|
||||||
|
);
|
||||||
|
ok(onLine > 0.35, `lined-up hard puck is grabable (${onLine.toFixed(2)})`);
|
||||||
|
ok(onLine > wide + 0.08, `and beats a wide miss (${onLine.toFixed(2)} vs ${wide.toFixed(2)})`);
|
||||||
|
|
||||||
|
// Still puck at the feet — easy.
|
||||||
|
const soft = interceptQuality(
|
||||||
|
{ x: 0, z: 0, yaw: 0, vx: 0, vz: 0 },
|
||||||
|
{ x: 0.4, y: 0.02, z: 0.3 },
|
||||||
|
{ x: 0, y: 0, z: 0 },
|
||||||
|
C.grabRadius,
|
||||||
|
);
|
||||||
|
ok(soft > 0.4, `soft puck nearby is easy (${soft.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
section('fast pucks and crowds lower proximity grab odds');
|
||||||
|
{
|
||||||
|
const { speedGrabFactor, crowdGrabFactor, CARRY: C } = await import('../src/game/possession.js');
|
||||||
|
const still = speedGrabFactor(0, C.grabSpeedHalf);
|
||||||
|
const pass = speedGrabFactor(12, C.grabSpeedHalf);
|
||||||
|
const slap = speedGrabFactor(35, C.grabSpeedHalf);
|
||||||
|
ok(still > 0.9, `still puck is free to grab (${still.toFixed(2)})`);
|
||||||
|
ok(pass < still * 0.55, `a firm pass is harder (${pass.toFixed(2)} vs ${still.toFixed(2)})`);
|
||||||
|
ok(slap < pass, `a rocket is harder still (${slap.toFixed(2)})`);
|
||||||
|
ok(slap < 0.2, `slapshot proximity is weak (${slap.toFixed(2)})`);
|
||||||
|
|
||||||
|
near(crowdGrabFactor(1, C.grabCrowdK), 1, 1e-9, 'one body: full claim');
|
||||||
|
ok(crowdGrabFactor(2, C.grabCrowdK) < 0.75, `two bodies cut odds (${crowdGrabFactor(2, C.grabCrowdK).toFixed(2)})`);
|
||||||
|
ok(
|
||||||
|
crowdGrabFactor(4, C.grabCrowdK) < crowdGrabFactor(2, C.grabCrowdK),
|
||||||
|
'more bodies cut further',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
section('a skater picks up a loose puck');
|
section('a skater picks up a loose puck');
|
||||||
{
|
{
|
||||||
const { physics, match } = arena(1);
|
const { physics, match } = arena(1);
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { goalLineX } from '../shared/net.js';
|
||||||
|
import { resolveShotAim, SHOT_AIM } from '../src/game/shotAim.js';
|
||||||
|
import { done, near, ok, section } from './harness.mjs';
|
||||||
|
|
||||||
|
/** Shortest-arc wrap to (−π, π]. */
|
||||||
|
function wrap(a) {
|
||||||
|
let x = a;
|
||||||
|
while (x > Math.PI) x -= Math.PI * 2;
|
||||||
|
while (x <= -Math.PI) x += Math.PI * 2;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EA-style shot aim: movement stick + wind-up skating places the puck;
|
||||||
|
* Skill Stick is a light fine-tune.
|
||||||
|
*/
|
||||||
|
|
||||||
|
section('a neutral shot goes where the body points');
|
||||||
|
{
|
||||||
|
const r = resolveShotAim({
|
||||||
|
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
|
||||||
|
moveX: 0, moveY: 0, skillAim: 0,
|
||||||
|
});
|
||||||
|
near(r.aimYaw, Math.PI / 2, 1e-9, 'facing +X → aim +X');
|
||||||
|
near(r.placement, 0, 1e-6, 'no body lean');
|
||||||
|
ok(r.targetZ === null, 'no net target without a move stick');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('skate velocity yaws the shot off pure facing');
|
||||||
|
{
|
||||||
|
// Facing +X, but skating hard toward +Z (body left when facing +X:
|
||||||
|
// forward = (1,0), left = (−0? wait: right = (cos, -sin) of π/2 = (0,-1) = −Z;
|
||||||
|
// so +Z is body left).
|
||||||
|
const r = resolveShotAim({
|
||||||
|
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 5,
|
||||||
|
moveX: 0, moveY: 0, skillAim: 0,
|
||||||
|
});
|
||||||
|
// Velocity heading is 0; aim should pull off π/2 toward 0.
|
||||||
|
ok(r.aimYaw < Math.PI / 2 - 0.05, `velocity pulls aim (${r.aimYaw.toFixed(3)})`);
|
||||||
|
ok(r.aimYaw > 0, 'but not all the way to pure velocity');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('move stick opens net corners when facing the goal');
|
||||||
|
{
|
||||||
|
// Attacker on the slot, facing the +X net. Camera behind them looking +X:
|
||||||
|
// look dir = (+1, 0) = −(sin θ, cos θ) ⇒ θ = −π/2.
|
||||||
|
const cameraYaw = -Math.PI / 2;
|
||||||
|
|
||||||
|
const right = resolveShotAim({
|
||||||
|
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
|
||||||
|
moveX: 1, moveY: 0.15, cameraYaw, skillAim: 0,
|
||||||
|
goalX: goalLineX(1),
|
||||||
|
});
|
||||||
|
const left = resolveShotAim({
|
||||||
|
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
|
||||||
|
moveX: -1, moveY: 0.15, cameraYaw, skillAim: 0,
|
||||||
|
goalX: goalLineX(1),
|
||||||
|
});
|
||||||
|
const middle = resolveShotAim({
|
||||||
|
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
|
||||||
|
moveX: 0, moveY: 1, cameraYaw, skillAim: 0,
|
||||||
|
goalX: goalLineX(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
ok(right.targetZ != null, 'right stick engages net aim');
|
||||||
|
ok(left.targetZ != null, 'left stick engages net aim');
|
||||||
|
ok(
|
||||||
|
right.targetZ * left.targetZ < 0,
|
||||||
|
`opposite sticks open opposite posts (${right.targetZ?.toFixed(2)} vs ${left.targetZ?.toFixed(2)})`,
|
||||||
|
);
|
||||||
|
ok(Math.abs(right.targetZ) > 0.4, `opens a real corner (${right.targetZ?.toFixed(2)} m)`);
|
||||||
|
if (middle.targetZ != null) {
|
||||||
|
ok(
|
||||||
|
Math.abs(middle.targetZ) < Math.abs(right.targetZ),
|
||||||
|
'forward stick is closer to the middle than a full cut',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ok(Math.abs(wrap(right.aimYaw - left.aimYaw)) > 0.08, 'left and right aim yaws diverge');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('Skill Stick is only a light fine-tune');
|
||||||
|
{
|
||||||
|
const base = resolveShotAim({
|
||||||
|
x: 12, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
|
||||||
|
moveX: 0, moveY: 0, skillAim: 0,
|
||||||
|
});
|
||||||
|
const tuned = resolveShotAim({
|
||||||
|
x: 12, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
|
||||||
|
moveX: 0, moveY: 0, skillAim: 1,
|
||||||
|
});
|
||||||
|
const delta = Math.abs(wrap(tuned.aimYaw - base.aimYaw));
|
||||||
|
near(delta, SHOT_AIM.SKILL_RAD, 1e-9, `full skillAim is ${SHOT_AIM.SKILL_RAD} rad`);
|
||||||
|
ok(delta < 0.25, 'far smaller than the old 0.6 rad primary aim');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('far from the net, stick does not magnetize to the mouth');
|
||||||
|
{
|
||||||
|
// Centre ice, range past the net-aim window — still free to dump wide.
|
||||||
|
const r = resolveShotAim({
|
||||||
|
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
|
||||||
|
moveX: 1, moveY: 0, cameraYaw: -Math.PI / 2, skillAim: 0,
|
||||||
|
goalX: goalLineX(1),
|
||||||
|
});
|
||||||
|
ok(r.targetZ === null, 'outside shooting range: no net corner lock');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('placement sign matches Skill Stick right for the animator');
|
||||||
|
{
|
||||||
|
// Skill Stick +X only → placement must be positive (poseWindup / poseShot).
|
||||||
|
const r = resolveShotAim({
|
||||||
|
x: 0, z: 0, yaw: 0, vx: 0, vz: 0,
|
||||||
|
moveX: 0, moveY: 0, skillAim: 1,
|
||||||
|
});
|
||||||
|
ok(r.placement > 0.1, `skill right → positive placement (${r.placement.toFixed(3)})`);
|
||||||
|
near(r.aimYaw, SHOT_AIM.SKILL_RAD, 1e-9, 'and yaw adds by the fine-tune amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('move stick right of body yields positive placement');
|
||||||
|
{
|
||||||
|
// Facing +Z (yaw 0). Body right is +X. Camera yaw 0: screen right is +X world.
|
||||||
|
// Stick right → world +X → aim turns right of facing → positive placement.
|
||||||
|
const r = resolveShotAim({
|
||||||
|
x: 0, z: 0, yaw: 0, vx: 0, vz: 0,
|
||||||
|
moveX: 1, moveY: 0, cameraYaw: 0, skillAim: 0,
|
||||||
|
// No net: pure world stick pull (out of range / not forced)
|
||||||
|
goalX: goalLineX(1),
|
||||||
|
});
|
||||||
|
// At centre ice net aim is off; stick right still pulls aimYaw toward +X (π/2).
|
||||||
|
ok(r.aimYaw > 0.1, `stick right yaws positive (${r.aimYaw.toFixed(3)})`);
|
||||||
|
ok(r.placement > 0.1, `and placement is positive for the pose (${r.placement.toFixed(3)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
done('shotAim');
|
||||||
@@ -183,6 +183,113 @@ section('turning is harder at speed than at rest');
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
section('LT does not spin the body around');
|
||||||
|
{
|
||||||
|
// Facing +X, stick pulled toward −X under LT — should retreat heels-first
|
||||||
|
// without yaw flipping to face the stick.
|
||||||
|
const s = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
|
||||||
|
const startYaw = s.yaw;
|
||||||
|
run(s, 2.5, (st) => {
|
||||||
|
st.ix = -1;
|
||||||
|
st.iz = 0;
|
||||||
|
st.backskate = true;
|
||||||
|
});
|
||||||
|
const yawDrift = Math.abs(wrap(s.yaw - startYaw));
|
||||||
|
ok(yawDrift < 0.45, `facing holds under LT (yaw drift ${yawDrift.toFixed(2)} rad)`);
|
||||||
|
ok(s.bladeSpeed < -1.2, `stick back drives reverse (${s.bladeSpeed.toFixed(2)} m/s)`);
|
||||||
|
ok(s.x < -1.5, `and the body retreats (x ${s.x.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
section('LT with stick forward is a crawl, not a rush');
|
||||||
|
{
|
||||||
|
const free = createSkaterState(0, START);
|
||||||
|
run(free, 5, forward);
|
||||||
|
const contain = createSkaterState(1, START);
|
||||||
|
run(contain, 5, (st) => {
|
||||||
|
forward(st);
|
||||||
|
st.backskate = true;
|
||||||
|
});
|
||||||
|
ok(
|
||||||
|
speedOf(free) > speedOf(contain) + 1.2,
|
||||||
|
`contain is much slower than free skate (${speedOf(contain).toFixed(2)} vs ${speedOf(free).toFixed(2)})`,
|
||||||
|
);
|
||||||
|
ok(speedOf(contain) <= SKATE.containForwardSpeed + 0.25, 'under the forward-contain ceiling');
|
||||||
|
ok(contain.bladeSpeed > 0, 'still facing the stick way, not flipped reverse');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('LT reverse and lateral accelerate quickly for gap control');
|
||||||
|
{
|
||||||
|
// Reverse from a standstill under LT should get moving fast.
|
||||||
|
const back = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
|
||||||
|
run(back, 0.45, (st) => {
|
||||||
|
st.ix = -1;
|
||||||
|
st.iz = 0;
|
||||||
|
st.backskate = true;
|
||||||
|
});
|
||||||
|
ok(back.bladeSpeed < -2.5, `quick reverse accel (${back.bladeSpeed.toFixed(2)} m/s in 0.45s)`);
|
||||||
|
ok(Math.abs(back.x) > 0.6, 'and covers ground retreating');
|
||||||
|
|
||||||
|
// Lateral shuffle under LT.
|
||||||
|
const side = createSkaterState(1, { x: 0, z: 0, yaw: Math.PI / 2 });
|
||||||
|
run(side, 0.45, (st) => {
|
||||||
|
st.ix = 0;
|
||||||
|
st.iz = 1; // body right when facing +X
|
||||||
|
st.backskate = true;
|
||||||
|
});
|
||||||
|
ok(Math.abs(side.z) > 0.7, `quick lateral under LT (z ${side.z.toFixed(2)})`);
|
||||||
|
ok(Math.abs(side.x) < Math.abs(side.z), 'mostly sideways relative to facing (+X)');
|
||||||
|
|
||||||
|
// Flip reverse → forward under LT (offense ↔ defense switch).
|
||||||
|
const flip = createSkaterState(2, { x: 0, z: 0, yaw: Math.PI / 2 });
|
||||||
|
run(flip, 0.8, (st) => {
|
||||||
|
st.ix = -1;
|
||||||
|
st.iz = 0;
|
||||||
|
st.backskate = true;
|
||||||
|
});
|
||||||
|
const rev = flip.bladeSpeed;
|
||||||
|
ok(rev < -2, 'was reverse');
|
||||||
|
run(flip, 0.35, (st) => {
|
||||||
|
st.ix = 1;
|
||||||
|
st.iz = 0;
|
||||||
|
st.backskate = true;
|
||||||
|
});
|
||||||
|
ok(flip.bladeSpeed > rev + 2.5, `snaps out of reverse toward forward (${flip.bladeSpeed.toFixed(2)} from ${rev.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
section('LT stick neutral bleeds a rush toward neutral');
|
||||||
|
{
|
||||||
|
const s = createSkaterState(0, START);
|
||||||
|
run(s, 5, forward);
|
||||||
|
const entry = speedOf(s);
|
||||||
|
ok(entry > 3.5, 'at cruise');
|
||||||
|
// Hold LT, stick centred — sit in the gap instead of flying.
|
||||||
|
run(s, 1.2, (st) => {
|
||||||
|
st.ix = 0;
|
||||||
|
st.iz = 0;
|
||||||
|
st.backskate = true;
|
||||||
|
});
|
||||||
|
ok(speedOf(s) < entry * 0.55, `bled speed under LT (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`);
|
||||||
|
ok(speedOf(s) > 0.15, 'but not a hard snowplow to zero');
|
||||||
|
const yawStill = Math.abs(wrap(s.yaw - Math.PI / 2));
|
||||||
|
ok(yawStill < 0.3, 'and did not spin to face the other way');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('applyIntent accepts backskate');
|
||||||
|
{
|
||||||
|
const s = createSkaterState(0, START);
|
||||||
|
applyIntent(s, { ix: 0, iz: 1, backskate: 1 });
|
||||||
|
ok(s.backskate === true, 'backskate flag is boolean true');
|
||||||
|
applyIntent(s, { ix: 0, iz: 1, backskate: 0 });
|
||||||
|
ok(s.backskate === false, 'and clears');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(a) {
|
||||||
|
let x = a;
|
||||||
|
while (x > Math.PI) x -= Math.PI * 2;
|
||||||
|
while (x <= -Math.PI) x += Math.PI * 2;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
section('nothing leaves the rink');
|
section('nothing leaves the rink');
|
||||||
{
|
{
|
||||||
// Point skaters at the boards from centre ice and hold it for ten seconds.
|
// Point skaters at the boards from centre ice and hold it for ten seconds.
|
||||||
|
|||||||
Reference in New Issue
Block a user