game-maker / suite (push) Successful in 12m1s
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.
87 lines
3.4 KiB
TypeScript
87 lines
3.4 KiB
TypeScript
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;
|
||
}
|