lecodes-cli 0.17.2 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +2376 -755
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +52 -8
  6. package/runtime/sdk/compile/assetMacro.ts +116 -15
  7. package/runtime/sdk/compile/bundler.ts +39 -4
  8. package/runtime/sdk/compile/compileProject.ts +16 -1
  9. package/runtime/sdk/compile/header.ts +6 -1
  10. package/runtime/sdk/compile/index.ts +31 -0
  11. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  12. package/runtime/sdk/compile/sceneEditor.ts +11 -1
  13. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  14. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  15. package/runtime/sdk/core/Aspect.ts +363 -95
  16. package/runtime/sdk/core/compWrite.ts +42 -0
  17. package/runtime/sdk/core/fields.ts +1 -1
  18. package/runtime/sdk/core/time.ts +81 -0
  19. package/runtime/sdk/g2/Camera2D.ts +8 -1
  20. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  21. package/runtime/sdk/g2/Node2D.ts +80 -10
  22. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  23. package/runtime/sdk/g2/Physics2D.ts +240 -30
  24. package/runtime/sdk/g2/Scene2D.ts +33 -1
  25. package/runtime/sdk/g2/Shape2D.ts +218 -22
  26. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  27. package/runtime/sdk/g2/groups2d.ts +106 -0
  28. package/runtime/sdk/g2/loop.ts +15 -4
  29. package/runtime/sdk/gl/Camera.ts +41 -0
  30. package/runtime/sdk/gl/CameraPlace.ts +52 -0
  31. package/runtime/sdk/gl/CharacterController.ts +184 -55
  32. package/runtime/sdk/gl/Gearbox.ts +212 -0
  33. package/runtime/sdk/gl/Geometry.ts +70 -9
  34. package/runtime/sdk/gl/IK.ts +193 -174
  35. package/runtime/sdk/gl/Light.ts +64 -2
  36. package/runtime/sdk/gl/Lightmap.ts +179 -0
  37. package/runtime/sdk/gl/Material.ts +36 -0
  38. package/runtime/sdk/gl/Mesh.ts +6 -23
  39. package/runtime/sdk/gl/Model.ts +23 -8
  40. package/runtime/sdk/gl/Node.ts +350 -285
  41. package/runtime/sdk/gl/Physics.ts +222 -126
  42. package/runtime/sdk/gl/Scene.ts +175 -8
  43. package/runtime/sdk/gl/Shape.ts +255 -12
  44. package/runtime/sdk/gl/Trigger.ts +1 -6
  45. package/runtime/sdk/gl/Vehicle.ts +473 -0
  46. package/runtime/sdk/gl/Wheel.ts +240 -0
  47. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  48. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  49. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  50. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  51. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  52. package/runtime/sdk/gl/animation/core.ts +294 -0
  53. package/runtime/sdk/gl/scenarios.ts +291 -349
  54. package/runtime/sdk/inject.ts +186 -162
  55. package/runtime/sdk/runtime/app.ts +13 -0
  56. package/runtime/sdk/runtime/input.ts +169 -6
  57. package/runtime/sdk/scene/defineScene.ts +1227 -1016
  58. package/runtime/sdk/scene/gizmos.ts +148 -0
  59. package/runtime/sdk/scene/material.ts +188 -0
  60. package/runtime/sdk-types.json +1 -1
  61. package/runtime/sdk/gl/Animator.ts +0 -642
  62. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -11,23 +11,29 @@
11
11
  // class Health extends Aspect<'health', Sprite> { hp = 100; hurt(n: number) { this.hp -= n } }
12
12
  // sprite.aspect(Health, { hp: 80 }).health.hurt(10)
13
13
  //
14
+ // SYSTEMS are the same thing attached to a SCENE instead of a node — game logic that has no single
15
+ // node to live on (input mapping, FX pools, the HUD, the game mode): `scene.system(Fx)` → `scene.fx`.
16
+ // They share the lifecycle, the update phases, the ordering and the events of aspects (see System).
17
+ //
14
18
  // The accessor name lives in the generic (Aspect<'name', Node>). The `chisel` bundler extracts it at
15
19
  // compile time and injects the runtime `static aspect` on user aspects + the virtual type accessor;
16
20
  // the SDK's own built-in aspects declare `static readonly aspect` directly (see SpriteAnimation etc.).
17
21
 
22
+ import type { Scene } from "../gl/Scene"
18
23
  import { Emitter, type EventMap } from "./events"
