feat: interactive traffic with car-following, overtakes and flash-to-pass
This commit is contained in:
@@ -1,72 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HERO_CAR_HALF_WIDTH,
|
||||
LAMPS_PER_VEHICLE,
|
||||
TRAFFIC_BODY,
|
||||
TRAFFIC_FADE_LENGTH,
|
||||
TRAFFIC_LANE_JITTER,
|
||||
TRAFFIC_LANES,
|
||||
TRAFFIC_NEAR_Z,
|
||||
trafficLamps,
|
||||
trafficNearFade,
|
||||
wrapTrafficZ,
|
||||
trafficCameraSide,
|
||||
trafficPoolSize,
|
||||
} from '@/scene/world/traffic';
|
||||
import {
|
||||
HERO_HALF_WIDTH,
|
||||
TRAFFIC_ASPHALT_HALF_WIDTH,
|
||||
TRAFFIC_KINDS,
|
||||
TRAFFIC_LANE_JITTER,
|
||||
TRAFFIC_LANE_X,
|
||||
TRAFFIC_NEAR_CAMERA_SIDE,
|
||||
TRAFFIC_NEAR_OPEN,
|
||||
} from '@/scene/world/traffic-model';
|
||||
|
||||
const kinds = ['car', 'truck'] as const;
|
||||
const sideLanes = [TRAFFIC_LANE_X[0]!, TRAFFIC_LANE_X[2]!];
|
||||
|
||||
describe('expressway traffic stays clear of the hero car', () => {
|
||||
it('keeps every body outside the hero car, with lane jitter', () => {
|
||||
for (const lane of TRAFFIC_LANES) {
|
||||
const inner = Math.abs(lane.x) - TRAFFIC_BODY[lane.kind].halfWidth - TRAFFIC_LANE_JITTER;
|
||||
expect(inner).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
|
||||
}
|
||||
it('agrees with the model on the hero envelope', () => {
|
||||
expect(HERO_CAR_HALF_WIDTH).toBe(HERO_HALF_WIDTH);
|
||||
});
|
||||
|
||||
it('never lets bodies in adjacent lanes overlap, and keeps them on the road', () => {
|
||||
const spans = TRAFFIC_LANES.map((lane) => {
|
||||
const half = TRAFFIC_BODY[lane.kind].halfWidth + TRAFFIC_LANE_JITTER;
|
||||
return [lane.x - half, lane.x + half] as const;
|
||||
}).sort((a, b) => a[0] - b[0]);
|
||||
for (let i = 1; i < spans.length; i++) expect(spans[i]![0]).toBeGreaterThan(spans[i - 1]![1]);
|
||||
for (const [left, right] of spans) {
|
||||
expect(left).toBeGreaterThan(-8);
|
||||
expect(right).toBeLessThan(8);
|
||||
}
|
||||
it('scales the 1x bodies to the model sizes', () => {
|
||||
kinds.forEach((kind, k) => {
|
||||
const size = TRAFFIC_KINDS[k]!;
|
||||
expect(TRAFFIC_BODY[kind].halfWidth * size.scale).toBeCloseTo(size.halfWidth, 1);
|
||||
expect(TRAFFIC_BODY[kind].roof * size.scale).toBeCloseTo(size.roof, 1);
|
||||
// The truck's length runs from the under-run bar to the cab's nose.
|
||||
expect(size.length).toBeGreaterThanOrEqual(TRAFFIC_BODY[kind].length * size.scale - 0.01);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs each lane at one speed, so no vehicle drives through another', () => {
|
||||
const xs = TRAFFIC_LANES.map((lane) => lane.x);
|
||||
expect(new Set(xs).size).toBe(xs.length);
|
||||
it('keeps every side-lane body outside the hero car and on the asphalt, with lane jitter', () => {
|
||||
for (const x of sideLanes) {
|
||||
for (const size of TRAFFIC_KINDS) {
|
||||
expect(Math.abs(x) - size.halfWidth - TRAFFIC_LANE_JITTER).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
|
||||
expect(Math.abs(x) + size.halfWidth + TRAFFIC_LANE_JITTER).toBeLessThan(TRAFFIC_ASPHALT_HALF_WIDTH);
|
||||
}
|
||||
}
|
||||
expect(TRAFFIC_LANE_X[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('puts every lamp on the vehicle body', () => {
|
||||
for (const kind of ['car', 'truck'] as const) {
|
||||
const lamps = trafficLamps(kind, 0.5);
|
||||
expect(lamps.length).toBeGreaterThanOrEqual(3);
|
||||
for (const lamp of lamps) expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01);
|
||||
for (const kind of kinds) {
|
||||
const lamps = trafficLamps(kind);
|
||||
expect(lamps.length).toBeGreaterThanOrEqual(5);
|
||||
expect(lamps.length).toBeLessThanOrEqual(LAMPS_PER_VEHICLE);
|
||||
for (const lamp of lamps) {
|
||||
expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01);
|
||||
expect(lamp.y + lamp.h / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].roof + 0.01);
|
||||
}
|
||||
}
|
||||
// A readable pair: two tail lamps mirrored at one height.
|
||||
const [left, right] = trafficLamps('car', 0);
|
||||
const [left, right] = trafficLamps('car');
|
||||
expect(left!.x).toBe(-right!.x);
|
||||
expect(left!.y).toBe(right!.y);
|
||||
});
|
||||
|
||||
it('is fully dark before the car near field and lit beyond the fade', () => {
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0);
|
||||
expect(trafficNearFade(0)).toBe(0);
|
||||
expect(trafficNearFade(40)).toBe(0);
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_Z - TRAFFIC_FADE_LENGTH)).toBe(1);
|
||||
expect(trafficNearFade(-300)).toBe(1);
|
||||
// The car's nose is at about z = -5.5: the near field starts well ahead.
|
||||
expect(TRAFFIC_NEAR_Z).toBeLessThan(-18);
|
||||
});
|
||||
|
||||
it('wraps vehicles inside the hidden band at any scroll speed', () => {
|
||||
// 110 units/s at a 0.1 s frame clamp is 11 units per step; test far more.
|
||||
for (const step of [0.5, 11, 64, 900, -11, -900]) {
|
||||
let z = -200;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
z = wrapTrafficZ(z + step);
|
||||
expect(z).toBeLessThanOrEqual(TRAFFIC_NEAR_Z);
|
||||
expect(z).toBeGreaterThanOrEqual(-660);
|
||||
it('gives every vehicle a left and a right indicator beside, not on, its tail lamps', () => {
|
||||
for (const kind of kinds) {
|
||||
const lamps = trafficLamps(kind);
|
||||
const indicators = lamps.filter((lamp) => lamp.role === 'indicator');
|
||||
expect(indicators.map((lamp) => lamp.side).sort()).toEqual([-1, 1]);
|
||||
for (const indicator of indicators) {
|
||||
expect(Math.sign(indicator.x)).toBe(indicator.side);
|
||||
for (const tail of lamps.filter((lamp) => lamp.role === 'tail')) {
|
||||
if (Math.sign(tail.x) !== indicator.side) continue;
|
||||
const apart = Math.abs(indicator.x - tail.x) - (indicator.w + tail.w) / 2;
|
||||
expect(apart).toBeGreaterThanOrEqual(-0.005);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only cars carry a high-mount stop lamp; trucks carry clearance lamps.
|
||||
expect(trafficLamps('car').filter((lamp) => lamp.role === 'stop')).toHaveLength(1);
|
||||
expect(trafficLamps('truck').filter((lamp) => lamp.role === 'clearance')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fades only before a near limit', () => {
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_CAMERA_SIDE, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(0);
|
||||
expect(trafficNearFade(0, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(0);
|
||||
expect(trafficNearFade(TRAFFIC_NEAR_CAMERA_SIDE - TRAFFIC_FADE_LENGTH, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(1);
|
||||
expect(trafficNearFade(-300, TRAFFIC_NEAR_CAMERA_SIDE)).toBe(1);
|
||||
// Open vehicles stay lit all the way past the hero and under the camera.
|
||||
expect(trafficNearFade(20, TRAFFIC_NEAR_OPEN)).toBe(1);
|
||||
});
|
||||
|
||||
it('dissolves only the lane beneath an off-centre chase camera', () => {
|
||||
expect(trafficCameraSide(-5.4, -5.6)).toBe(true);
|
||||
expect(trafficCameraSide(5.4, -5.6)).toBe(false);
|
||||
expect(trafficCameraSide(5.4, 4.5)).toBe(true);
|
||||
// A centred camera, or a car in the centre lane, never.
|
||||
expect(trafficCameraSide(-5.4, 0)).toBe(false);
|
||||
expect(trafficCameraSide(-1, -5.6)).toBe(false);
|
||||
expect(trafficCameraSide(-5.4, Number.NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('sizes the pool per tier', () => {
|
||||
expect(trafficPoolSize({ postEffects: true, postScale: 1 })).toBe(12);
|
||||
expect(trafficPoolSize({ postEffects: true, postScale: 0.75 })).toBe(10);
|
||||
expect(trafficPoolSize({ postEffects: false, postScale: 1 })).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user