feat: refine nightdrive world and authored car
Integrate the Blender grand tourer with a cohesive shader-driven road, terrain, atmosphere, lighting, and post-processing pass. Refine responsive drive dynamics and sign lifecycle behavior, and add durable visual, accessibility, and performance verification evidence.
This commit is contained in:
@@ -4,12 +4,14 @@ import type {
|
||||
ScrollDriveSnapshot,
|
||||
} from './types';
|
||||
|
||||
const MAX_RAW_SPEED = 2.5;
|
||||
const RISE_TIME_CONSTANT_MS = 90;
|
||||
const DECAY_TIME_CONSTANT_MS = 420;
|
||||
const MAX_VIEWPORTS_PER_SECOND = 3;
|
||||
const RISE_TIME_CONSTANT_MS = 180;
|
||||
const DECAY_TIME_CONSTANT_MS = 1_050;
|
||||
const CRUISE_SPEED = 0.18;
|
||||
const ACCELERATION_SPEED_RANGE = 0.82;
|
||||
const BACKGROUND_GAP_MS = 250;
|
||||
const MAX_INTEGRATION_INTERVAL_MS = 120;
|
||||
const DEFAULT_VIEWPORT_HEIGHT = 800;
|
||||
const DECAY_SETTLE_EPSILON = 0.001;
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number): number =>
|
||||
@@ -18,7 +20,10 @@ const clamp = (value: number, minimum: number, maximum: number): number =>
|
||||
const isFiniteSample = (input: ScrollDriveSample): boolean =>
|
||||
Number.isFinite(input.scrollY) &&
|
||||
Number.isFinite(input.now) &&
|
||||
Number.isFinite(input.maxScrollY);
|
||||
Number.isFinite(input.maxScrollY) &&
|
||||
(input.viewportHeight === undefined || (
|
||||
Number.isFinite(input.viewportHeight) && input.viewportHeight > 0
|
||||
));
|
||||
|
||||
const initialSnapshot = (): ScrollDriveSnapshot => ({
|
||||
scrollY: 0,
|
||||
@@ -76,18 +81,25 @@ export function createScrollDriveController(): ScrollDriveController {
|
||||
|
||||
const deltaY = input.scrollY - previousSample.scrollY;
|
||||
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
|
||||
if (elapsedMs > BACKGROUND_GAP_MS) {
|
||||
previousSample = { ...input };
|
||||
smoothedAcceleration = 0;
|
||||
return { ...cruiseSnapshot(input), direction };
|
||||
}
|
||||
|
||||
const rawSpeed = Math.abs(deltaY) / elapsedMs;
|
||||
const impulse = clamp(rawSpeed / MAX_RAW_SPEED, 0, 1);
|
||||
const integrationMs = elapsedMs > BACKGROUND_GAP_MS
|
||||
? MAX_INTEGRATION_INTERVAL_MS
|
||||
: elapsedMs;
|
||||
const viewportHeight = Math.max(
|
||||
1,
|
||||
input.viewportHeight ?? previousSample.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT,
|
||||
);
|
||||
const viewportVelocity = elapsedMs > BACKGROUND_GAP_MS
|
||||
? 0
|
||||
: (Math.abs(deltaY) / viewportHeight) / (elapsedMs / 1_000);
|
||||
const impulse = clamp(
|
||||
viewportVelocity / MAX_VIEWPORTS_PER_SECOND,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const timeConstantMs = impulse > smoothedAcceleration
|
||||
? RISE_TIME_CONSTANT_MS
|
||||
: DECAY_TIME_CONSTANT_MS;
|
||||
const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs);
|
||||
const alpha = 1 - Math.exp(-integrationMs / timeConstantMs);
|
||||
smoothedAcceleration += (impulse - smoothedAcceleration) * alpha;
|
||||
if (impulse === 0 && smoothedAcceleration < DECAY_SETTLE_EPSILON) {
|
||||
smoothedAcceleration = 0;
|
||||
|
||||
@@ -13,6 +13,17 @@ const DEPART_MS = 700;
|
||||
const COOLDOWN_MS = 350;
|
||||
const ELIGIBLE_DISTANCE_VH = 1.2;
|
||||
const JUMP_DISTANCE_VH = 0.75;
|
||||
const CONCEALED_OPACITY = 0.02;
|
||||
const SECTION_ORDER: readonly SectionId[] = [
|
||||
'start',
|
||||
'profile',
|
||||
'experience',
|
||||
'infrastructure',
|
||||
'selected-builds',
|
||||
'additional-work',
|
||||
'toolkit',
|
||||
'contact',
|
||||
];
|
||||
|
||||
interface VisibleState {
|
||||
kind: 'visible';
|
||||
@@ -20,6 +31,7 @@ interface VisibleState {
|
||||
latchedAt: number;
|
||||
approachDuration: number;
|
||||
departStartedAt: number | null;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
type State =
|
||||
@@ -62,16 +74,43 @@ const canLatch = (input: SignControllerSample): input is SignControllerSample &
|
||||
export function createSignController(): SignController {
|
||||
let state: State = { kind: 'idle' };
|
||||
let lastNow: number | null = null;
|
||||
let generation = 0;
|
||||
|
||||
const reset = (now?: number): void => {
|
||||
generation += 1;
|
||||
state = { kind: 'idle' };
|
||||
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
|
||||
};
|
||||
|
||||
const enterCooldown = (now: number): void => {
|
||||
generation += 1;
|
||||
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
|
||||
};
|
||||
|
||||
const latch = (
|
||||
input: SignControllerSample & {
|
||||
candidateId: SectionId;
|
||||
candidateDistanceVh: number;
|
||||
},
|
||||
): VisibleState => {
|
||||
generation += 1;
|
||||
return {
|
||||
kind: 'visible',
|
||||
targetId: input.candidateId,
|
||||
latchedAt: input.now,
|
||||
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
|
||||
departStartedAt: null,
|
||||
generation,
|
||||
};
|
||||
};
|
||||
|
||||
const beginDeparture = (visible: VisibleState, now: number): void => {
|
||||
if (visible.departStartedAt !== null) return;
|
||||
generation += 1;
|
||||
visible.generation = generation;
|
||||
visible.departStartedAt = now;
|
||||
};
|
||||
|
||||
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
|
||||
if (visible.departStartedAt !== null) {
|
||||
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
|
||||
@@ -126,15 +165,23 @@ export function createSignController(): SignController {
|
||||
}
|
||||
lastNow = input.now;
|
||||
|
||||
const suppressed =
|
||||
!input.motionAllowed ||
|
||||
input.source !== 'passive-scroll' ||
|
||||
if (!input.motionAllowed || input.source !== 'passive-scroll') {
|
||||
reset(input.now);
|
||||
return null;
|
||||
}
|
||||
|
||||
const discontinuous =
|
||||
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 (discontinuous) {
|
||||
if (state.kind !== 'visible') return null;
|
||||
const current = snapshotVisible(state, input.now);
|
||||
if (current === null || current.opacity <= CONCEALED_OPACITY) {
|
||||
enterCooldown(input.now);
|
||||
return null;
|
||||
}
|
||||
beginDeparture(state, input.now);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
if (state.kind === 'cooldown') {
|
||||
@@ -144,13 +191,31 @@ export function createSignController(): SignController {
|
||||
|
||||
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,
|
||||
};
|
||||
state = latch(input);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
const current = snapshotVisible(state, input.now);
|
||||
if (current === null) return null;
|
||||
|
||||
if (
|
||||
current.opacity <= CONCEALED_OPACITY
|
||||
&& canLatch(input)
|
||||
&& input.candidateId !== state.targetId
|
||||
) {
|
||||
state = latch(input);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
const activeIndex = SECTION_ORDER.indexOf(input.activeSectionId);
|
||||
const targetIndex = SECTION_ORDER.indexOf(state.targetId);
|
||||
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
|
||||
if (targetPassed && state.departStartedAt === null) {
|
||||
if (current.opacity <= CONCEALED_OPACITY) {
|
||||
enterCooldown(input.now);
|
||||
return null;
|
||||
}
|
||||
beginDeparture(state, input.now);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
@@ -162,7 +227,7 @@ export function createSignController(): SignController {
|
||||
return null;
|
||||
}
|
||||
if (state.departStartedAt === null && input.now >= dwellStartedAt) {
|
||||
state.departStartedAt = input.now;
|
||||
beginDeparture(state, input.now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +235,7 @@ export function createSignController(): SignController {
|
||||
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;
|
||||
beginDeparture(state, input.now);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ScrollDriveSample {
|
||||
scrollY: number;
|
||||
now: number;
|
||||
maxScrollY: number;
|
||||
viewportHeight?: number;
|
||||
}
|
||||
|
||||
export interface ScrollDriveSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user