feat: cursor-steered headlights light the hero name, plates and world
This commit is contained in:
138
tests/unit/headlights.test.ts
Normal file
138
tests/unit/headlights.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user