139 lines
5.7 KiB
TypeScript
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();
|
|
});
|
|
});
|