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
@@ -1,8 +1,9 @@
|
||||
import { createEmptyGraph } from '../graph/Graph';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, FontAsset, StateMachineAsset, TextureAsset } from '../model/Asset';
|
||||
import type { Asset, FontAsset, SoundAsset, StateMachineAsset, TextureAsset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { objectHolders, type Project } from '../model/Project';
|
||||
@@ -44,6 +45,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'shader', graph: createEmptyGraph() };
|
||||
case 'font':
|
||||
return { ...base, type: 'font', file: '' };
|
||||
case 'sound':
|
||||
return { ...base, type: 'sound', file: '' };
|
||||
case 'particles':
|
||||
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
||||
}
|
||||
@@ -156,6 +159,31 @@ export class CreateFontCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSoundAsset(name: string, file: string, id = newId()): SoundAsset {
|
||||
return { id, name, props: defaultsFor('sound'), type: 'sound', file };
|
||||
}
|
||||
|
||||
/** The sound is uploaded before the command runs, as a typeface is for `CreateFontCommand`. */
|
||||
export class CreateSoundCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly asset: SoundAsset;
|
||||
|
||||
constructor(name: string, file: string) {
|
||||
this.asset = buildSoundAsset(name, file);
|
||||
this.label = `Import sound "${name}"`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
if (!isSoundFile(this.asset.file))
|
||||
throw new CommandError(`"${this.asset.file}" is not a stored sound file`);
|
||||
project.assets.push(this.asset);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = removeById(project.assets, this.asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset and, as a cascade, every scene object referencing it, together with the objects
|
||||
* parented below those – in container templates as well as in scenes.
|
||||
|
||||
@@ -119,6 +119,16 @@ export interface NodeContext {
|
||||
*/
|
||||
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
|
||||
log(message: string): void;
|
||||
/**
|
||||
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
|
||||
* `loop` is the caller's answer to whether it repeats, and `undefined` leaves that to the asset,
|
||||
* because the asset says what a sound *is* and the node only colours it. Every call is a voice of
|
||||
* its own: two plays overlap, whether they loop or not, which is what makes one sound effect
|
||||
* fired twice in a row audible twice.
|
||||
*/
|
||||
playSound(assetId: string, volume: number, loop: boolean | undefined): void;
|
||||
/** Silences every voice of that sound; `''` silences everything this run started. */
|
||||
stopSound(assetId: string): void;
|
||||
/**
|
||||
* The position is local to `parent` (world when none); null when the asset does not exist or
|
||||
* cannot be placed. A spawned behavior runs its `On start` on the next tick.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString, type Value } from '../../values';
|
||||
import { exec, num, soundPort, withDefault } from '../shared';
|
||||
|
||||
/**
|
||||
* Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound
|
||||
* whose own `Loop` is on could never be played as a one-shot.
|
||||
*/
|
||||
const LOOP_OPTIONS = [
|
||||
{ value: '', label: 'From the asset' },
|
||||
{ value: 'true', label: 'On' },
|
||||
{ value: 'false', label: 'Off' },
|
||||
];
|
||||
|
||||
/** `any`, not `boolean`, so a graph can still drive the choice – see `loopChoice`. */
|
||||
const loopPort = (): PortDef => ({
|
||||
key: 'loop',
|
||||
label: 'loop',
|
||||
type: 'any',
|
||||
default: '',
|
||||
field: { key: 'loop', label: 'Loop', kind: 'enum', default: '', options: LOOP_OPTIONS },
|
||||
});
|
||||
|
||||
/**
|
||||
* What the node says about looping: nothing (the asset decides) or an answer that overrules it.
|
||||
* A boolean arriving over an edge is taken literally, which is what wiring a comparison or a
|
||||
* variable into this input is for.
|
||||
*/
|
||||
export function loopChoice(value: Value | undefined): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = asString(value);
|
||||
if (text === 'true') return true;
|
||||
if (text === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const PlaySound: NodeDef = {
|
||||
type: 'PlaySound',
|
||||
label: 'Play sound',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' +
|
||||
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
|
||||
'the node only colours it. Each play is a voice of its own: firing this twice in a row is heard ' +
|
||||
'twice, overlapping.',
|
||||
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const sound = asString(inputs.sound);
|
||||
if (sound) ctx.playSound(sound, asNumber(inputs.volume), loopChoice(inputs.loop));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asString } from '../../values';
|
||||
import { exec, soundPort } from '../shared';
|
||||
|
||||
export const StopSound: NodeDef = {
|
||||
type: 'StopSound',
|
||||
label: 'Stop sound',
|
||||
category: 'action',
|
||||
description:
|
||||
'Silences every voice of a sound, however many plays started it – including a looping one. ' +
|
||||
'With no sound picked it silences everything the run is playing.',
|
||||
inputs: [exec(), soundPort()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.stopSound(asString(inputs.sound));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -35,6 +35,11 @@ export const StateMachineLiteral = literal(
|
||||
'statemachine',
|
||||
'A constant state machine, for a "State branch" or a "Set state" to work on.',
|
||||
);
|
||||
export const SoundLiteral = literal(
|
||||
'Sound',
|
||||
'sound',
|
||||
'A constant sound, for a "Play sound" or a "Stop sound" to work on.',
|
||||
);
|
||||
export const ListLiteral = literal(
|
||||
'List',
|
||||
'list',
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Destroy } from './action/Destroy';
|
||||
import { Emit } from './action/Emit';
|
||||
import { Log } from './action/Log';
|
||||
import { Move } from './action/Move';
|
||||
import { PlaySound } from './action/PlaySound';
|
||||
import { RestartGame } from './action/RestartGame';
|
||||
import { SetPosition } from './action/SetPosition';
|
||||
import { Spawn } from './action/Spawn';
|
||||
import { StopGame } from './action/StopGame';
|
||||
import { StopSound } from './action/StopSound';
|
||||
import { Abs } from './data/Abs';
|
||||
import { Add } from './data/Add';
|
||||
import { And } from './data/And';
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
BooleanLiteral,
|
||||
ListLiteral,
|
||||
NumberLiteral,
|
||||
SoundLiteral,
|
||||
StateMachineLiteral,
|
||||
StringLiteral,
|
||||
TextureLiteral,
|
||||
@@ -146,6 +149,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
StringLiteral,
|
||||
TextureLiteral,
|
||||
StateMachineLiteral,
|
||||
SoundLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
@@ -195,6 +199,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
Spawn,
|
||||
Destroy,
|
||||
Emit,
|
||||
PlaySound,
|
||||
StopSound,
|
||||
Log,
|
||||
StopGame,
|
||||
RestartGame,
|
||||
|
||||
@@ -36,6 +36,17 @@ export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', de
|
||||
/** No `assetTypes`: any placeable asset is a valid answer here. */
|
||||
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
|
||||
|
||||
/**
|
||||
* The `sound` input of the two sound nodes; unconnected, it shows the sound picker – the way
|
||||
* `machinePort` shows the machine picker. A `Sound` literal is what feeds it through an edge.
|
||||
*/
|
||||
export const soundPort = (): PortDef => ({
|
||||
key: 'sound',
|
||||
label: 'sound',
|
||||
type: 'string',
|
||||
field: { key: 'sound', label: 'Sound', kind: 'sound', default: '' },
|
||||
});
|
||||
|
||||
export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef {
|
||||
return {
|
||||
type,
|
||||
|
||||
@@ -129,6 +129,16 @@ export interface FontAsset extends AssetBase {
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `file` names the sound in `data/sounds/` the way a font names its typeface: content-addressed
|
||||
* `{sha256}.{ext}`, empty when the upload failed. Nothing wears a sound – a graph plays it with
|
||||
* `Play sound`, and `props` says how loud and whether it repeats (see `properties/sounds.ts`).
|
||||
*/
|
||||
export interface SoundAsset extends AssetBase {
|
||||
type: 'sound';
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph is the motion of one particle: every output of its `Output` node is a function of the
|
||||
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
|
||||
@@ -153,6 +163,7 @@ export type Asset =
|
||||
| StateMachineAsset
|
||||
| ShaderAsset
|
||||
| FontAsset
|
||||
| SoundAsset
|
||||
| ParticlesAsset;
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ASSET_TYPES = [
|
||||
'statemachine',
|
||||
'shader',
|
||||
'font',
|
||||
'sound',
|
||||
'particles',
|
||||
] as const;
|
||||
|
||||
@@ -31,17 +32,19 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
font: 'Font',
|
||||
sound: 'Sound',
|
||||
particles: 'Particles',
|
||||
};
|
||||
|
||||
/**
|
||||
* Textures and animations are not placeable: they are pictures a sprite wears through `texture`,
|
||||
* as a shader is worn through `shader` and a font through `font`. A state machine is not either:
|
||||
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id.
|
||||
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. A
|
||||
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' | 'variables'
|
||||
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
@@ -51,6 +54,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'statemachine',
|
||||
'shader',
|
||||
'font',
|
||||
'sound',
|
||||
'variables',
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SoundAsset } from './Asset';
|
||||
import { findAsset, type Project } from './Project';
|
||||
|
||||
/** Mirrors `SoundStore::isValidName` on the server, where the name is derived from the bytes. */
|
||||
const FILE_PATTERN = /^[a-f0-9]{64}\.(mp3|ogg|wav|m4a)$/;
|
||||
|
||||
export function isSoundFile(name: string): boolean {
|
||||
return FILE_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** The file a `sound` value points at, empty when the id is unset or no longer resolves. */
|
||||
export function soundFileOf(project: Project, id: string): string {
|
||||
if (id === '') return '';
|
||||
const asset = findAsset(project, id);
|
||||
return asset?.type === 'sound' ? asset.file : '';
|
||||
}
|
||||
|
||||
export function isSoundAsset(asset: { type: string }): asset is SoundAsset {
|
||||
return asset.type === 'sound';
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
TIME_SCALE_KEY,
|
||||
} from './particles';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
|
||||
|
||||
/**
|
||||
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
|
||||
@@ -222,6 +223,12 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
shader: [],
|
||||
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property.
|
||||
font: [],
|
||||
// The file is intrinsic data (see SoundAsset); these two say how it is played by default, and a
|
||||
// `Play sound` node scales the one and can add to the other.
|
||||
sound: [
|
||||
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||||
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||||
],
|
||||
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
||||
// nodes add their rows after these – see `schemaForBag`.
|
||||
particles: [
|
||||
|
||||
@@ -106,6 +106,20 @@ export const KINDS = [
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
/** A sound asset's id – a text, so it is tagged in a list like a texture. */
|
||||
{
|
||||
kind: 'sound',
|
||||
label: 'Sound',
|
||||
mark: '♪',
|
||||
travels: 'string',
|
||||
empty: '',
|
||||
coerce: text,
|
||||
tagged: true,
|
||||
reference: true,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
|
||||
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
|
||||
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { booleanOf, type PropertyBag } from '../model/Properties';
|
||||
import { coerceAs } from './kinds';
|
||||
|
||||
/** How loud this sound is by itself, 0–1; a `Play sound` node scales this rather than replacing it. */
|
||||
export const VOLUME_KEY = 'volume';
|
||||
/** Whether this sound is music: it repeats until something stops it. */
|
||||
export const LOOP_KEY = 'loop';
|
||||
|
||||
export function volumeOf(bag: PropertyBag): number {
|
||||
return coerceAs(bag[VOLUME_KEY], 'number', 1);
|
||||
}
|
||||
|
||||
export function loopsOf(bag: PropertyBag): boolean {
|
||||
return booleanOf(bag[LOOP_KEY]);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiRequest } from './http';
|
||||
import type { SoundStore, StoredSound, UploadedSound } from './SoundStore';
|
||||
|
||||
export class HttpSoundStore implements SoundStore {
|
||||
constructor(private readonly endpoint: string) {}
|
||||
|
||||
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedSound> {
|
||||
return this.request<UploadedSound>('POST', 'sounds', { bytes, type, name });
|
||||
}
|
||||
|
||||
async list(): Promise<StoredSound[]> {
|
||||
return this.request<StoredSound[]>('GET', 'sounds');
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `sounds/${encodeURIComponent(file)}`);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
route: string,
|
||||
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
|
||||
): Promise<T> {
|
||||
return apiRequest<T>(this.endpoint, method, route, {
|
||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||
body: payload?.bytes,
|
||||
query: payload ? { name: payload.name } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface UploadedSound {
|
||||
/** Content-addressed name in `data/sounds/`: `{sha256}.{mp3|ogg|wav|m4a}` (see `isSoundFile`). */
|
||||
file: string;
|
||||
/** The name the file was uploaded under, '' when the server knows none. Never a path. */
|
||||
name: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** A sound the server holds, whether any project references it or not. */
|
||||
export interface StoredSound {
|
||||
file: string;
|
||||
name: string;
|
||||
bytes: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw bytes, like a font upload: the server derives name and format from the file itself. A sound
|
||||
* is never re-encoded on the way, so what the server stores is what was picked – and how long it
|
||||
* plays is nowhere in here, because only a decoder knows that.
|
||||
*/
|
||||
export interface SoundStore {
|
||||
/** `name` is kept beside the file as its caption; the stored file is always the hash. */
|
||||
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<UploadedSound>;
|
||||
list(): Promise<StoredSound[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { isShaderNodeDef } from '../shader/nodes';
|
||||
import type { Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
import {
|
||||
type FieldValue,
|
||||
@@ -153,6 +154,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
} else if (type === 'font') {
|
||||
const file = typeof a.file === 'string' && isFontFile(a.file) ? a.file : '';
|
||||
assets.push({ id, type, name, props, file });
|
||||
} else if (type === 'sound') {
|
||||
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
|
||||
assets.push({ id, type, name, props, file });
|
||||
} else if (type === 'animation') {
|
||||
// Kept even when they dangle, like a sprite's `texture`: a frame reads as no picture.
|
||||
const frames = Array.isArray(a.frames) ? a.frames : [];
|
||||
|
||||
+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'));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* What the runtime needs of the outside world to make a noise – the counterpart of `Input` on the
|
||||
* way out. The browser side of it is `viewport/SoundLibrary`; a test (and a run started before
|
||||
* anything wired one in) gets `SILENT_AUDIO` and plays to nobody.
|
||||
*
|
||||
* Files, not asset ids: resolving the id is the `BehaviorSystem`'s job, because it is the one
|
||||
* holding the project. Everything below this line only knows bytes and voices.
|
||||
*/
|
||||
export interface AudioSink {
|
||||
/** Starts one voice. `volume` is final (0–1) and every call overlaps whatever is already playing. */
|
||||
play(file: string, volume: number, loop: boolean): void;
|
||||
/** Silences every voice of that file; `''` silences all of them. */
|
||||
stop(file: string): void;
|
||||
}
|
||||
|
||||
export const SILENT_AUDIO: AudioSink = {
|
||||
play: () => {},
|
||||
stop: () => {},
|
||||
};
|
||||
@@ -19,7 +19,11 @@ import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { soundFileOf } from '../../core/model/sound';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem';
|
||||
@@ -80,10 +84,11 @@ export function addGameSystems(
|
||||
project: Project,
|
||||
input: Input,
|
||||
shouldRun?: (entity: Entity) => boolean,
|
||||
audio: AudioSink = SILENT_AUDIO,
|
||||
): BehaviorSystem {
|
||||
const collisions = new CollisionSystem();
|
||||
const animations = new AnimationSystem();
|
||||
const behaviors = new BehaviorSystem(project, input, { collisions, animations }, shouldRun);
|
||||
const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun);
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(animations);
|
||||
world.addSystem(behaviors);
|
||||
@@ -94,6 +99,8 @@ export function addGameSystems(
|
||||
export interface EventSources {
|
||||
collisions?: CollisionSystem | null;
|
||||
animations?: AnimationSystem | null;
|
||||
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
||||
audio?: AudioSink | null;
|
||||
}
|
||||
|
||||
/** The behavior failure that stopped a run, if that is why it stopped. */
|
||||
@@ -111,6 +118,7 @@ export class BehaviorSystem implements System {
|
||||
private declared: Record<string, PropertyBag> = {};
|
||||
private readonly collisions: CollisionSystem | null;
|
||||
private readonly animations: AnimationSystem | null;
|
||||
private readonly audio: AudioSink;
|
||||
|
||||
constructor(
|
||||
private readonly project: Project,
|
||||
@@ -124,6 +132,7 @@ export class BehaviorSystem implements System {
|
||||
) {
|
||||
this.collisions = sources.collisions ?? null;
|
||||
this.animations = sources.animations ?? null;
|
||||
this.audio = sources.audio ?? SILENT_AUDIO;
|
||||
}
|
||||
|
||||
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
|
||||
@@ -440,6 +449,19 @@ export class BehaviorSystem implements System {
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the graph asked for, as the sink understands it: the asset's own volume scaled by the
|
||||
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
|
||||
* *is* – a node that says nothing therefore plays it as the project describes it.
|
||||
*/
|
||||
private playSound(assetId: string, volume: number, loop: boolean | undefined): void {
|
||||
const asset = findAssetOf(this.project, assetId, 'sound');
|
||||
if (!asset?.file) return;
|
||||
const props = resolveAssetProps(this.project, asset);
|
||||
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
||||
this.audio.play(asset.file, level, loop ?? loopsOf(props));
|
||||
}
|
||||
|
||||
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
|
||||
private run(
|
||||
instance: Instance,
|
||||
@@ -499,6 +521,13 @@ export class BehaviorSystem implements System {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[behavior ${entity.name}]`, message);
|
||||
},
|
||||
playSound: (assetId, volume, loop) => this.playSound(assetId, volume, loop),
|
||||
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
|
||||
// only the empty id means "everything", which is what the node promises.
|
||||
stopSound: (assetId) => {
|
||||
const file = assetId === '' ? '' : soundFileOf(this.project, assetId);
|
||||
if (assetId === '' || file) this.audio.stop(file);
|
||||
},
|
||||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||||
destroy: (target) => {
|
||||
const found = resolve(target);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { AudioSink } from '../runtime/systems/Audio';
|
||||
|
||||
const DEFAULT_BASE = '/game-maker/data/sounds';
|
||||
|
||||
/** One sound in flight: its source, its own gain, and the file it came from. */
|
||||
interface Voice {
|
||||
file: string;
|
||||
source: AudioBufferSourceNode;
|
||||
gain: GainNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays uploaded sounds, keyed by file name – the texture library's twin on the audio side. Files
|
||||
* are content addressed, so a decoded buffer can never go stale and nothing is evicted.
|
||||
*
|
||||
* Everything here is asynchronous on the way in and synchronous on the way out: `play` returns at
|
||||
* once and starts the voice when the bytes have been fetched and decoded. A sound fired in the
|
||||
* first frame of a run is therefore heard a moment later rather than not at all, and a graph never
|
||||
* waits on the network.
|
||||
*
|
||||
* The context is created on the first play, not at start-up: a browser refuses one before the page
|
||||
* has been interacted with, and an editor that was only ever looked at should not be holding an
|
||||
* audio device open.
|
||||
*/
|
||||
export class SoundLibrary implements AudioSink {
|
||||
private base = DEFAULT_BASE;
|
||||
private context: AudioContext | null = null;
|
||||
private readonly buffers = new Map<string, AudioBuffer>();
|
||||
private readonly loading = new Map<string, Promise<AudioBuffer | null>>();
|
||||
/** Never retried, so one broken reference cannot spam the network. */
|
||||
private readonly failed = new Set<string>();
|
||||
private voices: Voice[] = [];
|
||||
private started = 0;
|
||||
private stopped = 0;
|
||||
/** The last voice actually started, with the values it was started at – what a check reads back. */
|
||||
private last: { file: string; volume: number; loop: boolean } | null = null;
|
||||
/**
|
||||
* How often a stop has reached each file, and how often one has silenced everything. Counters
|
||||
* rather than a "cancelled" flag: what a play has to know is whether a stop happened *after it
|
||||
* was asked for*, and a flag cannot tell that from one that happened long before – which would
|
||||
* make the first play of a sound disappear whenever anything had ever stopped it.
|
||||
*/
|
||||
private readonly stops = new Map<string, number>();
|
||||
private allStops = 0;
|
||||
|
||||
/** Where the files are served from. Set once at start-up, before anything plays. */
|
||||
configure(base: string): void {
|
||||
this.base = base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
play(file: string, volume: number, loop: boolean): void {
|
||||
if (file === '' || this.failed.has(file)) return;
|
||||
const buffer = this.buffers.get(file);
|
||||
if (buffer) {
|
||||
this.start(file, buffer, volume, loop);
|
||||
return;
|
||||
}
|
||||
const askedAt = this.stopsFor(file);
|
||||
void this.load(file).then((loaded) => {
|
||||
// A `Stop sound` that happened while the bytes were in flight has to win, or a sound the
|
||||
// graph already silenced would start afterwards and play on its own.
|
||||
if (loaded && this.stopsFor(file) === askedAt) this.start(file, loaded, volume, loop);
|
||||
});
|
||||
}
|
||||
|
||||
stop(file: string): void {
|
||||
const going = file === '' ? this.voices : this.voices.filter((voice) => voice.file === file);
|
||||
for (const voice of going) voice.source.stop();
|
||||
// `onended` removes each one, but not before the next frame – the count has to be right now.
|
||||
this.voices = this.voices.filter((voice) => !going.includes(voice));
|
||||
this.stopped += going.length;
|
||||
if (file === '') this.allStops += 1;
|
||||
else this.stops.set(file, (this.stops.get(file) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/** Every voice at once: what ending a run does, so nothing outlives the world that started it. */
|
||||
stopAll(): void {
|
||||
this.stop('');
|
||||
}
|
||||
|
||||
/** A pause holds the sounds where they are; resuming carries them on mid-note. */
|
||||
setSuspended(suspended: boolean): void {
|
||||
const context = this.context;
|
||||
if (!context) return;
|
||||
if (suspended) void context.suspend();
|
||||
else void context.resume();
|
||||
}
|
||||
|
||||
/** What has been played and what is playing – the only witness a muted check script has. */
|
||||
stats(): {
|
||||
playing: number;
|
||||
started: number;
|
||||
stopped: number;
|
||||
decoded: number;
|
||||
failed: number;
|
||||
last: { file: string; volume: number; loop: boolean } | null;
|
||||
} {
|
||||
return {
|
||||
playing: this.voices.length,
|
||||
started: this.started,
|
||||
stopped: this.stopped,
|
||||
decoded: this.buffers.size,
|
||||
failed: this.failed.size,
|
||||
last: this.last,
|
||||
};
|
||||
}
|
||||
|
||||
/** How long a decoded sound runs, in seconds; null while it has not been loaded (test hook). */
|
||||
duration(file: string): number | null {
|
||||
return this.buffers.get(file)?.duration ?? null;
|
||||
}
|
||||
|
||||
/** Fetches and decodes once per file; a second play while it is in flight waits on the first. */
|
||||
async load(file: string): Promise<AudioBuffer | null> {
|
||||
const held = this.buffers.get(file);
|
||||
if (held) return held;
|
||||
if (this.failed.has(file)) return null;
|
||||
const running = this.loading.get(file);
|
||||
if (running) return running;
|
||||
const work = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${this.base}/${file}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const buffer = await this.audio().decodeAudioData(bytes);
|
||||
this.buffers.set(file, buffer);
|
||||
return buffer;
|
||||
} catch (error: unknown) {
|
||||
this.failed.add(file);
|
||||
console.error(`[sounds] cannot load ${file}`, error);
|
||||
return null;
|
||||
} finally {
|
||||
this.loading.delete(file);
|
||||
}
|
||||
})();
|
||||
this.loading.set(file, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
private start(file: string, buffer: AudioBuffer, volume: number, loop: boolean): void {
|
||||
const context = this.audio();
|
||||
// A context starts suspended where the page has not been interacted with; without this the
|
||||
// voice would run silently and end as if it had played.
|
||||
if (context.state === 'suspended') void context.resume();
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = loop;
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
this.last = { file, volume: gain.gain.value, loop };
|
||||
source.connect(gain).connect(context.destination);
|
||||
const voice: Voice = { file, source, gain };
|
||||
source.onended = () => {
|
||||
this.voices = this.voices.filter((other) => other !== voice);
|
||||
};
|
||||
this.voices.push(voice);
|
||||
this.started += 1;
|
||||
source.start();
|
||||
}
|
||||
|
||||
private audio(): AudioContext {
|
||||
return (this.context ??= new AudioContext());
|
||||
}
|
||||
|
||||
/** Stops this file has seen, its share of the "everything" ones included. */
|
||||
private stopsFor(file: string): number {
|
||||
return (this.stops.get(file) ?? 0) + this.allStops;
|
||||
}
|
||||
}
|
||||
|
||||
export const soundLibrary = new SoundLibrary();
|
||||
Reference in new issue
Block a user