@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/dist/mycelium.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A living mycelium field: hyphae grow slowly across a host element and coral
|
|
3
|
+
* nodes pulse when something finishes. Rendered with Pixi 8, which the host
|
|
4
|
+
* loads lazily and passes in, so Sprite itself never bundles Pixi.
|
|
5
|
+
*/
|
|
6
|
+
/** A small, fast, seeded PRNG (mulberry32). */
|
|
7
|
+
function random(seed) {
|
|
8
|
+
let state = seed >>> 0;
|
|
9
|
+
return () => {
|
|
10
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
11
|
+
let t = state;
|
|
12
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
13
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
14
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function prefersStill(view) {
|
|
18
|
+
return view.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Mount a mycelium field that fills `host`. Reduced motion renders one
|
|
22
|
+
* composed, fully grown frame and never ticks. The ticker pauses while the host
|
|
23
|
+
* is off screen or the document is hidden.
|
|
24
|
+
*/
|
|
25
|
+
export async function mountMycelium(host, options) {
|
|
26
|
+
const { pixi, palette, seed = 7, segments = 2600, threadAlpha = 0.22, pulseEvery = 2.4, threadWidth = 1.6, avoid = () => [], focus = () => null, } = options;
|
|
27
|
+
const view = host.ownerDocument.defaultView;
|
|
28
|
+
if (!view)
|
|
29
|
+
throw new Error("Mycelium needs a browser window");
|
|
30
|
+
const app = new pixi.Application();
|
|
31
|
+
await app.init({
|
|
32
|
+
resizeTo: host,
|
|
33
|
+
backgroundAlpha: 0,
|
|
34
|
+
antialias: true,
|
|
35
|
+
autoDensity: true,
|
|
36
|
+
resolution: Math.min(view.devicePixelRatio || 1, 2),
|
|
37
|
+
preference: "webgl",
|
|
38
|
+
});
|
|
39
|
+
const canvas = app.canvas;
|
|
40
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
41
|
+
canvas.style.position = "absolute";
|
|
42
|
+
canvas.style.inset = "0";
|
|
43
|
+
canvas.style.width = "100%";
|
|
44
|
+
canvas.style.height = "100%";
|
|
45
|
+
host.append(canvas);
|
|
46
|
+
const rand = random(seed);
|
|
47
|
+
const still = prefersStill(view);
|
|
48
|
+
const nodes = [];
|
|
49
|
+
const nodeLayer = new pixi.Container();
|
|
50
|
+
const pen = new pixi.Graphics();
|
|
51
|
+
let baked = pixi.RenderTexture.create({
|
|
52
|
+
width: app.screen.width,
|
|
53
|
+
height: app.screen.height,
|
|
54
|
+
resolution: app.renderer.resolution,
|
|
55
|
+
});
|
|
56
|
+
const canvasSprite = new pixi.Sprite(baked);
|
|
57
|
+
app.stage.addChild(canvasSprite, nodeLayer);
|
|
58
|
+
let tips = [];
|
|
59
|
+
let drawn = 0;
|
|
60
|
+
const addNode = (x, y) => {
|
|
61
|
+
const dot = new pixi.Graphics()
|
|
62
|
+
.circle(0, 0, 2.6 + rand() * 2)
|
|
63
|
+
.fill({ color: palette.node });
|
|
64
|
+
dot.position.set(x, y);
|
|
65
|
+
dot.alpha = 0.55;
|
|
66
|
+
nodeLayer.addChild(dot);
|
|
67
|
+
nodes.push({ x, y, glow: 0, sprite: dot });
|
|
68
|
+
};
|
|
69
|
+
let blocked = [];
|
|
70
|
+
const isBlocked = (x, y) => blocked.some((rect) => x > rect.left && x < rect.right && y > rect.top && y < rect.bottom);
|
|
71
|
+
const seedTips = () => {
|
|
72
|
+
const { width, height } = app.screen;
|
|
73
|
+
blocked = avoid();
|
|
74
|
+
tips = [];
|
|
75
|
+
const start = (x, y, angle, life) => {
|
|
76
|
+
tips.push({ x, y, angle, life, width: threadWidth });
|
|
77
|
+
addNode(x, y);
|
|
78
|
+
};
|
|
79
|
+
const target = focus();
|
|
80
|
+
if (target) {
|
|
81
|
+
// Densest around the focus: threads leave its edges and wrap around it.
|
|
82
|
+
// Growth lives in the gutters and below the focus: threads leave its
|
|
83
|
+
// left and right edges and its bottom, never its top.
|
|
84
|
+
const count = Math.max(10, Math.round(width / 90));
|
|
85
|
+
for (let index = 0; index < count; index += 1) {
|
|
86
|
+
const side = rand();
|
|
87
|
+
const along = rand();
|
|
88
|
+
if (side < 0.4)
|
|
89
|
+
start(target.left - 6, target.top + along * (target.bottom - target.top), Math.PI + (rand() - 0.5) * 1.4, 110 + rand() * 180);
|
|
90
|
+
else if (side < 0.8)
|
|
91
|
+
start(target.right + 6, target.top + along * (target.bottom - target.top), (rand() - 0.5) * 1.4, 110 + rand() * 180);
|
|
92
|
+
else
|
|
93
|
+
start(target.left + along * (target.right - target.left), target.bottom + 6, Math.PI / 2 + (rand() - 0.5) * 1.2, 60 + rand() * 110);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const count = Math.max(4, Math.round(width / 220));
|
|
98
|
+
for (let index = 0; index < count; index += 1) {
|
|
99
|
+
const fromBottom = rand() < 0.6;
|
|
100
|
+
const x = fromBottom ? rand() * width : rand() < 0.5 ? 0 : width;
|
|
101
|
+
const y = fromBottom ? height : height * (0.35 + rand() * 0.65);
|
|
102
|
+
const angle = fromBottom
|
|
103
|
+
? -Math.PI / 2 + (rand() - 0.5) * 1.1
|
|
104
|
+
: x === 0
|
|
105
|
+
? -0.4 + (rand() - 0.5) * 0.8
|
|
106
|
+
: Math.PI + 0.4 + (rand() - 0.5) * 0.8;
|
|
107
|
+
start(x, y, angle, 120 + rand() * 220);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const grow = (steps) => {
|
|
111
|
+
const { width, height } = app.screen;
|
|
112
|
+
for (let step = 0; step < steps && drawn < segments; step += 1) {
|
|
113
|
+
const next = [];
|
|
114
|
+
for (const tip of tips) {
|
|
115
|
+
const length = 3 + rand() * 4;
|
|
116
|
+
const angle = tip.angle + (rand() - 0.5) * 0.5;
|
|
117
|
+
const x = tip.x + Math.cos(angle) * length;
|
|
118
|
+
const y = tip.y + Math.sin(angle) * length;
|
|
119
|
+
if (isBlocked(x, y))
|
|
120
|
+
continue;
|
|
121
|
+
pen.moveTo(tip.x, tip.y).lineTo(x, y).stroke({
|
|
122
|
+
width: tip.width,
|
|
123
|
+
color: palette.thread,
|
|
124
|
+
alpha: threadAlpha,
|
|
125
|
+
cap: "round",
|
|
126
|
+
});
|
|
127
|
+
drawn += 1;
|
|
128
|
+
const life = tip.life - 1;
|
|
129
|
+
const inside = x > -20 && x < width + 20 && y > -20 && y < height + 20;
|
|
130
|
+
if (!inside || life <= 0)
|
|
131
|
+
continue;
|
|
132
|
+
next.push({
|
|
133
|
+
x,
|
|
134
|
+
y,
|
|
135
|
+
angle,
|
|
136
|
+
life,
|
|
137
|
+
width: Math.max(0.5, tip.width * 0.998),
|
|
138
|
+
});
|
|
139
|
+
if (rand() < 0.035 && next.length < 90) {
|
|
140
|
+
const branch = angle + (rand() < 0.5 ? -1 : 1) * (0.5 + rand() * 0.6);
|
|
141
|
+
next.push({
|
|
142
|
+
x,
|
|
143
|
+
y,
|
|
144
|
+
angle: branch,
|
|
145
|
+
life: life * (0.5 + rand() * 0.4),
|
|
146
|
+
width: tip.width * 0.72,
|
|
147
|
+
});
|
|
148
|
+
if (rand() < 0.45)
|
|
149
|
+
addNode(x, y);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
tips = next;
|
|
153
|
+
if (tips.length === 0)
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
app.renderer.render({ container: pen, target: baked, clear: false });
|
|
157
|
+
pen.clear();
|
|
158
|
+
};
|
|
159
|
+
const pulse = () => {
|
|
160
|
+
const node = nodes[Math.floor(rand() * nodes.length)];
|
|
161
|
+
if (node)
|
|
162
|
+
node.glow = 1;
|
|
163
|
+
};
|
|
164
|
+
const restart = () => {
|
|
165
|
+
baked.destroy(true);
|
|
166
|
+
baked = pixi.RenderTexture.create({
|
|
167
|
+
width: app.screen.width,
|
|
168
|
+
height: app.screen.height,
|
|
169
|
+
resolution: app.renderer.resolution,
|
|
170
|
+
});
|
|
171
|
+
canvasSprite.texture = baked;
|
|
172
|
+
for (const node of nodes)
|
|
173
|
+
node.sprite.destroy();
|
|
174
|
+
nodes.length = 0;
|
|
175
|
+
drawn = 0;
|
|
176
|
+
seedTips();
|
|
177
|
+
if (still) {
|
|
178
|
+
grow(segments);
|
|
179
|
+
app.render();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
let untilPulse = pulseEvery;
|
|
183
|
+
const tick = () => {
|
|
184
|
+
const seconds = app.ticker.deltaMS / 1000;
|
|
185
|
+
if (drawn < segments && tips.length > 0)
|
|
186
|
+
grow(4);
|
|
187
|
+
untilPulse -= seconds;
|
|
188
|
+
if (untilPulse <= 0) {
|
|
189
|
+
pulse();
|
|
190
|
+
untilPulse = pulseEvery * (0.5 + rand());
|
|
191
|
+
}
|
|
192
|
+
for (const node of nodes) {
|
|
193
|
+
if (node.glow <= 0)
|
|
194
|
+
continue;
|
|
195
|
+
node.glow = Math.max(0, node.glow - seconds * 0.8);
|
|
196
|
+
node.sprite.alpha = 0.55 + node.glow * 0.45;
|
|
197
|
+
node.sprite.scale.set(1 + node.glow * 2.2);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
seedTips();
|
|
201
|
+
if (still) {
|
|
202
|
+
app.ticker.stop();
|
|
203
|
+
grow(segments);
|
|
204
|
+
app.render();
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
app.ticker.add(tick);
|
|
208
|
+
}
|
|
209
|
+
let visible = true;
|
|
210
|
+
const sync = () => {
|
|
211
|
+
if (still)
|
|
212
|
+
return;
|
|
213
|
+
if (visible && !host.ownerDocument.hidden)
|
|
214
|
+
app.ticker.start();
|
|
215
|
+
else
|
|
216
|
+
app.ticker.stop();
|
|
217
|
+
};
|
|
218
|
+
const observer = new view.IntersectionObserver((entries) => {
|
|
219
|
+
visible = entries.some((entry) => entry.isIntersecting);
|
|
220
|
+
sync();
|
|
221
|
+
});
|
|
222
|
+
observer.observe(host);
|
|
223
|
+
host.ownerDocument.addEventListener("visibilitychange", sync);
|
|
224
|
+
let lastWidth = app.screen.width;
|
|
225
|
+
const resized = new view.ResizeObserver(() => {
|
|
226
|
+
if (Math.abs(app.screen.width - lastWidth) < 80)
|
|
227
|
+
return;
|
|
228
|
+
lastWidth = app.screen.width;
|
|
229
|
+
restart();
|
|
230
|
+
});
|
|
231
|
+
resized.observe(host);
|
|
232
|
+
return {
|
|
233
|
+
pulse,
|
|
234
|
+
destroy: () => {
|
|
235
|
+
observer.disconnect();
|
|
236
|
+
resized.disconnect();
|
|
237
|
+
host.ownerDocument.removeEventListener("visibilitychange", sync);
|
|
238
|
+
baked.destroy(true);
|
|
239
|
+
app.destroy({ removeView: true }, { children: true });
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
package/dist/player.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Scope from "effect/Scope";
|
|
3
|
+
import { type AssetPolicy } from "./assets.js";
|
|
4
|
+
import { type Appearance } from "./controls.js";
|
|
5
|
+
import { type RenderStyle } from "./render-style.js";
|
|
6
|
+
import { type CharacterDocument, type CharacterSource } from "./document.js";
|
|
7
|
+
/** One complete request. Omitted customization resets to character defaults. */
|
|
8
|
+
export type AvatarPresentation = {
|
|
9
|
+
readonly src: CharacterSource;
|
|
10
|
+
readonly activity?: string;
|
|
11
|
+
readonly expression?: string;
|
|
12
|
+
readonly appearance?: Appearance;
|
|
13
|
+
/** SVG filter drawn over the character; omitted draws it plain. */
|
|
14
|
+
readonly renderStyle?: RenderStyle;
|
|
15
|
+
};
|
|
16
|
+
/** The resolved activity and appearance passed to a native renderer. */
|
|
17
|
+
export type NativePresentation = {
|
|
18
|
+
readonly activity: string;
|
|
19
|
+
readonly expression: string | null;
|
|
20
|
+
readonly appearance: Appearance;
|
|
21
|
+
};
|
|
22
|
+
/** The adapter registers native destruction in the selection scope. */
|
|
23
|
+
export type NativePlayer = {
|
|
24
|
+
readonly activities: readonly string[];
|
|
25
|
+
readonly expressions: readonly string[];
|
|
26
|
+
/** Update the existing native instance. */
|
|
27
|
+
set(presentation: NativePresentation): void;
|
|
28
|
+
/** Pause or resume animation without losing the current frame. */
|
|
29
|
+
pause(paused: boolean): void;
|
|
30
|
+
};
|
|
31
|
+
/** Native renderer access to the selected document and its admitted assets. */
|
|
32
|
+
export type RendererContext = {
|
|
33
|
+
readonly target: HTMLElement;
|
|
34
|
+
readonly document: CharacterDocument;
|
|
35
|
+
readonly signal: AbortSignal;
|
|
36
|
+
readonly maxPixels: number;
|
|
37
|
+
/** Report a native failure that occurs after the renderer has become ready. */
|
|
38
|
+
readonly reportError: (error: Error) => void;
|
|
39
|
+
/** Admit an asset and revoke its object URL when the selection closes. */
|
|
40
|
+
readonly assetURL: (reference: string) => Effect.Effect<string, Error, Scope.Scope>;
|
|
41
|
+
};
|
|
42
|
+
/** A native renderer whose resources live in the character selection scope. */
|
|
43
|
+
export type Renderer = (context: RendererContext) => Effect.Effect<NativePlayer, Error, Scope.Scope>;
|
|
44
|
+
/** A request after native fallback and appearance validation. */
|
|
45
|
+
export type EffectivePresentation = NativePresentation & {
|
|
46
|
+
readonly renderStyle: RenderStyle;
|
|
47
|
+
readonly requestedActivity: string;
|
|
48
|
+
readonly requestedExpression: string | null;
|
|
49
|
+
readonly fallback: boolean;
|
|
50
|
+
};
|
|
51
|
+
/** Observable loading, ready, error, and disposal states. */
|
|
52
|
+
export type PlayerState = {
|
|
53
|
+
readonly status: "empty" | "loading" | "destroyed";
|
|
54
|
+
} | {
|
|
55
|
+
readonly status: "error";
|
|
56
|
+
readonly error: Error;
|
|
57
|
+
} | {
|
|
58
|
+
readonly status: "ready";
|
|
59
|
+
readonly document: CharacterDocument;
|
|
60
|
+
readonly presentation: EffectivePresentation;
|
|
61
|
+
readonly updateError?: Error;
|
|
62
|
+
};
|
|
63
|
+
/** An installed document and its renderer, loaded together by the shell. */
|
|
64
|
+
export type LocalCharacter = {
|
|
65
|
+
readonly document: CharacterDocument;
|
|
66
|
+
readonly render: Renderer;
|
|
67
|
+
};
|
|
68
|
+
/** Host-owned character registry and resource policy. */
|
|
69
|
+
export type SpriteOptions = {
|
|
70
|
+
/** Omit for local-only characters; network access is denied by default. */
|
|
71
|
+
readonly assets?: AssetPolicy;
|
|
72
|
+
readonly maxPixels?: number;
|
|
73
|
+
/** Native adapters allowed for served character documents. */
|
|
74
|
+
readonly renderers?: Readonly<Record<string, () => Promise<{
|
|
75
|
+
default: Renderer;
|
|
76
|
+
}>>>;
|
|
77
|
+
/** Lazy local characters installed by this shell. */
|
|
78
|
+
readonly characters?: Readonly<Record<string, () => Promise<LocalCharacter>>>;
|
|
79
|
+
};
|
|
80
|
+
/** One mounted avatar; dispose releases its native and browser resources. */
|
|
81
|
+
export type AvatarPlayer = {
|
|
82
|
+
/** Current public state. */
|
|
83
|
+
readonly state: PlayerState;
|
|
84
|
+
/** Observe state changes until the returned unsubscribe function is called. */
|
|
85
|
+
subscribe(listener: (next: PlayerState) => void): () => void;
|
|
86
|
+
/** Select a source or update its activity and appearance. */
|
|
87
|
+
set(next: AvatarPresentation): void;
|
|
88
|
+
/** Pause or resume the current native animation. */
|
|
89
|
+
pause(value: boolean): void;
|
|
90
|
+
/** Cancel loading and release the current native selection. */
|
|
91
|
+
dispose(): Promise<void>;
|
|
92
|
+
};
|
|
93
|
+
/** Stable shell configuration that creates independent mounted players. */
|
|
94
|
+
export type SpriteService = {
|
|
95
|
+
/** Claim one DOM host for a new avatar instance. */
|
|
96
|
+
mount(target: HTMLElement): AvatarPlayer;
|
|
97
|
+
};
|
|
98
|
+
/** Stable shell service; each mounted player owns its own selection. */
|
|
99
|
+
export declare function createSprite(options: SpriteOptions): SpriteService;
|