lecodes-sdk 0.20.2 → 1.1.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 (129) hide show
  1. package/dist/global.d.ts +35 -9
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/canvas/Canvas.d.ts +2 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/DecalSet.d.ts +48 -3
  12. package/dist/types/gl/Foliage.d.ts +47 -0
  13. package/dist/types/gl/Geometry.d.ts +36 -0
  14. package/dist/types/gl/Light.d.ts +25 -7
  15. package/dist/types/gl/Lightmap.d.ts +90 -51
  16. package/dist/types/gl/Material.d.ts +32 -20
  17. package/dist/types/gl/Mesh.d.ts +7 -1
  18. package/dist/types/gl/Model.d.ts +41 -5
  19. package/dist/types/gl/Node.d.ts +18 -0
  20. package/dist/types/gl/Particles.d.ts +53 -1
  21. package/dist/types/gl/Scene.d.ts +21 -1
  22. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  23. package/dist/types/gl/animation/Animator.d.ts +27 -0
  24. package/dist/types/gl/animation/DynamicBone.d.ts +184 -0
  25. package/dist/types/gl/animation/IK.d.ts +109 -0
  26. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  27. package/dist/types/gl/animation/Warp.d.ts +2 -1
  28. package/dist/types/gl/animation/core.d.ts +35 -4
  29. package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
  30. package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
  31. package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
  32. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  33. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  34. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  35. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  36. package/dist/types/gl/physics/Ragdoll.d.ts +161 -0
  37. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  38. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  39. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +10 -8
  40. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  41. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  42. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  43. package/dist/types/inject.d.ts +35 -27
  44. package/dist/types/runtime/files.d.ts +24 -1
  45. package/dist/types/scene/defineScene.d.ts +50 -31
  46. package/dist/types/ui/UIButton.d.ts +3 -1
  47. package/dist/types/ui/UIInput.d.ts +5 -1
  48. package/dist/types/ui/UINode.d.ts +24 -24
  49. package/dist/types.json +1 -1
  50. package/package.json +1 -1
  51. package/prompts/README.md +142 -142
  52. package/prompts/core-design.md +27 -4
  53. package/prompts/core.md +35 -6
  54. package/prompts/select.ts +19 -4
  55. package/src/animate/tween/Animation.ts +378 -0
  56. package/src/animate/tween/Timeline.ts +175 -0
  57. package/src/animate/tween/animateValue.ts +100 -0
  58. package/src/animate/tween/easing.ts +172 -0
  59. package/src/animate/tween/spec.ts +479 -0
  60. package/src/bridges.d.ts +1760 -1481
  61. package/src/canvas/Canvas.ts +21 -0
  62. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  63. package/src/compile/__tests__/compile.test.ts +11 -0
  64. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  65. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  66. package/src/compile/bundler.ts +34 -4
  67. package/src/compile/compileProject.ts +31 -1
  68. package/src/compile/detectEntry.ts +8 -3
  69. package/src/compile/header.ts +6 -3
  70. package/src/compile/index.ts +3 -0
  71. package/src/compile/sceneEditor.ts +42 -1
  72. package/src/compile/serverSplit.ts +9 -3
  73. package/src/g2/Node2D.ts +38 -0
  74. package/src/g2/Sprite.ts +20 -1
  75. package/src/gl/Camera.ts +34 -1
  76. package/src/gl/DecalSet.ts +132 -5
  77. package/src/gl/Foliage.ts +102 -0
  78. package/src/gl/Geometry.ts +109 -0
  79. package/src/gl/Light.ts +46 -16
  80. package/src/gl/Lightmap.ts +440 -249
  81. package/src/gl/Material.ts +69 -36
  82. package/src/gl/Mesh.ts +120 -102
  83. package/src/gl/Model.ts +167 -124
  84. package/src/gl/Node.ts +40 -1
  85. package/src/gl/Particles.ts +82 -5
  86. package/src/gl/Scene.ts +35 -2
  87. package/src/gl/animation/AnimationClip.ts +52 -0
  88. package/src/gl/animation/Animator.ts +42 -2
  89. package/src/gl/animation/DynamicBone.ts +482 -0
  90. package/src/gl/animation/IK.ts +214 -0
  91. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  92. package/src/gl/animation/Playback.ts +5 -4
  93. package/src/gl/animation/Warp.ts +5 -2
  94. package/src/gl/animation/core.ts +65 -4
  95. package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
  96. package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
  97. package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
  98. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  99. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  100. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  101. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  102. package/src/gl/physics/Ragdoll.ts +451 -0
  103. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  104. package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
  105. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  106. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +14 -12
  107. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  108. package/src/gl/vehicle/Vehicle.ts +666 -0
  109. package/src/gl/vehicle/Wheel.ts +290 -0
  110. package/src/inject.ts +236 -224
  111. package/src/runtime/files.ts +32 -2
  112. package/src/scene/defineScene.ts +92 -66
  113. package/src/scene/level.ts +2 -2
  114. package/src/ui/UIButton.ts +2 -2
  115. package/src/ui/UIInput.ts +3 -3
  116. package/src/ui/UINode.ts +61 -36
  117. package/dist/types/animate/animate.d.ts +0 -20
  118. package/dist/types/gl/Gearbox.d.ts +0 -86
  119. package/dist/types/gl/IK.d.ts +0 -53
  120. package/dist/types/gl/Ragdoll.d.ts +0 -86
  121. package/dist/types/gl/Vehicle.d.ts +0 -191
  122. package/dist/types/gl/Wheel.d.ts +0 -95
  123. package/src/animate/animate.ts +0 -238
  124. package/src/gl/Gearbox.ts +0 -212
  125. package/src/gl/IK.ts +0 -193
  126. package/src/gl/Ragdoll.ts +0 -270
  127. package/src/gl/Vehicle.ts +0 -473
  128. package/src/gl/Wheel.ts +0 -240
  129. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
