@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/src/rigs.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Shared rigs and the common clip set ("all humanoids
3
+ * in a style use one skeleton, so any animation plays on any character").
4
+ *
5
+ * Every animated asset names a rig. A rig maps the runtime's canonical
6
+ * clips (idle, walk, run, jump, attack, shoot, hit, die) to the clip names
7
+ * its files carry, as a list of candidates: the first one a file has wins.
8
+ * "Armature|Idle" matches "Idle" (the armature prefix is ignored), and a
9
+ * candidate that starts with "*" matches by suffix ("*_Idle" matches
10
+ * "TRex_Idle"). A clip a file lacks falls back along CLIP_FALLBACK, then to
11
+ * a procedural move the renderer does itself (a hit flash, a sink on death).
12
+ *
13
+ * Body plans (biped, quadruped, flyer, blob, wheeled, static) pick the
14
+ * fallbacks and the crowd motion when hundreds share one instanced draw.
15
+ */
16
+ import type { BodyPlan } from "./schema.js";
17
+
18
+ export const CLIPS = ["idle", "walk", "run", "jump", "attack", "shoot", "hit", "die"] as const;
19
+ export type Clip = (typeof CLIPS)[number];
20
+
21
+ /** Where a missing clip borrows from. Null: the renderer plays it procedurally. */
22
+ export const CLIP_FALLBACK: Readonly<Record<Clip, Clip | null>> = {
23
+ idle: null,
24
+ walk: "run",
25
+ run: "walk",
26
+ jump: "idle",
27
+ attack: "shoot",
28
+ shoot: "attack",
29
+ hit: null,
30
+ die: null,
31
+ };
32
+
33
+ export interface RigDef {
34
+ readonly id: string;
35
+ /** Canonical clip -> candidate clip names, best first. */
36
+ readonly clips: Readonly<Record<Clip, readonly string[]>>;
37
+ /** Bone or node name patterns for the head and the right hand (parts and held weapons). */
38
+ readonly headBone: RegExp | null;
39
+ readonly handBone: RegExp | null;
40
+ /** Ground speed in m/s at which the walk and run clips look right at 1x. */
41
+ readonly walkSpeed: number;
42
+ readonly runSpeed: number;
43
+ }
44
+
45
+ export const RIGS: Readonly<Record<string, RigDef>> = {
46
+ /** Kenney Mini Characters and Blocky Characters: the same node names and the same 30-clip set. */
47
+ kenney_humanoid: {
48
+ id: "kenney_humanoid",
49
+ clips: {
50
+ idle: ["idle"],
51
+ walk: ["walk"],
52
+ run: ["sprint"],
53
+ jump: ["jump"],
54
+ attack: ["attack-melee-right"],
55
+ shoot: ["holding-right-shoot"],
56
+ hit: [],
57
+ die: ["die"],
58
+ },
59
+ headBone: /^head$/,
60
+ handBone: /^arm-right$/,
61
+ walkSpeed: 1.6,
62
+ runSpeed: 4.5,
63
+ },
64
+ /**
65
+ * Quaternius animated models. One naming family across the packs on the
66
+ * library (characters, big monsters, blobs, flyers, animals, dinosaurs),
67
+ * with per-family spellings ("HitRecieve", "Bat_Flying").
68
+ */
69
+ quaternius: {
70
+ id: "quaternius",
71
+ clips: {
72
+ idle: ["Idle", "Flying_Idle", "*_Idle", "Flying", "*_Flying", "Idle_2"],
73
+ walk: ["Walk", "*_Walk", "Flying_Idle", "Flying", "*_Flying", "*_Forward", "Run", "*_Running", "*_Run"],
74
+ run: ["Run", "*_Run", "*_Running", "Gallop", "Fast_Flying", "Flying", "*_Flying", "*_Forward", "Walk", "*_Walk"],
75
+ jump: ["Jump", "*_Jump", "Gallop_Jump"],
76
+ attack: ["Punch", "Attack", "*_Attack", "Bite_Front", "Headbutt", "Attack_Headbutt", "Dagger_Attack", "Sword_Slash"],
77
+ shoot: ["Gun_Shoot", "Idle_Gun_Shoot", "Idle_Shoot", "Shoot", "Punch", "Attack"],
78
+ hit: ["HitReact", "HitRecieve", "RecieveHit", "*_Hit", "Idle_HitReact_Left", "HitRecieve_1"],
79
+ die: ["Death", "*_Death", "Dead"],
80
+ },
81
+ headBone: /^head$/i,
82
+ handBone: /^(fist|hand|wrist|lowerarm)[._]?r$/i,
83
+ walkSpeed: 1.6,
84
+ runSpeed: 5,
85
+ },
86
+ };
87
+
88
+ /** The clip name after any "Armature|" prefixes. */
89
+ export function bareClipName(name: string): string {
90
+ const i = name.lastIndexOf("|");
91
+ return i >= 0 ? name.slice(i + 1) : name;
92
+ }
93
+
94
+ function matches(candidate: string, name: string): boolean {
95
+ const bare = bareClipName(name);
96
+ if (candidate.startsWith("*")) return bare.endsWith(candidate.slice(1));
97
+ return bare === candidate;
98
+ }
99
+
100
+ /**
101
+ * The file's clip to play for a canonical clip, following fallbacks; null
102
+ * means the renderer plays it procedurally. `names` are the clips the file has.
103
+ */
104
+ export function clipFor(rig: RigDef | undefined, clip: Clip, names: readonly string[]): string | null {
105
+ if (!rig) return null;
106
+ let c: Clip | null = clip;
107
+ const seen = new Set<Clip>();
108
+ while (c && !seen.has(c)) {
109
+ seen.add(c);
110
+ for (const cand of rig.clips[c]) {
111
+ // Prefer the shortest match: "CharacterArmature|Idle" over a doubled export of the same clip.
112
+ const hit = names.filter((n) => matches(cand, n)).sort((a, b) => a.length - b.length)[0];
113
+ if (hit) return hit;
114
+ }
115
+ c = CLIP_FALLBACK[c];
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** Procedural crowd motion per body plan (used where hundreds share one instanced draw). */
121
+ export interface PlanMotion {
122
+ /** Vertical bob, as a share of height, and its rate in cycles per metre walked. */
123
+ readonly bob: number;
124
+ readonly bobRate: number;
125
+ /** Side-to-side lean, radians. */
126
+ readonly waddle: number;
127
+ /** Hover height above the floor, as a share of height (flyers). */
128
+ readonly hover: number;
129
+ /** Squash and stretch on the bob (blobs). */
130
+ readonly squash: number;
131
+ }
132
+
133
+ export const PLAN_MOTION: Readonly<Record<BodyPlan, PlanMotion>> = {
134
+ biped: { bob: 0.04, bobRate: 1.5, waddle: 0.1, hover: 0, squash: 0 },
135
+ quadruped: { bob: 0.03, bobRate: 1.8, waddle: 0.04, hover: 0, squash: 0 },
136
+ flyer: { bob: 0.08, bobRate: 0.7, waddle: 0.16, hover: 0.55, squash: 0 },
137
+ blob: { bob: 0.06, bobRate: 1.3, waddle: 0.04, hover: 0, squash: 0.18 },
138
+ wheeled: { bob: 0.01, bobRate: 3, waddle: 0, hover: 0, squash: 0 },
139
+ static: { bob: 0, bobRate: 0, waddle: 0, hover: 0, squash: 0 },
140
+ };
package/src/schema.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The library manifest's shape. The manifest is the
3
+ * only list of what a game can draw: every entry has a library id, the pack
4
+ * it came from, and that pack's verified licence. Files are never in git;
5
+ * `scripts/fetch.ts` downloads each pack from its official source and the
6
+ * Net Forge asset pipeline optimizes and publishes them.
7
+ */
8
+ import { appearanceSchema } from "@netforge/engine";
9
+ import { z } from "zod";
10
+
11
+ /** Styles the library is designed for. Only lowpoly and pixel are stocked today. */
12
+ export const LIBRARY_STYLES = ["lowpoly", "pixel", "toon", "voxel", "ps1", "handdrawn"] as const;
13
+ export type LibraryStyle = (typeof LIBRARY_STYLES)[number];
14
+
15
+ /** What an asset is, for search, the gallery and the fallback shape. */
16
+ export const ASSET_KINDS = ["character", "creature", "vehicle", "weapon", "prop", "pickup", "building", "nature", "food", "tile"] as const;
17
+ export type AssetKind = (typeof ASSET_KINDS)[number];
18
+
19
+ /** How a body moves: picks the clip fallbacks and the crowd motion (bob, waddle, hover). */
20
+ export const BODY_PLANS = ["biped", "quadruped", "flyer", "blob", "wheeled", "static"] as const;
21
+ export type BodyPlan = (typeof BODY_PLANS)[number];
22
+
23
+ /** Licences we accept. Anything else is refused at load (permissive only). */
24
+ export const LICENSES = ["CC0-1.0"] as const;
25
+
26
+ const NAME = /^[a-z][a-z0-9_]{0,47}$/;
27
+ const LIBRARY_ID = /^style\.[a-z0-9]+\.[a-z][a-z0-9_]{0,47}$/;
28
+ const SHA256 = /^[0-9a-f]{64}$/;
29
+
30
+ export const packSchema = z
31
+ .object({
32
+ id: z.string().regex(NAME),
33
+ title: z.string().min(1),
34
+ author: z.string().min(1),
35
+ /** The pack's official page, where the licence is stated. */
36
+ page: z.string().url(),
37
+ license: z.enum(LICENSES),
38
+ licenseUrl: z.string().url(),
39
+ /** How the licence was verified, and when. */
40
+ verified: z.object({ on: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), how: z.string().min(10) }).strict(),
41
+ /** Where the files come from: one official archive, or per-file mirrors that state the same licence. */
42
+ download: z.discriminatedUnion("kind", [
43
+ z.object({ kind: z.literal("zip"), url: z.string().url(), sha256: z.string().regex(SHA256).optional() }).strict(),
44
+ z.object({ kind: z.literal("files"), host: z.string().url(), note: z.string().min(10) }).strict(),
45
+ ]),
46
+ })
47
+ .strict();
48
+
49
+ /** Where one asset's file is: a path inside the pack archive, or a direct URL (files packs) with its source page. */
50
+ const sourceSchema = z.union([
51
+ z
52
+ .object({
53
+ pack: z.string().regex(NAME),
54
+ path: z.string().min(1).refine((p) => !p.includes(".."), "paths stay inside the archive"),
55
+ sha256: z.string().regex(SHA256).optional(),
56
+ })
57
+ .strict(),
58
+ z
59
+ .object({
60
+ pack: z.string().regex(NAME),
61
+ url: z.string().url(),
62
+ /** The per-model page that states the licence. */
63
+ page: z.string().url(),
64
+ sha256: z.string().regex(SHA256).optional(),
65
+ })
66
+ .strict(),
67
+ /** Built from primitives at load (gems, cells): no file, no licence question. */
68
+ z.object({ procedural: z.string().regex(NAME) }).strict(),
69
+ /** Another library entry dressed with an appearance (e.g. a monster truck is the truck on big wheels). */
70
+ z.object({ base: z.string().regex(LIBRARY_ID), appearance: appearanceSchema }).strict(),
71
+ /**
72
+ * The voxel style: another entry's file rebuilt as voxels at library build time, `res` voxels
73
+ * tall (greedy-meshed, occlusion baked per face, each voxel riding its bone so the clips still play).
74
+ */
75
+ z.object({ voxelize: z.string().regex(LIBRARY_ID), res: z.number().int().min(4).max(96) }).strict(),
76
+ ]);
77
+
78
+ const PX = z.number().int().min(0).max(4096);
79
+ const HEX = z.string().regex(/^#[0-9a-f]{6}$/);
80
+
81
+ /**
82
+ * How the library build cuts a pixel-style sprite out of its source file.
83
+ * The build applies these, then snaps every pixel to the style's palette
84
+ * with all-or-nothing alpha, so sprites from different packs read as one
85
+ * game and stay crisp when the runtime scales them (in the Net Forge asset pipeline).
86
+ */
87
+ export const spriteSchema = z
88
+ .object({
89
+ /** Cut from the source first, px [x, y, w, h] (a prop or a tree out of a tilemap). */
90
+ crop: z.tuple([PX, PX, PX, PX]).optional(),
91
+ /** A background colour the build turns transparent. */
92
+ key: HEX.optional(),
93
+ /** Rectangles cleared before the crop, px [x, y, w, h] (a neighbour poking into it). */
94
+ clear: z.array(z.tuple([PX, PX, PX, PX])).max(8).optional(),
95
+ /**
96
+ * The pack's thick outline colour: the build strips it where it touches
97
+ * the outside and redraws a 1 px outline, so the sprite is its silhouette
98
+ * and not a dark tile (Tiny Dungeon and Pixel Platformer pad to the edge).
99
+ */
100
+ outline: HEX.optional(),
101
+ /** Lights itself (fire, gems, magic): the night does not darken it. */
102
+ glow: z.boolean().optional(),
103
+ })
104
+ .strict();
105
+ export type SpriteDef = z.output<typeof spriteSchema>;
106
+
107
+ export const assetSchema = z
108
+ .object({
109
+ id: z.string().regex(LIBRARY_ID, 'library ids look like "style.lowpoly.truck"'),
110
+ kind: z.enum(ASSET_KINDS),
111
+ plan: z.enum(BODY_PLANS),
112
+ /** Shared rig and clip set (rigs.ts), for animated models. */
113
+ rig: z.string().regex(NAME).optional(),
114
+ source: sourceSchema,
115
+ /** Height in metres the model is scaled to (sprites: tiles tall). */
116
+ height: z.number().positive().max(60),
117
+ /** One line the model reads when it picks assets. Plain, no franchise names. */
118
+ line: z.string().min(3).max(90),
119
+ /** Search words: what a creator might call it. */
120
+ tags: z.array(z.string().regex(/^[a-z0-9 -]+$/)).min(1).max(16),
121
+ /** Old ids that still resolve here (drafts written before the library). */
122
+ aliases: z.array(z.string().regex(LIBRARY_ID)).default([]),
123
+ /** Yaw that turns the model to face -z (the runtime's forward), radians. */
124
+ yaw: z.number().min(-Math.PI * 2).max(Math.PI * 2).default(0),
125
+ /** 2D styles: how the build cuts the sprite (absent: the whole file). */
126
+ sprite: spriteSchema.optional(),
127
+ })
128
+ .strict();
129
+
130
+ export const styleManifestSchema = z
131
+ .object({
132
+ style: z.enum(LIBRARY_STYLES),
133
+ /** "3d" models (glTF) or "2d" sprites (PNG). */
134
+ dimension: z.enum(["3d", "2d"]),
135
+ /** Palette the style's generated art is locked to (AssetGenerator). */
136
+ palette: z.array(z.string().regex(/^#[0-9a-f]{6}$/)).min(4).max(32),
137
+ assets: z.array(assetSchema).min(1),
138
+ })
139
+ .strict();
140
+
141
+ export const galleryEntrySchema = z
142
+ .object({
143
+ /** Stable id of the example, lower_snake_case. */
144
+ id: z.string().regex(NAME),
145
+ name: z.string().min(2).max(32),
146
+ /** One short line. */
147
+ line: z.string().min(3).max(60),
148
+ model: z.string().regex(LIBRARY_ID),
149
+ appearance: appearanceSchema.optional(),
150
+ /** Thumbnail path under the library's thumbs/ folder. */
151
+ thumb: z.string().regex(/^[a-z0-9_/.-]+\.(png|webp)$/),
152
+ })
153
+ .strict();
154
+
155
+ export const gallerySchema = z.partialRecord(z.enum(LIBRARY_STYLES), z.array(galleryEntrySchema));
156
+
157
+ /** Sound ids: "audio.sfx.hit", "audio.music.battle_theme_a". */
158
+ export const SOUND_ID = /^audio\.(sfx|music)\.[a-z][a-z0-9_]{0,47}$/;
159
+ /** Formats every browser decodes (Ogg Vorbis: Chrome, Firefox, Safari 17+; MP3: all). */
160
+ export const SOUND_FORMATS = ["ogg", "mp3"] as const;
161
+
162
+ /**
163
+ * One sound in the audio section (manifest/audio.json): a sound effect a
164
+ * FeelKit cue plays, or a music loop. Same rules as models: a pack with a
165
+ * verified CC0 licence, bytes pinned by sha256, files never in git.
166
+ */
167
+ export const soundSchema = z
168
+ .object({
169
+ id: z.string().regex(SOUND_ID, 'sound ids look like "audio.sfx.hit" or "audio.music.<name>"'),
170
+ kind: z.enum(["sfx", "music"]),
171
+ /** The FeelKit cue this effect plays by default (hit, kill, levelUp, ...). */
172
+ cue: z.string().regex(/^[a-z][a-zA-Z0-9]{0,31}$/).optional(),
173
+ /** Mix level: the sample's gain so a default mix sits well (1 = as recorded). */
174
+ gain: z.number().positive().max(8).default(1),
175
+ line: z.string().min(3).max(90),
176
+ tags: z.array(z.string().regex(/^[a-z0-9 -]+$/)).min(1).max(16),
177
+ source: z.union([
178
+ z
179
+ .object({
180
+ pack: z.string().regex(NAME),
181
+ path: z.string().min(1).refine((p) => !p.includes(".."), "paths stay inside the archive"),
182
+ sha256: z.string().regex(SHA256),
183
+ })
184
+ .strict(),
185
+ z.object({ pack: z.string().regex(NAME), url: z.string().url(), page: z.string().url(), sha256: z.string().regex(SHA256) }).strict(),
186
+ ]),
187
+ })
188
+ .strict()
189
+ .refine((s) => SOUND_FORMATS.some((f) => ("path" in s.source ? s.source.path : s.source.url).toLowerCase().endsWith(`.${f}`)), "sounds are .ogg or .mp3")
190
+ .refine((s) => s.kind === (s.id.split(".")[1] ?? ""), "a sound's kind matches its id (audio.sfx.* or audio.music.*)");
191
+
192
+ export const audioManifestSchema = z.object({ sounds: z.array(soundSchema).min(1) }).strict();
193
+
194
+ export type SoundDef = z.output<typeof soundSchema>;
195
+ export type AudioManifest = z.output<typeof audioManifestSchema>;
196
+ export type PackDef = z.output<typeof packSchema>;
197
+ export type AssetDef = z.output<typeof assetSchema>;
198
+ export type AssetSource = AssetDef["source"];
199
+ export type StyleManifest = z.output<typeof styleManifestSchema>;
200
+ export type GalleryEntry = z.output<typeof galleryEntrySchema>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Character animation: the engine runtime's AnimatedCharacter (skinned
3
+ * glTF, locomotion crossfades, ground-speed playback) driven through the
4
+ * library's shared rigs. The rig maps the canonical clips (idle, walk, run,
5
+ * attack, shoot, hit, die) to whatever each pack calls them, so templates
6
+ * never deal in file clip names.
7
+ */
8
+ import { AnimatedCharacter } from "@netforge/engine/runtime";
9
+ import type { AnimationClip, Object3D } from "three/webgpu";
10
+ import { clipFor, type Clip, type RigDef } from "../rigs.js";
11
+
12
+ export interface CharacterAnimator {
13
+ /** Ground speed in m/s: picks idle, walk or run and matches playback rate. */
14
+ setSpeed(mps: number): void;
15
+ /** Play a canonical clip; `once` returns to locomotion when it ends; `hold` stays on its last frame. False when the rig has no such clip. */
16
+ play(clip: Clip, opts?: { once?: boolean; hold?: boolean }): boolean;
17
+ update(dt: number): void;
18
+ /** The file clip playing now, or null. */
19
+ readonly playing: string | null;
20
+ dispose(): void;
21
+ }
22
+
23
+ /** A model with no clips: every call is a no-op. */
24
+ export class StaticAnimator implements CharacterAnimator {
25
+ readonly playing = null;
26
+ setSpeed(): void {}
27
+ play(): boolean {
28
+ return false;
29
+ }
30
+ update(): void {}
31
+ dispose(): void {}
32
+ }
33
+
34
+ /** CharacterAnimator over the engine's AnimatedCharacter. */
35
+ export class RigAnimator implements CharacterAnimator {
36
+ readonly character: AnimatedCharacter;
37
+ private readonly names: string[];
38
+ private held = false;
39
+
40
+ constructor(root: Object3D, clips: readonly AnimationClip[], private readonly rig: RigDef) {
41
+ this.names = clips.map((c) => c.name);
42
+ const pick = (c: Clip) => clipFor(rig, c, this.names) ?? undefined;
43
+ const idle = pick("idle");
44
+ const walk = pick("walk");
45
+ const run = pick("run");
46
+ this.character = new AnimatedCharacter(root, clips, {
47
+ clips: { ...(idle ? { idle } : {}), ...(walk ? { walk } : {}), ...(run ? { run } : {}) },
48
+ walkSpeed: rig.walkSpeed,
49
+ runSpeed: rig.runSpeed,
50
+ });
51
+ }
52
+
53
+ get playing(): string | null {
54
+ return this.character.playing;
55
+ }
56
+
57
+ setSpeed(mps: number): void {
58
+ if (!this.held) this.character.setSpeed(mps);
59
+ }
60
+
61
+ play(clip: Clip, opts: { once?: boolean; hold?: boolean } = {}): boolean {
62
+ const name = clipFor(this.rig, clip, this.names);
63
+ if (!name) return false;
64
+ if (opts.hold) {
65
+ this.held = true;
66
+ return this.character.play(name, { loop: "once" });
67
+ }
68
+ this.held = false;
69
+ if (opts.once) return this.character.oneShot(name);
70
+ if (clip === "idle" || clip === "walk" || clip === "run") {
71
+ // Back to locomotion: the engine picks idle, walk or run from the ground speed.
72
+ this.character.setSpeed(0);
73
+ return true;
74
+ }
75
+ return this.character.play(name);
76
+ }
77
+
78
+ update(dt: number): void {
79
+ this.character.update(dt);
80
+ }
81
+
82
+ dispose(): void {
83
+ this.character.dispose();
84
+ }
85
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The library on the client: resolve a library id (and a look) to something
3
+ * drawable. Models load through the engine runtime's AssetLoader (meshopt
4
+ * and KTX2 decoding, one fetch per id) unless a ModelSource is given.
5
+ * Every method rejects when the file is missing (offline with no library
6
+ * built, or a bad base URL); templates catch that and keep their primitive
7
+ * shapes, so a game always draws.
8
+ *
9
+ * Network: one GET per distinct library id, cached for the session. No
10
+ * chain reads.
11
+ */
12
+ import type { AppearanceDef } from "@netforge/engine";
13
+ import { AssetLoader, markSprite, markSpriteMaterial, SPRITE_TEXEL, spriteBillboardNode } from "@netforge/engine/runtime";
14
+ import type { RendererHandle } from "@netforge/engine/runtime";
15
+ import { Color, DoubleSide, Group, Mesh, MeshStandardNodeMaterial, NearestFilter, Object3D, PlaneGeometry, SRGBColorSpace, TextureLoader } from "three/webgpu";
16
+ import { float, texture, uv, vec2, vec3, vec4 } from "three/tsl";
17
+ import { resolveLook, type ResolvedLook } from "../look.js";
18
+ import { assetPath, libraryUrl } from "../paths.js";
19
+ import { restyleId } from "../restyle.js";
20
+ import { RIGS } from "../rigs.js";
21
+ import { RigAnimator, StaticAnimator, type CharacterAnimator } from "./animator.js";
22
+ import { bakeCrowd, poseModel, type CrowdModel } from "./crowd.js";
23
+ import { proceduralAsset } from "./parts.js";
24
+ import { prepareModel, type LoadedModel, type PreparedModel } from "./prepare.js";
25
+
26
+ /** Anything that can load a glTF by library id (the engine's AssetLoader fits). */
27
+ export interface ModelSource {
28
+ load(id: string): Promise<LoadedModel>;
29
+ }
30
+
31
+ export interface LibraryClientOptions {
32
+ /** Base URL of the built library: "./library" next to the page in dev, `<CDN>/ipfs/<cid>` in production. */
33
+ readonly baseUrl: string;
34
+ /** Where basis_transcoder.js/.wasm are served, for KTX2 textures. */
35
+ readonly transcoderPath?: string;
36
+ readonly source?: ModelSource;
37
+ /**
38
+ * The game's art style (game({ style })): a model id from another style
39
+ * draws this style's model of the same name when the style stocks one
40
+ * (`style.lowpoly.ghost` -> `style.ps1.ghost`).
41
+ */
42
+ readonly style?: string;
43
+ }
44
+
45
+ /**
46
+ * Pixel scale of a sprite asked to stand `wanted` m tall: 1 (its own
47
+ * pixels) for anything near its natural size, 2 for the big ones (bosses,
48
+ * trees), so the picture keeps one or two pixel sizes and never a smear.
49
+ */
50
+ export function spriteMultiple(wanted: number, natural: number): number {
51
+ return natural > 0 && wanted / natural >= 2.2 ? 2 : 1;
52
+ }
53
+
54
+ export interface CharacterInstance extends PreparedModel {
55
+ readonly animator: CharacterAnimator;
56
+ readonly look: ResolvedLook;
57
+ }
58
+
59
+ export class LibraryClient {
60
+ private readonly source: ModelSource;
61
+ private readonly crowds = new Map<string, Promise<CrowdModel>>();
62
+
63
+ constructor(handle: RendererHandle | null, private readonly opts: LibraryClientOptions) {
64
+ if (opts.source) this.source = opts.source;
65
+ else {
66
+ if (!handle) throw new Error("LibraryClient needs a renderer or a ModelSource");
67
+ const loader = new AssetLoader(handle.renderer, { resolve: (id) => this.url(id), transcoderPath: opts.transcoderPath ?? `${opts.baseUrl.replace(/\/+$/, "")}/basis/` });
68
+ this.source = { load: (id) => loader.load(id) };
69
+ }
70
+ }
71
+
72
+ /** The URL a library id is fetched from. Throws for ids with no file. */
73
+ url(id: string): string {
74
+ const look = resolveLook(id);
75
+ const path = assetPath(look.asset);
76
+ if (!path) throw new Error(`${id} has no file (procedural or variant)`);
77
+ return libraryUrl(this.opts.baseUrl, path);
78
+ }
79
+
80
+ private async loaded(look: ResolvedLook): Promise<LoadedModel> {
81
+ return this.source.load(look.asset.id);
82
+ }
83
+
84
+ private isSprite(look: ResolvedLook): boolean {
85
+ return assetPath(look.asset)?.endsWith(".png") ?? false;
86
+ }
87
+
88
+ /**
89
+ * A 2D sprite as an upright plane, feet at y = 0, in metres at
90
+ * SPRITE_TEXEL per source pixel (times a whole multiple), pixels kept
91
+ * crisp. Marked for billboarding (crowds and characters face the camera).
92
+ * Lit like the scene but carrying its own colour as emissive, so a sprite
93
+ * reads at night; `glow` sprites (fire, gems) light themselves fully.
94
+ */
95
+ private async sprite(look: ResolvedLook, height?: number, hero = false): Promise<{ geometry: PlaneGeometry; material: MeshStandardNodeMaterial; height: number }> {
96
+ const tex = await new TextureLoader().loadAsync(this.url(look.asset.id));
97
+ tex.magFilter = NearestFilter;
98
+ tex.minFilter = NearestFilter;
99
+ tex.generateMipmaps = false;
100
+ tex.colorSpace = SRGBColorSpace;
101
+ const img = tex.image as { width: number; height: number };
102
+ const natural = img.height * SPRITE_TEXEL;
103
+ // A character (a player) stands about twice as tall as the 16 px crowd, in whole pixel multiples, so a player finds themself at once.
104
+ const k = Math.max(hero ? Math.max(1, Math.round(32 / img.height)) : 1, spriteMultiple((height ?? look.asset.height) * look.scale, natural));
105
+ const h = natural * k;
106
+ const geometry = new PlaneGeometry(img.width * SPRITE_TEXEL * k, h);
107
+ geometry.translate(0, h / 2, 0);
108
+ markSprite(geometry);
109
+ const material = new MeshStandardNodeMaterial({ map: tex, emissiveMap: tex, side: DoubleSide, roughness: 1, metalness: 0 });
110
+ // As a node, not `alphaTest`: NodeMaterial.clone() drops `alphaTest` (three r186), and templates clone crowd materials.
111
+ material.alphaTestNode = float(0.5);
112
+ material.emissive.setRGB(1, 1, 1);
113
+ material.emissiveIntensity = look.asset.sprite?.glow ? 1 : hero ? 0.6 : 0.45;
114
+ if (hero) {
115
+ // The player: full colour (no tint) and a 1 px ember rim where the silhouette meets the clear, the brightest thing on screen.
116
+ const t = texture(tex);
117
+ const step = vec2(1 / Math.max(1, img.width), 1 / Math.max(1, img.height));
118
+ const at = (dx: number, dy: number) => texture(tex, uv().add(step.mul(vec2(dx, dy)))).a;
119
+ const edge = t.a.greaterThan(0.5).and(at(1, 0).min(at(-1, 0)).min(at(0, 1)).min(at(0, -1)).lessThan(0.5));
120
+ const rim = vec3(1, 0.86, 0.35);
121
+ // Unlit: its own colours whatever the lantern at its feet does (lit, it washed out to white).
122
+ material.colorNode = vec4(vec3(0), t.a);
123
+ material.emissiveNode = edge.select(rim.mul(0.85), t.rgb.mul(0.65));
124
+ } else if (look.tint !== null) material.color.setHex(look.tint).lerp(new Color(1, 1, 1), 0.5);
125
+ markSpriteMaterial(material);
126
+ return { geometry, material, height: h };
127
+ }
128
+
129
+ /** An animated character for the scene (players, bosses, a few dozen at most). */
130
+ async character(modelId: string, look?: AppearanceDef, opts: { height?: number } = {}): Promise<CharacterInstance> {
131
+ const l = resolveLook(restyleId(modelId, this.opts.style), look);
132
+ const src = l.asset.source;
133
+ if ("procedural" in src) {
134
+ const p = proceduralAsset(src.procedural);
135
+ if (!p) throw new Error(`unknown procedural asset ${src.procedural}`);
136
+ const root = new Group();
137
+ const mesh = new Mesh(p.geometry, p.material);
138
+ root.add(mesh);
139
+ return { root, model: mesh, height: 0.5, width: 0.5, depth: 0.5, hand: null, animator: new StaticAnimator(), look: l };
140
+ }
141
+ if (this.isSprite(l)) {
142
+ const s = await this.sprite(l, opts.height, true);
143
+ const root = new Group();
144
+ // One character, so it faces the camera from its own node (crowds billboard in AnimatedCrowd).
145
+ s.material.positionNode = spriteBillboardNode();
146
+ const mesh = new Mesh(s.geometry, s.material);
147
+ root.add(mesh);
148
+ return { root, model: mesh, height: s.height, width: s.height, depth: 0.05, hand: null, animator: new StaticAnimator(), look: l };
149
+ }
150
+ const loaded = await this.loaded(l);
151
+ const prepared = prepareModel(loaded, l, opts);
152
+ const rig = l.asset.rig ? RIGS[l.asset.rig] : undefined;
153
+ const animator = rig && loaded.animations.length ? new RigAnimator(prepared.model, loaded.animations, rig) : new StaticAnimator();
154
+ return { ...prepared, animator, look: l };
155
+ }
156
+
157
+ /** A static prop (trees, buildings, held weapons). */
158
+ async prop(modelId: string, look?: AppearanceDef, opts: { height?: number } = {}): Promise<Object3D> {
159
+ return (await this.character(modelId, look, opts)).root;
160
+ }
161
+
162
+ /** Static instanced parts for crowds. Cached per model, look and height. */
163
+ crowd(modelId: string, look?: AppearanceDef, opts: { height?: number } = {}): Promise<CrowdModel> {
164
+ const l = resolveLook(restyleId(modelId, this.opts.style), look);
165
+ const key = `${l.key}|${opts.height ?? "-"}`;
166
+ let p = this.crowds.get(key);
167
+ if (!p) {
168
+ p = this.buildCrowd(l, opts);
169
+ p.catch(() => this.crowds.delete(key));
170
+ this.crowds.set(key, p);
171
+ }
172
+ return p;
173
+ }
174
+
175
+ private async buildCrowd(l: ResolvedLook, opts: { height?: number }): Promise<CrowdModel> {
176
+ const src = l.asset.source;
177
+ if ("procedural" in src) {
178
+ const p = proceduralAsset(src.procedural);
179
+ if (!p) throw new Error(`unknown procedural asset ${src.procedural}`);
180
+ return { parts: [{ geometry: p.geometry, material: p.material }], height: 0.5, triangles: 0 };
181
+ }
182
+ if (this.isSprite(l)) {
183
+ const s = await this.sprite(l, opts.height);
184
+ return { parts: [{ geometry: s.geometry, material: s.material }], height: s.height, triangles: 2 };
185
+ }
186
+ const loaded = await this.loaded(l);
187
+ const prepared = prepareModel(loaded, l, opts);
188
+ poseModel(prepared, loaded.animations, l.asset.rig ? RIGS[l.asset.rig] : undefined);
189
+ return bakeCrowd(prepared);
190
+ }
191
+ }