lecodes-cli 0.3.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 +86 -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 +2 -2
  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 +12 -0
  18. package/runtime/sdk/g2/{Shape.ts → Shape2D.ts} +10 -10
  19. package/runtime/sdk/g2/Sprite.ts +7 -2
  20. package/runtime/sdk/g2/Texture2D.ts +4 -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/Node.ts +11 -2
  24. package/runtime/sdk/gl/Physics.ts +65 -41
  25. package/runtime/sdk/gl/Scene.ts +18 -0
  26. package/runtime/sdk/gl/Shape.ts +98 -0
  27. package/runtime/sdk/gl/Texture.ts +2 -2
  28. package/runtime/sdk/gl/Trigger.ts +46 -0
  29. package/runtime/sdk/gl/physicsEvents.ts +20 -0
  30. package/runtime/sdk/inject.ts +12 -5
  31. package/runtime/sdk/runtime/camera.ts +31 -0
  32. package/runtime/sdk/runtime/device.ts +26 -0
  33. package/runtime/sdk/runtime/share.ts +9 -0
  34. package/runtime/sdk/ui/UI.ts +5 -1
  35. package/runtime/sdk/ui/UIButton.ts +11 -5
  36. package/runtime/sdk/ui/UIContainer.ts +28 -13
  37. package/runtime/sdk/ui/UIImage.ts +5 -0
  38. package/runtime/sdk/ui/UIInput.ts +10 -0
  39. package/runtime/sdk/ui/UINode.ts +206 -8
  40. package/runtime/sdk/ui/UIScreen.ts +10 -5
  41. package/runtime/sdk/ui/UIScrollable.ts +10 -5
  42. package/runtime/sdk/ui/UISpacer.ts +4 -0
  43. package/runtime/sdk/ui/UIText.ts +21 -5
  44. package/runtime/sdk/ui/UIVideo.ts +5 -0
  45. package/runtime/sdk/ui/UIVirtualizedList.ts +5 -0
  46. package/runtime/sdk/ui/UIWidget.ts +10 -5
  47. package/runtime/sdk/canvas/Label.ts +0 -57
  48. package/runtime/sdk/gl/Collider.ts +0 -29
