hayao 0.2.0 → 0.4.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/README.md +123 -24
- package/bin/create-hayao.mjs +380 -0
- package/bin/hayao-mcp-cli.mjs +11 -0
- package/dist/app/browser.d.ts +65 -4
- package/dist/app/game.d.ts +59 -10
- package/dist/app/tuning.d.ts +68 -0
- package/dist/art/palette.d.ts +41 -0
- package/dist/audio/adaptive.d.ts +58 -0
- package/dist/audio/album.d.ts +16 -0
- package/dist/audio/analysis.d.ts +59 -0
- package/dist/audio/audio.d.ts +39 -1
- package/dist/audio/chord.d.ts +17 -0
- package/dist/audio/genres.d.ts +11 -0
- package/dist/audio/lint.d.ts +29 -0
- package/dist/audio/match.d.ts +38 -0
- package/dist/audio/music.d.ts +88 -0
- package/dist/audio/pcm.d.ts +54 -0
- package/dist/audio/quality.d.ts +28 -0
- package/dist/audio/reverb.d.ts +15 -0
- package/dist/audio/synth.d.ts +63 -0
- package/dist/audio/theory.d.ts +64 -0
- package/dist/audio/zzfx.d.ts +14 -0
- package/dist/content/campaign.d.ts +69 -0
- package/dist/content/generate.d.ts +78 -0
- package/dist/content/level.d.ts +93 -0
- package/dist/content/worldgraph.d.ts +73 -0
- package/dist/core/clock.d.ts +1 -1
- package/dist/core/dmath.d.ts +2 -0
- package/dist/core/projection.d.ts +36 -0
- package/dist/core/rng.d.ts +9 -0
- package/dist/hayao.global.js +200 -0
- package/dist/index.d.ts +39 -1
- package/dist/index.js +6558 -686
- package/dist/index.js.map +4 -4
- package/dist/index.min.js +200 -0
- package/dist/input/actions.d.ts +60 -6
- package/dist/input/gamepad.d.ts +101 -0
- package/dist/input/source.d.ts +133 -1
- package/dist/logic/coroutine.d.ts +68 -0
- package/dist/mcp.js +31225 -0
- package/dist/rasterize-worker-lite.mjs +13 -0
- package/dist/render/canvas.d.ts +6 -5
- package/dist/render/canvas2d-core.d.ts +9 -0
- package/dist/render/commands.d.ts +82 -2
- package/dist/render/paint.d.ts +41 -0
- package/dist/render/renderer.d.ts +47 -0
- package/dist/render/svg.d.ts +5 -1
- package/dist/render/svgString.d.ts +6 -2
- package/dist/render/webgl.d.ts +176 -0
- package/dist/scene/cameraController.d.ts +42 -0
- package/dist/scene/iso.d.ts +73 -0
- package/dist/scene/node.d.ts +98 -6
- package/dist/scene/nodes.d.ts +48 -0
- package/dist/scene/parallax.d.ts +15 -0
- package/dist/scene/particles.d.ts +19 -0
- package/dist/scene/verletChain.d.ts +76 -0
- package/dist/studio/mcpMain.d.ts +1 -0
- package/dist/studio/mcpServer.d.ts +2 -0
- package/dist/studio/record.d.ts +54 -0
- package/dist/studio/run.d.ts +78 -0
- package/dist/studio/session.d.ts +80 -0
- package/dist/studio/timeline.d.ts +35 -0
- package/dist/studio/vitePlugin.d.ts +6 -0
- package/dist/studio-plugin.js +228 -0
- package/dist/ui/overlay.d.ts +6 -0
- package/dist/ui/touch.d.ts +51 -0
- package/dist/verify/audioFilmstrip.d.ts +39 -0
- package/dist/verify/dom.d.ts +26 -0
- package/dist/verify/ethnography.d.ts +67 -0
- package/dist/verify/gates.d.ts +160 -0
- package/dist/verify/layout.d.ts +30 -3
- package/dist/verify/ramp.d.ts +40 -0
- package/dist/world.d.ts +109 -8
- package/dist-studio/assets/index-C7tty_Wo.js +109 -0
- package/dist-studio/assets/index-CM3tjRQo.css +1 -0
- package/dist-studio/index.html +18 -0
- package/docs/API.md +300 -23
- package/docs/CONVENTIONS.md +511 -0
- package/docs/EMBED.md +90 -0
- package/docs/QUICKSTART.md +141 -0
- package/docs/STUDIO.md +97 -0
- package/docs/VERIFICATION.md +256 -0
- package/package.json +40 -6
package/dist/scene/node.d.ts
CHANGED
|
@@ -1,25 +1,70 @@
|
|
|
1
1
|
import type { Rng } from '../core/rng';
|
|
2
2
|
import type { Clock } from '../core/clock';
|
|
3
|
-
import {
|
|
3
|
+
import type { InputState } from '../input/actions';
|
|
4
|
+
import { Signal, type EventBus } from '../core/events';
|
|
4
5
|
import { type Transform, type Vec2 } from '../core/math';
|
|
5
6
|
import type { DrawCommand } from '../render/commands';
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* What a live node can see of its host world. The World implements this — it is
|
|
9
|
+
* exactly the read-only context handed to update callbacks, so a behaviour can
|
|
10
|
+
* reach input/rng/clock/time WITHOUT closing over the `world` from build(). That
|
|
11
|
+
* closure-free property is what lets a node be lifted out and reused verbatim.
|
|
12
|
+
*/
|
|
7
13
|
export interface WorldContext {
|
|
8
14
|
readonly rng: Rng;
|
|
9
15
|
readonly clock: Clock;
|
|
16
|
+
/** Sampled input for this step — `ctx.input.isDown('jump')`, `ctx.input.axis('pointer.x')`. */
|
|
17
|
+
readonly input: InputState;
|
|
10
18
|
/** Queue a node to be freed at the end of the current step (safe during iteration). */
|
|
11
19
|
requestFree(node: Node): void;
|
|
12
20
|
/** Seconds elapsed in sim time. */
|
|
13
21
|
readonly time: number;
|
|
22
|
+
/**
|
|
23
|
+
* Canonical out-of-tree game state (hashed + snapshotted) — the same object
|
|
24
|
+
* as `world.state`, so behaviours read/write shared sim data without closing
|
|
25
|
+
* over the world or stashing it in module variables.
|
|
26
|
+
*/
|
|
27
|
+
readonly state: Record<string, unknown>;
|
|
28
|
+
/** The world event bus — emit/subscribe without a `world` closure. */
|
|
29
|
+
readonly events: EventBus<Record<string, unknown>>;
|
|
30
|
+
/** Design-space dimensions (what the camera letterboxes to). */
|
|
31
|
+
readonly width: number;
|
|
32
|
+
readonly height: number;
|
|
33
|
+
/** True while the world is paused (only `pauseMode: 'always'` subtrees update). */
|
|
34
|
+
readonly paused: boolean;
|
|
35
|
+
/** Sim-time multiplier (1 = realtime); `'always'` subtrees receive unscaled dt. */
|
|
36
|
+
readonly timeScale: number;
|
|
37
|
+
/**
|
|
38
|
+
* The active camera's world position + zoom, or null when none is current.
|
|
39
|
+
* Structural (no Camera2D import needed) — enough for parallax, culling, and
|
|
40
|
+
* screen-edge logic without a sibling search through the tree.
|
|
41
|
+
*/
|
|
42
|
+
camera(): {
|
|
43
|
+
pos: {
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
};
|
|
47
|
+
zoom: number;
|
|
48
|
+
} | null;
|
|
14
49
|
}
|
|
15
50
|
/** A composable update unit attached to a node (favour over deep inheritance). */
|
|
16
51
|
export interface Behavior {
|
|
17
52
|
ready?(node: Node): void;
|
|
18
|
-
|
|
53
|
+
/** `ctx` is the host world (input/rng/clock/time) — self-contained, no closure needed. */
|
|
54
|
+
update?(node: Node, dt: number, ctx: WorldContext): void;
|
|
19
55
|
exit?(node: Node): void;
|
|
20
56
|
/** Optional tag for lookup/serialization. */
|
|
21
57
|
readonly kind?: string;
|
|
22
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* How a node behaves while the world is paused. `'inherit'` (default) takes the
|
|
61
|
+
* parent's effective mode (the root default is pausable), `'always'` keeps its
|
|
62
|
+
* subtree updating through a pause (pause menus, transitions), `'stopped'`
|
|
63
|
+
* halts its subtree even when the world is running.
|
|
64
|
+
*/
|
|
65
|
+
export type PauseMode = 'inherit' | 'always' | 'stopped';
|
|
66
|
+
/** A resolved pause mode — what 'inherit' collapses to as it flows down the tree. */
|
|
67
|
+
type EffectivePauseMode = 'pausable' | 'always' | 'stopped';
|
|
23
68
|
/** Deterministic id source — reset by the World on load so ids are reproducible. */
|
|
24
69
|
export declare function resetNodeIds(start?: number): void;
|
|
25
70
|
export interface NodeConfig {
|
|
@@ -47,29 +92,73 @@ export declare class Node {
|
|
|
47
92
|
* particles, tweened positions) never pollutes the canonical, verifiable state.
|
|
48
93
|
*/
|
|
49
94
|
cosmetic: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Pause behaviour for this subtree (see PauseMode). Serialized only when
|
|
97
|
+
* non-default, so existing trees keep their pinned hashes.
|
|
98
|
+
*/
|
|
99
|
+
pauseMode: PauseMode;
|
|
100
|
+
/**
|
|
101
|
+
* Screen-space overlay: this subtree ignores the camera — its transforms
|
|
102
|
+
* compose from IDENTITY (design-space coordinates) — and every command it
|
|
103
|
+
* emits is tagged `layer: 1` so HUD/overlay content always paints above the
|
|
104
|
+
* world, whatever the z values. Serialized only when true.
|
|
105
|
+
*/
|
|
106
|
+
screenSpace: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Optional local anchor: when set, rotation/scale pivot around this point in
|
|
109
|
+
* local space instead of the node origin (the local transform gains a
|
|
110
|
+
* trailing translation of −pivot). Serialized only when set.
|
|
111
|
+
*/
|
|
112
|
+
pivot?: Vec2;
|
|
50
113
|
parent: Node | null;
|
|
51
114
|
readonly children: Node[];
|
|
52
115
|
world: WorldContext | null;
|
|
53
|
-
/**
|
|
54
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Optional inline update without subclassing. The third argument is the host
|
|
118
|
+
* world context (input/rng/clock/time), so the callback is self-contained —
|
|
119
|
+
* `node.onUpdate = (n, dt, ctx) => { if (ctx.input.isDown('jump')) … }`.
|
|
120
|
+
*/
|
|
121
|
+
onUpdate?: (node: Node, dt: number, ctx: WorldContext) => void;
|
|
55
122
|
private behaviors;
|
|
56
123
|
private signals;
|
|
57
124
|
private _ready;
|
|
58
125
|
private _freed;
|
|
59
126
|
constructor(config?: NodeConfig);
|
|
127
|
+
/** Shorthand for `pos.x` — reads and writes the same vector. */
|
|
128
|
+
get x(): number;
|
|
129
|
+
set x(v: number);
|
|
130
|
+
/** Shorthand for `pos.y` — reads and writes the same vector. */
|
|
131
|
+
get y(): number;
|
|
132
|
+
set y(v: number);
|
|
60
133
|
addChild<T extends Node>(child: T): T;
|
|
61
134
|
removeChild(child: Node): void;
|
|
62
135
|
/** Deferred free: runs exit hooks and detaches at the end of the step. */
|
|
63
136
|
free(): void;
|
|
137
|
+
/**
|
|
138
|
+
* Immediately exit + detach ALL children. Unlike `free()` this is NOT
|
|
139
|
+
* deferred — the children are gone when the call returns (safe here because
|
|
140
|
+
* it iterates a snapshot of the array). Use it to rebuild a container's
|
|
141
|
+
* contents wholesale; prefer `free()` for removals during an update.
|
|
142
|
+
*/
|
|
143
|
+
clearChildren(): void;
|
|
64
144
|
/** Depth-first find by name. */
|
|
65
145
|
find(name: string): Node | null;
|
|
146
|
+
/** Depth-first find of the first node (self included) that is an instance of `ctor` — typed. */
|
|
147
|
+
findOfType<T extends Node>(ctor: new (...args: never[]) => T): T | null;
|
|
66
148
|
/** All descendants (and self) of a given type tag. */
|
|
67
149
|
query(type: string, out?: Node[]): Node[];
|
|
68
150
|
addBehavior(b: Behavior): this;
|
|
69
151
|
signal<T = void>(name: string): Signal<T>;
|
|
70
152
|
emit<T>(name: string, payload: T): void;
|
|
71
153
|
enterTree(world: WorldContext): void;
|
|
72
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Advance this subtree. `dt` is the (time-scaled) step delta; `unscaledDt`
|
|
156
|
+
* and `paused` come from the world, and `parentMode` is the effective pause
|
|
157
|
+
* mode flowing down the tree. The walk always descends — a paused node just
|
|
158
|
+
* skips its callbacks — so an `'always'` descendant (pause menu, transition)
|
|
159
|
+
* keeps running inside a paused parent.
|
|
160
|
+
*/
|
|
161
|
+
updateTree(dt: number, unscaledDt?: number, parentMode?: EffectivePauseMode, paused?: boolean): void;
|
|
73
162
|
exitTree(): void;
|
|
74
163
|
get isFreed(): boolean;
|
|
75
164
|
localTransform(): Transform;
|
|
@@ -95,6 +184,9 @@ export interface SerializedNode {
|
|
|
95
184
|
scale: Vec2;
|
|
96
185
|
z: number;
|
|
97
186
|
visible: boolean;
|
|
187
|
+
/** Local rotation/scale anchor (absent when unset). */
|
|
188
|
+
pivot?: Vec2;
|
|
98
189
|
props: Record<string, unknown>;
|
|
99
190
|
children: SerializedNode[];
|
|
100
191
|
}
|
|
192
|
+
export {};
|
package/dist/scene/nodes.d.ts
CHANGED
|
@@ -1,14 +1,49 @@
|
|
|
1
1
|
import type { Transform } from '../core/math';
|
|
2
2
|
import type { DrawCommand, Paint, TextAlign } from '../render/commands';
|
|
3
3
|
import { Node, type NodeConfig } from './node';
|
|
4
|
+
/**
|
|
5
|
+
* A Sprite's shape. ORIGIN CONVENTIONS differ per kind — a Sprite's `pos` is the
|
|
6
|
+
* node origin, and each shape places itself relative to it:
|
|
7
|
+
* - `rect` → CENTER-anchored by default: drawn from (-w/2,-h/2), so `pos` is its
|
|
8
|
+
* middle. Pass `anchor: 'topLeft'` to place `pos` at the top-left
|
|
9
|
+
* corner instead (the canvas mental model) — no half-size offset.
|
|
10
|
+
* - `circle` → CENTER-anchored: `pos` is the center.
|
|
11
|
+
* - `ellipse`→ CENTER-anchored: `pos` is the center; `rx`/`ry` are the semi-axes.
|
|
12
|
+
* - `arc` → CENTER-anchored: `pos` is the arc's center. Radians, clockwise
|
|
13
|
+
* from +x (y-down screen convention), running `start` → `end`.
|
|
14
|
+
* `sector: true` closes through the center (a pie slice).
|
|
15
|
+
* - `glyph` → CENTER-anchored (align 'center').
|
|
16
|
+
* - `poly` → LOCAL coordinates: `points` are relative to `pos` as-is (put your
|
|
17
|
+
* own 0,0 wherever you like).
|
|
18
|
+
* - `path` → LOCAL coordinates: `d` is drawn relative to `pos`.
|
|
19
|
+
* - `diamond`→ CENTER-anchored: a `w×h` rhombus (iso tile). `pos` is its middle.
|
|
20
|
+
* - `regularPoly` → CENTER-anchored: `sides`-gon of circumradius `r`. `rotation`
|
|
21
|
+
* (radians) turns it; 0 puts the first vertex straight up.
|
|
22
|
+
* (`Text` is anchored by its `align`, not centered — see the Text node.)
|
|
23
|
+
*
|
|
24
|
+
* `diamond` and `regularPoly` are pure sugar over `poly` (resolved at draw time,
|
|
25
|
+
* no renderer change) — they exist so an iso tile or a hex reads as intent, not
|
|
26
|
+
* as four hand-typed corner numbers.
|
|
27
|
+
*/
|
|
4
28
|
export type Shape = {
|
|
5
29
|
kind: 'rect';
|
|
6
30
|
w: number;
|
|
7
31
|
h: number;
|
|
8
32
|
r?: number;
|
|
33
|
+
anchor?: 'center' | 'topLeft';
|
|
9
34
|
} | {
|
|
10
35
|
kind: 'circle';
|
|
11
36
|
radius: number;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'ellipse';
|
|
39
|
+
rx: number;
|
|
40
|
+
ry: number;
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'arc';
|
|
43
|
+
radius: number;
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
sector?: boolean;
|
|
12
47
|
} | {
|
|
13
48
|
kind: 'poly';
|
|
14
49
|
points: number[];
|
|
@@ -20,7 +55,20 @@ export type Shape = {
|
|
|
20
55
|
kind: 'glyph';
|
|
21
56
|
char: string;
|
|
22
57
|
size: number;
|
|
58
|
+
} | {
|
|
59
|
+
kind: 'diamond';
|
|
60
|
+
w: number;
|
|
61
|
+
h: number;
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'regularPoly';
|
|
64
|
+
sides: number;
|
|
65
|
+
r: number;
|
|
66
|
+
rotation?: number;
|
|
23
67
|
};
|
|
68
|
+
/** Local poly points for a center-anchored `w×h` diamond (top, right, bottom, left). */
|
|
69
|
+
export declare function diamondPoints(w: number, h: number): number[];
|
|
70
|
+
/** Local poly points for a center-anchored regular `sides`-gon of circumradius `r`. */
|
|
71
|
+
export declare function regularPolyPoints(sides: number, r: number, rotation?: number): number[];
|
|
24
72
|
export interface SpriteConfig extends NodeConfig, Paint {
|
|
25
73
|
shape: Shape;
|
|
26
74
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Node, type NodeConfig } from './node';
|
|
2
|
+
export interface ParallaxLayerConfig extends NodeConfig {
|
|
3
|
+
/** Scroll fraction: 0 = pinned to screen (far), 1 = full world scroll (near). */
|
|
4
|
+
factor: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class ParallaxLayer extends Node {
|
|
7
|
+
readonly type = "ParallaxLayer";
|
|
8
|
+
cosmetic: boolean;
|
|
9
|
+
factor: number;
|
|
10
|
+
constructor(config: ParallaxLayerConfig);
|
|
11
|
+
/** Re-sync to the current camera; called each process tick (and on demand). */
|
|
12
|
+
sync(): void;
|
|
13
|
+
protected onReady(): void;
|
|
14
|
+
protected onProcess(): void;
|
|
15
|
+
}
|
|
@@ -42,6 +42,25 @@ export declare class Particles extends Node {
|
|
|
42
42
|
private gravity;
|
|
43
43
|
private drag;
|
|
44
44
|
private shrink;
|
|
45
|
+
private emitRate;
|
|
46
|
+
private emitAcc;
|
|
47
|
+
private emitStyle;
|
|
48
|
+
private emitOrigin;
|
|
49
|
+
/**
|
|
50
|
+
* Start (or retune) continuous emission at `ratePerSec` particles per second.
|
|
51
|
+
* `style.at` sets the node-local origin (default 0,0); the rest styles
|
|
52
|
+
* particles exactly like `burst()`. Omitting `style` keeps the previous
|
|
53
|
+
* emitter style (falling back to the `burst` preset if none was ever set).
|
|
54
|
+
*
|
|
55
|
+
* The signal form `emit(name, payload)` inherited from Node still works —
|
|
56
|
+
* the overload dispatches on the first argument's type.
|
|
57
|
+
*/
|
|
58
|
+
emit(ratePerSec: number, style?: ParticleStyle & {
|
|
59
|
+
at?: Vec2;
|
|
60
|
+
}): void;
|
|
61
|
+
emit<T>(name: string, payload: T): void;
|
|
62
|
+
/** Stop continuous emission. Live particles finish their lives naturally. */
|
|
63
|
+
stopEmit(): void;
|
|
45
64
|
protected onProcess(dt: number): void;
|
|
46
65
|
protected draw(out: DrawCommand[], world: Transform): void;
|
|
47
66
|
get liveCount(): number;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type Transform, type Vec2 } from '../core/math';
|
|
2
|
+
import type { DrawCommand } from '../render/commands';
|
|
3
|
+
import { Node, type NodeConfig } from './node';
|
|
4
|
+
export interface VerletChainConfig extends NodeConfig {
|
|
5
|
+
/** Number of segments (points = segments + 1). */
|
|
6
|
+
segments: number;
|
|
7
|
+
/** Total rest length of the chain, px. */
|
|
8
|
+
length: number;
|
|
9
|
+
/** Acceleration applied to free points, px/s² in WORLD space (+y is down). */
|
|
10
|
+
gravity?: Vec2;
|
|
11
|
+
/** Per-step velocity retention 0..1 (1 = undamped). Default 0.985. */
|
|
12
|
+
damping?: number;
|
|
13
|
+
/** Constraint-relaxation passes per step. Default 3. */
|
|
14
|
+
iterations?: number;
|
|
15
|
+
/** Pin the tail too (set `tailTarget` for where). Default false. */
|
|
16
|
+
pinTail?: boolean;
|
|
17
|
+
/** Stroke color for draw(). Default '#333'. */
|
|
18
|
+
stroke?: string;
|
|
19
|
+
/** Stroke width at the head, px. Default 3. */
|
|
20
|
+
strokeWidth?: number;
|
|
21
|
+
/** Taper the stroke toward the tail (draws per-segment polys). Default false. */
|
|
22
|
+
taper?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The rope node. Read `points` (node-local) to attach child sprites along the
|
|
26
|
+
* chain, `segmentAngle(i)` to orient them, and call `impulse(v)` to flick it.
|
|
27
|
+
*/
|
|
28
|
+
export declare class VerletChain extends Node {
|
|
29
|
+
readonly type: string;
|
|
30
|
+
gravity: Vec2;
|
|
31
|
+
damping: number;
|
|
32
|
+
iterations: number;
|
|
33
|
+
pinTail: boolean;
|
|
34
|
+
stroke: string;
|
|
35
|
+
strokeWidth: number;
|
|
36
|
+
taper: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Where the tail is pinned when `pinTail` is true, in NODE-LOCAL space
|
|
39
|
+
* (same space as `points`). null leaves the tail free even with pinTail set.
|
|
40
|
+
*/
|
|
41
|
+
tailTarget: Vec2 | null;
|
|
42
|
+
readonly segments: number;
|
|
43
|
+
/** Rest length of one segment. */
|
|
44
|
+
readonly segmentLength: number;
|
|
45
|
+
private px;
|
|
46
|
+
private py;
|
|
47
|
+
private prevx;
|
|
48
|
+
private prevy;
|
|
49
|
+
/** Node-local mirror of the sim points, updated after each step. */
|
|
50
|
+
private readonly local;
|
|
51
|
+
private initialized;
|
|
52
|
+
private impX;
|
|
53
|
+
private impY;
|
|
54
|
+
constructor(config: VerletChainConfig);
|
|
55
|
+
/**
|
|
56
|
+
* Chain points in NODE-LOCAL space, head first (`points[0]` ≈ {0,0}).
|
|
57
|
+
* The Vec2 objects are reused across steps — read, don't retain copies-by-ref.
|
|
58
|
+
*/
|
|
59
|
+
get points(): readonly Vec2[];
|
|
60
|
+
/**
|
|
61
|
+
* Direction of segment i (0..segments-1) in node-local space, radians
|
|
62
|
+
* (0 = +x, y-down screen convention). Map child sprites to segments with
|
|
63
|
+
* `sprite.pos = chain.points[i]; sprite.rotation = chain.segmentAngle(i)`.
|
|
64
|
+
*/
|
|
65
|
+
segmentAngle(i: number): number;
|
|
66
|
+
/** Flick the chain: add a velocity (world px/s) to every free point next step. */
|
|
67
|
+
impulse(v: Vec2): void;
|
|
68
|
+
protected onProcess(dt: number): void;
|
|
69
|
+
/**
|
|
70
|
+
* Stroked open poly of the chain. `points` are node-local and `world` is the
|
|
71
|
+
* self-inclusive world transform handed to draw(), so commands emit with it
|
|
72
|
+
* directly. With `taper`, each segment is its own 2-point poly at a linearly
|
|
73
|
+
* decreasing width (segments are few, so this stays cheap); otherwise one poly.
|
|
74
|
+
*/
|
|
75
|
+
protected draw(out: DrawCommand[], world: Transform): void;
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { EndReason, KnobEvent, PlaytestSession, ScreenEvent, VariantRef, WallClockMark } from './session';
|
|
2
|
+
import type { TuningValues } from '../app/tuning';
|
|
3
|
+
import type { WorldSnapshot } from '../world';
|
|
4
|
+
export interface RecorderInit {
|
|
5
|
+
game: string;
|
|
6
|
+
seed: number;
|
|
7
|
+
tuningValues: TuningValues;
|
|
8
|
+
variant?: VariantRef;
|
|
9
|
+
buildRef?: string;
|
|
10
|
+
startedAt?: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
/** Segment sessions (post hot-swap) replay from this snapshot. */
|
|
13
|
+
startSnapshot?: WorldSnapshot;
|
|
14
|
+
}
|
|
15
|
+
export declare class SessionRecorder {
|
|
16
|
+
private frames;
|
|
17
|
+
private axesLog;
|
|
18
|
+
private lastAxes;
|
|
19
|
+
private knobEvents;
|
|
20
|
+
private screenEvents;
|
|
21
|
+
private wallClockMarks;
|
|
22
|
+
private annotations;
|
|
23
|
+
private readonly init;
|
|
24
|
+
private readonly startedAt;
|
|
25
|
+
readonly id: string;
|
|
26
|
+
/** Set once the dev server reports the git sha (fetched async by runStudio). */
|
|
27
|
+
buildRef: string;
|
|
28
|
+
constructor(init: RecorderInit);
|
|
29
|
+
/** Frames recorded so far — event hooks stamp themselves with this. */
|
|
30
|
+
get frame(): number;
|
|
31
|
+
/** Live view of the recorded input frames (the scrub timeline re-steps these). */
|
|
32
|
+
get liveInputFrames(): readonly string[][];
|
|
33
|
+
/** Live view of the delta-encoded axes log. */
|
|
34
|
+
get liveAxesLog(): ReadonlyArray<readonly [number, string, number]>;
|
|
35
|
+
/** Live view of mid-play knob changes (scrubbing reapplies them exactly). */
|
|
36
|
+
get liveKnobEvents(): readonly KnobEvent[];
|
|
37
|
+
/**
|
|
38
|
+
* Record one sim step: the action set it saw and (optionally) the analog
|
|
39
|
+
* axes. Axes are delta-encoded — an entry lands only when a value changed.
|
|
40
|
+
*/
|
|
41
|
+
step(actions: readonly string[], axes?: ReadonlyMap<string, number>): void;
|
|
42
|
+
knob(key: string, value: number | string): void;
|
|
43
|
+
screen(kind: ScreenEvent['kind'], detail?: string): void;
|
|
44
|
+
mark(kind: WallClockMark['kind'], t: number): void;
|
|
45
|
+
annotate(tag: string, note?: string): void;
|
|
46
|
+
/**
|
|
47
|
+
* A rewind-and-resume forked the timeline: everything after `frame` never
|
|
48
|
+
* happened in the final run. Drop those frames plus the events stamped in
|
|
49
|
+
* them, so the artifact stays exactly replayable as what the player kept.
|
|
50
|
+
*/
|
|
51
|
+
truncate(frame: number): void;
|
|
52
|
+
/** Finalize into the artifact. The recorder can keep recording afterwards (autosave). */
|
|
53
|
+
toSession(endReason: EndReason): PlaytestSession;
|
|
54
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type GameHandle, type RunOptions } from '../app/browser';
|
|
2
|
+
import { type TuningSpec, type TuningValues, type Variant } from '../app/tuning';
|
|
3
|
+
import { type GameDefinition } from '../app/game';
|
|
4
|
+
import type { EndReason, PlaytestSession, VariantRef } from './session';
|
|
5
|
+
/** The slice of Vite's import.meta.hot the carryover needs (structural, no vite type dep). */
|
|
6
|
+
export interface HotContext {
|
|
7
|
+
data: Record<string, unknown>;
|
|
8
|
+
dispose(cb: (data: Record<string, unknown>) => void): void;
|
|
9
|
+
}
|
|
10
|
+
export interface StudioOptions extends RunOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Named A/B variants of this game. `?variant=<name>` picks one: its `patch`
|
|
13
|
+
* rewrites the definition, its `tuning` seeds the knob values (explicit
|
|
14
|
+
* `?tuning=`/opts overrides still win). Sessions are stamped with the name.
|
|
15
|
+
*/
|
|
16
|
+
variants?: Record<string, Variant>;
|
|
17
|
+
/** Variant identity override (worktree builds stamp commit info here). */
|
|
18
|
+
variant?: VariantRef;
|
|
19
|
+
/**
|
|
20
|
+
* Pass `import.meta.hot` (only the game's own entry module has it) to carry
|
|
21
|
+
* the live world ACROSS code hot-swaps: snapshot on dispose, restore into
|
|
22
|
+
* the re-executed module. The old session flushes as 'hot-swap'; the new
|
|
23
|
+
* segment records the restored snapshot so it stays fully replayable.
|
|
24
|
+
*
|
|
25
|
+
* The entry MUST also contain the literal line `import.meta.hot?.accept();`
|
|
26
|
+
* — Vite decides HMR boundaries by statically scanning module source, so an
|
|
27
|
+
* accept() call inside this library cannot mark your entry self-accepting.
|
|
28
|
+
*/
|
|
29
|
+
hot?: HotContext;
|
|
30
|
+
}
|
|
31
|
+
export interface StudioHandle extends GameHandle {
|
|
32
|
+
/** Live-change a tuning knob: rebuild-with-carryover, recorded as a knob event. */
|
|
33
|
+
setKnob(key: string, value: number | string): void;
|
|
34
|
+
/** Current resolved tuning values. */
|
|
35
|
+
knobValues(): TuningValues;
|
|
36
|
+
/** The game's declared knob spec (the Studio panel builds its controls from this). */
|
|
37
|
+
tuningSpec(): TuningSpec | undefined;
|
|
38
|
+
/** Declared A/B variants: name → label. */
|
|
39
|
+
variants(): Record<string, string>;
|
|
40
|
+
/** The variant this run is playing (name + kind, worktrees include commit). */
|
|
41
|
+
activeVariant(): VariantRef;
|
|
42
|
+
/** The game title (the Studio page labels panes with it). */
|
|
43
|
+
title(): string;
|
|
44
|
+
/** Drop a human annotation at the current frame ("felt bad here"). */
|
|
45
|
+
annotate(tag: string, note?: string): void;
|
|
46
|
+
/** Freeze/unfreeze the sim (rendering continues; no pause overlay). */
|
|
47
|
+
setFrozen(frozen: boolean): void;
|
|
48
|
+
frozen(): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Time-travel to a recorded frame (freezes first). Exact: restores the
|
|
51
|
+
* nearest ring snapshot and re-steps the recorded inputs. Returns the frame
|
|
52
|
+
* reached, or null if it's off the ring. Resuming after a rewind FORKS the
|
|
53
|
+
* timeline: the discarded future is truncated from the session.
|
|
54
|
+
*/
|
|
55
|
+
scrub(frame: number): number | null;
|
|
56
|
+
/** Scrub bounds + position: min reachable frame, current, recorded max. */
|
|
57
|
+
timeline(): {
|
|
58
|
+
min: number;
|
|
59
|
+
frame: number;
|
|
60
|
+
max: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* 'live' = playing + recording; 'replay' = watching a past session loaded
|
|
64
|
+
* via ?session=<id> (read-only: scrub/playback over the whole recording,
|
|
65
|
+
* knobs and annotation disabled, nothing records).
|
|
66
|
+
*/
|
|
67
|
+
mode(): 'live' | 'replay';
|
|
68
|
+
/** Flush the session artifact to the dev server now. */
|
|
69
|
+
flush(reason?: EndReason): void;
|
|
70
|
+
/** The in-progress session (for inspection/tests). */
|
|
71
|
+
session(): PlaytestSession;
|
|
72
|
+
}
|
|
73
|
+
declare global {
|
|
74
|
+
interface Window {
|
|
75
|
+
__studio?: StudioHandle;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export declare function runStudio(baseDef: GameDefinition, mount: HTMLElement, opts?: StudioOptions): StudioHandle;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type GameDefinition } from '../app/game';
|
|
2
|
+
import type { TuningValues } from '../app/tuning';
|
|
3
|
+
import type { InputLog } from '../input/actions';
|
|
4
|
+
import type { World, WorldSnapshot } from '../world';
|
|
5
|
+
/** Which build/variant produced a session — a report is never ambiguous about code. */
|
|
6
|
+
export interface VariantRef {
|
|
7
|
+
name: string;
|
|
8
|
+
kind: 'dev' | 'module' | 'worktree';
|
|
9
|
+
commit?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ScreenEvent {
|
|
12
|
+
frame: number;
|
|
13
|
+
kind: 'pause' | 'resume' | 'restart' | 'overlay-show' | 'overlay-hide' | 'knob' | 'variant' | 'hot-swap' | 'scrub';
|
|
14
|
+
detail?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Wall-clock context the sim can't see (tab hidden ≠ hesitation). */
|
|
17
|
+
export interface WallClockMark {
|
|
18
|
+
frame: number;
|
|
19
|
+
/** ms since session start (wall clock — context only, never sim input). */
|
|
20
|
+
t: number;
|
|
21
|
+
kind: 'visibility-hidden' | 'visibility-visible' | 'blur' | 'focus';
|
|
22
|
+
}
|
|
23
|
+
export interface Annotation {
|
|
24
|
+
frame: number;
|
|
25
|
+
tag: string;
|
|
26
|
+
note?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A mid-play tuning change (Studio knob): replay applies it AT its frame. */
|
|
29
|
+
export interface KnobEvent {
|
|
30
|
+
frame: number;
|
|
31
|
+
key: string;
|
|
32
|
+
value: number | string;
|
|
33
|
+
}
|
|
34
|
+
export type EndReason = 'goal' | 'quit' | 'restart' | 'navigate' | 'idle' | 'hot-swap';
|
|
35
|
+
export interface PlaytestSession {
|
|
36
|
+
id: string;
|
|
37
|
+
game: string;
|
|
38
|
+
startedAt: string;
|
|
39
|
+
/** Git short sha of the code that ran (from the Studio dev server). */
|
|
40
|
+
buildRef: string;
|
|
41
|
+
seed: number;
|
|
42
|
+
variant: VariantRef;
|
|
43
|
+
/** Tuning at session start (resolved values). */
|
|
44
|
+
tuningValues: TuningValues;
|
|
45
|
+
/**
|
|
46
|
+
* When play continued across a code hot-swap, the new segment records the
|
|
47
|
+
* world it resumed FROM — replay restores it before stepping, so even
|
|
48
|
+
* mid-development sessions stay bit-exactly re-executable.
|
|
49
|
+
*/
|
|
50
|
+
startSnapshot?: WorldSnapshot;
|
|
51
|
+
knobEvents: KnobEvent[];
|
|
52
|
+
inputLog: InputLog;
|
|
53
|
+
/** Delta-encoded analog axes: [frame, axis, value] whenever a value changed. */
|
|
54
|
+
axesLog: Array<[number, string, number]>;
|
|
55
|
+
screenEvents: ScreenEvent[];
|
|
56
|
+
wallClockMarks: WallClockMark[];
|
|
57
|
+
annotations: Annotation[];
|
|
58
|
+
endReason: EndReason;
|
|
59
|
+
/** Invited-playtester fields — creator sessions leave them unset. */
|
|
60
|
+
playerId?: string;
|
|
61
|
+
consent?: {
|
|
62
|
+
recorded: boolean;
|
|
63
|
+
at: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export interface ReplayOptions {
|
|
67
|
+
toFrame?: number;
|
|
68
|
+
/** Observer called once with the fresh world BEFORE any step (subscribe to events here). */
|
|
69
|
+
onWorld?: (world: World) => void;
|
|
70
|
+
/** Observer called AFTER each step with the frame index just played. */
|
|
71
|
+
onFrame?: (world: World, frame: number) => void;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Replay a session headlessly to `toFrame` (default: the whole log) and return
|
|
75
|
+
* the live world. Bit-exact with the original run: axes deltas are applied
|
|
76
|
+
* before the step that first saw them; knob events rebuild-with-carryover at
|
|
77
|
+
* their frame exactly as the Studio panel did (including `def.attach`).
|
|
78
|
+
* A bare number as `opts` is `toFrame`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function replaySession(def: GameDefinition, session: PlaytestSession, opts?: number | ReplayOptions): World;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { GameDefinition } from '../app/game';
|
|
2
|
+
import type { World, WorldSnapshot } from '../world';
|
|
3
|
+
import type { KnobEvent } from './session';
|
|
4
|
+
export interface TimelineEntry {
|
|
5
|
+
frame: number;
|
|
6
|
+
snap: WorldSnapshot;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Periodic snapshot keeper. Every `stride` frames costs one snapshot; `cap`
|
|
10
|
+
* bounds memory (oldest evicted), so scrub range ≈ stride × cap frames back.
|
|
11
|
+
*/
|
|
12
|
+
export declare class SnapshotRing {
|
|
13
|
+
readonly stride: number;
|
|
14
|
+
readonly cap: number;
|
|
15
|
+
private entries;
|
|
16
|
+
constructor(stride?: number, cap?: number);
|
|
17
|
+
/** Call after every recorded step; keeps frame 0 and every stride-th frame. */
|
|
18
|
+
push(frame: number, world: World): void;
|
|
19
|
+
/** Nearest entry at or before `frame` (null if it fell off the ring). */
|
|
20
|
+
nearest(frame: number): TimelineEntry | null;
|
|
21
|
+
/** Oldest reachable frame (scrubbing earlier than this is off the ring). */
|
|
22
|
+
get minFrame(): number;
|
|
23
|
+
/** Drop entries after `frame` — a rewind-and-resume forked the timeline. */
|
|
24
|
+
truncate(frame: number): void;
|
|
25
|
+
clear(): void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Scrub a live world to `target`: restore the nearest ring snapshot, then
|
|
29
|
+
* re-step the recorded inputs. `inputFrames[i]` is the input DURING step i, so
|
|
30
|
+
* stepping frames[entry..target-1] lands the world exactly AT `target`.
|
|
31
|
+
* Returns the frame actually reached (clamped to the ring's reachable range),
|
|
32
|
+
* or null if the ring is empty. `def.attach` runs after the restore — the same
|
|
33
|
+
* contract as every other restore path.
|
|
34
|
+
*/
|
|
35
|
+
export declare function scrubTo(world: World, def: GameDefinition, ring: SnapshotRing, inputFrames: readonly string[][], axesLog: ReadonlyArray<readonly [number, string, number]>, knobEvents: readonly KnobEvent[], target: number): number | null;
|