19
24
  import type { InspectorUI } from "./InspectorUI"
20
25
  import type { Node } from "../gl/Node"
26
+ import { Time, _registerTimeScaleSink } from "./time"
21
27
 
22
28
  // ---- type helpers -----------------------------------------------------------
23
29
  /** 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
30
+ export type AspectCtor<A extends Aspect<any, any, any>> = abstract new () => A
25
31
  /** 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
32
+ export type KeyOf<A> = A extends Aspect<infer K, any, any> ? K : never
33
+ /** The node kind an aspect targets (the scene kind for a System). */
34
+ export type TargetOf<A> = A extends Aspect<any, infer P, any> ? P : never
29
35
  /** `{ name: Aspect }` — the field an aspect contributes to its node. */
30
- export type FieldOf<A extends Aspect<any, any>> = { [P in KeyOf<A>]: A }
36
+ export type FieldOf<A extends Aspect<any, any, any>> = { [P in KeyOf<A>]: A }
31
37
 
32
38
  type UnionToIntersection<U> =
33
39
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
@@ -37,73 +43,247 @@ type UnionToIntersection<U> =
37
43
  * English and mirrors the runtime guard `node.has(Health)`:
38
44
  * let boss: With<Sprite, Health | Physics>
39
45
  */
40
- export type With<N, A extends Aspect<any, any>> =
46
+ export type With<N, A extends Aspect<any, any, any>> =
41
47
  N & UnionToIntersection<A extends any ? FieldOf<A> : never>
42
48
 
43
49
  // ---- 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
+ // Aspects tick every frame in one of two render-synced phases, chosen by WHICH METHOD they define:
51
+ // • `update(dt)` — LATE: after the physics step + transform sync + animators, right before the frame
52
+ // draws. Reads of node.worldPosition are the FINAL drawn position — cameras/followers land exactly,
53
+ // what you write to a plain node is what this frame shows. The default home for game logic.
54
+ // • `updateBefore(dt)` — EARLY: before the physics step, so writes of velocity / move commands /
55
+ // kinematic transforms are consumed by the SAME frame's step (zero input latency).
56
+ // An aspect rarely needs both; when a behaviour does (feed the sim, then pose something after the
57
+ // animators), it is two aspects on the same node — not one class in two phases.
58
+ //
50
59
  // 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>
60
+ // pull in the 2D one): each engine layer installs a render-synced source via _installAspectFrames (the
61
+ // native early/late hooks), so dispatch is in lockstep with the draw. With none installed (a runtime
62
+ // without the hooks) it falls back to a single host setLoop, early-then-late — no true post-physics slot.
63
+ //
64
+ // ORDER within a phase: declared constraints first (`static after = [Controls]` / `static before`),
65
+ // then the numeric `updateOrder` (ascending, default 0), then attach order. Constraints are edges between
66
+ // classes: an aspect ranks after everything it names in `after` and after everything that names it in
67
+ // `before`; a class with no constraints has rank 0. `updateOrder` only breaks ties inside a rank — so a
68
+ // declared dependency always wins over a number.
69
+ //
70
+ // `dt` is GAME time (core/time.ts): scaled by Time.scale, 0 while Time.paused — and a paused frame skips
71
+ // every updater except those with `updateWhilePaused = true`. Lazy: the first update-bearing aspect installs
72
+ // the dispatch; a project with none starts nothing. No visibility culling yet.
73
+ // The dispatcher's view of an instance: the lifecycle members are `protected` on Aspect (the engine calls
74
+ // them, game code never does), so core reaches them through this structural type.
75
+ interface Updatable {
76
+ _phaseSeq: number
77
+ _phases: number
78
+ updateOrder: number
79
+ updateWhilePaused: boolean
80
+ update?(dt: number): void
81
+ updateBefore?(dt: number): void
82
+ onAttach?(): void
83
+ onDetach?(): void
84
+ onReconfigure?(): void
85
+ }
86
+ const internals = (a: Aspect<any, any, any>): Updatable => a as unknown as Updatable
58
87
  type PhaseFn = (dt: number) => void
59
- const earlyUpdaters: Updatable[] = []
60
- const lateUpdaters: Updatable[] = []
61
- let dispatchInstalled = false
88
+ type Ctor = Function & { after?: Function[], before?: Function[], aspect?: string }
62
89
 
63
90
  // 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.
91
+ // return false if unavailable (→ setLoop fallback). Installed by the engine layers (Scene / Scene2D).
66
92
  type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