@@ -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))
@@ -28,6 +28,12 @@ const installRenderSyncedFrames = (): void => {
28
28
  export type Scene2DOptions = {
29
29
  /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
30
30
  background?: ColorInput
31
+ /** Texture sampling for the 2D renderer: `'nearest'` (default — crisp pixel art) or `'linear'`
32
+ * (smooth — for hi-res / non-pixel-art assets). Applied when the scene is opened, so switching
33
+ * scenes restores the active scene's choice. NOTE: this is a GLOBAL renderer setting (one sampler
34
+ * for ALL textures, not per-texture), so the currently-open scene's filter wins. Omit to leave the
35
+ * current setting (engine default is nearest). */
36
+ filter?: 'nearest' | 'linear'
31
37
  }
32
38
 
33
39
  /** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
@@ -54,10 +60,14 @@ export class Scene2D {
54
60
  readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
55
61
  readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
56
62
 
63
+ /** Texture filter to (re)assert on open(); the sampler is global, so each scene reclaims it. */
64
+ private readonly _filter?: 'nearest' | 'linear'
65
+
57
66
  constructor(options: Scene2DOptions = {}) {
58
67
  installRenderSyncedFrames()
59
68
  this.id = _creator2d.createScene()
60
69
  this.camera = new Camera2D(this.id)
70
+ this._filter = options.filter
61
71
  if (options.background !== undefined) this.background = options.background
62
72
  }
63
73
 
@@ -103,6 +113,8 @@ export class Scene2D {
103
113
 
104
114
  /** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
105
115
  open(): this {
116
+ // Reassert this scene's texture filter (the sampler is global — see Scene2DOptions.filter).
117
+ if (this._filter !== undefined) _creator2d.setDefaultFilter(this._filter === 'linear')
106
118
  _creator2d.openScene(this.id)
107
119
  Scene2D._active = this
108
120
  return this
@@ -1,19 +1,19 @@
1
- // A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a Shape does not by itself
2
- // make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a Trigger (sensor), both
3
- // of which require a Shape and turn its geometry into a Box2D fixture. The shape is also what pointer
4
- // picking hits, so a node is clickable once it has a Shape + Physics2D/Trigger (a body to query).
1
+ // A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a Shape2D does not by itself
2
+ // make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a Trigger2D (sensor), both
3
+ // of which require a Shape2D and turn its geometry into a Box2D fixture. The shape is also what pointer
4
+ // picking hits, so a node is clickable once it has a Shape2D + Physics2D/Trigger2D (a body to query).
5
5
  //
6
- // wall.aspect(Shape, { box: [200, 16] }).aspect(Physics2D, { motion: 'static' })
7
- // goal.aspect(Shape, { circle: 20 }).aspect(Trigger)
8
- // hero.aspect(Shape, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
6
+ // wall.aspect(Shape2D, { box: [200, 16] }).aspect(Physics2D, { motion: 'static' })
7
+ // goal.aspect(Shape2D, { circle: 20 }).aspect(Trigger2D)
8
+ // hero.aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
9
9
  //
10
- // With no geometry set, Shape derives a box from the node's sprite size ("auto").
10
+ // With no geometry set, Shape2D derives a box from the node's sprite size ("auto").
11
11
 
12
12
  import { Aspect } from "../core/Aspect"
13
13
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
14
14
  import type { Node2D } from "./Node2D"
15
15
 
16
- export class Shape extends Aspect<"shape", Node2D> {
16
+ export class Shape2D extends Aspect<"shape", Node2D> {
17
17
  static readonly aspect = "shape"
18
18
 
19
19
  /** Box half-extents [hw, hh] (world units). */
@@ -30,7 +30,7 @@ export class Shape extends Aspect<"shape", Node2D> {
30
30
  offset?: Vec2Like
31
31
 
32
32
  /**
33
- * @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/Trigger).
33
+ * @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/Trigger2D).
34
34
  * `sensor` selects a sensor (trigger) vs solid fixture; density/friction/restitution are the
35
35
  * material (ignored for sensors / segments only use friction+restitution).
36
36
  */
@@ -52,11 +52,16 @@ export class Sprite extends Node2D {
52
52
  const tex = isCanvas(t) ? t.texture() : t // a Canvas bakes to a texture on assign
53
53
  this._texture = tex
54
54
  if (!tex) return
55
- _creator2d.setSprite(this.id, tex.id) // native resets UV + size to the full texture
55
+ _creator2d.setSprite(this.id, tex.id) // native resets UV + size to the full (pixel) texture
56
+ // A Canvas is authored in LOGICAL units but baked at logical × pixelRatio, so default the sprite's
57
+ // WORLD size to the canvas's LOGICAL size — pixelRatio then only affects crispness, not on-screen
58
+ // size. (A plain texture keeps the native default: the texture's pixel dimensions.)
59
+ if (!this._size && isCanvas(t)) this._size = new Vec2(t.width, t.height)
56
60
  if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
57
61
  }
58
62
 
59
- /** Current world size: explicit override if set, else the texture's pixel dimensions. */
63
+ /** Current world size: explicit override if set, else the texture's pixel dimensions (a Canvas-backed
64
+ * sprite defaults to the canvas's LOGICAL size, so pixelRatio is crispness only, not size). */
60
65
  get size(): Vec2 { return this._size ?? (this._texture ? new Vec2(this._texture.width, this._texture.height) : new Vec2(0, 0)) }
61
66
  set size(v: Vec2Like) { this._size = new Vec2(v); _creator2d.setSpriteSize(this.id, this._size.x, this._size.y) }
62
67
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  // Internal SDK code imports injected globals explicitly (the bare-global inject is only for USER
5
5
  // code; here `fetch` would otherwise resolve to the node/bun ambient one).
6
- import { fetch, type FetchResponse } from "../runtime/fetch"
6
+ import { fetch, type FetchResponse, type File } from "../runtime/fetch"
7
7
 
8
8
  export class Texture2D {
9
9
  /** Native texture handle. */
@@ -25,7 +25,9 @@ export class Texture2D {
25
25
  return new Texture2D(texId, Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio))
26
26
  }
27
27
 
28
- static load(source: string | FetchResponse): Promise<Texture2D> {
28
+ // Accepts a URL, an already-fetched response, or a File (e.g. from Canvas.toFile() / openFilePicker) —
29
+ // anything backed by a host buffer. The buffer's encoded bytes are decoded + uploaded by the engine.
30
+ static load(source: string | FetchResponse | File): Promise<Texture2D> {
29
31
  if (typeof source === "string") {
30
32
  return fetch(source, { useOnce: true }).then((resp) => {
31
33
  if (resp.status >= 400) {
@@ -1,10 +1,10 @@
1
- // A trigger zone, as an aspect on a Node2D. Requires a Shape (its geometry); creates a static SENSOR
1
+ // A trigger zone, as an aspect on a Node2D. Requires a Shape2D (its geometry); creates a static SENSOR
2
2
  // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
3
  // instead of colliding. A trigger does not block movement.
4
4
  //
5
5
  // const goal = new Node2D({ ... })
6
- // .aspect(Shape, { box: [32, 64] })
7
- // .aspect(Trigger)
6
+ // .aspect(Shape2D, { box: [32, 64] })
7
+ // .aspect(Trigger2D)
8
8
  // goal.addEventListener('enter', other => win(other))
9
9
  // goal.addEventListener('exit', other => ...)
10
10
  //
@@ -13,21 +13,21 @@
13
13
  import { Aspect } from "../core/Aspect"
14
14
  import { cx, cy, type Vec2Like } from "../math/vec"
15
15
  import { ensurePhysicsEvents } from "./loop"
16
- import { Shape } from "./Shape"
16
+ import { Shape2D } from "./Shape2D"
17
17
  import type { Node2D } from "./Node2D"
18
18
 
19
- export class Trigger extends Aspect<"trigger", Node2D> {
19
+ export class Trigger2D extends Aspect<"trigger", Node2D> {
20
20
  static readonly aspect = "trigger"
21
21
 
22
22
  private _h = 0
23
- /** Native body handle (0 if no physics support). */
24
- get handle(): number { return this._h }
23
+ /** Native physics body id (0 if no physics support). Mirrors 3D `Trigger.id`. */
24
+ get id(): number { return this._h }
25
25
 
26
26
  onAttach(): void {
27
27
  if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
28
- const shape = this.node.get(Shape)
28
+ const shape = this.node.get(Shape2D)
29
29
  if (!shape) {
30
- throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
30
+ throw new Error("Trigger2D requires a Shape2D aspect — add it first: node.aspect(Shape2D, {…}).aspect(Trigger2D)")
31
31
  }
32
32
  this._h = _creator2d.physicsCreateBody(this.node.id, 0 /* static */)
33
33
  if (!this._h) return
@@ -0,0 +1,101 @@
1
+ // A character controller, as an aspect on a 3D Node, backed by Jolt's CharacterVirtual — a kinematic
2
+ // collide-and-slide controller (NOT a rigid body: no bouncing/tipping, crisp control, built-in stair
3
+ // stepping + slope handling). Requires a Shape (a capsule is recommended) and does NOT use Physics.
4
+ //
5
+ // const hero = new Mesh(capsuleGeometry(), material)
6
+ // .aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
7
+ // .aspect(CharacterController, { speed: 6, jumpSpeed: 8 })
8
+ // Input.on('move', (x, z) => hero.controller.move(x, z))
9
+ // Input.on('jump', () => hero.controller.jump())
10
+ //
11
+ // You drive horizontal intent + jump; the controller integrates gravity and Jolt handles collision
12
+ // against the world. `grounded` reports whether it's on walkable ground. It runs in the EARLY update
13
+ // phase (feeds this frame's step). Note: it collides with solid bodies but PASSES THROUGH triggers,
14
+ // and (v1) is not itself detected by triggers and isn't pointer-pickable while active.
15
+
16
+ import { Aspect } from "../core/Aspect"
17
+ import { Vec3 } from "../math/vec"
18
+ import { Shape } from "./Shape"
19
+ import type { Node } from "./Node"
20
+
21
+ const clamp1 = (v: number): number => (v < -1 ? -1 : v > 1 ? 1 : v)
22
+
23
+ export class CharacterController extends Aspect<"controller", Node> {
24
+ static readonly aspect = "controller"
25
+
26
+ /** Horizontal move speed (world units/s). */
27
+ speed = 5
28
+ /** Jump take-off speed (world units/s). */
29
+ jumpSpeed = 7
30
+ /** Character-tuned gravity (world units/s²), separate from the world gravity — usually stronger for
31
+ * snappier feel. Applied to the vertical velocity each frame. */
32
+ gravity = -20
33
+ /** Max ground slope (degrees) the character treats as walkable. Set before attach. */
34
+ maxSlope = 45
35
+
36
+ updateBeforePhysics = true // EARLY phase — set velocity so THIS frame's step consumes it
37
+
38
+ private _charId = 0
39
+ private _mx = 0
40
+ private _mz = 0
41
+ private _jumpQueued = false
42
+ private _vy = 0
43
+
44
+ onAttach(): void {
45
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
46
+ const shape = this.node.get(Shape)
47
+ if (!shape) {
48
+ throw new Error("CharacterController requires a Shape aspect (a capsule is recommended) — add it first: node.aspect(Shape, { capsule: {…} }).aspect(CharacterController)")
49
+ }
50
+ const shapeId = shape._claim() // the character owns the shape; it is not a rigid body
51
+ this._charId = _creator.characterCreate(this.node.id, shapeId, this.maxSlope)
52
+ }
53
+
54
+ onDetach(): void {
55
+ if (this._charId) { _creator.characterDestroy(this._charId); this._charId = 0 }
56
+ this.node.get(Shape)?._recreatePickBody()
57
+ }
58
+
59
+ /** Native character id (0 until attached / no physics support). */
60
+ get id(): number { return this._charId }
61
+
62
+ /** Horizontal move intent; each component in [-1, 1] (e.g. from input). Sticky — set 0 to stop. */
63
+ move(x: number, z: number): void { this._mx = clamp1(x); this._mz = clamp1(z) }
64
+
65
+ /** Queue a jump; consumed on the next frame if grounded. */
66
+ jump(): void { this._jumpQueued = true }
67
+
68
+ /** True while standing on walkable ground (OnGround). */
69
+ get grounded(): boolean {
70
+ return this._charId ? _creator.characterGetGroundState(this._charId) === 0 : false
71
+ }
72
+
73
+ /** Ground state: 0 on-ground, 1 on-steep-slope, 2 touching-but-unsupported, 3 in-air. */
74
+ get groundState(): number {
75
+ return this._charId ? _creator.characterGetGroundState(this._charId) : 3
76
+ }
77
+
78
+ /** Current velocity (world units/s, fresh Vec3). */
79
+ get velocity(): Vec3 {
80
+ const o = new Float32Array(3)
81
+ if (this._charId) _creator.characterGetVelocity(this._charId, o)
82
+ return new Vec3(o[0], o[1], o[2])
83
+ }
84
+
85
+ /** Teleport to a world position (clears vertical velocity). */
86
+ teleport(x: number, y: number, z: number): this {
87
+ if (this._charId) _creator.characterSetPosition(this._charId, x, y, z)
88
+ this._vy = 0
89
+ return this
90
+ }
91
+
92
+ update(dt: number): void {
93
+ if (!this._charId) return
94
+ const grounded = _creator.characterGetGroundState(this._charId) === 0
95
+ if (grounded && this._vy < 0) this._vy = 0 // landed
96
+ if (this._jumpQueued && grounded) this._vy = this.jumpSpeed
97
+ this._jumpQueued = false
98
+ this._vy += this.gravity * dt // integrate gravity (CharacterVirtual is kinematic)
99
+ _creator.characterSetVelocity(this._charId, this._mx * this.speed, this._vy, this._mz * this.speed)
100
+ }
101
+ }