import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { FLARE_GAIN, FLARE_SWELL, flareReach, HERO_CAR_HALF_WIDTH, LAMPS_PER_VEHICLE, TRAFFIC_BODY, TRAFFIC_FADE_LENGTH, trafficLamps, trafficNearFade, 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('agrees with the model on the hero envelope', () => { expect(HERO_CAR_HALF_WIDTH).toBe(HERO_HALF_WIDTH); }); 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('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 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'); expect(left!.x).toBe(-right!.x); expect(left!.y).toBe(right!.y); }); 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); }); }); describe('the flash to pass', () => { it('peaks between 120 and 250 m, not within 100 m, and is gone past 420 m', () => { const peak = Math.max(...Array.from({ length: 500 }, (_, d) => flareReach(d))); expect(peak).toBe(1); for (let d = 125; d <= 250; d += 5) expect(flareReach(d)).toBeGreaterThan(0.97); expect(flareReach(120)).toBeGreaterThan(0.9); for (let d = 0; d <= 100; d += 2) expect(flareReach(d)).toBeLessThan(0.35); for (let d = 0; d <= 60; d += 2) expect(flareReach(d)).toBeLessThanOrEqual(0.15); expect(flareReach(420)).toBe(0); }); it('flares lamps in their own red and amber, only a little brighter', () => { const source = readFileSync(resolve(process.cwd(), 'src/scene/world/traffic.ts'), 'utf8'); // No warm-white mix toward the headlight colour. expect(source).not.toContain('vec3(1.0, 0.72, 0.58)'); expect(source).toContain('vGain * (1.0 + ${FLARE_GAIN.toFixed(2)} * vFlare)'); // At full lift (vFlare <= 0.7 + 1.6), a lamp gains at most ~0.8x of its own level. expect(FLARE_GAIN * (0.7 + 1.6)).toBeLessThan(0.85); expect(FLARE_SWELL).toBeLessThanOrEqual(0.3); }); });