Files
Portfolio/tests/unit/headlights.test.ts

139 lines
5.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality';
import { createRoad } from '@/scene/world/road';
import {
beamLevels,
beamUniforms,
createHeadlights,
roadOpenness,
} from '@/scene/world/headlights';
import { REST_INTENSITY } from '@/journey/headlight-beam';
const activation = (active: number, next: number | null) => ({
layoutRevision: 1,
activeHeadingOffsetVh: active,
nextHeadingOffsetVh: next,
});
const car = () => {
const group = new THREE.Group();
group.position.set(0, 0.4, 0.72);
return group;
};
const camera = () => {
const lens = new THREE.PerspectiveCamera(40, 1.6, 0.1, 1000);
lens.position.set(4, 4.5, 15);
return lens;
};
/* three ships untyped here (see src/scene/three.d.ts). */
interface Spot {
castShadow: boolean;
angle: number;
distance: number;
intensity: number;
color: { getHex(): number };
position: { x: number; z: number };
target: { position: { x: number; z: number } };
}
interface Uniforms { material: { uniforms: Record<string, { value: number }> } }
const isSpot = (light: unknown): light is Spot => light instanceof THREE.SpotLight;
const open = { activeSectionId: 'start' as const, sectionActivation: activation(0, 100) };
describe('high beam: scene', () => {
it('opens the road at the hero and between plates, closes it under a plate', () => {
expect(roadOpenness(open)).toBe(1);
// A chapter heading just reached the top: its plate is on screen.
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(-40, 60) })).toBeLessThan(0.05);
// Just past the heading, and again in the gap before the next one.
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(0, 100) })).toBe(1);
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(-95, 5) })).toBeGreaterThan(0.95);
// The destination has no next heading: the plate holds the screen.
expect(roadOpenness({ activeSectionId: 'contact', sectionActivation: activation(-20, null) })).toBe(0);
});
it('ramps presence with the sweep and lifts only above dipped beam', () => {
expect(beamLevels(undefined)).toEqual({ presence: 0, lift: 0 });
expect(beamLevels({ x: 0, y: 0, intensity: REST_INTENSITY / 2 }).presence).toBeCloseTo(0.5);
expect(beamLevels({ x: 0, y: 0, intensity: REST_INTENSITY })).toEqual({ presence: 1, lift: 0 });
expect(beamLevels({ x: 0, y: 0, intensity: 1 }).lift).toBeCloseTo(1);
});
it('high adds one shadowless warm spotlight and a haze cone; medium the spot only; low neither', () => {
for (const tier of ['high', 'medium', 'low'] as const) {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, tier, parent);
const spots = headlights.lights.filter(isSpot);
expect(spots).toHaveLength(tier === 'low' ? 0 : 1);
for (const spot of spots) {
expect(spot.castShadow).toBe(false);
expect(spot.angle).toBeCloseTo(0.42);
expect(spot.distance).toBe(70);
expect(spot.color.getHex()).toBe(0xffd6a8);
}
expect(parent.getObjectByName('high-beam-cone') !== undefined).toBe(tier === 'high');
resources.dispose();
}
});
it('aims the spot and the shader beam with the pointer, dark until the sweep', () => {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, 'high', parent);
const spot = headlights.lights.find(isSpot)!;
const lens = camera();
headlights.update(undefined, open, lens, 1, 0.016);
expect(spot.intensity).toBe(0);
expect(beamUniforms.uBeamGain.value).toBe(0);
const right = headlights.update({ x: 1, y: 0, intensity: REST_INTENSITY }, open, lens, 1, 0.016);
expect(right.yaw).toBeCloseTo(1);
expect(right.lift).toBe(0);
expect(spot.intensity).toBeGreaterThan(0);
expect(spot.target.position.x).toBeGreaterThan(spot.position.x);
expect(spot.target.position.z).toBeLessThan(spot.position.z);
expect(beamUniforms.uBeamDir.value.x).toBeGreaterThan(0);
const left = headlights.update({ x: -1, y: 0, intensity: 1 }, open, lens, 1, 0.016);
expect(left.yaw).toBeCloseTo(-1);
expect(left.lift).toBeCloseTo(1);
expect(spot.target.position.x).toBeLessThan(spot.position.x);
// Parked at the destination: the lamps are off.
headlights.update({ x: 0, y: 0, intensity: 1 }, open, lens, 0, 0.016);
expect(spot.intensity).toBe(0);
resources.dispose();
});
it('fades the haze cone out under a reading plate', () => {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, 'high', parent);
const cone = parent.getObjectByName('high-beam-cone');
const lens = camera();
const beam = { x: 0.3, y: 0, intensity: REST_INTENSITY };
headlights.update(beam, open, lens, 1, 0.016);
expect(cone.visible).toBe(true);
const plate = { activeSectionId: 'experience' as const, sectionActivation: activation(-40, 60) };
for (let frame = 0; frame < 120; frame++) headlights.update(beam, plate, lens, 1, 0.016);
expect(cone.visible).toBe(false);
resources.dispose();
});
it('the road takes the yaw and the lift as clamped uniforms', () => {
const resources = new SceneResourceBag();
const road = createRoad(resources, renderQualityFor('high', 1, false));
road.setBeam(4, -2);
const surface = road.group.getObjectByName('road-surface') as Uniforms;
expect(surface.material.uniforms.uBeamYaw?.value).toBe(1);
expect(surface.material.uniforms.uBeamLift?.value).toBe(0);
resources.dispose();
});
});