Files
feuerwerk/src/editor/fileImport.ts
T
admin 5671a38044
game-maker / suite (push) Successful in 12m1s
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
2026-09-15 07:54:28 +02:00

87 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type FontImportDeps, importFonts, isFontFileName } from './fontImport';
import { importSounds, isSoundFileName, type SoundImportDeps } from './soundImport';
import { importImages, type TextureImportDeps } from './textureImport';
export type FileImportDeps = TextureImportDeps & FontImportDeps & SoundImportDeps;
/**
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
*
* Pictures, typefaces and sounds arrive the same way, so one pair of handlers sorts them apart.
*/
export function installFileImport(target: HTMLElement, deps: FileImportDeps): () => void {
const onDragOver = (event: DragEvent) => {
if (!hasImportableItems(event.dataTransfer)) return;
// Without this the browser navigates away to the dropped file.
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
const files = sortFiles(event.dataTransfer);
if (files.none) {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
deps.state.setNotice('Only image, font and sound files can be dropped into the editor.');
return;
}
event.preventDefault();
run(files, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const files = sortFiles(event.clipboardData);
if (files.none) return;
event.preventDefault();
run(files, deps);
};
target.addEventListener('dragover', onDragOver);
target.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
target.removeEventListener('dragover', onDragOver);
target.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
}
interface SortedFiles {
images: File[];
fonts: File[];
sounds: File[];
none: boolean;
}
function sortFiles(data: DataTransfer | null): SortedFiles {
const all = [...(data?.files ?? [])];
const images = all.filter((file) => file.type.startsWith('image/'));
const fonts = all.filter((file) => isFontFileName(file.name));
const sounds = all.filter((file) => isSoundFileName(file.name));
return { images, fonts, sounds, none: images.length + fonts.length + sounds.length === 0 };
}
/** One kind after the other, so a mixed drop ends on one notice instead of three racing ones. */
function run(files: SortedFiles, deps: FileImportDeps): void {
const work = async () => {
if (files.images.length) await importImages(files.images, deps);
if (files.fonts.length) await importFonts(files.fonts, deps);
if (files.sounds.length) await importSounds(files.sounds, deps);
};
void work().catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
/**
* During a drag the files themselves are not readable yet – only kind and type are, and a font
* usually has no type at all. Anything that is not plainly text therefore counts as a candidate;
* the drop decides, and by then the names are readable.
*/
function hasImportableItems(data: DataTransfer | null): boolean {
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && !item.type.startsWith('text/'));
}
function isTextFieldFocused(): boolean {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
}