feat: drive signs from a shared animation clock
This commit is contained in:
@@ -1,53 +1,187 @@
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { JourneySource, SignSnapshot } from "./types";
|
||||
import type { SectionId } from '@/content/types';
|
||||
import type {
|
||||
SignController,
|
||||
SignControllerSample,
|
||||
SignSnapshot,
|
||||
} from '@/journey/types';
|
||||
|
||||
export interface SignSample {
|
||||
source: JourneySource;
|
||||
deltaY: number;
|
||||
viewportHeight: number;
|
||||
crossedBoundaries: number;
|
||||
candidateId: SectionId | null;
|
||||
distanceVh: number;
|
||||
motionAllowed: boolean;
|
||||
const ANTICIPATE_MS = 300;
|
||||
const APPROACH_MS = 900;
|
||||
const APPROACH_REDUCTION_MS = 250;
|
||||
const DWELL_MS = 1_400;
|
||||
const DEPART_MS = 700;
|
||||
const COOLDOWN_MS = 350;
|
||||
const ELIGIBLE_DISTANCE_VH = 1.2;
|
||||
const JUMP_DISTANCE_VH = 0.75;
|
||||
|
||||
interface VisibleState {
|
||||
kind: 'visible';
|
||||
targetId: SectionId;
|
||||
latchedAt: number;
|
||||
approachDuration: number;
|
||||
departStartedAt: number | null;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { kind: 'idle' }
|
||||
| VisibleState
|
||||
| { kind: 'cooldown'; until: number };
|
||||
|
||||
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
export function nextSign(
|
||||
current: SignSnapshot | null,
|
||||
input: SignSample,
|
||||
): SignSnapshot | null {
|
||||
const eligible =
|
||||
input.motionAllowed &&
|
||||
input.source === "passive-scroll" &&
|
||||
Number.isFinite(input.deltaY) &&
|
||||
Number.isFinite(input.viewportHeight) &&
|
||||
Number.isFinite(input.distanceVh) &&
|
||||
input.viewportHeight > 0 &&
|
||||
input.deltaY > 0 &&
|
||||
input.deltaY <= input.viewportHeight * 0.75 &&
|
||||
input.crossedBoundaries <= 1;
|
||||
const smoothstep = (minimum: number, maximum: number, value: number): number => {
|
||||
const progress = clamp01((value - minimum) / (maximum - minimum));
|
||||
return progress * progress * (3 - 2 * progress);
|
||||
};
|
||||
|
||||
if (!eligible || input.distanceVh > 1.2 || input.distanceVh <= -0.25) {
|
||||
return null;
|
||||
}
|
||||
const isFiniteSample = (input: SignControllerSample): boolean =>
|
||||
Number.isFinite(input.now) &&
|
||||
Number.isFinite(input.acceleration) &&
|
||||
Number.isFinite(input.deltaY) &&
|
||||
Number.isFinite(input.viewportHeight) &&
|
||||
Number.isFinite(input.crossedBoundaries) &&
|
||||
input.viewportHeight > 0 &&
|
||||
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
|
||||
|
||||
const targetId = current?.targetId ?? input.candidateId;
|
||||
if (targetId === null) return null;
|
||||
const canLatch = (input: SignControllerSample): input is SignControllerSample & {
|
||||
candidateId: SectionId;
|
||||
candidateDistanceVh: number;
|
||||
} =>
|
||||
input.motionAllowed &&
|
||||
input.source === 'passive-scroll' &&
|
||||
input.direction === 1 &&
|
||||
input.deltaY > 0 &&
|
||||
input.deltaY <= input.viewportHeight * JUMP_DISTANCE_VH &&
|
||||
input.crossedBoundaries <= 1 &&
|
||||
input.candidateId !== null &&
|
||||
input.candidateId !== input.activeSectionId &&
|
||||
input.candidateDistanceVh !== null &&
|
||||
input.candidateDistanceVh > 0 &&
|
||||
input.candidateDistanceVh <= ELIGIBLE_DISTANCE_VH;
|
||||
|
||||
if (input.distanceVh > 0.55) {
|
||||
return {
|
||||
targetId,
|
||||
phase: "approach",
|
||||
progress: clamp01((1.2 - input.distanceVh) / 0.65),
|
||||
};
|
||||
}
|
||||
if (input.distanceVh > 0.15) {
|
||||
return { targetId, phase: "stabilized", progress: 1 };
|
||||
}
|
||||
return {
|
||||
targetId,
|
||||
phase: "pass",
|
||||
progress: clamp01((0.15 - input.distanceVh) / 0.4),
|
||||
export function createSignController(): SignController {
|
||||
let state: State = { kind: 'idle' };
|
||||
let lastNow: number | null = null;
|
||||
|
||||
const reset = (now?: number): void => {
|
||||
state = { kind: 'idle' };
|
||||
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
|
||||
};
|
||||
|
||||
const enterCooldown = (now: number): void => {
|
||||
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
|
||||
};
|
||||
|
||||
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
|
||||
if (visible.departStartedAt !== null) {
|
||||
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
|
||||
if (progress >= 1) {
|
||||
state = {
|
||||
kind: 'cooldown',
|
||||
until: visible.departStartedAt + DEPART_MS + COOLDOWN_MS,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetId: visible.targetId,
|
||||
phase: 'depart',
|
||||
progress,
|
||||
opacity: 1 - smoothstep(0.1, 1, progress),
|
||||
};
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, now - visible.latchedAt);
|
||||
if (elapsed < ANTICIPATE_MS) {
|
||||
return {
|
||||
targetId: visible.targetId,
|
||||
phase: 'anticipate',
|
||||
progress: clamp01(elapsed / ANTICIPATE_MS),
|
||||
opacity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const approachElapsed = elapsed - ANTICIPATE_MS;
|
||||
if (approachElapsed < visible.approachDuration) {
|
||||
const progress = clamp01(approachElapsed / visible.approachDuration);
|
||||
return {
|
||||
targetId: visible.targetId,
|
||||
phase: 'approach',
|
||||
progress,
|
||||
opacity: smoothstep(0, 0.42, progress),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
targetId: visible.targetId,
|
||||
phase: 'dwell',
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const sample = (input: SignControllerSample): SignSnapshot | null => {
|
||||
if (!isFiniteSample(input) || (lastNow !== null && input.now < lastNow)) {
|
||||
reset();
|
||||
return null;
|
||||
}
|
||||
lastNow = input.now;
|
||||
|
||||
const suppressed =
|
||||
!input.motionAllowed ||
|
||||
input.source !== 'passive-scroll' ||
|
||||
input.crossedBoundaries > 1 ||
|
||||
Math.abs(input.deltaY) > input.viewportHeight * JUMP_DISTANCE_VH;
|
||||
if (suppressed) {
|
||||
if (!input.motionAllowed) reset(input.now);
|
||||
else if (state.kind === 'visible') enterCooldown(input.now);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.kind === 'cooldown') {
|
||||
if (input.now < state.until) return null;
|
||||
state = { kind: 'idle' };
|
||||
}
|
||||
|
||||
if (state.kind === 'idle') {
|
||||
if (!canLatch(input)) return null;
|
||||
state = {
|
||||
kind: 'visible',
|
||||
targetId: input.candidateId,
|
||||
latchedAt: input.now,
|
||||
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
|
||||
departStartedAt: null,
|
||||
};
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
if (input.direction < 0) {
|
||||
const elapsed = input.now - state.latchedAt;
|
||||
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
|
||||
if (elapsed < ANTICIPATE_MS + state.approachDuration) {
|
||||
state = { kind: 'idle' };
|
||||
return null;
|
||||
}
|
||||
if (state.departStartedAt === null && input.now >= dwellStartedAt) {
|
||||
state.departStartedAt = input.now;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.departStartedAt === null) {
|
||||
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
|
||||
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
|
||||
if (dwellComplete && input.activeSectionId === state.targetId) {
|
||||
state.departStartedAt = input.now;
|
||||
}
|
||||
}
|
||||
|
||||
return snapshotVisible(state, input.now);
|
||||
};
|
||||
|
||||
const needsFrame = (): boolean => {
|
||||
if (state.kind !== 'visible' || lastNow === null) return false;
|
||||
if (state.departStartedAt !== null) return true;
|
||||
return lastNow < state.latchedAt + ANTICIPATE_MS + state.approachDuration + DWELL_MS;
|
||||
};
|
||||
|
||||
return { sample, reset, needsFrame };
|
||||
}
|
||||
|
||||
@@ -30,10 +30,33 @@ export type EnvironmentChapter =
|
||||
| "pre-dawn"
|
||||
| "destination";
|
||||
|
||||
export type SignPhase = 'anticipate' | 'approach' | 'dwell' | 'depart';
|
||||
|
||||
export interface SignSnapshot {
|
||||
targetId: SectionId;
|
||||
phase: "approach" | "stabilized" | "pass";
|
||||
phase: SignPhase;
|
||||
progress: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface SignControllerSample {
|
||||
now: number;
|
||||
source: JourneySource;
|
||||
direction: -1 | 0 | 1;
|
||||
acceleration: number;
|
||||
deltaY: number;
|
||||
viewportHeight: number;
|
||||
crossedBoundaries: number;
|
||||
candidateId: SectionId | null;
|
||||
candidateDistanceVh: number | null;
|
||||
activeSectionId: SectionId;
|
||||
motionAllowed: boolean;
|
||||
}
|
||||
|
||||
export interface SignController {
|
||||
sample(input: SignControllerSample): SignSnapshot | null;
|
||||
reset(now?: number): void;
|
||||
needsFrame(): boolean;
|
||||
}
|
||||
|
||||
export interface JourneySnapshot extends ScrollDriveSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user