animations
This commit is contained in:
@@ -51,7 +51,7 @@ export const SKATE_POSE = {
|
||||
* Foot targets are not written here; they are world-space and belong to the
|
||||
* 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 fast = clamp(speed / 7, 0, 1);
|
||||
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 crouch = lerp(K.crouchIdle, K.crouchStride, gait) + idle;
|
||||
const pitch = lerp(K.leanIdle, K.leanFast, fast);
|
||||
const twist = K.hipTwist * gait;
|
||||
// Backskating is more upright — eyes on the rush, weight over the heels —
|
||||
// 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
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* reaching after it, back almost as fast as it went out.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { poseSkate, poseStop } from './poses/skate.js';
|
||||
import {
|
||||
STICK_ARMS, STICK_BONES, STICK_SPINE,
|
||||
mirrorStickwork,
|
||||
poseCarry, posePass, posePoke,
|
||||
poseCarry, posePass, posePoke, poseSpread,
|
||||
} from './poses/stickwork.js';
|
||||
import { frameSpan, sampleTiltStick } from './clip.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. */
|
||||
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. */
|
||||
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. */
|
||||
yawRate: 0,
|
||||
braking: false,
|
||||
/** LT backskate / reverse blade — more upright, smaller forward lean. */
|
||||
backskating: false,
|
||||
|
||||
// ---- derived, smoothed --------------------------------------------------
|
||||
/** Stride amplitude, 0 (pure glide) .. 1 (digging in). */
|
||||
@@ -123,6 +140,12 @@ export function buildAnimator(skelData, mover) {
|
||||
// ---- stickwork ---------------------------------------------------------
|
||||
/** True while this skater has the puck. Decides the resting grip. */
|
||||
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. */
|
||||
handling: { x: 0, y: 0 },
|
||||
/** 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. */
|
||||
stick: null,
|
||||
/**
|
||||
* Current stick action: null, 'windup', 'shoot', 'pass' or 'poke'.
|
||||
* Wind-up is held; the other three run once and blend out.
|
||||
* Current stick action: null, 'windup', 'spread', 'shoot', 'pass' or 'poke'.
|
||||
* Wind-up and spread are held; the others run once and blend out.
|
||||
*/
|
||||
action: null,
|
||||
actionTime: 0,
|
||||
actionPower: 1,
|
||||
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. */
|
||||
actionFromWindup: false,
|
||||
/** Eased 0..1 between the settled grip and the one-handed dangle. */
|
||||
@@ -338,6 +363,17 @@ export function buildAnimator(skelData, mover) {
|
||||
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 t = anim.actionTime / duration;
|
||||
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. */
|
||||
function gripFor() {
|
||||
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') {
|
||||
// Three beats matching poseShot: loaded → blade square on the ice →
|
||||
// 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,
|
||||
phase: anim.stridePhase,
|
||||
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']) {
|
||||
const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1;
|
||||
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
|
||||
// a stick.
|
||||
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, {
|
||||
hustle: anim.hustleGrip,
|
||||
reach: anim.handling.y,
|
||||
hustle: Math.max(anim.hustleGrip, grabReach * 0.45),
|
||||
reach: anim.handling.y + grabReach * 0.55,
|
||||
lateral: anim.handling.x,
|
||||
});
|
||||
// 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.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}`].getWorldQuaternion(_handQuat);
|
||||
_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
|
||||
// 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.
|
||||
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);
|
||||
if (twoHanded > 0.05 && !referenceHandsAligned) {
|
||||
// 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.effort = s.effort;
|
||||
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. */
|
||||
|
||||
+151
-72
@@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import { clamp } from '../core/math.js';
|
||||
import { PART } from './body.js';
|
||||
import { JERSEY_COVERAGE, jerseyParts, skinnedFrom } from './jersey.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 = {
|
||||
helmet: {
|
||||
/** Skull centre in head-bone-local space. */
|
||||
riseY: 0.094,
|
||||
pushZ: -0.004,
|
||||
rx: 0.114,
|
||||
ry: 0.148,
|
||||
rz: 0.125,
|
||||
riseY: 0.098,
|
||||
pushZ: 0.002,
|
||||
/** Half-widths of the shell ellipse (scaled by headF at build). */
|
||||
rx: 0.118,
|
||||
ry: 0.142,
|
||||
rz: 0.132,
|
||||
/**
|
||||
* Polar angle the shell starts at. This is the number that decides whether
|
||||
* you get a helmet or a beanie: the bottom ring sits at
|
||||
* riseY − ry·cos(phi0), so it has to come out *below* the ear line.
|
||||
* Polar angle the shell starts at. Lower = deeper bowl so the brim sits
|
||||
* under the ear line instead of reading as a beanie on the crown.
|
||||
*/
|
||||
phi0: 0.36,
|
||||
wall: 0.009,
|
||||
/** Brow line: everything in front of and below this is open face. */
|
||||
browY: 0.03,
|
||||
earY: -0.022,
|
||||
phi0: 0.28,
|
||||
wall: 0.011,
|
||||
/** Face port: open below the brow, full cage covers it. */
|
||||
browY: 0.018,
|
||||
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. */
|
||||
bladeY: -0.09,
|
||||
@@ -414,10 +418,14 @@ export function buildSkaterGear(mats, skelData, phys) {
|
||||
const gloveR = makeGlove('R');
|
||||
|
||||
// ---- 7. helmet ----------------------------------------------------------
|
||||
// Same carved-shell builder as the goalie mask, cut differently: the whole
|
||||
// lower front is open face, with ear ports at the sides.
|
||||
// White hard-shell player helmet with a full wire cage — not a team-coloured
|
||||
// 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 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) {
|
||||
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 front = Math.max(0, f);
|
||||
const back = Math.max(0, -f);
|
||||
const side = Math.abs(sx);
|
||||
|
||||
let rx = H.rx * headF;
|
||||
let rz = H.rz * headF;
|
||||
// Occipital shell carries out over the back of the skull.
|
||||
rz *= 1 + 0.10 * back * v;
|
||||
// Slight flat across the forehead.
|
||||
rz *= 1 - 0.06 * front * front * v;
|
||||
// Flatten the crown slightly so it reads as a helmet, not a ball.
|
||||
const crownFlat = 1 - 0.14 * Math.max(0, 1 - v * 1.8) ** 2;
|
||||
let rx = H.rx * headF * crownFlat;
|
||||
let ry = H.ry * headF;
|
||||
let rz = H.rz * headF * crownFlat;
|
||||
// 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 y = -H.ry * headF * cp;
|
||||
const y = -ry * cp;
|
||||
let z = rz * sp * f;
|
||||
// Brow lip juts forward over the eyes.
|
||||
const lip = Math.exp(-(((v - 0.08) / 0.12) ** 2)) * front ** 2;
|
||||
z += 0.008 * lip;
|
||||
|
||||
return out.set(skull.x + x, skull.y + y, skull.z + z);
|
||||
const lip = Math.exp(-(((v - 0.1) / 0.11) ** 2)) * front ** 2;
|
||||
z += 0.012 * lip * headF;
|
||||
// Subtle centre ridge along the crown (vents live here visually).
|
||||
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 dy = p.y - skull.y;
|
||||
const dz = p.z - skull.z;
|
||||
const ax = Math.abs(p.x);
|
||||
// The face: front-centre below the brow. Narrow, so the shell keeps its
|
||||
// cheek coverage instead of turning into a cap.
|
||||
if (dz > 0.028 && dy < H.browY && ax < 0.072) return true;
|
||||
// Ear ports, covered by the cups.
|
||||
if (ax > 0.088 && dy < H.earY + 0.026 && dy > H.earY - 0.042 && Math.abs(dz + 0.014) < 0.038) {
|
||||
// Full face opening for the cage — wider and deeper than the old half-cap.
|
||||
if (dz > 0.022 * headF && dy < H.browY && ax < H.portW * headF) return true;
|
||||
// Ear ports.
|
||||
if (
|
||||
ax > 0.09 * headF
|
||||
&& dy < H.earY + 0.03
|
||||
&& dy > H.earY - 0.048
|
||||
&& Math.abs(dz + 0.01) < 0.042
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const helmetColor = (p, kind) => {
|
||||
if (kind === 'inner') return PAL.pad;
|
||||
if (kind === 'inner') return LINER;
|
||||
const dy = p.y - skull.y;
|
||||
// Dark brim around the bottom edge of the shell.
|
||||
if (dy < -0.028) return PAL.trim;
|
||||
// Centre stripe over the crown.
|
||||
if (Math.abs(p.x) < 0.019 && dy > 0.03) return PAL.accent;
|
||||
return PAL.jersey;
|
||||
// Dark rubber brim / chin-strap mounts along the lower edge.
|
||||
if (dy < -0.055 * headF) return LINER;
|
||||
// Soft grey vents strip on the crown midline.
|
||||
if (Math.abs(p.x) < 0.016 * headF && dy > 0.04 * headF) return WHITE_DIM;
|
||||
return WHITE;
|
||||
};
|
||||
|
||||
const helmet = new THREE.Group();
|
||||
helmet.name = 'helmet';
|
||||
helmet.add(mesh(
|
||||
carvedShell({
|
||||
rows: 26,
|
||||
cols: 36,
|
||||
rows: 32,
|
||||
cols: 44,
|
||||
thickness: H.wall,
|
||||
center: skull,
|
||||
surface: helmetSurface,
|
||||
@@ -486,49 +504,104 @@ export function buildSkaterGear(mats, skelData, phys) {
|
||||
'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]) {
|
||||
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.104, skull.y + H.earY, skull.z - 0.014), 0.03, 0.028, 3),
|
||||
S(V(s * 0.111, skull.y + H.earY, skull.z - 0.014), 0.023, 0.021, 3),
|
||||
], { radial: 12, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
|
||||
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.108 * headF, skull.y + H.earY, skull.z - 0.012), 0.034, 0.032, 3.2, WHITE),
|
||||
S(V(s * 0.116 * headF, skull.y + H.earY, skull.z - 0.012), 0.026, 0.024, 3.5, WHITE_DIM),
|
||||
], { radial: 14, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
|
||||
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(
|
||||
tube([
|
||||
V(-0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
|
||||
V(-0.07, skull.y - 0.12, skull.z + 0.03),
|
||||
V(0, skull.y - 0.145, skull.z + 0.05),
|
||||
V(0.07, skull.y - 0.12, skull.z + 0.03),
|
||||
V(0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
|
||||
], 0.006, { radial: 6 }),
|
||||
V(-0.11 * headF, skull.y + H.earY - 0.01, skull.z - 0.008),
|
||||
V(-0.072 * headF, skull.y - 0.118 * headF, skull.z + 0.028 * headF),
|
||||
V(0, skull.y - 0.14 * headF, skull.z + 0.052 * headF),
|
||||
V(0.072 * headF, skull.y - 0.118 * headF, skull.z + 0.028 * headF),
|
||||
V(0.11 * headF, skull.y + H.earY - 0.01, skull.z - 0.008),
|
||||
], 0.0055, { radial: 6 }),
|
||||
mats.strap,
|
||||
'helmetStrap',
|
||||
));
|
||||
|
||||
// Half visor: eye level only. Run it down over the whole face and the player
|
||||
// reads as a welder.
|
||||
// Full wire cage over the face port — horizontal + vertical grid + rim.
|
||||
{
|
||||
const arc = [];
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const a = -0.82 + (1.64 * i) / 10;
|
||||
arc.push(V(
|
||||
Math.sin(a) * 0.106 * headF,
|
||||
skull.y + 0.004,
|
||||
skull.z + Math.cos(a) * 0.116 * headF,
|
||||
));
|
||||
const CAGE_W = H.portW * 1.22 * headF;
|
||||
const CAGE_H = 0.078 * headF;
|
||||
const portCY = skull.y + H.browY - CAGE_H * 0.55;
|
||||
const _probe = new THREE.Vector3();
|
||||
// Sample shell front at brow so the cage sits on the opening, not through it.
|
||||
helmetSurface(0, 0.12, _probe);
|
||||
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
|
||||
// height and `rz` is its thickness. Swap those two and you get a shelf
|
||||
// sticking out of the face instead of a shield hanging over the eyes.
|
||||
const visor = loft(
|
||||
arc.map((c, i) => S(c, i === 0 || i === arc.length - 1 ? 0.026 : 0.038, 0.003, 3)),
|
||||
{ radial: 8, sub: 2, ref: new THREE.Vector3(0, 1, 0) },
|
||||
);
|
||||
helmet.add(mesh(visor, mats.visor, 'helmetVisor'));
|
||||
// Vertical bars (3).
|
||||
for (const fx of [-0.55, 0, 0.55]) {
|
||||
const x = CAGE_W * fx;
|
||||
const span = CAGE_H * Math.sqrt(Math.max(0, 1 - (x / Math.max(CAGE_W, 1e-4)) ** 2));
|
||||
if (span < 0.018) continue;
|
||||
const pts = [];
|
||||
for (let j = 0; j <= 10; j++) {
|
||||
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);
|
||||
|
||||
@@ -583,8 +656,14 @@ export function buildSkaterGearMaterials(teamJersey, teamAccent = 0xf0e6d2) {
|
||||
hard: new THREE.MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
vertexColors: true,
|
||||
roughness: 0.38,
|
||||
metalness: 0.06,
|
||||
roughness: 0.32,
|
||||
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({
|
||||
color: 0xc8ccd4,
|
||||
|
||||
@@ -86,6 +86,13 @@ export const GRIP = {
|
||||
follow: { target: [0.28, 0.72, 0.88], roll: 0.42 },
|
||||
/** Poke: thrust out flat, as far ahead as the arm reaches. */
|
||||
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. */
|
||||
|
||||
+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,
|
||||
* 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.
|
||||
*
|
||||
* 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 = {
|
||||
/** Right stick Y below this counts as winding up. */
|
||||
@@ -82,6 +86,11 @@ const SHOT = {
|
||||
timeout: 1.6,
|
||||
/** Floor so a quick snap still does something. */
|
||||
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 = () => ({
|
||||
@@ -116,8 +125,19 @@ export function createInput(target = window) {
|
||||
// Previous frame's button state, for edge detection.
|
||||
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 = {
|
||||
// ---- the movement contract the match consumes --------------------------
|
||||
@@ -125,6 +145,13 @@ export function createInput(target = window) {
|
||||
y: 0,
|
||||
sprint: 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,
|
||||
|
||||
// ---- richer view for everything else -----------------------------------
|
||||
@@ -141,12 +168,19 @@ export function createInput(target = window) {
|
||||
pressed: rising(),
|
||||
/**
|
||||
* 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
|
||||
* release, which is where the shot is being placed.
|
||||
* `{ power, move: {x,y}, skillAim, aim }` —
|
||||
* 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,
|
||||
/** How wound up the shot is right now, 0..1. Drives the wind-up pose. */
|
||||
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',
|
||||
padId: null,
|
||||
};
|
||||
@@ -181,6 +215,22 @@ export function createInput(target = window) {
|
||||
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.
|
||||
*
|
||||
@@ -195,16 +245,27 @@ export function createInput(target = window) {
|
||||
wind.active = true;
|
||||
wind.t = 0;
|
||||
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.aimHint = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
wind.t += dt;
|
||||
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.aimHint = clamp(wind.moveX * 0.85 + wind.skillAim * 0.25, -1, 1);
|
||||
|
||||
if (y > SHOT.releaseAt) {
|
||||
const power = clamp(
|
||||
@@ -212,14 +273,24 @@ export function createInput(target = window) {
|
||||
0,
|
||||
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;
|
||||
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.
|
||||
if (wind.t > SHOT.timeout) {
|
||||
wind.active = false;
|
||||
state.charge = 0;
|
||||
state.aimHint = 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -323,15 +394,37 @@ export function createInput(target = window) {
|
||||
// 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.
|
||||
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;
|
||||
|
||||
// ---- Skill Stick -------------------------------------------------------
|
||||
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.
|
||||
if (!state.shot && state.pressed.shoot) {
|
||||
state.shot = { power: 0.6, aim: clamp(state.skill.x, -1, 1) };
|
||||
// While spreading, Skill Stick is the waggle — cancel any wind-up so a
|
||||
// lateral push does not bank a slapshot.
|
||||
if (state.spread) {
|
||||
wind.active = false;
|
||||
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;
|
||||
|
||||
+75
-21
@@ -3,6 +3,7 @@ import { createSkater } from '../character/skater.js';
|
||||
import { createBrain, spawnLineup, steer } from '../../shared/ai.js';
|
||||
import { applyIntent, createSkaterState, stepSkater, SKATE } from '../../shared/skaterSim.js';
|
||||
import { stickToWorld } from './input.js';
|
||||
import { resolveShotAim } from './shotAim.js';
|
||||
import { createHitResolver } from './hits.js';
|
||||
import { PUCK, createPuck } from '../physics/puck.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 = [];
|
||||
/** Previous velocity heading per skater, for the animator's bank. */
|
||||
const prevVelYaw = [];
|
||||
/** Previous Skill Stick X while stick-spreading, for swat waggle rate. */
|
||||
const prevSpreadWaggle = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const spawn = spawns[i];
|
||||
@@ -59,6 +62,7 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
||||
states.push(s);
|
||||
brains.push(createBrain(rng.f, {}));
|
||||
prevVelYaw.push(spawn.yaw);
|
||||
prevSpreadWaggle.push(0);
|
||||
skaters.push(createSkater({
|
||||
seed: seed + i * 977,
|
||||
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.
|
||||
*
|
||||
* Aim comes from where the skater is facing plus the Skill Stick's lateral
|
||||
* position, so you place a shot by holding the stick off to one side as you
|
||||
* release. A pass looks for the nearest teammate ahead instead.
|
||||
* Aim is EA-style: the movement stick (and how you skated during the wind-up)
|
||||
* places the shot across the net; the Skill Stick is power + release with a
|
||||
* light lateral fine-tune. See `resolveShotAim`. A pass looks for the nearest
|
||||
* teammate ahead instead.
|
||||
*/
|
||||
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;
|
||||
const state = states[i];
|
||||
|
||||
if (control.shot) {
|
||||
// Up to ~35° of placement either side of where they are pointing.
|
||||
// Skill Stick +X is "push right"; positive yaw is a left turn in this
|
||||
// frame, so aim subtracts — otherwise every placed shot went the wrong way.
|
||||
const stickAim = control.shot.aim ?? 0;
|
||||
const aimYaw = state.yaw - stickAim * 0.6;
|
||||
possession.shoot(control.shot.power, aimYaw);
|
||||
const move = control.shot.move ?? control.move ?? { x: 0, y: 0 };
|
||||
const resolved = resolveShotAim({
|
||||
x: state.x,
|
||||
z: state.z,
|
||||
yaw: state.yaw,
|
||||
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', {
|
||||
power: control.shot.power,
|
||||
aim: stickAim,
|
||||
aim: resolved.placement,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -334,8 +349,9 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
||||
}
|
||||
const control = controls.get(i);
|
||||
if (control) {
|
||||
// The Skill Stick moves the puck, and only for whoever is carrying it.
|
||||
if (possession.carrier === i && control.skill) {
|
||||
// The Skill Stick moves the puck, and only for whoever is carrying it
|
||||
// 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.y = control.skill.y;
|
||||
}
|
||||
@@ -346,16 +362,40 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
|
||||
iz: _worldIntent.iz,
|
||||
sprint: control.sprint,
|
||||
brake: control.brake,
|
||||
backskate: control.backskate,
|
||||
});
|
||||
// Keep the brain's waypoint fresh so handing control back does not
|
||||
// send them skating off to somewhere chosen a minute ago.
|
||||
brains[i].target = null;
|
||||
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);
|
||||
|
||||
if (control.spread) {
|
||||
// Stick-spread: held pose + continuous swat check while the blade
|
||||
// is out. Waggle rate powers active sweeps.
|
||||
const waggle = control.skill?.x ?? 0;
|
||||
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 {
|
||||
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;
|
||||
const ctrl = controls.get(i);
|
||||
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) {
|
||||
anim.handling.x = possession.handling.x;
|
||||
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);
|
||||
}
|
||||
// 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.actionTime = 0;
|
||||
} else if (anim.action === 'windup' && (!anim.hasPuck || anim.charge <= 0.05)) {
|
||||
anim.action = null;
|
||||
anim.actionAim = ctrl?.aimHint ?? 0;
|
||||
} 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);
|
||||
|
||||
+366
-9
@@ -30,8 +30,40 @@ import { clamp, lerp } from '../../shared/scalar.js';
|
||||
export const CARRY = {
|
||||
/** Default dial position. Tuned by hand; see the note above. */
|
||||
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,
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -60,6 +92,17 @@ export const CARRY = {
|
||||
pokeRadius: 1.25,
|
||||
/** How hard a poke or a check knocks the puck away, m/s. */
|
||||
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. */
|
||||
releaseGap: 0.4,
|
||||
};
|
||||
@@ -71,6 +114,74 @@ const _puckPos = new THREE.Vector3();
|
||||
const _puckVel = 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.puck from createPuck
|
||||
@@ -85,6 +196,16 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
||||
/** Global cooldown after a deliberate release. */
|
||||
let looseFor = 0;
|
||||
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. */
|
||||
const handling = { x: 0, y: 0 };
|
||||
@@ -161,6 +282,92 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
||||
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 —
|
||||
* 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; },
|
||||
shoot,
|
||||
poke,
|
||||
swat,
|
||||
jar,
|
||||
release,
|
||||
capture,
|
||||
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. */
|
||||
carryPoint(out) {
|
||||
@@ -237,6 +449,7 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
||||
*/
|
||||
update(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);
|
||||
|
||||
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) {
|
||||
let best = null;
|
||||
let bestGap = tuning.captureRadius;
|
||||
const puckSpd = Math.hypot(_puckVel.x, _puckVel.z);
|
||||
const speedF = speedGrabFactor(puckSpd, tuning.grabSpeedHalf);
|
||||
|
||||
// Who is in the scramble?
|
||||
const contesting = [];
|
||||
for (let i = 0; i < skaters.length; i++) {
|
||||
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);
|
||||
const gap = _puckPos.distanceTo(_carryWorld);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
best = i;
|
||||
const bladeGap = Math.hypot(
|
||||
_puckPos.x - _carryWorld.x,
|
||||
_puckPos.z - _carryWorld.z,
|
||||
);
|
||||
|
||||
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 ------------------------------------------------------------
|
||||
@@ -306,6 +660,9 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
|
||||
carrier = null;
|
||||
looseFor = 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.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 { PUCK } from './physics/puck.js';
|
||||
import { createCamera } from './render/camera.js';
|
||||
import { createMarkers } from './render/markers.js';
|
||||
import { createMatch } from './game/match.js';
|
||||
import { createInput } from './game/input.js';
|
||||
import { describeHit } from './game/hits.js';
|
||||
@@ -121,6 +122,8 @@ async function bootApp() {
|
||||
let scrimmage = null;
|
||||
/** @type {ReturnType<typeof buildPuckMesh> | null} */
|
||||
let puckView = null;
|
||||
/** @type {ReturnType<typeof createMarkers> | null} */
|
||||
let markers = null;
|
||||
/** @type {(() => void) | null} */
|
||||
let removeSubstepSync = null;
|
||||
|
||||
@@ -140,6 +143,10 @@ async function bootApp() {
|
||||
scrimmage.destroy();
|
||||
scrimmage = null;
|
||||
}
|
||||
if (markers) {
|
||||
markers.destroy();
|
||||
markers = null;
|
||||
}
|
||||
if (puckView) {
|
||||
// Ring is parented under the mesh.
|
||||
scene.remove(puckView.mesh);
|
||||
@@ -180,8 +187,8 @@ async function bootApp() {
|
||||
if (playerDriving) {
|
||||
cam.state.mode = 'follow';
|
||||
cam.state.followIndex = shootout.state.shooter;
|
||||
cam.state.distance = 9;
|
||||
cam.state.pitch = 0.3;
|
||||
// Dreamfall-style chase seeds distance/pitch itself on first follow frame.
|
||||
if (cam.chase) cam.chase.seeded = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -197,8 +204,7 @@ async function bootApp() {
|
||||
match.setControl(idx, stick);
|
||||
cam.state.mode = 'follow';
|
||||
cam.state.followIndex = idx;
|
||||
cam.state.distance = 9;
|
||||
cam.state.pitch = 0.3;
|
||||
if (cam.chase) cam.chase.seeded = false;
|
||||
}
|
||||
|
||||
function showMenu() {
|
||||
@@ -221,6 +227,10 @@ async function bootApp() {
|
||||
|
||||
match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
|
||||
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') {
|
||||
// The shootout owns the nets and the goalies, and drives its own
|
||||
@@ -380,7 +390,28 @@ async function bootApp() {
|
||||
if (puckView) {
|
||||
puckView.mesh.position.copy(match.puck.position());
|
||||
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);
|
||||
@@ -400,6 +431,7 @@ async function bootApp() {
|
||||
cam.state.yaw += dt * 0.08;
|
||||
cam.update(dt, []);
|
||||
hud.textContent = '';
|
||||
if (markers) markers.update(dt, {});
|
||||
}
|
||||
|
||||
renderer.render(scene, cam.camera);
|
||||
@@ -433,9 +465,14 @@ async function bootApp() {
|
||||
|
||||
const controlsHint = player
|
||||
? (input.connected
|
||||
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
|
||||
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
|
||||
? '\nL-stick skate + aim · RT hustle · LT contain · RB stick-spread · R-stick wind-up/waggle\nA pass · X shoot · B 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)}`
|
||||
+ (stick.backskate ? ' LT' : '')
|
||||
+ (stick.spread ? ' SPREAD' : '')
|
||||
+ (stick.charge > 0.05
|
||||
? ` aim ${bar((stick.aimHint + 1) * 0.5)}`
|
||||
: '')
|
||||
: '';
|
||||
|
||||
if (mode === '1v1' && shootout) {
|
||||
|
||||
+278
-51
@@ -3,23 +3,106 @@ import { RINK } from '../../shared/rink.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:
|
||||
* 'broadcast' sits off the side boards and pans with the action — the view
|
||||
* 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.
|
||||
* Follow mode is ported from Dreamfall's rally chase cam
|
||||
* (`dreamfall/src/game/systems/CameraSystem.js` → `updateVehicleChaseCamera`):
|
||||
*
|
||||
* Drag orbits, wheel zooms, and the target is smoothed rather than snapped so
|
||||
* a bot changing direction does not whip the camera.
|
||||
* - Heading-locked yaw (not stick-coupled)
|
||||
* - 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) {
|
||||
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 400);
|
||||
|
||||
const state = {
|
||||
mode: 'broadcast',
|
||||
/** Orbit angles, radians. */
|
||||
/** Orbit yaw: where the camera sits relative to the target (broadcast / drag). */
|
||||
yaw: 0,
|
||||
pitch: 0.62,
|
||||
distance: 34,
|
||||
@@ -28,8 +111,28 @@ export function createCamera(canvas, aspect) {
|
||||
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 _offset = new THREE.Vector3();
|
||||
const _desiredPos = new THREE.Vector3();
|
||||
const _look = new THREE.Vector3();
|
||||
|
||||
let dragging = false;
|
||||
let lastX = 0;
|
||||
@@ -62,12 +165,165 @@ export function createCamera(canvas, aspect) {
|
||||
canvas.addEventListener('pointercancel', endDrag);
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
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 });
|
||||
|
||||
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 {
|
||||
camera,
|
||||
state,
|
||||
/** Exposed for tests / tuning probes. */
|
||||
chase,
|
||||
CHASE,
|
||||
|
||||
resize(w, h) {
|
||||
camera.aspect = w / h;
|
||||
@@ -79,62 +335,33 @@ export function createCamera(canvas, aspect) {
|
||||
if (state.mode === 'broadcast') {
|
||||
state.mode = 'follow';
|
||||
state.followIndex = 0;
|
||||
chase.seeded = false;
|
||||
state.distance = CHASE.distance;
|
||||
state.pitch = CHASE.pitch;
|
||||
} else if (state.followIndex + 1 < count) {
|
||||
state.followIndex += 1;
|
||||
chase.seeded = false;
|
||||
state.distance = CHASE.distance;
|
||||
state.pitch = CHASE.pitch;
|
||||
} else {
|
||||
state.mode = 'broadcast';
|
||||
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 {{x:number,z:number,yaw:number}[]} skaters
|
||||
* @param {{x:number,z:number,yaw:number,vx?:number,vz?:number}[]} 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]) {
|
||||
const s = 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));
|
||||
}
|
||||
updateFollow(dt, skaters[state.followIndex]);
|
||||
} else {
|
||||
// Centroid of everyone, clamped so the camera never leaves the barn.
|
||||
_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);
|
||||
updateBroadcast(dt, skaters);
|
||||
}
|
||||
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 };
|
||||
Reference in New Issue
Block a user