lecodes-cli 0.17.2 → 0.18.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.
Files changed (37) hide show
  1. package/dist/index.js +1340 -648
  2. package/package.json +3 -3
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/bundler.ts +1 -1
  5. package/runtime/sdk/compile/compileProject.ts +16 -1
  6. package/runtime/sdk/compile/header.ts +6 -1
  7. package/runtime/sdk/compile/index.ts +16 -0
  8. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  9. package/runtime/sdk/compile/shaderTargets.ts +42 -0
  10. package/runtime/sdk/core/Aspect.ts +255 -244
  11. package/runtime/sdk/g2/CharacterController2D.ts +2 -2
  12. package/runtime/sdk/gl/Camera.ts +41 -0
  13. package/runtime/sdk/gl/CameraPlace.ts +51 -0
  14. package/runtime/sdk/gl/CharacterController.ts +3 -2
  15. package/runtime/sdk/gl/IK.ts +193 -174
  16. package/runtime/sdk/gl/Material.ts +11 -0
  17. package/runtime/sdk/gl/Model.ts +7 -6
  18. package/runtime/sdk/gl/Node.ts +270 -285
  19. package/runtime/sdk/gl/Physics.ts +171 -126
  20. package/runtime/sdk/gl/Scene.ts +27 -3
  21. package/runtime/sdk/gl/Shape.ts +214 -10
  22. package/runtime/sdk/gl/Vehicle.ts +519 -0
  23. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  24. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  25. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  26. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  27. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  28. package/runtime/sdk/gl/animation/core.ts +294 -0
  29. package/runtime/sdk/gl/scenarios.ts +26 -58
  30. package/runtime/sdk/inject.ts +18 -9
  31. package/runtime/sdk/runtime/app.ts +13 -0
  32. package/runtime/sdk/runtime/input.ts +169 -6
  33. package/runtime/sdk/scene/defineScene.ts +182 -26
  34. package/runtime/sdk/scene/gizmos.ts +128 -0
  35. package/runtime/sdk-types.json +1 -1
  36. package/runtime/sdk/gl/Animator.ts +0 -642
  37. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -1,244 +1,255 @@
