lecodes-cli 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/index.js +166 -14
  2. package/package.json +3 -2
  3. package/runtime/sdk/animate/animate.ts +238 -0
  4. package/runtime/sdk/animate/bezier.ts +138 -0
  5. package/runtime/sdk/animate/easings.ts +126 -0
  6. package/runtime/sdk/core/color.ts +66 -0
  7. package/runtime/sdk/core/events.ts +38 -0
  8. package/runtime/sdk/core/registry.ts +23 -0
  9. package/runtime/sdk/g2/Camera2D.ts +73 -0
  10. package/runtime/sdk/g2/Node2D.ts +104 -0
  11. package/runtime/sdk/g2/Scene2D.ts +73 -0
  12. package/runtime/sdk/g2/Sprite.ts +164 -0
  13. package/runtime/sdk/g2/Texture2D.ts +37 -0
  14. package/runtime/sdk/g2/Tilemap.ts +41 -0
  15. package/runtime/sdk/g2/loop.ts +22 -0
  16. package/runtime/sdk/gl/Body.ts +101 -0
  17. package/runtime/sdk/gl/Camera.ts +40 -0
  18. package/runtime/sdk/gl/Geometry.ts +218 -0
  19. package/runtime/sdk/gl/Light.ts +37 -0
  20. package/runtime/sdk/gl/Material.ts +124 -0
  21. package/runtime/sdk/gl/Mesh.ts +123 -0
  22. package/runtime/sdk/gl/Node.ts +307 -0
  23. package/runtime/sdk/gl/Noise.ts +30 -0
  24. package/runtime/sdk/gl/Particles.ts +185 -0
  25. package/runtime/sdk/gl/Plane.ts +53 -0
  26. package/runtime/sdk/gl/Ray.ts +16 -0
  27. package/runtime/sdk/gl/Scene.ts +179 -0
  28. package/runtime/sdk/gl/Texture.ts +35 -0
  29. package/runtime/sdk/gl/controls.ts +95 -0
  30. package/runtime/sdk/gl/state.ts +6 -0
  31. package/runtime/sdk/gl/touch.ts +56 -0
  32. package/runtime/sdk/inject.ts +72 -0
  33. package/runtime/sdk/math/Mathf.ts +118 -0
  34. package/runtime/sdk/math/mat4.ts +278 -0
  35. package/runtime/sdk/math/quat.ts +232 -0
  36. package/runtime/sdk/math/vec.ts +255 -0
  37. package/runtime/sdk/plugins/qr.ts +36 -0
  38. package/runtime/sdk/runtime/device.ts +33 -0
  39. package/runtime/sdk/runtime/fetch.ts +71 -0
  40. package/runtime/sdk/runtime/files.ts +23 -0
  41. package/runtime/sdk/runtime/input.ts +12 -0
  42. package/runtime/sdk/runtime/media.ts +111 -0
  43. package/runtime/sdk/runtime/misc.ts +12 -0
  44. package/runtime/sdk/runtime/net.ts +36 -0
  45. package/runtime/sdk/runtime/storage.ts +13 -0
  46. package/runtime/sdk/runtime/touch.ts +44 -0
  47. package/runtime/sdk/ui/UI.ts +19 -0
  48. package/runtime/sdk/ui/UIButton.ts +84 -0
  49. package/runtime/sdk/ui/UIContainer.ts +100 -0
  50. package/runtime/sdk/ui/UIImage.ts +68 -0
  51. package/runtime/sdk/ui/UIInput.ts +78 -0
  52. package/runtime/sdk/ui/UINode.ts +342 -0
  53. package/runtime/sdk/ui/UIScreen.ts +122 -0
  54. package/runtime/sdk/ui/UIScrollable.ts +70 -0
  55. package/runtime/sdk/ui/UISpacer.ts +14 -0
  56. package/runtime/sdk/ui/UIText.ts +40 -0
  57. package/runtime/sdk/ui/UIVideo.ts +38 -0
  58. package/runtime/sdk/ui/UIVirtualizedList.ts +222 -0
  59. package/runtime/sdk/ui/UIWidget.ts +97 -0
  60. package/runtime/sdk/ui/fonts.ts +10 -0
  61. package/runtime/sdk/ui/router.ts +73 -0
  62. package/runtime/inject.js +0 -4629
