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

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:
admin committed 2026-09-15 07:54:28 +02:00
1 parent 7babf88636
commit 5671a38044
47 files changed
+2066 -42

No files matched your search

+46 -3
View File
@@ -21,20 +21,26 @@ declare(strict_types=1);
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
* DELETE ?r=fonts/{file} → 204
*
* GET ?r=sounds → [{file,name,bytes,updatedAt}]
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
* DELETE ?r=sounds/{file} → 204
*
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
*
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
* static files; the API only writes them.
* Stored images, fonts and sounds are served straight from `data/textures/`, `data/fonts/` and
* `data/sounds/` as static files; the API only writes them.
*/
require __DIR__ . '/lib/FontStore.php';
require __DIR__ . '/lib/OriginalName.php';
require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/SoundStore.php';
require __DIR__ . '/lib/Store.php';
require __DIR__ . '/lib/TextureStore.php';
const MAX_BODY_BYTES = 8 * 1024 * 1024;
/** Has to clear the largest single upload below, which is a sound; the web server has its own say. */
const MAX_BODY_BYTES = 20 * 1024 * 1024;
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
@@ -42,6 +48,9 @@ const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
/** Upload ceiling for a single font. A font cannot be shrunk on the way, so this is a plain limit. */
const MAX_FONT_BYTES = 4 * 1024 * 1024;
/** The same for a sound, four times as high: one music track is worth more than four typefaces. */
const MAX_SOUND_BYTES = 16 * 1024 * 1024;
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $severity, $file, $line);
});
@@ -171,6 +180,39 @@ function routeFonts(array $parts, string $method): never
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeSounds(array $parts, string $method): never
{
$store = new SoundStore(dirname(__DIR__) . '/data/sounds');
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
}
if ($method === 'POST') {
$bytes = Response::rawBody();
if (strlen($bytes) > MAX_SOUND_BYTES) {
Response::error('Sound must be at most ' . (MAX_SOUND_BYTES / 1024 / 1024) . ' MB', 413);
}
try {
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
} catch (InvalidSoundException $e) {
Response::error($e->getMessage(), 422);
}
}
Response::error('Method not allowed', 405);
}
if ($method === 'DELETE') {
if (!$store->delete($parts[1])) {
Response::error('Sound not found', 404);
}
Response::noContent();
}
Response::error('Method not allowed', 405);
}
try {
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
Response::error('Request body too large', 413);
@@ -188,6 +230,7 @@ try {
'projects' => routeProjects($parts, $method),
'textures' => routeTextures($parts, $method),
'fonts' => routeFonts($parts, $method),
'sounds' => routeSounds($parts, $method),
default => Response::error('Not found', 404),
};
} catch (Throwable $e) {