An agent that built a graph through MCP left every new node in one row. In the first real test that put nine nodes on a line 2080 px long, with the Owner's links running straight through all the others. Placing nodes by hand cost nine move_node calls and guessed heights. The agent now gives no coordinates, and the editor lays the nodes out. - arrange.ts is a pure function. It puts the flow into columns from left to right and sets an Owner or Get right before the first node it feeds. It orders each column for the fewest crossings and lines nodes up by their linked ports without overlaps. - Node sizes come from the drawn NodeView in the browser and from an estimate in unit tests. Both share the constants in nodeBox.ts. - run_commands arranges the nodes that came without x and y when the call ends, inside the same undo step. Nodes that were there stay. - New command arrange_graph and new tool lint_graph. The lint counts overlaps, crossings, links through foreign nodes, backward links and the longest links, with example ids. - The relay instructions tell the agent not to give coordinates and to fetch far values with a second Get or Owner node. - An Arrange button next to the snap magnet in all eight graph tabs. It arranges the selected nodes, or all of them, as one undo step. Found while testing on a real project: - lint_graph missed a crossing where two links swap rows exactly mirrored. Both curves then share a sample point in the middle, and only a crossing between two sample points was counted. Links that meet on a point count now (segmentsMeet). - The Arrow-key graph with one Owner for four Moves cannot get below 8 crossings. The Owner feeds object in row 2 of Move, between exec and dt of the key, so it always crosses one of them. Tests: - graph-arrange, agent-edit, snap-toggle: 33/33. The swapped-rows test was red before the fix (0 instead of 1). - Four counter-checks that bend arrange or the edit plan, all red. - e2e 71 on melektaus-atx: 18/18. Lint after the build shows 0 overlaps, 0 links through nodes, 0 backward. Arrange takes a tangled graph from 3 backward links and 2 crossings to 0, and one Ctrl+Z restores it. - Gate on melektaus-atx: CHECK EXIT 0, 318 files, 4917 tests, measured before the lint fix. - Live through MCP on the user's project: lint_graph reports 8 crossings after Arrange, matching the screenshot. 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, true);
|
||
/** 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);
|
||
}
|
||
}
|