feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord
This commit is contained in:
@@ -17,6 +17,38 @@ function recordFor(
|
||||
}
|
||||
|
||||
describe('adaptive mixer', () => {
|
||||
it('sums the dawn input after the mood controls, untouched by chapter moods', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const connections = new Map<unknown, unknown[]>();
|
||||
const createGain = harness.context.createGain.bind(harness.context);
|
||||
harness.context.createGain = () => {
|
||||
const node = createGain();
|
||||
const connect = node.connect.bind(node) as (target: AudioNode) => AudioNode;
|
||||
node.connect = ((target: AudioNode) => {
|
||||
connections.set(node, [...(connections.get(node) ?? []), target]);
|
||||
return connect(target);
|
||||
}) as GainNode['connect'];
|
||||
return node;
|
||||
};
|
||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||
const sum = connections.get(mixer.dawnInput)?.[0];
|
||||
expect(sum).toBeDefined();
|
||||
// The same summing node feeds the destination (mute, volume, pulse tap).
|
||||
expect(connections.get(sum)).toEqual([harness.destination]);
|
||||
const dawnRecord = recordFor(harness, mixer.dawnInput.gain);
|
||||
harness.setCurrentTime(2);
|
||||
mixer.setChapterIntent({
|
||||
sectionId: 'contact',
|
||||
mood: 'resolve',
|
||||
atStart: false,
|
||||
entryPhrase: 6,
|
||||
generation: 1,
|
||||
});
|
||||
expect(dawnRecord.events).toEqual([]);
|
||||
mixer.dispose();
|
||||
expect((mixer.dawnInput as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
|
||||
});
|
||||
|
||||
it('ramps only the four approved mood controls over 1.4 seconds', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||
@@ -129,7 +161,8 @@ describe('adaptive mixer', () => {
|
||||
mixer.dispose();
|
||||
mixer.dispose();
|
||||
|
||||
expect(harness.gainNodes).toHaveLength(8);
|
||||
// Nine with the END OF SHIFT dawn input.
|
||||
expect(harness.gainNodes).toHaveLength(9);
|
||||
for (const node of harness.gainNodes) {
|
||||
expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
|
||||
}
|
||||
|
||||
88
tests/unit/dawn-resolve.test.ts
Normal file
88
tests/unit/dawn-resolve.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createDawnResolve,
|
||||
DAWN_CHORD_HZ,
|
||||
DAWN_DETUNE_CENTS,
|
||||
DAWN_OPEN_SECONDS,
|
||||
DAWN_PAD_GAIN,
|
||||
DAWN_RELEASE_SECONDS,
|
||||
DAWN_SWELL_SECONDS,
|
||||
} from '@/audio/dawn-resolve';
|
||||
import { createFakeAudioHarness, type FakeAudioHarness } from '../helpers/fake-audio-context';
|
||||
|
||||
const events = (harness: FakeAudioHarness) => harness.params.flatMap((record) => record.events);
|
||||
|
||||
describe('dawn resolve', () => {
|
||||
it('preallocates a silent four-note pad, each note a +/-4 cent pair, plus one breath source', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
createDawnResolve(harness.context, harness.destination);
|
||||
const oscillators = harness.sources.filter((record) => record.kind === 'oscillator');
|
||||
const buffers = harness.sources.filter((record) => record.kind === 'buffer');
|
||||
expect(oscillators).toHaveLength(DAWN_CHORD_HZ.length * 2);
|
||||
expect(buffers).toHaveLength(1);
|
||||
const notes = oscillators.map((record) => {
|
||||
const node = record.node as OscillatorNode;
|
||||
return [node.frequency.value, node.detune.value];
|
||||
});
|
||||
for (const frequency of [110, 164.8, 220, 329.6]) {
|
||||
expect(notes).toContainEqual([frequency, -DAWN_DETUNE_CENTS]);
|
||||
expect(notes).toContainEqual([frequency, DAWN_DETUNE_CENTS]);
|
||||
}
|
||||
for (const source of harness.sources) expect(source.starts).toHaveLength(1);
|
||||
// Silent until arrival: no ramp is scheduled at construction.
|
||||
expect(events(harness).filter((event) => event.kind === 'linear' || event.kind === 'exponential'))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
it('swells in over 5 s while the low-pass opens 900 -> 2600 Hz over 6 s, without new nodes', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const dawn = createDawnResolve(harness.context, harness.destination);
|
||||
const sourceCount = harness.sources.length;
|
||||
const gainCount = harness.gainNodes.length;
|
||||
harness.setCurrentTime(10);
|
||||
dawn.setDawn(true);
|
||||
const scheduled = events(harness);
|
||||
expect(scheduled).toContainEqual({ kind: 'linear', value: DAWN_PAD_GAIN, at: 10 + DAWN_SWELL_SECONDS });
|
||||
expect(scheduled).toContainEqual({ kind: 'exponential', value: 2_600, at: 10 + DAWN_OPEN_SECONDS });
|
||||
expect(DAWN_PAD_GAIN).toBe(0.09);
|
||||
expect(harness.sources).toHaveLength(sourceCount);
|
||||
expect(harness.gainNodes).toHaveLength(gainCount);
|
||||
});
|
||||
|
||||
it('releases over 1.6 s when driving away and ignores repeated states', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const dawn = createDawnResolve(harness.context, harness.destination);
|
||||
dawn.setDawn(false);
|
||||
expect(events(harness).filter((event) => event.kind === 'linear')).toEqual([]);
|
||||
harness.setCurrentTime(1);
|
||||
dawn.setDawn(true);
|
||||
const afterOn = events(harness).length;
|
||||
dawn.setDawn(true);
|
||||
expect(events(harness)).toHaveLength(afterOn);
|
||||
harness.setCurrentTime(4);
|
||||
dawn.setDawn(false);
|
||||
// Events are grouped per parameter, so pick the release out by its start time.
|
||||
const release = events(harness).filter((event) => event.at >= 4);
|
||||
expect(release).toContainEqual({ kind: 'linear', value: 0, at: 4 + DAWN_RELEASE_SECONDS });
|
||||
expect(release).toContainEqual({ kind: 'exponential', value: 900, at: 4 + DAWN_RELEASE_SECONDS });
|
||||
// Every ramp holds its parameter first, so a release mid-swell never jumps.
|
||||
expect(release.filter((event) => event.kind === 'hold' && event.at === 4)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('stops and disconnects every source exactly once and goes quiet after dispose', () => {
|
||||
const harness = createFakeAudioHarness();
|
||||
const dawn = createDawnResolve(harness.context, harness.destination);
|
||||
dawn.dispose();
|
||||
dawn.dispose();
|
||||
for (const source of harness.sources) {
|
||||
expect(source.stops).toHaveLength(1);
|
||||
expect(source.disconnectCount).toBe(1);
|
||||
}
|
||||
for (const node of harness.gainNodes) {
|
||||
expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
|
||||
}
|
||||
const before = events(harness).length;
|
||||
dawn.setDawn(true);
|
||||
expect(events(harness)).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
79
tests/unit/daybreak.test.ts
Normal file
79
tests/unit/daybreak.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DAWN_STEPS, dawnStep, END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
||||
import {
|
||||
blendDaybreak,
|
||||
CHAPTER_PALETTES,
|
||||
createMutableChapterPalette,
|
||||
DAYBREAK_PALETTE,
|
||||
engineOffStarScale,
|
||||
} from '@/scene/world/palette';
|
||||
|
||||
describe('dawn step mapping (html[data-dawn])', () => {
|
||||
it('steps night -> first-light -> sunrise -> day at 0.3 / 0.6 / 0.95', () => {
|
||||
expect(DAWN_STEPS).toEqual({ 'first-light': 0.3, sunrise: 0.6, day: 0.95 });
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, 'night'],
|
||||
[0.29, 'night'],
|
||||
[0.3, 'first-light'],
|
||||
[0.59, 'first-light'],
|
||||
[0.6, 'sunrise'],
|
||||
[0.949, 'sunrise'],
|
||||
[0.95, 'day'],
|
||||
[1, 'day'],
|
||||
[-1, 'night'],
|
||||
[Number.NaN, 'night'],
|
||||
];
|
||||
for (const [dawn, step] of cases) expect(dawnStep(dawn)).toBe(step);
|
||||
});
|
||||
|
||||
it('reads the end-of-shift sub-line', () => {
|
||||
expect(END_OF_SHIFT_SUBLINE).toBe('END OF SHIFT · 05:48');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daybreak palette', () => {
|
||||
it('is not a journey chapter', () => {
|
||||
expect(Object.keys(CHAPTER_PALETTES)).not.toContain('daybreak');
|
||||
});
|
||||
|
||||
it('leaves the destination untouched at 0 and becomes daybreak at 1', () => {
|
||||
const untouched = createMutableChapterPalette('destination');
|
||||
blendDaybreak(untouched, 0);
|
||||
expect(untouched.sky.getHex()).toBe(CHAPTER_PALETTES.destination.sky);
|
||||
expect(untouched.starVisibility).toBe(CHAPTER_PALETTES.destination.starVisibility);
|
||||
|
||||
const full = createMutableChapterPalette('destination');
|
||||
blendDaybreak(full, 1);
|
||||
for (const key of ['sky', 'fog', 'terrainLow', 'terrainHigh', 'sun', 'haze'] as const) {
|
||||
expect(full[key].getHex()).toBe(DAYBREAK_PALETTE[key]);
|
||||
}
|
||||
expect(full.warmth).toBe(1.25);
|
||||
expect(full.terrainEnergy).toBe(0.4);
|
||||
expect(full.starVisibility).toBe(0);
|
||||
expect(full.streakEnergy).toBe(0);
|
||||
});
|
||||
|
||||
it('blends partway and clamps out-of-range amounts', () => {
|
||||
const half = createMutableChapterPalette('destination');
|
||||
blendDaybreak(half, 0.5);
|
||||
expect(half.warmth).toBeCloseTo((1 + 1.25) / 2);
|
||||
expect(half.starVisibility).toBeCloseTo(0.08);
|
||||
const over = createMutableChapterPalette('destination');
|
||||
blendDaybreak(over, 3);
|
||||
expect(over.sky.getHex()).toBe(DAYBREAK_PALETTE.sky);
|
||||
const nan = createMutableChapterPalette('destination');
|
||||
blendDaybreak(nan, Number.NaN);
|
||||
expect(nan.sky.getHex()).toBe(CHAPTER_PALETTES.destination.sky);
|
||||
});
|
||||
|
||||
it('boosts the stars as the engine cuts, then drains them as the dawn rises', () => {
|
||||
expect(engineOffStarScale(0, 0)).toBe(1);
|
||||
expect(engineOffStarScale(1, 0)).toBeCloseTo(1.8);
|
||||
expect(engineOffStarScale(1, 0.5)).toBeCloseTo(1.4);
|
||||
expect(engineOffStarScale(1, 1)).toBe(1);
|
||||
// Combined with the daybreak blend, the stars are gone by full dawn.
|
||||
const palette = createMutableChapterPalette('destination');
|
||||
blendDaybreak(palette, 1);
|
||||
expect(palette.starVisibility * engineOffStarScale(1, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/audio/soundtrack-transport';
|
||||
import type { AdaptiveMixer } from '@/audio/adaptive-mixer';
|
||||
import type { DriveTexture } from '@/audio/drive-texture';
|
||||
import type { DawnResolve } from '@/audio/dawn-resolve';
|
||||
import type {
|
||||
AudioInitialState,
|
||||
SoundtrackSources,
|
||||
@@ -41,6 +42,7 @@ interface EngineHarnessOptions {
|
||||
readonly rejectDriveLift?: boolean;
|
||||
readonly rejectDriveTexture?: boolean;
|
||||
readonly deferDriveLift?: boolean;
|
||||
readonly rejectDawn?: boolean;
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
@@ -71,10 +73,12 @@ function createEngineHarness(
|
||||
const bedInput = harness.context.createGain();
|
||||
const driveLiftInput = harness.context.createGain();
|
||||
const driveTextureInput = harness.context.createGain();
|
||||
const dawnInput = harness.context.createGain();
|
||||
const mixer = {
|
||||
bedInput,
|
||||
driveLiftInput,
|
||||
driveTextureInput,
|
||||
dawnInput,
|
||||
setChapterIntent: vi.fn(),
|
||||
setDriveEnergy: vi.fn(),
|
||||
setTransportAnchor: vi.fn(),
|
||||
@@ -85,6 +89,14 @@ function createEngineHarness(
|
||||
setEnergy: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
} satisfies DriveTexture;
|
||||
const dawn = {
|
||||
setDawn: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
} satisfies DawnResolve;
|
||||
const createDawnResolve = vi.fn((_context: AudioContext, _destination: AudioNode) => {
|
||||
if (options.rejectDawn) throw new Error('dawn chord unavailable');
|
||||
return dawn;
|
||||
});
|
||||
const createMixer = vi.fn(() => mixer);
|
||||
const createDriveTexture = vi.fn(() => {
|
||||
if (options.rejectDriveTexture) throw new Error('drive texture unavailable');
|
||||
@@ -116,6 +128,7 @@ function createEngineHarness(
|
||||
loadBuffer,
|
||||
createMixer,
|
||||
createDriveTexture,
|
||||
createDawnResolve,
|
||||
});
|
||||
|
||||
const sourcesFor = (url: string): FakeSourceRecord[] => {
|
||||
@@ -144,6 +157,8 @@ function createEngineHarness(
|
||||
harness,
|
||||
mixer,
|
||||
texture,
|
||||
dawn,
|
||||
createDawnResolve,
|
||||
loadCalls,
|
||||
sourcesFor,
|
||||
sourceFor,
|
||||
@@ -348,7 +363,35 @@ describe('soundtrack engine', () => {
|
||||
expect(source.disconnectCount).toBe(1);
|
||||
}
|
||||
expect(test.texture.dispose).toHaveBeenCalledOnce();
|
||||
expect(test.dawn.dispose).toHaveBeenCalledOnce();
|
||||
expect(test.mixer.dispose).toHaveBeenCalledOnce();
|
||||
expect(test.harness.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('routes the dawn chord into the mixer and resolves it only at the destination', () => {
|
||||
const test = createEngineHarness();
|
||||
expect(test.createDawnResolve).toHaveBeenCalledWith(test.harness.context, test.mixer.dawnInput);
|
||||
expect(test.dawn.setDawn).not.toHaveBeenCalled();
|
||||
test.graph.setChapterIntent(resolveChapterIntent('toolkit', 1));
|
||||
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(false);
|
||||
test.graph.setChapterIntent(resolveChapterIntent('contact', 2));
|
||||
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(true);
|
||||
// A repeated intent for the same chapter does not re-trigger the chord.
|
||||
const calls = test.dawn.setDawn.mock.calls.length;
|
||||
test.graph.setChapterIntent(resolveChapterIntent('contact', 3));
|
||||
expect(test.dawn.setDawn).toHaveBeenCalledTimes(calls);
|
||||
test.graph.setChapterIntent(resolveChapterIntent('toolkit', 4));
|
||||
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('keeps playing when the dawn chord cannot be built, and ignores it after close', async () => {
|
||||
const test = createEngineHarness(initialState, { rejectDawn: true });
|
||||
expect(() => test.graph.setChapterIntent(resolveChapterIntent('contact', 1))).not.toThrow();
|
||||
expect(test.dawn.setDawn).not.toHaveBeenCalled();
|
||||
const closed = createEngineHarness();
|
||||
await closed.graph.close();
|
||||
closed.graph.setChapterIntent(resolveChapterIntent('contact', 1));
|
||||
expect(closed.dawn.setDawn).not.toHaveBeenCalled();
|
||||
expect(closed.dawn.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { createSun } from '@/scene/world/sun';
|
||||
import { createSun, DAWN_LIFT } from '@/scene/world/sun';
|
||||
import { createMutableChapterPalette } from '@/scene/world/palette';
|
||||
|
||||
interface Halo {
|
||||
@@ -107,6 +107,39 @@ describe('destination sun', () => {
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('lifts the disc, widens the halo and fades the meteors as the dawn rises, without allocating', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sun = createSun(resources);
|
||||
const palette = createMutableChapterPalette('destination');
|
||||
const disc = sun.group.getObjectByName('horizon-sun') as unknown as {
|
||||
position: { y: number };
|
||||
material: { uniforms: Record<string, { value: number }> };
|
||||
};
|
||||
const limb = sun.group.getObjectByName('sun-limb-glow') as unknown as { position: { y: number } };
|
||||
const halo = sun.group.getObjectByName('horizon-halo') as unknown as Halo & { position: { y: number } };
|
||||
const meteors = sun.group.getObjectByName('arrival-meteors') as unknown as {
|
||||
material: { uniforms: { uFade: { value: number } } };
|
||||
};
|
||||
sun.update(palette, 1, 10, 0, true, 0);
|
||||
sun.update(palette, 1, 20, 0, true, 0);
|
||||
const night = { y: disc.position.y, width: halo.scale.x };
|
||||
expect(meteors.material.uniforms.uFade.value).toBe(1);
|
||||
const allocation = resources.snapshot();
|
||||
|
||||
sun.update(palette, 1, 20, 0, true, 0.7);
|
||||
expect(meteors.material.uniforms.uFade.value).toBe(1);
|
||||
sun.update(palette, 1, 20, 0, true, 1);
|
||||
expect(disc.position.y).toBeCloseTo(night.y + DAWN_LIFT);
|
||||
expect(DAWN_LIFT).toBe(58);
|
||||
expect(limb.position.y).toBe(disc.position.y);
|
||||
expect(halo.position.y).toBe(disc.position.y);
|
||||
expect(halo.scale.x / night.width).toBeCloseTo(1.18);
|
||||
expect(disc.material.uniforms.uDawn!.value).toBe(1);
|
||||
expect(meteors.material.uniforms.uFade.value).toBe(0);
|
||||
expect(resources.snapshot()).toEqual(allocation);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('does not pulse from route progress alone and cancels when leaving Contact', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sun = createSun(resources);
|
||||
|
||||
Reference in New Issue
Block a user