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
@@ -2,15 +2,38 @@
2
2
  // 2D engine and starts its RAF loop; only the active scene renders. Grown from the Phase 0 seed.
3
3
 
4
4
  import { Color, type ColorInput } from "../core/color"
5
+ import { _installAspectFrames } from "../core/Aspect"
5
6
  import { cx, cy, type Vec2Like } from "../math/vec"
6
7
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
7
8
  import { Camera2D } from "./Camera2D"
8
9
  import { node2dRegistry, type Node2D } from "./Node2D"
9
10
  import { ensurePointerEvents } from "./touch"
10
11
 
12
+ // Route aspect update(dt) through the render-synced native phases (c2dRender early = before physics,
13
+ // late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
14
+ // the 2D layer (not core) so a 3D-only bundle never pulls in the 2D bridge. Installed once, lazily on
15
+ // the first Scene2D. On an older wasm without the hooks the installer returns false → setLoop fallback.
16
+ let framesInstalled = false
17
+ const installRenderSyncedFrames = (): void => {
18
+ if (framesInstalled) return
19
+ framesInstalled = true
20
+ _installAspectFrames((early, late) => {
21
+ if (typeof _creator2d.setEarlyUpdate !== "function") return false
22
+ _creator2d.setEarlyUpdate(early)
23
+ _creator2d.setLateUpdate(late)
24
+ return true
25
+ })
26
+ }
27
+
11
28
  export type Scene2DOptions = {
12
29
  /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
13
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'
14
37
  }
15
38
 
16
39
  /** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
@@ -37,9 +60,14 @@ export class Scene2D {
37
60
  readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
38
61
  readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
39
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
+
40
66
  constructor(options: Scene2DOptions = {}) {
67
+ installRenderSyncedFrames()
41
68
  this.id = _creator2d.createScene()
42
69
  this.camera = new Camera2D(this.id)
70
+ this._filter = options.filter
43
71
  if (options.background !== undefined) this.background = options.background
44
72
  }
45
73
 
@@ -85,6 +113,8 @@ export class Scene2D {
85
113
 
86
114
  /** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
87
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')
88
118
  _creator2d.openScene(this.id)
89
119
  Scene2D._active = this
90
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
  */
@@ -4,11 +4,15 @@
4
4
 
5
5
  import { Color, type ColorInput } from "../core/color"
6
6
  import { Vec2, type Vec2Like } from "../math/vec"
7
+ import { Canvas, isCanvas } from "../canvas/Canvas"
7
8
  import { Node2D } from "./Node2D"
8
9
  import type { Texture2D } from "./Texture2D"
9
10
 