67
93
  let frameInstaller: FrameInstaller | undefined
68
- /** @internal Install a render-synced frame source for aspect update(dt). Called from the 2D layer. */
94
+ /** @internal Install a render-synced frame source for aspect update(dt). Called from an engine layer. */
69
95
  export const _installAspectFrames = (fn: FrameInstaller): void => { frameInstaller = fn }
70
96
 
71
- const policyOf = (a: Updatable): { early: boolean; order: number } =>
72
- ({ early: a.updateBeforePhysics === true, order: a.order ?? 0 })
97
+ let attachSeq = 0
98
+ const warned = new Set<string>()
99
+ const warnOnce = (msg: string): void => { if (!warned.has(msg)) { warned.add(msg); console.warn(msg) } }
100
+
101
+ /** One phase's updater list. Iterated LIVE (no per-frame copy): removals during a pass null the slot
102
+ * and compact afterwards; additions land past the pass's end and tick from the next frame. Sorting
103
+ * is lazy — re-ranked before the next pass after any add. */
104
+ class PhaseList {
105
+ readonly items: (Updatable | null)[] = []
106
+ private iterating = false
107
+ private dirty = false
108
+ private holes = false
73
109
 
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)
110
+ add(a: Updatable, phase: "update" | "updateBefore"): void {
111
+ a._phaseSeq = ++attachSeq
112
+ this.items.push(a)
113
+ this.dirty = true
114
+ a._phases |= phase === "update" ? 2 : 1
115
+ }
116
+ remove(a: Updatable): boolean {
117
+ const i = this.items.indexOf(a)
118
+ if (i < 0) return false
119
+ if (this.iterating) { this.items[i] = null; this.holes = true }
120
+ else this.items.splice(i, 1)
121
+ return true
122
+ }
123
+ run(method: "update" | "updateBefore", dt: number): void {
124
+ if (this.dirty) this.sort()
125
+ const items = this.items
126
+ const n = items.length // appended during the pass → next frame (attach-order snapshot semantics)
127
+ const paused = Time.paused
128
+ this.iterating = true
129
+ for (let i = 0; i < n; i++) {
130
+ const a = items[i]
131
+ if (a === null || (paused && !a.updateWhilePaused)) continue
132
+ a[method]!(dt)
133
+ }
134
+ this.iterating = false
135
+ if (this.holes) {
136
+ this.holes = false
137
+ let w = 0
138
+ for (let r = 0; r < items.length; r++) { const a = items[r]; if (a !== null) items[w++] = a }
139
+ items.length = w
140
+ }
141
+ }
142
+ /** Stable sort by (constraint rank, updateOrder, attach sequence). */
143
+ private sort(): void {
144
+ this.dirty = false
145
+ const ranks = rankConstraints(this.items)
146
+ this.items.sort((x, y) => {
147
+ if (x === null || y === null) return x === null ? (y === null ? 0 : 1) : -1
148
+ const rx = ranks.get(x.constructor) ?? 0, ry = ranks.get(y.constructor) ?? 0
149
+ if (rx !== ry) return rx - ry
150
+ if (x.updateOrder !== y.updateOrder) return x.updateOrder - y.updateOrder
151
+ return x._phaseSeq - y._phaseSeq
152
+ })
153
+ }
79
154
  }
80
155
 
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)
156
+ // Constraint rank per class among the classes present in one phase list: rank = 0 with no incoming
157
+ // edge, else 1 + max(rank of predecessors). Edges: C after D (from C.after or D.before). A cycle is
158
+ // warned once and its closing edge ignored; a constraint naming a class not present in this phase is
159
+ // simply unconstrained here (early always precedes late anyway).
160
+ const rankConstraints = (items: (Updatable | null)[]): Map<Function, number> => {
161
+ const present = new Set<Function>()
162
+ for (const a of items) if (a) present.add(a.constructor)
163
+ const preds = new Map<Function, Set<Function>>()
164
+ const edge = (before: Function, after: Function): void => {
165
+ if (!present.has(before) || !present.has(after) || before === after) return
166
+ let s = preds.get(after)
167
+ if (!s) preds.set(after, (s = new Set()))
168
+ s.add(before)
169
+ }
170
+ for (const c of present) {
171
+ for (const d of (c as Ctor).after ?? []) edge(d, c)
172
+ for (const d of (c as Ctor).before ?? []) edge(c, d)
173
+ }
174
+ if (preds.size === 0) return new Map()
175
+ const rank = new Map<Function, number>()
176
+ const visiting = new Set<Function>()
177
+ const rankOf = (c: Function): number => {
178
+ const known = rank.get(c)
179
+ if (known !== undefined) return known
180
+ if (visiting.has(c)) {
181
+ warnOnce(`[aspects] ordering cycle through ${c.name || "?"} — its constraint is ignored`)
182
+ return 0
183
+ }
184
+ visiting.add(c)
185
+ let r = 0
186
+ for (const p of preds.get(c) ?? []) r = Math.max(r, rankOf(p) + 1)
187
+ visiting.delete(c)
188
+ rank.set(c, r)
189
+ return r
190
+ }
191
+ for (const c of present) rankOf(c)
192
+ return rank
84
193
  }
