@fungi.computer/sprite 0.0.0-stage → 0.0.1
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/README.md +152 -2
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +83 -0
- package/dist/avatar.d.ts +152 -0
- package/dist/avatar.js +228 -0
- package/dist/controls.d.ts +32 -0
- package/dist/controls.js +64 -0
- package/dist/customization.d.ts +21 -0
- package/dist/customization.js +13 -0
- package/dist/document.d.ts +43 -0
- package/dist/document.js +15 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/dist/ink.d.ts +50 -0
- package/dist/ink.js +225 -0
- package/dist/mycelium.d.ts +139 -0
- package/dist/mycelium.js +242 -0
- package/dist/player.d.ts +99 -0
- package/dist/player.js +368 -0
- package/dist/react.d.ts +23 -0
- package/dist/react.js +61 -0
- package/dist/render-style.d.ts +45 -0
- package/dist/render-style.js +639 -0
- package/dist/video.d.ts +4 -0
- package/dist/video.js +141 -0
- package/package.json +93 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,153 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sprite (`@fungi.computer/sprite`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sprite presents a character document through a host-installed renderer, so a
|
|
4
|
+
game or app can show an animated avatar without writing its own rendering layer.
|
|
5
|
+
The host chooses local characters, renderer adapters, network origins, and asset
|
|
6
|
+
limits. Each mounted player owns its current source and presentation lifetime.
|
|
7
|
+
|
|
8
|
+
## Install or set up
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @fungi.computer/sprite
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The package ships ESM only and depends on Effect, Zod, and
|
|
15
|
+
[Cairn](../cairn/README.md) at runtime. React is an optional peer dependency,
|
|
16
|
+
needed only for the `./react` entrypoint. The core entrypoint,
|
|
17
|
+
`@fungi.computer/sprite`, exports the renderer-neutral player API.
|
|
18
|
+
`@fungi.computer/sprite/react` exports the React provider and component.
|
|
19
|
+
`@fungi.computer/sprite/video` exports the optional video renderer. `./avatar`
|
|
20
|
+
supplies avatar descriptors and catalog-aware validation. `./ink` and
|
|
21
|
+
`./mycelium` mount decorative drawings independently of the character player.
|
|
22
|
+
Zdog support ships as the separate [Sprite Zdog](../sprite-zdog/README.md)
|
|
23
|
+
package.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
This illustrative React example configures the host's asset policy and lazy
|
|
28
|
+
video renderer, then presents a character document:
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { createSprite } from "@fungi.computer/sprite";
|
|
32
|
+
import { Sprite, SpriteProvider } from "@fungi.computer/sprite/react";
|
|
33
|
+
|
|
34
|
+
const service = createSprite({
|
|
35
|
+
assets: {
|
|
36
|
+
baseURL: window.location.href,
|
|
37
|
+
allowedOrigins: [window.location.origin],
|
|
38
|
+
maxBytes: 2_000_000,
|
|
39
|
+
maxTotalBytes: 4_000_000,
|
|
40
|
+
maxRequests: 4,
|
|
41
|
+
},
|
|
42
|
+
renderers: {
|
|
43
|
+
video: () => import("@fungi.computer/sprite/video"),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function Character() {
|
|
48
|
+
return (
|
|
49
|
+
<SpriteProvider service={service}>
|
|
50
|
+
<Sprite
|
|
51
|
+
src="/characters/my-avatar.json"
|
|
52
|
+
activity="idle"
|
|
53
|
+
role="img"
|
|
54
|
+
aria-label="My avatar"
|
|
55
|
+
/>
|
|
56
|
+
</SpriteProvider>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The package tests exercise player updates, React lifetime, and asset admission.
|
|
62
|
+
See [`src/player.test.ts`](src/player.test.ts),
|
|
63
|
+
[`src/react.test.tsx`](src/react.test.tsx), and
|
|
64
|
+
[`src/assets.test.ts`](src/assets.test.ts).
|
|
65
|
+
|
|
66
|
+
## Core concepts
|
|
67
|
+
|
|
68
|
+
A character document has `version: 1`, a name, a renderer name, optional
|
|
69
|
+
controls and poster, and renderer-specific `data`. The shared schema validates
|
|
70
|
+
that envelope. The selected adapter validates its own data. A source is either a
|
|
71
|
+
document URL or `{ local: "key" }` for a character installed by the host.
|
|
72
|
+
|
|
73
|
+
`createSprite` creates a stable host service. The host supplies local
|
|
74
|
+
characters, renderer loaders, and asset policy. A player selects one source and
|
|
75
|
+
receives activity, expression, appearance, and render-style updates without
|
|
76
|
+
replacing its host element. The React `Sprite` owns one player and disposes it
|
|
77
|
+
when it unmounts. The imperative player exposes `set`, `pause`, `subscribe`, and
|
|
78
|
+
asynchronous `dispose`.
|
|
79
|
+
|
|
80
|
+
`renderStyle` applies one of the built-in SVG filters: `plain`, `pencil`,
|
|
81
|
+
`manga`, `riso`, `halftone`, `blueprint`, `crt`, or `watercolor`. Each player
|
|
82
|
+
owns its filter instance. Animated styles pause when the player is paused, is
|
|
83
|
+
offscreen, the page is hidden, or reduced motion is requested.
|
|
84
|
+
|
|
85
|
+
Remote documents and nested assets pass through one reader. The host sets
|
|
86
|
+
allowed origins, the maximum bytes per response, total bytes per selection, and
|
|
87
|
+
request count. Local characters need no remote asset policy. Video uses the
|
|
88
|
+
configured `video` renderer and reads clip assets through that same path.
|
|
89
|
+
|
|
90
|
+
The React `Sprite` takes `src`, optional `activity`, `expression`, `appearance`
|
|
91
|
+
and `renderStyle`, plus ordinary attributes for its host `div`. Pass `paused` to
|
|
92
|
+
suspend it and `onState` to observe player states. A direct `service` prop
|
|
93
|
+
overrides `SpriteProvider`; one of them is required.
|
|
94
|
+
|
|
95
|
+
### Saved avatars
|
|
96
|
+
|
|
97
|
+
`@fungi.computer/sprite/avatar` defines the v1 descriptor: `v`, `character`,
|
|
98
|
+
`appearance`, `expression` and `renderStyle`. `avatarEnvelopeSchema` checks only
|
|
99
|
+
that envelope. Use `createAvatarSchema(catalog)` or `parseAvatar(data, catalog)`
|
|
100
|
+
to also validate the selected character's controls and expressions. Sprite Zdog
|
|
101
|
+
supplies a validator bound to its built-in catalog.
|
|
102
|
+
|
|
103
|
+
### Decorative drawings
|
|
104
|
+
|
|
105
|
+
`mountInkDrawing(host, { art, palette, ...options })` from `./ink` paints traced
|
|
106
|
+
art with Canvas 2D. The palette is a callback; call the returned `redraw()`
|
|
107
|
+
after a theme change and `destroy()` when the host leaves.
|
|
108
|
+
|
|
109
|
+
`mountMycelium(host, { pixi, palette, ...options })` from `./mycelium` returns a
|
|
110
|
+
Promise for a growing field with `pulse()` and `destroy()`. The host supplies
|
|
111
|
+
Pixi 8 and the palette. Both drawings respect reduced motion and release their
|
|
112
|
+
canvas and observers through `destroy()`.
|
|
113
|
+
|
|
114
|
+
## API reference
|
|
115
|
+
|
|
116
|
+
See the generated [Sprite API reference](docs/api/README.md). Import the
|
|
117
|
+
renderer-neutral player API from `@fungi.computer/sprite`, the React provider
|
|
118
|
+
and component from `@fungi.computer/sprite/react`, and the video adapter from
|
|
119
|
+
`@fungi.computer/sprite/video`. The reference also covers `./avatar`; the
|
|
120
|
+
decorative `./ink` and `./mycelium` contracts are described above.
|
|
121
|
+
|
|
122
|
+
## Examples and checks
|
|
123
|
+
|
|
124
|
+
The video workbench story presents a character with multiple clips and changes
|
|
125
|
+
the activity to select a clip. Start the local Ladle workbench from the
|
|
126
|
+
repository root with:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
pnpm --filter @fungi.computer/sprite exec ladle serve
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Run the package tests with:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
pnpm --filter @fungi.computer/sprite test
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Limits and errors
|
|
139
|
+
|
|
140
|
+
Sprite validates the shared document envelope, not renderer-specific data. The
|
|
141
|
+
host must configure the renderer named by a served document and review its asset
|
|
142
|
+
policy. Without an asset policy, remote origins are denied. Video codec support
|
|
143
|
+
depends on the browser and the encoded file.
|
|
144
|
+
|
|
145
|
+
The built-in render styles draw through an SVG filter. Reduced-motion
|
|
146
|
+
preferences, pause state, offscreen status, and page visibility suspend styles
|
|
147
|
+
that animate. A still frame remains. `poster` supplies a loading image, not a
|
|
148
|
+
replacement for host asset limits.
|
|
149
|
+
|
|
150
|
+
## Contribute and license
|
|
151
|
+
|
|
152
|
+
Sprite is MIT licensed. See [`LICENSE`](LICENSE) and
|
|
153
|
+
[`THIRD_PARTY_NOTICES`](THIRD_PARTY_NOTICES).
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Host-owned admission and resource limits for published avatar artwork. */
|
|
2
|
+
export type AssetPolicy = {
|
|
3
|
+
readonly baseURL: string;
|
|
4
|
+
readonly allowedOrigins: readonly string[];
|
|
5
|
+
/** Maximum bytes admitted for one response. */
|
|
6
|
+
readonly maxBytes: number;
|
|
7
|
+
/** Cumulative bytes admitted across one character selection. */
|
|
8
|
+
readonly maxTotalBytes: number;
|
|
9
|
+
/** Number of requests admitted for one character selection. */
|
|
10
|
+
readonly maxRequests: number;
|
|
11
|
+
readonly fetch?: typeof fetch;
|
|
12
|
+
};
|
|
13
|
+
/** Decoded transport bytes; adapters use these instead of opening their own URLs. */
|
|
14
|
+
export type Asset = {
|
|
15
|
+
readonly url: string;
|
|
16
|
+
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
17
|
+
readonly contentType: string;
|
|
18
|
+
};
|
|
19
|
+
/** One policy-enforced path for documents and their nested native resources. */
|
|
20
|
+
export declare function createAssetReader(policy: AssetPolicy): () => (reference: string, signal: AbortSignal, base?: string) => Promise<Asset>;
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** One policy-enforced path for documents and their nested native resources. */
|
|
2
|
+
export function createAssetReader(policy) {
|
|
3
|
+
for (const [name, limit] of Object.entries({
|
|
4
|
+
maxBytes: policy.maxBytes,
|
|
5
|
+
maxTotalBytes: policy.maxTotalBytes,
|
|
6
|
+
maxRequests: policy.maxRequests,
|
|
7
|
+
})) {
|
|
8
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
9
|
+
throw new Error(`Avatar ${name} must be a positive safe integer`);
|
|
10
|
+
}
|
|
11
|
+
const origins = new Set(policy.allowedOrigins.map((origin) => new URL(origin).origin));
|
|
12
|
+
const request = policy.fetch ?? globalThis.fetch;
|
|
13
|
+
return () => {
|
|
14
|
+
let totalBytes = 0;
|
|
15
|
+
let requests = 0;
|
|
16
|
+
return async (reference, signal, base = policy.baseURL) => {
|
|
17
|
+
const url = new URL(reference, base);
|
|
18
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
19
|
+
!origins.has(url.origin) ||
|
|
20
|
+
url.username ||
|
|
21
|
+
url.password) {
|
|
22
|
+
throw new Error(`Avatar asset is not admitted: ${url.origin}`);
|
|
23
|
+
}
|
|
24
|
+
requests += 1;
|
|
25
|
+
if (requests > policy.maxRequests)
|
|
26
|
+
throw new Error("Avatar asset request limit reached");
|
|
27
|
+
// Reject redirects before a browser can contact an unapproved destination.
|
|
28
|
+
const response = await request(url.href, { signal, redirect: "error" });
|
|
29
|
+
if (!response.ok)
|
|
30
|
+
throw new Error(`Avatar asset request failed: ${String(response.status)}`);
|
|
31
|
+
const declaredSize = Number(response.headers.get("content-length"));
|
|
32
|
+
if (declaredSize > policy.maxBytes ||
|
|
33
|
+
declaredSize > policy.maxTotalBytes - totalBytes) {
|
|
34
|
+
await response.body?.cancel();
|
|
35
|
+
throw new Error("Avatar asset exceeds the byte limit");
|
|
36
|
+
}
|
|
37
|
+
const reader = response.body?.getReader();
|
|
38
|
+
if (!reader)
|
|
39
|
+
throw new Error("Avatar asset has no response body");
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
try {
|
|
43
|
+
for (;;) {
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
const chunk = await reader.read();
|
|
46
|
+
if (chunk.done)
|
|
47
|
+
break;
|
|
48
|
+
size += chunk.value.byteLength;
|
|
49
|
+
if (size > policy.maxBytes)
|
|
50
|
+
throw new Error("Avatar asset exceeds the byte limit");
|
|
51
|
+
if (chunk.value.byteLength > policy.maxTotalBytes - totalBytes)
|
|
52
|
+
throw new Error("Avatar selection exceeds the total byte limit");
|
|
53
|
+
totalBytes += chunk.value.byteLength;
|
|
54
|
+
chunks.push(chunk.value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const cancellation = await reader.cancel().then(() => ({ kind: "cancelled" }), (cause) => ({
|
|
59
|
+
kind: "failed",
|
|
60
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
61
|
+
}));
|
|
62
|
+
if (cancellation.kind === "failed") {
|
|
63
|
+
throw new AggregateError([error, cancellation.error], "Avatar asset read and cancellation failed", { cause: error });
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
reader.releaseLock();
|
|
69
|
+
}
|
|
70
|
+
const bytes = new Uint8Array(size);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (const chunk of chunks) {
|
|
73
|
+
bytes.set(chunk, offset);
|
|
74
|
+
offset += chunk.byteLength;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
url: url.href,
|
|
78
|
+
bytes,
|
|
79
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
}
|
package/dist/avatar.d.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import type { Control } from "./controls.js";
|
|
3
|
+
/**
|
|
4
|
+
* The closed v1 catalog of avatar characters. Adding a character is a new
|
|
5
|
+
* major version; this list is not meant to grow silently.
|
|
6
|
+
*/
|
|
7
|
+
export declare const avatarCharacters: readonly ["mushroom", "cat", "burger"];
|
|
8
|
+
/** One character in the closed v1 avatar catalog. */
|
|
9
|
+
export type AvatarCharacter = (typeof avatarCharacters)[number];
|
|
10
|
+
/** A saved `agentColors` preference: shift an Agent's identity, or copy it. */
|
|
11
|
+
export declare const agentColorsSchema: z.ZodEnum<{
|
|
12
|
+
shift: "shift";
|
|
13
|
+
keep: "keep";
|
|
14
|
+
}>;
|
|
15
|
+
/** Whether an Agent created from a member's avatar wears a shifted identity. */
|
|
16
|
+
export type AgentColors = z.infer<typeof agentColorsSchema>;
|
|
17
|
+
/**
|
|
18
|
+
* The Avatar v1 envelope: a closed character, up to 16 appearance values, a
|
|
19
|
+
* resting expression, and a render style. It is a complete snapshot, not a
|
|
20
|
+
* reference into character data, so it survives a later change to the
|
|
21
|
+
* catalog. This checks shape only; it does not know a character's real
|
|
22
|
+
* controls or expressions, so a bound catalog cannot validate against it by
|
|
23
|
+
* name alone. `createAvatarSchema`/`parseAvatar` build the catalog-refined
|
|
24
|
+
* schema a caller should actually parse untrusted data with; `sprite-zdog`'s
|
|
25
|
+
* `avatarSchema` is that schema, bound to the built-in catalog.
|
|
26
|
+
*/
|
|
27
|
+
export declare const avatarEnvelopeSchema: z.ZodObject<{
|
|
28
|
+
v: z.ZodLiteral<1>;
|
|
29
|
+
character: z.ZodEnum<{
|
|
30
|
+
mushroom: "mushroom";
|
|
31
|
+
cat: "cat";
|
|
32
|
+
burger: "burger";
|
|
33
|
+
}>;
|
|
34
|
+
appearance: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
35
|
+
expression: z.ZodString;
|
|
36
|
+
renderStyle: z.ZodEnum<{
|
|
37
|
+
plain: "plain";
|
|
38
|
+
pencil: "pencil";
|
|
39
|
+
manga: "manga";
|
|
40
|
+
riso: "riso";
|
|
41
|
+
halftone: "halftone";
|
|
42
|
+
blueprint: "blueprint";
|
|
43
|
+
crt: "crt";
|
|
44
|
+
watercolor: "watercolor";
|
|
45
|
+
}>;
|
|
46
|
+
}, z.core.$strict>;
|
|
47
|
+
/** A parsed, envelope-valid Avatar v1 descriptor. */
|
|
48
|
+
export type Avatar = z.infer<typeof avatarEnvelopeSchema>;
|
|
49
|
+
/** Pure per-character facts a catalog owner (e.g. `sprite-zdog`) supplies. */
|
|
50
|
+
export type AvatarCatalogEntry = {
|
|
51
|
+
/** Every control the character's document declares. */
|
|
52
|
+
readonly controls: Readonly<Record<string, Control>>;
|
|
53
|
+
/** Every expression name the character's document declares. */
|
|
54
|
+
readonly expressions: readonly string[];
|
|
55
|
+
/** Controls that carry the character's identity colour, in priority order. */
|
|
56
|
+
readonly identitySlots: readonly string[];
|
|
57
|
+
};
|
|
58
|
+
/** Pure character facts for every avatar character, owned by its renderer. */
|
|
59
|
+
export type AvatarCatalog = Readonly<Record<AvatarCharacter, AvatarCatalogEntry>>;
|
|
60
|
+
/**
|
|
61
|
+
* Build the full avatar schema for a catalog: the envelope above, refined so
|
|
62
|
+
* every appearance key is one of the character's controls, every value is
|
|
63
|
+
* typed by that control, and the expression is one the character declares.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createAvatarSchema(catalog: AvatarCatalog): z.ZodObject<{
|
|
66
|
+
v: z.ZodLiteral<1>;
|
|
67
|
+
character: z.ZodEnum<{
|
|
68
|
+
mushroom: "mushroom";
|
|
69
|
+
cat: "cat";
|
|
70
|
+
burger: "burger";
|
|
71
|
+
}>;
|
|
72
|
+
appearance: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
73
|
+
expression: z.ZodString;
|
|
74
|
+
renderStyle: z.ZodEnum<{
|
|
75
|
+
plain: "plain";
|
|
76
|
+
pencil: "pencil";
|
|
77
|
+
manga: "manga";
|
|
78
|
+
riso: "riso";
|
|
79
|
+
halftone: "halftone";
|
|
80
|
+
blueprint: "blueprint";
|
|
81
|
+
crt: "crt";
|
|
82
|
+
watercolor: "watercolor";
|
|
83
|
+
}>;
|
|
84
|
+
}, z.core.$strict>;
|
|
85
|
+
/** Parse an unknown value into a catalog-valid Avatar v1 descriptor. */
|
|
86
|
+
export declare function parseAvatar(data: unknown, catalog: AvatarCatalog): Avatar;
|
|
87
|
+
/** A hexadecimal colour decomposed into hue (degrees), saturation, lightness. */
|
|
88
|
+
export type Hsl = {
|
|
89
|
+
readonly h: number;
|
|
90
|
+
readonly s: number;
|
|
91
|
+
readonly l: number;
|
|
92
|
+
};
|
|
93
|
+
/** Encode HSL (hue in degrees, saturation/lightness in percent) as `#rrggbb`. */
|
|
94
|
+
export declare function hexFromHsl(hue: number, saturation: number, lightness: number): string;
|
|
95
|
+
/** Decode a `#rrggbb` colour into HSL (hue in degrees, saturation/lightness in percent). */
|
|
96
|
+
export declare function hslFromHex(hex: unknown): Hsl;
|
|
97
|
+
/** Shortest angular distance between two hues in degrees, 0 to 180. */
|
|
98
|
+
export declare function hueDistance(a: number, b: number): number;
|
|
99
|
+
/** Lower bound (degrees) every colour-shift step must clear, each direction. */
|
|
100
|
+
export declare const AGENT_HUE_MIN_SHIFT = 40;
|
|
101
|
+
/**
|
|
102
|
+
* A deterministic FNV-1a hash: the same seed always lands on the same value.
|
|
103
|
+
* A copy of `@fungi.computer/caps`'s `seedHash` so this package stays free of
|
|
104
|
+
* a React-package dependency; both must keep computing the same value.
|
|
105
|
+
*/
|
|
106
|
+
export declare function seedHash(seed: string): number;
|
|
107
|
+
/**
|
|
108
|
+
* The hue-rotation step for `seed`, chosen against `hex`'s own realised
|
|
109
|
+
* result, not the idealised `hue + step` arithmetic: `shiftHue` rounds to a
|
|
110
|
+
* `#rrggbb` colour, and re-decoding that colour can land a few tenths of a
|
|
111
|
+
* degree short of an exact-degree step, which the 40-degree guarantee must
|
|
112
|
+
* still hold against. A step is only a candidate when rotating `hex` by it
|
|
113
|
+
* and reading the result's hue back out clears 40 degrees from `hex`'s own
|
|
114
|
+
* hue. When `avoid` carries other agents' identity hues, the candidate
|
|
115
|
+
* farthest from all of them (by minimum distance, scored on the same
|
|
116
|
+
* realised hue) is preferred; ties keep whichever candidate is nearest the
|
|
117
|
+
* seed's own deterministic default step, so an unconflicted seed keeps its
|
|
118
|
+
* usual look.
|
|
119
|
+
*/
|
|
120
|
+
export declare function agentHueStep(hex: string, seed: string, avoid?: readonly number[]): number;
|
|
121
|
+
/** Rotate one hex colour's hue by `step` degrees, keeping its saturation and lightness. */
|
|
122
|
+
export declare function shiftHue(hex: string, step: number): string;
|
|
123
|
+
/**
|
|
124
|
+
* The mushroom's two identity controls for one seed: its cap and cap spots
|
|
125
|
+
* take a hue from the seed, so an Agent wears the same cap in every list it
|
|
126
|
+
* appears in. Kept byte-identical to the Sprite seen before avatars existed.
|
|
127
|
+
*/
|
|
128
|
+
export type AgentAppearance = Readonly<{
|
|
129
|
+
/** Cap colour as a hexadecimal CSS colour. */
|
|
130
|
+
cap: string;
|
|
131
|
+
/** Cap spot colour as a hexadecimal CSS colour. */
|
|
132
|
+
capSpot: string;
|
|
133
|
+
}>;
|
|
134
|
+
/** The pre-avatar mushroom identity colours for `seed`. */
|
|
135
|
+
export declare function agentAppearance(seed: string): AgentAppearance;
|
|
136
|
+
/**
|
|
137
|
+
* The avatar a Sprite presents when none is stored: the mushroom, in the same
|
|
138
|
+
* seeded cap colour Agents have always worn, at rest, undecorated. Every
|
|
139
|
+
* reader falls back to this so an existing Agent's look never changes.
|
|
140
|
+
*/
|
|
141
|
+
export declare function legacyAvatar(seed: string): Avatar;
|
|
142
|
+
/**
|
|
143
|
+
* Derive an Agent's avatar from its creator's, for a catalog's character.
|
|
144
|
+
* `"keep"` hands back the member's look unchanged (their Agent is a visible
|
|
145
|
+
* copy, told apart only by name). `"shift"` resolves the member's full
|
|
146
|
+
* appearance against the catalog and rotates only the character's identity
|
|
147
|
+
* slots by one shared hue step, so accessories, face and drawing style carry
|
|
148
|
+
* over untouched. `avoid` lists hues already worn by the creator's other
|
|
149
|
+
* Agents so a new one does not land beside them. An achromatic primary
|
|
150
|
+
* identity colour has no hue to rotate, so its resolved appearance is kept.
|
|
151
|
+
*/
|
|
152
|
+
export declare function deriveAgentAvatar(member: Avatar, agentColors: AgentColors, seed: string, catalog: AvatarCatalog, avoid?: readonly number[]): Avatar;
|
package/dist/avatar.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { resolveAppearance } from "./controls.js";
|
|
3
|
+
import { renderStyleSchema } from "./render-style.js";
|
|
4
|
+
/**
|
|
5
|
+
* The closed v1 catalog of avatar characters. Adding a character is a new
|
|
6
|
+
* major version; this list is not meant to grow silently.
|
|
7
|
+
*/
|
|
8
|
+
export const avatarCharacters = ["mushroom", "cat", "burger"];
|
|
9
|
+
/** A saved `agentColors` preference: shift an Agent's identity, or copy it. */
|
|
10
|
+
export const agentColorsSchema = z.enum(["shift", "keep"]);
|
|
11
|
+
const hexColor = z.string().regex(/^#[0-9a-f]{6}$/i);
|
|
12
|
+
/** Appearance values a v1 avatar may carry: a hex colour, a number, or a flag. */
|
|
13
|
+
const avatarAppearanceValue = z.union([hexColor, z.number(), z.boolean()]);
|
|
14
|
+
const MAX_APPEARANCE_KEYS = 16;
|
|
15
|
+
/**
|
|
16
|
+
* The Avatar v1 envelope: a closed character, up to 16 appearance values, a
|
|
17
|
+
* resting expression, and a render style. It is a complete snapshot, not a
|
|
18
|
+
* reference into character data, so it survives a later change to the
|
|
19
|
+
* catalog. This checks shape only; it does not know a character's real
|
|
20
|
+
* controls or expressions, so a bound catalog cannot validate against it by
|
|
21
|
+
* name alone. `createAvatarSchema`/`parseAvatar` build the catalog-refined
|
|
22
|
+
* schema a caller should actually parse untrusted data with; `sprite-zdog`'s
|
|
23
|
+
* `avatarSchema` is that schema, bound to the built-in catalog.
|
|
24
|
+
*/
|
|
25
|
+
export const avatarEnvelopeSchema = z.strictObject({
|
|
26
|
+
v: z.literal(1),
|
|
27
|
+
character: z.enum(avatarCharacters),
|
|
28
|
+
appearance: z
|
|
29
|
+
.record(z.string().min(1), avatarAppearanceValue)
|
|
30
|
+
.refine((appearance) => Object.keys(appearance).length <= MAX_APPEARANCE_KEYS, {
|
|
31
|
+
message: `Avatar appearance cannot exceed ${String(MAX_APPEARANCE_KEYS)} keys`,
|
|
32
|
+
}),
|
|
33
|
+
expression: z.string().min(1),
|
|
34
|
+
renderStyle: renderStyleSchema,
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Build the full avatar schema for a catalog: the envelope above, refined so
|
|
38
|
+
* every appearance key is one of the character's controls, every value is
|
|
39
|
+
* typed by that control, and the expression is one the character declares.
|
|
40
|
+
*/
|
|
41
|
+
export function createAvatarSchema(catalog) {
|
|
42
|
+
return avatarEnvelopeSchema.superRefine((avatar, context) => {
|
|
43
|
+
const entry = catalog[avatar.character];
|
|
44
|
+
try {
|
|
45
|
+
resolveAppearance(entry.controls, avatar.appearance);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
context.addIssue({
|
|
49
|
+
code: "custom",
|
|
50
|
+
path: ["appearance"],
|
|
51
|
+
message: error instanceof Error ? error.message : String(error),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (!entry.expressions.includes(avatar.expression)) {
|
|
55
|
+
context.addIssue({
|
|
56
|
+
code: "custom",
|
|
57
|
+
path: ["expression"],
|
|
58
|
+
message: `Unknown avatar expression for ${avatar.character}: ${avatar.expression}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Parse an unknown value into a catalog-valid Avatar v1 descriptor. */
|
|
64
|
+
export function parseAvatar(data, catalog) {
|
|
65
|
+
return createAvatarSchema(catalog).parse(data);
|
|
66
|
+
}
|
|
67
|
+
/** Encode HSL (hue in degrees, saturation/lightness in percent) as `#rrggbb`. */
|
|
68
|
+
export function hexFromHsl(hue, saturation, lightness) {
|
|
69
|
+
const s = saturation / 100;
|
|
70
|
+
const l = lightness / 100;
|
|
71
|
+
const a = s * Math.min(l, 1 - l);
|
|
72
|
+
const channel = (offset) => {
|
|
73
|
+
const k = (offset + hue / 30) % 12;
|
|
74
|
+
const value = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
|
75
|
+
return Math.round(255 * value)
|
|
76
|
+
.toString(16)
|
|
77
|
+
.padStart(2, "0");
|
|
78
|
+
};
|
|
79
|
+
return `#${channel(0)}${channel(8)}${channel(4)}`;
|
|
80
|
+
}
|
|
81
|
+
/** Decode a `#rrggbb` colour into HSL (hue in degrees, saturation/lightness in percent). */
|
|
82
|
+
export function hslFromHex(hex) {
|
|
83
|
+
const parsed = hexColor.parse(hex);
|
|
84
|
+
const r = Number.parseInt(parsed.slice(1, 3), 16) / 255;
|
|
85
|
+
const g = Number.parseInt(parsed.slice(3, 5), 16) / 255;
|
|
86
|
+
const b = Number.parseInt(parsed.slice(5, 7), 16) / 255;
|
|
87
|
+
const max = Math.max(r, g, b);
|
|
88
|
+
const min = Math.min(r, g, b);
|
|
89
|
+
const l = (max + min) / 2;
|
|
90
|
+
const delta = max - min;
|
|
91
|
+
if (delta === 0)
|
|
92
|
+
return { h: 0, s: 0, l: l * 100 };
|
|
93
|
+
const s = delta / (1 - Math.abs(2 * l - 1));
|
|
94
|
+
let h;
|
|
95
|
+
if (max === r)
|
|
96
|
+
h = (((g - b) / delta) % 6) * 60;
|
|
97
|
+
else if (max === g)
|
|
98
|
+
h = ((b - r) / delta + 2) * 60;
|
|
99
|
+
else
|
|
100
|
+
h = ((r - g) / delta + 4) * 60;
|
|
101
|
+
if (h < 0)
|
|
102
|
+
h += 360;
|
|
103
|
+
return { h, s: s * 100, l: l * 100 };
|
|
104
|
+
}
|
|
105
|
+
const normalizeHue = (hue) => ((hue % 360) + 360) % 360;
|
|
106
|
+
/** Shortest angular distance between two hues in degrees, 0 to 180. */
|
|
107
|
+
export function hueDistance(a, b) {
|
|
108
|
+
const diff = Math.abs(normalizeHue(a) - normalizeHue(b)) % 360;
|
|
109
|
+
return Math.min(diff, 360 - diff);
|
|
110
|
+
}
|
|
111
|
+
/** Lower bound (degrees) every colour-shift step must clear, each direction. */
|
|
112
|
+
export const AGENT_HUE_MIN_SHIFT = 40;
|
|
113
|
+
/** `agentHueStep`'s deterministic step range has exactly this many values. */
|
|
114
|
+
const SHIFT_RANGE = 281;
|
|
115
|
+
/**
|
|
116
|
+
* A deterministic FNV-1a hash: the same seed always lands on the same value.
|
|
117
|
+
* A copy of `@fungi.computer/caps`'s `seedHash` so this package stays free of
|
|
118
|
+
* a React-package dependency; both must keep computing the same value.
|
|
119
|
+
*/
|
|
120
|
+
export function seedHash(seed) {
|
|
121
|
+
let hash = 0x811c9dc5;
|
|
122
|
+
for (const unit of seed) {
|
|
123
|
+
hash ^= unit.codePointAt(0) ?? 0;
|
|
124
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
125
|
+
}
|
|
126
|
+
return hash;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The hue-rotation step for `seed`, chosen against `hex`'s own realised
|
|
130
|
+
* result, not the idealised `hue + step` arithmetic: `shiftHue` rounds to a
|
|
131
|
+
* `#rrggbb` colour, and re-decoding that colour can land a few tenths of a
|
|
132
|
+
* degree short of an exact-degree step, which the 40-degree guarantee must
|
|
133
|
+
* still hold against. A step is only a candidate when rotating `hex` by it
|
|
134
|
+
* and reading the result's hue back out clears 40 degrees from `hex`'s own
|
|
135
|
+
* hue. When `avoid` carries other agents' identity hues, the candidate
|
|
136
|
+
* farthest from all of them (by minimum distance, scored on the same
|
|
137
|
+
* realised hue) is preferred; ties keep whichever candidate is nearest the
|
|
138
|
+
* seed's own deterministic default step, so an unconflicted seed keeps its
|
|
139
|
+
* usual look.
|
|
140
|
+
*/
|
|
141
|
+
export function agentHueStep(hex, seed, avoid = []) {
|
|
142
|
+
const memberHue = hslFromHex(hex).h;
|
|
143
|
+
const fallbackStep = AGENT_HUE_MIN_SHIFT + (seedHash(seed) % SHIFT_RANGE);
|
|
144
|
+
const blocked = avoid.map(normalizeHue);
|
|
145
|
+
const realizedHue = (step) => hslFromHex(shiftHue(hex, step)).h;
|
|
146
|
+
const clearsMember = (step) => hueDistance(realizedHue(step), memberHue) >= AGENT_HUE_MIN_SHIFT;
|
|
147
|
+
const avoidScore = (step) => blocked.length === 0
|
|
148
|
+
? 0
|
|
149
|
+
: Math.min(...blocked.map((hue) => hueDistance(realizedHue(step), hue)));
|
|
150
|
+
let bestStep;
|
|
151
|
+
let bestScore = -1;
|
|
152
|
+
let bestDistanceToFallback = Infinity;
|
|
153
|
+
for (let step = AGENT_HUE_MIN_SHIFT; step <= 360 - AGENT_HUE_MIN_SHIFT; step += 1) {
|
|
154
|
+
if (!clearsMember(step))
|
|
155
|
+
continue;
|
|
156
|
+
const score = avoidScore(step);
|
|
157
|
+
const distanceToFallback = Math.abs(step - fallbackStep);
|
|
158
|
+
if (bestStep === undefined ||
|
|
159
|
+
score > bestScore ||
|
|
160
|
+
(score === bestScore && distanceToFallback < bestDistanceToFallback)) {
|
|
161
|
+
bestStep = step;
|
|
162
|
+
bestScore = score;
|
|
163
|
+
bestDistanceToFallback = distanceToFallback;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (bestStep === undefined)
|
|
167
|
+
throw new Error(`No colour-shift step clears ${String(AGENT_HUE_MIN_SHIFT)} degrees for ${hex}`);
|
|
168
|
+
return bestStep;
|
|
169
|
+
}
|
|
170
|
+
/** Rotate one hex colour's hue by `step` degrees, keeping its saturation and lightness. */
|
|
171
|
+
export function shiftHue(hex, step) {
|
|
172
|
+
const { h, s, l } = hslFromHex(hex);
|
|
173
|
+
return hexFromHsl(normalizeHue(h + step), s, l);
|
|
174
|
+
}
|
|
175
|
+
/** The pre-avatar mushroom identity colours for `seed`. */
|
|
176
|
+
export function agentAppearance(seed) {
|
|
177
|
+
const hue = seedHash(seed) % 360;
|
|
178
|
+
return { cap: hexFromHsl(hue, 68, 72), capSpot: hexFromHsl(hue, 70, 92) };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The avatar a Sprite presents when none is stored: the mushroom, in the same
|
|
182
|
+
* seeded cap colour Agents have always worn, at rest, undecorated. Every
|
|
183
|
+
* reader falls back to this so an existing Agent's look never changes.
|
|
184
|
+
*/
|
|
185
|
+
export function legacyAvatar(seed) {
|
|
186
|
+
return {
|
|
187
|
+
v: 1,
|
|
188
|
+
character: "mushroom",
|
|
189
|
+
appearance: agentAppearance(seed),
|
|
190
|
+
expression: "normal",
|
|
191
|
+
renderStyle: "plain",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Derive an Agent's avatar from its creator's, for a catalog's character.
|
|
196
|
+
* `"keep"` hands back the member's look unchanged (their Agent is a visible
|
|
197
|
+
* copy, told apart only by name). `"shift"` resolves the member's full
|
|
198
|
+
* appearance against the catalog and rotates only the character's identity
|
|
199
|
+
* slots by one shared hue step, so accessories, face and drawing style carry
|
|
200
|
+
* over untouched. `avoid` lists hues already worn by the creator's other
|
|
201
|
+
* Agents so a new one does not land beside them. An achromatic primary
|
|
202
|
+
* identity colour has no hue to rotate, so its resolved appearance is kept.
|
|
203
|
+
*/
|
|
204
|
+
export function deriveAgentAvatar(member, agentColors, seed, catalog, avoid = []) {
|
|
205
|
+
if (agentColors === "keep")
|
|
206
|
+
return { ...member };
|
|
207
|
+
const characterEntry = catalog[member.character];
|
|
208
|
+
const resolved = resolveAppearance(characterEntry.controls, member.appearance);
|
|
209
|
+
// Every identity slot whose control is a colour, with its resolved hex
|
|
210
|
+
// value already parsed (not asserted): the control's declared type is the
|
|
211
|
+
// contract, not the resolved value's runtime shape.
|
|
212
|
+
const identityHues = characterEntry.identitySlots.flatMap((slot) => {
|
|
213
|
+
const control = characterEntry.controls[slot];
|
|
214
|
+
return control?.type === "color"
|
|
215
|
+
? [{ slot, hex: hexColor.parse(resolved[slot]) }]
|
|
216
|
+
: [];
|
|
217
|
+
});
|
|
218
|
+
const primary = identityHues[0];
|
|
219
|
+
if (!primary || hslFromHex(primary.hex).s === 0)
|
|
220
|
+
return { ...member, appearance: resolved };
|
|
221
|
+
const step = agentHueStep(primary.hex, seed, avoid);
|
|
222
|
+
const shiftedBySlot = new Map(identityHues.map(({ slot, hex }) => [slot, shiftHue(hex, step)]));
|
|
223
|
+
const appearance = Object.fromEntries(Object.entries(resolved).map(([key, value]) => [
|
|
224
|
+
key,
|
|
225
|
+
shiftedBySlot.get(key) ?? value,
|
|
226
|
+
]));
|
|
227
|
+
return { ...member, appearance };
|
|
228
|
+
}
|