package/src/gl/Model.ts CHANGED
@@ -1,124 +1,167 @@
1
- // A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
2
- // distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
3
- // model.anim (an Animator over the GLB's clips — crossfades, blend spaces, layers when you need them):
4
- // const hero = await Model.load(asset('./hero.glb'))
5
- // hero.anim.play('Run', { loop: true })
6
-
7
- import { fetch, type FetchResponse } from "../runtime/fetch"
8
- import { Node } from "./Node"
9
- import { Animator, _pushLod, type LodMode } from "./animation/Animator"
10
- import { Material } from "./Material"
11
-
12
- export class Model extends Node {
13
- /** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
14
- * more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
15
- declare readonly anim: Animator
16
- /** @internal loaded through the lightmap material (docs/lightmap-plan.md); clones inherit it. */
17
- _lightmapped = false
18
- /** @internal what `load` does without an explicit `lightmap` option: `'dynamic'` while a scene file with
19
- * `env.lightmap.volume` runs (Lightmap sets it), so weapons, spawns and make() subtrees read the volume. */
20
- static _lightmapDefault: false | "dynamic" = false
21
- /** @internal the shadow pair, mirrored here because the bridge writes both at once. */
22
- _castShadows = true
23
- /** @internal */
24
- _receiveShadows = true
25
- /** @internal */
26
- _culling = true
27
- /** @internal -1 = auto (see `lod`) */
28
- _lodMesh = -1
29
-
30
- constructor(internalId: number) {
31
- super(internalId)
32
- this.aspect(Animator)
33
- }
34
-
35
- /** Does this model cast a real-time shadow? Unlike Mesh (one renderable) a GLB is a whole
36
- * hierarchy, so the flag goes to EVERY renderable of the instance. A first-person viewmodel —
37
- * arms, weapon, attachments — sets it false: it lives in front of the camera and its shadow
38
- * is never wanted. */
39
- get castShadows(): boolean { return this._castShadows }
40
- set castShadows(v: boolean) { this._castShadows = v; this._applyShadows() }
41
-
42
- /** Is this model lit by other casters' shadows? Same instance-wide reach as castShadows. */
43
- get receiveShadows(): boolean { return this._receiveShadows }
44
- set receiveShadows(v: boolean) { this._receiveShadows = v; this._applyShadows() }
45
-
46
- /** Frustum culling for the whole instance: a model the camera cannot see skips the draw and the
47
- * shadow pass. ON by default where the host keeps a skinned mesh's bounds honest — the engine refits
48
- * them to the joints every frame, so a walking, kneeling or ragdolled body is never culled while on
49
- * screen (`_creator.skinnedCullingSupported`); OFF (always draw) on hosts without that, where
50
- * Filament would cull an animated body by its bind-pose box. `false` = always draw (a skybox-sized
51
- * mesh, a debugging aid); `true` forces it on regardless of the host. */
52
- get culling(): boolean { return this._culling }
53
- set culling(v: boolean) { this._culling = v; _creator.setGlbCulling(this.id, v) }
54
- /** Level of detail (docs/lod-plan.md). `'auto'` (default): the engine shows the `_LOD<n>` mesh level
55
- * that fits the model's size on screen (`lecodes assets doctor --lod` makes them) and scales the
56
- * animation rate with it; a number 0–3 pins that level for both — `0` = always full detail (a hero,
57
- * a showcase), `2`/`3` = always cheap (a crowd filler). `model.anim.lod = 'full'` keeps the animation
58
- * exact while the mesh still switches. No-op on hosts without the LOD pass. */
59
- get lod(): LodMode { return this._lodMesh < 0 ? "auto" : (this._lodMesh as 0 | 1 | 2 | 3) }
60
- set lod(v: LodMode) { this._lodMesh = v === "auto" ? -1 : Math.max(0, Math.min(3, Math.round(v))); _pushLod(this) }
61
-
62
- /** @internal the default for a fresh instance (load / clone), decided once per host. */
63
- static _cullingDefault(): boolean {
64
- if (Model._cullDefault === undefined) Model._cullDefault = !!_creator.skinnedCullingSupported?.()
65
- return Model._cullDefault
66
- }
67
- private static _cullDefault: boolean | undefined
68
-
69
- /** @internal both flags travel together — the bridge walks the instance once. */
70
- _applyShadows(): void { _creator.setGlbShadows?.(this.id, this._castShadows, this._receiveShadows) }
71
-
72
- /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
73
- * the same parent and scene and sharing this model's current transform. The clone has its own
74
- * independent animation state (reach it via clone.anim). Mirrors this model's culling flag. */
75
- clone(): Model {
76
- const id = _creator.cloneEntity(this.id)
77
- const m = new Model(id)
78
- m._lightmapped = this._lightmapped
79
- if (!this._culling) { m._culling = false; _creator.setGlbCulling(id, false) } // the engine default is on
80
- if (this._lodMesh >= 0) { m._lodMesh = this._lodMesh; _pushLod(m) }
81
- // the clone is a FRESH gltfio instance — it comes back with the asset's own shadow flags,
82
- // not this model's, so a cleared flag has to be re-applied
83
- m._castShadows = this._castShadows
84
- m._receiveShadows = this._receiveShadows
85
- if (!this._castShadows || !this._receiveShadows) m._applyShadows()
86
- return m
87
- }
88
-
89
- /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
90
- static load(
91
- source: string | FetchResponse,
92
- options: {
93
- /** Frustum culling — see `culling` (default: on where the host refits skinned bounds, else off). */
94
- culling?: boolean
95
- onProgress?: (p: { loaded: number, total?: number }) => void
96
- /** Baked lighting (docs/lightmap-plan.md). `true` = a STATIC: loads through the lightmap material so
97
- * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 — `lecodes assets doctor
98
- * --lightmap-uv`). `'dynamic'` = a mover: the same material, lit by the level's light VOLUME instead
99
- * (no UV1 needed). Omitted: `'dynamic'` while a scene file with `env.lightmap.volume` is running,
100
- * else off. `false` = the standard shader. Hosts without lightmap support ignore it. */
101
- lightmap?: boolean | "dynamic"
102
- } = {},
103
- ): Promise<Model> {
104
- if (typeof source === "string") {
105
- return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
106
- if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
107
- return Model.load(resp, options)
108
- })
109
- }
110
- return new Promise<Model>((resolve, reject) => {
111
- const mode = options.lightmap ?? Model._lightmapDefault
112
- const lightmapped = !!mode && !!_creator.setNextGlbLightmapped
113
- if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()))
114
- _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
115
- if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
116
- const model = new Model(entityId)
117
- model._lightmapped = lightmapped
118
- const culling = options.culling ?? Model._cullingDefault()
119
- if (!culling) { model._culling = false; _creator.setGlbCulling(entityId, false) }
120
- resolve(model)
121
- }, reject)
122
- })
123
- }
124
- }
1
+ // A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
2
+ // distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
3
+ // model.anim (an Animator over the GLB's clips — crossfades, blend spaces, layers when you need them):
4
+ // const hero = await Model.load(asset('./hero.glb'))
5
+ // hero.anim.play('Run', { loop: true })
6
+
7
+ import { fetch, type FetchResponse } from "../runtime/fetch"
8
+ import { Node } from "./Node"
9
+ import { Animator, _pushLod, type LodMode } from "./animation/Animator"
10
+ import { Material } from "./Material"
11
+
12
+ /** One polygon under a screen point — what `Model.pickTriangle` returns. `bones` are the vertex's raw
13
+ * JOINTS_0 / WEIGHTS_0 pairs (weight > 0; empty = unweighted), `bind` its position in mesh space,
14
+ * `world` its skinned position this frame. */
15
+ export interface TrianglePick {
16
+ /** the Model's root entity, and the mesh node's own entity (0 when the host has no entity for it) */
17
+ entity: number
18
+ node: number
19
+ nodeName: string
20
+ mesh: string
21
+ primitive: number
22
+ material: string
23
+ /** triangle index within the primitive (index-buffer order), and whether the ray came from behind */
24
+ triangle: number
25
+ backface: boolean
26
+ distance: number
27
+ point: [number, number, number]
28
+ bary: [number, number, number]
29
+ skin: string
30
+ vertices: { index: number, bind: [number, number, number], world: [number, number, number], bones: { name: string, weight: number }[] }[]
31
+ }
32
+
33
+ export class Model extends Node {
34
+ /** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
35
+ * more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
36
+ declare readonly anim: Animator
37
+ /** @internal loaded through the lightmap material (a baked static); clones inherit it. */
38
+ _lightmapped = false
39
+ /** @internal loaded through the foliage tier (`Foliage`); clones inherit it. */
40
+ _foliage = false
41
+ /** @internal the shadow pair, mirrored here because the bridge writes both at once. */
42
+ _castShadows = true
43
+ /** @internal */
44
+ _receiveShadows = true
45
+ /** @internal */
46
+ _culling = true
47
+ /** @internal -1 = auto (see `lod`) */
48
+ _lodMesh = -1
49
+
50
+ constructor(internalId: number) {
51
+ super(internalId)
52
+ this.aspect(Animator)
53
+ }
54
+
55
+ /** Does this model cast a real-time shadow? Unlike Mesh (one renderable) a GLB is a whole
56
+ * hierarchy, so the flag goes to EVERY renderable of the instance. A first-person viewmodel —
57
+ * arms, weapon, attachments — sets it false: it lives in front of the camera and its shadow
58
+ * is never wanted. */
59
+ get castShadows(): boolean { return this._castShadows }
60
+ set castShadows(v: boolean) { this._castShadows = v; this._applyShadows() }
61
+
62
+ /** Is this model lit by other casters' shadows? Same instance-wide reach as castShadows. */
63
+ get receiveShadows(): boolean { return this._receiveShadows }
64
+ set receiveShadows(v: boolean) { this._receiveShadows = v; this._applyShadows() }
65
+
66
+ /** Frustum culling for the whole instance: a model the camera cannot see skips the draw and the
67
+ * shadow pass. ON by default where the host keeps a skinned mesh's bounds honest — the engine refits
68
+ * them to the joints every frame, so a walking, kneeling or ragdolled body is never culled while on
69
+ * screen (`_creator.skinnedCullingSupported`); OFF (always draw) on hosts without that, where
70
+ * Filament would cull an animated body by its bind-pose box. `false` = always draw (a skybox-sized
71
+ * mesh, a debugging aid); `true` forces it on regardless of the host. */
72
+ get culling(): boolean { return this._culling }
73
+ set culling(v: boolean) { this._culling = v; _creator.setGlbCulling(this.id, v) }
74
+ /** Level of detail (docs/lod-plan.md). `'auto'` (default): the engine shows the `_LOD<n>` mesh level
75
+ * that fits the model's size on screen (`lecodes assets doctor --lod` makes them) and scales the
76
+ * animation rate with it; a number 0–3 pins that level for both — `0` = always full detail (a hero,
77
+ * a showcase), `2`/`3` = always cheap (a crowd filler). `model.anim.lod = 'full'` keeps the animation
78
+ * exact while the mesh still switches. No-op on hosts without the LOD pass. */
79
+ get lod(): LodMode { return this._lodMesh < 0 ? "auto" : (this._lodMesh as 0 | 1 | 2 | 3) }
80
+ set lod(v: LodMode) { this._lodMesh = v === "auto" ? -1 : Math.max(0, Math.min(3, Math.round(v))); _pushLod(this) }
81
+
82
+ /** @internal the default for a fresh instance (load / clone), decided once per host. */
83
+ static _cullingDefault(): boolean {
84
+ if (Model._cullDefault === undefined) Model._cullDefault = !!_creator.skinnedCullingSupported?.()
85
+ return Model._cullDefault
86
+ }
87
+ private static _cullDefault: boolean | undefined
88
+
89
+ /** @internal both flags travel together — the bridge walks the instance once. */
90
+ _applyShadows(): void { _creator.setGlbShadows?.(this.id, this._castShadows, this._receiveShadows) }
91
+
92
+ /** DEBUG: the closest polygon of this model under a screen point (logical px — `Input.mouse.position`,
93
+ * a touch event's clientX/Y), tested against the CPU-skinned CURRENT pose, both faces. Names the
94
+ * triangle, its three vertices (bind + skinned positions) and their raw bone weights, so a stretched
95
+ * or misbound polygon can be traced to its binding. One full CPU skin of the model per call: click-rate
96
+ * only. null = miss, or a host without the pick (desktop today). */
97
+ pickTriangle(screenX: number, screenY: number): TrianglePick | null {
98
+ const json = _creator.pickTriangle?.(this.id, screenX, screenY)
99
+ return json ? JSON.parse(json) as TrianglePick : null
100
+ }
101
+
102
+ /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
103
+ * the same parent and scene and sharing this model's current transform. The clone has its own
104
+ * independent animation state (reach it via clone.anim). Mirrors this model's culling flag. */
105
+ clone(): Model {
106
+ const id = _creator.cloneEntity(this.id)
107
+ const m = new Model(id)
108
+ m._lightmapped = this._lightmapped
109
+ m._foliage = this._foliage
110
+ if (!this._culling) { m._culling = false; _creator.setGlbCulling(id, false) } // the engine default is on
111
+ if (this._lodMesh >= 0) { m._lodMesh = this._lodMesh; _pushLod(m) }
112
+ // the clone is a FRESH gltfio instance — it comes back with the asset's own shadow flags,
113
+ // not this model's, so a cleared flag has to be re-applied
114
+ m._castShadows = this._castShadows
115
+ m._receiveShadows = this._receiveShadows
116
+ if (!this._castShadows || !this._receiveShadows) m._applyShadows()
117
+ return m
118
+ }
119
+
120
+ /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
121
+ static load(
122
+ source: string | FetchResponse,
123
+ options: {
124
+ /** Frustum culling — see `culling` (default: on where the host refits skinned bounds, else off). */
125
+ culling?: boolean
126
+ onProgress?: (p: { loaded: number, total?: number }) => void
127
+ /** Baked lighting (packages/creator-bake). `true` = a STATIC: loads through the lightmap material so
128
+ * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 — `lecodes assets doctor
129
+ * --lightmap-uv`) and takes nothing from the real-time lights once the bake applies. Omitted / `false` =
130
+ * the standard shader, lit real-time (movers). Hosts without lightmap support ignore it. */
131
+ lightmap?: boolean
132
+ /** Vegetation: load through the FOLIAGE tier (see `Foliage`) — wind, touch bending, distance fade,
133
+ * per-copy tint. Hosts without the tier fall back to the standard shader. */
134
+ foliage?: boolean
135
+ } = {},
136
+ ): Promise<Model> {
137
+ if (typeof source === "string") {
138
+ return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
139
+ if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
140
+ return Model.load(resp, options)
141
+ })
142
+ }
143
+ return new Promise<Model>((resolve, reject) => {
144
+ // the provider path: a foliage model takes the vegetation tier's pair when the host ships it (fetchLocal
145
+ // id 0 = it does not), a static the lightmap material + its masked twin; anything else the ubershader
146
+ const foliageWanted = !!options.foliage && !!_creator.setNextGlbLightmapped
147
+ let foliage = false
148
+ if (foliageWanted) {
149
+ const fol = Material.idOf(Material._foliageTemplate())
150
+ const folMasked = fol ? Material.idOf(Material._foliageMaskedTemplate()) : 0
151
+ foliage = fol > 0 && folMasked > 0
152
+ if (foliage) _creator.setNextGlbLightmapped!(fol, folMasked)
153
+ }
154
+ const lightmapped = !foliage && !!options.lightmap && !!_creator.setNextGlbLightmapped
155
+ if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()), Material.idOf(Material._lightmapMaskedTemplate()))
156
+ _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
157
+ if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
158
+ const model = new Model(entityId)
159
+ model._lightmapped = lightmapped
160
+ model._foliage = foliage
161
+ const culling = options.culling ?? Model._cullingDefault()
162
+ if (!culling) { model._culling = false; _creator.setGlbCulling(entityId, false) }
163
+ resolve(model)
164
+ }, reject)
165
+ })
166
+ }
167
+ }
package/src/gl/Node.ts CHANGED
@@ -12,10 +12,23 @@ 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
+ import { ensurePhysicsEvents } from "./physics/physicsEvents"
16
16
  import { Material } from "./Material"