@@ -0,0 +1,123 @@
1
+ // A renderable 3D node. Static factories build the common shapes; Mesh.from wraps a custom Geometry;
2
+ // Mesh.load brings in a GLB (returned as its root Node, with baked animation clips). Ported from
3
+ // worker/src/components/Mesh.ts — folded into the node (no component).
4
+
5
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
6
+ import { fetch, type FetchResponse } from "../runtime/fetch"
7
+ import {
8
+ Geometry,
9
+ type CylinderOptions,
10
+ type PlaneOptions,
11
+ type SphereOptions,
12
+ } from "./Geometry"
13
+ import { Material } from "./Material"
14
+ import { Node } from "./Node"
15
+
16
+ /** Common transform/render options every primitive factory accepts. */
17
+ export type MeshOptions = {
18
+ material?: Material
19
+ position?: Vec3Like
20
+ eulerAngles?: Vec3Like
21
+ scale?: Vec3Like | number
22
+ name?: string
23
+ castShadows?: boolean
24
+ receiveShadows?: boolean
25
+ }
26
+
27
+ export class Mesh extends Node {
28
+ private _geometry?: Geometry
29
+ private _materials: Material[] = []
30
+
31
+ constructor(geometry?: Geometry, material?: Material) {
32
+ super()
33
+ if (geometry) {
34
+ const mat = material ?? Material.unlit()
35
+ this._geometry = geometry
36
+ this._materials = [ mat ]
37
+ _creator.setMesh(this.id, Material.idOf(mat), geometry.vertices, geometry.normals, geometry.indices, geometry.uv, geometry._meshKind)
38
+ }
39
+ }
40
+
41
+ override get geometry(): Geometry | undefined { return this._geometry }
42
+
43
+ get material(): Material { return this._materials[0] }
44
+ set material(m: Material) { this.setMaterial(m, 0) }
45
+
46
+ setMaterial(material: Material, index = 0): this {
47
+ this._materials[index] = material
48
+ _creator.setMaterial(this.id, Material.idOf(material), index)
49
+ return this
50
+ }
51
+ getMaterial(index = 0): Material | null {
52
+ const existing = this._materials[index]
53
+ if (existing) return existing
54
+ const id = _creator.getMaterial(this.id, index)
55
+ if (id === 0) return null
56
+ const mat = new Material(id)
57
+ this._materials[index] = mat
58
+ return mat
59
+ }
60
+
61
+ set culling(v: boolean) { _creator.setCulling(this.id, v) }
62
+ set castShadows(v: boolean) { _creator.setCastShadows(this.id, v) }
63
+ set receiveShadows(v: boolean) { _creator.setReceiveShadows(this.id, v) }
64
+
65
+ // --- factories ---
66
+
67
+ static box(options: MeshOptions & { size?: Vec3Like | number } = {}): Mesh {
68
+ const geo = Geometry.box()
69
+ const [ sx, sy, sz ] = sizeToVec(options.size ?? 1)
70
+ if (sx !== 1 || sy !== 1 || sz !== 1) geo.scale(sx, sy, sz)
71
+ return apply(new Mesh(geo, options.material), options)
72
+ }
73
+
74
+ static sphere(options: MeshOptions & SphereOptions = {}): Mesh {
75
+ return apply(new Mesh(Geometry.sphere(options), options.material), options)
76
+ }
77
+
78
+ static cylinder(options: MeshOptions & CylinderOptions = {}): Mesh {
79
+ return apply(new Mesh(Geometry.cylinder(options), options.material), options)
80
+ }
81
+
82
+ static plane(options: MeshOptions & PlaneOptions = {}): Mesh {
83
+ return apply(new Mesh(Geometry.plane(options), options.material), options)
84
+ }
85
+
86
+ /** Wrap a custom Geometry. */
87
+ static from(geometry: Geometry, options: MeshOptions = {}): Mesh {
88
+ return apply(new Mesh(geometry, options.material), options)
89
+ }
90
+
91
+ /**
92
+ * Load a GLB model. Returns the model's root Node (a GLB is a node hierarchy); its animation clips
93
+ * are available via node.clips and driven with node.play(name|index).
94
+ */
95
+ static load(source: string | FetchResponse, options: { culling?: boolean, onProgress?: (p: { loaded: number, total?: number }) => void } = {}): Promise<Node> {
96
+ if (typeof source === "string") {
97
+ return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
98
+ if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
99
+ return Mesh.load(resp, options)
100
+ })
101
+ }
102
+ return new Promise<Node>((resolve, reject) => {
103
+ _creator.createGlb((source as any)._id, (entityId) => {
104
+ if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
105
+ const node = new Node(entityId)
106
+ if (options.culling !== true) _creator.setGlbCulling(entityId, false)
107
+ resolve(node)
108
+ }, reject)
109
+ })
110
+ }
111
+ }
112
+
113
+ const sizeToVec = (s: Vec3Like | number): [number, number, number] => (typeof s === "number" ? [ s, s, s ] : [ cx(s), cy(s), cz(s) ])
114
+
115
+ const apply = (mesh: Mesh, o: MeshOptions): Mesh => {
116
+ if (o.position) mesh.position = o.position
117
+ if (o.eulerAngles) mesh.eulerAngles = o.eulerAngles
118
+ if (o.scale !== undefined) mesh.scale = typeof o.scale === "number" ? [ o.scale, o.scale, o.scale ] : o.scale
119
+ if (o.name !== undefined) mesh.name = o.name
120
+ if (o.castShadows !== undefined) mesh.castShadows = o.castShadows
121
+ if (o.receiveShadows !== undefined) mesh.receiveShadows = o.receiveShadows
122
+ return mesh
123
+ }
@@ -0,0 +1,307 @@
1
+ // Base 3D scene node — an empty transform. Mesh and Light extend it, so every node operation works
2
+ // on them. Native (creator-gl) owns the authoritative transform; reads decompose a cached local
3
+ // matrix (re-synced lazily, since physics can write the transform from native each frame).
4
+ //
5
+ // "Behaviors are methods, not components": .physics() (+ node.body), .collider(), GLB animation
6
+ // (.play()/.stop()), and hierarchy all live here.
7
+
8
+ import { Emitter } from "../core/events"
9
+ import { Registry } from "../core/registry"
10
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
11
+ import { Quat, cw, type QuatLike } from "../math/quat"
12
+ import { Mat4, type Mat4Like } from "../math/mat4"
13
+ import { Body, type PhysicsOptions } from "./Body"
14
+ import type { Geometry } from "./Geometry"
15
+ import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
16
+ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
17
+
18
+ /** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
19
+ export const nodeRegistry = new Registry<Node>()
20
+
21
+ export type NodeEvents = {
22
+ click: (ev: ClickEvent<Node | null>) => void
23
+ touchstart: (ev: TouchStartEvent<Node | null>) => void
24
+ /** A looping GLB animation clip wrapped around. */
25
+ loopReached: (clip: number) => void
26
+ /** A non-looping GLB animation clip finished. */
27
+ completed: (clip: number) => void
28
+ /** AR anchors only (`scene.root` / `scene.createAnchor()`): the anchor began tracking. */
29
+ track: () => void
30
+ /** AR anchors only: the anchor lost tracking. */
31
+ untrack: () => void
32
+ }
33
+
34
+ export type ColliderOptions = {
35
+ shape?: "box" | "sphere"
36
+ /** Explicit box: center + full size. */
37
+ center?: Vec3Like
38
+ size?: Vec3Like
39
+ /** Explicit sphere: center + radius. */
40
+ radius?: number
41
+ }
42
+
43
+ let animatorRegistered = false
44
+ const ensureAnimator = (): void => {
45
+ if (animatorRegistered) return
46
+ animatorRegistered = true
47
+ _creator.setOnAnimationComplete((entityId, clipIndex) => nodeRegistry.get(entityId)?._onAnimationComplete(clipIndex) ?? true)
48
+ }
49
+
50
+ export class Node extends Emitter<NodeEvents> {
51
+ /** Native entity handle. */
52
+ readonly id: number
53
+
54
+ /** Physics handle once .physics() is called. */
55
+ body?: Body
56
+
57
+ /** AR anchors only — whether the anchor is currently being tracked. Kept in step with the
58
+ * 'track' / 'untrack' events; `false` for any non-anchor node. */
59
+ isTracked = false
60
+
61
+ private _matrix?: Float32Array
62
+ private _lastSync = 0
63
+ private _worldMatrix?: Float32Array
64
+ private _scaleCache?: [number, number, number]
65
+
66
+ private _animPlaying = false
67
+ private _animClip = 0
68
+ private _animSpeed = 1
69
+ private _animLoop = false
70
+
71
+ constructor(internalId?: number) {
72
+ super()
73
+ this.id = internalId ?? _creator.createEntity()
74
+ nodeRegistry.set(this.id, this)
75
+ }
76
+
77
+ /** Geometry, if this node is a Mesh (used by physics auto-shape). Overridden by Mesh. */
78
+ get geometry(): Geometry | undefined { return undefined }
79
+
80
+ // --- name / visibility ---
81
+ get name(): string { return _creator.getName(this.id) }
82
+ set name(value: string) { _creator.setName(this.id, value) }
83
+ get visible(): boolean { return _creator.isVisible(this.id) }
84
+ set visible(value: boolean) { _creator.setVisible(this.id, value) }
85
+
86
+ // --- local transform ---
87
+ // Native owns the authoritative matrix; we keep a Float32Array cache (re-synced lazily because physics
88
+ // can rewrite it each frame). The PUBLIC `matrix` getter hands back a fresh Mat4 *copy* (value
89
+ // semantics — mutating it never touches the node until you assign it back). `_sync()` is the internal
90
+ // hot path that returns the cache directly for cheap component reads.
91
+ private _sync(): Float32Array {
92
+ if (!this._matrix) this._matrix = new Float32Array(16)
93
+ if (this._lastSync === 0 || Date.now() - this._lastSync > 10) {
94
+ _creator.getMatrix(this.id, this._matrix)
95
+ this._scaleCache = undefined
96
+ this._lastSync = Date.now()
97
+ }
98
+ return this._matrix
99
+ }
100
+
101
+ get matrix(): Mat4 { return new Mat4(this._sync()) }
102
+ set matrix(m: Mat4Like) {
103
+ if (!this._matrix) this._matrix = new Float32Array(16)
104
+ this._matrix.set(matArr(m))
105
+ this._lastSync = Date.now()
106
+ _creator.setMatrix(this.id, this._matrix)
107
+ }
108
+
109
+ get worldMatrix(): Mat4 {
110
+ if (this.parent === null) return this.matrix
111
+ if (!this._worldMatrix) this._worldMatrix = new Float32Array(16)
112
+ _creator.getWorldMatrix(this.id, this._worldMatrix)
113
+ return new Mat4(this._worldMatrix)
114
+ }
115
+ set worldMatrix(m: Mat4Like) {
116
+ if (this.parent === null) { this.matrix = m; return }
117
+ this._lastSync = 0
118
+ _creator.setWorldMatrix(this.id, Float32Array.from(matArr(m)))
119
+ }
120
+
121
+ get position(): Vec3 { const m = this._sync(); return new Vec3(m[12], m[13], m[14]) }
122
+ set position(v: Vec3Like) { this._lastSync = 0; _creator.setPosition(this.id, cx(v), cy(v), cz(v)) }
123
+
124
+ // Scalar position accessors — the loud, correct way to nudge one axis (`node.x = 3`), so nobody reaches
125
+ // for `node.position.x = 3` (a no-op: the getter returns a fresh copy).
126
+ get x(): number { return this._sync()[12] }
127
+ set x(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, v, m[13], m[14]) }
128
+ get y(): number { return this._sync()[13] }
129
+ set y(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, m[12], v, m[14]) }
130
+ get z(): number { return this._sync()[14] }
131
+ set z(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, m[12], m[13], v) }
132
+
133
+ get scale(): Vec3 {
134
+ if (this._lastSync === 0 || !this._scaleCache) {
135
+ const m = this._sync()
136
+ this._scaleCache = [ Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]) ]
137
+ }
138
+ return new Vec3(this._scaleCache)
139
+ }
140
+ set scale(v: Vec3Like | number) {
141
+ this._lastSync = 0
142
+ if (typeof v === "number") _creator.setScale(this.id, v, v, v)
143
+ else _creator.setScale(this.id, cx(v), cy(v), cz(v))
144
+ }
145
+
146
+ get quaternion(): Quat { return new Mat4(this._sync()).rotation }
147
+ set quaternion(v: QuatLike) { this._lastSync = 0; _creator.setQuaternion(this.id, cx(v), cy(v), cz(v), cw(v)) }
148
+
149
+ get eulerAngles(): Vec3 { return new Mat4(this._sync()).eulerAngles }
150
+ set eulerAngles(v: Vec3Like) { this._lastSync = 0; _creator.setEulerAngles(this.id, cx(v), cy(v), cz(v), 0) }
151
+
152
+ // --- world-space reads ---
153
+ get forward(): Vec3 {
154
+ const v = new Float32Array(3)
155
+ _creator.getWorldDirection(this.id, 2, v)
156
+ return new Vec3(-v[0], -v[1], -v[2])
157
+ }
158
+ get worldPosition(): Vec3 {
159
+ const v = new Float32Array(3)
160
+ _creator.getWorldPosition(this.id, v)
161
+ return new Vec3(v[0], v[1], v[2])
162
+ }
163
+ get worldScale(): Vec3 { return this.worldMatrix.scaling }
164
+ get worldQuaternion(): Quat { return this.worldMatrix.rotation }
165
+ get worldEulerAngles(): Vec3 { return this.worldMatrix.eulerAngles }
166
+
167
+ // --- hierarchy ---
168
+ get parent(): Node | null {
169
+ const id = _creator.getParent(this.id)
170
+ return id === 0 ? null : (nodeRegistry.get(id) ?? new Node(id))
171
+ }
172
+ get children(): Node[] {
173
+ return _creator.getChildren(this.id).map((id) => nodeRegistry.get(id) ?? new Node(id))
174
+ }
175
+ get childCount(): number { return _creator.getChildCount(this.id) }
176
+ getChild(index: number): Node | null {
177
+ const id = _creator.getChild(this.id, index)
178
+ return id === 0 ? null : (nodeRegistry.get(id) ?? new Node(id))
179
+ }
180
+ /** Parent the given nodes under this one. */
181
+ add(...children: Node[]): this {
182
+ for (const c of children) _creator.setParent(c.id, this.id, false)
183
+ return this
184
+ }
185
+ setParent(parent: Node | null, worldPositionStays = false): this {
186
+ if (parent === null) _creator.setParentNull(this.id, worldPositionStays)
187
+ else _creator.setParent(this.id, parent.id, worldPositionStays)
188
+ if (worldPositionStays) this._lastSync = 0
189
+ return this
190
+ }
191
+ traverse(callback: (node: Node) => void): void {
192
+ callback(this)
193
+ _creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
194
+ }
195
+
196
+ // --- behaviors ---
197
+
198
+ /** Add a physics body (reach it at node.body). Chainable. */
199
+ physics(options: PhysicsOptions = {}): this {
200
+ this.body = new Body(this, options)
201
+ return this
202
+ }
203
+
204
+ /** Add a collider for click/touch hit-testing. Derives from the mesh by default. Chainable. */
205
+ collider(options: ColliderOptions = {}): this {
206
+ const form = options.shape ?? "box"
207
+ if (form === "box" && options.center && options.size) {
208
+ _creator.setColliderBox(this.id, cx(options.center), cy(options.center), cz(options.center), cx(options.size), cy(options.size), cz(options.size))
209
+ } else if (form === "sphere" && options.center && options.radius !== undefined) {
210
+ _creator.setColliderSphere(this.id, cx(options.center), cy(options.center), cz(options.center), options.radius)
211
+ } else {
212
+ _creator.setColliderFromMesh(this.id, this.id, form === "sphere" ? 1 : 0)
213
+ }
214
+ return this
215
+ }
216
+
217
+ /** Orient the node so the given axis (default "-z", "forward") points at a world point. */
218
+ lookAt(point: Vec3Like, mode: "z" | "-z" | "x" | "-x" | "y" | "-y" = "-z", ortho: Vec3Like = [ 0, 1, 0 ]): this {
219
+ const world = this.worldMatrix
220
+ const pos = world.position
221
+ let dir = new Vec3(point).sub(pos)
222
+ const len = dir.length()
223
+ if (len === 0) return this
224
+ let axis: string = mode
225
+ if (mode.startsWith("-")) { dir = dir.scale(-1 / len); axis = mode.slice(1) }
226
+ else dir = dir.scale(1 / len)
227
+
228
+ const o = new Vec3(ortho)
229
+ let forward: Vec3, right: Vec3, up: Vec3
230
+ if (axis === "x") {
231
+ right = dir; up = o.cross(right).normalize(); forward = up.cross(right).normalize()
232
+ } else if (axis === "y") {
233
+ up = dir; right = o.cross(up).normalize(); forward = up.cross(right).normalize()
234
+ } else {
235
+ forward = dir; right = o.cross(dir).normalize(); up = dir.cross(right)
236
+ }
237
+ const s = world.scaling, w = world.m
238
+ this.worldMatrix = Float32Array.of(
239
+ right.x * s.x, right.y * s.x, right.z * s.x, 0,
240
+ up.x * s.y, up.y * s.y, up.z * s.y, 0,
241
+ forward.x * s.z, forward.y * s.z, forward.z * s.z, 0,
242
+ w[12], w[13], w[14], w[15],
243
+ )
244
+ return this
245
+ }
246
+
247
+ // --- GLB animation (no-op until a GLB is loaded as this node) ---
248
+ get clips(): { name: string, duration: number }[] { return _creator.getGlbAnimations(this.id) }
249
+
250
+ get animationPlaying(): boolean { return this._animPlaying }
251
+ set animationPlaying(value: boolean) {
252
+ this._animPlaying = value
253
+ _creator.setAnimation(this.id, value, this._animClip, this._animSpeed)
254
+ }
255
+
256
+ /** Play a GLB clip by name or index. Chainable. */
257
+ play(clip?: string | number, options: { loop?: boolean } = {}): this {
258
+ if (typeof clip === "number") this._animClip = clip
259
+ else if (typeof clip === "string") {
260
+ const i = this.clips.findIndex((c) => c.name === clip)
261
+ if (i >= 0) this._animClip = i
262
+ }
263
+ if (options.loop !== undefined) this._animLoop = options.loop
264
+ this.animationPlaying = true
265
+ ensureAnimator()
266
+ return this
267
+ }
268
+ stop(): this { this.animationPlaying = false; return this }
269
+
270
+ get animationLoop(): boolean { return this._animLoop }
271
+ set animationLoop(v: boolean) { this._animLoop = v }
272
+ get animationSpeed(): number { return this._animSpeed }
273
+ set animationSpeed(v: number) {
274
+ this._animSpeed = v
275
+ _creator.setAnimation(this.id, this._animPlaying, this._animClip, v)
276
+ }
277
+ get animationTime(): number { return _creator.getAnimationTime(this.id) }
278
+ set animationTime(v: number) { _creator.setAnimationTime(this.id, this._animClip, v) }
279
+
280
+ // --- events ---
281
+ override addEventListener<K extends keyof NodeEvents>(channel: K, callback: NodeEvents[K]): void {
282
+ if (channel === "click") registerTouchEndEvent()
283
+ else if (channel === "touchstart") registerTouchStartEvent()
284
+ super.addEventListener(channel, callback)
285
+ }
286
+
287
+ /** @internal — called by the touch system to deliver a hit. */
288
+ _dispatchEvent<K extends keyof NodeEvents>(channel: K, ...args: Parameters<NodeEvents[K]>): void {
289
+ this.dispatch(channel, ...args)
290
+ }
291
+
292
+ /** @internal — host animation-complete callback; returns whether the clip should keep playing. */
293
+ _onAnimationComplete(clipIndex: number): boolean {
294
+ if (clipIndex !== this._animClip) return true
295
+ if (!this._animLoop) { this._animPlaying = false; this.dispatch("completed", clipIndex) }
296
+ else this.dispatch("loopReached", clipIndex)
297
+ return this._animPlaying
298
+ }
299
+
300
+ destroy(): void {
301
+ nodeRegistry.delete(this.id)
302
+ _creator.destroyEntity(this.id)
303
+ }
304
+ }
305
+
306
+ /** Read the raw column-major elements out of a Mat4 wrapper or a plain length-16 array. */
307
+ const matArr = (m: Mat4Like): ArrayLike<number> => (m as { m?: ArrayLike<number> }).m ?? (m as ArrayLike<number>)
@@ -0,0 +1,30 @@
1
+ // Native FastNoise (Perlin/fractal) sampler. Ported from worker/src/utils/PerlinNoise.ts.
2
+
3
+ export class Noise {
4
+ private readonly _id: number
5
+
6
+ constructor() {
7
+ this._id = _creator.createNoise()
8
+ }
9
+
10
+ /** Sample 2D noise. */
11
+ get(x: number, y: number): number { return _creator.getNoise2D(this._id, x, y) }
12
+ /** Sample 3D noise. */
13
+ get3d(x: number, y: number, z: number): number { return _creator.getNoise3D(this._id, x, y, z) }
14
+
15
+ private _frequency = 0.01
16
+ get frequency(): number { return this._frequency }
17
+ set frequency(v: number) { this._frequency = v; _creator.setNoiseFrequency(this._id, v) }
18
+
19
+ private _octaves = 1
20
+ get octaves(): number { return this._octaves }
21
+ set octaves(v: number) { this._octaves = v; _creator.setNoiseOctaves(this._id, v) }
22
+
23
+ private _fractalLacunarity = 2
24
+ get fractalLacunarity(): number { return this._fractalLacunarity }
25
+ set fractalLacunarity(v: number) { this._fractalLacunarity = v; _creator.setNoiseFractalLunacrity(this._id, v) }
26
+
27
+ private _fractalGain = 0.5
28
+ get fractalGain(): number { return this._fractalGain }
29
+ set fractalGain(v: number) { this._fractalGain = v; _creator.setNoiseFractalGain(this._id, v) }
30
+ }
@@ -0,0 +1,185 @@
1
+ // GPU particle system, as a Node. Created with a material; properties drive the native emitter.
2
+ // Ported from worker/src/components/Particles.ts — folded into the node, so (unlike the worker
3
+ // component) the system exists at construction and setters apply immediately.
4
+
5
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
6
+ import { Material } from "./Material"
7
+ import { Node } from "./Node"
8
+
9
+ type Range<T> = T | { min: T, max: T }
10
+
11
+ type VelocityValue = {
12
+ speed?: Range<number>
13
+ randomizeAngle?: { min: Vec3Like, max: Vec3Like }
14
+ } & ({ from: Vec3Like } | { to: Vec3Like } | { dir: Vec3Like } | {})
15
+
16
+ export type DynamicValue =
17
+ | Range<number>
18
+ | { value?: Range<number>, inc?: Float32Array, inc_b?: Float32Array, mul?: Float32Array, mul_b?: Float32Array }
19
+
20
+ export type DynamicColor =
21
+ | Range<string>
22
+ | { value?: Range<string>, mul: string, mul_b?: string, t: Float32Array }
23
+
24
+ type Shape = { type: "point", v: Vec3Like } | { type: "box", min: Vec3Like, max: Vec3Like }
25
+ type Noise = { strength?: number, frequency?: number, speed?: number }
26
+
27
+ export type ParticlesOptions = {
28
+ material: Material
29
+ rate?: Range<number>
30
+ shape?: Shape
31
+ startVelocity?: Vec3Like | VelocityValue
32
+ gravity?: number
33
+ drag?: number
34
+ lifetime?: Range<number>
35
+ color?: DynamicColor
36
+ size?: DynamicValue
37
+ rotation?: DynamicValue
38
+ noise?: Noise | null
39
+ }
40
+
41
+ export class Particles extends Node {
42
+ private _material: Material
43
+
44
+ /** Custom curve params (slots 0..3); size/rotation alias slots 0/1. */
45
+ readonly custom: DynamicValue[]
46
+
47
+ constructor(options: ParticlesOptions) {
48
+ super()
49
+ this._material = options.material
50
+ _creator.createParticleSystem(this.id, Material.idOf(options.material))
51
+
52
+ const id = this.id
53
+ this.custom = new Proxy([] as DynamicValue[], {
54
+ set(target, prop: string, value: DynamicValue) {
55
+ const i = parseInt(prop)
56
+ if (Number.isNaN(i)) { (target as any)[prop] = value; return true }
57
+ target[i] = value
58
+ applyParam(id, i, value)
59
+ return true
60
+ },
61
+ })
62
+
63
+ if (options.rate !== undefined) this.rate = options.rate
64
+ if (options.shape !== undefined) this.shape = options.shape
65
+ if (options.startVelocity !== undefined) this.startVelocity = options.startVelocity
66
+ if (options.gravity !== undefined) this.gravity = options.gravity
67
+ if (options.drag !== undefined) this.drag = options.drag
68
+ if (options.lifetime !== undefined) this.lifetime = options.lifetime
69
+ if (options.color !== undefined) this.color = options.color
70
+ if (options.size !== undefined) this.custom[0] = options.size
71
+ if (options.rotation !== undefined) this.custom[1] = options.rotation
72
+ if (options.noise) this.noise = options.noise
73
+ }
74
+
75
+ /** Emit `count` particles immediately (a burst). */
76
+ spawn(count: number): this { _creator.spawnParticles(this.id, count); return this }
77
+
78
+ set rate(val: Range<number>) {
79
+ if (typeof val === "number") _creator.setParticleSystemRateValue(this.id, val)
80
+ }
81
+
82
+ set shape(shape: Shape) {
83
+ if (shape.type === "point") _creator.setParticleSystemShapePoint(this.id, cx(shape.v), cy(shape.v), cz(shape.v))
84
+ else _creator.setParticleSystemShapeBox(this.id, cx(shape.min), cy(shape.min), cz(shape.min), cx(shape.max), cy(shape.max), cz(shape.max))
85
+ }
86
+
87
+ set startVelocity(val: Vec3Like | VelocityValue) {
88
+ if (Array.isArray(val) || val instanceof Float32Array || val instanceof Vec3) {
89
+ _creator.setParticleSystemVelocityValue(this.id, 1, cx(val), cy(val), cz(val))
90
+ return
91
+ }
92
+ const v = val as VelocityValue
93
+ if ("from" in v) _creator.setParticleSystemVelocityValue(this.id, 2, cx(v.from), cy(v.from), cz(v.from))
94
+ else if ("to" in v) _creator.setParticleSystemVelocityValue(this.id, 3, cx(v.to), cy(v.to), cz(v.to))
95
+ else if ("dir" in v) _creator.setParticleSystemVelocityValue(this.id, 1, cx(v.dir), cy(v.dir), cz(v.dir))
96
+ else _creator.setParticleSystemVelocityValue(this.id, 0, 0, 0, 0)
97
+ if ("speed" in v && v.speed !== undefined) {
98
+ if (typeof v.speed === "number") _creator.setParticleSystemVelocitySpeedValue(this.id, v.speed)
99
+ else _creator.setParticleSystemVelocitySpeedRange(this.id, v.speed.min, v.speed.max)
100
+ }
101
+ if (v.randomizeAngle) {
102
+ const a = v.randomizeAngle
103
+ _creator.setParticleSystemVelocityRndAngle(this.id, cx(a.min), cy(a.min), cz(a.min), cx(a.max), cy(a.max), cz(a.max))
104
+ }
105
+ }
106
+
107
+ set gravity(val: number) { _creator.setParticleSystemGravityValue(this.id, val) }
108
+ set drag(val: number) { _creator.setParticleSystemDragValue(this.id, val) }
109
+
110
+ set lifetime(val: Range<number>) {
111
+ if (typeof val === "number") _creator.setParticleSystemLifetimeValue(this.id, val)
112
+ else _creator.setParticleSystemLifetimeRange(this.id, val.min, val.max)
113
+ }
114
+
115
+ set size(val: DynamicValue) { this.custom[0] = val }
116
+ set rotation(val: DynamicValue) { this.custom[1] = val }
117
+
118
+ set color(value: DynamicColor) {
119
+ if (typeof value === "string") _creator.setParticleSystemColorValue(this.id, value)
120
+ else if ("min" in value) _creator.setParticleSystemColorRange(this.id, value.min, value.max)
121
+ else {
122
+ if (typeof value.value === "string") _creator.setParticleSystemColorValue(this.id, value.value)
123
+ else if (value.value) _creator.setParticleSystemColorRange(this.id, value.value.min, value.value.max)
124
+ if (value.mul) _creator.setParticleSystemColorCurveA(this.id, value.mul, value.t)
125
+ if (value.mul_b) _creator.setParticleSystemColorCurveB(this.id, value.mul_b, value.t)
126
+ }
127
+ }
128
+
129
+ get material(): Material { return this._material }
130
+ set material(material: Material) {
131
+ this._material = material
132
+ _creator.setMaterial(this.id, Material.idOf(material), 0)
133
+ }
134
+
135
+ set noise(noise: Noise | null) {
136
+ if (noise) _creator.setParticleSystemNoise(this.id, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1)
137
+ else _creator.disableParticleSystemNoise(this.id)
138
+ }
139
+ }
140
+
141
+ const applyParam = (id: number, i: number, value: DynamicValue): void => {
142
+ if (typeof value === "number") {
143
+ _creator.setParticleSystemParamValue(id, i, value)
144
+ } else if ("min" in value) {
145
+ _creator.setParticleSystemParamRange(id, i, value.min, value.max)
146
+ } else {
147
+ if (typeof value.value === "number") _creator.setParticleSystemParamValue(id, i, value.value)
148
+ else if (value.value) _creator.setParticleSystemParamRange(id, i, value.value.min, value.value.max)
149
+ if (value.inc) _creator.setParticleSystemParamCurveA(id, i, value.inc, 1)
150
+ if (value.inc_b) _creator.setParticleSystemParamCurveB(id, i, value.inc_b)
151
+ if (value.mul) _creator.setParticleSystemParamCurveA(id, i, value.mul, 0)
152
+ if (value.mul_b) _creator.setParticleSystemParamCurveB(id, i, value.mul_b)
153
+ }
154
+ }
155
+
156
+ // --- curve builders (unchanged from worker) ---
157
+
158
+ export const dynamicColor = (baseValue: Range<string>, ...args: (number | string | { min: string, max: string })[]): DynamicColor => {
159
+ const t = new Float32Array(args.length / 2)
160
+ const mul: string[] = []
161
+ let mul_b: string[] | undefined
162
+ for (let i = 0; i < args.length / 2; i++) {
163
+ if (typeof args[i * 2] !== "number") throw new Error(`dynamicColor: arg ${i * 2} must be a number`)
164
+ t[i] = args[i * 2] as number
165
+ const val = args[i * 2 + 1]
166
+ if (typeof val === "string") { mul[i] = val; if (mul_b) mul_b[i] = val }
167
+ else if (typeof val === "number") throw new Error(`dynamicColor: arg ${i * 2 + 1} must be a string`)
168
+ else { mul[i] = val.min; if (!mul_b) mul_b = [ ...mul ]; mul_b[i] = val.max }
169
+ }
170
+ return { value: baseValue, mul: mul.join("\0"), mul_b: mul_b?.join("\0"), t }
171
+ }
172
+
173
+ export const dynamic = (baseValue: Range<number>, type: "multiply" | "inc", ...args: (number | { min: number, max: number })[]): DynamicValue => {
174
+ const inc = new Float32Array(args.length)
175
+ let inc_b: Float32Array | undefined
176
+ for (let i = 0; i < args.length; i += 2) {
177
+ if (typeof args[i] !== "number") throw new Error(`dynamic: arg ${i} must be a number`)
178
+ inc[i] = args[i] as number
179
+ if (inc_b) inc_b[i] = args[i] as number
180
+ const val = args[i + 1]
181
+ if (typeof val === "number") { inc[i + 1] = val; if (inc_b) inc_b[i + 1] = val }
182
+ else { inc[i + 1] = val.min; if (!inc_b) inc_b = new Float32Array(inc); inc_b[i + 1] = val.max }
183
+ }
184
+ return type === "inc" ? { value: baseValue, inc, inc_b } : { value: baseValue, mul: inc, mul_b: inc_b }
185
+ }