A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s
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.
This commit is contained in:
1 parent
7babf88636
commit
5671a38044
47 files changed
+2066
-42
No files matched your search
+6
-1
@@ -1,9 +1,11 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { HttpFontStore } from '../core/store/HttpFontStore';
|
||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||
import { HttpSoundStore } from '../core/store/HttpSoundStore';
|
||||
import { HttpTextureStore } from '../core/store/HttpTextureStore';
|
||||
import { requestRender, type VStack } from '../ui';
|
||||
import { fontLibrary } from '../viewport/FontLibrary';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import { installDebugHook } from './debugHook';
|
||||
import { Editor } from './Editor';
|
||||
@@ -13,16 +15,18 @@ const API_ENDPOINT = '/game-maker/api/index.php';
|
||||
|
||||
/**
|
||||
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
|
||||
* static files named by their content hash. Fonts work the same way, one directory over.
|
||||
* static files named by their content hash. Fonts and sounds work the same way, one directory over.
|
||||
*/
|
||||
const TEXTURE_BASE = '/game-maker/data/textures';
|
||||
const FONT_BASE = '/game-maker/data/fonts';
|
||||
const SOUND_BASE = '/game-maker/data/sounds';
|
||||
|
||||
/** Wires state, stores and the editor composition together and opens the first project. */
|
||||
export async function startEditor(app: Application, root: VStack): Promise<void> {
|
||||
const state = new EditorState();
|
||||
textureLibrary.configure(TEXTURE_BASE);
|
||||
fontLibrary.configure(FONT_BASE);
|
||||
soundLibrary.configure(SOUND_BASE);
|
||||
const editor = new Editor(
|
||||
app,
|
||||
root,
|
||||
@@ -30,6 +34,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
new HttpTextureStore(API_ENDPOINT),
|
||||
new HttpFontStore(API_ENDPOINT),
|
||||
new HttpSoundStore(API_ENDPOINT),
|
||||
);
|
||||
// An image or a font arrives long after the click that asked for it, and the widget waiting for
|
||||
// it repaints without telling anyone: both libraries are a wake-up of their own.
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { FontStore } from '../core/store/FontStore';
|
||||
import type { SoundStore } from '../core/store/SoundStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import { gameClock } from '../core/time';
|
||||
import {
|
||||
@@ -151,6 +152,7 @@ export class Editor implements PlayHost {
|
||||
store: ProjectStore,
|
||||
textures: TextureStore,
|
||||
fonts: FontStore,
|
||||
sounds: SoundStore,
|
||||
) {
|
||||
this.viewport = new SceneViewport(state);
|
||||
this.gameView = new GameViewport(state);
|
||||
@@ -196,9 +198,9 @@ export class Editor implements PlayHost {
|
||||
|
||||
this.workspace = new HStack();
|
||||
this.leftColumn = new VStack({ gap: 1 });
|
||||
this.assets = new AssetPanel(state, textures, fonts);
|
||||
this.assets = new AssetPanel(state, textures, fonts, sounds);
|
||||
this.leftColumn.add(this.assets, 1);
|
||||
installFileImport(app.canvas, { state, textures, fonts });
|
||||
installFileImport(app.canvas, { state, textures, fonts, sounds });
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
|
||||
this.centre = new VStack();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
|
||||
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { type ModelWrite, World } from '../runtime/World';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import type { LiveMachineState } from './graph/GraphCanvas';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
@@ -119,7 +120,7 @@ export class EditorRun {
|
||||
// Baking a container's content into the scene would be an edit nobody asked for, so it is off
|
||||
// here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`).
|
||||
const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
|
||||
this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props));
|
||||
this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props), soundLibrary);
|
||||
this.world = fresh;
|
||||
this.hadFocus = this.hooks.focused();
|
||||
this.input.attach(this.hooks.focused);
|
||||
@@ -153,6 +154,9 @@ export class EditorRun {
|
||||
|
||||
private stop(): void {
|
||||
if (!this.world) return;
|
||||
// Its sounds end with it, for the reason a played run's do – an editor run is restarted by any
|
||||
// edit, and a loop left behind would pile up one voice per edit.
|
||||
soundLibrary.stopAll();
|
||||
this.world.destroy();
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { gameClock } from '../core/time';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { World } from '../runtime/World';
|
||||
import { ui } from '../ui';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import type { GameViewport } from './viewport/GameViewport';
|
||||
import type { GraphEditor } from './graph/GraphEditor';
|
||||
import type { LiveMachineState } from './graph/GraphCanvas';
|
||||
@@ -72,6 +73,9 @@ export class PlaySession {
|
||||
togglePause(): void {
|
||||
if (!this.world) return;
|
||||
this.state.setPaused(!this.state.paused);
|
||||
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
|
||||
// on mid-note, rather than playing to their end over a still image.
|
||||
soundLibrary.setSuspended(this.state.paused);
|
||||
ui().focus.blur();
|
||||
this.state.setNotice(this.playNotice(), 'quiet');
|
||||
}
|
||||
@@ -109,7 +113,7 @@ export class PlaySession {
|
||||
const world = World.instantiate(runtime, scene.id);
|
||||
this.world = world;
|
||||
this.input.attach();
|
||||
this.behaviors = addGameSystems(world, runtime, this.input);
|
||||
this.behaviors = addGameSystems(world, runtime, this.input, undefined, soundLibrary);
|
||||
this.host.gameView.setWorld(world);
|
||||
this.host.showGame();
|
||||
this.state.setNotice(this.playNotice(), 'quiet');
|
||||
@@ -126,6 +130,9 @@ export class PlaySession {
|
||||
stop(): void {
|
||||
// The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene.
|
||||
gameClock.reset();
|
||||
// And its sounds end with it, looping music included – nothing may outlive the world.
|
||||
soundLibrary.setSuspended(false);
|
||||
soundLibrary.stopAll();
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
|
||||
@@ -7,9 +7,11 @@ import { effectiveScale, gameClock } from '../core/time';
|
||||
import { activeDrag, Button, isRendering, renderCount, requestRender, ui, Widget } from '../ui';
|
||||
import { particleLibrary } from '../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../viewport/ShaderLibrary';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import type { CentreTab, Editor } from './Editor';
|
||||
import { currentFontPicker } from './font/FontPicker';
|
||||
import { currentSoundPicker } from './sound/SoundPicker';
|
||||
import { currentImagePicker } from './texture/ImagePicker';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
@@ -361,6 +363,22 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
/** The same for the font picker, whose rows are the preview. */
|
||||
fontPicker: () => currentFontPicker()?.debugInfo() ?? null,
|
||||
fontPickerRow: (file: string) => currentFontPicker()?.rowGlobal(file) ?? null,
|
||||
/** And for the sound picker, whose rows carry a ▶ of their own. */
|
||||
soundPicker: () => currentSoundPicker()?.debugInfo() ?? null,
|
||||
soundPickerRow: (file: string) => currentSoundPicker()?.rowGlobal(file) ?? null,
|
||||
soundPickerPlay: (file: string) => currentSoundPicker()?.playGlobal(file) ?? null,
|
||||
/**
|
||||
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
|
||||
* is ever heard there: these counters are the only way a check can see that a graph played
|
||||
* anything at all, the way `renderStats` is for a repaint that did not happen.
|
||||
*/
|
||||
sound: {
|
||||
stats: () => soundLibrary.stats(),
|
||||
duration: (file: string) => soundLibrary.duration(file),
|
||||
/** Decodes ahead of a check, so a play can be asserted without racing the network. */
|
||||
load: (file: string) => soundLibrary.load(file).then((buffer) => buffer?.duration ?? null),
|
||||
stopAll: () => soundLibrary.stopAll(),
|
||||
},
|
||||
/** Which typeface a text ended up drawn in, and how wide it made it. */
|
||||
textInfo: (id: string) => viewport.textInfo(id),
|
||||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||||
|
||||
+30
-23
@@ -1,13 +1,14 @@
|
||||
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;
|
||||
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 and typefaces arrive the same way, so one pair of handlers sorts them apart.
|
||||
* 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) => {
|
||||
@@ -17,24 +18,22 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): ()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
const onDrop = (event: DragEvent) => {
|
||||
const images = imageFilesOf(event.dataTransfer);
|
||||
const fonts = fontFilesOf(event.dataTransfer);
|
||||
if (images.length === 0 && fonts.length === 0) {
|
||||
const files = sortFiles(event.dataTransfer);
|
||||
if (files.none) {
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
event.preventDefault();
|
||||
deps.state.setNotice('Only image and font files can be dropped into the editor.');
|
||||
deps.state.setNotice('Only image, font and sound files can be dropped into the editor.');
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
run(images, fonts, deps);
|
||||
run(files, deps);
|
||||
};
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (isTextFieldFocused()) return;
|
||||
const images = imageFilesOf(event.clipboardData);
|
||||
const fonts = fontFilesOf(event.clipboardData);
|
||||
if (images.length === 0 && fonts.length === 0) return;
|
||||
const files = sortFiles(event.clipboardData);
|
||||
if (files.none) return;
|
||||
event.preventDefault();
|
||||
run(images, fonts, deps);
|
||||
run(files, deps);
|
||||
};
|
||||
|
||||
target.addEventListener('dragover', onDragOver);
|
||||
@@ -47,23 +46,31 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): ()
|
||||
};
|
||||
}
|
||||
|
||||
/** Images first, then fonts, so a mixed drop ends on one notice instead of two racing ones. */
|
||||
function run(images: readonly File[], fonts: readonly File[], deps: FileImportDeps): void {
|
||||
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 (images.length) await importImages(images, deps);
|
||||
if (fonts.length) await importFonts(fonts, deps);
|
||||
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'));
|
||||
}
|
||||
|
||||
function imageFilesOf(data: DataTransfer | null): File[] {
|
||||
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
|
||||
}
|
||||
|
||||
function fontFilesOf(data: DataTransfer | null): File[] {
|
||||
return [...(data?.files ?? [])].filter((file) => isFontFileName(file.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
|
||||
import { FontPreview } from '../font/FontPreview';
|
||||
import { SoundPreview } from '../sound/SoundPreview';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
@@ -76,6 +77,11 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
|
||||
host.body.add(
|
||||
asset.file ? new FontPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
|
||||
);
|
||||
// A sound has two rows above this; the preview is the only way to find out which sound it is.
|
||||
if (asset.type === 'sound')
|
||||
host.body.add(
|
||||
asset.file ? new SoundPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
|
||||
|
||||
@@ -237,6 +237,11 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
choices: (project) =>
|
||||
assetRefChoices(project, ['statemachine']).map((a) => ({ id: a.id, label: a.name })),
|
||||
},
|
||||
sound: {
|
||||
none: NO_ASSET_LABEL,
|
||||
label: assetRefLabel,
|
||||
choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })),
|
||||
},
|
||||
};
|
||||
|
||||
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
|
||||
@@ -362,6 +367,7 @@ export function createField(
|
||||
case 'asset':
|
||||
case 'texture':
|
||||
case 'statemachine':
|
||||
case 'sound':
|
||||
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ import { findAsset } from '../../core/model/Project';
|
||||
import { spriteFromTexture } from '../../core/properties/aspect';
|
||||
import { assetDeviates } from '../../core/properties/deviation';
|
||||
import type { FontStore } from '../../core/store/FontStore';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import type { TextureStore } from '../../core/store/TextureStore';
|
||||
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
|
||||
import { confirmDeleteAsset } from '../actions';
|
||||
import type { EditorState } from '../state';
|
||||
import { openFontPicker } from '../font/FontPicker';
|
||||
import { openSoundPicker } from '../sound/SoundPicker';
|
||||
import { openImagePicker } from '../texture/ImagePicker';
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,8 @@ export class AssetPanel extends Panel {
|
||||
private readonly textures: TextureStore | null = null,
|
||||
/** The same for typefaces and the Font entry. */
|
||||
private readonly fonts: FontStore | null = null,
|
||||
/** And for sounds and the Sound entry. */
|
||||
private readonly sounds: SoundStore | null = null,
|
||||
) {
|
||||
super({ title: 'Assets', padding: 0, gap: 0 });
|
||||
this.minWidth = 160;
|
||||
@@ -100,12 +104,14 @@ export class AssetPanel extends Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture is a picture and a font a file, so their menu entries offer what the server already
|
||||
* holds instead of creating an empty asset. Uploading a new one is a button inside that dialog.
|
||||
* A texture is a picture, a font and a sound are files, so their menu entries offer what the
|
||||
* server already holds instead of creating an empty asset. Uploading a new one is a button
|
||||
* inside that dialog.
|
||||
*/
|
||||
private addAsset(type: AssetType): void {
|
||||
if (type === 'texture') this.importTextures();
|
||||
else if (type === 'font') this.importFonts();
|
||||
else if (type === 'sound') this.importSounds();
|
||||
else this.createAsset(type);
|
||||
}
|
||||
|
||||
@@ -125,6 +131,14 @@ export class AssetPanel extends Panel {
|
||||
openFontPicker({ state: this.state, fonts: this.fonts });
|
||||
}
|
||||
|
||||
private importSounds(): void {
|
||||
if (!this.sounds) {
|
||||
this.state.setNotice('Sound upload is not available.');
|
||||
return;
|
||||
}
|
||||
openSoundPicker({ state: this.state, sounds: this.sounds });
|
||||
}
|
||||
|
||||
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
|
||||
private activate(id: string): void {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import type { StoredSound } from '../../core/store/SoundStore';
|
||||
import {
|
||||
Button,
|
||||
drawControlBox,
|
||||
formatBytes,
|
||||
HStack,
|
||||
Label,
|
||||
Popup,
|
||||
pressCount,
|
||||
ScrollContainer,
|
||||
type Size,
|
||||
Spacer,
|
||||
Theme,
|
||||
textStyle,
|
||||
truncate,
|
||||
Widget,
|
||||
} from '../../ui';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import { type SoundImportDeps, importStoredSound, pickAndImportSounds } from '../soundImport';
|
||||
|
||||
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||||
const LIST_WIDTH = 560;
|
||||
const LIST_HEIGHT = 340;
|
||||
const ROW_HEIGHT = 44;
|
||||
const ROW_GAP = 4;
|
||||
const ROW_PAD = 8;
|
||||
/** The play glyph's column at the left of a row: a press in here plays instead of selecting. */
|
||||
const PLAY_WIDTH = 34;
|
||||
|
||||
/** One offered sound, with the asset that already uses it (if any). */
|
||||
interface PickerItem extends StoredSound {
|
||||
assetName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows. A sound has nothing to show, so each row shows what is known about the file and plays
|
||||
* it on demand: the ▶ at its left is the preview, the rest of the row is the pick.
|
||||
*/
|
||||
class SoundList extends Widget {
|
||||
private items: PickerItem[] = [];
|
||||
private rows: { box: Container; background: Graphics; play: Text; caption: Text; detail: Text }[] = [];
|
||||
private selectedFile: string | null = null;
|
||||
private readonly empty = new Text({ text: '', style: textStyle('muted') });
|
||||
onPick?: (file: string) => void;
|
||||
onActivate?: (file: string) => void;
|
||||
emptyText = '';
|
||||
/** The last file a ▶ started, so a check can tell a preview from a pick (test hook). */
|
||||
lastPlayed: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.empty);
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
|
||||
}
|
||||
|
||||
setItems(items: PickerItem[]): void {
|
||||
this.items = items;
|
||||
for (const row of this.rows) row.box.destroy({ children: true });
|
||||
this.rows = items.map(() => {
|
||||
// A Container holding the background, not a Graphics with children: Pixi v8 only lets
|
||||
// containers have them (`addChild: Only Containers will be allowed to add children`).
|
||||
const box = new Container();
|
||||
const background = new Graphics();
|
||||
const play = new Text({ text: '▶', style: textStyle('accent') });
|
||||
const caption = new Text({ style: textStyle('body') });
|
||||
const detail = new Text({ style: textStyle('small') });
|
||||
box.addChild(background, play, caption, detail);
|
||||
this.addChild(box);
|
||||
return { box, background, play, caption, detail };
|
||||
});
|
||||
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
|
||||
this.layout();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get selected(): PickerItem | null {
|
||||
return this.items.find((item) => item.file === this.selectedFile) ?? null;
|
||||
}
|
||||
|
||||
get fileList(): string[] {
|
||||
return this.items.map((item) => item.file);
|
||||
}
|
||||
|
||||
get usedFiles(): string[] {
|
||||
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
|
||||
}
|
||||
|
||||
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
|
||||
get captions(): string[] {
|
||||
return this.rows.map((row) => row.caption.text);
|
||||
}
|
||||
|
||||
select(file: string | null): void {
|
||||
if (file === this.selectedFile) return;
|
||||
this.selectedFile = file;
|
||||
this.redraw();
|
||||
if (file) this.onPick?.(file);
|
||||
}
|
||||
|
||||
/** Global centre of a row, clear of the ▶ column; null when the file is not offered (test hook). */
|
||||
rowGlobal(file: string): { x: number; y: number } | null {
|
||||
const index = this.items.findIndex((item) => item.file === file);
|
||||
const row = this.rows[index];
|
||||
if (!row) return null;
|
||||
const p = row.box.getGlobalPosition();
|
||||
return { x: p.x + PLAY_WIDTH + (this.w - PLAY_WIDTH) / 2, y: p.y + ROW_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
/** Global centre of a row's ▶ – what a check presses to hear one without choosing it. */
|
||||
playGlobal(file: string): { x: number; y: number } | null {
|
||||
const index = this.items.findIndex((item) => item.file === file);
|
||||
const row = this.rows[index];
|
||||
if (!row) return null;
|
||||
const p = row.box.getGlobalPosition();
|
||||
return { x: p.x + PLAY_WIDTH / 2, y: p.y + ROW_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.empty.text = this.items.length === 0 ? this.emptyText : '';
|
||||
this.empty.position.set(0, 0);
|
||||
|
||||
this.rows.forEach((node, index) => {
|
||||
const item = this.items[index];
|
||||
if (!item) return;
|
||||
const selected = item.file === this.selectedFile;
|
||||
node.background.clear();
|
||||
drawControlBox(node.background, this.w, ROW_HEIGHT, {
|
||||
fill: selected ? Theme.colors.selection : Theme.colors.control,
|
||||
border: selected ? Theme.colors.accent : Theme.colors.border,
|
||||
});
|
||||
node.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - node.play.height) / 2);
|
||||
|
||||
// The asset name wins where there is one – it is what the project calls this sound. Otherwise
|
||||
// the name it was uploaded under, which is all a bare hash leaves to go on.
|
||||
node.caption.style = textStyle(item.assetName ? 'accent' : 'body');
|
||||
node.caption.text = truncate(
|
||||
item.assetName ?? item.name,
|
||||
node.caption.style,
|
||||
this.w - PLAY_WIDTH - ROW_PAD,
|
||||
);
|
||||
node.caption.position.set(PLAY_WIDTH, 6);
|
||||
|
||||
node.detail.text = truncate(
|
||||
item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes),
|
||||
node.detail.style,
|
||||
this.w - PLAY_WIDTH - ROW_PAD,
|
||||
);
|
||||
node.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16);
|
||||
});
|
||||
}
|
||||
|
||||
private onPress(e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP));
|
||||
const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined;
|
||||
if (!item) return;
|
||||
if (local.x < PLAY_WIDTH) {
|
||||
this.lastPlayed = item.file;
|
||||
soundLibrary.play(item.file, 1, false);
|
||||
return;
|
||||
}
|
||||
this.select(item.file);
|
||||
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
|
||||
}
|
||||
}
|
||||
|
||||
function heightFor(count: number): number {
|
||||
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers every sound the server holds, the way the font picker offers typefaces: uploading the same
|
||||
* file twice is wasteful, and nothing collects the files a project stopped referencing.
|
||||
*/
|
||||
export class SoundPicker extends Popup {
|
||||
private readonly list = new SoundList();
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly status: Label;
|
||||
private readonly detail: Label;
|
||||
private readonly use: Button;
|
||||
|
||||
constructor(private readonly deps: SoundImportDeps) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.content.add(new Label('Choose a sound', { style: 'bodyBold' }));
|
||||
this.status = new Label('Loading sounds…', { style: 'muted' });
|
||||
this.content.add(this.status);
|
||||
|
||||
this.list.minWidth = LIST_WIDTH;
|
||||
this.list.emptyText = 'No sounds on the server yet – upload one.';
|
||||
this.list.onPick = () => this.refreshSelection();
|
||||
this.list.onActivate = () => this.confirm();
|
||||
this.scroll.minWidth = LIST_WIDTH;
|
||||
this.scroll.minHeight = LIST_HEIGHT;
|
||||
this.scroll.setContent(this.list);
|
||||
this.content.add(this.scroll);
|
||||
|
||||
this.detail = new Label('Press ▶ to hear one.', { style: 'muted' });
|
||||
this.content.add(this.detail);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
const upload = new Button('Upload…');
|
||||
upload.onClick = this.closeThen(() => pickAndImportSounds(this.deps));
|
||||
buttons.add(upload);
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.use = new Button('Use sound', { variant: 'primary' });
|
||||
this.use.onClick = () => this.confirm();
|
||||
this.use.setEnabled(false);
|
||||
buttons.add(this.use);
|
||||
this.content.add(buttons);
|
||||
|
||||
void this.loadList();
|
||||
}
|
||||
|
||||
/** What the dialog offers, which of it the project already uses, and what is picked (test hook). */
|
||||
debugInfo(): {
|
||||
files: string[];
|
||||
used: string[];
|
||||
captions: string[];
|
||||
selected: string | null;
|
||||
played: string | null;
|
||||
} {
|
||||
return {
|
||||
files: this.list.fileList,
|
||||
used: this.list.usedFiles,
|
||||
captions: this.list.captions,
|
||||
selected: this.list.selected?.file ?? null,
|
||||
played: this.list.lastPlayed,
|
||||
};
|
||||
}
|
||||
|
||||
rowGlobal(file: string): { x: number; y: number } | null {
|
||||
return this.list.rowGlobal(file);
|
||||
}
|
||||
|
||||
playGlobal(file: string): { x: number; y: number } | null {
|
||||
return this.list.playGlobal(file);
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
super.close();
|
||||
if (current === this) current = null;
|
||||
// A preview left running would outlive the dialog that started it, with nothing left to stop it.
|
||||
soundLibrary.stopAll();
|
||||
}
|
||||
|
||||
private async loadList(): Promise<void> {
|
||||
try {
|
||||
const stored = await this.deps.sounds.list();
|
||||
const named = new Map<string, string>();
|
||||
for (const asset of this.deps.state.project.assets) {
|
||||
if (asset.type === 'sound') named.set(asset.file, asset.name);
|
||||
}
|
||||
this.list.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })));
|
||||
this.scroll.minHeight = Math.min(LIST_HEIGHT, Math.max(ROW_HEIGHT, heightFor(stored.length)));
|
||||
this.status.setText(
|
||||
stored.length === 1 ? '1 sound on the server' : `${stored.length} sounds on the server`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.status.setText(
|
||||
`Could not list the sounds: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
this.refreshSelection();
|
||||
}
|
||||
|
||||
private refreshSelection(): void {
|
||||
const item = this.list.selected;
|
||||
this.use.setEnabled(item !== null);
|
||||
this.detail.setText(item ? describe(item) : 'Press ▶ to hear one.');
|
||||
this.refreshLayout();
|
||||
}
|
||||
|
||||
private confirm(): void {
|
||||
const item = this.list.selected;
|
||||
if (!item) return;
|
||||
this.close();
|
||||
importStoredSound(item, this.deps);
|
||||
}
|
||||
}
|
||||
|
||||
function describe(item: PickerItem): string {
|
||||
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
|
||||
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
||||
}
|
||||
|
||||
let current: SoundPicker | null = null;
|
||||
|
||||
export function openSoundPicker(deps: SoundImportDeps): SoundPicker {
|
||||
current?.close();
|
||||
current = new SoundPicker(deps);
|
||||
current.openCentered();
|
||||
return current;
|
||||
}
|
||||
|
||||
/** The open dialog, for the test hook. */
|
||||
export function currentSoundPicker(): SoundPicker | null {
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Button, HStack, Label, Theme } from '../../ui';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
|
||||
/**
|
||||
* What a sound asset looks like: a button that plays it and the length the decoder reports. There
|
||||
* is nothing else to show – its file name is a hash, and the waveform would be a picture of bytes
|
||||
* nobody reads.
|
||||
*
|
||||
* Playing here goes through the same library a running game uses, so what the inspector plays is
|
||||
* exactly what the graph will: same decode, same device, and `Stop` reaches both.
|
||||
*/
|
||||
export class SoundPreview extends HStack {
|
||||
private readonly length: Label;
|
||||
|
||||
constructor(private readonly file: string) {
|
||||
super({ gap: Theme.gap, align: 'center' });
|
||||
const play = new Button('▶ Play');
|
||||
play.onClick = () => soundLibrary.play(this.file, 1, false);
|
||||
this.add(play);
|
||||
const stop = new Button('■ Stop');
|
||||
stop.onClick = () => soundLibrary.stop(this.file);
|
||||
this.add(stop);
|
||||
this.length = new Label('', { style: 'muted' });
|
||||
this.add(this.length, 1);
|
||||
this.showLength();
|
||||
}
|
||||
|
||||
/**
|
||||
* The length is only known once the bytes are decoded, and decoding needs an audio device. So it
|
||||
* is asked for rather than waited on: a preview nobody has played yet says nothing about length,
|
||||
* which is more honest than a number guessed from the file size.
|
||||
*/
|
||||
private showLength(): void {
|
||||
const known = soundLibrary.duration(this.file);
|
||||
if (known !== null) {
|
||||
this.length.setText(`${known.toFixed(2)} s`);
|
||||
return;
|
||||
}
|
||||
void soundLibrary.load(this.file).then((buffer) => {
|
||||
if (this.destroyed) return;
|
||||
this.length.setText(buffer ? `${buffer.duration.toFixed(2)} s` : 'Could not be decoded.');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CreateSoundCommand } from '../core/commands/assetCommands';
|
||||
import { nextAssetName } from '../core/commands/naming';
|
||||
import type { SoundAsset } from '../core/model/Asset';
|
||||
import type { SoundStore, UploadedSound } from '../core/store/SoundStore';
|
||||
import type { EditorState } from './state';
|
||||
import { type ImportOutcome, assetNameForFile, importNotice } from './textureImport';
|
||||
|
||||
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
||||
export const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a';
|
||||
|
||||
const SOUND_EXTENSION = /\.(mp3|ogg|wav|m4a)$/i;
|
||||
|
||||
/**
|
||||
* Sounds are told apart by their name, not their MIME type: browsers report a dropped `.m4a` as
|
||||
* `audio/mp4`, `audio/x-m4a` or nothing at all, and an `.ogg` as `audio/ogg` or `video/ogg`.
|
||||
*/
|
||||
export function isSoundFileName(name: string): boolean {
|
||||
return SOUND_EXTENSION.test(name);
|
||||
}
|
||||
|
||||
export interface SoundImportDeps {
|
||||
state: EditorState;
|
||||
sounds: SoundStore;
|
||||
}
|
||||
|
||||
/** Sequential like `importImages`, so each asset is named against the project as it then stands. */
|
||||
export async function importSounds(files: readonly File[], deps: SoundImportDeps): Promise<ImportOutcome[]> {
|
||||
if (files.length === 0) return [];
|
||||
const outcomes: ImportOutcome[] = [];
|
||||
for (const file of files) outcomes.push(await importSound(file, deps));
|
||||
const created = outcomes.filter((outcome) => outcome.assetId !== null);
|
||||
const last = created[created.length - 1];
|
||||
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
|
||||
deps.state.setNotice(importNotice(outcomes, 'sound'));
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
async function importSound(file: File, deps: SoundImportDeps): Promise<ImportOutcome> {
|
||||
try {
|
||||
if (!isSoundFileName(file.name)) throw new Error('Not a sound file – use MP3, OGG, WAV or M4A.');
|
||||
// The bytes go up untouched: nothing here could encode audio, and a re-encode would be a loss.
|
||||
const uploaded = await deps.sounds.upload(file, file.type || 'application/octet-stream', file.name);
|
||||
return adopt(uploaded, assetNameForFile(file.name, 'Sound'), file.name, deps);
|
||||
} catch (error) {
|
||||
return {
|
||||
name: file.name,
|
||||
assetId: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes an asset for a sound the server holds, or reuses the one already naming those bytes. */
|
||||
function adopt(sound: UploadedSound, nameBase: string, label: string, deps: SoundImportDeps): ImportOutcome {
|
||||
const existing = deps.state.project.assets.find(
|
||||
(asset): asset is SoundAsset => asset.type === 'sound' && asset.file === sound.file,
|
||||
);
|
||||
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
|
||||
|
||||
const name = nextAssetName(deps.state.project, nameBase);
|
||||
const command = new CreateSoundCommand(name, sound.file);
|
||||
// A rejected command has already put its reason into the notice; that is the message here.
|
||||
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
|
||||
return { name: label, assetId: command.asset.id, error: null, reused: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts a sound the server already holds – no upload, the bytes are there. Chosen in the picker,
|
||||
* so this is "+ → Sound" and the asset is selected the way a created one would be.
|
||||
*/
|
||||
export function importStoredSound(sound: UploadedSound, deps: SoundImportDeps): ImportOutcome {
|
||||
const outcome = adopt(sound, assetNameForFile(sound.name, 'Sound'), sound.name || sound.file, deps);
|
||||
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
|
||||
deps.state.setNotice(
|
||||
outcome.error ??
|
||||
(outcome.reused ? 'That sound is already in the project.' : 'Added the sound to the project.'),
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||
export function pickSoundFiles(): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = SOUND_ACCEPT;
|
||||
input.multiple = true;
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
const finish = (files: File[]) => {
|
||||
input.remove();
|
||||
resolve(files);
|
||||
};
|
||||
input.addEventListener('change', () => finish([...(input.files ?? [])]));
|
||||
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
|
||||
input.addEventListener('cancel', () => finish([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
export function pickAndImportSounds(deps: SoundImportDeps): void {
|
||||
void pickSoundFiles()
|
||||
.then((files) => importSounds(files, deps))
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||
}
|
||||
Reference in new issue
Block a user