17
17
  import type { CompAxis, CompWriter } from "../core/compWrite"
18
18
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
19
+ import type { Animation } from "../animate/tween/Animation"
20
+ import { CLOCK_GAME, DOM_GL_NODE, eulerValue, quatValue, vec3Value, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
21
+ import { tweenBag } from "../animate/tween/Timeline"
22
+
23
+ /** Animatable transform props of a 3D node: a value tweens from the current one, an ARRAY OF
24
+ * VALUES is a keyframe list (`position: [[0,0,0], [0,2,0]]`). */
25
+ export type NodeTweenProps = {
26
+ position?: Vec3Like | Vec3Like[],
27
+ scale?: number | Vec3Like | (number | Vec3Like)[],
28
+ quaternion?: QuatLike | QuatLike[],
29
+ /** Degrees, YXZ; interpolated per axis without shortest-arc, so `[0, 720, 0]` spins twice. */
30
+ eulerAngles?: Vec3Like | Vec3Like[],
31
+ }
19
32
 
20
33
  /** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
21
34
  export const nodeRegistry = new Registry<Node>()
@@ -197,6 +210,32 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
197
210
  if (this._xf) this._setOwnedRotation()
198
211
  }
199
212
 
213
+ // --- keyframe animation (docs/timeline-plan.md) ---
214
+ /** Tween the transform to the given values — `node.animateTo({ position: [0, 2, 0], duration: 800,
215
+ * easing: 'inOutCubic' })`; arrays of values are keyframes. Runs on the game clock (pauses with
216
+ * the game) unless `clock: 'ui'`. Returns the {@link Animation} handle. Phase 1: plain nodes —
217
+ * a physics-owned node (a body / character) is not routed through its engine object yet. */
218
+ animateTo(props: NodeTweenProps & TweenMeta): Animation {
219
+ return tweenBag(this, props as Record<string, unknown>, true)
220
+ }
221
+ /** Tween FROM the given values to the node's current transform (an entrance). */
222
+ animateFrom(props: NodeTweenProps & TweenMeta): Animation {
223
+ return tweenBag(this, props as Record<string, unknown>, false)
224
+ }
225
+ /** @internal keyframe-core target protocol */
226
+ readonly _tweenClock: number = CLOCK_GAME
227
+ /** @internal */
228
+ _tweenChannel(prop: string): TweenChannel | null {
229
+ const id = () => this.id
230
+ switch (prop) {
231
+ case "position": return { domain: DOM_GL_NODE, id, value: vec3Value }
232
+ case "scale": return { domain: DOM_GL_NODE, id, value: vec3Value }
233
+ case "quaternion": return { domain: DOM_GL_NODE, id, value: quatValue }
234
+ case "eulerAngles": return { domain: DOM_GL_NODE, id, value: eulerValue }
235
+ default: return null
236
+ }
237
+ }
238
+
200
239
  get eulerAngles(): Vec3 { return new Mat4(this._sync()).eulerAngles }
201
240
  // order 1 = YXZ — the SDK's euler convention (math/quat.ts); the getter also extracts YXZ, so
202
241
  // the pair round-trips. (Historically this passed 0/XYZ AND the engine stored the euler matrix
@@ -40,6 +40,10 @@ const TAG_ANGULAR_VELOCITY = 17
40
40
  const TAG_GROUND = 18
41
41
  const TAG_VELOCITY_LIFE = 19
42
42
  const TAG_ORDER = 20
43
+ const TAG_SMOOTH_PATH = 21
44
+ const TAG_RIBBON_ORIENT = 22
45
+ /** `TrailOptions.orient` → the native axis code (0 = the camera-facing default). */
46
+ const RIBBON_AXIS: Record<"camera" | "x" | "y" | "z", number> = { camera: 0, x: 1, y: 2, z: 3 }
43
47
 
44
48
  /** @internal — exported for the tag-sync test only. */
45
49
  export const _particleTags = {
@@ -49,7 +53,7 @@ export const _particleTags = {
49
53
  SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
50
54
  RENDER_MODE: TAG_RENDER_MODE, MESH_GEOMETRY: TAG_MESH_GEOMETRY,
51
55
  ANGULAR_VELOCITY: TAG_ANGULAR_VELOCITY, GROUND: TAG_GROUND, VELOCITY_LIFE: TAG_VELOCITY_LIFE,
52
- ORDER: TAG_ORDER,
56
+ ORDER: TAG_ORDER, SMOOTH_PATH: TAG_SMOOTH_PATH, RIBBON_ORIENT: TAG_RIBBON_ORIENT,
53
57
  }
54
58
 
55
59
  // --- curve builders -----------------------------------------------------------------------------
@@ -231,7 +235,15 @@ type Shape =
231
235
  * birth direction (a burst that decays: `curve(4).to(0)`), `x`/`y`/`z` drift in the simulation
232
236
  * space (rising smoke: `y: curve(2).from(0).to(1)`). Unity's velocityOverLifetime. */
233
237
  export type VelocityOverLife = { radial?: ParticleValue, x?: ParticleValue, y?: ParticleValue, z?: ParticleValue }
234
- type Noise = { strength?: number, frequency?: number, speed?: number }
238
+ type Noise = {
239
+ strength?: number, frequency?: number, speed?: number,
240
+ /** How much of the displacement a particle earns with AGE (default 0 = all of it from birth).
241
+ * The field is sampled by position, so at 0 every particle born at the same spot is pushed the
242
+ * same way and a plume's root wanders off its emitter by up to `strength` metres; at 1 a particle
243
+ * is born exactly where the emitter put it and drifts into the field over its life — what you
244
+ * want whenever the source is visible (an exhaust pipe, a contact patch, a muzzle). */
245
+ ramp?: number,
246
+ }
235
247
 
236
248
  /** Ground plane for bouncing particles (all render modes). `height` is in the simulation space:
237
249
  * world y with `space: 'world'`, emitter-local y otherwise. */
@@ -278,11 +290,25 @@ export type ParticlesOptions = ParticlesMaterialOptions & {
278
290
  * spread evenly along the path — trail density independent of speed, no per-frame clumps.
279
291
  * Meant for `space: 'world'`. */
280
292
  rateOverDistance?: number
293
+ /** Spread those spawn points along a CURVE through the emitter's recent path instead of the
294
+ * straight line between where it was last frame and where it is now. A straight line cuts the
295
+ * corner by however far the path bows inside one frame, which grows with the frame TIME — so a
296
+ * fast curving emitter looks faceted, and looks worse the slower the machine. Default false
297
+ * (the straight line); costs three stored positions and one curve evaluation per spawn.
298
+ *
299
+ * The curve needs a point one frame AHEAD, so each frame's spawns are placed provisionally and
300
+ * nudged into place on the next frame, once it exists. Nothing else observes them in between. */
301
+ smooth?: boolean
281
302
  /** Draw order among blended systems at the same depth — higher draws later, i.e. on top (Unity's
282
303
  * sortingOrder). Systems are depth-sorted by their node, so the emitters of one effect tie and
283
304
  * without this the engine picks who covers whom per frame (smoke popping over a fireball).
284
305
  * Default 0; a fireball wants 2, its smoke 1, a smoke trail -1. */
285
306
  order?: number
307
+ /** Coarse draw order among ALL blended draws, 0 (first) … 7 (last) — see `Mesh.renderPriority`.
308
+ * Default 5: meshes sit at 4 and decals at 3, so smoke covers a car's glass and its skid marks
309
+ * whatever the camera does (the engine's depth sort compares object centres, and an emitter's
310
+ * centre says nothing about where its cloud is). `order` breaks ties inside one priority. */
311
+ renderPriority?: number
286
312
  shape?: Shape
287
313
  /** Extra velocity over life (radial burst that decays, axis drift) — see `VelocityOverLife`. */
288
314
  velocityOverLife?: VelocityOverLife
@@ -437,6 +463,7 @@ export class Particles extends Node {
437
463
  super()
438
464
  this._material = options.material ?? (options.mesh ? Material.lit() : Material.particles(options))
439
465
  _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxParticles ?? 0)
466
+ this.renderPriority = options.renderPriority ?? 5
440
467
 
441
468
  const id = this.id
442
469
  this.custom = new Proxy([] as ParticleValue[], {
@@ -463,6 +490,7 @@ export class Particles extends Node {
463
490
  if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
464
491
  if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
465
492
  if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
493
+ if (options.smooth !== undefined) out.push(TAG_SMOOTH_PATH, 1, options.smooth ? 1 : 0)
466
494
  if (options.order !== undefined) out.push(TAG_ORDER, 1, options.order)
467
495
  if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
468
496
  if (options.shape !== undefined) pushShape(out, options.shape)
@@ -496,7 +524,7 @@ export class Particles extends Node {
496
524
  }
497
525
  if (options.noise !== undefined) {
498
526
  const n = options.noise
499
- out.push(TAG_NOISE, 3, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0)
527
+ out.push(TAG_NOISE, 4, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0, n ? n.ramp ?? 0 : 0)
500
528
  }
501
529
  if (options.seed !== undefined) out.push(TAG_SEED, 1, options.seed)
502
530
 
@@ -516,7 +544,13 @@ export class Particles extends Node {
516
544
  set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
517
545
  set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
518
546
  set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
547
+ /** Lay a frame's spawns along a curve through the emitter's path, not the straight chord. */
548
+ set smooth(val: boolean) { this._send([ TAG_SMOOTH_PATH, 1, val ? 1 : 0 ]) }
519
549
  set order(val: number) { this._send([ TAG_ORDER, 1, val ]) }
550
+ /** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
551
+ set renderPriority(v: number) {
552
+ if (_creator.setRenderPriority) _creator.setRenderPriority(this.id, Math.max(0, Math.min(7, Math.round(v))))
553
+ }
520
554
 
521
555
  set velocityOverLife(v: VelocityOverLife) {
522
556
  const out: number[] = []
@@ -585,7 +619,7 @@ export class Particles extends Node {
585
619
 
586
620
  set noise(noise: Noise | null) {
587
621
  this._send(noise
588
- ? [ TAG_NOISE, 3, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1 ]
622
+ ? [ TAG_NOISE, 4, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1, noise.ramp ?? 0 ]
589
623
  : [ TAG_NOISE, 3, 0, 0, 0 ])
590
624
  }
591
625
  }
@@ -602,8 +636,28 @@ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch">
602
636
  width?: ParticleValue
603
637
  /** Minimum emitter movement (world units) between recorded points. Default 0.05. */
604
638
  minDistance?: number
605
- /** Point capacity (default 128). */
639
+ /** Point capacity (default 128). A spawn that does not fit is DROPPED, so a fast emitter at a
640
+ * small `minDistance` wants headroom: a sword tip covers metres inside one `time` window. */
606
641
  maxPoints?: number
642
+ /** Follow a CURVE through the emitter's recent path instead of the straight line between frames.
643
+ * A trail is where this shows most — the strip is the path — and it shows worse the lower the
644
+ * frame rate, since a longer frame bows further off its own chord. Default false; see
645
+ * `ParticlesOptions.smooth`. */
646
+ smooth?: boolean
647
+ /** Which way the strip's WIDTH points.
648
+ *
649
+ * `'camera'` (default) rolls the strip about its own length to stay flat to the viewer — it can
650
+ * never disappear, which is why it is the default, but it bears no relation to the thing that drew
651
+ * it. An AXIS (`'x'`/`'y'`/`'z'`) uses that axis of the emitter's own transform, as it was when
652
+ * each point was laid: on a node riding a sword's blade, `'z'` (the blade) makes the strip the
653
+ * surface the blade actually swept, and `width` stops being a made-up number — it is the blade. */
654
+ orient?: "camera" | "x" | "y" | "z"
655
+ /** With `orient` on an axis: the floor under the strip's APPARENT width, 0..1 of its real width,
656
+ * below which it rolls back toward the camera. An honestly oriented strip goes edge-on — and
657
+ * vanishes — whenever the swing happens in the plane of view; this is the hybrid that keeps it
658
+ * readable. 0 = never roll (honest, and it will vanish), 1 = always (the same as `'camera'`).
659
+ * Default 0.4: a sweep still reads as a sweep, and a swing toward the camera still shows. */
660
+ faceCamera?: number
607
661
  color?: ParticleColor
608
662
  /** Opacity along the trail. Defaults to fading the tail out (`curve().to(0)`). */
609
663
  opacity?: ParticleValue
@@ -617,6 +671,8 @@ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch">
617
671
  export class Trail extends Node {
618
672
  private _material: Material
619
673
  private _rateDistance: number
674
+ private _orient = 0
675
+ private _faceCamera = 0.4
620
676
 
621
677
  constructor(options: TrailOptions = {}) {
622
678
  super()
@@ -632,6 +688,12 @@ export class Trail extends Node {
632
688
  TAG_RATE_DISTANCE, 1, this._rateDistance,
633
689
  TAG_LIFETIME, 2, time, time,
634
690
  ]
691
+ if (options.smooth) out.push(TAG_SMOOTH_PATH, 1, 1)
692
+ if (options.orient !== undefined || options.faceCamera !== undefined) {
693
+ this._orient = RIBBON_AXIS[options.orient ?? "camera"]
694
+ if (options.faceCamera !== undefined) this._faceCamera = options.faceCamera
695
+ out.push(TAG_RIBBON_ORIENT, 2, this._orient, this._faceCamera)
696
+ }
635
697
  pushParam(out, 0, options.width ?? 0.1)
636
698
  pushParam(out, 2, options.opacity ?? curve().to(0))
637
699
  if (options.color !== undefined) pushColor(out, options.color)
@@ -668,6 +730,21 @@ export class Trail extends Node {
668
730
  this._send([ TAG_RATE_DISTANCE, 1, this._rateDistance ])
669
731
  }
670
732
 
733
+ /** Follow a curve through the emitter's path instead of the straight chord between frames. */
734
+ set smooth(val: boolean) { this._send([ TAG_SMOOTH_PATH, 1, val ? 1 : 0 ]) }
735
+
736
+ /** Which way the strip's width points — see `TrailOptions.orient`. Keeps the current `faceCamera`. */
737
+ set orient(val: "camera" | "x" | "y" | "z") {
738
+ this._orient = RIBBON_AXIS[val]
739
+ this._send([ TAG_RIBBON_ORIENT, 2, this._orient, this._faceCamera ])
740
+ }
741
+
742
+ /** The hybrid's floor — see `TrailOptions.faceCamera`. */
743
+ set faceCamera(val: number) {
744
+ this._faceCamera = val
745
+ this._send([ TAG_RIBBON_ORIENT, 2, this._orient, val ])
746
+ }
747
+
671
748
  /** Pause/resume laying down points — existing ones still age out, so the trail fades naturally
672
749
  * after e.g. a sword swing ends. */
673
750
  set emitting(val: boolean) { this._send([ TAG_RATE_DISTANCE, 1, val ? this._rateDistance : 0 ]) }
package/src/gl/Scene.ts CHANGED
@@ -13,7 +13,7 @@ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
13
13
  import type { FetchResponse } from "../runtime/fetch"
14
14
  import { _requestCameraPermission } from "../plugins/permission"
15
15
  import { Camera } from "./Camera"
16
- import { SceneAudio } from "./SceneAudio"
16
+ import { SceneAudio } from "./audio/SceneAudio"
17
17
  import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
18
18
  import { Material } from "./Material"
19
19
  import { Texture } from "./Texture"
@@ -177,7 +177,8 @@ const installRenderSyncedFrames = (): void => {
177
177
  export class Scene implements Presentable {
178
178
  /** @internal native scene handle. */
179
179
  readonly _id: number
180
- /** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
180
+ /** @internal what `environmentIntensity` currently asks for — the accessor's backing field, so it
181
+ * answers on hosts whose engine has no live setter too. */
181
182
  _environmentIntensity = 20000
182
183
  readonly camera: Camera
183
184
  /** The listener + global 3D audio knobs (docs/audio-plan.md). */
@@ -309,6 +310,26 @@ export class Scene implements Presentable {
309
310
  setBloom(enabled: boolean, intensity = 0.2): void {
310
311
  _creator.setBloomOptions(this._id, enabled, intensity, 1)
311
312
  }
313
+ /**
314
+ * How bright the environment (IBL) lights the scene, in lux — `SceneOptions.environmentIntensity`
315
+ * after the fact. Live: it changes the probe's intensity, not the probe, so it costs nothing and
316
+ * can be dragged. (`setDefaultIbl`, the call that installs a probe, rebuilds the cubemap from the
317
+ * ktx every time — never drive a slider through a scene option that has to re-open.)
318
+ *
319
+ * `lecodes lightmap bake` reads the same number off the live probe, so a scene dimmed here bakes
320
+ * dimmed. Hosts without the call keep whatever the scene opened with, and the getter still
321
+ * reports what was asked for.
322
+ */
323
+ get environmentIntensity(): number { return this._environmentIntensity }
324
+ set environmentIntensity(lux: number) {
325
+ this._environmentIntensity = lux
326
+ if (_creator.setEnvironmentIntensity) _creator.setEnvironmentIntensity(this._id, lux)
327
+ else if (!Scene._warnedEnvIntensity) {
328
+ Scene._warnedEnvIntensity = true
329
+ console.warn("[scene] this host has no setEnvironmentIntensity — environmentIntensity stays as the scene opened")
330
+ }
331
+ }
332
+ private static _warnedEnvIntensity = false
312
333
  /** The scene's stencil buffer on / off (see `SceneOptions.stencil`). */
313
334
  setStencil(enabled: boolean): void {
314
335
  if (_creator.setSceneStencil) _creator.setSceneStencil(this._id, enabled)
@@ -367,6 +388,18 @@ export class Scene implements Presentable {
367
388
  return new Promise<void>((res) => _creator.warmRender(this._id, res))
368
389
  }
369
390
 
391
+ /** Precompile the scene's shader variants so nothing is compiled mid-game. Every material the host
392
+ * knows is queued for the sun / shadow / fog / skinning variants and the dynamic-light key — the
393
+ * first point light (a muzzle flash, an explosion) would otherwise rebuild every lit shader in view
394
+ * on that frame. Call it once the sun and fog are set, typically under a loading screen; materials
395
+ * loaded later are queued in the background as they are created. Resolves at once on a host
396
+ * without the bridge. */
397
+ precompileShaders(): Promise<void> {
398
+ const fn = _creator.precompileShaders
399
+ if (!fn) return Promise.resolve()
400
+ return new Promise<void>((res) => fn.call(_creator, this._id, res))
401
+ }
402
+
370
403
  // ---- Presentable (docs/navigation-presentable-plan.md) ----
371
404
  // A scene is a destination like a screen: opening it replaces whatever is visible (suspending
372
405
  // an active Router until Router.restore()), and it can be pushed onto the Router stack. The