11
+ /** Anything that can back a sprite: a texture, or a Canvas (baked to a texture on assign). */
12
+ export type TextureSource = Texture2D | Canvas
13
+
10
14
  export type SpriteOptions = {
11
- texture?: Texture2D
15
+ texture?: TextureSource
12
16
  /** Pivot, [0,0] = top-left .. [1,1] = bottom-right. Use [0.5, 1] (feet) for Y-sorted depth. */
13
17
  anchor?: Vec2Like
14
18
  /** World size; defaults to the texture's pixel dimensions. */
@@ -44,14 +48,20 @@ export class Sprite extends Node2D {
44
48
  }
45
49
 
46
50
  get texture(): Texture2D | undefined { return this._texture }
47
- set texture(t: Texture2D | undefined) {
48
- this._texture = t
49
- if (!t) return
50
- _creator2d.setSprite(this.id, t.id) // native resets UV + size to the full texture
51
+ set texture(t: TextureSource | undefined) {
52
+ const tex = isCanvas(t) ? t.texture() : t // a Canvas bakes to a texture on assign
53
+ this._texture = tex
54
+ if (!tex) return
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)
51
60
  if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
52
61
  }
53
62
 
54
- /** 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). */
55
65
  get size(): Vec2 { return this._size ?? (this._texture ? new Vec2(this._texture.width, this._texture.height) : new Vec2(0, 0)) }
56
66
  set size(v: Vec2Like) { this._size = new Vec2(v); _creator2d.setSpriteSize(this.id, this._size.x, this._size.y) }
57
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. */
@@ -17,7 +17,17 @@ export class Texture2D {
17
17
  this.height = height
18
18
  }
19
19
 
20
- static load(source: string | FetchResponse): Promise<Texture2D> {
20
+ // Wrap a Canvas's baked surface as a texture (RGBA8, already rasterized → synchronous, no decode).
21
+ // Structural type avoids a Canvas import cycle (Canvas imports Texture2D).
22
+ static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number }): Texture2D {
23
+ const surfaceId = canvas._bakeSurface()
24
+ const texId = _creator2d.createTextureFromCanvas(surfaceId)
25
+ return new Texture2D(texId, Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio))
26
+ }
27
+
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> {
21
31
  if (typeof source === "string") {
22
32
  return fetch(source, { useOnce: true }).then((resp) => {
23
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
+ }
@@ -6,6 +6,12 @@
6
6
  import { Color, type ColorInput } from "../core/color"
7
7
  import { fetch, type FetchResponse } from "../runtime/fetch"
8
8
  import { Texture } from "./Texture"
9
+ // Type-only (erased at build) so a 3D material never pulls the 2D-canvas runtime; the guard is inline.
10
+ import type { Canvas } from "../canvas/Canvas"
11
+
12
+ /** Duck-type a Canvas without importing it (keeps 3D material free of 2D-canvas code). */
13
+ const isCanvasSource = (v: unknown): v is Canvas =>
14
+ typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
9
15
 
10
16
  type UniformValue = string | Texture | number | boolean | number[] | Float32Array | null
11
17
 
@@ -28,7 +34,7 @@ const applyUniform = (id: number, key: string, value: UniformValue): void => {
28
34
  }
29
35
  }
30
36
 
31
- export type MaterialColorOptions = { color?: ColorInput, map?: Texture | null }
37
+ export type MaterialColorOptions = { color?: ColorInput, map?: Texture | Canvas | null }
32
38
 
33
39
  export class Material {
34
40
  /** @internal native material-instance handle. */
@@ -65,11 +71,12 @@ export class Material {
65
71
  if (this._colorKey) this.uniforms[this._colorKey] = Color.toHexString(c)
66
72
  }
67
73
 
68
- set map(value: Texture | null) {
74
+ set map(value: Texture | Canvas | null) {
69
75
  if (value === null) {
70
76
  if (this._useMapFlag) this.uniforms.useBaseColorMap = false
71
77
  } else {
72
- this.uniforms.baseColorMap = value
78
+ // A Canvas bakes to a (cached) 3D texture on assign — same as Sprite accepting a Canvas in 2D.
79
+ this.uniforms.baseColorMap = isCanvasSource(value) ? Texture.fromCanvas(value) : value
73
80
  if (this._useMapFlag) this.uniforms.useBaseColorMap = true
74
81
  }
75
82
  }
@@ -2,7 +2,7 @@
2
2
  // on them. Native (creator-gl) owns the authoritative transform; reads decompose a cached local
3
3
  // matrix (re-synced lazily, since physics can write the transform from native each frame).
4
4
  //
5
- // Capabilities are aspects: node.aspect(Physics, …) (→ node.physics), node.aspect(Collider, …).
5
+ // Capabilities are aspects: node.aspect(Physics, …) (→ node.physics), node.aspect(Shape, …).
6
6
  // A GLB import is the Model node kind (with the ModelAnimation aspect). Hierarchy lives here.
7
7
 
8
8
  import { AspectHost } from "../core/Aspect"
@@ -12,6 +12,7 @@ import { Quat, cw, type QuatLike } from "../math/quat"
12
12
  import { Mat4, type Mat4Like } from "../math/mat4"
13
13
  import type { Geometry } from "./Geometry"
14
14
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
15
+ import { ensurePhysicsEvents } from "./physicsEvents"
15
16
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
16
17
 
17
18
  /** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
@@ -28,6 +29,10 @@ export type NodeEvents = {
28
29
  track: () => void
29
30
  /** AR anchors only: the anchor lost tracking. */
30
31
  untrack: () => void
32
+ /** A physics contact/trigger overlap began (the other body's node). Needs a Shape + Physics/Trigger. */
33
+ enter: (other: Node) => void
34
+ /** A physics contact/trigger overlap ended. */
35
+ exit: (other: Node) => void
31
36
  }
32
37
 
33
38
  export class Node extends AspectHost<NodeEvents> {
@@ -171,7 +176,7 @@ export class Node extends AspectHost<NodeEvents> {
171
176
  _creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
172
177
  }
173
178
 
174
- // --- behaviors are aspects: node.aspect(Physics, …) / node.aspect(Collider, …) ---
179
+ // --- behaviors are aspects: node.aspect(Shape, …) / node.aspect(Physics, …) ---
175
180
 
176
181
  /** Orient the node so the given axis (default "-z", "forward") points at a world point. */
177
182
  lookAt(point: Vec3Like, mode: "z" | "-z" | "x" | "-x" | "y" | "-y" = "-z", ortho: Vec3Like = [ 0, 1, 0 ]): this {
@@ -207,6 +212,7 @@ export class Node extends AspectHost<NodeEvents> {
207
212
  override addEventListener<K extends keyof NodeEvents>(channel: K, callback: NodeEvents[K]): void {
208
213
  if (channel === "click") registerTouchEndEvent()
209
214
  else if (channel === "touchstart") registerTouchStartEvent()
215
+ else if (channel === "enter" || channel === "exit") ensurePhysicsEvents()
210
216
  super.addEventListener(channel, callback)
211
217
  }
212
218
 
@@ -215,6 +221,9 @@ export class Node extends AspectHost<NodeEvents> {
215
221
  this.dispatch(channel, ...args)
216
222
  }
217
223
 
224
+ /** @internal — emit a physics contact/trigger event (the physics-event router can't reach dispatch). */
225
+ _emitCollision(channel: "enter" | "exit", other: Node): void { this.dispatch(channel, other) }
226
+
218
227
  /** @internal — host animation-complete callback; forwards to the ModelAnimation aspect (if any).
219
228
  * Returns whether the clip should keep playing. */
220
229
  _onAnimationComplete(clipIndex: number): boolean {
@@ -1,47 +1,70 @@
1
- // Physics body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl.
2
- // node.aspect(Physics, { motion: 'dynamic' }); node.physics.applyImpulse([0, 6, 0])
1
+ // Rigid body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl. Requires a Shape
2
+ // aspect on the same node (its geometry becomes the body's collision shape).
3
3
  //
4
- // For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it
5
- // via velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic
6
- // bodies are driven with moveTo. (Ported from the former Body class.)
4
+ // Physics.configure({ gravity: [0, -9.81, 0] }) // once, before creating bodies
5
+ // ground.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' })
6
+ // crate.aspect(Shape, {}).aspect(Physics, { mass: 2 }) // auto box from the mesh
7
+ // crate.physics.applyImpulse([0, 6, 0])
8
+ // crate.addEventListener('enter', other => …) // contact / trigger overlap began
9
+ //
10
+ // For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it via
11
+ // velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic bodies
12
+ // are driven with moveTo.
7
13
 
8
14
  import { Aspect } from "../core/Aspect"
9
15
  import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
10
- import type { Node } from "./Node"
16
+ import { nodeRegistry, type Node } from "./Node"
17
+ import { Shape } from "./Shape"
11
18
 
12
19
  export type MotionType = "static" | "dynamic" | "kinematic"
13
20
  const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
14
21
 
15
- export type BodyShape =
16
- | { type: "auto" }
17
- | { type: "box", halfExtents: Vec3Like }
18
- | { type: "sphere", radius: number }
19
- | { type: "cylinder", halfHeight: number, radius: number }
22
+ export interface PhysicsConfig {
23
+ /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
24
+ gravity?: Vec3Like
25
+ /** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
26
+ maxBodies?: number
27
+ }
28
+
29
+ /** Closest raycast hit. */
30
+ export interface RayHit {
31
+ node: Node | null
32
+ point: Vec3
33
+ normal: Vec3
34
+ fraction: number
35
+ }
20
36
 
21
37
  export class Physics extends Aspect<"physics", Node> {
22
38
  static readonly aspect = "physics"
23
39
 
40
+ /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
41
+ * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
42
+ * smoothness — bodies then advance in discrete steps. */
43
+ private static _interpolation = true
44
+ static get interpolation(): boolean { return Physics._interpolation }
45
+ static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }
46
+
24
47
  /** Default "dynamic". */
25
48
  motion: MotionType = "dynamic"
26
49
  /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
27
50
  mass = 1
28
- /** Default { type: "auto" } — a box from the mesh AABB × world scale. */
29
- shape: BodyShape = { type: "auto" }
30
51
 
31
52
  private _bodyId = 0
32
53
 
33
54
  onAttach(): void {
34
- // No physics in this build (CREATOR_GL_PHYSICS=OFF) → bodyId stays 0; all methods no-op.
35
55
  if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
36
- const motion = MOTION[this.motion]
37
- const shape = resolveShape(this.node, this.shape)
38
- if (shape.type === "sphere") {
39
- this._bodyId = _creator.physicsCreateSphere(this.node.id, shape.radius, motion, this.mass)
40
- } else if (shape.type === "cylinder") {
41
- this._bodyId = _creator.physicsCreateCylinder(this.node.id, shape.halfHeight, shape.radius, motion, this.mass)
42
- } else {
43
- this._bodyId = _creator.physicsCreateBox(this.node.id, cx(shape.halfExtents), cy(shape.halfExtents), cz(shape.halfExtents), motion, this.mass)
56
+ const shape = this.node.get(Shape)
57
+ if (!shape) {
58
+ throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
44
59
  }
60
+ const shapeId = shape._claim() // drop the pick-only body; reuse its shape
61
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
62
+ shape._ownBody(this._bodyId)
63
+ }
64
+
65
+ onDetach(): void {
66
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
67
+ this.node.get(Shape)?._recreatePickBody() // keep the node pickable if the Shape stays
45
68
  }
46
69
 
47
70
  /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
@@ -69,27 +92,28 @@ export class Physics extends Aspect<"physics", Node> {
69
92
  return this
70
93
  }
71
94
 
72
- onDetach(): void {
73
- if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
95
+ // --- world (static) -------------------------------------------------------------------------
96
+ /** Configure gravity / limits. Call once before creating bodies. */
97
+ static configure(config: PhysicsConfig = {}): void {
98
+ const g = config.gravity ?? [0, -9.81, 0]
99
+ _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
100
+ }
101
+
102
+ /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
103
+ static get supported(): boolean {
104
+ return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
74
105
  }
75
- }
76
106
 
77
- // Resolve "auto" to a box from the mesh AABB × world scale (so the physical box matches the rendered
78
- // size). Falls back to a unit box if there is no geometry.
79
- const resolveShape = (node: Node, shape: BodyShape): Exclude<BodyShape, { type: "auto" }> => {
80
- if (shape.type !== "auto") return shape
81
- const scale = node.worldScale
82
- let hx = 0.5, hy = 0.5, hz = 0.5
83
- const geom = node.geometry
84
- if (geom) {
85
- const v = geom.vertices
86
- let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
87
- for (let i = 0; i + 2 < v.length; i += 3) {
88
- if (v[i] < minX) minX = v[i]; if (v[i] > maxX) maxX = v[i]
89
- if (v[i + 1] < minY) minY = v[i + 1]; if (v[i + 1] > maxY) maxY = v[i + 1]
90
- if (v[i + 2] < minZ) minZ = v[i + 2]; if (v[i + 2] > maxZ) maxZ = v[i + 2]
107
+ /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
108
+ static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
109
+ const out = new Float32Array(7)
110
+ const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
111
+ if (!id) return null
112
+ return {
113
+ node: nodeRegistry.get(id) ?? null,
114
+ point: new Vec3(out[0], out[1], out[2]),
115
+ normal: new Vec3(out[3], out[4], out[5]),
116
+ fraction: out[6],
91
117
  }
92
- hx = (maxX - minX) * 0.5; hy = (maxY - minY) * 0.5; hz = (maxZ - minZ) * 0.5
93
118
  }
94
- return { type: "box", halfExtents: [ hx * scale.x, hy * scale.y, hz * scale.z ] }
95
119
  }
@@ -6,6 +6,7 @@
6
6
  // direct light + shadows.
7
7
 
8
8
  import { Color, type ColorInput } from "../core/color"
9
+ import { _installAspectFrames } from "../core/Aspect"
9
10
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
10
11
  import type { FetchResponse } from "../runtime/fetch"
11
12
  import { Camera } from "./Camera"
@@ -29,6 +30,22 @@ export type SceneOptions = {
29
30
  antialias?: boolean
30
31
  }
31
32
 
33
+ // Route aspect update(dt) through the render-synced native phases (render() early = before physics,
34
+ // late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
35
+ // the 3D layer (not core) so a 2D-only bundle never pulls in the 3D bridge. Installed once, lazily on
36
+ // the first Scene. On an older wasm without the hooks the installer returns false → setLoop fallback.
37
+ let framesInstalled = false
38
+ const installRenderSyncedFrames = (): void => {
39
+ if (framesInstalled) return
40
+ framesInstalled = true
41
+ _installAspectFrames((early, late) => {
42
+ if (typeof _creator.setEarlyUpdate !== "function") return false
43
+ _creator.setEarlyUpdate(early)
44
+ _creator.setLateUpdate(late)
45
+ return true
46
+ })
47
+ }
48
+
32
49
  export class Scene {
33
50
  /** @internal native scene handle. */
34
51
  readonly _id: number
@@ -44,6 +61,7 @@ export class Scene {
44
61
  static get active(): Scene | null { return Scene._active }
45
62
 
46
63
  constructor(options: SceneOptions = {}) {
64
+ installRenderSyncedFrames()
47
65
  const parent = (options as any)._parent as Scene | undefined
48
66
  if (parent) {
49
67
  this.camera = parent.camera