import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs'; /** * Sound assets (#45): uploaded like a typeface – picked from what the server holds through * "+ → Sound", or dropped onto the editor – and played by a graph through `Play sound`. Nothing * wears a sound and nothing places it; the asset says how loud it is and whether it loops, and the * node scales the one and can overrule the other in either direction. * * Nothing is ever *heard* here: CI runs the browser muted (see tools/lib/browser.mjs). The witness * is `__gm.sound.stats()` – voices started, stopped and in flight – the way `renderStats` is the * witness for a repaint that did not happen. No pixel check could see any of this. * * The seed puts two generated WAVs on the server without making assets of them: a 0.25 s blip and * a 0.75 s drone, told apart by their exact size rather than by their name. */ const LANE = LANES.sound; const BLIP_BYTES = 4044; const DRONE_BYTES = 12044; export async function sound(t) { const { check, sleep, json, evaluate, mutate, click, clickText, press, play, stop, shot, showTab } = t; const { select } = t; const assetsOfType = (type) => json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)}) .map((a) => ({ id: a.id, name: a.name, file: a.file, props: a.props }))`); const stats = () => json('__gm.sound.stats()'); /** A voice starts only once its bytes have been decoded, so what it changes is awaited. */ const waitFor = async (read, done, tries = 25) => { let value = await read(); for (let i = 0; i < tries && !done(value); i++) { await sleep(150); value = await read(); } return value; }; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); await showTab('scene'); // 34a. What the server holds, straight from the API: the two files the seed put there. const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`); const blip = stored.find((item) => item.bytes === BLIP_BYTES); const drone = stored.find((item) => item.bytes === DRONE_BYTES); check( 'the server holds the two seeded sounds, named by their content', Boolean(blip && drone) && /^[a-f0-9]{64}\.wav$/.test(blip.file), JSON.stringify({ blip: blip?.file, drone: drone?.file, count: stored.length }), ); if (!blip || !drone) return; check( 'the listing carries the name each file was uploaded under', blip.name === 'blip.wav' && drone.name === 'drone.wav', JSON.stringify({ blip: blip.name, drone: drone.name }), ); // 34b. "+ → Sound" offers them; a sound is picked rather than created empty, like a typeface. await clickText('+'); await pickAssetType(t, 'Sound'); await sleep(500); const picker = await json('__gm.soundPicker()'); check( 'the + menu opens the sound picker on what the server holds, none of it in the project yet', Boolean(picker) && picker.files.includes(blip.file) && picker.used.length === 0, JSON.stringify(picker), ); check( 'a row nobody uses yet is captioned with the name the file was uploaded under', picker.captions[picker.files.indexOf(blip.file)] === 'blip.wav', JSON.stringify(picker.captions), ); await shot('34-sound-picker'); // 34c. The ▶ at a row's left is a preview: it plays that file without choosing it. const playAt = await json(`__gm.soundPickerPlay(${JSON.stringify(drone.file)})`); await click(playAt.x, playAt.y); const previewed = await waitFor(stats, (s) => s.started > 0); const afterPreview = await json('__gm.soundPicker()'); check( 'pressing a row’s ▶ plays that file and picks nothing', previewed.started === 1 && previewed.last?.file === drone.file && afterPreview.played === drone.file && afterPreview.selected === null, JSON.stringify({ previewed, selected: afterPreview.selected, played: afterPreview.played }), ); const row = await json(`__gm.soundPickerRow(${JSON.stringify(blip.file)})`); await click(row.x, row.y); await clickText('Use sound'); const sounds = await assetsOfType('sound'); check( 'picking a row makes a sound asset of that file, named after the file rather than "Sound"', sounds.length === 1 && sounds[0].file === blip.file && sounds[0].name === 'blip', JSON.stringify(sounds), ); if (sounds.length !== 1) return; const soundId = sounds[0].id; check( 'a fresh sound asset is full volume and one-shot', sounds[0].props.volume === 1 && sounds[0].props.loop === false, JSON.stringify(sounds[0].props), ); // Closing the picker silences its preview; nothing it started may outlive the dialog. check( 'closing the picker leaves nothing playing', (await stats()).playing === 0, JSON.stringify(await stats()), ); // 34d. Its inspector: the two rows, and the preview that is the only way to tell which sound it is. await select({ kind: 'asset', assetId: soundId }); const volumeRow = await json("__gm.inspectorField('Volume', true)"); const loopRow = await json("__gm.inspectorField('Loop', true)"); check( 'a sound asset has a Volume row and a Loop row', Boolean(volumeRow) && Boolean(loopRow), JSON.stringify({ volumeRow, loopRow }), ); await shot('34-sound-asset'); // 34e. A graph plays it. The behavior sits in its own lane; the node's volume is a factor on the // asset's, so 0.5 × 0.4 has to reach the library as 0.2 rather than as either of the two. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.volume = 0.4; const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); Object.assign(asset, { id: 'sn-logic', name: 'Noisy', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify({ nodes: [ node('sn-start', 'OnStart', 0, 0), node('sn-play', 'PlaySound', 260, 0, { sound: soundId, volume: 0.5, loop: '' }), ], edges: [edge('sn-e1', 'sn-start', 'exec', 'sn-play', 'exec')], })}, }); p.assets.unshift(asset); ${MAKE_OBJECT} scene.objects.push( make('sn-box', 'crate', '', ${LANE}, 'Noisy box'), make('sn-brain', 'sn-logic', 'sn-box', 0, 'Noisy'), ); __gm.lookAt(0, ${LANE});`); await evaluate('__gm.sound.stopAll()'); const before = await stats(); await play(); const played = await waitFor(stats, (s) => s.started > before.started); check( 'a graph playing a sound reaches the library, at the asset volume scaled by the node’s', played.started === before.started + 1 && played.last?.file === blip.file && Math.abs(played.last.volume - 0.2) < 0.001 && played.last.loop === false, JSON.stringify(played), ); await stop(); // 34f. A looping sound is music: it plays on, and ending the run ends it – nothing may outlive // the world that started it. await mutate(` const p = __gm.state.project; p.assets.find((a) => a.id === ${JSON.stringify(soundId)}).props.loop = true;`); await play(); const looping = await waitFor(stats, (s) => s.playing > 0 && s.last?.loop === true); check( 'the asset’s own loop flag reaches the library even though the node never asked for one', looping.playing >= 1 && looping.last?.loop === true, JSON.stringify(looping), ); // Well past the blip's own 0.25 s, so a one-shot would have ended by itself here. await sleep(700); const stillLooping = await stats(); await stop(); const afterStop = await stats(); check( 'a loop keeps playing while the run is on and ends with it', stillLooping.playing >= 1 && afterStop.playing === 0, JSON.stringify({ stillLooping, afterStop }), ); // 34f-2. The node overrules the asset in both directions – the reason `loop` is a choice of three // and not a checkbox: music has to be playable as a one-shot. const setNodeLoop = (value) => mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-play').params.loop = ${JSON.stringify(value)};`); await setNodeLoop('false'); const beforeOnce = await stats(); await play(); const once = await waitFor(stats, (s) => s.started > beforeOnce.started); check('a node saying "off" plays a looping asset once', once.last?.loop === false, JSON.stringify(once)); await stop(); await setNodeLoop(''); // 34g. `Stop sound` silences it from inside the graph, without ending the run. await mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push(${JSON.stringify(node('sn-stop', 'StopSound', 520, 0, { sound: soundId }))}); graph.nodes.push(${JSON.stringify(node('sn-key', 'OnKeyDown', 260, 220, { key: 's' }))}); graph.edges.push(${JSON.stringify(edge('sn-e2', 'sn-key', 'exec', 'sn-stop', 'exec'))});`); await play(); await waitFor(stats, (s) => s.playing > 0); await press('s'); const silenced = await waitFor(stats, (s) => s.playing === 0); check( 'Stop sound silences a looping sound while the run carries on', silenced.playing === 0 && (await json('__gm.playInfo()')).playing === true, JSON.stringify(silenced), ); await stop(); // 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it, // without any `Stop sound` in the graph. `detached` is the one way out, for the death // sound of an object that destroys itself. await mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push( ${JSON.stringify(node('sn-key2', 'OnKeyDown', 260, 440, { key: 'd' }))}, ${JSON.stringify(node('sn-owner', 'Owner', 260, 620))}, ${JSON.stringify(node('sn-destroy', 'Destroy', 520, 440))}, ); graph.edges.push( ${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))}, ${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))}, );`); await play(); await waitFor(stats, (s) => s.playing > 0); await press('d'); const destroyed = await waitFor(stats, (s) => s.playing === 0); check( 'destroying the object silences the looping sound its behavior was playing', destroyed.playing === 0 && (await json('__gm.playInfo()')).playing === true, JSON.stringify(destroyed), ); await stop(); const setDetached = (value) => mutate(` const p = __gm.state.project; const graph = p.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`); await setDetached(true); await play(); await waitFor(stats, (s) => s.playing > 0); await press('d'); // Long enough that the reaping frame has been and gone – a voice still here now is here on purpose. await sleep(600); const detached = await stats(); check( 'a detached voice is nobody’s and plays on after the object that started it is gone', detached.playing >= 1, JSON.stringify(detached), ); await stop(); check( 'ending the run silences a detached voice too', (await stats()).playing === 0, JSON.stringify(await stats()), ); await setDetached(false); // 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph // from compiling; built into Input → Pitch → Gain → Output, the header plays the sound // through it, and a `Play sound` naming it sends the game's voice the same way. The pitch // comes from a parameter `depth` of the Input: the header has a field for it, and a // `Sound FX parameters` node hands the game's value in. await showTab('scene'); await clickText('+'); await pickAssetType(t, 'Sound FX'); const fxCreated = await json(`{ tab: __gm.tab(), current: __gm.soundfx.current(), info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), assets: __gm.state.project.assets.filter((a) => a.type === 'soundfx').map((a) => a.id), }`); check( 'the + menu creates a sound FX asset and opens its tab, with no Output to compile and no Play', fxCreated.tab === 'soundfx' && fxCreated.assets.length === 1 && fxCreated.current === fxCreated.assets[0] && fxCreated.info === 'No Output node' && fxCreated.preview.sound === soundId && fxCreated.preview.play === false, JSON.stringify(fxCreated), ); const fxId = fxCreated.assets[0]; const fxCanvas = await json('__gm.soundfx.canvasRect()'); await click(fxCanvas.x + 80, fxCanvas.y + 60, 'right'); const fxPalette = await json('__gm.overlayTexts()'); check( 'its palette offers the sound effects and no behavior node', ['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ', 'Mix'].every((label) => fxPalette.includes(label)) && !fxPalette.includes('Branch'), JSON.stringify(fxPalette.slice(0, 12)), ); await press('Escape'); await mutate(` const fx = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}); fx.name = 'Low'; fx.graph = ${JSON.stringify({ nodes: [ node('fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'depth', kind: 'number', default: -12 }]), node('fx-pitch', 'SoundFxPitch', 220, 0), node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }), node('fx-out', 'SoundFxOutput', 660, 0), ], edges: [ edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'), edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'), edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'), edge('fx-e4', 'fx-in', 'depth', 'fx-pitch', 'semitones'), ], })};`); const fxBuilt = await json( '{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), params: __gm.soundfx.params() }', ); check( 'Input → Pitch → Gain → Output compiles to two effects, Play is offered, depth starts at its default', fxBuilt.info === '2 effects' && fxBuilt.preview.play === true && fxBuilt.params.depth === -12, JSON.stringify(fxBuilt), ); await evaluate('__gm.sound.stopAll()'); const beforeFx = await stats(); const fxPlayAt = await json('__gm.soundfx.playAt()'); await click(fxPlayAt.x, fxPlayAt.y); const heardFx = await waitFor(stats, (s) => s.started > beforeFx.started); const blipSeconds = await json(`__gm.sound.duration(${JSON.stringify(blip.file)})`); check( 'the tab’s Play sends the sound through the graph: an octave down, as long as before, one gain stage', heardFx.last?.file === blip.file && heardFx.last.pitches.join() === '-12' && heardFx.last.duration === blipSeconds && heardFx.last.stages === 1, JSON.stringify({ ...heardFx, blipSeconds }), ); await evaluate('__gm.sound.stopAll()'); await evaluate('__gm.soundfx.setParam("depth", 5)'); const beforeParam = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started); check( 'the header plays with the value its depth field is set to', heardParam.last?.pitches.join() === '5' && heardParam.last.stages === 1, JSON.stringify(heardParam), ); await evaluate('__gm.sound.stopAll()'); // #316: the Pitch → Gain way and the dry Input meet in a Mix – two sources, one per pitch. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-mix', 'SoundFxMix', 550, 0, { mix: 0.5 }))}); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3'); graph.edges.push( ${JSON.stringify(edge('fx-e5', 'fx-gain', 'out', 'fx-mix', 'a'))}, ${JSON.stringify(edge('fx-e6', 'fx-in', 'out', 'fx-mix', 'b'))}, ${JSON.stringify(edge('fx-e7', 'fx-mix', 'out', 'fx-out', 'in'))}, );`); const mixInfo = await json('__gm.soundfx.info()'); const beforeMix = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const heardMix = await waitFor(stats, (s) => s.started > beforeMix.started); check( 'a Mix of the pitched way and the dry Input plays one voice from two sources of the same length', mixInfo === '3 effects' && heardMix.last?.pitches.join() === '5,0' && heardMix.last.stages === 1 && heardMix.last.duration === blipSeconds, JSON.stringify({ mixInfo, ...heardMix }), ); await evaluate('__gm.sound.stopAll()'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-mix'); graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); await showTab('scene'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: 'false', fx: ${JSON.stringify(fxId)} });`); const beforeGameFx = await stats(); await play(); const gameFx = await waitFor(stats, (s) => s.started > beforeGameFx.started); check( 'a Play sound naming the asset plays the game’s voice through it', gameFx.last?.pitches.join() === '-12' && gameFx.last.stages === 1, JSON.stringify(gameFx), ); await stop(); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.push(${JSON.stringify( node('sn-fxp', 'SoundFxParameters', 0, 120, { soundFxAsset: fxId, depth: 7 }, [ { key: 'depth', kind: 'number' }, ]), )}); graph.edges.push(${JSON.stringify(edge('sn-e-fxp', 'sn-fxp', 'params', 'sn-play', 'fxParams'))});`); const beforeGameParam = await stats(); await play(); const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started); check( 'a Sound FX parameters node hands depth 7 to the game’s voice', gameParam.last?.pitches.join() === '7' && gameParam.last.stages === 1, JSON.stringify(gameParam), ); await stop(); // #315: two looping voices of the same sound start at depth 7, the first keeps its number in a // variable, and `Set sound FX parameters` hands that one voice depth 3 every frame. In a // real AudioContext the first one's speed glides there, the second one's stays put. const tuneIds = ['sn-keep', 'sn-play2', 'sn-tick', 'sn-read', 'sn-tune', 'sn-fxp2']; const tuneEdges = [ 'sn-e-keep', 'sn-e-voice', 'sn-e-play2', 'sn-e-fxp3', 'sn-e-tick', 'sn-e-read', 'sn-e-fxp2', ]; await mutate(` const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic'); logic.variables = [${JSON.stringify(variable('voice', 'number', 0))}]; const graph = logic.graph; const play = graph.nodes.find((n) => n.id === 'sn-play'); play.params.loop = 'true'; graph.nodes.push( ${JSON.stringify(node('sn-keep', 'Variables', 520, 0, {}, [written('voice', 'number')]))}, { ...structuredClone(play), id: 'sn-play2', x: 780 }, ${JSON.stringify(node('sn-tick', 'OnUpdate', 0, 300))}, ${JSON.stringify(node('sn-read', 'Variables', 0, 420, {}, [port('voice', 'number')]))}, ${JSON.stringify(node('sn-tune', 'SetSoundFxParameters', 260, 300))}, ${JSON.stringify( node('sn-fxp2', 'SoundFxParameters', 0, 540, { soundFxAsset: fxId, depth: 3 }, [ { key: 'depth', kind: 'number' }, ]), )}, ); graph.edges.push( ${JSON.stringify(edge('sn-e-keep', 'sn-play', 'exec', 'sn-keep', 'exec'))}, ${JSON.stringify(edge('sn-e-voice', 'sn-play', 'voice', 'sn-keep', 'voice'))}, ${JSON.stringify(edge('sn-e-play2', 'sn-keep', 'exec', 'sn-play2', 'exec'))}, ${JSON.stringify(edge('sn-e-fxp3', 'sn-fxp', 'params', 'sn-play2', 'fxParams'))}, ${JSON.stringify(edge('sn-e-tick', 'sn-tick', 'exec', 'sn-tune', 'exec'))}, ${JSON.stringify(edge('sn-e-read', 'sn-read', 'voice', 'sn-tune', 'voice'))}, ${JSON.stringify(edge('sn-e-fxp2', 'sn-fxp2', 'params', 'sn-tune', 'fxParams'))}, );`); const beforeTune = await stats(); await play(); const tunedStats = await waitFor( stats, (s) => s.tuned > beforeTune.tuned && s.started >= beforeTune.started + 2, ); const rate = 2 ** ((3 - 7) / 12); const glided = (levels) => Array.isArray(levels) && levels.length === 2 && Math.abs(levels[0][0] - rate) < 0.01; const levels = await waitFor(() => json(`__gm.sound.levels(${JSON.stringify(blip.file)})`), glided); check( 'Set sound FX parameters moves the one voice whose number it holds from depth 7 to 3, not the other', tunedStats.last?.pitches.join() === '7' && tunedStats.last.loop === true && glided(levels) && Math.abs(levels[0][1] - 10 ** (-6 / 20)) < 0.001 && levels[1][0] === 1, JSON.stringify({ last: tunedStats.last, tuned: tunedStats.tuned, levels, rate }), ); await stop(); // A `time` of 2 s takes the same voice from depth 7 to -5 evenly on the audio clock: half a // second in it is still well above the end (speed 0.5), where a quick glide would be there by then. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes.find((n) => n.id === 'sn-tune').params.time = 2; graph.nodes.find((n) => n.id === 'sn-fxp2').params.depth = -5;`); await play(); const speed = async () => (await json(`__gm.sound.levels(${JSON.stringify(blip.file)})`))?.[0]?.[0]; const early = await waitFor(speed, (s) => typeof s === 'number' && s < 0.99); const later = await waitFor(speed, (s) => typeof s === 'number' && s < early - 0.05); await new Promise((resolve) => setTimeout(resolve, 500)); const afterHalf = await speed(); check( 'Set sound FX parameters with a time ramps the pitch over that time instead of jumping', early > 0.6 && later > 0.55 && afterHalf > 0.52 && afterHalf < later, JSON.stringify({ early, later, afterHalf }), ); await stop(); // The easings of an animation key: Quad in hands the clock a curve, which is still near the start // 0.7 s in and only then falls to the end (speed 0.5). Read a little later than that for the // round trips, it is above 0.8 until 1.1 s; linear is below 0.79 from 0.7 s on. await mutate(` const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune'); Object.assign(tune.params, { ease: 'quad', direction: 'in' });`); await play(); await waitFor(speed, (s) => typeof s === 'number'); await new Promise((resolve) => setTimeout(resolve, 700)); const easedEarly = await speed(); const easedEnd = await waitFor(speed, (s) => typeof s === 'number' && s < 0.52); check( 'Set sound FX parameters with Quad in ramps slowly first, then on to the end', easedEarly > 0.8 && easedEarly < 0.995 && easedEnd < 0.52, JSON.stringify({ easedEarly, easedEnd }), ); await stop(); // Step holds the pitch for the whole time and then jumps there. await mutate(` const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune'); Object.assign(tune.params, { ease: 'step', time: 1 });`); await play(); await waitFor(speed, (s) => typeof s === 'number'); await new Promise((resolve) => setTimeout(resolve, 500)); const stepHeld = await speed(); const stepEnd = await waitFor(speed, (s) => typeof s === 'number' && s < 0.9); check( 'Set sound FX parameters with Step holds the pitch for the time, then jumps to the end', stepHeld === 1 && Math.abs(stepEnd - 0.5) < 0.001, JSON.stringify({ stepHeld, stepEnd }), ); await stop(); await mutate(` const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic'); logic.variables = []; const graph = logic.graph; graph.nodes = graph.nodes.filter((n) => !${JSON.stringify(tuneIds)}.includes(n.id)); graph.edges = graph.edges.filter((e) => !${JSON.stringify(tuneEdges)}.includes(e.id));`); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp'); graph.edges = graph.edges.filter((e) => e.id !== 'sn-e-fxp'); Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' }); const project = __gm.state.project; project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`); // 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file. const dropped = await evaluate(` (async () => { const bytes = await fetch('/game-maker/data/sounds/${drone.file}').then((r) => r.arrayBuffer()); const file = new File([bytes], 'drone.wav', { type: 'audio/wav' }); const data = new DataTransfer(); data.items.add(file); const canvas = document.querySelector('canvas'); canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true })); return file.size; })()`); await waitFor( () => assetsOfType('sound'), (list) => list.length === 2, ); const both = await assetsOfType('sound'); check( 'a dropped sound is uploaded and becomes an asset named after the file', dropped === DRONE_BYTES && both.length === 2 && both.some((a) => a.file === drone.file && a.name === 'drone'), JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }), ); // 34i. The step takes back everything it made, so the steps after it see the fixture again. await mutate(` const gone = new Set(['sn-logic', ...${JSON.stringify(both.map((a) => a.id))}]); const project = __gm.state.project; project.assets = project.assets.filter((a) => !gone.has(a.id)); __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter( (o) => !['sn-box', 'sn-brain'].includes(o.id)); __gm.state.select(null); __gm.lookAt(0, 0);`); check( 'the step leaves nothing of its own behind', (await assetsOfType('sound')).length === 0 && (await stats()).playing === 0, JSON.stringify(await assetsOfType('sound')), ); }