@starcried/starlite-core 0.0.0-stage → 0.1.0-alpha.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 ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, StarCried
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -1,3 +1,42 @@
1
- # Temporary Holding Version
1
+ # @starcried/starlite-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ StarLite 的公共播放契约、生命周期和气泡组件。该包不导入 Three.js 或 Inochi2D WASM;宿主先选择后端,再加载对应运行时。
4
+
5
+ 在 Node.js 22.12+ 项目中安装 alpha 版本:
6
+
7
+ ```sh
8
+ npm install @starcried/starlite-core@alpha
9
+ ```
10
+
11
+ 通常直接安装所需后端,它会自动安装本包。
12
+
13
+ ## 公共能力
14
+
15
+ `ModelController` 提供 `pause`、`resume`、`getAnimations`、`playAnimation`、`stopAnimation`、`resetCamera` 和 `destroy`。动作 Promise 的结束结果为 `completed`、`interrupted` 或 `stopped`;循环在中断或停止后结束等待。
16
+
17
+ `ModelMountSlot` 中止被替换的加载,并销毁迟到的实例。动作载荷、骨骼和参数属于后端,公共层只交换动作名称、时长和播放结果。
18
+
19
+ `DialogueScheduler` 管理首次入场、偶发小动作和点击回应;`DialogueBubble` 管理纯文本、ARIA 状态提示、边缘位置、过期和清理。两个后端共用该组件与样式。气泡可由宿主独立调用,无需模型或动作;VRM 的声明式场景也可省略 `animation`,仅配置 `lines` 显示句子,不打断待机。
20
+
21
+ ## 语句与动作绑定
22
+
23
+ ```ts
24
+ dialogue: {
25
+ click: {
26
+ animation: "Wave",
27
+ animations: ["Wave", "Nod"],
28
+ linesByAnimation: {
29
+ Wave: ["你好!", "很高兴见到你。"],
30
+ Nod: ["我在这里。", "继续吧。"]
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ 每次先选择一个动作,再从该动作的句子中随机选择。动作名称精确匹配且区分大小写;动作池允许 1–20 个不同名称。`linesByAnimation` 必须覆盖整个动作池,且不能包含池外名称;也可用 `lines` 提供共用语句库。
37
+
38
+ ## 进阶
39
+
40
+ [完整配置](https://github.com/StarCried/starlite/blob/main/docs/configuration.md) · [组件结构](https://github.com/StarCried/starlite/blob/main/docs/architecture.md) · [VRM 后端](https://github.com/StarCried/starlite/blob/main/packages/vrm/README.md) · [Inochi2D 后端](https://github.com/StarCried/starlite/blob/main/packages/inochi2d/README.md)
41
+
42
+ BSD-2-Clause;不包含模型、角色素材或第三方动作。
@@ -0,0 +1,61 @@
1
+ .model-widget__bubble {
2
+ --model-bubble-shift-x: 0px;
3
+ position: absolute;
4
+ left: 50%;
5
+ bottom: calc(100% + 12px);
6
+ z-index: 2;
7
+ box-sizing: border-box;
8
+ width: max-content;
9
+ max-width: min(280px, calc(100vw - 24px));
10
+ padding: 10px 14px;
11
+ border: 1px solid rgb(255 255 255 / 65%);
12
+ border-radius: 14px;
13
+ color: #20242b;
14
+ background: rgb(255 255 255 / 94%);
15
+ box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
16
+ font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
17
+ overflow-wrap: anywhere;
18
+ pointer-events: none;
19
+ transform: translateX(calc(-50% + var(--model-bubble-shift-x)));
20
+ }
21
+
22
+ .model-widget__bubble[data-model-placement="below"] {
23
+ top: calc(100% + 12px);
24
+ bottom: auto;
25
+ }
26
+
27
+ .model-widget__bubble::after {
28
+ position: absolute;
29
+ left: calc(50% - var(--model-bubble-shift-x));
30
+ bottom: -7px;
31
+ width: 12px;
32
+ height: 12px;
33
+ border-right: 1px solid rgb(255 255 255 / 65%);
34
+ border-bottom: 1px solid rgb(255 255 255 / 65%);
35
+ background: rgb(255 255 255 / 94%);
36
+ content: "";
37
+ transform: translateX(-50%) rotate(45deg);
38
+ }
39
+
40
+ .model-widget__bubble[data-model-placement="below"]::after {
41
+ top: -7px;
42
+ bottom: auto;
43
+ transform: translateX(-50%) rotate(225deg);
44
+ }
45
+
46
+ .model-widget__bubble[hidden] {
47
+ display: none;
48
+ }
49
+
50
+ .model-widget__bubble-text {
51
+ display: -webkit-box;
52
+ -webkit-box-orient: vertical;
53
+ -webkit-line-clamp: 6;
54
+ overflow: hidden;
55
+ animation: model-bubble-reveal 180ms ease-out;
56
+ }
57
+
58
+ @keyframes model-bubble-reveal { from { opacity: 0.35; } to { opacity: 1; } }
59
+ @media (prefers-reduced-motion: reduce) {
60
+ .model-widget__bubble-text { animation: none; }
61
+ }
@@ -0,0 +1,12 @@
1
+ import type { DialogueAction } from "./dialogue.js";
2
+ /** Plain-text, accessible dialogue presentation shared by every renderer. */
3
+ export declare class DialogueBubble {
4
+ #private;
5
+ readonly element: HTMLDivElement;
6
+ constructor(host: HTMLElement);
7
+ show(action: DialogueAction, timestamp: number, withoutFrames?: boolean): void;
8
+ update(timestamp: number): void;
9
+ hide(): void;
10
+ position(): void;
11
+ destroy(): void;
12
+ }
package/dist/bubble.js ADDED
@@ -0,0 +1,56 @@
1
+ /** Plain-text, accessible dialogue presentation shared by every renderer. */
2
+ export class DialogueBubble {
3
+ element;
4
+ #host;
5
+ #until = 0;
6
+ #timeout;
7
+ constructor(host) {
8
+ this.#host = host;
9
+ this.element = document.createElement("div");
10
+ this.element.className = "model-widget__bubble";
11
+ this.element.hidden = true;
12
+ this.element.setAttribute("role", "status");
13
+ this.element.setAttribute("aria-live", "polite");
14
+ this.element.setAttribute("aria-atomic", "true");
15
+ }
16
+ show(action, timestamp, withoutFrames = false) {
17
+ this.hide();
18
+ const content = document.createElement("span");
19
+ content.className = "model-widget__bubble-text";
20
+ content.textContent = action.line;
21
+ this.element.replaceChildren(content);
22
+ this.element.dataset.modelScene = action.scene;
23
+ if (action.animation)
24
+ this.element.dataset.modelAnimation = action.animation;
25
+ this.element.hidden = false;
26
+ this.#until = timestamp + action.durationMs;
27
+ this.position();
28
+ if (withoutFrames)
29
+ this.#timeout = setTimeout(() => this.hide(), action.durationMs);
30
+ }
31
+ update(timestamp) {
32
+ if (!this.element.hidden && timestamp >= this.#until)
33
+ this.hide();
34
+ }
35
+ hide() {
36
+ if (this.#timeout !== undefined)
37
+ clearTimeout(this.#timeout);
38
+ this.#timeout = undefined;
39
+ this.#until = 0;
40
+ this.element.hidden = true;
41
+ this.element.textContent = "";
42
+ delete this.element.dataset.modelScene;
43
+ delete this.element.dataset.modelAnimation;
44
+ }
45
+ position() {
46
+ if (this.element.hidden)
47
+ return;
48
+ const host = this.#host.getBoundingClientRect(), bubble = this.element.getBoundingClientRect();
49
+ const width = Math.max(1, document.documentElement.clientWidth || window.innerWidth);
50
+ const margin = 12, centered = host.left + host.width / 2 - bubble.width / 2;
51
+ this.element.dataset.modelPlacement = host.top >= bubble.height + margin * 2 ? "above" : "below";
52
+ const left = Math.max(margin, Math.min(Math.max(margin, width - bubble.width - margin), centered));
53
+ this.element.style.setProperty("--model-bubble-shift-x", `${Math.round(left - centered)}px`);
54
+ }
55
+ destroy() { this.hide(); this.element.remove(); }
56
+ }
@@ -0,0 +1,47 @@
1
+ export type DialogueSceneName = 'enter' | 'idle' | 'click';
2
+ export interface DialogueSceneConfig {
3
+ animation?: string;
4
+ animations?: readonly string[];
5
+ /** Shared sentence library used when linesByAnimation is absent. */
6
+ lines?: readonly string[];
7
+ /** Complete sentence libraries for the selected animation or animation pool. */
8
+ linesByAnimation?: Readonly<Record<string, readonly string[]>>;
9
+ durationMs?: number;
10
+ }
11
+ export interface ResolvedDialogueScene extends DialogueSceneConfig {
12
+ durationMs: number;
13
+ }
14
+ /** Validate and copy plain-text libraries at the backend configuration boundary. */
15
+ export declare function resolveDialogueSentences(input: Pick<DialogueSceneConfig, "lines" | "linesByAnimation">, animations: readonly string[], field: string): Pick<DialogueSceneConfig, "lines" | "linesByAnimation">;
16
+ export interface DialogueSchedule {
17
+ enter: false | (ResolvedDialogueScene & {
18
+ delayMs: number;
19
+ });
20
+ idle: false | (ResolvedDialogueScene & {
21
+ intervalMinMs: number;
22
+ intervalMaxMs: number;
23
+ });
24
+ click: false | ResolvedDialogueScene;
25
+ }
26
+ export interface DialogueAction {
27
+ scene: DialogueSceneName;
28
+ animation?: string;
29
+ line: string;
30
+ durationMs: number;
31
+ }
32
+ /**
33
+ * Schedules model-authored foreground actions and selects plain-text sentences.
34
+ *
35
+ * Clicks are delivered synchronously by the controller. Enter and idle scenes
36
+ * are polled from the existing render loop, avoiding a second animation loop
37
+ * or duplicate timers across PJAX navigation.
38
+ */
39
+ export declare class DialogueScheduler {
40
+ #private;
41
+ constructor(config: DialogueSchedule, random: () => number);
42
+ start(timestampMs: number, immediateEnter?: boolean): void;
43
+ poll(timestampMs: number, foregroundActive: boolean): DialogueAction | undefined;
44
+ triggerClick(timestampMs: number): DialogueAction | undefined;
45
+ /** Postpones natural speech after visibility changes or direct interaction. */
46
+ deferIdle(timestampMs: number): void;
47
+ }
@@ -0,0 +1,139 @@
1
+ /** Validate and copy plain-text libraries at the backend configuration boundary. */
2
+ export function resolveDialogueSentences(input, animations, field) {
3
+ const resolve = (value, path) => {
4
+ if (!Array.isArray(value) || value.length < 1 || value.length > 128) {
5
+ throw new Error(`${path} must contain 1..=128 sentences`);
6
+ }
7
+ return value.map((line, index) => {
8
+ if (typeof line !== "string")
9
+ throw new Error(`${path}[${index}] must be a string`);
10
+ const text = line.trim();
11
+ const bytes = new TextEncoder().encode(text).byteLength;
12
+ if (bytes < 1 || bytes > 1024)
13
+ throw new Error(`${path}[${index}] must contain 1..=1024 UTF-8 bytes`);
14
+ return text;
15
+ });
16
+ };
17
+ const lines = input.lines === undefined ? undefined : resolve(input.lines, `${field}.lines`);
18
+ const mapping = input.linesByAnimation;
19
+ if (mapping === undefined) {
20
+ if (!lines)
21
+ throw new Error(`${field}.lines or linesByAnimation is required`);
22
+ return { lines };
23
+ }
24
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) {
25
+ throw new Error(`${field}.linesByAnimation must be an animation-to-sentences object`);
26
+ }
27
+ if (!animations.length)
28
+ throw new Error(`${field}.linesByAnimation needs an animation`);
29
+ const selected = new Set(animations);
30
+ const keys = Object.keys(mapping);
31
+ if (keys.length !== selected.size || keys.some(name => !selected.has(name)) || animations.some(name => !Object.hasOwn(mapping, name))) {
32
+ throw new Error(`${field}.linesByAnimation must cover exactly the selected animations`);
33
+ }
34
+ const linesByAnimation = Object.fromEntries(keys.map(name => [name, resolve(mapping[name], `${field}.linesByAnimation[${JSON.stringify(name)}]`)]));
35
+ return { ...(lines ? { lines } : {}), linesByAnimation };
36
+ }
37
+ /**
38
+ * Schedules model-authored foreground actions and selects plain-text sentences.
39
+ *
40
+ * Clicks are delivered synchronously by the controller. Enter and idle scenes
41
+ * are polled from the existing render loop, avoiding a second animation loop
42
+ * or duplicate timers across PJAX navigation.
43
+ */
44
+ export class DialogueScheduler {
45
+ #config;
46
+ #random;
47
+ #started = false;
48
+ #enterDueAt;
49
+ #nextIdleAt;
50
+ constructor(config, random) {
51
+ this.#config = config;
52
+ this.#random = random;
53
+ }
54
+ start(timestampMs, immediateEnter = false) {
55
+ if (this.#started) {
56
+ return;
57
+ }
58
+ this.#started = true;
59
+ if (this.#config.enter) {
60
+ this.#enterDueAt =
61
+ timestampMs + (immediateEnter ? 0 : this.#config.enter.delayMs);
62
+ }
63
+ this.#scheduleIdle(timestampMs);
64
+ }
65
+ poll(timestampMs, foregroundActive) {
66
+ if (!this.#started || foregroundActive) {
67
+ return undefined;
68
+ }
69
+ if (this.#config.enter &&
70
+ this.#enterDueAt !== undefined &&
71
+ timestampMs >= this.#enterDueAt) {
72
+ this.#enterDueAt = undefined;
73
+ const action = this.#action("enter", this.#config.enter);
74
+ this.#scheduleIdle(timestampMs + action.durationMs);
75
+ return action;
76
+ }
77
+ if (this.#config.idle &&
78
+ this.#nextIdleAt !== undefined &&
79
+ timestampMs >= this.#nextIdleAt) {
80
+ const action = this.#action("idle", this.#config.idle);
81
+ this.#scheduleIdle(timestampMs + action.durationMs);
82
+ return action;
83
+ }
84
+ return undefined;
85
+ }
86
+ triggerClick(timestampMs) {
87
+ this.deferIdle(timestampMs);
88
+ if (!this.#config.click) {
89
+ return undefined;
90
+ }
91
+ const action = this.#action("click", this.#config.click);
92
+ this.#scheduleIdle(timestampMs + action.durationMs);
93
+ return action;
94
+ }
95
+ /** Postpones natural speech after visibility changes or direct interaction. */
96
+ deferIdle(timestampMs) {
97
+ if (this.#started) {
98
+ this.#scheduleIdle(timestampMs);
99
+ }
100
+ }
101
+ #action(scene, config) {
102
+ // Preserve the historical random draw order for shared sentence libraries.
103
+ // Bound libraries choose the animation first, then only its own sentences.
104
+ const chooseAnimation = () => config.animations?.length
105
+ ? config.animations[Math.floor(this.#unitRandom() * config.animations.length)]
106
+ : config.animation;
107
+ const animation = config.linesByAnimation ? chooseAnimation() : undefined;
108
+ const lines = animation === undefined ? config.lines : config.linesByAnimation[animation];
109
+ const index = Math.floor(this.#unitRandom() * lines.length);
110
+ const chosenAnimation = animation ?? chooseAnimation();
111
+ return {
112
+ scene,
113
+ ...(chosenAnimation !== undefined ? { animation: chosenAnimation } : {}),
114
+ line: lines[index],
115
+ durationMs: config.durationMs,
116
+ };
117
+ }
118
+ #scheduleIdle(timestampMs) {
119
+ if (this.#enterDueAt !== undefined) {
120
+ this.#nextIdleAt = undefined;
121
+ return;
122
+ }
123
+ const idle = this.#config.idle;
124
+ if (!idle) {
125
+ this.#nextIdleAt = undefined;
126
+ return;
127
+ }
128
+ const interval = idle.intervalMinMs +
129
+ (idle.intervalMaxMs - idle.intervalMinMs) * this.#unitRandom();
130
+ this.#nextIdleAt = timestampMs + interval;
131
+ }
132
+ #unitRandom() {
133
+ const value = this.#random();
134
+ if (!Number.isFinite(value)) {
135
+ return 0;
136
+ }
137
+ return Math.min(1 - Number.EPSILON, Math.max(0, value));
138
+ }
139
+ }
@@ -0,0 +1,22 @@
1
+ /** Model-only alpha coverage. Coordinates are CSS pixels in the host viewport. */
2
+ export interface ModelHitMask {
3
+ width: number;
4
+ height: number;
5
+ left: number;
6
+ top: number;
7
+ displayWidth: number;
8
+ displayHeight: number;
9
+ viewportWidth: number;
10
+ viewportHeight: number;
11
+ bits: Uint8Array;
12
+ }
13
+ export type ModelHitMaskListener = (mask: ModelHitMask | undefined) => void;
14
+ export declare function packAlphaMask(rgba: Uint8ClampedArray): Uint8Array;
15
+ /** Opt-in readback, called immediately after rendering before WebGL clears its buffer. */
16
+ export declare class RenderedHitMask {
17
+ #private;
18
+ setListener(listener?: ModelHitMaskListener): void;
19
+ capture(canvas: HTMLCanvasElement, timestamp: number): void;
20
+ clear(): void;
21
+ destroy(): void;
22
+ }
@@ -0,0 +1,56 @@
1
+ export function packAlphaMask(rgba) {
2
+ const bits = new Uint8Array(Math.ceil(rgba.length / 4 / 8));
3
+ for (let pixel = 0; pixel < rgba.length / 4; pixel++) {
4
+ // Include visible translucent fur and anti-aliased edges, not transparent quads.
5
+ if (rgba[pixel * 4 + 3] >= 16)
6
+ bits[pixel >> 3] |= 1 << (pixel & 7);
7
+ }
8
+ return bits;
9
+ }
10
+ /** Opt-in readback, called immediately after rendering before WebGL clears its buffer. */
11
+ export class RenderedHitMask {
12
+ #listener;
13
+ #surface;
14
+ #lastTime = -Infinity;
15
+ setListener(listener) {
16
+ this.#listener?.(undefined);
17
+ this.#listener = listener;
18
+ this.#lastTime = -Infinity;
19
+ if (!listener)
20
+ this.#surface = undefined;
21
+ }
22
+ capture(canvas, timestamp) {
23
+ if (!this.#listener || timestamp - this.#lastTime < 50)
24
+ return;
25
+ this.#lastTime = timestamp;
26
+ const rect = canvas.getBoundingClientRect();
27
+ if (rect.width <= 0 || rect.height <= 0 || !canvas.width || !canvas.height)
28
+ return;
29
+ // At most 512² bits. Normally one sample per two CSS pixels (240 × 360).
30
+ const scale = Math.min(.5, 512 / rect.width, 512 / rect.height);
31
+ const width = Math.max(1, Math.ceil(rect.width * scale));
32
+ const height = Math.max(1, Math.ceil(rect.height * scale));
33
+ const surface = this.#surface ??= document.createElement("canvas");
34
+ if (surface.width !== width)
35
+ surface.width = width;
36
+ if (surface.height !== height)
37
+ surface.height = height;
38
+ try {
39
+ const context = surface.getContext("2d", { willReadFrequently: true });
40
+ if (!context)
41
+ throw new Error("alpha sampling unavailable");
42
+ context.clearRect(0, 0, width, height);
43
+ context.drawImage(canvas, 0, 0, width, height);
44
+ this.#listener({ width, height, left: rect.left, top: rect.top,
45
+ displayWidth: rect.width, displayHeight: rect.height,
46
+ viewportWidth: innerWidth, viewportHeight: innerHeight,
47
+ bits: packAlphaMask(context.getImageData(0, 0, width, height).data) });
48
+ }
49
+ catch {
50
+ // Unreadable/tainted output must never become a rectangular hit target.
51
+ this.#listener(undefined);
52
+ }
53
+ }
54
+ clear() { this.#listener?.(undefined); }
55
+ destroy() { this.setListener(); }
56
+ }
@@ -0,0 +1,47 @@
1
+ export type ModelBackend = "inochi2d" | "vrm";
2
+ export type WidgetStatus = "disabled" | "loading" | "running" | "paused" | "fallback" | "destroyed";
3
+ export type AnimationPlaybackResult = "completed" | "interrupted" | "stopped";
4
+ export interface AnimationPlaybackOptions {
5
+ mode?: "once" | "loop";
6
+ restart?: boolean;
7
+ }
8
+ export interface ModelAnimationInfo {
9
+ name: string;
10
+ durationMs: number;
11
+ }
12
+ import type { ModelHitMaskListener } from "./hit-mask.js";
13
+ export { RenderedHitMask, packAlphaMask, type ModelHitMask, type ModelHitMaskListener } from "./hit-mask.js";
14
+ export interface ModelController {
15
+ readonly status: WidgetStatus;
16
+ pause(): void;
17
+ resume(): void;
18
+ getAnimations(): readonly ModelAnimationInfo[];
19
+ playAnimation(name: string, options?: AnimationPlaybackOptions): Promise<AnimationPlaybackResult>;
20
+ stopAnimation(): void;
21
+ resetCamera(): void;
22
+ observeInteractionMask(listener?: ModelHitMaskListener): void;
23
+ setPointerPosition(clientX: number, clientY: number): void;
24
+ /** Optional host-movement capability. Coordinates are CSS pixels, local only. */
25
+ setHostPosition?(x: number, y: number): void;
26
+ destroy(): void;
27
+ }
28
+ export interface MountOptions {
29
+ signal?: AbortSignal;
30
+ }
31
+ /** Own one mount, including asynchronous work which outlives a replacement. */
32
+ export declare class ModelMountSlot {
33
+ #private;
34
+ mount(factory: (signal: AbortSignal) => Promise<ModelController>): Promise<ModelController | undefined>;
35
+ destroy(): void;
36
+ }
37
+ /** Only import the selected backend. Runtime URLs are supplied by the host. */
38
+ export declare function mountModelWidget<C extends {
39
+ backend?: ModelBackend;
40
+ }>(host: HTMLElement, config: C, modules: Record<ModelBackend, () => Promise<{
41
+ mountModelWidget(host: HTMLElement, config: C, options?: MountOptions): Promise<ModelController>;
42
+ }>>, options?: MountOptions): Promise<ModelController>;
43
+ /** Streaming limit applies to decoded response bytes, not just Content-Length. */
44
+ export declare function fetchBounded(url: string, maximumBytes: number, signal: AbortSignal): Promise<ArrayBuffer>;
45
+ export { DialogueScheduler, resolveDialogueSentences, type DialogueAction, type DialogueSchedule, type DialogueSceneConfig, type ResolvedDialogueScene } from "./dialogue.js";
46
+ export { DialogueBubble } from "./bubble.js";
47
+ export { observePassThroughClicks } from "./pass-through.js";
package/dist/index.js ADDED
@@ -0,0 +1,85 @@
1
+ export { RenderedHitMask, packAlphaMask } from "./hit-mask.js";
2
+ /** Own one mount, including asynchronous work which outlives a replacement. */
3
+ export class ModelMountSlot {
4
+ #abort;
5
+ #controller;
6
+ async mount(factory) {
7
+ this.destroy();
8
+ const abort = new AbortController();
9
+ this.#abort = abort;
10
+ try {
11
+ const controller = await factory(abort.signal);
12
+ if (abort.signal.aborted || this.#abort !== abort) {
13
+ controller.destroy();
14
+ return undefined;
15
+ }
16
+ this.#controller = controller;
17
+ return controller;
18
+ }
19
+ catch (error) {
20
+ if (abort.signal.aborted)
21
+ return undefined;
22
+ throw error;
23
+ }
24
+ }
25
+ destroy() {
26
+ this.#abort?.abort();
27
+ this.#abort = undefined;
28
+ this.#controller?.destroy();
29
+ this.#controller = undefined;
30
+ }
31
+ }
32
+ /** Only import the selected backend. Runtime URLs are supplied by the host. */
33
+ export async function mountModelWidget(host, config, modules, options = {}) {
34
+ options.signal?.throwIfAborted();
35
+ const backend = config.backend ?? "inochi2d";
36
+ const runtime = await modules[backend]();
37
+ options.signal?.throwIfAborted();
38
+ return runtime.mountModelWidget(host, config, options);
39
+ }
40
+ /** Streaming limit applies to decoded response bytes, not just Content-Length. */
41
+ export async function fetchBounded(url, maximumBytes, signal) {
42
+ const response = await fetch(url, { signal, credentials: "same-origin" });
43
+ if (!response.ok)
44
+ throw new Error(`asset request failed: HTTP ${response.status}`);
45
+ if (Number(response.headers.get("content-length")) > maximumBytes) {
46
+ await response.body?.cancel();
47
+ throw new Error("asset exceeds the byte limit");
48
+ }
49
+ if (!response.body) {
50
+ const bytes = await response.arrayBuffer();
51
+ if (bytes.byteLength > maximumBytes)
52
+ throw new Error("asset exceeds the byte limit");
53
+ return bytes;
54
+ }
55
+ const reader = response.body.getReader();
56
+ const chunks = [];
57
+ let size = 0;
58
+ try {
59
+ for (;;) {
60
+ const { done, value } = await reader.read();
61
+ if (done)
62
+ break;
63
+ size += value.byteLength;
64
+ if (size > maximumBytes) {
65
+ await reader.cancel();
66
+ throw new Error("asset exceeds the byte limit");
67
+ }
68
+ chunks.push(value);
69
+ }
70
+ }
71
+ finally {
72
+ reader.releaseLock();
73
+ }
74
+ signal.throwIfAborted();
75
+ const bytes = new Uint8Array(size);
76
+ let offset = 0;
77
+ for (const chunk of chunks) {
78
+ bytes.set(chunk, offset);
79
+ offset += chunk.byteLength;
80
+ }
81
+ return bytes.buffer;
82
+ }
83
+ export { DialogueScheduler, resolveDialogueSentences } from "./dialogue.js";
84
+ export { DialogueBubble } from "./bubble.js";
85
+ export { observePassThroughClicks } from "./pass-through.js";
@@ -0,0 +1,3 @@
1
+ /** Observe the same native click that reaches the page beneath an inert canvas.
2
+ * Callers opt the host/canvas out of hit testing with pointer-events: none. */
3
+ export declare function observePassThroughClicks(surface: HTMLElement, onClick: () => void): () => void;
@@ -0,0 +1,58 @@
1
+ /** Observe the same native click that reaches the page beneath an inert canvas.
2
+ * Callers opt the host/canvas out of hit testing with pointer-events: none. */
3
+ export function observePassThroughClicks(surface, onClick) {
4
+ let down;
5
+ let ready;
6
+ const inside = (event) => {
7
+ const rect = surface.getBoundingClientRect();
8
+ return rect.width > 0 && rect.height > 0
9
+ && event.clientX >= rect.left && event.clientX < rect.right
10
+ && event.clientY >= rect.top && event.clientY < rect.bottom;
11
+ };
12
+ const clear = () => { down = undefined; ready = undefined; };
13
+ const move = (event) => {
14
+ if (down?.id === event.pointerId && Math.hypot(event.clientX - down.x, event.clientY - down.y) > 5)
15
+ down.moved = true;
16
+ };
17
+ const listeners = [
18
+ ["pointerdown", event => {
19
+ clear();
20
+ const pointer = event;
21
+ if (pointer.button === 0 && pointer.isPrimary !== false && inside(pointer)) {
22
+ down = { id: pointer.pointerId, x: pointer.clientX, y: pointer.clientY, moved: false };
23
+ }
24
+ }],
25
+ ["pointermove", event => move(event)],
26
+ ["pointerup", event => {
27
+ const pointer = event;
28
+ if (down?.id !== pointer.pointerId)
29
+ return;
30
+ move(pointer);
31
+ ready = pointer.button === 0 && !down.moved && inside(pointer) ? down : undefined;
32
+ down = undefined;
33
+ }],
34
+ ["pointercancel", clear],
35
+ ["scroll", clear],
36
+ // Capture also sees a page element lose focus during a normal mouse click.
37
+ // Only leaving this window cancels the in-progress pointer gesture.
38
+ ["blur", event => { if (event.target === event.currentTarget)
39
+ clear(); }],
40
+ ["click", event => {
41
+ const click = event;
42
+ const gesture = ready;
43
+ clear();
44
+ // Keyboard/programmatic clicks have no matching completed pointer gesture.
45
+ // Modifier keys intentionally remain available to the page's native click.
46
+ if (gesture && click.button === 0 && click.detail > 0 && inside(click)
47
+ && Math.hypot(click.clientX - gesture.x, click.clientY - gesture.y) <= 5)
48
+ onClick();
49
+ }],
50
+ ];
51
+ for (const [type, listener] of listeners)
52
+ window.addEventListener(type, listener, { capture: true, passive: true });
53
+ return () => {
54
+ clear();
55
+ for (const [type, listener] of listeners)
56
+ window.removeEventListener(type, listener, true);
57
+ };
58
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@starcried/starlite-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.1",
4
+ "type": "module",
5
+ "license": "BSD-2-Clause",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./bubble.css": "./dist/bubble.css"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
18
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
19
+ "test": "vitest run"
20
+ },
21
+ "description": "Format-neutral lifecycle, animation contracts and dialogue components for StarLite",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/StarCried/starlite.git",
25
+ "directory": "packages/core"
26
+ },
27
+ "homepage": "https://github.com/StarCried/starlite#readme",
28
+ "bugs": "https://github.com/StarCried/starlite/issues"
29
+ }