@netforge/asset-library 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSES/CC0-1.0.txt +121 -0
- package/NOTICE +60 -0
- package/README.md +73 -2
- package/manifest/audio.json +22 -0
- package/manifest/gallery.ts +440 -0
- package/manifest/lowpoly.json +4486 -0
- package/manifest/packs.json +212 -0
- package/manifest/pixel.json +1209 -0
- package/manifest/ps1.json +245 -0
- package/manifest/voxel.json +4539 -0
- package/package.json +56 -3
- package/src/audio.ts +43 -0
- package/src/guide.ts +54 -0
- package/src/index.ts +20 -0
- package/src/library.ts +199 -0
- package/src/look.ts +112 -0
- package/src/paths.ts +34 -0
- package/src/restyle.ts +23 -0
- package/src/rigs.ts +140 -0
- package/src/schema.ts +200 -0
- package/src/three/animator.ts +85 -0
- package/src/three/client.ts +191 -0
- package/src/three/crowd.ts +216 -0
- package/src/three/index.ts +13 -0
- package/src/three/parts.ts +149 -0
- package/src/three/prepare.ts +207 -0
- package/src/vite.ts +89 -0
- package/src/voxel.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/asset-library",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The per-style asset library of the Net Forge game engine: a manifest of CC0 models and sprites addressed by library id, shared rigs and clip sets, the look composer, the example gallery, and the three.js loader the templates draw with.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./three": "./src/three/index.ts",
|
|
10
|
+
"./vite": "./src/vite.ts",
|
|
11
|
+
"./gallery": "./manifest/gallery.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/brand",
|
|
16
|
+
"!src/**/*.test.ts",
|
|
17
|
+
"manifest",
|
|
18
|
+
"README.md",
|
|
19
|
+
"NOTICE",
|
|
20
|
+
"LICENSES",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/netnetdev/net-forge.git",
|
|
29
|
+
"directory": "packages/asset-library"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/asset-library#readme",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"fflate": "0.8.3",
|
|
37
|
+
"three": "0.186.1",
|
|
38
|
+
"zod": "4.4.3",
|
|
39
|
+
"@netforge/engine": "0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "22.20.1",
|
|
43
|
+
"@types/three": "0.186.0",
|
|
44
|
+
"tsx": "4.23.0",
|
|
45
|
+
"typescript": "5.8.3",
|
|
46
|
+
"vite": "6.4.3",
|
|
47
|
+
"vitest": "3.2.7"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/netnetdev/net-forge/issues"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"fetch-packs": "tsx scripts/fetch.ts",
|
|
56
|
+
"viewer": "vite --config viewer/vite.config.ts",
|
|
57
|
+
"shoot": "tsx scripts/shoot.ts"
|
|
58
|
+
}
|
|
6
59
|
}
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audio section as a sound bank the engine's SoundPlayer loads
|
|
3
|
+
* (`@netforge/engine/runtime`): every cue's sample URL and mix gain,
|
|
4
|
+
* and one music loop. Pure data: the engine does the playing.
|
|
5
|
+
*/
|
|
6
|
+
import { cueSounds, musicTracks, soundById } from "./library.js";
|
|
7
|
+
import { libraryUrl, soundPath } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
export interface BankSound {
|
|
10
|
+
readonly url: string;
|
|
11
|
+
readonly gain: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Structurally the engine's `SoundBank`. */
|
|
15
|
+
export interface LibrarySoundBank {
|
|
16
|
+
readonly cues: Readonly<Record<string, BankSound>>;
|
|
17
|
+
readonly music: BankSound | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SoundBankOptions {
|
|
21
|
+
/** A music id (audio.music.*), or null for none. Default: the first track. */
|
|
22
|
+
readonly music?: string | null;
|
|
23
|
+
/** Cue name -> sound id overrides (e.g. { kill: "audio.sfx.big_kill" }). */
|
|
24
|
+
readonly cues?: Readonly<Record<string, string>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** URLs and gains for every cue sound and one music loop, under the library's base URL. */
|
|
28
|
+
export function soundBank(base: string, o: SoundBankOptions = {}): LibrarySoundBank {
|
|
29
|
+
const cues: Record<string, BankSound> = {};
|
|
30
|
+
for (const [cue, s] of cueSounds()) cues[cue] = { url: libraryUrl(base, soundPath(s)), gain: s.gain };
|
|
31
|
+
for (const [cue, id] of Object.entries(o.cues ?? {})) {
|
|
32
|
+
const s = soundById(id);
|
|
33
|
+
if (!s || s.kind !== "sfx") throw new Error(`${id} is not a sound effect in the library`);
|
|
34
|
+
cues[cue] = { url: libraryUrl(base, soundPath(s)), gain: s.gain };
|
|
35
|
+
}
|
|
36
|
+
let music: BankSound | null = null;
|
|
37
|
+
if (o.music !== null) {
|
|
38
|
+
const s = o.music ? soundById(o.music) : musicTracks()[0];
|
|
39
|
+
if (o.music && (!s || s.kind !== "music")) throw new Error(`${o.music} is not a music loop in the library`);
|
|
40
|
+
if (s) music = { url: libraryUrl(base, soundPath(s)), gain: s.gain };
|
|
41
|
+
}
|
|
42
|
+
return { cues, music };
|
|
43
|
+
}
|
package/src/guide.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The asset library as the builder model reads it: every id it may use in a
|
|
3
|
+
* style, grouped by kind with one plain line each, then how appearances dress a
|
|
4
|
+
* model. Stable text (no dates, no per-request data) so it caches in the
|
|
5
|
+
* model's system prefix.
|
|
6
|
+
*/
|
|
7
|
+
import { APPEARANCE_PARTS } from "@netforge/engine";
|
|
8
|
+
import { library } from "./library.js";
|
|
9
|
+
import { ASSET_KINDS, type LibraryStyle } from "./schema.js";
|
|
10
|
+
|
|
11
|
+
const KIND_TITLE: Readonly<Record<(typeof ASSET_KINDS)[number], string>> = {
|
|
12
|
+
character: "Characters (heroes and players; animated)",
|
|
13
|
+
creature: "Creatures and enemies (animated)",
|
|
14
|
+
vehicle: "Vehicles",
|
|
15
|
+
weapon: "Weapons",
|
|
16
|
+
prop: "Props",
|
|
17
|
+
pickup: "Pickups",
|
|
18
|
+
building: "Buildings",
|
|
19
|
+
nature: "Nature",
|
|
20
|
+
food: "Food",
|
|
21
|
+
tile: "Tiles",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function modelGuide(style: string): string {
|
|
25
|
+
const m = library().styles.get(style as LibraryStyle);
|
|
26
|
+
if (!m) return `No assets are stocked for style "${style}".`;
|
|
27
|
+
const lines: string[] = [
|
|
28
|
+
`## Asset library: style "${style}" (${m.assets.length} models, all CC0, all originals you can rename freely)`,
|
|
29
|
+
"",
|
|
30
|
+
"Use these ids for every model: prefab model, players.prefab, weapon model. Pick by what the thing is; name it anything original in names.json.",
|
|
31
|
+
"Body plans: biped, quadruped, flyer (hovers), blob (squashes), wheeled, static. Animated ones walk, run, attack, get hit and die by themselves.",
|
|
32
|
+
];
|
|
33
|
+
for (const kind of ASSET_KINDS) {
|
|
34
|
+
const of = m.assets.filter((a) => a.kind === kind);
|
|
35
|
+
if (of.length === 0) continue;
|
|
36
|
+
lines.push("", `### ${KIND_TITLE[kind]}`);
|
|
37
|
+
for (const a of of) lines.push(`${a.id}: ${a.line} [${a.plan}]`);
|
|
38
|
+
}
|
|
39
|
+
lines.push(
|
|
40
|
+
"",
|
|
41
|
+
"## Appearances: dress a model so it becomes your own character",
|
|
42
|
+
"A prefab (and players) can carry appearance: { tint, accent, scale, parts }. tint shifts the model's colours toward \"#rrggbb\"; accent colours the parts; scale is 0.4 to 3 (visual only, radius is the hit size); parts adds up to 4 of:",
|
|
43
|
+
APPEARANCE_PARTS.join(", "),
|
|
44
|
+
"Use an appearance whenever the creator describes a character the library does not have as-is, and to make several enemies from one base.",
|
|
45
|
+
"",
|
|
46
|
+
"Examples",
|
|
47
|
+
'- "add a monster truck": prefab("big_wheel", { model: "style.lowpoly.monster_truck", ... }) (the truck on big_wheels).',
|
|
48
|
+
'- "make the enemies look like slimes": change each enemy prefab\'s model to style.lowpoly.slime_green, slime_pink, slime_spiky (tint them apart if you want more).',
|
|
49
|
+
'- "a storm fox that crackles": model "style.lowpoly.shiba", appearance: { tint: "#5ec8ff", accent: "#1a2a40", parts: ["antenna", "tail_bolt"] }, with a new name of your own.',
|
|
50
|
+
'- "a hero in overalls with a mustache": players.prefab "style.lowpoly.worker", players.appearance: { tint: "#2e7d32", parts: ["mustache", "hat_cap"] }.',
|
|
51
|
+
"- Unknown words resolve to the nearest library model plus an appearance; never invent an id.",
|
|
52
|
+
);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @netforge/asset-library: the per-style asset library of the NetNet
|
|
3
|
+
* game runtime. Pure data and lookups here (no
|
|
4
|
+
* three.js); the renderer side is "@netforge/asset-library/three".
|
|
5
|
+
*/
|
|
6
|
+
export { assetById, baseAsset, buildLibrary, cueSounds, galleryFor, idsForStyle, library, musicTracks, referenceableIds, search, soundById, stockedStyles } from "./library.js";
|
|
7
|
+
export type { Library, SearchHit } from "./library.js";
|
|
8
|
+
export { PART_ANCHOR, resolveLook } from "./look.js";
|
|
9
|
+
export type { AnchorName, ResolvedLook, ResolvedPart } from "./look.js";
|
|
10
|
+
export { bareClipName, CLIP_FALLBACK, CLIPS, clipFor, PLAN_MOTION, RIGS } from "./rigs.js";
|
|
11
|
+
export type { Clip, PlanMotion, RigDef } from "./rigs.js";
|
|
12
|
+
export { ASSET_KINDS, BODY_PLANS, LIBRARY_STYLES, LICENSES, SOUND_FORMATS, SOUND_ID } from "./schema.js";
|
|
13
|
+
export type { AssetDef, AssetKind, AssetSource, AudioManifest, BodyPlan, GalleryEntry, LibraryStyle, PackDef, SoundDef, StyleManifest } from "./schema.js";
|
|
14
|
+
export { assetPath, libraryIdsIn, libraryUrl, soundPath } from "./paths.js";
|
|
15
|
+
export { soundBank } from "./audio.js";
|
|
16
|
+
export type { BankSound, LibrarySoundBank, SoundBankOptions } from "./audio.js";
|
|
17
|
+
export { modelGuide } from "./guide.js";
|
|
18
|
+
export { inEveryStyle, restyleId } from "./restyle.js";
|
|
19
|
+
// The voxel style: the voxel core the library build and the runtime share.
|
|
20
|
+
export * as voxel from "./voxel.js";
|
package/src/library.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library: every asset a game can draw, by id. Loaded from the JSON
|
|
3
|
+
* manifests in ../manifest and validated once. Pure data, no three.js, so
|
|
4
|
+
* the generator, the gate, the assets service and the templates all read
|
|
5
|
+
* the same list.
|
|
6
|
+
*/
|
|
7
|
+
import audio from "../manifest/audio.json" with { type: "json" };
|
|
8
|
+
import { GALLERY as gallery } from "../manifest/gallery.js";
|
|
9
|
+
import lowpoly from "../manifest/lowpoly.json" with { type: "json" };
|
|
10
|
+
import packs from "../manifest/packs.json" with { type: "json" };
|
|
11
|
+
import pixel from "../manifest/pixel.json" with { type: "json" };
|
|
12
|
+
import ps1 from "../manifest/ps1.json" with { type: "json" };
|
|
13
|
+
import voxel from "../manifest/voxel.json" with { type: "json" };
|
|
14
|
+
import { RIGS } from "./rigs.js";
|
|
15
|
+
import {
|
|
16
|
+
audioManifestSchema,
|
|
17
|
+
gallerySchema,
|
|
18
|
+
packSchema,
|
|
19
|
+
styleManifestSchema,
|
|
20
|
+
type AssetDef,
|
|
21
|
+
type GalleryEntry,
|
|
22
|
+
type LibraryStyle,
|
|
23
|
+
type PackDef,
|
|
24
|
+
type SoundDef,
|
|
25
|
+
type StyleManifest,
|
|
26
|
+
} from "./schema.js";
|
|
27
|
+
|
|
28
|
+
export interface Library {
|
|
29
|
+
readonly packs: ReadonlyMap<string, PackDef>;
|
|
30
|
+
readonly styles: ReadonlyMap<LibraryStyle, StyleManifest>;
|
|
31
|
+
/** Every asset by id, aliases included (an alias maps to its asset). */
|
|
32
|
+
readonly byId: ReadonlyMap<string, AssetDef>;
|
|
33
|
+
readonly gallery: Readonly<Partial<Record<LibraryStyle, readonly GalleryEntry[]>>>;
|
|
34
|
+
/** The audio section: sound effects and music loops by id (audio.sfx.hit). */
|
|
35
|
+
readonly sounds: ReadonlyMap<string, SoundDef>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Validate manifests and cross-references. Throws with every problem listed. */
|
|
39
|
+
export function buildLibrary(input: { packs: unknown; styles: readonly unknown[]; gallery: unknown; audio?: unknown }): Library {
|
|
40
|
+
const issues: string[] = [];
|
|
41
|
+
const packList = packSchema.array().parse(input.packs);
|
|
42
|
+
const packMap = new Map(packList.map((p) => [p.id, p]));
|
|
43
|
+
const styles = new Map<LibraryStyle, StyleManifest>();
|
|
44
|
+
const byId = new Map<string, AssetDef>();
|
|
45
|
+
for (const raw of input.styles) {
|
|
46
|
+
const m = styleManifestSchema.parse(raw);
|
|
47
|
+
styles.set(m.style, m);
|
|
48
|
+
for (const a of m.assets) {
|
|
49
|
+
if (!a.id.startsWith(`style.${m.style}.`)) issues.push(`${a.id} is listed under style ${m.style}`);
|
|
50
|
+
if (byId.has(a.id)) issues.push(`${a.id} is listed twice`);
|
|
51
|
+
byId.set(a.id, a);
|
|
52
|
+
if ("pack" in a.source && !packMap.has(a.source.pack)) issues.push(`${a.id} names unknown pack ${a.source.pack}`);
|
|
53
|
+
if (a.rig && !RIGS[a.rig]) issues.push(`${a.id} names unknown rig ${a.rig}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const m of styles.values()) {
|
|
57
|
+
for (const a of m.assets) {
|
|
58
|
+
if ("base" in a.source) {
|
|
59
|
+
const base = byId.get(a.source.base);
|
|
60
|
+
if (!base) issues.push(`${a.id} is based on unknown ${a.source.base}`);
|
|
61
|
+
else if ("base" in base.source) issues.push(`${a.id}: a variant's base must be a real asset, not another variant`);
|
|
62
|
+
}
|
|
63
|
+
if ("voxelize" in a.source) {
|
|
64
|
+
const from = byId.get(a.source.voxelize);
|
|
65
|
+
if (!from) issues.push(`${a.id} voxelizes unknown ${a.source.voxelize}`);
|
|
66
|
+
else if (!("pack" in from.source)) issues.push(`${a.id}: only a model with a file can be voxelized (${a.source.voxelize} has none)`);
|
|
67
|
+
}
|
|
68
|
+
for (const al of a.aliases) {
|
|
69
|
+
if (byId.has(al)) issues.push(`alias ${al} collides with an id`);
|
|
70
|
+
byId.set(al, a);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const g = gallerySchema.parse(input.gallery);
|
|
75
|
+
for (const [style, entries] of Object.entries(g)) {
|
|
76
|
+
for (const e of entries) if (!byId.has(e.model)) issues.push(`gallery ${style}.${e.id} uses unknown model ${e.model}`);
|
|
77
|
+
}
|
|
78
|
+
const sounds = new Map<string, SoundDef>();
|
|
79
|
+
const cues = new Set<string>();
|
|
80
|
+
for (const s of input.audio === undefined ? [] : audioManifestSchema.parse(input.audio).sounds) {
|
|
81
|
+
if (sounds.has(s.id)) issues.push(`${s.id} is listed twice`);
|
|
82
|
+
sounds.set(s.id, s);
|
|
83
|
+
if (!packMap.has(s.source.pack)) issues.push(`${s.id} names unknown pack ${s.source.pack}`);
|
|
84
|
+
if (s.cue && cues.has(s.cue)) issues.push(`${s.id}: cue ${s.cue} already has a sound`);
|
|
85
|
+
if (s.cue) cues.add(s.cue);
|
|
86
|
+
if (s.kind === "music" && s.cue) issues.push(`${s.id}: music has no cue`);
|
|
87
|
+
}
|
|
88
|
+
if (issues.length) throw new Error(`asset library manifest:\n - ${issues.join("\n - ")}`);
|
|
89
|
+
return { packs: packMap, styles, byId, gallery: g, sounds };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let cached: Library | null = null;
|
|
93
|
+
|
|
94
|
+
/** The library as shipped in this repository. */
|
|
95
|
+
export function library(): Library {
|
|
96
|
+
cached ??= buildLibrary({ packs, styles: [lowpoly, pixel, ps1, voxel], gallery, audio });
|
|
97
|
+
return cached;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An asset by id or alias, or undefined. */
|
|
101
|
+
export function assetById(id: string): AssetDef | undefined {
|
|
102
|
+
return library().byId.get(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The asset a variant ultimately draws (itself for a real asset). */
|
|
106
|
+
export function baseAsset(a: AssetDef): AssetDef {
|
|
107
|
+
if ("base" in a.source) return library().byId.get(a.source.base) ?? a;
|
|
108
|
+
return a;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Canonical ids (no aliases) of one style. */
|
|
112
|
+
export function idsForStyle(style: string): string[] {
|
|
113
|
+
const m = library().styles.get(style as LibraryStyle);
|
|
114
|
+
return m ? m.assets.map((a) => a.id) : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Ids a game may reference in a style: canonical ids and the aliases older drafts used. */
|
|
118
|
+
export function referenceableIds(style: string): Set<string> {
|
|
119
|
+
const m = library().styles.get(style as LibraryStyle);
|
|
120
|
+
const out = new Set<string>();
|
|
121
|
+
for (const a of m?.assets ?? []) {
|
|
122
|
+
out.add(a.id);
|
|
123
|
+
for (const al of a.aliases) out.add(al);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Styles that have assets today. */
|
|
129
|
+
export function stockedStyles(): LibraryStyle[] {
|
|
130
|
+
return [...library().styles.keys()];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const STOP = new Set(["a", "an", "the", "of", "like", "with", "and", "or", "to", "make", "add", "look", "looks", "some", "my", "for", "in", "on"]);
|
|
134
|
+
|
|
135
|
+
function words(s: string): string[] {
|
|
136
|
+
return s
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/[^a-z0-9 ]+/g, " ")
|
|
139
|
+
.split(/\s+/)
|
|
140
|
+
.filter((w) => w && !STOP.has(w))
|
|
141
|
+
.map((w) => (w.length > 3 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface SearchHit {
|
|
145
|
+
readonly asset: AssetDef;
|
|
146
|
+
readonly score: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Library assets that match a description ("a monster truck", "slimes",
|
|
151
|
+
* "a knight"), best first. Matches tags, the id's name and the line.
|
|
152
|
+
*/
|
|
153
|
+
export function search(style: string, query: string, limit = 8): SearchHit[] {
|
|
154
|
+
const q = words(query);
|
|
155
|
+
if (q.length === 0) return [];
|
|
156
|
+
const m = library().styles.get(style as LibraryStyle);
|
|
157
|
+
if (!m) return [];
|
|
158
|
+
const hits: SearchHit[] = [];
|
|
159
|
+
for (const a of m.assets) {
|
|
160
|
+
const name = a.id.split(".")[2] ?? "";
|
|
161
|
+
const tagWords = a.tags.flatMap((t) => words(t));
|
|
162
|
+
const nameWords = words(name.replace(/_/g, " "));
|
|
163
|
+
const lineWords = words(a.line);
|
|
164
|
+
let score = 0;
|
|
165
|
+
for (const w of q) {
|
|
166
|
+
if (a.tags.includes(q.join(" "))) score += 6;
|
|
167
|
+
if (tagWords.includes(w)) score += 3;
|
|
168
|
+
if (nameWords.includes(w)) score += 3;
|
|
169
|
+
if (lineWords.includes(w)) score += 1;
|
|
170
|
+
}
|
|
171
|
+
// Whole-phrase tag match ("monster truck") beats its words alone.
|
|
172
|
+
const phrase = q.join(" ");
|
|
173
|
+
if (a.tags.some((t) => t === phrase)) score += 8;
|
|
174
|
+
if (score > 0) hits.push({ asset: a, score });
|
|
175
|
+
}
|
|
176
|
+
return hits.sort((x, y) => y.score - x.score || x.asset.id.localeCompare(y.asset.id)).slice(0, limit);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The gallery of ready-made looks for a style. */
|
|
180
|
+
export function galleryFor(style: string): readonly GalleryEntry[] {
|
|
181
|
+
return library().gallery[style as LibraryStyle] ?? [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A sound by id (audio.sfx.hit, audio.music.battle_theme_a), or undefined. */
|
|
185
|
+
export function soundById(id: string): SoundDef | undefined {
|
|
186
|
+
return library().sounds.get(id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Every sound effect that plays a FeelKit cue by default, by cue name. */
|
|
190
|
+
export function cueSounds(): ReadonlyMap<string, SoundDef> {
|
|
191
|
+
const out = new Map<string, SoundDef>();
|
|
192
|
+
for (const s of library().sounds.values()) if (s.kind === "sfx" && s.cue) out.set(s.cue, s);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The music loops, in manifest order. */
|
|
197
|
+
export function musicTracks(): SoundDef[] {
|
|
198
|
+
return [...library().sounds.values()].filter((s) => s.kind === "music");
|
|
199
|
+
}
|
package/src/look.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The look composer (internal): base model + palette + scale + a few parts.
|
|
3
|
+
* This is how the builder turns a description ("a yellow electric critter
|
|
4
|
+
* with long ears") into a consistent, animated original from the library.
|
|
5
|
+
* Creators never see it; they describe what they want or pick from the
|
|
6
|
+
* gallery, and the model writes the look.
|
|
7
|
+
*
|
|
8
|
+
* `resolveLook` is pure and deterministic: the same model id and look give
|
|
9
|
+
* the same result on every client and in every test.
|
|
10
|
+
*/
|
|
11
|
+
import { appearanceSchema, type AppearanceDef, type AppearancePart } from "@netforge/engine";
|
|
12
|
+
import { assetById, baseAsset } from "./library.js";
|
|
13
|
+
import type { AssetDef, BodyPlan } from "./schema.js";
|
|
14
|
+
|
|
15
|
+
/** Where a part goes, per body plan: an anchor and an offset in model units (0..1 of height). */
|
|
16
|
+
export type AnchorName = "head" | "back" | "tail" | "face" | "wheels" | "body";
|
|
17
|
+
|
|
18
|
+
export const PART_ANCHOR: Readonly<Record<AppearancePart, AnchorName>> = {
|
|
19
|
+
horns: "head",
|
|
20
|
+
ears_long: "head",
|
|
21
|
+
ears_round: "head",
|
|
22
|
+
antenna: "head",
|
|
23
|
+
crown: "head",
|
|
24
|
+
hat_cap: "head",
|
|
25
|
+
hat_top: "head",
|
|
26
|
+
mustache: "face",
|
|
27
|
+
tail: "tail",
|
|
28
|
+
tail_bolt: "tail",
|
|
29
|
+
wings: "back",
|
|
30
|
+
armor: "body",
|
|
31
|
+
backpack: "back",
|
|
32
|
+
big_wheels: "wheels",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Parts that make no sense on a plan are dropped (a mustache on a truck), with a note. */
|
|
36
|
+
const PLAN_PARTS: Readonly<Record<BodyPlan, ReadonlySet<AnchorName>>> = {
|
|
37
|
+
biped: new Set(["head", "back", "tail", "face", "body"]),
|
|
38
|
+
quadruped: new Set(["head", "back", "tail", "face", "body"]),
|
|
39
|
+
flyer: new Set(["head", "back", "tail", "face", "body"]),
|
|
40
|
+
blob: new Set(["head", "back", "tail", "face", "body"]),
|
|
41
|
+
wheeled: new Set(["wheels", "body", "back"]),
|
|
42
|
+
static: new Set(["body"]),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface ResolvedPart {
|
|
46
|
+
readonly part: AppearancePart;
|
|
47
|
+
readonly anchor: AnchorName;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResolvedLook {
|
|
51
|
+
/** The asset whose file is drawn. */
|
|
52
|
+
readonly asset: AssetDef;
|
|
53
|
+
/** The id the game asked for (may be a variant or an alias). */
|
|
54
|
+
readonly requested: string;
|
|
55
|
+
readonly tint: number | null;
|
|
56
|
+
readonly accent: number;
|
|
57
|
+
readonly scale: number;
|
|
58
|
+
readonly parts: readonly ResolvedPart[];
|
|
59
|
+
/** Parts dropped because the body plan has no place for them. */
|
|
60
|
+
readonly dropped: readonly AppearancePart[];
|
|
61
|
+
/** Stable key: identical looks share one loaded model and one instanced draw. */
|
|
62
|
+
readonly key: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hex = (s: string): number => Number.parseInt(s.slice(1), 16);
|
|
66
|
+
|
|
67
|
+
/** Darken a colour for a default accent. */
|
|
68
|
+
function shade(c: number, f: number): number {
|
|
69
|
+
const r = Math.round(((c >> 16) & 0xff) * f);
|
|
70
|
+
const g = Math.round(((c >> 8) & 0xff) * f);
|
|
71
|
+
const b = Math.round((c & 0xff) * f);
|
|
72
|
+
return (r << 16) | (g << 8) | b;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Merge a variant's built-in look under the game's own look: the game's fields win, parts are unioned. */
|
|
76
|
+
function mergeLooks(base: AppearanceDef | undefined, top: AppearanceDef | undefined): AppearanceDef {
|
|
77
|
+
const parts = [...new Set([...(base?.parts ?? []), ...(top?.parts ?? [])])].slice(0, 4);
|
|
78
|
+
return appearanceSchema.parse({
|
|
79
|
+
...(base?.tint ? { tint: base.tint } : {}),
|
|
80
|
+
...(base?.accent ? { accent: base.accent } : {}),
|
|
81
|
+
...(base?.scale !== undefined ? { scale: base.scale } : {}),
|
|
82
|
+
...(top?.tint ? { tint: top.tint } : {}),
|
|
83
|
+
...(top?.accent ? { accent: top.accent } : {}),
|
|
84
|
+
...(top?.scale !== undefined ? { scale: (base?.scale ?? 1) * top.scale } : {}),
|
|
85
|
+
parts,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve a model id and an optional look into what to draw. Throws on an unknown id. */
|
|
90
|
+
export function resolveLook(modelId: string, look?: AppearanceDef): ResolvedLook {
|
|
91
|
+
const requested = assetById(modelId);
|
|
92
|
+
if (!requested) throw new Error(`unknown library id ${modelId}`);
|
|
93
|
+
const asset = baseAsset(requested);
|
|
94
|
+
const builtIn = "base" in requested.source ? requested.source.appearance : undefined;
|
|
95
|
+
const l = mergeLooks(builtIn, look);
|
|
96
|
+
const allowed = PLAN_PARTS[asset.plan];
|
|
97
|
+
const parts: ResolvedPart[] = [];
|
|
98
|
+
const dropped: AppearancePart[] = [];
|
|
99
|
+
for (const p of l.parts) {
|
|
100
|
+
const anchor = PART_ANCHOR[p];
|
|
101
|
+
if (!allowed.has(anchor)) {
|
|
102
|
+
dropped.push(p);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
parts.push({ part: p, anchor });
|
|
106
|
+
}
|
|
107
|
+
const tint = l.tint ? hex(l.tint) : null;
|
|
108
|
+
const accent = l.accent ? hex(l.accent) : tint !== null ? shade(tint, 0.45) : 0x2a2a2a;
|
|
109
|
+
const scale = l.scale ?? 1;
|
|
110
|
+
const key = `${asset.id}|${tint ?? "-"}|${accent}|${scale}|${parts.map((p) => p.part).join(",")}`;
|
|
111
|
+
return { asset, requested: modelId, tint, accent, scale, parts, dropped, key };
|
|
112
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a library file lives, relative to the library's base URL. Games
|
|
3
|
+
* never see these (they use library ids); only the runtime's resolver does.
|
|
4
|
+
* The base URL is the local build in dev ("./library" next to the page),
|
|
5
|
+
* and `<CDN>/ipfs/<library cid>` in production (VITE_LIBRARY_URL).
|
|
6
|
+
*/
|
|
7
|
+
import type { AssetDef, SoundDef } from "./schema.js";
|
|
8
|
+
|
|
9
|
+
/** "style.lowpoly.truck" -> "lowpoly/truck.glb"; pixel sprites are PNG. Null for procedural and variant entries. */
|
|
10
|
+
export function assetPath(a: AssetDef): string | null {
|
|
11
|
+
const src = a.source;
|
|
12
|
+
if ("procedural" in src || "base" in src) return null;
|
|
13
|
+
const [, style = "", name = ""] = a.id.split(".");
|
|
14
|
+
const ext = "path" in src && src.path.toLowerCase().endsWith(".png") ? "png" : "glb";
|
|
15
|
+
return `${style}/${name}.${ext}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Every library id written in a source text (a game.ts), in first-seen order. */
|
|
19
|
+
export function libraryIdsIn(source: string): string[] {
|
|
20
|
+
return [...new Set(source.match(/style\.[a-z0-9]+\.[a-z][a-z0-9_]*/g) ?? [])];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Join a base URL and a library path. */
|
|
24
|
+
export function libraryUrl(base: string, path: string): string {
|
|
25
|
+
return `${base.replace(/\/+$/, "")}/${path}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "audio.sfx.hit" -> "audio/sfx/hit.ogg"; the extension is the source file's. */
|
|
29
|
+
export function soundPath(s: SoundDef): string {
|
|
30
|
+
const [, group = "", name = ""] = s.id.split(".");
|
|
31
|
+
const file = "path" in s.source ? s.source.path : s.source.url;
|
|
32
|
+
const ext = file.toLowerCase().endsWith(".mp3") ? "mp3" : "ogg";
|
|
33
|
+
return `audio/${group}/${name}.${ext}`;
|
|
34
|
+
}
|
package/src/restyle.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draw a game in another art style: the same library name in that style.
|
|
3
|
+
* A game written against "style.lowpoly.ghost" draws "style.voxel.ghost"
|
|
4
|
+
* when it plays in the voxel style (a template's ?style= flag, or the game's
|
|
5
|
+
* declared style). Names a style does not carry stay as they are.
|
|
6
|
+
*/
|
|
7
|
+
import { assetById, library } from "./library.js";
|
|
8
|
+
|
|
9
|
+
export function restyleId(id: string, style: string | null | undefined): string {
|
|
10
|
+
if (!style) return id;
|
|
11
|
+
const parts = id.split(".");
|
|
12
|
+
if (parts.length !== 3 || parts[0] !== "style" || parts[1] === style) return id;
|
|
13
|
+
const target = `style.${style}.${parts[2]}`;
|
|
14
|
+
const a = assetById(target);
|
|
15
|
+
return a ? a.id : id;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Ids plus the same name in every other style the library carries (a draft build copies what any ?style= may draw). */
|
|
19
|
+
export function inEveryStyle(ids: readonly string[]): string[] {
|
|
20
|
+
const out = new Set(ids);
|
|
21
|
+
for (const id of ids) for (const style of library().styles.keys()) out.add(restyleId(id, style));
|
|
22
|
+
return [...out];
|
|
23
|
+
}
|