import { describe, expect, it } from 'vitest'; import { NORMAL_CONE, UV_CONE, angleDelta, coneVisibility, conePolygon, inCone, } from '../../src/scenes/floor/cone'; import type { ConeSpec } from '../../src/scenes/floor/cone'; const EAST: ConeSpec = { x: 100, y: 100, facing: 0, ...NORMAL_CONE }; const at = (spec: ConeSpec, dist: number, off: number) => ({ x: spec.x + Math.cos(spec.facing + off) * dist, y: spec.y + Math.sin(spec.facing + off) * dist, }); describe('angleDelta', () => { it('is zero for identical angles and full turns apart', () => { expect(angleDelta(0, 0)).toBeCloseTo(0, 12); expect(angleDelta(Math.PI * 2, 0)).toBeCloseTo(0, 12); expect(angleDelta(-Math.PI * 4, 0)).toBeCloseTo(0, 12); }); it('takes the short way around the wrap point', () => { expect(angleDelta(0.1, -0.1)).toBeCloseTo(0.2, 12); // 175 deg and -175 deg are 10 deg apart, not 350. const a = Math.PI - 0.087; const b = -Math.PI + 0.087; expect(Math.abs(angleDelta(a, b))).toBeCloseTo(0.174, 6); expect(Math.abs(angleDelta(b, a))).toBeCloseTo(0.174, 6); }); it('stays in (-PI, PI] for a sweep of inputs', () => { for (let i = -40; i <= 40; i++) { const d = angleDelta(i * 0.37, i * -0.61); expect(d).toBeGreaterThan(-Math.PI); expect(d).toBeLessThanOrEqual(Math.PI + 1e-12); } }); it('maps an exact half turn to +PI, not -PI', () => { expect(angleDelta(Math.PI, 0)).toBeCloseTo(Math.PI, 12); expect(angleDelta(0, Math.PI)).toBeCloseTo(Math.PI, 12); }); }); describe('inCone', () => { it('sees straight ahead', () => { expect(inCone(EAST, 200, 100)).toBe(true); expect(inCone(EAST, EAST.x, EAST.y)).toBe(true); }); it('is blind behind and to the sides', () => { expect(inCone(EAST, 0, 100)).toBe(false); expect(inCone(EAST, 100, 20)).toBe(false); expect(inCone(EAST, ...([at(EAST, 50, 1.2).x, at(EAST, 50, 1.2).y] as const))).toBe(false); }); it('is blind beyond range', () => { const far = at(EAST, EAST.range + 1, 0); expect(inCone(EAST, far.x, far.y)).toBe(false); const near = at(EAST, EAST.range - 1, 0); expect(inCone(EAST, near.x, near.y)).toBe(true); }); it('respects facing — rotating the cone rotates what it sees', () => { const north: ConeSpec = { ...EAST, facing: -Math.PI / 2 }; expect(inCone(north, 100, 20)).toBe(true); expect(inCone(north, 200, 100)).toBe(false); }); it('works across the PI wrap', () => { const west: ConeSpec = { ...EAST, facing: Math.PI }; const p = at(west, 60, 0.2); expect(inCone(west, p.x, p.y)).toBe(true); expect(inCone(west, 200, 100)).toBe(false); }); it('UV torch is tighter and shorter than the normal one', () => { expect(UV_CONE.halfAngle).toBeLessThan(NORMAL_CONE.halfAngle); expect(UV_CONE.range).toBeLessThan(NORMAL_CONE.range); const uv: ConeSpec = { x: 100, y: 100, facing: 0, ...UV_CONE }; const p = at(EAST, 160, 0.5); expect(inCone(EAST, p.x, p.y)).toBe(true); expect(inCone(uv, p.x, p.y)).toBe(false); }); }); describe('coneVisibility', () => { it('is 0 wherever inCone is false', () => { for (let off = -2; off <= 2; off += 0.1) { for (const dist of [10, 90, 189, 191, 400]) { const p = at(EAST, dist, off); if (!inCone(EAST, p.x, p.y)) expect(coneVisibility(EAST, p.x, p.y)).toBe(0); } } }); it('is strictly positive inside and never above 1', () => { for (const off of [0, 0.2, -0.2, 0.5, -0.5]) { for (const dist of [1, 40, 120, 180]) { const p = at(EAST, dist, off); const v = coneVisibility(EAST, p.x, p.y); expect(v).toBeGreaterThan(0); expect(v).toBeLessThanOrEqual(1); } } expect(coneVisibility(EAST, EAST.x, EAST.y)).toBe(1); }); it('is brighter on-axis than off-axis at the same distance', () => { const onAxis = at(EAST, 100, 0); const offAxis = at(EAST, 100, 0.4); expect(coneVisibility(EAST, onAxis.x, onAxis.y)) .toBeGreaterThan(coneVisibility(EAST, offAxis.x, offAxis.y)); }); it('decreases monotonically with distance along the axis', () => { let prev = Infinity; for (let d = 1; d < EAST.range; d += 10) { const p = at(EAST, d, 0); const v = coneVisibility(EAST, p.x, p.y); expect(v).toBeLessThan(prev); prev = v; } }); it('fades to nothing at the cone edge', () => { const edge = at(EAST, 100, EAST.halfAngle); expect(coneVisibility(EAST, edge.x, edge.y)).toBeCloseTo(0, 9); const rim = at(EAST, EAST.range, 0); expect(coneVisibility(EAST, rim.x, rim.y)).toBeCloseTo(0, 9); }); }); describe('conePolygon', () => { it('starts at the apex and closes back on it', () => { const pts = conePolygon(EAST, 12); expect(pts[0]).toEqual({ x: EAST.x, y: EAST.y }); expect(pts[pts.length - 1]).toEqual(pts[0]); expect(pts).toHaveLength(12 + 3); // apex + (segments+1) arc points + closing apex }); it('puts every arc point at exactly range, inside the cone', () => { const pts = conePolygon(EAST, 16).slice(1, -1); for (const p of pts) { expect(Math.hypot(p.x - EAST.x, p.y - EAST.y)).toBeCloseTo(EAST.range, 9); expect(Math.abs(angleDelta(Math.atan2(p.y - EAST.y, p.x - EAST.x), EAST.facing))) .toBeLessThanOrEqual(EAST.halfAngle + 1e-9); } }); it('spans the full cone width, edge to edge', () => { const pts = conePolygon(EAST, 8); const firstArc = pts[1]!; const lastArc = pts[pts.length - 2]!; expect(angleDelta(Math.atan2(firstArc.y - EAST.y, firstArc.x - EAST.x), EAST.facing)) .toBeCloseTo(-EAST.halfAngle, 9); expect(angleDelta(Math.atan2(lastArc.y - EAST.y, lastArc.x - EAST.x), EAST.facing)) .toBeCloseTo(EAST.halfAngle, 9); }); it('tolerates a degenerate segment count', () => { expect(conePolygon(EAST, 0)).toHaveLength(4); expect(conePolygon(EAST, 1)).toHaveLength(4); }); it('defaults to a smooth arc', () => { expect(conePolygon(EAST).length).toBeGreaterThan(20); }); });