85
194
 
195
+ const earlyUpdaters = new PhaseList()
196
+ const lateUpdaters = new PhaseList()
197
+ let dispatchInstalled = false
198
+
86
199
  const ensureDispatch = (): void => {
87
200
  if (dispatchInstalled) return
88
201
  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).
202
+ // Both phases scale their OWN raw dt (the engine hands the same value to both; a host or test that
203
+ // drives them apart still gets each phase's dt right). The frame counter / clocks advance once, early.
204
+ const early: PhaseFn = (dt) => { Time._beginFrame(dt); earlyUpdaters.run("updateBefore", Time.dt) }
205
+ const late: PhaseFn = (dt) => lateUpdaters.run("update", Time._phaseDt(dt))
206
+ // render-synced source if an engine layer installed one; else one host loop (early then late).
92
207
  if (!(frameInstaller && frameInstaller(early, late))) {
93
208
  setLoop((dt) => { early(dt); late(dt) })
94
209
  }
95
210
  }
96
211
 
97
- const registerUpdater = (a: Updatable): void => {
98
- const { early, order } = policyOf(a)
99
- addSorted(early ? earlyUpdaters : lateUpdaters, a, order)
212
+ const registerUpdater = (inst: Aspect<any, any, any>): void => {
213
+ const a = internals(inst)
214
+ const hasBefore = typeof a.updateBefore === "function"
215
+ const hasUpdate = typeof a.update === "function"
216
+ if (!hasBefore && !hasUpdate) return
217
+ if (hasBefore) earlyUpdaters.add(a, "updateBefore")
218
+ if (hasUpdate) lateUpdaters.add(a, "update")
100
219
  ensureDispatch()
101
220
  }
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)
221
+ const unregisterUpdater = (inst: Aspect<any, any, any>): void => {
222
+ const a = internals(inst)
223
+ if (a._phases === 0) return
224
+ earlyUpdaters.remove(a)
225
+ lateUpdaters.remove(a)
226
+ a._phases = 0
227
+ }
228
+
229
+ // ---- per-class instance registry (`Aspect.all(Ctor)`) -----------------------
230
+ const instances = new Map<Function, Aspect<any, any, any>[]>()
231
+ const trackInstance = (a: Aspect<any, any, any>): void => {
232
+ let list = instances.get(a.constructor)
233
+ if (!list) instances.set(a.constructor, (list = []))
234
+ list.push(a)
235
+ }
236
+ const untrackInstance = (a: Aspect<any, any, any>): void => {
237
+ const list = instances.get(a.constructor)
238
+ if (!list) return
239
+ const i = list.indexOf(a)
240
+ if (i >= 0) list.splice(i, 1)
241
+ }
242
+
243
+ // ---- shared attach / detach (nodes AND scenes) ------------------------------
244
+ // `host` receives the named accessor and becomes `inst.node`; `store` is the class → instance map
245
+ // (a node's `_aspects`, a scene's `_systems`).
246
+ type Store = Map<Function, Aspect<any, any, any>>
247
+
248
+ const attachTo = <Self, A extends Aspect<any, any, any>>(
249
+ host: Self, store: Store, ctor: AspectCtor<A>, opts: Partial<A> | undefined, scene: unknown,
250
+ ): Self & FieldOf<A> => {
251
+ let inst = store.get(ctor) as A | undefined
252
+ const fresh = inst === undefined
253
+ if (!inst) {
254
+ inst = new (ctor as unknown as new () => A)()
255
+ ;(inst as { node: unknown }).node = host
256
+ if (scene !== undefined) (inst as { scene: unknown }).scene = scene
257
+ store.set(ctor, inst)
258
+ const name = (ctor as Ctor).aspect
259
+ if (name) (host as unknown as Record<string, unknown>)[name] = inst
260
+ }
261
+ if (opts) Object.assign(inst, opts)
262
+ if (fresh) {
263
+ trackInstance(inst)
264
+ internals(inst).onAttach?.()
265
+ registerUpdater(inst)
266
+ } else if (opts) internals(inst).onReconfigure?.()
267
+ return host as Self & FieldOf<A>
268
+ }
269
+
270
+ const detachFrom = (host: unknown, store: Store, ctor: Function): void => {
271
+ const inst = store.get(ctor)
272
+ if (!inst) return
273
+ internals(inst).onDetach?.()
274
+ unregisterUpdater(inst)
275
+ untrackInstance(inst)
276
+ inst._clearEvents()
277
+ store.delete(ctor)
278
+ const name = (ctor as Ctor).aspect
279
+ if (name) delete (host as Record<string, unknown>)[name]
280
+ }
281
+
282
+ /** Detach every aspect of a store, last-attached first (a Physics attached after its Shape releases
283
+ * its body before the Shape goes). */
284
+ const detachAllFrom = (host: unknown, store: Store): void => {
285
+ const ctors = [...store.keys()]
286
+ for (let i = ctors.length - 1; i >= 0; i--) detachFrom(host, store, ctors[i])
107
287
  }