1
- // The Aspect system: attachable capabilities (animation, collider, physics, and user behaviors)
2
- // that hang off a node WITHOUT bloating the node class. Composition with the terseness of methods.
3
- //
4
- // const hero = new Sprite({ texture })
5
- // .aspect(SpriteAnimation, { fps: 8, clips: { walk: [1, 2, 3] } }) // attach + configure
6
- // .aspect(Shape2D, { box: [16, 8] }) // chains: returns the node
7
- // hero.anim.play('walk') // access by name (the chain result is typed as having it)
8
- // if (hero.has(Physics)) hero.physics.velocity = [0, 0] // has() is a type guard
9
- //
10
- // A user aspect declares its name + target node in the generic and reads `this.node`:
11
- // class Health extends Aspect<'health', Sprite> { hp = 100; hurt(n: number) { this.hp -= n } }
12
- // sprite.aspect(Health, { hp: 80 }).health.hurt(10)
13
- //
14
- // The accessor name lives in the generic (Aspect<'name', Node>). The `chisel` bundler extracts it at
15
- // compile time and injects the runtime `static aspect` on user aspects + the virtual type accessor;
16
- // the SDK's own built-in aspects declare `static readonly aspect` directly (see SpriteAnimation etc.).
17
-
18
- import { Emitter, type EventMap } from "./events"
19
- import type { InspectorUI } from "./InspectorUI"
20
- import type { Node } from "../gl/Node"
21
-
22
- // ---- type helpers -----------------------------------------------------------
23
- /** The class object of an aspect — accepted even when its constructor is protected/abstract. */
24
- export type AspectCtor<A extends Aspect<any, any>> = abstract new () => A
25
- /** The accessor name carried in the aspect's generic. */
26
- export type KeyOf<A> = A extends Aspect<infer K, any> ? K : never
27
- /** The node kind an aspect targets. */
28
- export type TargetOf<A> = A extends Aspect<any, infer P> ? P : never
29
- /** `{ name: Aspect }` — the field an aspect contributes to its node. */
30
- export type FieldOf<A extends Aspect<any, any>> = { [P in KeyOf<A>]: A }
31
-
32
- type UnionToIntersection<U> =
33
- (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
34
-
35
- /**
36
- * A variable typed as a node KNOWN to carry the given aspect(s). Union, not a tuple — reads as
37
- * English and mirrors the runtime guard `node.has(Health)`:
38
- * let boss: With<Sprite, Health | Physics>
39
- */
40
- export type With<N, A extends Aspect<any, any>> =
41
- N & UnionToIntersection<A extends any ? FieldOf<A> : never>
42
-
43
- // ---- per-frame update dispatch (two phases) ---------------------------------
44
- // Aspects that define update(dt) are ticked every frame in ONE of two phases, chosen by the aspect's
45
- // `updateBeforePhysics` field:
46
- // • LATE (default): after the physics step + transform sync, right before the frame draws. Reads of
47
- // node.worldPosition are the FINAL drawn position — cameras/followers land exactly, no 1-frame lag.
48
- // • EARLY (updateBeforePhysics = true): before the physics step, so writing velocity/force/kinematic
49
- // transforms is consumed by the SAME frame's step (zero input latency).
50
- // The FRAME SOURCE is pluggable so core never names a specific engine bridge (a 3D-only bundle must not
51
- // pull in the 2D one): the 2D layer installs a render-synced source via _installAspectFrames (the native
52
- // c2dRender early/late hooks), so dispatch is in lockstep with the draw. With none installed (3D-only,
53
- // or a runtime without the hooks) it falls back to a single host setLoop, early-then-late — no true
54
- // post-physics slot. Both lists stay sorted by the aspect's `order` field (default 0; ties keep
55
- // insertion order). Lazy: the first update-bearing aspect installs the dispatch; a project with none
56
- // starts nothing. No visibility culling yet — every registered aspect ticks regardless of on-screen state.
57
- type Updatable = Aspect<any, any>
58
- type PhaseFn = (dt: number) => void
59
- const earlyUpdaters: Updatable[] = []
60
- const lateUpdaters: Updatable[] = []
61
- let dispatchInstalled = false
62
-
63
- // A render-synced frame source: given the two phase runners, wire them to the engine and return true;
64
- // return false if unavailable (→ setLoop fallback). Installed by the 2D layer (see Scene2D) so core
65
- // never references a concrete engine bridge — that keeps the 2D bridge out of a 3D-only bundle.
66
- type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
67
- let frameInstaller: FrameInstaller | undefined
68
- /** @internal Install a render-synced frame source for aspect update(dt). Called from the 2D layer. */
69
- export const _installAspectFrames = (fn: FrameInstaller): void => { frameInstaller = fn }
70
-
71
- const policyOf = (a: Updatable): { early: boolean; order: number } =>
72
- ({ early: a.updateBeforePhysics === true, order: a.order ?? 0 })
73
-
74
- // Stable insert by ascending order: place after the last element whose order is <= this one's.
75
- const addSorted = (list: Updatable[], a: Updatable, order: number): void => {
76
- let i = list.length
77
- while (i > 0 && list[i - 1].order > order) i--
78
- list.splice(i, 0, a)
79
- }
80
-
81
- const runPhase = (list: Updatable[], dt: number): void => {
82
- // iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
83
- for (const a of list.slice()) a.update?.(dt)
84
- }
85
-
86
- const ensureDispatch = (): void => {
87
- if (dispatchInstalled) return
88
- dispatchInstalled = true
89
- const early: PhaseFn = (dt) => runPhase(earlyUpdaters, dt)
90
- const late: PhaseFn = (dt) => runPhase(lateUpdaters, dt)
91
- // render-synced source if the engine layer installed one; else one host loop (early then late).
92
- if (!(frameInstaller && frameInstaller(early, late))) {
93
- setLoop((dt) => { early(dt); late(dt) })
94
- }
95
- }
96
-
97
- const registerUpdater = (a: Updatable): void => {
98
- const { early, order } = policyOf(a)
99
- addSorted(early ? earlyUpdaters : lateUpdaters, a, order)
100
- ensureDispatch()
101
- }
102
- const unregisterUpdater = (a: Updatable): void => {
103
- let i = earlyUpdaters.indexOf(a)
104
- if (i >= 0) { earlyUpdaters.splice(i, 1); return }
105
- i = lateUpdaters.indexOf(a)
106
- if (i >= 0) lateUpdaters.splice(i, 1)
107
- }
108
-
109
- // ---- the host: mixed into every node kind (Node2D, Node) --------------------
110
- /**
111
- * Base for anything that can carry aspects. Extends Emitter, so node kinds get events too. Provides
112
- * the four verbs; the named accessor (node.physics, node.health, …) is set as an own property at
113
- * attach time, keyed by the aspect class's runtime `aspect` name.
114
- */
115
- export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<E> {
116
- /** @internal class → instance, the authoritative store (named accessors mirror this). */
117
- readonly _aspects = new Map<Function, Aspect<any, any>>()
118
-
119
- /** Attach (and configure) an aspect, or reconfigure it if already present. Returns the node typed
120
- * as now-having that aspect (so no guard/`?.` is needed afterwards). Rejects a wrong target node. */
121
- aspect<Self extends TargetOf<A>, A extends Aspect<any, any>>(
122
- this: Self,
123
- ctor: AspectCtor<A>,
124
- opts?: Partial<A>,
125
- ): Self & FieldOf<A> {
126
- const host = this as unknown as AspectHost
127
- let inst = host._aspects.get(ctor) as A | undefined
128
- const fresh = inst === undefined
129
- if (!inst) {
130
- inst = new (ctor as unknown as new () => A)()
131
- ;(inst as { node: unknown }).node = this
132
- host._aspects.set(ctor, inst)
133
- const name = (ctor as { aspect?: string }).aspect
134
- if (name) (host as unknown as Record<string, unknown>)[name] = inst
135
- }
136
- if (opts) Object.assign(inst, opts)
137
- if (fresh) {
138
- inst.onAttach?.()
139
- if (typeof inst.update === "function") registerUpdater(inst)
140
- }
141
- return this as Self & FieldOf<A>
142
- }
143
-
144
- /** Safe access — undefined if the aspect isn't attached. */
145
- get<A extends Aspect<any, any>>(ctor: AspectCtor<A>): A | undefined {
146
- return this._aspects.get(ctor) as A | undefined
147
- }
148
-
149
- /** Existence check AND type guard: inside `if (node.has(Physics))`, `node.physics` is present. */
150
- has<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
151
- return this._aspects.has(ctor)
152
- }
153
-
154
- /** Detach an aspect (runs its onDetach). Named `removeAspect` so it never collides with the
155
- * child-management `add`/`remove` semantics some node kinds expose. */
156
- removeAspect<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this {
157
- const inst = this._aspects.get(ctor)
158
- if (inst) {
159
- inst.onDetach?.()
160
- unregisterUpdater(inst)
161
- this._aspects.delete(ctor)
162
- const name = (ctor as { aspect?: string }).aspect
163
- if (name) delete (this as unknown as Record<string, unknown>)[name]
164
- }
165
- return this
166
- }
167
- }
168
-
169
- // ---- the aspect base --------------------------------------------------------
170
- /**
171
- * Base for everything you attach. `K` = accessor name (extracted by chisel for the runtime). `P` =
172
- * the target node kind: `this.node` is typed to it, AND attaching to a wrong node is a compile error.
173
- * Initialize in `onAttach` (the node is set by then) — not a constructor; aspects are created by the
174
- * engine via `node.aspect()`, never `new`.
175
- */
176
- export abstract class Aspect<K extends string, P = Node> {
177
- /**
178
- * Opt this aspect class into EDITOR-RUN mode (generators): while a scene is edited, the scene
179
- * loader constructs the instance (refs resolved, `node`/`generated` set — never `onAttach`) and
180
- * calls `rebuild()` — again on inspector prop edits and when a `ref()`-referenced node moves.
181
- * `static editor = { rebuild: true }`.
182
- */
183
- static editor?: { rebuild?: boolean }
184
-
185
- /**
186
- * Custom inspector card for this aspect in the scene editor (immediate-mode — see
187
- * core/InspectorUI.ts): re-runs on every edit/event and emits a widget list. Without it, the
188
- * editor shows the inferred fields; `ui.auto()` emits those same fields, so a custom inspector
189
- * usually starts with `ui.auto()` and appends status lines / buttons / dynamic dropdowns:
190
- *
191
- * static inspector(ui: InspectorUI, self: MyAspect) {
192
- * ui.auto()
193
- * if (ui.button('Randomize')) self.rebuild()
194
- * }
195
- */
196
- static inspector?: (ui: InspectorUI, aspect: any) => void
197
-
198
- // NOTE: P has no `extends AspectHost` constraint on purpose — Emitter<E>'s generic
199
- // addEventListener makes a specific-event node (AspectHost<NodeEvents>) not assignable to
200
- // AspectHost<any>, which would reject every real node. P only types `this.node`; target
201
- // enforcement happens in aspect() via `this: Self extends TargetOf<A>`. Defaults to Node
202
- // (3D is the default engine) — a 2D aspect declares its own P, e.g. Aspect<'x', Sprite>.
203
- /** The node this aspect is attached to (set at attach time). */
204
- readonly node!: P
205
- /**
206
- * GENERATOR aspects (`static editor = { rebuild: true }`, attached through a scene file): a
207
- * scene-added container child for the aspect's generated output — `rebuild()` clears and refills
208
- * it. Provided by the scene loader before `onAttach`/`rebuild` run; nodes `add()`ed to it join
209
- * the scene's draw set automatically (membership is separate from parenting). Undefined for
210
- * aspects attached by hand outside scene files. See docs/3d/scene-files.md.
211
- */
212
- readonly generated!: Node & { clear(): void }
213
- /** @internal phantom — lets the type system recover the accessor name `K`. Never read at runtime. */
214
- declare protected readonly __key__?: K
215
- onAttach?(): void
216
- onDetach?(): void
217
- /**
218
- * GENERATOR aspects: (re)build the derived output under `this.generated` — must be idempotent
219
- * (clear, then create). Call it from `onAttach()` for play mode; a class opting in with
220
- * `static editor = { rebuild: true }` ALSO runs it while a scene is being edited: once at load,
221
- * and again whenever an inspector prop changes or a `ref()`-referenced node moves.
222
- */
223
- rebuild?(): void
224
- /**
225
- * Called every frame while attached (dt = seconds since the last frame). Render-synced: runs in the
226
- * LATE phase by default — after the physics step + transform sync, right before the frame draws — so
227
- * reads of `node.worldPosition` are the final drawn position (cameras/followers have no 1-frame lag).
228
- * Set `updateBeforePhysics = true` (an instance field) to run in the EARLY phase (before the step)
229
- * instead — for aspects that WRITE velocity/force/kinematic transforms and want the same frame's
230
- * step to consume them. Cameras/followers READ, so they stay in the default LATE phase.
231
- */
232
- update?(dt: number): void
233
-
234
- /** Run update(dt) in the EARLY phase (before the physics step) rather than the default LATE phase.
235
- * An instance field (like `updateWhenVisible`) — set it as a class field: `updateBeforePhysics = true`.
236
- * Use only when you FEED the simulation (velocity/force/kinematic transform). Read once, at attach. */
237
- updateBeforePhysics = false
238
- /** Tick order within a phase — ascending; default 0, ties keep attach order. Read once, at attach. */
239
- order = 0
240
-
241
- /** Opt-in: only run update(dt) while the node is on-screen. NOOP for now — visibility culling isn't
242
- * wired yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
243
- updateWhenVisible = false
244
- }
1
+ // The Aspect system: attachable capabilities (animation, collider, physics, and user behaviors)
2
+ // that hang off a node WITHOUT bloating the node class. Composition with the terseness of methods.
3
+ //
4
+ // const hero = new Sprite({ texture })
5
+ // .aspect(SpriteAnimation, { fps: 8, clips: { walk: [1, 2, 3] } }) // attach + configure
6
+ // .aspect(Shape2D, { box: [16, 8] }) // chains: returns the node
7
+ // hero.anim.play('walk') // access by name (the chain result is typed as having it)
8
+ // if (hero.has(Physics)) hero.physics.velocity = [0, 0] // has() is a type guard
9
+ //
10
+ // A user aspect declares its name + target node in the generic and reads `this.node`:
11
+ // class Health extends Aspect<'health', Sprite> { hp = 100; hurt(n: number) { this.hp -= n } }
12
+ // sprite.aspect(Health, { hp: 80 }).health.hurt(10)
13
+ //
14
+ // The accessor name lives in the generic (Aspect<'name', Node>). The `chisel` bundler extracts it at
15
+ // compile time and injects the runtime `static aspect` on user aspects + the virtual type accessor;
16
+ // the SDK's own built-in aspects declare `static readonly aspect` directly (see SpriteAnimation etc.).
17
+
18
+ import type { Scene } from "../gl/Scene"
19
+ import { Emitter, type EventMap } from "./events"
20
+ import type { InspectorUI } from "./InspectorUI"
21
+ import type { Node } from "../gl/Node"
22
+
23
+ // ---- type helpers -----------------------------------------------------------
24
+ /** The class object of an aspect — accepted even when its constructor is protected/abstract. */
25
+ export type AspectCtor<A extends Aspect<any, any>> = abstract new () => A
26
+ /** The accessor name carried in the aspect's generic. */
27
+ export type KeyOf<A> = A extends Aspect<infer K, any> ? K : never
28
+ /** The node kind an aspect targets. */
29
+ export type TargetOf<A> = A extends Aspect<any, infer P> ? P : never
30
+ /** `{ name: Aspect }` — the field an aspect contributes to its node. */
31
+ export type FieldOf<A extends Aspect<any, any>> = { [P in KeyOf<A>]: A }
32
+
33
+ type UnionToIntersection<U> =
34
+ (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
35
+
36
+ /**
37
+ * A variable typed as a node KNOWN to carry the given aspect(s). Union, not a tuple — reads as
38
+ * English and mirrors the runtime guard `node.has(Health)`:
39
+ * let boss: With<Sprite, Health | Physics>
40
+ */
41
+ export type With<N, A extends Aspect<any, any>> =
42
+ N & UnionToIntersection<A extends any ? FieldOf<A> : never>
43
+
44
+ // ---- per-frame update dispatch (two phases) ---------------------------------
45
+ // Aspects that define update(dt) are ticked every frame in ONE of two phases, chosen by the aspect's
46
+ // `updateBeforePhysics` field:
47
+ // • LATE (default): after the physics step + transform sync, right before the frame draws. Reads of
48
+ // node.worldPosition are the FINAL drawn position — cameras/followers land exactly, no 1-frame lag.
49
+ // • EARLY (updateBeforePhysics = true): before the physics step, so writing velocity/force/kinematic
50
+ // transforms is consumed by the SAME frame's step (zero input latency).
51
+ // The FRAME SOURCE is pluggable so core never names a specific engine bridge (a 3D-only bundle must not
52
+ // pull in the 2D one): the 2D layer installs a render-synced source via _installAspectFrames (the native
53
+ // c2dRender early/late hooks), so dispatch is in lockstep with the draw. With none installed (3D-only,
54
+ // or a runtime without the hooks) it falls back to a single host setLoop, early-then-late — no true
55
+ // post-physics slot. Both lists stay sorted by the aspect's `order` field (default 0; ties keep
56
+ // insertion order). Lazy: the first update-bearing aspect installs the dispatch; a project with none
57
+ // starts nothing. No visibility culling yet — every registered aspect ticks regardless of on-screen state.
58
+ type Updatable = Aspect<any, any>
59
+ type PhaseFn = (dt: number) => void
60
+ const earlyUpdaters: Updatable[] = []
61
+ const lateUpdaters: Updatable[] = []
62
+ let dispatchInstalled = false
63
+
64
+ // A render-synced frame source: given the two phase runners, wire them to the engine and return true;
65
+ // return false if unavailable (→ setLoop fallback). Installed by the 2D layer (see Scene2D) so core
66
+ // never references a concrete engine bridge — that keeps the 2D bridge out of a 3D-only bundle.
67
+ type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
68
+ let frameInstaller: FrameInstaller | undefined
69
+ /** @internal Install a render-synced frame source for aspect update(dt). Called from the 2D layer. */
70
+ export const _installAspectFrames = (fn: FrameInstaller): void => { frameInstaller = fn }
71
+
72
+ const policyOf = (a: Updatable): { early: boolean; order: number } =>
73
+ ({ early: a.updateBeforePhysics === true, order: a.order ?? 0 })
74
+
75
+ // Stable insert by ascending order: place after the last element whose order is <= this one's.
76
+ const addSorted = (list: Updatable[], a: Updatable, order: number): void => {
77
+ let i = list.length
78
+ while (i > 0 && list[i - 1].order > order) i--
79
+ list.splice(i, 0, a)
80
+ }
81
+
82
+ const runPhase = (list: Updatable[], dt: number): void => {
83
+ // iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
84
+ for (const a of list.slice()) a.update?.(dt)
85
+ }
86
+
87
+ const ensureDispatch = (): void => {
88
+ if (dispatchInstalled) return
89
+ dispatchInstalled = true
90
+ const early: PhaseFn = (dt) => runPhase(earlyUpdaters, dt)
91
+ const late: PhaseFn = (dt) => runPhase(lateUpdaters, dt)
92
+ // render-synced source if the engine layer installed one; else one host loop (early then late).
93
+ if (!(frameInstaller && frameInstaller(early, late))) {
94
+ setLoop((dt) => { early(dt); late(dt) })
95
+ }
96
+ }
97
+
98
+ const registerUpdater = (a: Updatable): void => {
99
+ const { early, order } = policyOf(a)
100
+ addSorted(early ? earlyUpdaters : lateUpdaters, a, order)
101
+ ensureDispatch()
102
+ }
103
+ const unregisterUpdater = (a: Updatable): void => {
104
+ let i = earlyUpdaters.indexOf(a)
105
+ if (i >= 0) { earlyUpdaters.splice(i, 1); return }
106
+ i = lateUpdaters.indexOf(a)
107
+ if (i >= 0) lateUpdaters.splice(i, 1)
108
+ }
109
+
110
+ // ---- the host: mixed into every node kind (Node2D, Node) --------------------
111
+ /**
112
+ * Base for anything that can carry aspects. Extends Emitter, so node kinds get events too. Provides
113
+ * the four verbs; the named accessor (node.physics, node.health, …) is set as an own property at
114
+ * attach time, keyed by the aspect class's runtime `aspect` name.
115
+ */
116
+ export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<E> {
117
+ /** @internal class → instance, the authoritative store (named accessors mirror this). */
118
+ readonly _aspects = new Map<Function, Aspect<any, any>>()
119
+
120
+ /** Attach (and configure) an aspect, or reconfigure it if already present. Returns the node typed
121
+ * as now-having that aspect (so no guard/`?.` is needed afterwards). Rejects a wrong target node. */
122
+ aspect<Self extends TargetOf<A>, A extends Aspect<any, any>>(
123
+ this: Self,
124
+ ctor: AspectCtor<A>,
125
+ opts?: Partial<A>,
126
+ ): Self & FieldOf<A> {
127
+ const host = this as unknown as AspectHost
128
+ let inst = host._aspects.get(ctor) as A | undefined
129
+ const fresh = inst === undefined
130
+ if (!inst) {
131
+ inst = new (ctor as unknown as new () => A)()
132
+ ;(inst as { node: unknown }).node = this
133
+ host._aspects.set(ctor, inst)
134
+ const name = (ctor as { aspect?: string }).aspect
135
+ if (name) (host as unknown as Record<string, unknown>)[name] = inst
136
+ }
137
+ if (opts) Object.assign(inst, opts)
138
+ if (fresh) {
139
+ inst.onAttach?.()
140
+ if (typeof inst.update === "function") registerUpdater(inst)
141
+ } else if (opts) inst.onReconfigure?.()
142
+ return this as Self & FieldOf<A>
143
+ }
144
+
145
+ /** Safe access — undefined if the aspect isn't attached. */
146
+ get<A extends Aspect<any, any>>(ctor: AspectCtor<A>): A | undefined {
147
+ return this._aspects.get(ctor) as A | undefined
148
+ }
149
+
150
+ /** Existence check AND type guard: inside `if (node.has(Physics))`, `node.physics` is present. */
151
+ has<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
152
+ return this._aspects.has(ctor)
153
+ }
154
+
155
+ /** Detach an aspect (runs its onDetach). Named `removeAspect` so it never collides with the
156
+ * child-management `add`/`remove` semantics some node kinds expose. */
157
+ removeAspect<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this {
158
+ const inst = this._aspects.get(ctor)
159
+ if (inst) {
160
+ inst.onDetach?.()
161
+ unregisterUpdater(inst)
162
+ this._aspects.delete(ctor)
163
+ const name = (ctor as { aspect?: string }).aspect
164
+ if (name) delete (this as unknown as Record<string, unknown>)[name]
165
+ }
166
+ return this
167
+ }
168
+ }
169
+
170
+ // ---- the aspect base --------------------------------------------------------
171
+ /**
172
+ * Base for everything you attach. `K` = accessor name (extracted by chisel for the runtime). `P` =
173
+ * the target node kind: `this.node` is typed to it, AND attaching to a wrong node is a compile error.
174
+ * Initialize in `onAttach` (the node is set by then) — not a constructor; aspects are created by the
175
+ * engine via `node.aspect()`, never `new`.
176
+ */
177
+ export abstract class Aspect<K extends string, P = Node> {
178
+ /**
179
+ * Opt this aspect class into EDITOR-RUN mode (generators): while a scene is edited, the scene
180
+ * loader constructs the instance (refs resolved, `node`/`generated` set — never `onAttach`) and
181
+ * calls `rebuild()` — again on inspector prop edits and when a `ref()`-referenced node moves.
182
+ * `static editor = { rebuild: true }`.
183
+ */
184
+ static editor?: { rebuild?: boolean }
185
+
186
+ /**
187
+ * Custom inspector card for this aspect in the scene editor (immediate-mode — see
188
+ * core/InspectorUI.ts): re-runs on every edit/event and emits a widget list. Without it, the
189
+ * editor shows the inferred fields; `ui.auto()` emits those same fields, so a custom inspector
190
+ * usually starts with `ui.auto()` and appends status lines / buttons / dynamic dropdowns:
191
+ *
192
+ * static inspector(ui: InspectorUI, self: MyAspect) {
193
+ * ui.auto()
194
+ * if (ui.button('Randomize')) self.rebuild()
195
+ * }
196
+ */
197
+ static inspector?: (ui: InspectorUI, aspect: any) => void
198
+
199
+ // NOTE: P has no `extends AspectHost` constraint on purpose — Emitter<E>'s generic
200
+ // addEventListener makes a specific-event node (AspectHost<NodeEvents>) not assignable to
201
+ // AspectHost<any>, which would reject every real node. P only types `this.node`; target
202
+ // enforcement happens in aspect() via `this: Self extends TargetOf<A>`. Defaults to Node
203
+ // (3D is the default engine) — a 2D aspect declares its own P, e.g. Aspect<'x', Sprite>.
204
+ /** The node this aspect is attached to (set at attach time). */
205
+ readonly node!: P
206
+ /**
207
+ * GENERATOR aspects (`static editor = { rebuild: true }`, attached through a scene file): a
208
+ * scene-added container child for the aspect's generated output — `rebuild()` clears and refills
209
+ * it. Provided by the scene loader before `onAttach`/`rebuild` run; nodes `add()`ed to it join
210
+ * the scene's draw set automatically (membership is separate from parenting). Undefined for
211
+ * aspects attached by hand outside scene files. See docs/3d/scene-files.md.
212
+ */
213
+ readonly generated!: Node & { clear(): void, readonly version: number }
214
+ /**
215
+ * GENERATOR aspects attached through a scene file: the `Scene` the host node was built into —
216
+ * what `handle.instantiate({ scene })` / `scene.add()` need inside `rebuild()`. Set by the scene
217
+ * loader (edit AND play mode); undefined for aspects attached by hand. 3D only (2D aspects own
218
+ * their `scene` field).
219
+ */
220
+ readonly scene!: P extends Node ? Scene : unknown
221
+ /** @internal phantom — lets the type system recover the accessor name `K`. Never read at runtime. */
222
+ declare protected readonly __key__?: K
223
+ onAttach?(): void
224
+ onDetach?(): void
225
+ /** Called after `node.aspect(Ctor, opts)` re-assigns options on an ALREADY attached aspect (a
226
+ * pre-attached one like `model.anim`): rebuild whatever was derived from the options at attach. */
227
+ onReconfigure?(): void
228
+ /**
229
+ * GENERATOR aspects: (re)build the derived output under `this.generated` — must be idempotent
230
+ * (clear, then create). Call it from `onAttach()` for play mode; a class opting in with
231
+ * `static editor = { rebuild: true }` ALSO runs it while a scene is being edited: once at load,
232
+ * and again whenever an inspector prop changes or a `ref()`-referenced node moves.
233
+ */
234
+ rebuild?(): void
235
+ /**
236
+ * Called every frame while attached (dt = seconds since the last frame). Render-synced: runs in the
237
+ * LATE phase by default — after the physics step + transform sync, right before the frame draws — so
238
+ * reads of `node.worldPosition` are the final drawn position (cameras/followers have no 1-frame lag).
239
+ * Set `updateBeforePhysics = true` (an instance field) to run in the EARLY phase (before the step)
240
+ * instead — for aspects that WRITE velocity/force/kinematic transforms and want the same frame's
241
+ * step to consume them. Cameras/followers READ, so they stay in the default LATE phase.
242
+ */
243
+ update?(dt: number): void
244
+
245
+ /** Run update(dt) in the EARLY phase (before the physics step) rather than the default LATE phase.
246
+ * An instance field (like `updateWhenVisible`) — set it as a class field: `updateBeforePhysics = true`.
247
+ * Use only when you FEED the simulation (velocity/force/kinematic transform). Read once, at attach. */
248
+ updateBeforePhysics = false
249
+ /** Tick order within a phase — ascending; default 0, ties keep attach order. Read once, at attach. */
250
+ order = 0
251
+
252
+ /** Opt-in: only run update(dt) while the node is on-screen. NOOP for now — visibility culling isn't
253
+ * wired yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
254
+ updateWhenVisible = false
255
+ }
@@ -6,8 +6,8 @@
6
6
  // .aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } })
7
7
  // .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
8
8
  // .aspect(CharacterController2D, { speed: 200, jumpSpeed: 520 })
9
- // Input.on('left', d => hero.controller.move(d ? -1 : 0))
10
- // Input.on('jump', () => hero.controller.jump())
9
+ // setLoop(() => hero.controller.move((Input.key('ArrowRight') ? 1 : 0) - (Input.key('ArrowLeft') ? 1 : 0)))
10
+ // Input.on('keydown', e => { if (e.code === 'Space') hero.controller.jump() })
11
11
  //
12
12
  // Velocity-control (set velocity each frame) is the standard, robust approach for platformers and
13
13
  // reuses the physics solver. For pixel-tight movement without any solver bounce/seam quirks, a
@@ -2,9 +2,23 @@
2
2
  // screen↔world ray helpers. Created and attached by Scene. Ported from worker/src/components/Camera.ts.
3
3
 
4
4
  import { Vec3 } from "../math/vec"
5
+ import { Aspect } from "../core/Aspect"
5
6
  import { Node } from "./Node"
6
7
  import { Ray } from "./Ray"
7
8
 
9
+ /** @internal `camera.follow(node)`: copies the node's world pose onto the camera every LATE frame
10
+ * (order 1000 — after every other aspect has moved things). One per camera; lives on the target. */
11
+ export class CameraFollowRig extends Aspect<"__cameraFollow"> {
12
+ static readonly aspect = "__cameraFollow"
13
+ /** @internal */ _camera!: Camera
14
+ constructor() { super(); this.order = 1000 }
15
+ update(): void {
16
+ const cam = this._camera
17
+ cam.position = this.node.worldPosition
18
+ cam.quaternion = this.node.worldQuaternion
19
+ }
20
+ }
21
+
8
22
  /** Projection every host starts a scene with (creator-gl scene.cpp / the lite core agree on these). */
9
23
  export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }
10
24
 
@@ -60,6 +74,33 @@ export class Camera extends Node {
60
74
  if (this._sceneId < 0 || typeof _creator.setCameraProjection !== "function") return
61
75
  _creator.setCameraProjection(this._sceneId, this._fov, this._near, this._far)
62
76
  }
77
+ private _following: Node | null = null
78
+ /** The node the camera currently follows (`follow()`), or null. */
79
+ get following(): Node | null { return this._following }
80
+
81
+ /**
82
+ * Make the camera ride a node: every late frame the camera takes the node's WORLD pose (−Z =
83
+ * view direction), so movement aspects / animation on that node are camera moves — dolly shots,
84
+ * a cutscene path, a `CameraPlace` in a scene file. A node carrying a `CameraPlace` also hands
85
+ * over its projection (fov / near / far). `follow(null)` releases the camera where it is.
86
+ * One camera per scene: following a new node stops following the previous one.
87
+ */
88
+ follow(target: Node | { node: Node, applyTo(camera: Camera): void } | null): this {
89
+ const node = target === null ? null : target instanceof Node ? target : target.node
90
+ if (this._following && this._following !== node) this._following.removeAspect(CameraFollowRig)
91
+ this._following = node
92
+ if (!node) return this
93
+ // a CameraPlace on the node (or passed directly) sets the lens too
94
+ const place = target instanceof Node
95
+ ? (node as unknown as { cameraPlace?: { applyTo(camera: Camera): void } }).cameraPlace
96
+ : target as { applyTo(camera: Camera): void }
97
+ place?.applyTo(this)
98
+ node.aspect(CameraFollowRig, { _camera: this })
99
+ this.position = node.worldPosition
100
+ this.quaternion = node.worldQuaternion
101
+ return this
102
+ }
103
+
63
104
  get displaySize(): [number, number] {
64
105
  const s = _creator.getDisplaySize()
65
106
  return [ s[0], s[1] ]