lecodes-cli 0.2.0 → 0.5.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 (48) hide show
  1. package/README.md +3 -0
  2. package/dist/index.js +883 -507
  3. package/package.json +9 -4
  4. package/runtime/sdk/canvas/Canvas.ts +270 -0
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetMacro.ts +44 -0
  7. package/runtime/sdk/compile/bundler.ts +173 -0
  8. package/runtime/sdk/compile/compileProject.ts +84 -0
  9. package/runtime/sdk/compile/header.ts +46 -0
  10. package/runtime/sdk/compile/index.ts +12 -0
  11. package/runtime/sdk/compile/sourcemap.ts +25 -0
  12. package/runtime/sdk/core/Aspect.ts +82 -26
  13. package/runtime/sdk/core/signals.ts +277 -0
  14. package/runtime/sdk/g2/CharacterController2D.ts +5 -4
  15. package/runtime/sdk/g2/Node2D.ts +1 -1
  16. package/runtime/sdk/g2/Physics2D.ts +22 -12
  17. package/runtime/sdk/g2/Scene2D.ts +30 -0
  18. package/runtime/sdk/g2/{Shape.ts → Shape2D.ts} +10 -10
  19. package/runtime/sdk/g2/Sprite.ts +16 -6
  20. package/runtime/sdk/g2/Texture2D.ts +12 -2
  21. package/runtime/sdk/g2/{Trigger.ts → Trigger2D.ts} +9 -9
  22. package/runtime/sdk/gl/CharacterController.ts +101 -0
  23. package/runtime/sdk/gl/Material.ts +10 -3
  24. package/runtime/sdk/gl/Node.ts +11 -2
  25. package/runtime/sdk/gl/Physics.ts +65 -41
  26. package/runtime/sdk/gl/Scene.ts +18 -0
  27. package/runtime/sdk/gl/Shape.ts +98 -0
  28. package/runtime/sdk/gl/Texture.ts +13 -2
  29. package/runtime/sdk/gl/Trigger.ts +46 -0
  30. package/runtime/sdk/gl/physicsEvents.ts +20 -0
  31. package/runtime/sdk/inject.ts +14 -3
  32. package/runtime/sdk/runtime/camera.ts +31 -0
  33. package/runtime/sdk/runtime/device.ts +26 -0
  34. package/runtime/sdk/runtime/share.ts +9 -0
  35. package/runtime/sdk/ui/UI.ts +5 -1
  36. package/runtime/sdk/ui/UIButton.ts +11 -5
  37. package/runtime/sdk/ui/UIContainer.ts +28 -13
  38. package/runtime/sdk/ui/UIImage.ts +31 -11
  39. package/runtime/sdk/ui/UIInput.ts +10 -0
  40. package/runtime/sdk/ui/UINode.ts +206 -8
  41. package/runtime/sdk/ui/UIScreen.ts +17 -7
  42. package/runtime/sdk/ui/UIScrollable.ts +10 -5
  43. package/runtime/sdk/ui/UISpacer.ts +4 -0
  44. package/runtime/sdk/ui/UIText.ts +21 -5
  45. package/runtime/sdk/ui/UIVideo.ts +5 -0
  46. package/runtime/sdk/ui/UIVirtualizedList.ts +5 -0
  47. package/runtime/sdk/ui/UIWidget.ts +10 -5
  48. package/runtime/sdk/gl/Collider.ts +0 -29
