feat: engine off at the destination

On arrival the drive winds down to a stop, the tail and running lamps
drop to parking level, the sky darkens about 10% with more stars and
the grid fades. The engine and road noise take a 1.2 s low-pass sweep
to idle. The letterbox stays within 8vh and the closing credit types
into the lower bar; reduced motion keeps only the bars and dimming.
This commit is contained in:
2026-10-10 23:37:51 +03:00
parent 07e3ea014d
commit f08dfc3fba
7 changed files with 124 additions and 34 deletions

View File

@@ -10,11 +10,15 @@ const CRUISE_ROAD_GAIN = 0.006;
const MAX_ROAD_GAIN = 0.024;
const PASS_BY_GAIN = 0.05;
const PASS_BY_SECONDS = 1.6;
/** Engine off at the destination: a low-pass sweep down to a parked idle. */
const PARK_SECONDS = 1.2;
export interface DriveTexture {
setEnergy(sample: DriveEnergySample): void;
/** Passing under the gantry: a filtered air sweep across the stereo field. */
passBy?(): void;
/** Parked at the final exit (true) or driving again (false). */
park?(parked: boolean): void;
dispose(): void;
}
@@ -141,6 +145,7 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) =>
let currentEnergy = 0;
let disposed = false;
let parked = false;
const automate = (parameter: AudioParam, value: number, duration: number): void => {
const now = context.currentTime;
holdParameter(parameter, now);
@@ -151,6 +156,10 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) =>
setEnergy(sample) {
if (disposed) return;
const next = Math.min(1, Math.max(0, sample.energy));
if (parked) {
currentEnergy = next;
return;
}
const duration = next > currentEnergy ? ATTACK_SECONDS : RELEASE_SECONDS;
automate(fundamental.source.frequency, 42 + next * 20, duration);
automate(harmonic.source.frequency, 84 + next * 40, duration);
@@ -160,6 +169,24 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) =>
automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration);
currentEnergy = next;
},
park(next) {
if (disposed || next === parked) return;
parked = next;
if (!parked) {
const energy = currentEnergy;
currentEnergy = -1;
this.setEnergy({ energy, source: 'idle' });
return;
}
const now = context.currentTime;
for (const parameter of [engineFilter.frequency, roadLowpass.frequency, engineGain.gain, roadGain.gain]) {
holdParameter(parameter, now);
}
engineFilter.frequency.exponentialRampToValueAtTime(70, now + PARK_SECONDS);
roadLowpass.frequency.exponentialRampToValueAtTime(260, now + PARK_SECONDS);
engineGain.gain.linearRampToValueAtTime(CRUISE_ENGINE_GAIN * 0.3, now + PARK_SECONDS);
roadGain.gain.linearRampToValueAtTime(CRUISE_ROAD_GAIN * 0.25, now + PARK_SECONDS);
},
passBy() {
if (disposed) return;
const now = context.currentTime;