108
288
 
109
289
  // ---- the host: mixed into every node kind (Node2D, Node) --------------------
@@ -114,66 +294,68 @@ const unregisterUpdater = (a: Updatable): void => {
114
294
  */
115
295
  export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<E> {
116
296
  /** @internal class → instance, the authoritative store (named accessors mirror this). */
117
- readonly _aspects = new Map<Function, Aspect<any, any>>()
297
+ readonly _aspects = new Map<Function, Aspect<any, any, any>>()
118
298
 
119
299
  /** Attach (and configure) an aspect, or reconfigure it if already present. Returns the node typed
120
300
  * 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>>(
301
+ aspect<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
122
302
  this: Self,
123
303
  ctor: AspectCtor<A>,
124
304
  opts?: Partial<A>,
125
305
  ): 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>
306
+ return attachTo(this, (this as unknown as AspectHost)._aspects, ctor, opts, undefined)
142
307
  }
143
308
 
144
309
  /** Safe access — undefined if the aspect isn't attached. */
145
- get<A extends Aspect<any, any>>(ctor: AspectCtor<A>): A | undefined {
310
+ get<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): A | undefined {
146
311
  return this._aspects.get(ctor) as A | undefined
147
312
  }
148
313
 
149
314
  /** 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> {
315
+ has<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
151
316
  return this._aspects.has(ctor)
152
317
  }
153
318
 
154
319
  /** Detach an aspect (runs its onDetach). Named `removeAspect` so it never collides with the
155
320
  * 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
- }
321
+ removeAspect<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this {
322
+ detachFrom(this, this._aspects, ctor)
165
323
  return this
166
324
  }
325
+
326
+ /** @internal Detach every aspect (node teardown): onDetach, updaters, events, registries. */
327
+ _detachAll(): void {
328
+ detachAllFrom(this, this._aspects)
329
+ this.clearListeners()
330
+ }
167
331
  }
168
332
 
333
+ // ---- systems: aspects of a scene --------------------------------------------
334
+ /** @internal What a scene needs to carry systems. Scene / Scene2D implement it over these helpers. */
335
+ export type SystemHost = { readonly _systems: Map<Function, Aspect<any, any, any>> }
336
+
337
+ /** @internal `scene.system(Ctor, opts)`. */
338
+ export const _attachSystem = <Self extends SystemHost, A extends Aspect<any, any, any>>(
339
+ scene: Self, ctor: AspectCtor<A>, opts?: Partial<A>,
340
+ ): Self & FieldOf<A> => attachTo(scene, scene._systems, ctor, opts, scene)
341
+ /** @internal `scene.removeSystem(Ctor)`. */
342
+ export const _detachSystem = (scene: SystemHost, ctor: Function): void => detachFrom(scene, scene._systems, ctor)
343
+ /** @internal Scene teardown: every system, last-attached first. */
344
+ export const _detachAllSystems = (scene: SystemHost): void => detachAllFrom(scene, scene._systems)
345
+
346
+ /** @internal Engine layers call this once with their bridge's clock hook (feature-detected). */
347
+ export const _installTimeScale = (sink: (scale: number) => void): void => _registerTimeScaleSink(sink)
348
+
169
349
  // ---- the aspect base --------------------------------------------------------
170
350
  /**
171
351
  * Base for everything you attach. `K` = accessor name (extracted by chisel for the runtime). `P` =
172
352
  * the target node kind: `this.node` is typed to it, AND attaching to a wrong node is a compile error.
353
+ * `E` = the events this aspect emits (`{ explode: (b: Barrel) => void }`): `this.emit('explode', …)`
354
+ * inside, `node.barrel.on('explode', fn)` outside.
173
355
  * Initialize in `onAttach` (the node is set by then) — not a constructor; aspects are created by the
174
356
  * engine via `node.aspect()`, never `new`.
175
357
  */
176
- export abstract class Aspect<K extends string, P = Node> {
358
+ export abstract class Aspect<K extends string, P = Node, E extends EventMap = {}> {
177
359
  /**
178
360
  * Opt this aspect class into EDITOR-RUN mode (generators): while a scene is edited, the scene
179
361
  * loader constructs the instance (refs resolved, `node`/`generated` set — never `onAttach`) and
@@ -195,12 +377,25 @@ export abstract class Aspect<K extends string, P = Node> {
195
377
  */
196
378
  static inspector?: (ui: InspectorUI, aspect: any) => void
197
379
 
380
+ /** Tick AFTER these classes within the same phase (`static after = [Controls]`). A constraint
381
+ * always beats the numeric `updateOrder`. Classes absent from the phase are ignored. */
382
+ static after?: Function[]
383
+ /** Tick BEFORE these classes within the same phase (`static before = [Camera]`). */
384
+ static before?: Function[]
385
+
386
+ /** Every live instance of an aspect/system class, in attach order — the registry a game used to
387
+ * hand-roll as `static all[]`. Live, read-only: copy it (`[...Aspect.all(Barrel)]`) before a loop
388
+ * that detaches. Empty in the scene editor (aspects are inert data there). */
389
+ static all<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): readonly A[] {
390
+ return (instances.get(ctor) ?? []) as unknown as readonly A[]
391
+ }
392
+
198
393
  // NOTE: P has no `extends AspectHost` constraint on purpose — Emitter<E>'s generic
199
394
  // addEventListener makes a specific-event node (AspectHost<NodeEvents>) not assignable to
200
395
  // AspectHost<any>, which would reject every real node. P only types `this.node`; target
201
396
  // enforcement happens in aspect() via `this: Self extends TargetOf<A>`. Defaults to Node
202
397
  // (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). */
398
+ /** The node this aspect is attached to (set at attach time). For a System: the scene. */
204
399
  readonly node!: P
205
400
  /**
206
401
  * GENERATOR aspects (`static editor = { rebuild: true }`, attached through a scene file): a
@@ -209,11 +404,36 @@ export abstract class Aspect<K extends string, P = Node> {
209
404
  * the scene's draw set automatically (membership is separate from parenting). Undefined for
210
405
  * aspects attached by hand outside scene files. See docs/3d/scene-files.md.
211
406
  */
212
- readonly generated!: Node & { clear(): void }
213
- /** @internal phantom — lets the type system recover the accessor name `K`. Never read at runtime. */
407
+ readonly generated!: Node & { clear(): void, readonly version: number }
408
+ /**
409
+ * GENERATOR aspects attached through a scene file: the `Scene` the host node was built into —
410
+ * what `handle.instantiate({ scene })` / `scene.add()` need inside `rebuild()`. Set by the scene
411
+ * loader (edit AND play mode); undefined for aspects attached by hand. 3D only (2D aspects own
412
+ * their `scene` field). For a System: always set — the scene it is attached to.
413
+ */
414
+ readonly scene!: P extends Node ? Scene : P extends SystemHost ? P : unknown
415
+ // Phantom fields — they let the type system recover `K` (the accessor name) and `E` from an
416
+ // instance type (`KeyOf<A>`), never read at runtime. Deliberately NOT marked as internal-only:
417
+ // build-types emits with `stripInternal` (which matches that tag in ANY leading comment, `//`
418
+ // included), and without these in the public .d.ts `infer K` collapses to `string`, which turned
419
+ // every `node.<accessor>` / `scene.<accessor>` into an untyped index signature in projects.
214
420
  declare protected readonly __key__?: K
215
- onAttach?(): void
216
- onDetach?(): void
421
+ declare protected readonly __events__?: E
422
+ /** @internal attach sequence within the phase lists (tie-breaker). */
423
+ _phaseSeq = 0
424
+ /** @internal bit 1 = registered early, bit 2 = registered late. */
425
+ _phases = 0
426
+ private _listeners?: Map<string, Function[]>
427
+
428
+ // ---- lifecycle: the engine calls these, game code never does (hence `protected`; a subclass may
429
+ // still widen one to public when a method doubles as an explicit action). ----
430
+ /** The node (or scene) is set — initialize here, not in a constructor. */
431
+ protected onAttach?(): void
432
+ /** Release what onAttach acquired. */
433
+ protected onDetach?(): void
434
+ /** Called after `node.aspect(Ctor, opts)` re-assigns options on an ALREADY attached aspect (a
435
+ * pre-attached one like `model.anim`): rebuild whatever was derived from the options at attach. */
436
+ protected onReconfigure?(): void
217
437
  /**
218
438
  * GENERATOR aspects: (re)build the derived output under `this.generated` — must be idempotent
219
439
  * (clear, then create). Call it from `onAttach()` for play mode; a class opting in with
@@ -222,23 +442,71 @@ export abstract class Aspect<K extends string, P = Node> {
222
442
  */
223
443
  rebuild?(): void
224
444
  /**
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.
445
+ * LATE phase — every frame while attached, after the physics step + transform sync + animators,
446
+ * right before the frame draws: reads of `node.worldPosition` are the final drawn position
447
+ * (cameras/followers have no 1-frame lag) and what you write to a plain node is what this frame
448
+ * shows. The default home for game logic. `dt` = GAME seconds since the last frame (`Time.scale`
449
+ * applied; 0 while paused — see `updateWhilePaused`).
231
450
  */
232
- update?(dt: number): void
451
+ protected update?(dt: number): void
452
+ /**
453
+ * EARLY phase — every frame BEFORE the physics step, so what you feed the simulation (velocity,
454
+ * `controller.move()`, forces, kinematic transforms) is consumed by this same frame's step: zero
455
+ * input latency. Use it only when you FEED the simulation. Reads here see last frame's settled
456
+ * state. A class rarely needs both phases — that is two aspects on one node.
457
+ */
458
+ protected updateBefore?(dt: number): void
233
459
 
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
460
+ /** Tick order within a phase and rank — ascending; default 0, ties keep attach order. A declared
461
+ * `static after` / `static before` constraint always beats this number. Read once, at attach —
462
+ * set it as a class field. */
463
+ protected updateOrder = 0
464
+ /** Keep ticking while `Time.paused` (a HUD fade, the pause menu). `dt` is still 0 then — read
465
+ * `Time.unscaledDt` for wall-clock motion. Checked every frame; may be toggled at any time. */
466
+ protected updateWhilePaused = false
240
467
 
241
468
  /** Opt-in: only run update(dt) while the node is on-screen. NOOP for now — visibility culling isn't
242
469
  * wired yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
243
- updateWhenVisible = false
470
+ protected updateWhenVisible = false
471
+
472
+ // ---- events: what this aspect tells the world (`E` in the generic) ----
473
+ /** Listen to one of this aspect's events. Chainable. Cleared on detach. */
474
+ on<C extends keyof E & string>(channel: C, callback: E[C]): this {
475
+ const map = (this._listeners ??= new Map())
476
+ const list = map.get(channel)
477
+ if (list) list.push(callback)
478
+ else map.set(channel, [callback])
479
+ return this
480
+ }
481
+ /** Remove a listener added with `on` (same function reference). */
482
+ off<C extends keyof E & string>(channel: C, callback: E[C]): this {
483
+ const list = this._listeners?.get(channel)
484
+ if (list) { const i = list.indexOf(callback); if (i >= 0) list.splice(i, 1) }
485
+ return this
486
+ }
487
+ /** Fire one of this aspect's events. Protected: only the aspect itself emits. */
488
+ protected emit<C extends keyof E & string>(channel: C, ...args: Parameters<E[C]>): void {
489
+ const list = this._listeners?.get(channel)
490
+ if (!list || list.length === 0) return
491
+ for (const fn of list.slice()) fn(...args)
492
+ }
493
+ /** @internal */
494
+ _clearEvents(): void { this._listeners = undefined }
495
+ }
496
+
497
+ /**
498
+ * An aspect of a SCENE: game logic with no single node to live on — input mapping, the FX pools, the
499
+ * HUD, the game mode. Same lifecycle (`onAttach`/`onDetach`), same two phases (`updateBefore` /
500
+ * `update`), same ordering (`updateOrder`, `static after`) and events as a node aspect; `this.scene` is the
501
+ * scene it was attached to. `S` = the scene kind (`Scene` by default, `Scene2D` for 2D games).
502
+ *
503
+ * class Hud extends System<'hud'> {
504
+ * static after = [Player]
505
+ * update(dt: number) { … }
506
+ * }
507
+ * scene.system(Hud) // attach
508
+ * scene.hud // the typed accessor
509
+ */
510
+ export abstract class System<K extends string, S extends SystemHost = Scene, E extends EventMap = {}> extends Aspect<K, S, E> {
511
+ // `this.scene` (and `this.node`) are typed S by the base class's conditional — nothing to add.
244
512
  }
@@ -0,0 +1,42 @@
1
+ // Component writes on SDK-owned vectors — the runtime half of chisel's `comp_write` pass.
2
+ //
3
+ // hero.controller.velocity.y = 7 → __compWrite(hero.controller, "velocity", "y", 7)
4
+ // hero.controller.velocity.y += 3 → __compOp(hero.controller, "velocity", "y", 0, 3)
5
+ //
6
+ // A getter like `velocity` hands out a fresh Vec3, so the literal spelling would write to a copy and
7
+ // silently do nothing. chisel rewrites the exact `<owner>.<prop>.<x|y|z|w> = v` shape into these
8
+ // helpers, which ask the OWNER to apply the component write (no getter call, no allocation) and
9
+ // fall back to the plain write on anything else, so a user record `{ velocity: { x, y } }` behaves
10
+ // exactly as written. Only the direct spelling is covered: `const v = c.velocity; v.y = 7` is still
11
+ // a write to a copy.
12
+ //
13
+ // Owners today: Node / Node2D / Camera2D (`position` → the scalar setters, physics routing included),
14
+ // Physics (`velocity`, `angularVelocity`), CharacterController / CharacterController2D (`velocity`).
15
+ //
16
+ // An owner declares WHICH getters qualify as compile-time data and answers them at runtime:
17
+ //
18
+ // static _comps = ["velocity"] // chisel reads the list, then strips it (0 bytes shipped)
19
+ // _writeComp(prop, axis, value) { … } // called for listed props only; a hook without the
20
+ // // list is a build diagnostic and routes nothing
21
+
22
+ export type CompAxis = "x" | "y" | "z" | "w"
23
+
24
+ /** Implemented by a class whose vector getters accept component writes (see above). The class also
25
+ * carries `static _comps: string[]` naming those getters — compile-time data, absent at runtime. */
26
+ export interface CompWriter {
27
+ _writeComp(prop: string, axis: CompAxis, value: number): void
28
+ }
29
+
30
+ /** `<o>.<prop>.<axis> = value`, routed to the owner when it has the hook. Returns the value. */
31
+ export const __compWrite = (o: any, prop: string, axis: CompAxis, value: number): number => {
32
+ if (o._writeComp !== undefined) o._writeComp(prop, axis, value) // a direct call, not .call — cheaper on QuickJS
33
+ else o[prop][axis] = value
34
+ return value
35
+ }
36
+
37
+ /** `<o>.<prop>.<axis> op= v` — `op`: 0 `+=`, 1 `-=`, 2 `*=`, 3 `/=`. The current component is read
38
+ * through the normal getter (one fresh vector), the result written like `__compWrite`. */
39
+ export const __compOp = (o: any, prop: string, axis: CompAxis, op: number, v: number): number => {
40
+ const cur = o[prop][axis] as number
41
+ return __compWrite(o, prop, axis, op === 0 ? cur + v : op === 1 ? cur - v : op === 2 ? cur * v : cur / v)
42
+ }
@@ -62,7 +62,7 @@ export type AspectClassInfo = {
62
62
 
63
63
  // Instance fields every aspect inherits from the Aspect base — configuration plumbing, not content.
64
64
  // `generated` is declare-only in TS, but the bundler lowers it to a real (undefined) class field.
65
- const BASE_FIELDS = new Set([ "node", "generated", "updateBeforePhysics", "order", "updateWhenVisible" ])
65
+ const BASE_FIELDS = new Set([ "node", "generated", "updateOrder", "updateWhilePaused", "updateWhenVisible" ])
66
66
 
67
67
  /** Infer the editor widget from a default value's shape. */
68
68
  export const inferFieldEditor = (value: unknown): FieldEditor | undefined => {