Files
tilt/src/game/hits.js
T
2026-08-03 06:43:21 -05:00

282 lines
10 KiB
JavaScript

import * as THREE from 'three';
import { segSegDistance } from '../core/math.js';
import { KIND, readTag } from '../physics/bridge.js';
import { REGION } from '../character/skeleton.js';
import { clamp } from '../../shared/scalar.js';
/**
* Body checks.
*
* Two problems have to be solved separately, and conflating them is what makes
* hits feel like one canned event:
*
* *Did* a hit land — a physics question, answered by the proxy capsules,
* which are what actually collide. Closing speed and mass give severity.
*
* *What kind* of hit was it — a pose question, and the proxy cannot answer
* it. A capsule contact point tells you two bodies met at roughly hip height;
* it cannot tell you a shoulder went through a chest. So on the frame a hit
* lands we go back to the two 18-capsule ragdolls, which *are* posed, and
* find the closest pair of limbs. That pair is the hit: `upperArmR → spine2`
* is a shoulder into the chest, `pelvis → thighL` is a hip check, `spine3 →
* head` is the one that should draw a penalty.
*
* 324 segment-segment tests sounds like a lot until you notice it only runs on
* the frame of an actual impact, which is a handful of times a match.
*/
export const HIT = {
/**
* Closing speed thresholds, m/s. Below `bump` nothing happens beyond the
* momentum the solver already exchanged.
*/
bump: 2.6,
stagger: 4.4,
knockdown: 7.0,
/** Impulse per m/s of closing speed, per kg of effective mass. */
impulseScale: 0.55,
/**
* How much of the impulse goes into the struck limb at the contact point,
* versus into the pelvis through its centre.
*
* All of it at the contact point is what launches people: the point is on
* the chest, well above the centre of mass, so a linear impulse there is
* mostly torque and the victim cartwheels over the hitter. Driving most of
* the mass from the middle and using the limb share only to shape the fall
* is what makes a check read as being knocked *down and back*.
*/
limbShare: 0.35,
/**
* Upward fraction. A check lifts a skater slightly off their edges; it does
* not throw them in the air.
*/
liftKnockdown: 0.15,
liftStagger: 0.08,
/** A hit to the head or an unbraced back is worth more than a square one. */
blindsideBonus: 1.5,
headBonus: 1.4,
/** Joint stiffness for a stagger — stiff enough to stay on the feet. */
staggerStiffness: 5,
/** Seconds a downed skater stays down before getting up. */
downTime: 1.5,
/** Seconds of get-up blend from the collapsed pose back to skating. */
riseTime: 0.7,
/** Ignore repeat contacts between the same pair for this long. */
refractory: 0.45,
};
/** Which part of the *attacker* delivered it — this is what varies the hit. */
const DELIVERED_BY = {
upperArmL: 'shoulder', upperArmR: 'shoulder', spine3: 'shoulder',
spine1: 'body', spine2: 'body',
pelvis: 'hip', thighL: 'hip', thighR: 'hip',
forearmL: 'arm', forearmR: 'arm',
shinL: 'leg', shinR: 'leg',
};
/**
* Parts that can deliver a check.
*
* Not a fudge — a rule of the game. A skater at speed is pitched ~30° forward,
* which makes the *head* the geometrically leading part of the body, so an
* unrestricted nearest-pair search credits almost every hit to a headbutt. You
* check with a shoulder, a chest, a hip or a thigh.
*
* The victim side stays unrestricted, deliberately: a shoulder that arrives at
* someone's head is exactly the hit that should register as a head shot.
*/
const CAN_DELIVER = new Set(Object.keys(DELIVERED_BY));
/** Human-readable label, for the HUD and for tests to assert against. */
export function describeHit(hit) {
const where = hit.victimRegion === REGION.HEAD ? 'head'
: hit.victimRegion === REGION.TORSO ? 'body'
: hit.victimRegion.startsWith('upperLeg') || hit.victimRegion.startsWith('lowerLeg') ? 'legs'
: 'arm';
return `${hit.by} to the ${where}`;
}
const _a1 = new THREE.Vector3();
const _b1 = new THREE.Vector3();
const _rel = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _impulse = new THREE.Vector3();
const _point = new THREE.Vector3();
/**
* Closest limb pair between two posed ragdolls.
* Returns `{ attackerPart, victimPart, point, distance }`, or null if the two
* rigs are somehow nowhere near each other.
*/
export function closestLimbs(attacker, victim, { deliveringOnly = true } = {}) {
const A = attacker.worldSegments();
// `worldSegments` reuses its scratch array, so the first result has to be
// copied out before the second call overwrites it.
const aCopy = A
.filter((s) => !deliveringOnly || CAN_DELIVER.has(s.part.name))
.map((s) => ({ part: s.part, a: s.a.clone(), b: s.b.clone(), radius: s.radius }));
const B = victim.worldSegments();
let best = null;
let bestGap = Infinity;
for (const sa of aCopy) {
for (const sb of B) {
const d = segSegDistance(sa.a, sa.b, sb.a, sb.b, _a1, _b1) - sa.radius - sb.radius;
if (d < bestGap) {
bestGap = d;
if (!best) best = { attackerPart: null, victimPart: null, point: new THREE.Vector3(), distance: 0 };
best.attackerPart = sa.part;
best.victimPart = sb.part;
// Midway between the two surfaces is where the impact reads as having
// happened, and is where the impulse should be applied.
best.point.addVectors(_a1, _b1).multiplyScalar(0.5);
best.distance = d;
}
}
}
return best;
}
/**
* Wire up hit detection for a match.
*
* `onHit` is called with a description of every landed check, for the HUD,
* audio and (later) penalties.
*/
export function createHitResolver({ physics, skaters, states, onHit = null }) {
// Last time each unordered pair traded a hit, so one collision does not fire
// every substep it stays in contact.
const lastHit = new Map();
let clock = 0;
const pairKey = (i, j) => (i < j ? `${i}|${j}` : `${j}|${i}`);
function resolve(event) {
const a = readTag(event.userMaterialIdA);
const b = readTag(event.userMaterialIdB);
// Only proxy-on-proxy counts as a check. Limb contacts happen constantly
// once someone is down and are not hits.
if (a.kind !== KIND.PROXY || b.kind !== KIND.PROXY) return;
if (a.skater === b.skater) return;
const speed = event.approachSpeed;
if (speed < HIT.bump) return;
const key = pairKey(a.skater, b.skater);
if (clock - (lastHit.get(key) ?? -Infinity) < HIT.refractory) return;
// Whoever is carrying more speed into the contact is the one throwing it.
const sa = states[a.skater];
const sb = states[b.skater];
_rel.set(sb.x - sa.x, 0, sb.z - sa.z);
const len = _rel.length() || 1;
_rel.multiplyScalar(1 / len);
const closingA = sa.vx * _rel.x + sa.vz * _rel.z;
const closingB = -(sb.vx * _rel.x + sb.vz * _rel.z);
const attackerIndex = closingA >= closingB ? a.skater : b.skater;
const victimIndex = attackerIndex === a.skater ? b.skater : a.skater;
const attacker = skaters[attackerIndex];
const victim = skaters[victimIndex];
// Neither a body already on the ice nor a body being slid into by one is
// throwing a check. Those contacts are real and the solver handles them;
// they are just not hits, and attributing one to a limp skater's flailing
// hand produces nonsense like "arm to the legs" as a headline event.
if (!attacker?.ragdoll || !victim?.ragdoll) return;
if (attacker.limp || victim.limp) return;
const pair = closestLimbs(attacker.ragdoll, victim.ragdoll);
if (!pair) return;
// Direction of the blow: attacker's travel, which is what the victim
// actually has to absorb.
const attackerState = states[attackerIndex];
const victimState = states[victimIndex];
_dir.set(attackerState.vx - victimState.vx, 0, attackerState.vz - victimState.vz);
if (_dir.lengthSq() < 1e-6) _dir.set(_rel.x, 0, _rel.z);
_dir.normalize();
// A hit taken from behind or side-on is worth more than one you can brace
// for: `facing` is +1 square on, -1 straight in the back.
const victimFacing = Math.sin(victimState.yaw) * -_dir.x + Math.cos(victimState.yaw) * -_dir.z;
const blindside = clamp((1 - victimFacing) / 2, 0, 1);
const by = DELIVERED_BY[pair.attackerPart.name] ?? 'body';
const region = pair.victimPart.region;
const headshot = region === REGION.HEAD;
let severity = speed
* (1 + blindside * (HIT.blindsideBonus - 1))
* (headshot ? HIT.headBonus : 1);
// A hit thrown with an arm or a trailing leg is a brush, not a check.
if (by === 'arm' || by === 'leg') severity *= 0.55;
const outcome = severity >= HIT.knockdown ? 'knockdown'
: severity >= HIT.stagger ? 'stagger'
: 'bump';
lastHit.set(key, clock);
const hit = {
attacker: attackerIndex,
victim: victimIndex,
by,
attackerPart: pair.attackerPart.name,
victimPart: pair.victimPart.name,
victimRegion: region,
speed,
severity,
blindside,
headshot,
outcome,
point: pair.point.clone(),
direction: _dir.clone(),
};
apply(hit);
if (onHit) onHit(hit);
}
/** Turn a resolved hit into forces on the victim's skeleton. */
function apply(hit) {
if (hit.outcome === 'bump') return;
const victim = skaters[hit.victim];
const body = victim.ragdoll;
const knockdown = hit.outcome === 'knockdown';
// Impulse scaled by the mass actually being moved, aimed slightly upward —
// a purely horizontal shove on a body standing on near-frictionless ice
// just slides it along without ever putting it on the floor.
const mag = hit.severity * HIT.impulseScale * body.totalMass() * 0.08;
const lift = knockdown ? HIT.liftKnockdown : HIT.liftStagger;
if (knockdown) victim.goDown(hit);
else victim.stagger(hit);
// Most of it through the pelvis centre, which moves the whole body; the
// rest at the contact point, which is what tips them over.
_impulse.copy(hit.direction).multiplyScalar(mag * (1 - HIT.limbShare));
_impulse.y += mag * lift * (1 - HIT.limbShare);
body.applyImpulse('pelvis', _impulse, null);
_impulse.copy(hit.direction).multiplyScalar(mag * HIT.limbShare);
_impulse.y += mag * lift * HIT.limbShare;
_point.copy(hit.point);
body.applyImpulse(hit.victimPart, _impulse, _point);
}
const off = physics.onHit(resolve);
return {
/** Advance the refractory clock. Call once per frame. */
tick(dt) {
clock += dt;
},
get time() { return clock; },
destroy() {
off();
lastHit.clear();
},
};
}