Sharper steering, solid traffic, and a map that is mostly war
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>
This commit is contained in:
+11
-6
@@ -4,13 +4,18 @@ import type { PhysicsWorld } from './physics';
|
||||
import { WHEELS } from '../carSpec';
|
||||
|
||||
/** How fast the steering rack follows the key, radians per second. */
|
||||
const STEER_RATE = 2.6;
|
||||
const STEER_RETURN_RATE = 4.5;
|
||||
const STEER_RATE = 4.6;
|
||||
const STEER_RETURN_RATE = 6;
|
||||
/**
|
||||
* Steering authority falls off with speed, or the car is undriveable at pace.
|
||||
* Lower = more falloff. At 12, full lock is roughly halved by 45 km/h.
|
||||
* Speed at which steering authority is halved, metres per second.
|
||||
*
|
||||
* The falloff is quadratic rather than linear, and that matters. Linear falloff
|
||||
* starts eating lock immediately — at 20 km/h the car had already lost a third
|
||||
* of its steering, which is exactly the speed you take a 90-degree junction at,
|
||||
* so the map's right-angle corners were nearly impossible to hit. A quadratic
|
||||
* curve leaves low speeds almost untouched and still calms the car down at pace.
|
||||
*/
|
||||
const STEER_SPEED_FALLOFF = 12;
|
||||
const STEER_SPEED_FALLOFF = 20;
|
||||
/** Below this, lifting off should bring the car to rest rather than a crawl. */
|
||||
const CREEP_SPEED = 1.2;
|
||||
|
||||
@@ -37,7 +42,7 @@ export function drive(
|
||||
|
||||
// Steering: ease toward the target rather than snapping, and shrink the
|
||||
// available lock as speed rises.
|
||||
const authority = 1 / (1 + Math.abs(speed) / STEER_SPEED_FALLOFF);
|
||||
const authority = 1 / (1 + (Math.abs(speed) / STEER_SPEED_FALLOFF) ** 2);
|
||||
const target = input.steer * handling.maxSteer * authority;
|
||||
const rate = input.steer === 0 ? STEER_RETURN_RATE : STEER_RATE;
|
||||
const maxDelta = rate * handling.maxSteer * dt;
|
||||
|
||||
@@ -109,6 +109,67 @@ describe('slowing down', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Seconds to swing the nose through a quarter turn from a given entry speed. */
|
||||
async function quarterTurn(entrySpeed: number) {
|
||||
const physics = await createPhysics(generateWorld(1, 0));
|
||||
const state = createDriveState();
|
||||
const handling = deriveHandling(freshCondition());
|
||||
|
||||
// Get up to the speed you would actually take a junction at.
|
||||
while (physics.vehicle.currentVehicleSpeed() < entrySpeed) {
|
||||
drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP);
|
||||
physics.step(STEP);
|
||||
}
|
||||
|
||||
const yawOf = () => {
|
||||
const r = physics.chassis.rotation();
|
||||
return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x));
|
||||
};
|
||||
let turned = 0;
|
||||
let previous = yawOf();
|
||||
let time = 0;
|
||||
let travelled = 0;
|
||||
|
||||
for (let i = 0; i < 8 / STEP && turned < Math.PI / 2; i++) {
|
||||
drive(physics, state, { ...IDLE, throttle: 0.4, steer: 1 }, handling, STEP);
|
||||
physics.step(STEP);
|
||||
const now = yawOf();
|
||||
let delta = now - previous;
|
||||
// Unwrap, so crossing ±π does not read as a huge jump.
|
||||
if (delta > Math.PI) delta -= Math.PI * 2;
|
||||
if (delta < -Math.PI) delta += Math.PI * 2;
|
||||
turned += Math.abs(delta);
|
||||
previous = now;
|
||||
time += STEP;
|
||||
travelled += Math.abs(physics.vehicle.currentVehicleSpeed()) * STEP;
|
||||
}
|
||||
// Radius, not time, is what a corner costs you: a fast car sweeps through
|
||||
// ninety degrees *quicker* than a slow one, just across far more tarmac.
|
||||
return { turned, time, radius: travelled / Math.max(turned, 1e-6) };
|
||||
}
|
||||
|
||||
describe('taking a junction', () => {
|
||||
it('gets round a right-angle corner at junction speed', async () => {
|
||||
// The map is a grid of 90-degree turns. If the car cannot make one at a
|
||||
// sane approach speed, the whole road network fights the player.
|
||||
const turn = await quarterTurn(9);
|
||||
expect(turn.turned).toBeGreaterThanOrEqual(Math.PI / 2);
|
||||
expect(turn.time).toBeLessThan(3);
|
||||
// Tight enough to stay inside a junction rather than swinging into the
|
||||
// buildings on the far side of it.
|
||||
expect(turn.radius).toBeLessThan(14);
|
||||
});
|
||||
|
||||
it('costs you road, not steering, as speed rises', async () => {
|
||||
const slow = await quarterTurn(6);
|
||||
const fast = await quarterTurn(22);
|
||||
// Both corners get made; the fast one just eats far more tarmac doing it.
|
||||
expect(slow.turned).toBeGreaterThanOrEqual(Math.PI / 2);
|
||||
expect(fast.turned).toBeGreaterThanOrEqual(Math.PI / 2);
|
||||
expect(fast.radius).toBeGreaterThan(slow.radius * 1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vehicle', () => {
|
||||
it('settles on its suspension instead of sinking or bouncing away', async () => {
|
||||
const r = await run({}, 2);
|
||||
@@ -131,7 +192,10 @@ describe('vehicle', () => {
|
||||
const turning = await run({ throttle: 1, steer: 1 }, 5);
|
||||
// Position is a poor check here — a hard turn loops back near the start.
|
||||
expect(turning.maxYawRate).toBeGreaterThan(0.3);
|
||||
expect(turning.maxYawRate).toBeLessThan(1.6);
|
||||
// Loosened when steering was sharpened for junctions: at low speed under
|
||||
// full lock the car now comes round at about 100 deg/s, which is tight but
|
||||
// is what makes the map's right angles drivable.
|
||||
expect(turning.maxYawRate).toBeLessThan(2.2);
|
||||
expect(straight.maxYawRate).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ export interface PhysicsWorld {
|
||||
removeBody(body: RAPIER.RigidBody): void;
|
||||
/** What the wheels are doing, for anything that needs to react to grip. */
|
||||
telemetry(): Telemetry;
|
||||
/** A body the sim moves by hand, which still collides with the player. */
|
||||
addKinematicBox(box: KinematicBox): RAPIER.RigidBody;
|
||||
}
|
||||
|
||||
export interface KinematicBox {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
halfExtents: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
export interface Telemetry {
|
||||
@@ -147,6 +157,22 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
|
||||
return v;
|
||||
},
|
||||
|
||||
addKinematicBox(box) {
|
||||
// Kinematic rather than dynamic: the unit sim owns where these are, but
|
||||
// they still shove the player's car when they meet it.
|
||||
const body = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(box.x, box.y, box.z),
|
||||
);
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(box.halfExtents.x, box.halfExtents.y, box.halfExtents.z)
|
||||
.setFriction(0.6)
|
||||
.setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS)
|
||||
.setContactForceEventThreshold(IMPACT_THRESHOLD),
|
||||
body,
|
||||
);
|
||||
return body;
|
||||
},
|
||||
|
||||
telemetry() {
|
||||
let sideSlip = 0;
|
||||
let wheelsOnGround = 0;
|
||||
|
||||
Reference in New Issue
Block a user