133 lines
6.0 KiB
TypeScript
133 lines
6.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { SceneResourceBag } from '@/scene/resources';
|
|
import {
|
|
MILESTONE_FLASH_MAX_RATE,
|
|
createMilestones,
|
|
MILESTONE_FAR_Z,
|
|
MILESTONE_NEAR_Z,
|
|
MILESTONE_PASS_Z,
|
|
MILESTONE_SIDE,
|
|
milestoneLayout,
|
|
milestoneZ,
|
|
} from '@/scene/world/milestones';
|
|
import { MILESTONE_COUNTS, passPoint } from '@/journey/route-log';
|
|
|
|
const sweep = (from: number, to: number, steps = 200): number[] =>
|
|
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
|
|
|
|
describe('mile marker layout', () => {
|
|
it('rolls every post toward the camera monotonically with progress, in order', () => {
|
|
for (const section of ['experience', 'profile'] as const) {
|
|
const count = MILESTONE_COUNTS[section];
|
|
for (let i = 0; i < count; i += 1) {
|
|
let previous = Number.NEGATIVE_INFINITY;
|
|
for (const p of sweep(0, 1)) {
|
|
const z = milestoneZ(p, i, count);
|
|
expect(z).toBeGreaterThan(previous);
|
|
previous = z;
|
|
}
|
|
// At the camera's near edge exactly as its plate crosses the line.
|
|
expect(milestoneZ(passPoint(i, count), i, count)).toBeCloseTo(MILESTONE_PASS_Z);
|
|
expect(MILESTONE_PASS_Z).toBeCloseTo((MILESTONE_FAR_Z + MILESTONE_NEAR_Z) / 2);
|
|
// Still on the near side of the camera (z ~25) and large in frame.
|
|
expect(MILESTONE_PASS_Z).toBeLessThan(20);
|
|
// Earlier posts are always nearer.
|
|
if (i > 0) expect(milestoneZ(0.5, i - 1, count)).toBeGreaterThan(milestoneZ(0.5, i, count));
|
|
}
|
|
}
|
|
});
|
|
|
|
it('stands on the free shoulder: right of the road in Experience, left in Profile', () => {
|
|
expect(MILESTONE_SIDE.experience).toBeGreaterThan(8);
|
|
expect(MILESTONE_SIDE.profile).toBeLessThan(-8);
|
|
const posts = milestoneLayout('experience', 0.5).filter((post) => post.presence > 0);
|
|
expect(posts.length).toBeGreaterThan(0);
|
|
expect(posts.every((post) => post.section === 'experience' && post.x === MILESTONE_SIDE.experience)).toBe(true);
|
|
});
|
|
|
|
it('is hidden outside its chapters and fades at the chapter edges', () => {
|
|
for (const section of ['start', 'infrastructure', 'toolkit', 'contact'] as const) {
|
|
expect(milestoneLayout(section, 0.5).every((post) => post.presence === 0)).toBe(true);
|
|
}
|
|
expect(milestoneLayout('experience', null).every((post) => post.presence === 0)).toBe(true);
|
|
expect(milestoneLayout('experience', 0).every((post) => post.presence === 0)).toBe(true);
|
|
expect(milestoneLayout('profile', 1).every((post) => post.presence === 0)).toBe(true);
|
|
const inside = milestoneLayout('profile', 0.5);
|
|
expect(inside.filter((post) => post.section === 'experience').every((post) => post.presence === 0)).toBe(true);
|
|
expect(inside.some((post) => post.section === 'profile' && post.presence === 1)).toBe(true);
|
|
});
|
|
|
|
it('is symmetric in reverse: the same progress gives the same frame either way', () => {
|
|
const ps = sweep(0, 1);
|
|
const forward = ps.map((p) => milestoneLayout('experience', p));
|
|
const backward = [...ps].reverse().map((p) => milestoneLayout('experience', p)).reverse();
|
|
expect(backward).toEqual(forward);
|
|
});
|
|
});
|
|
|
|
describe('mile marker mesh', () => {
|
|
it('draws seven instances in one mesh, visible only in Experience and Profile', () => {
|
|
const resources = new SceneResourceBag();
|
|
const markers = createMilestones(resources, true);
|
|
expect(markers.mesh.count).toBe(7);
|
|
expect(markers.pool.count).toBe(7);
|
|
markers.update('start', null, 0, 1 / 60);
|
|
expect(markers.mesh.visible).toBe(false);
|
|
expect(markers.pool.visible).toBe(false);
|
|
markers.update('experience', 0.4, 0.5, 1 / 60);
|
|
expect(markers.mesh.visible).toBe(true);
|
|
expect(markers.pool.visible).toBe(true);
|
|
markers.update('infrastructure', null, 0, 1 / 60);
|
|
expect(markers.mesh.visible).toBe(false);
|
|
expect(markers.pool.visible).toBe(false);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('flashes a post as its plate crosses the line, either way, and decays in 300 ms', () => {
|
|
const resources = new SceneResourceBag();
|
|
const markers = createMilestones(resources, true);
|
|
const second = passPoint(1, 4);
|
|
markers.update('experience', second - 0.02, 0, 1 / 60);
|
|
markers.update('experience', second - 0.01, 0, 1 / 60);
|
|
expect(markers.flashes()[1]).toBe(0);
|
|
markers.update('experience', second + 0.01, 0, 1 / 60);
|
|
expect(markers.flashes()[1]).toBe(1);
|
|
for (let i = 0; i < 20; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
|
|
expect(markers.flashes()[1]).toBe(0);
|
|
// Back the other way (after the 0.6 s gap) flashes again.
|
|
for (let i = 0; i < 30; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
|
|
markers.update('experience', second - 0.01, 0, 1 / 60);
|
|
expect(markers.flashes()[1]).toBe(1);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('does not flash posts swept past by a navigator jump or a fling', () => {
|
|
const resources = new SceneResourceBag();
|
|
const markers = createMilestones(resources, true);
|
|
// A navigator long-scroll crosses the whole chapter in about 0.6 s.
|
|
let peak = 0;
|
|
for (let frame = 0; frame <= 36; frame += 1) {
|
|
markers.update('experience', frame / 36, 0, 1 / 60);
|
|
peak = Math.max(peak, ...markers.flashes());
|
|
}
|
|
expect(peak).toBe(0);
|
|
// Parked, then a single wheel tick at reading pace still flashes the post it crosses.
|
|
for (let i = 0; i < 90; i += 1) markers.update('experience', passPoint(3, 4) + 0.01, 0, 1 / 60);
|
|
markers.update('experience', passPoint(3, 4) - 0.01, 0, 1 / 60);
|
|
expect(markers.flashes()[3]).toBe(1);
|
|
expect(MILESTONE_FLASH_MAX_RATE).toBeGreaterThan(0.5);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('never flashes on the low tier or touch', () => {
|
|
const resources = new SceneResourceBag();
|
|
const markers = createMilestones(resources, false);
|
|
const first = passPoint(0, 3);
|
|
markers.update('profile', first - 0.01, 1, 1 / 60);
|
|
markers.update('profile', first + 0.01, 1, 1 / 60);
|
|
expect(markers.flashes().every((level) => level === 0)).toBe(true);
|
|
expect(markers.mesh.visible).toBe(true);
|
|
resources.dispose();
|
|
});
|
|
});
|