Initial commit
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user