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 } } 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(); }); });