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(); }, }; }