Laying out a graph by hand mostly means lining nodes up, so the magnet was switched on in nearly every session. Every graph tab now starts with it lit: Behavior, State machine, Shader, Particles, Sound FX, Instrument, the song's Routing and the Routing tab. - SnapToggle takes the state it starts in and lights its button to match. GRAPH_SNAPS_AT_FIRST sets it for the toggles and for GraphCanvas alike, so button and canvas cannot disagree. - Curve and Vector Art are no graphs and still start without it. - New hook __gm.graphSnapping(on?) reads every graph tab's magnet, or "mixed" where button and canvas differ, and switches them all. - The e2e harness reads the magnets after each navigate() and then switches them off, as it already sets the zoom transition to 0. Most checks drag a node to an exact spot. e2e 45 does the same itself, because the editor comes up there through the sign-in. - e2e 06 checks that all eight graph tabs started with the magnet on. The checks in 06, 26 and 65 that read it off now say they read what the harness left. Tests: - Unit snap-toggle: 4 tests, 1 new (lit at first, nothing asked of the target before a click). - e2e 06 (melektaus-atx): 150 checks, 1 new. e2e 34: 134 checks. - Gate (melektaus-atx, together with #390): 287 files, 4397 tests. - Pictures looked at: none, the state comes from the hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
5.6 KiB
TypeScript
144 lines
5.6 KiB
TypeScript
import type { Asset, SoundFxAsset } from '../../core/model/Asset';
|
||
import { findSound } from '../../core/model/sound';
|
||
import { assetRefLabel, soundChoices } from '../../core/properties/assetRef';
|
||
import { playedSoundOf } from '../../core/properties/playedSound';
|
||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||
import { volumeOf } from '../../core/properties/sounds';
|
||
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
|
||
import { Button, Dropdown, Menu } from '../../ui';
|
||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||
import { MASTER_MIX } from '../../core/music/mixer';
|
||
import { AssetTabEditor } from '../AssetTabEditor';
|
||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||
import { GRAPH_SNAPS_AT_FIRST, SnapToggle } from '../graph/SnapToggle';
|
||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||
import type { EditorState } from '../state';
|
||
import { PreviewParams } from './PreviewParams';
|
||
|
||
/** Room kept for "N effects", or for the reason the graph does not compile. */
|
||
const INFO_WIDTH = 220;
|
||
const SOUND_WIDTH = 140;
|
||
/** Whose voices the tab's Stop silences: its own, never a running game's. */
|
||
const PREVIEW_OWNER = 'soundfx-preview';
|
||
|
||
/**
|
||
* The nodes of one sound FX asset (#311), and in the header a way to hear them: a sound of the
|
||
* project played through the graph as it stands. Which sound is the tab's, not the asset's – it is
|
||
* only there to listen with.
|
||
*/
|
||
export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
|
||
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
|
||
/** Public for the check scripts. */
|
||
readonly soundPicker: Dropdown;
|
||
readonly playButton: Button;
|
||
readonly stopButton: Button;
|
||
private soundId = '';
|
||
/** One name and number per parameter of the Input: what the header's Play hands in. */
|
||
private readonly params = new PreviewParams();
|
||
/** The asset whose voices the tab may still be playing. */
|
||
private playedFor = '';
|
||
|
||
constructor(state: EditorState) {
|
||
super(state, { infoWidth: INFO_WIDTH });
|
||
this.soundPicker = new Dropdown('No sound', {
|
||
width: SOUND_WIDTH,
|
||
onClick: () => this.openSoundPicker(),
|
||
});
|
||
this.playButton = new Button('Play', { icon: TOOLBAR_ICON_SVG.play, onClick: () => this.play() });
|
||
this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() });
|
||
this.mount(new GraphCanvas(state, 'sound FX'), [
|
||
...this.snap.widgets,
|
||
this.soundPicker,
|
||
this.playButton,
|
||
this.stopButton,
|
||
this.params.bar,
|
||
]);
|
||
state.on('load', () => {
|
||
this.soundId = '';
|
||
this.params.forget();
|
||
});
|
||
}
|
||
|
||
override selectedId(): string {
|
||
return this.state.selectedSoundFxId ?? '';
|
||
}
|
||
|
||
override isMine(asset: Asset): asset is SoundFxAsset {
|
||
return asset.type === 'soundfx';
|
||
}
|
||
|
||
/** The sound or clip (#357) the header plays: the one picked, else the project's first. */
|
||
get previewSoundId(): string {
|
||
const sounds = soundChoices(this.state.project);
|
||
return sounds.some((s) => s.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? '');
|
||
}
|
||
|
||
pickSound(id: string): void {
|
||
this.soundId = id;
|
||
this.refresh();
|
||
}
|
||
|
||
/** What the header's parameter fields are set to – the check scripts read this. */
|
||
get previewValues(): Record<string, number> {
|
||
return this.params.current;
|
||
}
|
||
|
||
/** Sets one parameter the header plays with, as typing into its field does. */
|
||
setPreviewValue(key: string, value: number): void {
|
||
this.params.set(key, value);
|
||
}
|
||
|
||
/** Plays the picked sound through the graph; a graph that does not compile is not played at all. */
|
||
play(): void {
|
||
const asset = this.asset();
|
||
const sound = findSound(this.state.project, this.previewSoundId);
|
||
const plan = asset && soundFxBuildFor(asset, this.state.project).compiled;
|
||
if (!sound?.asset.file || !plan) return;
|
||
const props = resolveAssetProps(this.state.project, sound.asset);
|
||
const fx = { plan, params: this.params.current };
|
||
// Straight into the master: a preview leaves the routing of the sound out (#368).
|
||
const file = playedSoundOf(sound);
|
||
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, MASTER_MIX);
|
||
this.params.heard(voice);
|
||
this.playedFor = asset.id;
|
||
}
|
||
|
||
stop(): void {
|
||
soundLibrary.stopOwner(PREVIEW_OWNER);
|
||
this.params.silenced();
|
||
}
|
||
|
||
protected override closed(assetId: string): void {
|
||
if (assetId === this.playedFor) this.stop();
|
||
}
|
||
|
||
protected override infoText(asset: SoundFxAsset): string {
|
||
const build = soundFxBuildFor(asset, this.state.project);
|
||
if (!build.compiled) return build.reason;
|
||
return `${build.effects} ${build.effects === 1 ? 'effect' : 'effects'}`;
|
||
}
|
||
|
||
protected override syncExtras(asset: SoundFxAsset | null): void {
|
||
this.snap.setEnabled(Boolean(asset));
|
||
const id = this.previewSoundId;
|
||
const sound = findSound(this.state.project, id);
|
||
this.soundPicker.setText(sound ? assetRefLabel(this.state.project, id) : 'No sound');
|
||
this.soundPicker.setEnabled(Boolean(asset && sound));
|
||
this.playButton.setEnabled(
|
||
Boolean(asset && sound?.asset.file && soundFxBuildFor(asset, this.state.project).compiled),
|
||
);
|
||
this.stopButton.setEnabled(Boolean(asset));
|
||
this.params.sync(asset?.id ?? '', asset ? soundFxParams(asset.graph) : []);
|
||
}
|
||
|
||
private openSoundPicker(): void {
|
||
const current = this.previewSoundId;
|
||
const items = soundChoices(this.state.project).map((s) => ({
|
||
label: s.label,
|
||
current: s.id === current,
|
||
onSelect: () => this.pickSound(s.id),
|
||
}));
|
||
if (items.length > 0) new Menu(items, { searchable: true }).openBelow(this.soundPicker);
|
||
}
|
||
}
|