Calibrate crash damage against forces Rapier actually reports
The impact coefficients in applyWear were written against an imagined scale. Measured on the running game, a head-on into a building at 85 km/h comes back as a single contact event of ~2.5e6 N — 25 units of "impact" against the old 1e5 reference. Through the old coefficients that was 123% of the chassis and 37% of its ceiling, gone, in one hit, in a game whose missions pay about 0.3 parts each. One crash ended the campaign, and the campaign had no way to end. Anchor the reference to the measured figure instead, and cap what any single step can take: a crash is a pile of contacts across several steps plus whatever the car scrapes on the way to a standstill, and that sum has no natural bound. The same 14-second flat-out run into a building now costs chassis 100 -> 65% and its ceiling 100 -> 89%. Expensive, survivable, and still the worst thing that can happen to the car. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d10033d79e
commit
84ee726a51
@ -116,22 +116,55 @@ export interface WearInput {
|
|||||||
const PERMANENT_SHARE = 0.3;
|
const PERMANENT_SHARE = 0.3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a new condition. Numbers here are deliberately aggressive so a
|
* Newtons of contact force that count as one unit of "impact".
|
||||||
* 15-minute session shows visible decline — tune down once the feel is right.
|
*
|
||||||
|
* Measured, not guessed. Rapier reports contact-force magnitudes far larger
|
||||||
|
* than the first pass here assumed: a head-on into a building at 85 km/h comes
|
||||||
|
* back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25
|
||||||
|
* units of impact, which through the coefficients below wrote off the chassis —
|
||||||
|
* and 37% of its ceiling — in one hit, on a car whose whole economy pays about
|
||||||
|
* 0.3 parts a mission. One crash ended the campaign.
|
||||||
|
*
|
||||||
|
* At 1.6e7 the same crash reads as 0.156 impact: about 15% of the chassis and a
|
||||||
|
* permanent 4.7% off its ceiling. Expensive, survivable, and still the most
|
||||||
|
* costly thing you can do to the car.
|
||||||
|
*/
|
||||||
|
const IMPACT_REFERENCE = 1.6e7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most damage any one subsystem can take in a single step.
|
||||||
|
*
|
||||||
|
* A crash is not one contact. It is a pile of them across a handful of steps,
|
||||||
|
* plus whatever the car scrapes along on the way to a standstill, and the sum
|
||||||
|
* has no natural bound. Without a cap the tail of a bad landing is worth more
|
||||||
|
* than the landing. This is what stops a single frame from writing off a part.
|
||||||
|
*
|
||||||
|
* Set well above the ordinary crash so it stays a backstop rather than the
|
||||||
|
* number that actually decides what a collision costs.
|
||||||
|
*/
|
||||||
|
const MAX_DAMAGE_PER_STEP = 0.25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new condition.
|
||||||
|
*
|
||||||
|
* Baseline wear is deliberately quicker than real life so decline is legible
|
||||||
|
* within a session, but it has to stay inside what the parts economy can pay
|
||||||
|
* for — see the reasoning on the two constants above.
|
||||||
*/
|
*/
|
||||||
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
||||||
const impact = w.impactForce / 1e5;
|
const impact = w.impactForce / IMPACT_REFERENCE;
|
||||||
const damage: Subsystems = {
|
const damage: Subsystems = {
|
||||||
engine: w.throttle * w.dt * 6e-4 + impact * 0.01,
|
engine: w.throttle * w.dt * 6e-4 + impact * 0.32,
|
||||||
tires: w.distance * 8e-5 + impact * 0.02,
|
tires: w.distance * 8e-5 + impact * 0.5,
|
||||||
chassis: impact * 0.05,
|
chassis: impact * 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const level = {} as Subsystems;
|
const level = {} as Subsystems;
|
||||||
const ceiling = {} as Subsystems;
|
const ceiling = {} as Subsystems;
|
||||||
for (const part of SUBSYSTEMS) {
|
for (const part of SUBSYSTEMS) {
|
||||||
ceiling[part] = clamp01(c.ceiling[part] - damage[part] * PERMANENT_SHARE);
|
const taken = Math.min(damage[part], MAX_DAMAGE_PER_STEP);
|
||||||
level[part] = Math.min(clamp01(c.level[part] - damage[part]), ceiling[part]);
|
ceiling[part] = clamp01(c.ceiling[part] - taken * PERMANENT_SHARE);
|
||||||
|
level[part] = Math.min(clamp01(c.level[part] - taken), ceiling[part]);
|
||||||
}
|
}
|
||||||
return { level, ceiling };
|
return { level, ceiling };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,52 @@ describe('car condition', () => {
|
|||||||
expect(c.level.tires).toBe(0);
|
expect(c.level.tires).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The force magnitude Rapier actually reports for a head-on into a building
|
||||||
|
* at 85 km/h, read off the running game. Every number below is anchored to it
|
||||||
|
* rather than to a guess about what a collision "should" be worth.
|
||||||
|
*/
|
||||||
|
const HARD_CRASH = 2.47e6;
|
||||||
|
|
||||||
|
it('leaves a car driveable after one hard crash', () => {
|
||||||
|
const c = applyWear(freshCondition(), {
|
||||||
|
dt: 1 / 60,
|
||||||
|
distance: 0.4,
|
||||||
|
throttle: 0,
|
||||||
|
impactForce: HARD_CRASH,
|
||||||
|
});
|
||||||
|
// Expensive — the worst single thing that can happen to the car — but the
|
||||||
|
// campaign continues. The first pass wrote off the chassis in this one step.
|
||||||
|
expect(c.level.chassis).toBeGreaterThan(0.7);
|
||||||
|
expect(c.level.chassis).toBeLessThan(0.95);
|
||||||
|
expect(c.ceiling.chassis).toBeGreaterThan(0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps what a single step can take, however violent the contact', () => {
|
||||||
|
// A pileup reports contact forces without any natural bound; without a cap
|
||||||
|
// one frame of a bad landing is worth more than the crash that caused it.
|
||||||
|
const c = applyWear(freshCondition(), {
|
||||||
|
dt: 1 / 60,
|
||||||
|
distance: 0.4,
|
||||||
|
throttle: 1,
|
||||||
|
impactForce: 1e9,
|
||||||
|
});
|
||||||
|
for (const part of SUBSYSTEMS) {
|
||||||
|
expect(c.level[part]).toBeGreaterThanOrEqual(0.74);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a sustained battering to write the car off', () => {
|
||||||
|
let c = freshCondition();
|
||||||
|
let crashes = 0;
|
||||||
|
while (c.level.chassis > 0 && crashes < 100) {
|
||||||
|
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: HARD_CRASH });
|
||||||
|
crashes++;
|
||||||
|
}
|
||||||
|
// Enough hard crashes to be a story about the driver, not one bad corner.
|
||||||
|
expect(crashes).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
it('makes a worn car measurably worse to drive', () => {
|
it('makes a worn car measurably worse to drive', () => {
|
||||||
const fresh = deriveHandling(freshCondition());
|
const fresh = deriveHandling(freshCondition());
|
||||||
const worn = deriveHandling({
|
const worn = deriveHandling({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user