Steering. The falloff with speed was linear, so the car had already lost a third of its lock by 20km/h — exactly the speed you take a right-angle junction at, on a map made of right-angle junctions. It is quadratic now, which leaves low speeds nearly untouched and still calms things down at pace, plus a faster rack and a little more lock. Pinned with a test that drives an actual quarter turn and measures its radius. A first attempt asserted that fast corners take longer, which is simply false: a fast car swings through ninety degrees quicker, just across far more tarmac. Radius is what a corner costs you. Contact. Units were points that ignored everything, so there was nothing to hit. Vehicles now carry kinematic bodies and ramming one is a real collision that damages them and shoves you. People get no collider on purpose — a capsule means snagging on pedestrians or launching them — so you drive through them and they go down, and running over a civilian is counted for later. Territory. Liberated ground covered more than half the map because the player started in the middle of it, so most of the world was safe by geometry. The starting base is now at the friendly edge and the bands are anchored to give roughly 15/25/27/33 across liberated, contested, occupied and frontier. Measured in the running game rather than guessed, since the axis runs diagonally across a square and area is not linear in depth. Also fixes a fragile test that compared reward rates across whichever targets a board happened to offer, and so mostly measured route length rather than the novelty premium it claimed to check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
/**
|
|
* Gives the inhabited world a physical presence. Integration layer: Rapier on
|
|
* one side, the pure unit sim on the other.
|
|
*
|
|
* Units are simulated as points that ignore each other, which is fine for
|
|
* traffic going about its business and useless the moment the player wants to
|
|
* interact with any of it. This is what makes them things you can hit.
|
|
*
|
|
* Two deliberately different treatments:
|
|
*
|
|
* - **Vehicles get colliders.** Kinematic bodies, driven from the sim, so
|
|
* ramming a patrol is a real collision that shoves your car about.
|
|
* - **People do not.** A capsule collider would mean getting hung up on
|
|
* pedestrians, or launching them like skittles. Instead they are checked for
|
|
* proximity and simply go down. You drive *through* a person, not into them.
|
|
*/
|
|
import type RAPIER from '@dimforge/rapier3d-compat';
|
|
import type { PhysicsWorld } from './physics/physics';
|
|
import type { Unit, UnitState } from './sim/units';
|
|
|
|
/** Units further out than this get no body; the physics world stays small. */
|
|
const PHYSICAL_RANGE = 160;
|
|
/** Half-extents of a vehicle body. */
|
|
const CAR_HALF = { x: 0.9, y: 0.7, z: 2 };
|
|
/** How close the car has to pass to knock a person down. */
|
|
const RUN_OVER_RADIUS = 2.6;
|
|
/** Below this you are nudging past someone, not running them over. */
|
|
const RUN_OVER_SPEED = 3.5;
|
|
/** Closing speed at which a vehicle collision starts hurting the other car. */
|
|
const RAM_SPEED = 7;
|
|
const RAM_RADIUS = 4.6;
|
|
|
|
export interface ContactEvents {
|
|
/** People knocked down by the player this step. */
|
|
ranOver: Unit[];
|
|
/** Vehicles the player rammed hard enough to damage. */
|
|
rammed: Unit[];
|
|
}
|
|
|
|
export function createUnitBodies(physics: PhysicsWorld) {
|
|
const bodies = new Map<number, RAPIER.RigidBody>();
|
|
|
|
const drop = (id: number) => {
|
|
const body = bodies.get(id);
|
|
if (!body) return;
|
|
physics.removeBody(body);
|
|
bodies.delete(id);
|
|
};
|
|
|
|
return {
|
|
/**
|
|
* Syncs bodies to the sim and reports what the player just hit.
|
|
* Call once per fixed step, after the units have moved.
|
|
*/
|
|
sync(
|
|
units: UnitState,
|
|
player: { x: number; z: number },
|
|
playerSpeed: number,
|
|
dt: number,
|
|
): ContactEvents {
|
|
const events: ContactEvents = { ranOver: [], rammed: [] };
|
|
const alive = new Set<number>();
|
|
const fast = Math.abs(playerSpeed);
|
|
|
|
for (const unit of units.units) {
|
|
const gap = Math.hypot(unit.x - player.x, unit.z - player.z);
|
|
|
|
if (unit.kind === 'soldier') {
|
|
// No collider — just whether the car went through them.
|
|
if (gap < RUN_OVER_RADIUS && fast > RUN_OVER_SPEED && unit.hp > 0) {
|
|
unit.hp = 0;
|
|
events.ranOver.push(unit);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (gap > PHYSICAL_RANGE) continue;
|
|
alive.add(unit.id);
|
|
|
|
let body = bodies.get(unit.id);
|
|
if (!body) {
|
|
body = physics.addKinematicBox({
|
|
x: unit.x,
|
|
z: unit.z,
|
|
y: CAR_HALF.y,
|
|
yaw: unit.heading,
|
|
halfExtents: CAR_HALF,
|
|
});
|
|
bodies.set(unit.id, body);
|
|
}
|
|
// Kinematic: the sim decides where it is, and the physics engine works
|
|
// out what that does to anything dynamic it meets — namely the player.
|
|
body.setNextKinematicTranslation({ x: unit.x, y: CAR_HALF.y, z: unit.z });
|
|
body.setNextKinematicRotation({
|
|
x: 0,
|
|
y: Math.sin(unit.heading / 2),
|
|
z: 0,
|
|
w: Math.cos(unit.heading / 2),
|
|
});
|
|
|
|
// Rapier will resolve the shove; the damage is ours to decide.
|
|
if (gap < RAM_RADIUS && fast > RAM_SPEED) {
|
|
unit.hp -= fast * 4 * dt;
|
|
if (!events.rammed.includes(unit)) events.rammed.push(unit);
|
|
}
|
|
}
|
|
|
|
for (const id of [...bodies.keys()]) if (!alive.has(id)) drop(id);
|
|
return events;
|
|
},
|
|
|
|
get count() {
|
|
return bodies.size;
|
|
},
|
|
};
|
|
}
|