@@ -0,0 +1,46 @@
1
+ // The metadata header the launcher reads from the compiled bundle: name / icon / preload / gl / 2d /
2
+ // ar. Single source of truth — the backend and CLI both call this (today they mirror it by hand).
3
+ //
4
+ // Detection keys on the *bundled* `_creator*` calls, which the SDK's thin wrappers still emit
5
+ // (new Scene() → _creator.createScene, new Scene2D() → _creator2d.createScene), so the re-skin keeps
6
+ // the exact same signals the old worker bundles produced.
7
+
8
+ export type HeaderOptions = {
9
+ /** Generated-icon URL (backend supplies it; the CLI omits it). */
10
+ icon?: string
11
+ }
12
+
13
+ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}): string => {
14
+ let header = `// name: ${name.slice(0, 20)}\n`
15
+ if (opts.icon) header += `// icon: ${opts.icon}\n`
16
+ header += `// last-updated: ${new Date().toISOString()}\n`
17
+
18
+ // Local assets to preload: explicit fetchLocal() calls + implicit engine archives.
19
+ const localAssets = new Set<string>()
20
+ for (const m of code.matchAll(/_creatorUtils\.fetchLocal\((.+?)\)/g)) {
21
+ localAssets.add(m[1].replaceAll(/['"]/g, "").trim())
22
+ }
23
+ if (/_creator\.createGlb/.test(code)) localAssets.add("uberarchive")
24
+ if (/_creator\.captureImage/.test(code)) localAssets.add("capture")
25
+ if (localAssets.size > 0) header += `// preload: ${[ ...localAssets ].join(";")}\n`
26
+
27
+ // GL: disabled unless the project touches the 3D engine; deferred if it lazily creates the engine.
28
+ if (!code.includes("_creator.createScene") && !code.includes("_creator.createEntity")) header += `// gl: disable\n`
29
+ else if (code.includes("_creator.createEngine")) header += `// gl: defer\n`
30
+
31
+ // 2D (creator-2d) — non-exclusive: emitted alongside `gl: disable` so hosts skip Filament and a
32
+ // 2D-aware host brings up the lightweight sokol engine instead.
33
+ if (code.includes("_creator2d.")) header += `// 2d: enable\n`
34
+
35
+ if (code.includes("_creator.createARController")) header += "// ar: enable"
36
+ header += "\n"
37
+ return header
38
+ }
39
+
40
+ /** Same engine signals the header uses, for callers that need the project kind (e.g. icon type). */
41
+ export const detectProjectKind = (code: string): "ar" | "ui" | "2d" | "3d" => {
42
+ if (code.includes("_creator.createARController")) return "ar"
43
+ if (code.includes("_creator2d.")) return "2d"
44
+ if (!code.includes("_creator.")) return "ui"
45
+ return "3d"
46
+ }
@@ -0,0 +1,12 @@
1
+ // The public entry of the SDK's bundle build system. The backend, CLI, and local renderer import
2
+ // from here instead of re-forking a bundler. (Node + Bun safe — no top-level host globals.)
3
+
4
+ export { bundleProject, bundleProjectWithMap, type MemoryFiles } from "./bundler"
5
+ export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
6
+ export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
7
+ export {
8
+ compileProject,
9
+ type CompileEntry,
10
+ type CompileOptions,
11
+ type CompileFileType,
12
+ } from "./compileProject"
@@ -0,0 +1,25 @@
1
+ // Helpers for serving the bundler's source map alongside the bundle. The map's generated positions
2
+ // are relative to the bundle code; when the deploy/preview prepends a comment header, the map must be
3
+ // shifted down by that many lines, then inlined into the bundle as a data URL (the preview executes
4
+ // the bundle via `new Function(string)`, which has no URL identity, so an external `.map` can't be
5
+ // fetched — it must be inline).
6
+
7
+ /** Shift every generated position in `mapJson` down by `lines` (each VLQ line is `;`-separated). */
8
+ export const offsetSourceMap = (mapJson: string, lines: number): string => {
9
+ if (lines <= 0) return mapJson
10
+ const map = JSON.parse(mapJson) as { mappings: string }
11
+ map.mappings = ";".repeat(lines) + map.mappings
12
+ return JSON.stringify(map)
13
+ }
14
+
15
+ /**
16
+ * Inline the map into a bundle string under a **non-standard** directive (`lecodesMap`, not
17
+ * `sourceMappingURL`), so the browser does NOT auto-apply it. The bundle runs via `new Function`, and
18
+ * the browser maps the raw stack position *including* the `new Function` wrapper (~2-3 lines) which the
19
+ * map can't subtract — so its native source location would be off by those lines. Our preview mapper
20
+ * reads `lecodesMap` and subtracts the (calibrated) wrapper itself, so its locations are correct.
21
+ */
22
+ export const inlineSourceMapComment = (mapJson: string): string => {
23
+ const b64 = Buffer.from(mapJson, "utf8").toString("base64")
24
+ return `//# lecodesMap=data:application/json;charset=utf-8;base64,${b64}`
25
+ }
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // const hero = new Sprite({ texture })
5
5
  // .aspect(SpriteAnimation, { fps: 8, clips: { walk: [1, 2, 3] } }) // attach + configure
6
- // .aspect(Collider, { box: [16, 8], trigger: true }) // chains: returns the node
6
+ // .aspect(Shape2D, { box: [16, 8] }) // chains: returns the node
7
7
  // hero.anim.play('walk') // access by name (the chain result is typed as having it)
8
8
  // if (hero.has(Physics)) hero.physics.velocity = [0, 0] // has() is a type guard
9
9
  //
@@ -39,28 +39,70 @@ type UnionToIntersection<U> =
39
39
  export type With<N, A extends Aspect<any, any>> =
40
40
  N & UnionToIntersection<A extends any ? FieldOf<A> : never>
41
41
 
42
- // ---- per-frame update dispatch ----------------------------------------------
43
- // Aspects that define update(dt) are ticked every frame from a SINGLE global loop, created LAZILY on
44
- // the first such aspect (a project with no behavior aspects starts no loop). dt is seconds, like
45
- // setLoop. No visibility/frustum culling yet — every registered aspect ticks regardless of on-screen
46
- // state.
47
- const updaters: Aspect<any, any>[] = []
48
- let updateLoopId: number | undefined
49
-
50
- const ensureUpdateLoop = (): void => {
51
- if (updateLoopId !== undefined) return
52
- updateLoopId = setLoop((dt) => {
53
- // iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
54
- for (const a of updaters.slice()) {
55
- // later (visibility culling): if (a.updateWhenVisible && nodeOffScreen(a)) continue
56
- a.update?.(dt)
57
- }
58
- })
42
+ // ---- per-frame update dispatch (two phases) ---------------------------------
43
+ // Aspects that define update(dt) are ticked every frame in ONE of two phases, chosen by the aspect's
44
+ // `updateBeforePhysics` field:
45
+ // • LATE (default): after the physics step + transform sync, right before the frame draws. Reads of
46
+ // node.worldPosition are the FINAL drawn position — cameras/followers land exactly, no 1-frame lag.
47
+ // • EARLY (updateBeforePhysics = true): before the physics step, so writing velocity/force/kinematic
48
+ // transforms is consumed by the SAME frame's step (zero input latency).
49
+ // The FRAME SOURCE is pluggable so core never names a specific engine bridge (a 3D-only bundle must not
50
+ // pull in the 2D one): the 2D layer installs a render-synced source via _installAspectFrames (the native
51
+ // c2dRender early/late hooks), so dispatch is in lockstep with the draw. With none installed (3D-only,
52
+ // or a runtime without the hooks) it falls back to a single host setLoop, early-then-late — no true
53
+ // post-physics slot. Both lists stay sorted by the aspect's `order` field (default 0; ties keep
54
+ // insertion order). Lazy: the first update-bearing aspect installs the dispatch; a project with none
55
+ // starts nothing. No visibility culling yet — every registered aspect ticks regardless of on-screen state.
56
+ type Updatable = Aspect<any, any>
57
+ type PhaseFn = (dt: number) => void
58
+ const earlyUpdaters: Updatable[] = []
59
+ const lateUpdaters: Updatable[] = []
60
+ let dispatchInstalled = false
61
+
62
+ // A render-synced frame source: given the two phase runners, wire them to the engine and return true;
63
+ // return false if unavailable (→ setLoop fallback). Installed by the 2D layer (see Scene2D) so core
64
+ // never references a concrete engine bridge — that keeps the 2D bridge out of a 3D-only bundle.
65
+ type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
66
+ let frameInstaller: FrameInstaller | undefined
67
+ /** @internal Install a render-synced frame source for aspect update(dt). Called from the 2D layer. */
68
+ export const _installAspectFrames = (fn: FrameInstaller): void => { frameInstaller = fn }
69
+
70
+ const policyOf = (a: Updatable): { early: boolean; order: number } =>
71
+ ({ early: a.updateBeforePhysics === true, order: a.order ?? 0 })
72
+
73
+ // Stable insert by ascending order: place after the last element whose order is <= this one's.
74
+ const addSorted = (list: Updatable[], a: Updatable, order: number): void => {
75
+ let i = list.length
76
+ while (i > 0 && list[i - 1].order > order) i--
77
+ list.splice(i, 0, a)
59
78
  }
60
- const registerUpdater = (a: Aspect<any, any>): void => { updaters.push(a); ensureUpdateLoop() }
61
- const unregisterUpdater = (a: Aspect<any, any>): void => {
62
- const i = updaters.indexOf(a)
63
- if (i >= 0) updaters.splice(i, 1)
79
+
80
+ const runPhase = (list: Updatable[], dt: number): void => {
81
+ // iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
82
+ for (const a of list.slice()) a.update?.(dt)
83
+ }
84
+
85
+ const ensureDispatch = (): void => {
86
+ if (dispatchInstalled) return
87
+ dispatchInstalled = true
88
+ const early: PhaseFn = (dt) => runPhase(earlyUpdaters, dt)
89
+ const late: PhaseFn = (dt) => runPhase(lateUpdaters, dt)
90
+ // render-synced source if the engine layer installed one; else one host loop (early then late).
91
+ if (!(frameInstaller && frameInstaller(early, late))) {
92
+ setLoop((dt) => { early(dt); late(dt) })
93
+ }
94
+ }
95
+
96
+ const registerUpdater = (a: Updatable): void => {
97
+ const { early, order } = policyOf(a)
98
+ addSorted(early ? earlyUpdaters : lateUpdaters, a, order)
99
+ ensureDispatch()
100
+ }
101
+ const unregisterUpdater = (a: Updatable): void => {
102
+ let i = earlyUpdaters.indexOf(a)
103
+ if (i >= 0) { earlyUpdaters.splice(i, 1); return }
104
+ i = lateUpdaters.indexOf(a)
105
+ if (i >= 0) lateUpdaters.splice(i, 1)
64
106
  }
65
107
 
66
108
  // ---- the host: mixed into every node kind (Node2D, Node) --------------------
@@ -142,10 +184,24 @@ export abstract class Aspect<K extends string, P = Node> {
142
184
  declare protected readonly __key__?: K
143
185
  onAttach?(): void
144
186
  onDetach?(): void
145
- /** Called every frame while attached (dt = seconds since the last frame), via a lazy global loop. */
187
+ /**
188
+ * Called every frame while attached (dt = seconds since the last frame). Render-synced: runs in the
189
+ * LATE phase by default — after the physics step + transform sync, right before the frame draws — so
190
+ * reads of `node.worldPosition` are the final drawn position (cameras/followers have no 1-frame lag).
191
+ * Set `updateBeforePhysics = true` (an instance field) to run in the EARLY phase (before the step)
192
+ * instead — for aspects that WRITE velocity/force/kinematic transforms and want the same frame's
193
+ * step to consume them. Cameras/followers READ, so they stay in the default LATE phase.
194
+ */
146
195
  update?(dt: number): void
147
- /** Opt-in: only run update(dt) while the node is on-screen. Lives on the aspect (not the node) so
148
- * aspects on the same node can choose independently. NOOP for now — visibility culling isn't wired
149
- * yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
196
+
197
+ /** Run update(dt) in the EARLY phase (before the physics step) rather than the default LATE phase.
198
+ * An instance field (like `updateWhenVisible`) — set it as a class field: `updateBeforePhysics = true`.
199
+ * Use only when you FEED the simulation (velocity/force/kinematic transform). Read once, at attach. */
200
+ updateBeforePhysics = false
201
+ /** Tick order within a phase — ascending; default 0, ties keep attach order. Read once, at attach. */
202
+ order = 0
203
+
204
+ /** Opt-in: only run update(dt) while the node is on-screen. NOOP for now — visibility culling isn't
205
+ * wired yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
150
206
  updateWhenVisible = false
151
207
  }
@@ -0,0 +1,277 @@
1
+ // Fine-grained reactivity for the retained-mode UI: signal() / computed() / effect() globals plus
2
+ // the element-binding helpers UINode/UIText use for function-valued style props and text.
3
+ //
4
+ // Design notes (the parts that are deliberate, not incidental):
5
+ // - Dependents (effects, computeds) hold their sources STRONGLY; sources hold dependents WEAKLY
6
+ // (a WeakRef in the subscriber set). An element owns its binding effects, the effect's closure
7
+ // owns the element — so when user code drops a subtree, the whole element+effect subgraph
8
+ // becomes unreachable and collects, and the signal prunes the dead WeakRef on its next write.
9
+ // No unmount hook is needed and re-appending a kept subtree keeps its bindings alive.
10
+ // - Free-standing effect() computations are additionally held in a strong root set (otherwise a
11
+ // `effect(() => ...)` whose handle the user discards would be GC'd mid-flight); dispose()
12
+ // releases them. Element bindings are NOT in the root set — the element is their owner.
13
+ // - Writes are batched: a signal write schedules a microtask flush; each dirty effect runs once
14
+ // per flush. The first run of any effect/binding is synchronous, so element styles hold
15
+ // concrete values immediately after construction (hosts serialize _style at mount).
16
+ // - QuickJS-safe: promise microtasks (the hosts drain pending jobs), WeakRef with a strong-ref
17
+ // fallback for engines without it (then stale subscribers just live until the run ends —
18
+ // every run executes in a fresh JSRuntime anyway).
19
+
20
+ interface WeakRefLike<T extends object> { deref(): T | undefined }
21
+
22
+ const WeakRefImpl: new <T extends object>(target: T) => WeakRefLike<T> =
23
+ typeof WeakRef !== "undefined" ? WeakRef : class StrongRef<T extends object> {
24
+ private _t: T
25
+ constructor(target: T) { this._t = target }
26
+ deref(): T | undefined { return this._t }
27
+ }
28
+
29
+ interface Computation {
30
+ _ref: WeakRefLike<Computation>
31
+ _deps: Dep[]
32
+ _disposed: boolean
33
+ _invalidate(): void
34
+ }
35
+
36
+ let activeComputation: Computation | null = null
37
+
38
+ /** Removes `c` from every source it subscribed to on its last run. */
39
+ const cleanupDeps = (c: Computation) => {
40
+ for (const dep of c._deps) dep._subs.delete(c._ref)
41
+ c._deps.length = 0
42
+ }
43
+
44
+ class Dep {
45
+ _subs = new Set<WeakRefLike<Computation>>()
46
+
47
+ _track() {
48
+ const c = activeComputation
49
+ if (c === null || this._subs.has(c._ref)) return
50
+ this._subs.add(c._ref)
51
+ c._deps.push(this)
52
+ }
53
+
54
+ _notify() {
55
+ for (const ref of [ ...this._subs ]) {
56
+ const c = ref.deref()
57
+ if (c === undefined || c._disposed) {
58
+ this._subs.delete(ref) // lazy pruning of collected/disposed subscribers
59
+ } else {
60
+ c._invalidate()
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ // ---- effect scheduling (microtask batch) ----
67
+
68
+ const queue = new Set<EffectImpl>()
69
+ let flushScheduled = false
70
+
71
+ const flush = () => {
72
+ // Effects may write signals, re-filling the queue mid-flush; drain until empty with a guard
73
+ // against write-loops (effect A invalidating itself / a cycle of effects).
74
+ let runs = 0
75
+ while (queue.size > 0) {
76
+ if (++runs > 10000) {
77
+ queue.clear()
78
+ console.warn("signals: effect flush exceeded 10000 runs — possible infinite update loop, remaining effects dropped")
79
+ break
80
+ }
81
+ const first: EffectImpl = queue.values().next().value!
82
+ queue.delete(first)
83
+ first._run()
84
+ }
85
+ flushScheduled = false
86
+ }
87
+
88
+ const schedule = (fx: EffectImpl) => {
89
+ queue.add(fx)
90
+ if (!flushScheduled) {
91
+ flushScheduled = true
92
+ Promise.resolve().then(flush)
93
+ }
94
+ }
95
+
96
+ /** Runs every pending effect now instead of waiting for the microtask (test/host hook). */
97
+ export const flushEffects = (): void => {
98
+ if (queue.size > 0) flush()
99
+ }
100
+
101
+ class EffectImpl implements Computation {
102
+ _ref: WeakRefLike<Computation>
103
+ _deps: Dep[] = []
104
+ _disposed = false
105
+ private _fn: () => void
106
+
107
+ constructor(fn: () => void) {
108
+ this._fn = fn
109
+ this._ref = new WeakRefImpl(this)
110
+ }
111
+
112
+ _run() {
113
+ if (this._disposed) return
114
+ cleanupDeps(this)
115
+ const prev = activeComputation
116
+ activeComputation = this
117
+ try {
118
+ this._fn()
119
+ } finally {
120
+ activeComputation = prev
121
+ }
122
+ }
123
+
124
+ _invalidate() {
125
+ schedule(this)
126
+ }
127
+
128
+ _dispose() {
129
+ if (this._disposed) return
130
+ this._disposed = true
131
+ cleanupDeps(this)
132
+ queue.delete(this)
133
+ rootEffects.delete(this)
134
+ }
135
+ }
136
+
137
+ // Free-standing effect() handles the user may never store — kept alive here until disposed.
138
+ const rootEffects = new Set<EffectImpl>()
139
+
140
+ // ---- public surface ----
141
+
142
+ export interface Signal<T> {
143
+ value: T,
144
+ /** Read without subscribing the current effect/computed. */
145
+ peek(): T,
146
+ }
147
+
148
+ export interface Computed<T> {
149
+ readonly value: T,
150
+ /** Read without subscribing the current effect/computed (still recomputes if stale). */
151
+ peek(): T,
152
+ }
153
+
154
+ class SignalImpl<T> {
155
+ private _dep = new Dep()
156
+ private _v: T
157
+
158
+ constructor(value: T) {
159
+ this._v = value
160
+ }
161
+
162
+ get value(): T {
163
+ this._dep._track()
164
+ return this._v
165
+ }
166
+
167
+ set value(next: T) {
168
+ if (Object.is(next, this._v)) return
169
+ this._v = next
170
+ this._dep._notify()
171
+ }
172
+
173
+ peek(): T {
174
+ return this._v
175
+ }
176
+ }
177
+
178
+ class ComputedImpl<T> implements Computation {
179
+ _ref: WeakRefLike<Computation>
180
+ _deps: Dep[] = []
181
+ _disposed = false
182
+ private _dep = new Dep()
183
+ private _fn: () => T
184
+ private _stale = true
185
+ private _v!: T
186
+
187
+ constructor(fn: () => T) {
188
+ this._fn = fn
189
+ this._ref = new WeakRefImpl(this)
190
+ }
191
+
192
+ get value(): T {
193
+ this._dep._track()
194
+ if (this._stale) this._recompute()
195
+ return this._v
196
+ }
197
+
198
+ peek(): T {
199
+ if (this._stale) this._recompute()
200
+ return this._v
201
+ }
202
+
203
+ private _recompute() {
204
+ cleanupDeps(this)
205
+ const prev = activeComputation
206
+ activeComputation = this
207
+ try {
208
+ this._v = this._fn()
209
+ this._stale = false
210
+ } finally {
211
+ activeComputation = prev
212
+ }
213
+ }
214
+
215
+ // Lazy: a dep change only marks the memo stale and passes the wave downstream; the next read
216
+ // recomputes.
217
+ _invalidate() {
218
+ if (this._stale) return
219
+ this._stale = true
220
+ this._dep._notify()
221
+ }
222
+ }
223
+
224
+ /**
225
+ * A reactive value. Reading `.value` inside an `effect`/`computed` (or a function-valued style
226
+ * prop / `UIText` content) subscribes it; writing `.value` re-runs subscribers (batched — once
227
+ * per microtask).
228
+ */
229
+ export function signal<T>(initialValue: T): Signal<T>
230
+ export function signal<T = undefined>(): Signal<T | undefined>
231
+ export function signal<T>(initialValue?: T): Signal<T | undefined> {
232
+ return new SignalImpl(initialValue)
233
+ }
234
+
235
+ /** A lazily-cached derived value: recomputes on read after any of its dependencies changed. */
236
+ export function computed<T>(fn: () => T): Computed<T> {
237
+ return new ComputedImpl(fn)
238
+ }
239
+
240
+ /**
241
+ * Runs `fn` now and again whenever any signal/computed it read changes (batched per microtask).
242
+ * Returns a dispose function; an undisposed effect lives for the rest of the app run.
243
+ */
244
+ export function effect(fn: () => void): () => void {
245
+ const fx = new EffectImpl(fn)
246
+ rootEffects.add(fx)
247
+ fx._run()
248
+ return () => fx._dispose()
249
+ }
250
+
251
+ // ---- element bindings (internal — used by UINode/UIText for function-valued props) ----
252
+
253
+ /** The shape UINode's Element exposes for binding ownership. */
254
+ export interface BindingHost {
255
+ /** @internal */ _fx?: Map<string, EffectImpl>
256
+ }
257
+
258
+ /**
259
+ * @internal Creates (or replaces) the reactive binding stored under `key` on the element. The
260
+ * effect is owned by the element (strong ref in `_fx`), NOT by the root set — discard the element
261
+ * and the binding collects with it. Runs synchronously once.
262
+ */
263
+ export function createBinding(el: BindingHost, key: string, run: () => void): void {
264
+ const map = el._fx ?? (el._fx = new Map())
265
+ map.get(key)?._dispose()
266
+ const fx = new EffectImpl(run)
267
+ map.set(key, fx)
268
+ fx._run()
269
+ }
270
+
271
+ /** @internal Disposes the binding stored under `key`, if any (static value overriding a binding). */
272
+ export function disposeBinding(el: BindingHost, key: string): void {
273
+ const fx = el._fx?.get(key)
274
+ if (fx === undefined) return
275
+ fx._dispose()
276
+ el._fx!.delete(key)
277
+ }
@@ -3,7 +3,8 @@
3
3
  // wall/floor collision response; a short downward ray reports `grounded`.
4
4
  //
5
5
  // const hero = new Sprite({ texture, anchor: [0.5, 1] })
6
- // .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true, shape: { type: 'capsule', from:[0,6], to:[0,26], radius:6 } })
6
+ // .aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } })
7
+ // .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
7
8
  // .aspect(CharacterController2D, { speed: 200, jumpSpeed: 520 })
8
9
  // Input.on('left', d => hero.controller.move(d ? -1 : 0))
9
10
  // Input.on('jump', () => hero.controller.jump())
@@ -15,7 +16,7 @@
15
16
  import { Aspect } from "../core/Aspect"
16
17
  import { Vec2 } from "../math/vec"
17
18
  import { Physics2D } from "./Physics2D"
18
- import { Shape } from "./Shape"
19
+ import { Shape2D } from "./Shape2D"
19
20
  import type { Node2D } from "./Node2D"
20
21
 
21
22
  export class CharacterController2D extends Aspect<"controller", Node2D> {
@@ -44,8 +45,8 @@ export class CharacterController2D extends Aspect<"controller", Node2D> {
44
45
  private get phys(): Physics2D | undefined { return this.node.get(Physics2D) }
45
46
 
46
47
  onAttach(): void {
47
- // Ensure a Shape (auto box from the sprite) + a dynamic, fixed-rotation body if not already set up.
48
- if (!this.node.has(Shape)) this.node.aspect(Shape, {})
48
+ // Ensure a Shape2D (auto box from the sprite) + a dynamic, fixed-rotation body if not already set up.
49
+ if (!this.node.has(Shape2D)) this.node.aspect(Shape2D, {})
49
50
  if (!this.node.has(Physics2D)) this.node.aspect(Physics2D, { motion: "dynamic", fixedRotation: true })
50
51
  }
51
52
 
@@ -7,7 +7,7 @@
7
7
  // under physics without any per-frame bookkeeping.
8
8
  //
9
9
  // Empty Node2Ds are useful as: grouping parents, world-space markers/spawn points, and physics /
10
- // trigger objects — attach a Shape (geometry) plus a Physics2D (rigid body) or Trigger (sensor).
10
+ // trigger objects — attach a Shape2D (geometry) plus a Physics2D (rigid body) or Trigger2D (sensor).
11
11
 
12
12
  import { AspectHost } from "../core/Aspect"
13
13
  import { Registry } from "../core/registry"
@@ -1,9 +1,9 @@
1
1
  // A rigid body, as an aspect on a Node2D, backed by Box2D v3 in creator-2d. Mirrors the 3D Physics
2
- // aspect. Requires a Shape aspect on the same node (its geometry becomes the body's solid fixture).
2
+ // aspect. Requires a Shape2D aspect on the same node (its geometry becomes the body's solid fixture).
3
3
  //
4
4
  // Physics2D.configure({ gravity: [0, -980] }) // once, before creating bodies
5
- // ground.aspect(Shape, { segment: { from: [-500,0], to: [500,0] } }).aspect(Physics2D, { motion: 'static' })
6
- // hero.aspect(Shape, { box: [12, 20] }).aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
5
+ // ground.aspect(Shape2D, { segment: { from: [-500,0], to: [500,0] } }).aspect(Physics2D, { motion: 'static' })
6
+ // hero.aspect(Shape2D, { box: [12, 20] }).aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
7
7
  // hero.physics.applyImpulse([0, 400]) // jump
8
8
  // hero.addEventListener('enter', other => …) // contact / sensor overlap began
9
9
  //
@@ -15,7 +15,7 @@
15
15
  import { Aspect } from "../core/Aspect"
16
16
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
17
17
  import { node2dRegistry, type Node2D } from "./Node2D"
18
- import { Shape } from "./Shape"
18
+ import { Shape2D } from "./Shape2D"
19
19
 
20
20
  export type MotionType = "static" | "kinematic" | "dynamic"
21
21
  const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
@@ -50,20 +50,20 @@ export class Physics2D extends Aspect<"physics", Node2D> {
50
50
  restitution = 0
51
51
  /** Lock rotation (essential for platformer characters). */
52
52
  fixedRotation = false
53
- gravityScale = 1
54
53
  /** Continuous collision for fast-moving bodies (projectiles). */
55
54
  bullet = false
56
55
 
57
- private _h = 0 // native body handle (0 = none / no physics support)
56
+ private _gravityScale = 1
57
+ private _h = 0 // native body id (0 = none / no physics support)
58
58
 
59
- /** Native body handle. 0 until attached, or if the build has no physics support. */
60
- get handle(): number { return this._h }
59
+ /** Native physics body id. 0 until attached, or if the build has no physics support. Mirrors 3D `Physics.id`. */
60
+ get id(): number { return this._h }
61
61
 
62
62
  onAttach(): void {
63
63
  if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
64
- const shape = this.node.get(Shape)
64
+ const shape = this.node.get(Shape2D)
65
65
  if (!shape) {
66
- throw new Error("Physics2D requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics2D, {…})")
66
+ throw new Error("Physics2D requires a Shape2D aspect — add it first: node.aspect(Shape2D, {…}).aspect(Physics2D, {…})")
67
67
  }
68
68
  this._h = _creator2d.physicsCreateBody(this.node.id, MOTION[this.motion])
69
69
  if (!this._h) return
@@ -72,7 +72,7 @@ export class Physics2D extends Aspect<"physics", Node2D> {
72
72
  // cache from native on read. Static bodies never move, so they keep the free cache.
73
73
  if (this.motion !== "static") this.node._physicsDriven = true
74
74
  if (this.fixedRotation) _creator2d.physicsSetFixedRotation(this._h, true)
75
- if (this.gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
75
+ if (this._gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this._gravityScale)
76
76
  if (this.bullet) _creator2d.physicsSetBullet(this._h, true)
77
77
  }
78
78
 
@@ -91,7 +91,10 @@ export class Physics2D extends Aspect<"physics", Node2D> {
91
91
  /** Angular velocity in degrees/second. */
92
92
  set angularVelocity(degPerSec: number) { if (this._h) _creator2d.physicsSetAngularVelocity(this._h, degPerSec) }
93
93
 
94
- set gravityFactor(scale: number) { this.gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }
94
+ /** Per-body gravity multiplier: 1 = full world gravity, 0 = floats, <0 = repelled. Set at attach
95
+ * time (as an option) or live. */
96
+ get gravityScale(): number { return this._gravityScale }
97
+ set gravityScale(scale: number) { this._gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }
95
98
  set linearDamping(d: number) { if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }
96
99
  set enabled(v: boolean) { if (this._h) _creator2d.physicsSetEnabled(this._h, v) }
97
100
 
@@ -112,6 +115,13 @@ export class Physics2D extends Aspect<"physics", Node2D> {
112
115
  /** Whether this build has physics support (CREATOR_2D_PHYSICS). */
113
116
  static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }
114
117
 
118
+ /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
119
+ * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
120
+ * smoothness — bodies then advance in discrete steps. */
121
+ private static _interpolation = true
122
+ static get interpolation(): boolean { return Physics2D._interpolation }
123
+ static set interpolation(on: boolean) { Physics2D._interpolation = on; _creator2d.setInterpolation(on) }
124
+
115
125
  /** Closest body hit by the segment from→to, or null. `node` resolves the hit entity. */
116
126
  static raycast(from: Vec2Like, to: Vec2Like): RayHit | null {
117
127
  const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to))