aispritejs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/README_ZHTW.md +209 -0
- package/dist/atlas/index.cjs +518 -0
- package/dist/atlas/index.cjs.map +1 -0
- package/dist/atlas/index.d.cts +52 -0
- package/dist/atlas/index.d.ts +52 -0
- package/dist/atlas/index.js +514 -0
- package/dist/atlas/index.js.map +1 -0
- package/dist/index.cjs +451 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +58 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +445 -0
- package/dist/index.js.map +1 -0
- package/dist/pixi/index.cjs +520 -0
- package/dist/pixi/index.cjs.map +1 -0
- package/dist/pixi/index.d.cts +75 -0
- package/dist/pixi/index.d.ts +75 -0
- package/dist/pixi/index.js +517 -0
- package/dist/pixi/index.js.map +1 -0
- package/dist/types-DKMFvfx9.d.cts +251 -0
- package/dist/types-DKMFvfx9.d.ts +251 -0
- package/llms-full.txt +449 -0
- package/llms.txt +40 -0
- package/package.json +103 -0
- package/schemas/aispritejs-graph.schema.json +126 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yshengliao
|
|
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
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# aispritejs
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/aispritejs)
|
|
4
|
+
[](https://github.com/yshengliao/aispritejs/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.anthropic.com/claude-code)
|
|
7
|
+
[](README_ZHTW.md)
|
|
8
|
+
|
|
9
|
+
> Input-driven, renderer-agnostic 2D sprite animation runtime — a tiny, Rive-like *visual* state machine driven by `Number` / `Boolean` / `Trigger` inputs.
|
|
10
|
+
|
|
11
|
+
`aispritejs` decides **which animation frame is on screen** from a small set of runtime **inputs** (e.g. `speed`, `isGrounded`, `jump`), using a JSON-defined transition graph. Your code sets inputs; `aispritejs` picks the visual state and ticks the active frame. The core is pure TypeScript with **zero dependencies** and **no renderer imports** — bind it to PixiJS v8 (or anything) through a thin adapter.
|
|
12
|
+
|
|
13
|
+
Part of the **ai\*js** family: zero cross-package dependencies, framework-agnostic core, AI-readable docs.
|
|
14
|
+
|
|
15
|
+
## Why aispritejs
|
|
16
|
+
|
|
17
|
+
- **Input-driven, not name-driven.** You set parameters (`speed=4`, `isGrounded=false`, `fireTrigger("jump")`), not animation names. Visual transitions live in data, decoupled from game code.
|
|
18
|
+
- **Renderer-agnostic core.** The state machine computes the active frame from delta-time + inputs; it never imports PixiJS or touches the DOM. Adapters map the result to textures.
|
|
19
|
+
- **Visual ≠ logic.** This is strictly a *visual* animator. It is **not** a game-logic FSM and does **not** depend on `aifsmjs`. Drive it from any logic layer (plain code, an FSM, an ECS) — they compose by convention, never by dependency.
|
|
20
|
+
- **Tiny + fast.** O(1) input lookups, O(N) checks over transitions leaving the current state, no per-frame allocation.
|
|
21
|
+
|
|
22
|
+
## Mental model
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
inputs ─▶ [transition graph] ─▶ active state ─▶ (Δt) ─▶ active frame ─▶ adapter ─▶ texture
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **Inputs** — `Number` (continuous, e.g. `speed`), `Boolean` (toggle, e.g. `isGrounded`), `Trigger` (one-shot; auto-resets after a transition consumes it, e.g. `jump` / `attack`).
|
|
29
|
+
- **States** — an animation key (into the atlas `animations`) + loop / on-end behaviour + optional speed multiplier.
|
|
30
|
+
- **Transitions** — from a state (or **Any State**) to another when conditions over inputs hold (`Equals` / `NotEquals` / `GreaterThan` / `LessThan`). The highest-priority satisfied transition wins.
|
|
31
|
+
- **`update(dt)`** — advances the playback timer; evaluates transitions (switching state, firing `onStateChange`, consuming triggers); computes the current frame from the animation's per-frame durations + loop; fires `onComplete` when a non-looping clip ends.
|
|
32
|
+
|
|
33
|
+
## Quick start — core (zero-dep)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createSpriteAnimator } from "aispritejs";
|
|
37
|
+
|
|
38
|
+
const anim = createSpriteAnimator(graph); // graph = { inputs, states, transitions, animations }
|
|
39
|
+
|
|
40
|
+
anim.setInput("speed", 4);
|
|
41
|
+
anim.setInput("isGrounded", true);
|
|
42
|
+
anim.fireTrigger("jump");
|
|
43
|
+
|
|
44
|
+
anim.onStateChange((to, from) => {/* ... */});
|
|
45
|
+
anim.onComplete((state) => {/* ... */});
|
|
46
|
+
|
|
47
|
+
// in your render loop:
|
|
48
|
+
anim.update(deltaMs);
|
|
49
|
+
const frameKey = anim.activeFrameKey; // hand to your renderer
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start — PixiJS v8 adapter
|
|
53
|
+
|
|
54
|
+
The `aispritejs/pixi` subpath binds the core to a `PIXI.Sprite`. `pixi.js` is an **optional** `peerDependency`, imported **type-only** — the built adapter contains no runtime `pixi.js` require, and the core never imports it.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createPixiSpriteAnimator } from "aispritejs/pixi"; // pixi.js is an OPTIONAL peer
|
|
58
|
+
|
|
59
|
+
// `textures` is a PIXI.Spritesheet (or a frame-key → Texture map) covering
|
|
60
|
+
// every frame the graph references — missing keys throw MissingTextureError.
|
|
61
|
+
const view = createPixiSpriteAnimator(sprite, graph, spritesheet);
|
|
62
|
+
|
|
63
|
+
// each frame:
|
|
64
|
+
view.update(deltaMs); // swaps the bound sprite's texture to the active frame,
|
|
65
|
+
// applying that frame's atlas anchor (texture.defaultAnchor)
|
|
66
|
+
|
|
67
|
+
view.setInput("speed", 4);
|
|
68
|
+
view.fireTrigger("jump");
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It swaps the texture only when the active frame changes, and honours per-frame `duration` (via the core) and non-centre / foot pivots (via `texture.defaultAnchor`; pass `{ applyAnchor: false }` to manage the anchor yourself). `view.sprite` is the bound sprite; `dispose()` tears down the core without destroying the sprite.
|
|
72
|
+
|
|
73
|
+
Pass a plain `Sprite` — the adapter owns frame selection. (An `AnimatedSprite` is accepted since it extends `Sprite`, but its own playback is stopped on bind so it cannot fight the adapter for the texture.)
|
|
74
|
+
|
|
75
|
+
## Data format (atlas)
|
|
76
|
+
|
|
77
|
+
`aispritejs` reads a **PixiJS v8-native** spritesheet atlas (`meta` / `frames` / `animations`) — the same shape the family's sprite pipeline emits — augmented with an `aispritejs` **input-driven** control block:
|
|
78
|
+
|
|
79
|
+
```jsonc
|
|
80
|
+
{
|
|
81
|
+
"meta": { "image": "sheet.png", "size": { "w": 1024, "h": 1024 }, "scale": "1" },
|
|
82
|
+
"frames": { /* PixiJS native: frame{x,y,w,h}, anchor, duration, trimmed, ... */ },
|
|
83
|
+
"animations": { "idle": ["idle_0", "idle_1"], "walk": ["walk_0", "..."], "jump": ["jump_0", "..."] },
|
|
84
|
+
|
|
85
|
+
"inputs": {
|
|
86
|
+
"speed": { "type": "number", "default": 0 },
|
|
87
|
+
"isGrounded": { "type": "boolean", "default": true },
|
|
88
|
+
"jump": { "type": "trigger" }
|
|
89
|
+
},
|
|
90
|
+
"states": {
|
|
91
|
+
"idle": { "animation": "idle", "loop": true },
|
|
92
|
+
"walk": { "animation": "walk", "loop": true },
|
|
93
|
+
"jump": { "animation": "jump", "loop": false }
|
|
94
|
+
},
|
|
95
|
+
"transitions": [
|
|
96
|
+
{ "from": "*", "to": "jump", "when": [{ "input": "jump", "op": "Trigger" }], "priority": 10 },
|
|
97
|
+
{ "from": "idle", "to": "walk", "when": [{ "input": "speed", "op": "GreaterThan", "value": 0 }] },
|
|
98
|
+
{ "from": "walk", "to": "idle", "when": [{ "input": "speed", "op": "Equals", "value": 0 }] }
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
This **input-driven** model is deliberately distinct from an event-driven FSM. `aispritejs` ingests only the universal `frames` / `animations`; the `inputs` / `states` / `transitions` are its own. If an atlas carries a foreign event-driven `states` block from another tool, `aispritejs` ignores it.
|
|
104
|
+
|
|
105
|
+
## Loading an atlas — `aispritejs/atlas`
|
|
106
|
+
|
|
107
|
+
The `aispritejs/atlas` subpath turns a parsed PixiJS-v8 atlas into a graph (or a ready animator). It is pure and zero-dependency.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { parseAtlas, loadAtlas } from "aispritejs/atlas";
|
|
111
|
+
|
|
112
|
+
// Augmented atlas (the shape above, with inputs/states/transitions inline):
|
|
113
|
+
const anim = loadAtlas(atlasJson);
|
|
114
|
+
|
|
115
|
+
// Real atlas whose own `states` block is foreign (event-driven) or absent —
|
|
116
|
+
// supply the input-driven control separately; the foreign block is ignored:
|
|
117
|
+
const graph = parseAtlas(atlasJson, { inputs, states, transitions, initial });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- `parseAtlas(atlas, control?)` → `SpriteGraph`; `loadAtlas(atlas, control?)` → `SpriteAnimator` (parse + create in one fail-fast step).
|
|
121
|
+
- A foreign event-driven `states` block (the `{ initial, definitions }` FSM shape) is **detected and ignored** — pass an `aispritejs` control block instead. Structural problems throw `InvalidAtlasError`; semantic ones surface as `InvalidGraphError` from the core.
|
|
122
|
+
- The canonical structure is published as a JSON Schema at [`schemas/aispritejs-graph.schema.json`](schemas/aispritejs-graph.schema.json) (also exported as `aispritejs/schema`) for editor and CI validation; the parser mirrors it in code, so there is no runtime schema-validator dependency.
|
|
123
|
+
|
|
124
|
+
## Decoupling (P0)
|
|
125
|
+
|
|
126
|
+
- **Zero cross-package imports** — `aispritejs` does not import `aifsmjs`, `aieventjs`, or any sibling. It has its own minimal typed emitter.
|
|
127
|
+
- **Renderer-agnostic core** — the root entry never imports `pixi.js`. Only `aispritejs/pixi` does, and `pixi.js` is an **optional `peerDependency`**.
|
|
128
|
+
- **Visual animator only** — pair it with a game-logic layer by setting inputs; it makes no assumption about how your logic is structured.
|
|
129
|
+
|
|
130
|
+
## Core API
|
|
131
|
+
|
|
132
|
+
The public surface is a single factory plus types and named errors. There is **no exported class constructor** — `createSpriteAnimator` returns a `SpriteAnimator`.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const anim = createSpriteAnimator(graph); // throws InvalidGraphError on a bad graph
|
|
136
|
+
|
|
137
|
+
anim.setInput(name, value); // Number | Boolean; throws Unknown/InputTypeError
|
|
138
|
+
anim.fireTrigger(name); // marks a Trigger pending
|
|
139
|
+
anim.update(deltaMs); // advance; evaluate transitions; tick the frame
|
|
140
|
+
anim.reset(); // back to initial + default inputs (keeps buffers)
|
|
141
|
+
anim.dispose(); // idempotent; mutators throw afterwards
|
|
142
|
+
|
|
143
|
+
const off = anim.onStateChange((to, from) => {}, { signal?, once? }); // → unsubscribe
|
|
144
|
+
const off2 = anim.onComplete((state) => {}, { signal?, once? });
|
|
145
|
+
|
|
146
|
+
anim.activeState; // current state name
|
|
147
|
+
anim.activeFrameKey; // frame key into the atlas `frames` — hand to a renderer
|
|
148
|
+
anim.activeFrameIndex; // index within the active animation
|
|
149
|
+
anim.disposed; // boolean
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Every subscription returns an unsubscribe function and accepts `{ signal }` (an `AbortSignal` that removes the listener) and `{ once }`.
|
|
153
|
+
|
|
154
|
+
## Semantics (the precise rules)
|
|
155
|
+
|
|
156
|
+
These rules are deterministic and frozen for the 1.x line once 1.0 ships:
|
|
157
|
+
|
|
158
|
+
- **`update(dt)` order** — advance the timer by `dt × speed` (negative `dt` clamps to `0`); evaluate transitions; recompute the active frame; fire `onComplete` for a finished non-looping clip and then any `onEnd` auto-transition. An explicit input transition therefore wins over end-of-clip behaviour on the same frame.
|
|
159
|
+
- **Transition resolution** — among the transitions leaving the current state (plus **Any-State** `from: "*"`), candidates are ordered by `priority` (desc) then declared order (asc); the **first effective** one is taken. All `when` conditions must hold (logical AND).
|
|
160
|
+
- **Self-transition rule** — a transition whose `to` equals the current state is *effective only if it consumes a Trigger*. A Number/Boolean self-loop is skipped, so it cannot reset the clip to frame 0 every frame. A trigger-bearing self-transition **restarts** the clip (e.g. re-attack) but does **not** fire `onStateChange` (the state name is unchanged).
|
|
161
|
+
- **Triggers** — `fireTrigger(name)` marks a trigger pending; it stays pending across frames until a transition that checks it is taken, which **consumes** it. One fire → at most one transition.
|
|
162
|
+
- **Frame timing** — the active frame is the first whose cumulative duration exceeds the elapsed time. Looping clips wrap at the total duration; non-looping clips hold the last frame and fire `onComplete` exactly once. Per-frame `duration` comes from the atlas `frames`; frames without one use `defaultFrameDuration` (default `100` ms). `speed` is a time-scale multiplier (`2` = twice as fast).
|
|
163
|
+
- **Determinism** — identical input + `dt` sequences always yield identical frame sequences. The no-transition path allocates nothing.
|
|
164
|
+
|
|
165
|
+
## Errors
|
|
166
|
+
|
|
167
|
+
Named errors, never bare throws:
|
|
168
|
+
|
|
169
|
+
- `InvalidGraphError` — thrown by `createSpriteAnimator` when the graph fails validation (missing animation, unknown transition target, operator/kind mismatch, non-positive duration/speed, `onEnd` with `loop:true`, …). Fail-fast: an invalid graph never yields a half-built animator.
|
|
170
|
+
- `UnknownInputError` — `setInput` / `fireTrigger` on an input not declared in `inputs` (carries `.input`).
|
|
171
|
+
- `InputTypeError` — wrong value type, `setInput` on a Trigger, or `fireTrigger` on a non-Trigger (carries `.input`).
|
|
172
|
+
- `SpriteAnimatorDisposedError` — any mutator (`setInput` / `fireTrigger` / `update` / `reset`) after `dispose()`.
|
|
173
|
+
|
|
174
|
+
## Comparison
|
|
175
|
+
|
|
176
|
+
| | aispritejs | Rive | aifsmjs | raw `AnimatedSprite` |
|
|
177
|
+
|---|---|---|---|---|
|
|
178
|
+
| Control model | input-driven (Number/Boolean/Trigger) | input-driven | event-driven (logic) | manual |
|
|
179
|
+
| Scope | visual animation | visual animation | game logic | playback only |
|
|
180
|
+
| Runtime | tiny TS, no wasm | wasm runtime | tiny TS | — |
|
|
181
|
+
| Renderer | agnostic + adapters | own | n/a | PixiJS |
|
|
182
|
+
|
|
183
|
+
`aispritejs` and `aifsmjs` are complementary — logic FSM sets inputs, visual animator picks frames — and never coupled.
|
|
184
|
+
|
|
185
|
+
## AI-agent reading guide
|
|
186
|
+
|
|
187
|
+
- **Whole context in one fetch** — [`llms-full.txt`](llms-full.txt) concatenates this README, the changelog, the contributing guide, and the examples index.
|
|
188
|
+
- **Source layout** — the core lives in [`src/sprite/`](src/sprite/): `types.ts` (every public type in one file), `machine.ts` (the `createSpriteAnimator` engine), `compile.ts` (graph validation + normalisation), `inputs.ts` (the input store), `emitter.ts` (the own typed signal), `errors.ts`. The root [`src/index.ts`](src/index.ts) re-exports the public surface and imports **no** renderer.
|
|
189
|
+
- **Stability tiers** — see [STABILITY.md](STABILITY.md).
|
|
190
|
+
|
|
191
|
+
## Testing
|
|
192
|
+
|
|
193
|
+
Behavioural `vitest` suites cover inputs, transitions, trigger consumption, frame timing, `onComplete` / `onEnd`, subscriptions (`signal` / `once`), `dispose` / `reset`, and graph validation. `fast-check` property tests assert **transition determinism** (identical input + `dt` sequences ⇒ identical frame traces) and **trigger consumption** (one fire ⇒ one entry). Coverage runs at the family floor (≥95 % statements / ≥90 % branches / 100 % functions-and-lines).
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
pnpm test # run once
|
|
197
|
+
pnpm coverage # with thresholds
|
|
198
|
+
pnpm example:platformer
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Status
|
|
202
|
+
|
|
203
|
+
**v0.1.0 — full first release.** All roadmap modules (1–4) ship together: the renderer-agnostic core (`.`), the PixiJS v8 adapter (`aispritejs/pixi`), the atlas parser (`aispritejs/atlas`), and the JSON Schema (`aispritejs/schema`). Zero runtime dependencies; the root import graph contains no `pixi.js`; `pixi.js` is an optional, type-only peer used only by the `/pixi` subpath. Versioning and release tags are cut by the maintainer.
|
|
204
|
+
|
|
205
|
+
## Roadmap
|
|
206
|
+
|
|
207
|
+
See [ROADMAP.md](ROADMAP.md).
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT © yshengliao — see [LICENSE](LICENSE).
|
package/README_ZHTW.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# aispritejs
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/aispritejs)
|
|
4
|
+
[](https://github.com/yshengliao/aispritejs/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.anthropic.com/claude-code)
|
|
7
|
+
[](README.md)
|
|
8
|
+
|
|
9
|
+
> 以輸入驅動、與渲染器無關的 2D sprite 動畫 runtime —— 一個輕巧、類 Rive 的*視覺*狀態機,由 `Number` / `Boolean` / `Trigger` 輸入驅動。
|
|
10
|
+
|
|
11
|
+
`aispritejs` 依據一小組執行期**輸入**(例如 `speed`、`isGrounded`、`jump`),透過 JSON 定義的轉移圖,決定**畫面上要顯示哪一格動畫影格**。你的程式設定輸入,`aispritejs` 挑選視覺狀態並推進當前影格。核心是純 TypeScript,**零相依**且**不 import 任何渲染器** —— 透過薄薄一層轉接器即可接上 PixiJS v8(或任何東西)。
|
|
12
|
+
|
|
13
|
+
屬於 **ai\*js** 家族:零跨套件相依、核心與框架無關、AI 可讀的文件。
|
|
14
|
+
|
|
15
|
+
## 為什麼用 aispritejs
|
|
16
|
+
|
|
17
|
+
- **輸入驅動,而非名稱驅動。** 你設定參數(`speed=4`、`isGrounded=false`、`fireTrigger("jump")`),而不是動畫名稱。視覺轉移存在於資料中,與遊戲程式解耦。
|
|
18
|
+
- **核心與渲染器無關。** 狀態機從 delta-time 加上輸入算出當前影格;它從不 import PixiJS 也不碰 DOM。轉接器負責把結果對映成 texture。
|
|
19
|
+
- **視覺 ≠ 邏輯。** 這嚴格來說是一個*視覺*動畫器,**不是**遊戲邏輯 FSM,也**不**相依 `aifsmjs`。用任何邏輯層(純程式、FSM、ECS)來驅動它 —— 它們以慣例組合,從不以相依耦合。
|
|
20
|
+
- **輕巧又快。** O(1) 的輸入查找、對離開當前狀態的轉移做 O(N) 檢查、無每幀配置。
|
|
21
|
+
|
|
22
|
+
## 心智模型
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
inputs ─▶ [轉移圖] ─▶ 當前狀態 ─▶ (Δt) ─▶ 當前影格 ─▶ 轉接器 ─▶ texture
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **Inputs(輸入)** —— `Number`(連續,如 `speed`)、`Boolean`(開關,如 `isGrounded`)、`Trigger`(一次性;被某個轉移消耗後自動重置,如 `jump` / `attack`)。
|
|
29
|
+
- **States(狀態)** —— 一個動畫鍵(指向 atlas 的 `animations`)加上 loop / on-end 行為與選用的速度倍率。
|
|
30
|
+
- **Transitions(轉移)** —— 當輸入條件成立時(`Equals` / `NotEquals` / `GreaterThan` / `LessThan`),從某狀態(或 **Any State**)轉到另一狀態。優先度最高且成立者勝出。
|
|
31
|
+
- **`update(dt)`** —— 推進播放計時器;評估轉移(切換狀態、觸發 `onStateChange`、消耗 trigger);由動畫的逐影格時長加 loop 算出當前影格;非循環片段播完時觸發 `onComplete`。
|
|
32
|
+
|
|
33
|
+
## 快速開始 —— 核心(零相依)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createSpriteAnimator } from "aispritejs";
|
|
37
|
+
|
|
38
|
+
const anim = createSpriteAnimator(graph); // graph = { inputs, states, transitions, animations }
|
|
39
|
+
|
|
40
|
+
anim.setInput("speed", 4);
|
|
41
|
+
anim.setInput("isGrounded", true);
|
|
42
|
+
anim.fireTrigger("jump");
|
|
43
|
+
|
|
44
|
+
anim.onStateChange((to, from) => {/* ... */});
|
|
45
|
+
anim.onComplete((state) => {/* ... */});
|
|
46
|
+
|
|
47
|
+
// 在你的 render loop 裡:
|
|
48
|
+
anim.update(deltaMs);
|
|
49
|
+
const frameKey = anim.activeFrameKey; // 交給你的渲染器
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 快速開始 —— PixiJS v8 轉接器
|
|
53
|
+
|
|
54
|
+
`aispritejs/pixi` 子路徑把核心綁到一個 `PIXI.Sprite`。`pixi.js` 是**選用的** `peerDependency`,且僅以 **type-only** 方式 import —— 編譯後的轉接器不含任何 `pixi.js` runtime require,核心也永不碰它。
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createPixiSpriteAnimator } from "aispritejs/pixi"; // pixi.js 是選用 peer
|
|
58
|
+
|
|
59
|
+
// `textures` 是 PIXI.Spritesheet(或 frame-key → Texture 的 map),需涵蓋 graph
|
|
60
|
+
// 參照的每一格 —— 缺鍵會丟出 MissingTextureError。
|
|
61
|
+
const view = createPixiSpriteAnimator(sprite, graph, spritesheet);
|
|
62
|
+
|
|
63
|
+
// 每幀:
|
|
64
|
+
view.update(deltaMs); // 把綁定 sprite 的 texture 換成當前影格,
|
|
65
|
+
// 並套用該格的 atlas anchor(texture.defaultAnchor)
|
|
66
|
+
|
|
67
|
+
view.setInput("speed", 4);
|
|
68
|
+
view.fireTrigger("jump");
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
它只在當前影格改變時換 texture,並尊重逐影格 `duration`(透過核心)與非置中/腳底樞軸(透過 `texture.defaultAnchor`;傳 `{ applyAnchor: false }` 可自行管理 anchor)。`view.sprite` 是綁定的 sprite;`dispose()` 拆除核心但不銷毀 sprite。
|
|
72
|
+
|
|
73
|
+
## 資料格式(atlas)
|
|
74
|
+
|
|
75
|
+
`aispritejs` 讀取 **PixiJS v8 原生**的 spritesheet atlas(`meta` / `frames` / `animations`),並額外加上一個 `aispritejs` 的**輸入驅動**控制區塊:
|
|
76
|
+
|
|
77
|
+
```jsonc
|
|
78
|
+
{
|
|
79
|
+
"meta": { "image": "sheet.png", "size": { "w": 1024, "h": 1024 }, "scale": "1" },
|
|
80
|
+
"frames": { /* PixiJS 原生:frame{x,y,w,h}、anchor、duration、trimmed... */ },
|
|
81
|
+
"animations": { "idle": ["idle_0", "idle_1"], "walk": ["walk_0", "..."], "jump": ["jump_0", "..."] },
|
|
82
|
+
|
|
83
|
+
"inputs": {
|
|
84
|
+
"speed": { "type": "number", "default": 0 },
|
|
85
|
+
"isGrounded": { "type": "boolean", "default": true },
|
|
86
|
+
"jump": { "type": "trigger" }
|
|
87
|
+
},
|
|
88
|
+
"states": {
|
|
89
|
+
"idle": { "animation": "idle", "loop": true },
|
|
90
|
+
"walk": { "animation": "walk", "loop": true },
|
|
91
|
+
"jump": { "animation": "jump", "loop": false }
|
|
92
|
+
},
|
|
93
|
+
"transitions": [
|
|
94
|
+
{ "from": "*", "to": "jump", "when": [{ "input": "jump", "op": "Trigger" }], "priority": 10 },
|
|
95
|
+
{ "from": "idle", "to": "walk", "when": [{ "input": "speed", "op": "GreaterThan", "value": 0 }] },
|
|
96
|
+
{ "from": "walk", "to": "idle", "when": [{ "input": "speed", "op": "Equals", "value": 0 }] }
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
這個**輸入驅動**模型刻意與事件驅動 FSM 區隔。`aispritejs` 只吃通用的 `frames` / `animations`;`inputs` / `states` / `transitions` 屬於它自己。若某個 atlas 帶有來自其他工具的事件驅動 `states` 區塊,`aispritejs` 會忽略它。
|
|
102
|
+
|
|
103
|
+
## 載入 atlas —— `aispritejs/atlas`
|
|
104
|
+
|
|
105
|
+
`aispritejs/atlas` 子路徑把已解析的 PixiJS v8 atlas 轉成 graph(或現成的 animator)。它是純函式且零相依。
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { parseAtlas, loadAtlas } from "aispritejs/atlas";
|
|
109
|
+
|
|
110
|
+
// 增強型 atlas(上述形狀,inputs/states/transitions 內嵌):
|
|
111
|
+
const anim = loadAtlas(atlasJson);
|
|
112
|
+
|
|
113
|
+
// 真實 atlas 的 `states` 是 foreign(事件驅動)或不存在 —— 另外傳入輸入驅動
|
|
114
|
+
// 控制區塊;foreign 區塊會被忽略:
|
|
115
|
+
const graph = parseAtlas(atlasJson, { inputs, states, transitions, initial });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- `parseAtlas(atlas, control?)` → `SpriteGraph`;`loadAtlas(atlas, control?)` → `SpriteAnimator`(一步 parse + create,fail-fast)。
|
|
119
|
+
- foreign 事件驅動 `states`(`{ initial, definitions }` 的 FSM 形狀)會被**偵測並忽略** —— 改傳 `aispritejs` 控制區塊。結構問題丟出 `InvalidAtlasError`;語意問題由核心丟出 `InvalidGraphError`。
|
|
120
|
+
- 標準結構以 JSON Schema 發佈於 [`schemas/aispritejs-graph.schema.json`](schemas/aispritejs-graph.schema.json)(亦匯出為 `aispritejs/schema`),供編輯器與 CI 驗證;parser 以程式碼鏡像它,因此無 runtime schema-validator 相依。
|
|
121
|
+
|
|
122
|
+
## 核心 API
|
|
123
|
+
|
|
124
|
+
公開介面是單一工廠函式,加上型別與具名錯誤。**不匯出任何 class 建構子** —— `createSpriteAnimator` 回傳一個 `SpriteAnimator`。
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const anim = createSpriteAnimator(graph); // graph 不合法時丟出 InvalidGraphError
|
|
128
|
+
|
|
129
|
+
anim.setInput(name, value); // Number | Boolean;丟出 Unknown/InputTypeError
|
|
130
|
+
anim.fireTrigger(name); // 將某個 Trigger 標記為 pending
|
|
131
|
+
anim.update(deltaMs); // 推進;評估轉移;推進影格
|
|
132
|
+
anim.reset(); // 回到初始狀態與預設輸入(保留 buffer)
|
|
133
|
+
anim.dispose(); // 冪等;之後呼叫 mutator 會丟錯
|
|
134
|
+
|
|
135
|
+
const off = anim.onStateChange((to, from) => {}, { signal?, once? }); // → 取消訂閱
|
|
136
|
+
const off2 = anim.onComplete((state) => {}, { signal?, once? });
|
|
137
|
+
|
|
138
|
+
anim.activeState; // 當前狀態名稱
|
|
139
|
+
anim.activeFrameKey; // 指向 atlas `frames` 的影格鍵 —— 交給渲染器
|
|
140
|
+
anim.activeFrameIndex; // 在當前動畫中的索引
|
|
141
|
+
anim.disposed; // boolean
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
每個訂閱都回傳一個取消訂閱函式,並接受 `{ signal }`(用 `AbortSignal` 移除監聽)與 `{ once }`。
|
|
145
|
+
|
|
146
|
+
## 語意(精確規則)
|
|
147
|
+
|
|
148
|
+
這些規則是決定性的,並在 1.0 推出後對 1.x 凍結:
|
|
149
|
+
|
|
150
|
+
- **`update(dt)` 順序** —— 以 `dt × speed` 推進計時器(負的 `dt` 夾到 `0`);評估轉移;重算當前影格;為播完的非循環片段觸發 `onComplete`,接著執行任何 `onEnd` 自動轉移。因此同一幀內,明確的輸入轉移優先於片段結束行為。
|
|
151
|
+
- **轉移解析** —— 在離開當前狀態的轉移(加上 **Any-State** `from: "*"`)中,候選依 `priority`(遞減)再依宣告順序(遞增)排序;取**第一個有效**者。所有 `when` 條件須全部成立(邏輯 AND)。
|
|
152
|
+
- **自我轉移規則** —— `to` 等於當前狀態的轉移,*只有在它消耗一個 Trigger 時才有效*。純 Number/Boolean 的自我迴圈會被跳過,因此不會每幀把片段重置回第 0 格。帶 trigger 的自我轉移會**重啟**片段(例如連續攻擊),但**不**觸發 `onStateChange`(狀態名稱沒變)。
|
|
153
|
+
- **Triggers** —— `fireTrigger(name)` 把某 trigger 標記為 pending;它跨幀保持 pending,直到某個檢查它的轉移被採用而**消耗**它。一次 fire → 至多一次轉移。
|
|
154
|
+
- **影格時序** —— 當前影格是累積時長首次超過已經過時間的那一格。循環片段在總時長處回繞;非循環片段停在最後一格並只觸發一次 `onComplete`。逐影格 `duration` 取自 atlas `frames`;沒有的影格採用 `defaultFrameDuration`(預設 `100` ms)。`speed` 是時間倍率(`2` = 兩倍速)。
|
|
155
|
+
- **決定性** —— 相同的輸入加 `dt` 序列必產生相同的影格序列。無轉移的路徑不做任何配置。
|
|
156
|
+
|
|
157
|
+
## 錯誤
|
|
158
|
+
|
|
159
|
+
具名錯誤,從不裸 throw:
|
|
160
|
+
|
|
161
|
+
- `InvalidGraphError` —— `createSpriteAnimator` 在 graph 驗證失敗時丟出(缺少動畫、未知的轉移目標、運算子/型別不符、非正的時長/速度、`onEnd` 配 `loop:true`…)。Fail-fast:不合法的 graph 絕不產出半成品動畫器。
|
|
162
|
+
- `UnknownInputError` —— 對 `inputs` 未宣告的輸入呼叫 `setInput` / `fireTrigger`(帶有 `.input`)。
|
|
163
|
+
- `InputTypeError` —— 值型別錯誤、對 Trigger 呼叫 `setInput`、或對非 Trigger 呼叫 `fireTrigger`(帶有 `.input`)。
|
|
164
|
+
- `SpriteAnimatorDisposedError` —— `dispose()` 之後呼叫任何 mutator(`setInput` / `fireTrigger` / `update` / `reset`)。
|
|
165
|
+
|
|
166
|
+
## 解耦(P0)
|
|
167
|
+
|
|
168
|
+
- **零跨套件 import** —— `aispritejs` 不 import `aifsmjs`、`aieventjs` 或任何手足套件。它有自己最小的具名 emitter。
|
|
169
|
+
- **核心與渲染器無關** —— 根進入點永不 import `pixi.js`。只有 `aispritejs/pixi` 會,而 `pixi.js` 是**選用的** `peerDependency`。
|
|
170
|
+
- **僅是視覺動畫器** —— 用設定輸入的方式搭配遊戲邏輯層;它不假設你的邏輯如何組織。
|
|
171
|
+
|
|
172
|
+
## 比較
|
|
173
|
+
|
|
174
|
+
| | aispritejs | Rive | aifsmjs | 原生 `AnimatedSprite` |
|
|
175
|
+
|---|---|---|---|---|
|
|
176
|
+
| 控制模型 | 輸入驅動(Number/Boolean/Trigger) | 輸入驅動 | 事件驅動(邏輯) | 手動 |
|
|
177
|
+
| 範圍 | 視覺動畫 | 視覺動畫 | 遊戲邏輯 | 僅播放 |
|
|
178
|
+
| Runtime | 輕巧 TS,無 wasm | wasm runtime | 輕巧 TS | — |
|
|
179
|
+
| 渲染器 | 無關 + 轉接器 | 自帶 | n/a | PixiJS |
|
|
180
|
+
|
|
181
|
+
`aispritejs` 與 `aifsmjs` 互補 —— 邏輯 FSM 設定輸入、視覺動畫器挑影格 —— 且永不耦合。
|
|
182
|
+
|
|
183
|
+
## 給 AI agent 的閱讀指南
|
|
184
|
+
|
|
185
|
+
- **一次抓完整 context** —— [`llms-full.txt`](llms-full.txt) 串接了本 README、changelog、contributing 指南與範例索引。
|
|
186
|
+
- **原始碼版面** —— 核心位於 [`src/sprite/`](src/sprite/):`types.ts`(所有公開型別集中一檔)、`machine.ts`(`createSpriteAnimator` 引擎)、`compile.ts`(graph 驗證與正規化)、`inputs.ts`(輸入儲存)、`emitter.ts`(自有具名 signal)、`errors.ts`。根 [`src/index.ts`](src/index.ts) 再匯出公開介面,且**不** import 任何渲染器。
|
|
187
|
+
- **穩定度分級** —— 見 [STABILITY.md](STABILITY.md)。
|
|
188
|
+
|
|
189
|
+
## 測試
|
|
190
|
+
|
|
191
|
+
`vitest` 行為測試涵蓋輸入、轉移、trigger 消耗、影格時序、`onComplete` / `onEnd`、訂閱(`signal` / `once`)、`dispose` / `reset` 與 graph 驗證。`fast-check` 屬性測試驗證**轉移決定性**(相同輸入加 `dt` 序列 ⇒ 相同影格軌跡)與 **trigger 消耗**(一次 fire ⇒ 一次進入)。覆蓋率以家族下限執行(≥95% statements / ≥90% branches / 100% functions 與 lines)。
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pnpm test # 跑一次
|
|
195
|
+
pnpm coverage # 帶門檻
|
|
196
|
+
pnpm example:platformer
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## 狀態
|
|
200
|
+
|
|
201
|
+
**v0.1.0 —— 完整首發。** roadmap 模組 1–4 一併 ship:與渲染器無關的核心(`.`)、PixiJS v8 轉接器(`aispritejs/pixi`)、atlas parser(`aispritejs/atlas`)、JSON Schema(`aispritejs/schema`)。零執行期相依;根 import 圖不含 `pixi.js`;`pixi.js` 是選用、type-only 的 peer,僅 `/pixi` 子路徑用。版本號與發佈 tag 由維護者切。
|
|
202
|
+
|
|
203
|
+
## Roadmap
|
|
204
|
+
|
|
205
|
+
見 [ROADMAP.md](ROADMAP.md)。
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT © yshengliao —— 見 [LICENSE](LICENSE)。
|