Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions apps/game/public/assets/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"generator": "tools/art/build-assets.mjs",
"blender": "Blender 4.5.12 LTS",
"commit": "ad926825e31ded8e6197881e6d68cf00f368ed65",
"commit": "e132eb8caf8583e204fe8a2292219ebce39c6955",
"license": "Original work, © NIGHTCELL 7. No third-party assets.",
"source": "Generated procedurally from the scripts in tools/art. No asset is downloaded, photographed, traced, or derived from another game.",
"textureSize": 1024,
Expand Down Expand Up @@ -69,12 +69,12 @@
"ui_hover.mp3"
],
"music": [
"music_frost-on-the-oar.mp3",
"music_ironwood-oath.mp3",
"music_runes-on-ice.mp3",
"music_storm-crown-oath.mp3",
"music_the-wolf-called-want-part-2.mp3",
"music_the-wolf-called-want.mp3"
"throngva/frost-on-the-oar.mp3",
"throngva/ironwood-oath.mp3",
"throngva/runes-on-ice.mp3",
"throngva/storm-crown-oath.mp3",
"throngva/the-wolf-called-want-part-2.mp3",
"throngva/the-wolf-called-want.mp3"
],
"bytes": {
"models": 4206012,
Expand Down
25 changes: 25 additions & 0 deletions apps/game/public/audio/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,28 @@ variations exist to prevent.

Original work, © NIGHTCELL 7. No third-party audio, no sampled material, and
nothing produced by a generative audio model.

## Music

Original work by **Þrøngva**, written for this game by the project owner. Not
licensed from a third party, so there is no external licence to comply with and
no attribution obligation — the credit below is for the record.

Laid out as `music/<artist>/<song>.mp3`. The licence and attribution belong to
the artist rather than to each file, and a folder per artist is where a reader
looks for that. It also removed a real bug: the download-budget guard used to
exclude music by a `^music_` filename pattern, which silently under-counted the
moment a track arrived under its own name and put 22 MB of songs inside a 6 MB
guard. A directory cannot be misspelt into the wrong bucket.

| File | Title |
| ------------------------------------------------ | ----------------------------- |
| `music/throngva/frost-on-the-oar.mp3` | Frost on the Oar |
| `music/throngva/runes-on-ice.mp3` | Runes on Ice |
| `music/throngva/ironwood-oath.mp3` | Ironwood Oath |
| `music/throngva/storm-crown-oath.mp3` | Storm Crown Oath |
| `music/throngva/the-wolf-called-want.mp3` | The Wolf Called Want |
| `music/throngva/the-wolf-called-want-part-2.mp3` | The Wolf Called Want (Part 2) |

Streamed by an `<audio>` element on demand and deliberately outside the 15 MB
shell budget (PRD §30): the game is playable before a note arrives.
50 changes: 40 additions & 10 deletions apps/game/src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,39 @@ const SINGLE = ["reload", "ui_hover", "ui_click", "ui_error", "ambience_yard"] a
* is playable before a note has arrived, so music is fetched on demand rather
* than gating the first match (PRD §30).
*/
const MUSIC = [
"music_frost-on-the-oar",
"music_ironwood-oath",
"music_runes-on-ice",
"music_storm-crown-oath",
"music_the-wolf-called-want",
"music_the-wolf-called-want-part-2",
] as const;
interface Track {
/** Path under `audio/`, artist folder and all. */
readonly file: string;
readonly title: string;
readonly artist: string;
}

/**
* The soundtrack, organised by artist.
*
* `music/<artist>/<song>.mp3` rather than a flat `music_*.mp3` pile: the
* licence and attribution belong to the artist, not to each file, and a folder
* per artist is where a reader looks for that. It also stopped a real bug —
* the download-budget guard excluded music by a `^music_` filename pattern,
* which silently under-counted the moment a track arrived under its own name.
* A directory cannot be misspelt into the wrong bucket.
*/
const MUSIC: readonly Track[] = [
{ file: "music/throngva/frost-on-the-oar.mp3", title: "Frost on the Oar", artist: "Þrøngva" },
{ file: "music/throngva/runes-on-ice.mp3", title: "Runes on Ice", artist: "Þrøngva" },
{ file: "music/throngva/ironwood-oath.mp3", title: "Ironwood Oath", artist: "Þrøngva" },
{ file: "music/throngva/storm-crown-oath.mp3", title: "Storm Crown Oath", artist: "Þrøngva" },
{
file: "music/throngva/the-wolf-called-want.mp3",
title: "The Wolf Called Want",
artist: "Þrøngva",
},
{
file: "music/throngva/the-wolf-called-want-part-2.mp3",
title: "The Wolf Called Want (Part 2)",
artist: "Þrøngva",
},
];

type VariedName = keyof typeof VARIED;

Expand Down Expand Up @@ -235,7 +260,7 @@ export class GameAudio {

const advance = () => {
this.musicIndex = (this.musicIndex + 1) % MUSIC.length;
element.src = `${BASE}${MUSIC[this.musicIndex]}.mp3`;
element.src = `${BASE}${MUSIC[this.musicIndex]?.file ?? ""}`;
void element.play().catch(() => {
// Autoplay can still be refused; failing quietly is right here,
// because losing the soundtrack must never interrupt a match.
Expand All @@ -246,11 +271,16 @@ export class GameAudio {
// A track that fails to load should move on rather than end the playlist.
element.addEventListener("error", advance);

element.src = `${BASE}${MUSIC[this.musicIndex]}.mp3`;
element.src = `${BASE}${MUSIC[this.musicIndex]?.file ?? ""}`;
void element.play().catch(() => {});
this.music = element;
}

/** The track playing now, for a "now playing" line if one is ever wanted. */
nowPlaying(): Track | undefined {
return this.music ? MUSIC[this.musicIndex] : undefined;
}

stopMusic(): void {
this.music?.pause();
this.music = null;
Expand Down
29 changes: 18 additions & 11 deletions tools/art/build-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,16 @@ function main() {
const modelBytes = directoryBytes(MODELS_OUT);
const textureBytes = directoryBytes(TEXTURES_OUT);
// Skip streamed music and any video the directory happens to hold.
// Allowlist the generated effects rather than trying to pattern-match the
// music. Tracks arrive with whatever names they were given, so an exclusion
// pattern silently under-counts the moment one does not match — which is how
// 22 MB of songs ended up inside a 6 MB budget guard.
const SFX = /^(fire_|step_|impact_|reload\.|ui_|ambience_yard\.)/;
const audioBytesTotal = directoryBytes(AUDIO_OUT, (f) => !SFX.test(f));
// Only the generated effects, which are the flat .mp3 files in the audio
// root. Music lives under audio/music/<artist>/ and is streamed on demand,
// so it is outside the shell budget by virtue of where it sits rather than
// by a filename pattern — a pattern silently under-counted the moment a
// track arrived under its own name, which put 22 MB of songs inside a 6 MB
// guard.
const audioBytesTotal = directoryBytes(AUDIO_OUT, (f) => !f.endsWith(".mp3"));
const audio = existsSync(AUDIO_OUT)
? readdirSync(AUDIO_OUT)
.filter((f) => f.endsWith(".mp3") && SFX.test(f))
.filter((f) => f.endsWith(".mp3"))
.sort()
: [];

Expand Down Expand Up @@ -304,10 +305,16 @@ function main() {
models,
textures,
audio,
// Streamed on demand, so outside the shell budget entirely.
music: existsSync(AUDIO_OUT)
? readdirSync(AUDIO_OUT)
.filter((f) => f.endsWith(".mp3") && !SFX.test(f))
// Streamed on demand, so outside the shell budget entirely. Listed as
// artist/song so provenance is readable straight from the manifest.
music: existsSync(join(AUDIO_OUT, "music"))
? readdirSync(join(AUDIO_OUT, "music"), { withFileTypes: true })
.filter((e) => e.isDirectory())
.flatMap((artist) =>
readdirSync(join(AUDIO_OUT, "music", artist.name))
.filter((f) => f.endsWith(".mp3"))
.map((f) => `${artist.name}/${f}`),
)
.sort()
: [],
bytes: {
Expand Down
Loading