@dice-o-rolla/dice-assets 0.2.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 +202 -0
- package/NOTICE +4 -0
- package/README.md +61 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/assets/runtime/audio/classic-coin.webm +0 -0
- package/assets/runtime/audio/classic-dice.webm +0 -0
- package/assets/runtime/audio/classic-felt.webm +0 -0
- package/assets/runtime/audio/classic-metal.webm +0 -0
- package/assets/runtime/audio/classic-wood-table.webm +0 -0
- package/assets/runtime/audio/classic-wood-tray.webm +0 -0
- package/assets/runtime/audio/resin.webm +0 -0
- package/assets/runtime/audio/wood.webm +0 -0
- package/assets/runtime/catalog.json +616 -0
- package/assets/runtime/textures/digits.ktx2 +0 -0
- package/assets/runtime/textures/speckle-base.ktx2 +0 -0
- package/assets/runtime/textures/speckle-normal.ktx2 +0 -0
- package/assets/runtime/textures/speckle-orm.ktx2 +0 -0
- package/dist/asset-registry.d.ts +31 -0
- package/dist/asset-registry.js +203 -0
- package/dist/catalog-loader.d.ts +15 -0
- package/dist/catalog-loader.js +80 -0
- package/dist/impact-sound-gate.d.ts +20 -0
- package/dist/impact-sound-gate.js +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/three-material-provider.d.ts +16 -0
- package/dist/three-material-provider.js +132 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +0 -0
- package/dist/web-audio-player.d.ts +45 -0
- package/dist/web-audio-player.js +110 -0
- package/package.json +53 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
const ASSET_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/i;
|
|
2
|
+
function assertId(value) {
|
|
3
|
+
if (!ASSET_ID_PATTERN.test(value)) {
|
|
4
|
+
throw new RangeError('asset id must be a non-empty portable identifier');
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
function immutable(source) {
|
|
8
|
+
assertId(source.id);
|
|
9
|
+
return deepFreeze(structuredClone(source));
|
|
10
|
+
}
|
|
11
|
+
function deepFreeze(value) {
|
|
12
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
13
|
+
for (const child of Object.values(value))
|
|
14
|
+
deepFreeze(child);
|
|
15
|
+
Object.freeze(value);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export class AssetRegistry {
|
|
20
|
+
#assets = new Map();
|
|
21
|
+
#validate;
|
|
22
|
+
#revision = 0;
|
|
23
|
+
constructor(validate = () => undefined) {
|
|
24
|
+
this.#validate = validate;
|
|
25
|
+
}
|
|
26
|
+
get revision() {
|
|
27
|
+
return this.#revision;
|
|
28
|
+
}
|
|
29
|
+
register(source, options = {}) {
|
|
30
|
+
const asset = immutable(source);
|
|
31
|
+
this.#validate(asset);
|
|
32
|
+
if (this.#assets.has(asset.id) && options.replace !== true) {
|
|
33
|
+
throw new Error(`Dice asset "${asset.id}" is already registered`);
|
|
34
|
+
}
|
|
35
|
+
this.#assets.set(asset.id, asset);
|
|
36
|
+
this.#revision += 1;
|
|
37
|
+
return asset;
|
|
38
|
+
}
|
|
39
|
+
unregister(id) {
|
|
40
|
+
const asset = this.#assets.get(id);
|
|
41
|
+
if (asset === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
this.#assets.delete(id);
|
|
44
|
+
this.#revision += 1;
|
|
45
|
+
return asset;
|
|
46
|
+
}
|
|
47
|
+
get(id) {
|
|
48
|
+
return this.#assets.get(id);
|
|
49
|
+
}
|
|
50
|
+
has(id) {
|
|
51
|
+
return this.#assets.has(id);
|
|
52
|
+
}
|
|
53
|
+
list() {
|
|
54
|
+
return Object.freeze(Array.from(this.#assets.values()));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export class DiceAssetRegistry {
|
|
58
|
+
audio = new AssetRegistry(validateAudioSprite);
|
|
59
|
+
audioBanks = new AssetRegistry(validateAudioBank);
|
|
60
|
+
materials = new AssetRegistry(validateMaterial);
|
|
61
|
+
patterns = new AssetRegistry(validatePattern);
|
|
62
|
+
skins = new AssetRegistry(validateSkin);
|
|
63
|
+
faces = new AssetRegistry(validateFaceAtlas);
|
|
64
|
+
get revision() {
|
|
65
|
+
return (this.audio.revision +
|
|
66
|
+
this.audioBanks.revision +
|
|
67
|
+
this.materials.revision +
|
|
68
|
+
this.patterns.revision +
|
|
69
|
+
this.skins.revision +
|
|
70
|
+
this.faces.revision);
|
|
71
|
+
}
|
|
72
|
+
registerCatalog(catalog) {
|
|
73
|
+
if (catalog.schemaVersion !== 1)
|
|
74
|
+
throw new RangeError('unsupported asset catalog schema');
|
|
75
|
+
for (const asset of catalog.audioSprites ?? [])
|
|
76
|
+
this.audio.register(asset);
|
|
77
|
+
for (const asset of catalog.audioBanks ?? [])
|
|
78
|
+
this.audioBanks.register(asset);
|
|
79
|
+
for (const asset of catalog.materials ?? [])
|
|
80
|
+
this.materials.register(asset);
|
|
81
|
+
for (const asset of catalog.patterns ?? [])
|
|
82
|
+
this.patterns.register(asset);
|
|
83
|
+
for (const asset of catalog.faces ?? [])
|
|
84
|
+
this.faces.register(asset);
|
|
85
|
+
for (const asset of catalog.skins ?? [])
|
|
86
|
+
this.skins.register(asset);
|
|
87
|
+
this.validateReferences();
|
|
88
|
+
}
|
|
89
|
+
validateReferences() {
|
|
90
|
+
for (const bank of this.audioBanks.list()) {
|
|
91
|
+
const sprite = this.audio.get(bank.spriteId);
|
|
92
|
+
if (sprite === undefined)
|
|
93
|
+
throw new Error(`Audio bank "${bank.id}" references missing sprite`);
|
|
94
|
+
for (const clipId of bank.clipIds) {
|
|
95
|
+
if (sprite.clips[clipId] === undefined) {
|
|
96
|
+
throw new Error(`Audio bank "${bank.id}" references missing clip "${clipId}"`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
assertRange(bank.forceRange, 'forceRange');
|
|
100
|
+
assertRange(bank.gainRange, 'gainRange');
|
|
101
|
+
}
|
|
102
|
+
for (const skin of this.skins.list()) {
|
|
103
|
+
if (!this.materials.has(skin.materialId)) {
|
|
104
|
+
throw new Error(`Skin "${skin.id}" references missing material`);
|
|
105
|
+
}
|
|
106
|
+
if (!this.patterns.has(skin.patternId)) {
|
|
107
|
+
throw new Error(`Skin "${skin.id}" references missing pattern`);
|
|
108
|
+
}
|
|
109
|
+
if (skin.faceAtlasId !== undefined && !this.faces.has(skin.faceAtlasId)) {
|
|
110
|
+
throw new Error(`Skin "${skin.id}" references missing face atlas`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
registerSkin(source, options) {
|
|
115
|
+
return this.skins.register(source, options);
|
|
116
|
+
}
|
|
117
|
+
unregisterSkin(id) {
|
|
118
|
+
return this.skins.unregister(id);
|
|
119
|
+
}
|
|
120
|
+
getSkin(id) {
|
|
121
|
+
return this.skins.get(id);
|
|
122
|
+
}
|
|
123
|
+
listSkins() {
|
|
124
|
+
return this.skins.list();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function assertRange(range, name) {
|
|
128
|
+
if (!range.every(Number.isFinite) || range[0] < 0 || range[1] <= range[0]) {
|
|
129
|
+
throw new RangeError(`${name} must be a finite ascending non-negative range`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function validateReference(reference, name) {
|
|
133
|
+
if (reference.uri.trim().length === 0)
|
|
134
|
+
throw new RangeError(`${name} uri must not be empty`);
|
|
135
|
+
}
|
|
136
|
+
function validateTexture(texture, name) {
|
|
137
|
+
validateReference(texture, name);
|
|
138
|
+
if (texture.mediaType !== 'image/ktx2' || !texture.mipmaps) {
|
|
139
|
+
throw new RangeError(`${name} must be a mipmapped KTX2 texture`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function validateAudioSprite(sprite) {
|
|
143
|
+
validateReference(sprite.audio, 'audio sprite');
|
|
144
|
+
if (sprite.channels !== 1)
|
|
145
|
+
throw new RangeError('runtime audio sprites must be mono');
|
|
146
|
+
if (Object.keys(sprite.clips).length === 0)
|
|
147
|
+
throw new RangeError('audio sprite must contain clips');
|
|
148
|
+
for (const [id, clip] of Object.entries(sprite.clips)) {
|
|
149
|
+
assertId(id);
|
|
150
|
+
if (!Number.isFinite(clip.offsetSeconds) ||
|
|
151
|
+
clip.offsetSeconds < 0 ||
|
|
152
|
+
!Number.isFinite(clip.durationSeconds) ||
|
|
153
|
+
clip.durationSeconds <= 0) {
|
|
154
|
+
throw new RangeError('audio sprite clip timing must be finite and non-negative');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function validateAudioBank(bank) {
|
|
159
|
+
assertId(bank.spriteId);
|
|
160
|
+
if (bank.clipIds.length === 0)
|
|
161
|
+
throw new RangeError('audio bank must contain clip ids');
|
|
162
|
+
for (const id of bank.clipIds)
|
|
163
|
+
assertId(id);
|
|
164
|
+
assertRange(bank.forceRange, 'forceRange');
|
|
165
|
+
assertRange(bank.gainRange, 'gainRange');
|
|
166
|
+
if ((bank.pitchVariationCents ?? 0) < 0 || (bank.gainVariation ?? 0) < 0) {
|
|
167
|
+
throw new RangeError('audio variation must not be negative');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function validateMaterial(material) {
|
|
171
|
+
for (const [name, value] of [
|
|
172
|
+
['roughness', material.roughness],
|
|
173
|
+
['metalness', material.metalness],
|
|
174
|
+
]) {
|
|
175
|
+
if (!Number.isFinite(value) || value < 0 || value > 1)
|
|
176
|
+
throw new RangeError(`${name} must be within [0, 1]`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function validatePattern(pattern) {
|
|
180
|
+
validateTexture(pattern.baseColor, 'baseColor');
|
|
181
|
+
if (pattern.normal !== undefined)
|
|
182
|
+
validateTexture(pattern.normal, 'normal');
|
|
183
|
+
if (pattern.orm !== undefined)
|
|
184
|
+
validateTexture(pattern.orm, 'orm');
|
|
185
|
+
}
|
|
186
|
+
function validateSkin(skin) {
|
|
187
|
+
assertId(skin.materialId);
|
|
188
|
+
assertId(skin.patternId);
|
|
189
|
+
if (skin.faceAtlasId !== undefined)
|
|
190
|
+
assertId(skin.faceAtlasId);
|
|
191
|
+
if (skin.saturation !== undefined && (!Number.isFinite(skin.saturation) || skin.saturation < 0)) {
|
|
192
|
+
throw new RangeError('skin saturation must be a non-negative finite number');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function validateFaceAtlas(atlas) {
|
|
196
|
+
validateTexture(atlas.texture, 'face atlas');
|
|
197
|
+
if (!Number.isSafeInteger(atlas.width) ||
|
|
198
|
+
atlas.width <= 0 ||
|
|
199
|
+
!Number.isSafeInteger(atlas.height) ||
|
|
200
|
+
atlas.height <= 0) {
|
|
201
|
+
throw new RangeError('face atlas dimensions must be positive integers');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DiceAssetRegistry } from './asset-registry.js';
|
|
2
|
+
import type { DiceAssetCatalogManifest } from './types.js';
|
|
3
|
+
export interface DiceAssetCatalogLoaderOptions {
|
|
4
|
+
readonly fetch?: (uri: string) => Promise<{
|
|
5
|
+
json(): Promise<unknown>;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
export declare class DiceAssetCatalogLoader {
|
|
9
|
+
#private;
|
|
10
|
+
readonly registry: DiceAssetRegistry;
|
|
11
|
+
constructor(registry: DiceAssetRegistry, options?: DiceAssetCatalogLoaderOptions);
|
|
12
|
+
load(uri: string): Promise<DiceAssetCatalogManifest>;
|
|
13
|
+
}
|
|
14
|
+
export declare function resolveCatalogReferences(catalog: DiceAssetCatalogManifest, catalogUri: string): DiceAssetCatalogManifest;
|
|
15
|
+
export declare function parseCatalog(source: unknown): DiceAssetCatalogManifest;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export class DiceAssetCatalogLoader {
|
|
2
|
+
registry;
|
|
3
|
+
#fetch;
|
|
4
|
+
constructor(registry, options = {}) {
|
|
5
|
+
this.registry = registry;
|
|
6
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
7
|
+
}
|
|
8
|
+
async load(uri) {
|
|
9
|
+
if (uri.trim().length === 0)
|
|
10
|
+
throw new RangeError('catalog uri must not be empty');
|
|
11
|
+
const source = await (await this.#fetch(uri)).json();
|
|
12
|
+
const catalog = resolveCatalogReferences(parseCatalog(source), uri);
|
|
13
|
+
this.registry.registerCatalog(catalog);
|
|
14
|
+
return catalog;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function resolveCatalogReferences(catalog, catalogUri) {
|
|
18
|
+
const resolveReference = (reference) => ({
|
|
19
|
+
...reference,
|
|
20
|
+
uri: resolveAssetUri(reference.uri, catalogUri),
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
...catalog,
|
|
24
|
+
...(catalog.audioSprites === undefined
|
|
25
|
+
? {}
|
|
26
|
+
: {
|
|
27
|
+
audioSprites: catalog.audioSprites.map((sprite) => ({
|
|
28
|
+
...sprite,
|
|
29
|
+
audio: resolveReference(sprite.audio),
|
|
30
|
+
})),
|
|
31
|
+
}),
|
|
32
|
+
...(catalog.patterns === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: {
|
|
35
|
+
patterns: catalog.patterns.map((pattern) => ({
|
|
36
|
+
...pattern,
|
|
37
|
+
baseColor: resolveReference(pattern.baseColor),
|
|
38
|
+
...(pattern.normal === undefined ? {} : { normal: resolveReference(pattern.normal) }),
|
|
39
|
+
...(pattern.orm === undefined ? {} : { orm: resolveReference(pattern.orm) }),
|
|
40
|
+
})),
|
|
41
|
+
}),
|
|
42
|
+
...(catalog.faces === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
faces: catalog.faces.map((atlas) => ({
|
|
46
|
+
...atlas,
|
|
47
|
+
texture: resolveReference(atlas.texture),
|
|
48
|
+
})),
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function resolveAssetUri(assetUri, catalogUri) {
|
|
53
|
+
if (/^(?:[a-z][a-z\d+.-]*:|\/)/i.test(assetUri))
|
|
54
|
+
return assetUri;
|
|
55
|
+
const dummyOrigin = 'https://dice-assets.invalid';
|
|
56
|
+
const resolved = new URL(assetUri, new URL(catalogUri, `${dummyOrigin}/`));
|
|
57
|
+
return resolved.origin === dummyOrigin
|
|
58
|
+
? `${resolved.pathname}${resolved.search}${resolved.hash}`
|
|
59
|
+
: resolved.href;
|
|
60
|
+
}
|
|
61
|
+
export function parseCatalog(source) {
|
|
62
|
+
if (!isCatalogManifest(source)) {
|
|
63
|
+
throw new TypeError('asset catalog must use schemaVersion 1');
|
|
64
|
+
}
|
|
65
|
+
return source;
|
|
66
|
+
}
|
|
67
|
+
function isCatalogManifest(source) {
|
|
68
|
+
if (!isRecord(source))
|
|
69
|
+
return false;
|
|
70
|
+
if (source.schemaVersion !== 1)
|
|
71
|
+
return false;
|
|
72
|
+
for (const key of ['audioSprites', 'audioBanks', 'materials', 'patterns', 'skins', 'faces']) {
|
|
73
|
+
if (source[key] !== undefined && !Array.isArray(source[key]))
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
function isRecord(value) {
|
|
79
|
+
return value !== null && typeof value === 'object';
|
|
80
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface SoundCollisionEvent {
|
|
2
|
+
readonly dieId: string;
|
|
3
|
+
readonly otherDieId?: string;
|
|
4
|
+
readonly started: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface SoundImpactEvent {
|
|
7
|
+
readonly dieId: string;
|
|
8
|
+
readonly otherDieId?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Converts Rapier's persistent contact-force stream into one audible impact
|
|
12
|
+
* for each collision lifecycle. Collision-start events must be observed before
|
|
13
|
+
* their matching impact events, as emitted by DiceEngine.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ImpactSoundGate {
|
|
16
|
+
#private;
|
|
17
|
+
observeCollision(event: SoundCollisionEvent): void;
|
|
18
|
+
consumeImpact(event: SoundImpactEvent): boolean;
|
|
19
|
+
clear(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts Rapier's persistent contact-force stream into one audible impact
|
|
3
|
+
* for each collision lifecycle. Collision-start events must be observed before
|
|
4
|
+
* their matching impact events, as emitted by DiceEngine.
|
|
5
|
+
*/
|
|
6
|
+
export class ImpactSoundGate {
|
|
7
|
+
#armedContacts = new Set();
|
|
8
|
+
observeCollision(event) {
|
|
9
|
+
const key = contactKey(event);
|
|
10
|
+
if (event.started)
|
|
11
|
+
this.#armedContacts.add(key);
|
|
12
|
+
else
|
|
13
|
+
this.#armedContacts.delete(key);
|
|
14
|
+
}
|
|
15
|
+
consumeImpact(event) {
|
|
16
|
+
const key = contactKey(event);
|
|
17
|
+
if (!this.#armedContacts.has(key))
|
|
18
|
+
return false;
|
|
19
|
+
this.#armedContacts.delete(key);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
clear() {
|
|
23
|
+
this.#armedContacts.clear();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function contactKey(event) {
|
|
27
|
+
if (event.otherDieId === undefined)
|
|
28
|
+
return JSON.stringify([event.dieId, 'surface']);
|
|
29
|
+
return JSON.stringify([event.dieId, event.otherDieId].toSorted());
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { AssetRegistry, DiceAssetRegistry, type RegisterDiceAssetOptions, } from './asset-registry.js';
|
|
2
|
+
export { resolveImpactGain, WebAudioSpritePlayer, type AudioBufferSourceNodeLike, type AudioContextLike, type AudioParamLike, type GainNodeLike, type PlayImpactOptions, type WebAudioSpritePlayerOptions, } from './web-audio-player.js';
|
|
3
|
+
export { ThreeAssetMaterialProvider, type ThreeAssetMaterialProviderOptions, } from './three-material-provider.js';
|
|
4
|
+
export { DiceAssetCatalogLoader, parseCatalog, resolveCatalogReferences, type DiceAssetCatalogLoaderOptions, } from './catalog-loader.js';
|
|
5
|
+
export { ImpactSoundGate, type SoundCollisionEvent, type SoundImpactEvent, } from './impact-sound-gate.js';
|
|
6
|
+
export type { AudioBankDefinition, AudioBankKind, AudioSpriteClip, AudioSpriteManifest, DiceAssetCatalogManifest, DiceAssetMetadataValue, DiceAssetReference, DiceFaceAtlasDefinition, DiceFaceRegion, DiceMaterialDefinition, DicePatternDefinition, DiceSkinCompositeMode, DiceSkinDefinition, RuntimeTextureReference, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AssetRegistry, DiceAssetRegistry, } from './asset-registry.js';
|
|
2
|
+
export { resolveImpactGain, WebAudioSpritePlayer, } from './web-audio-player.js';
|
|
3
|
+
export { ThreeAssetMaterialProvider, } from './three-material-provider.js';
|
|
4
|
+
export { DiceAssetCatalogLoader, parseCatalog, resolveCatalogReferences, } from './catalog-loader.js';
|
|
5
|
+
export { ImpactSoundGate, } from './impact-sound-gate.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type FaceMaterialContext, type FaceMaterialResource, type ThreeFaceMaterialProvider } from '@dice-o-rolla/dice-renderer-three';
|
|
2
|
+
import { type WebGLRenderer } from 'three';
|
|
3
|
+
import type { DiceAssetRegistry } from './asset-registry.js';
|
|
4
|
+
export interface ThreeAssetMaterialProviderOptions {
|
|
5
|
+
readonly renderer: WebGLRenderer;
|
|
6
|
+
readonly transcoderPath: string;
|
|
7
|
+
}
|
|
8
|
+
/** KTX2-backed PBR adapter; lower-level renderer packages remain asset-schema agnostic. */
|
|
9
|
+
export declare class ThreeAssetMaterialProvider implements ThreeFaceMaterialProvider {
|
|
10
|
+
#private;
|
|
11
|
+
readonly registry: DiceAssetRegistry;
|
|
12
|
+
constructor(registry: DiceAssetRegistry, options: ThreeAssetMaterialProviderOptions);
|
|
13
|
+
prepareSkin(skinId: string): Promise<void>;
|
|
14
|
+
createFace(context: FaceMaterialContext): FaceMaterialResource;
|
|
15
|
+
dispose(): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ThreeMaterialFactory, } from '@dice-o-rolla/dice-renderer-three';
|
|
2
|
+
import { Color, MeshPhysicalMaterial, RepeatWrapping, SRGBColorSpace, } from 'three';
|
|
3
|
+
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
|
|
4
|
+
/** KTX2-backed PBR adapter; lower-level renderer packages remain asset-schema agnostic. */
|
|
5
|
+
export class ThreeAssetMaterialProvider {
|
|
6
|
+
registry;
|
|
7
|
+
#loader;
|
|
8
|
+
#textures = new Map();
|
|
9
|
+
#skins = new Map();
|
|
10
|
+
#fallback = new ThreeMaterialFactory();
|
|
11
|
+
constructor(registry, options) {
|
|
12
|
+
this.registry = registry;
|
|
13
|
+
this.#loader = new KTX2Loader()
|
|
14
|
+
.setTranscoderPath(options.transcoderPath)
|
|
15
|
+
.detectSupport(options.renderer);
|
|
16
|
+
}
|
|
17
|
+
async prepareSkin(skinId) {
|
|
18
|
+
if (this.#skins.has(skinId))
|
|
19
|
+
return;
|
|
20
|
+
this.registry.validateReferences();
|
|
21
|
+
const skin = this.registry.skins.get(skinId);
|
|
22
|
+
if (skin === undefined)
|
|
23
|
+
throw new Error(`Unknown skin: ${skinId}`);
|
|
24
|
+
const pattern = this.registry.patterns.get(skin.patternId);
|
|
25
|
+
const atlas = skin.faceAtlasId === undefined ? undefined : this.registry.faces.get(skin.faceAtlasId);
|
|
26
|
+
const [baseColor, normal, orm, faces] = await Promise.all([
|
|
27
|
+
this.#load(pattern.baseColor),
|
|
28
|
+
pattern.normal === undefined ? undefined : this.#load(pattern.normal),
|
|
29
|
+
pattern.orm === undefined ? undefined : this.#load(pattern.orm),
|
|
30
|
+
atlas === undefined ? undefined : this.#load(atlas.texture),
|
|
31
|
+
]);
|
|
32
|
+
baseColor.wrapS = baseColor.wrapT = RepeatWrapping;
|
|
33
|
+
if (normal !== undefined)
|
|
34
|
+
normal.wrapS = normal.wrapT = RepeatWrapping;
|
|
35
|
+
if (orm !== undefined)
|
|
36
|
+
orm.wrapS = orm.wrapT = RepeatWrapping;
|
|
37
|
+
this.#skins.set(skinId, {
|
|
38
|
+
skin,
|
|
39
|
+
baseColor,
|
|
40
|
+
...(normal === undefined ? {} : { normal }),
|
|
41
|
+
...(orm === undefined ? {} : { orm }),
|
|
42
|
+
...(faces === undefined ? {} : { faces }),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
createFace(context) {
|
|
46
|
+
const skinId = context.preset?.skinId;
|
|
47
|
+
const loaded = skinId === undefined ? undefined : this.#skins.get(skinId);
|
|
48
|
+
if (skinId !== undefined && loaded === undefined) {
|
|
49
|
+
throw new Error(`Skin "${skinId}" must be prepared before creating dice`);
|
|
50
|
+
}
|
|
51
|
+
if (loaded === undefined)
|
|
52
|
+
return this.#createFallback(context);
|
|
53
|
+
const definition = this.registry.materials.get(loaded.skin.materialId);
|
|
54
|
+
const atlas = loaded.skin.faceAtlasId === undefined
|
|
55
|
+
? undefined
|
|
56
|
+
: this.registry.faces.get(loaded.skin.faceAtlasId);
|
|
57
|
+
const region = atlas?.faces[String(context.label)] ?? atlas?.faces[String(context.faceValue)];
|
|
58
|
+
const material = new MeshPhysicalMaterial({
|
|
59
|
+
color: loaded.skin.tint ?? '#ffffff',
|
|
60
|
+
map: loaded.baseColor,
|
|
61
|
+
normalMap: loaded.normal ?? null,
|
|
62
|
+
aoMap: loaded.orm ?? null,
|
|
63
|
+
roughnessMap: loaded.orm ?? null,
|
|
64
|
+
metalnessMap: loaded.orm ?? null,
|
|
65
|
+
roughness: definition.roughness,
|
|
66
|
+
metalness: definition.metalness,
|
|
67
|
+
clearcoat: definition.clearcoat ?? 0,
|
|
68
|
+
clearcoatRoughness: definition.clearcoatRoughness ?? 0,
|
|
69
|
+
});
|
|
70
|
+
if (definition.normalScale !== undefined)
|
|
71
|
+
material.normalScale.setScalar(definition.normalScale);
|
|
72
|
+
if (loaded.faces !== undefined && region !== undefined && atlas !== undefined) {
|
|
73
|
+
configureCompositingShader(material, loaded.faces, region, atlas.width, atlas.height, loaded.skin);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
material,
|
|
77
|
+
dispose() {
|
|
78
|
+
material.dispose();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
dispose() {
|
|
83
|
+
for (const pending of this.#textures.values())
|
|
84
|
+
void pending.then((texture) => texture.dispose());
|
|
85
|
+
this.#textures.clear();
|
|
86
|
+
this.#skins.clear();
|
|
87
|
+
this.#loader.dispose();
|
|
88
|
+
}
|
|
89
|
+
#load(reference) {
|
|
90
|
+
const existing = this.#textures.get(reference.uri);
|
|
91
|
+
if (existing !== undefined)
|
|
92
|
+
return existing;
|
|
93
|
+
const pending = this.#loader.loadAsync(reference.uri).then((texture) => {
|
|
94
|
+
texture.colorSpace = reference.colorSpace === 'srgb' ? SRGBColorSpace : '';
|
|
95
|
+
texture.generateMipmaps = false;
|
|
96
|
+
texture.needsUpdate = true;
|
|
97
|
+
return texture;
|
|
98
|
+
});
|
|
99
|
+
this.#textures.set(reference.uri, pending);
|
|
100
|
+
return pending;
|
|
101
|
+
}
|
|
102
|
+
#createFallback(context) {
|
|
103
|
+
const material = this.#fallback.createFace(context.label, context.theme, context.labelScale === undefined ? {} : { labelScale: context.labelScale });
|
|
104
|
+
return {
|
|
105
|
+
material,
|
|
106
|
+
dispose() {
|
|
107
|
+
material.map?.dispose();
|
|
108
|
+
material.dispose();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function configureCompositingShader(material, atlas, region, width, height, skin) {
|
|
114
|
+
const labelColor = new Color(skin.labelColor ?? '#111111');
|
|
115
|
+
material.onBeforeCompile = (shader) => {
|
|
116
|
+
shader.uniforms.faceAtlas = { value: atlas };
|
|
117
|
+
shader.uniforms.faceRect = {
|
|
118
|
+
value: [region.x / width, region.y / height, region.width / width, region.height / height],
|
|
119
|
+
};
|
|
120
|
+
shader.uniforms.labelColor = { value: labelColor };
|
|
121
|
+
shader.uniforms.skinHue = { value: skin.hueRotation ?? 0 };
|
|
122
|
+
shader.uniforms.skinSaturation = { value: skin.saturation ?? 1 };
|
|
123
|
+
shader.uniforms.patternScale = { value: skin.patternScale ?? [1, 1] };
|
|
124
|
+
shader.uniforms.compositeMode = {
|
|
125
|
+
value: skin.composite === 'overlay' ? 2 : skin.composite === 'multiply' ? 1 : 0,
|
|
126
|
+
};
|
|
127
|
+
shader.fragmentShader = shader.fragmentShader
|
|
128
|
+
.replace('#include <map_pars_fragment>', `#include <map_pars_fragment>\nuniform sampler2D faceAtlas;\nuniform vec4 faceRect;\nuniform vec3 labelColor;\nuniform float skinHue;\nuniform float skinSaturation;\nuniform vec2 patternScale;\nuniform int compositeMode;`)
|
|
129
|
+
.replace('#include <map_fragment>', `#ifdef USE_MAP\nvec4 skinPattern = texture2D(map, fract(vMapUv * patternScale));\nvec3 skinTint = diffuseColor.rgb;\nif (compositeMode == 0) diffuseColor.rgb = mix(skinTint, skinPattern.rgb, skinPattern.a);\nelse if (compositeMode == 1) diffuseColor.rgb = skinTint * skinPattern.rgb;\nelse { vec3 low = 2.0 * skinTint * skinPattern.rgb; vec3 high = 1.0 - 2.0 * (1.0 - skinTint) * (1.0 - skinPattern.rgb); diffuseColor.rgb = mix(low, high, step(vec3(0.5), skinTint)); }\ndiffuseColor.a *= skinPattern.a;\n#endif\nvec3 skinGray = vec3(dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)));\ndiffuseColor.rgb = mix(skinGray, diffuseColor.rgb, skinSaturation);\nfloat skinAngle = skinHue * 6.28318530718;\nvec3 skinAxis = normalize(vec3(1.0));\ndiffuseColor.rgb = diffuseColor.rgb * cos(skinAngle) + cross(skinAxis, diffuseColor.rgb) * sin(skinAngle) + skinAxis * dot(skinAxis, diffuseColor.rgb) * (1.0 - cos(skinAngle));\nvec2 faceUv = faceRect.xy + vMapUv * faceRect.zw;\nfloat faceMask = texture2D(faceAtlas, faceUv).a;\ndiffuseColor.rgb = mix(diffuseColor.rgb, labelColor, faceMask);`);
|
|
130
|
+
};
|
|
131
|
+
material.customProgramCacheKey = () => `dice-skin:${skin.id}`;
|
|
132
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export type DiceAssetMetadataValue = string | number | boolean;
|
|
2
|
+
export interface DiceAssetReference {
|
|
3
|
+
readonly uri: string;
|
|
4
|
+
readonly mediaType?: string;
|
|
5
|
+
readonly integrity?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface RuntimeTextureReference extends DiceAssetReference {
|
|
8
|
+
readonly mediaType: 'image/ktx2';
|
|
9
|
+
readonly colorSpace: 'srgb' | 'linear';
|
|
10
|
+
readonly mipmaps: true;
|
|
11
|
+
}
|
|
12
|
+
export interface AudioSpriteClip {
|
|
13
|
+
readonly offsetSeconds: number;
|
|
14
|
+
readonly durationSeconds: number;
|
|
15
|
+
readonly gain?: number;
|
|
16
|
+
readonly weight?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface AudioSpriteManifest {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly audio: DiceAssetReference & {
|
|
21
|
+
readonly mediaType: 'audio/webm; codecs=opus';
|
|
22
|
+
};
|
|
23
|
+
readonly channels: 1;
|
|
24
|
+
readonly clips: Readonly<Record<string, AudioSpriteClip>>;
|
|
25
|
+
}
|
|
26
|
+
export type AudioBankKind = 'die-material' | 'surface-material';
|
|
27
|
+
export interface AudioBankDefinition {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly kind: AudioBankKind;
|
|
30
|
+
readonly spriteId: string;
|
|
31
|
+
readonly clipIds: readonly string[];
|
|
32
|
+
readonly forceRange: readonly [minimum: number, maximum: number];
|
|
33
|
+
readonly gainRange: readonly [minimum: number, maximum: number];
|
|
34
|
+
readonly pitchVariationCents?: number;
|
|
35
|
+
readonly gainVariation?: number;
|
|
36
|
+
readonly maxVoices?: number;
|
|
37
|
+
readonly metadata?: Readonly<Record<string, DiceAssetMetadataValue>>;
|
|
38
|
+
}
|
|
39
|
+
export interface DiceMaterialDefinition {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly roughness: number;
|
|
42
|
+
readonly metalness: number;
|
|
43
|
+
readonly normalScale?: number;
|
|
44
|
+
readonly clearcoat?: number;
|
|
45
|
+
readonly clearcoatRoughness?: number;
|
|
46
|
+
readonly metadata?: Readonly<Record<string, DiceAssetMetadataValue>>;
|
|
47
|
+
}
|
|
48
|
+
export interface DicePatternDefinition {
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly baseColor: RuntimeTextureReference;
|
|
51
|
+
readonly normal?: RuntimeTextureReference;
|
|
52
|
+
/** Occlusion, roughness and metalness packed into R, G and B channels. */
|
|
53
|
+
readonly orm?: RuntimeTextureReference;
|
|
54
|
+
readonly repeat?: readonly [u: number, v: number];
|
|
55
|
+
readonly metadata?: Readonly<Record<string, DiceAssetMetadataValue>>;
|
|
56
|
+
}
|
|
57
|
+
export interface DiceFaceRegion {
|
|
58
|
+
readonly x: number;
|
|
59
|
+
readonly y: number;
|
|
60
|
+
readonly width: number;
|
|
61
|
+
readonly height: number;
|
|
62
|
+
}
|
|
63
|
+
export interface DiceFaceAtlasDefinition {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly texture: RuntimeTextureReference;
|
|
66
|
+
readonly width: number;
|
|
67
|
+
readonly height: number;
|
|
68
|
+
readonly faces: Readonly<Record<string, DiceFaceRegion>>;
|
|
69
|
+
readonly metadata?: Readonly<Record<string, DiceAssetMetadataValue>>;
|
|
70
|
+
}
|
|
71
|
+
export type DiceSkinCompositeMode = 'normal' | 'multiply' | 'overlay';
|
|
72
|
+
export interface DiceSkinDefinition {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly name?: string;
|
|
75
|
+
readonly materialId: string;
|
|
76
|
+
readonly patternId: string;
|
|
77
|
+
readonly faceAtlasId?: string;
|
|
78
|
+
readonly tint?: string;
|
|
79
|
+
readonly labelColor?: string;
|
|
80
|
+
readonly hueRotation?: number;
|
|
81
|
+
readonly saturation?: number;
|
|
82
|
+
readonly patternScale?: readonly [u: number, v: number];
|
|
83
|
+
readonly composite?: DiceSkinCompositeMode;
|
|
84
|
+
readonly metadata?: Readonly<Record<string, DiceAssetMetadataValue>>;
|
|
85
|
+
}
|
|
86
|
+
export interface DiceAssetCatalogManifest {
|
|
87
|
+
readonly schemaVersion: 1;
|
|
88
|
+
readonly audioSprites?: readonly AudioSpriteManifest[];
|
|
89
|
+
readonly audioBanks?: readonly AudioBankDefinition[];
|
|
90
|
+
readonly materials?: readonly DiceMaterialDefinition[];
|
|
91
|
+
readonly patterns?: readonly DicePatternDefinition[];
|
|
92
|
+
readonly skins?: readonly DiceSkinDefinition[];
|
|
93
|
+
readonly faces?: readonly DiceFaceAtlasDefinition[];
|
|
94
|
+
}
|
package/dist/types.js
ADDED
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DiceAssetRegistry } from './asset-registry.js';
|
|
2
|
+
import type { AudioBankDefinition, AudioSpriteClip } from './types.js';
|
|
3
|
+
export interface AudioParamLike {
|
|
4
|
+
value: number;
|
|
5
|
+
}
|
|
6
|
+
export interface AudioBufferSourceNodeLike {
|
|
7
|
+
buffer: unknown;
|
|
8
|
+
readonly playbackRate: AudioParamLike;
|
|
9
|
+
connect(destination: unknown): void;
|
|
10
|
+
start(when?: number, offset?: number, duration?: number): void;
|
|
11
|
+
addEventListener(type: 'ended', listener: () => void, options?: {
|
|
12
|
+
readonly once?: boolean;
|
|
13
|
+
}): void;
|
|
14
|
+
}
|
|
15
|
+
export interface GainNodeLike {
|
|
16
|
+
readonly gain: AudioParamLike;
|
|
17
|
+
connect(destination: unknown): void;
|
|
18
|
+
}
|
|
19
|
+
export interface AudioContextLike {
|
|
20
|
+
readonly destination: unknown;
|
|
21
|
+
decodeAudioData(data: ArrayBuffer): Promise<unknown>;
|
|
22
|
+
createBufferSource(): AudioBufferSourceNodeLike;
|
|
23
|
+
createGain(): GainNodeLike;
|
|
24
|
+
}
|
|
25
|
+
export interface WebAudioSpritePlayerOptions {
|
|
26
|
+
readonly context: AudioContextLike;
|
|
27
|
+
readonly fetch?: (uri: string) => Promise<{
|
|
28
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
29
|
+
}>;
|
|
30
|
+
readonly random?: () => number;
|
|
31
|
+
}
|
|
32
|
+
export interface PlayImpactOptions {
|
|
33
|
+
readonly force: number;
|
|
34
|
+
readonly dieMaterialBankId: string;
|
|
35
|
+
readonly surfaceMaterialBankId?: string;
|
|
36
|
+
}
|
|
37
|
+
export declare class WebAudioSpritePlayer {
|
|
38
|
+
#private;
|
|
39
|
+
readonly registry: DiceAssetRegistry;
|
|
40
|
+
constructor(registry: DiceAssetRegistry, options: WebAudioSpritePlayerOptions);
|
|
41
|
+
preloadBank(bankId: string): Promise<void>;
|
|
42
|
+
playImpact(options: PlayImpactOptions): Promise<void>;
|
|
43
|
+
playBank(bankId: string, force: number): Promise<boolean>;
|
|
44
|
+
}
|
|
45
|
+
export declare function resolveImpactGain(bank: AudioBankDefinition, clip: AudioSpriteClip, force: number, signedVariation?: number): number;
|