lecodes-sdk 1.0.0 → 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 (78) hide show
  1. package/dist/global.d.ts +18 -4
  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/g2/Node2D.d.ts +16 -0
  8. package/dist/types/g2/Sprite.d.ts +11 -1
  9. package/dist/types/gl/Camera.d.ts +15 -1
  10. package/dist/types/gl/Foliage.d.ts +47 -0
  11. package/dist/types/gl/Geometry.d.ts +24 -0
  12. package/dist/types/gl/Light.d.ts +25 -7
  13. package/dist/types/gl/Lightmap.d.ts +90 -60
  14. package/dist/types/gl/Material.d.ts +28 -20
  15. package/dist/types/gl/Model.d.ts +7 -5
  16. package/dist/types/gl/Node.d.ts +18 -0
  17. package/dist/types/gl/Particles.d.ts +40 -1
  18. package/dist/types/gl/Scene.d.ts +20 -0
  19. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  20. package/dist/types/gl/animation/Animator.d.ts +27 -0
  21. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  22. package/dist/types/gl/animation/IK.d.ts +86 -30
  23. package/dist/types/gl/animation/Warp.d.ts +2 -1
  24. package/dist/types/gl/animation/core.d.ts +35 -4
  25. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  26. package/dist/types/gl/terrain/Terrain.d.ts +4 -2
  27. package/dist/types/inject.d.ts +8 -2
  28. package/dist/types/scene/defineScene.d.ts +44 -32
  29. package/dist/types/ui/UIButton.d.ts +3 -1
  30. package/dist/types/ui/UIInput.d.ts +5 -1
  31. package/dist/types/ui/UINode.d.ts +24 -24
  32. package/dist/types.json +1 -1
  33. package/package.json +1 -1
  34. package/prompts/core-design.md +27 -4
  35. package/prompts/core.md +35 -6
  36. package/prompts/select.ts +19 -4
  37. package/src/animate/tween/Animation.ts +378 -0
  38. package/src/animate/tween/Timeline.ts +175 -0
  39. package/src/animate/tween/animateValue.ts +100 -0
  40. package/src/animate/tween/easing.ts +172 -0
  41. package/src/animate/tween/spec.ts +479 -0
  42. package/src/bridges.d.ts +226 -65
  43. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  44. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  45. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  46. package/src/compile/bundler.ts +34 -4
  47. package/src/compile/compileProject.ts +31 -1
  48. package/src/compile/detectEntry.ts +8 -3
  49. package/src/compile/index.ts +2 -0
  50. package/src/compile/serverSplit.ts +9 -3
  51. package/src/g2/Node2D.ts +38 -0
  52. package/src/g2/Sprite.ts +20 -1
  53. package/src/gl/Camera.ts +34 -1
  54. package/src/gl/Foliage.ts +102 -0
  55. package/src/gl/Geometry.ts +393 -348
  56. package/src/gl/Light.ts +46 -16
  57. package/src/gl/Lightmap.ts +439 -275
  58. package/src/gl/Material.ts +59 -47
  59. package/src/gl/Model.ts +167 -156
  60. package/src/gl/Node.ts +39 -0
  61. package/src/gl/Particles.ts +61 -2
  62. package/src/gl/Scene.ts +34 -1
  63. package/src/gl/animation/AnimationClip.ts +52 -0
  64. package/src/gl/animation/Animator.ts +42 -2
  65. package/src/gl/animation/DynamicBone.ts +482 -459
  66. package/src/gl/animation/IK.ts +173 -152
  67. package/src/gl/animation/Playback.ts +5 -4
  68. package/src/gl/animation/Warp.ts +5 -2
  69. package/src/gl/animation/core.ts +65 -4
  70. package/src/gl/physics/Ragdoll.ts +451 -272
  71. package/src/gl/terrain/Terrain.ts +4 -2
  72. package/src/inject.ts +12 -2
  73. package/src/scene/defineScene.ts +72 -62
  74. package/src/ui/UIButton.ts +2 -2
  75. package/src/ui/UIInput.ts +3 -3
  76. package/src/ui/UINode.ts +61 -36
  77. package/dist/types/animate/animate.d.ts +0 -20
  78. package/src/animate/animate.ts +0 -238
@@ -46,11 +46,13 @@ import {
46
46
  } from "./terrainMesh"
47
47
 
48
48
  export type TerrainLayer = {
49
- /** Albedo texture (a URL / `asset()` handle, or a loaded Texture). Unset = white. */
49
+ /** Albedo texture (a URL / `asset()` handle, or a loaded Texture). Unset = white. Its ALPHA is the layer's roughness
50
+ * map (`lecodes assets terrain-pack --roughness` puts it there): an opaque albedo reads 1. */
50
51
  albedo?: string | Texture
51
52
  /** Metres per texture repeat. Default 8. */
52
53
  tiling?: number
53
- /** Perceptual roughness of the layer. Default 1. */
54
+ /** Perceptual roughness of the layer — a factor on the albedo's alpha (its roughness map), the whole value when the
55
+ * albedo is opaque. A terrain has no metallic: ground is a dielectric. Default 1. */
54
56
  roughness?: number
55
57
  /** Normal-map strength (0 = the layer's slot of the pack is not read). Default 0 — set 1 when the
56
58
  * layer has a normal map in `normals`. */
package/src/inject.ts CHANGED
@@ -123,8 +123,16 @@ export type { UINode, UINodeChild } from "./ui/UINode"
123
123
  export { theme, type ThemeValues, type ThemeAccessors } from "./ui/theme"
124
124
 
125
125
  // ---- animate ----
126
- export { animate, animateMat4, stopAnimation, pauseAnimation, resumeAnimation } from "./animate/animate"
126
+ // animate() runs on the keyframe core (a VALUE track) since 2026-09-14: same handle, easing and clocks
127
+ // as animateTo; stop / pause / resume are the handle's methods, a matrix is just a value kind.
128
+ export { animate, type AnimateOptions, type AnimateValue, type AnimateOut } from "./animate/tween/animateValue"
127
129
  export { cubicBezier } from "./animate/bezier"
130
+ // Keyframe animation core (docs/timeline-plan.md): the handle `animateTo` / `animateFrom` return,
131
+ // and `Timeline()` — choreography over UI elements, 3D / 2D nodes, lights and cameras.
132
+ export { Timeline, type TimelineOptions, type TimelineAddOptions, type TimelinePosition } from "./animate/tween/Timeline"
133
+ export { type Animation } from "./animate/tween/Animation"
134
+ export { type TweenMeta } from "./animate/tween/spec"
135
+ export { type EasingInput } from "./animate/tween/easing"
128
136
  export { easeIn, easeOut, easeInOut } from "./animate/easings"
129
137
 
130
138
  // ---- plugins (optional, host-provided capabilities — typed NativeView/service wrappers, see src/plugins/) ----
@@ -182,7 +190,7 @@ export { Vehicle } from "./gl/vehicle/Vehicle"
182
190
  export type { DriveLayout, DifferentialMode, EngineConfig, SteeringConfig, SteeringFeedback, SteeringDriver, AeroConfig } from "./gl/vehicle/Vehicle"
183
191
  export { Wheel, Tire, resolveTire } from "./gl/vehicle/Wheel"
184
192
  export { Ragdoll } from "./gl/physics/Ragdoll"
185
- export type { RagdollPart, RagdollHinge } from "./gl/physics/Ragdoll"
193
+ export type { RagdollPart, RagdollHinge, RagdollFacing, RagdollActivateOptions, RagdollDeactivateOptions, RagdollHitOptions } from "./gl/physics/Ragdoll"
186
194
  export type { Point, WheelSuspension } from "./gl/vehicle/Wheel"
187
195
  export { AnimationClip } from "./gl/animation/AnimationClip"
188
196
  export type { ClipDef, ClipTrackDef, ClipKey } from "./gl/animation/AnimationClip"
@@ -203,6 +211,8 @@ export type { DynamicBoneCurve, DynamicBoneFloor, DynamicBoneColliders } from ".
203
211
  export type { IKTwoBone, IKLookAt } from "./gl/animation/IK"
204
212
  export { Light } from "./gl/Light"
205
213
  export { Lightmap } from "./gl/Lightmap"
214
+ export { Foliage } from "./gl/Foliage"
215
+ export type { FoliageOptions, FoliageWind } from "./gl/Foliage"
206
216
  export { Terrain } from "./gl/terrain/Terrain"
207
217
  export type { TerrainOptions, TerrainLayer, TerrainRegion, ConformOptions, RibbonOptions, AutoPaintRules, SculptOptions, TerrainHit, TerrainSnapshot } from "./gl/terrain/Terrain"
208
218
  export { NavMesh, NavCrowd } from "./gl/nav/NavMesh"
@@ -48,10 +48,12 @@ import { Mesh } from "../gl/Mesh"
48
48
  import { Model } from "../gl/Model"
49
49
  import { Physics } from "../gl/physics/Physics"
50
50
  import { CharacterController } from "../gl/physics/CharacterController"
51
- import { Lightmap } from "../gl/Lightmap"
51
+ import { Lightmap, type LightmapTransmit } from "../gl/Lightmap"
52
+ import { Foliage, type FoliageOptions } from "../gl/Foliage"
52
53
  import { NavMesh } from "../gl/nav/NavMesh"
53
54
  import { Terrain, type TerrainLayer, type TerrainRegion, type TerrainOptions } from "../gl/terrain/Terrain"
54
55
  import { Light, type SunOptions } from "../gl/Light"
56
+ import { Material } from "../gl/Material"
55
57
  import { assignMaterialDef, MaterialHandle, resolveMaterialDef, type MaterialDef } from "./material"
56
58
  import type { CapsuleOptions, CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
57
59
  import { GizmoBuffer, Gizmos, withGizmoScope, type GizmoBatch } from "./gizmos"
@@ -184,6 +186,10 @@ export type SceneNodeDef = {
184
186
  * Default: static unless a `Physics` aspect moves the node (`dynamic` — Physics' default — or
185
187
  * `kinematic`). Set it only to override that rule; prefab subtrees inherit the verdict. */
186
188
  lightmap?: boolean
189
+ /** `model` nodes: vegetation — load through the FOLIAGE tier (wind, touch bending, per-copy tint; see
190
+ * `Foliage`). `{ fade: [start, end] }` also thins the cards out over that distance range (m) and drops the
191
+ * copy past `end` — for ground cover. The fade is per ASSET: every node of the same GLB shares it. */
192
+ foliage?: boolean | { fade?: [number, number] }
187
193
  /** Navigation (a scene with `env.navmesh`, see navmesh.md): every STATIC body is walkable by
188
194
  * default. `false` leaves this one out of the bake; `'unwalkable'` cuts its footprint out
189
195
  * (nobody stands on or crosses it). */
@@ -201,41 +207,44 @@ export type SceneCameraDef = CameraProjectionDef & {
201
207
  target?: Vec3Like
202
208
  }
203
209
 
204
- /** `env.lightmap` — the level's baked lighting (see lightmap.md): the two files
205
- * `lecodes lightmap bake` writes, plus how the bake is applied. Absent = real-time only. */
210
+ /** `env.lightmap` — the level's baked lighting (packages/creator-bake): the files `lecodes lightmap bake`
211
+ * writes. Absent = real-time only. */
206
212
  export type SceneLightmapDef = {
207
- /** `asset('./assets/lightmap/lightmap.bake')` */
213
+ /** `asset('./assets/lightmap/level.bake')` */
208
214
  data: string
209
- /** `asset('./assets/lightmap/lightmap.ktx2')` — or, for a bake that took more than one atlas PAGE
210
- * (`lecodes lightmap bake --pages`), every page in order: `[asset('x.ktx2'), asset('x_1.ktx2')]`. */
211
- texture: string | string[]
212
- /** `asset('./assets/lightmap/lightmap-light.ktx2')` (pages like `texture`) — the point lights' baked
213
- * irradiance the bake writes when the level holds point lights; the real-time lights it carries are
214
- * switched off once it applies. Absent = the lamps stay real-time. */
215
- light?: string | string[]
216
- /** `asset('./assets/lightmap/lightmap.volume')` — the light VOLUME for everything that moves (the
217
- * same bake writes it): dynamic nodes, and every model loaded in code while the level runs, read
218
- * the baked sun/ambient at their own position. Absent = movers stay real-time only. */
215
+ /** `asset('./assets/lightmap/level-light.ktx2')` — or, for a bake that took more than one atlas PAGE
216
+ * (`lecodes lightmap bake --pages`), every page in order: `[asset('x-light.ktx2'), asset('x-light_1.ktx2')]`. */
217
+ light: string | string[]
218
+ /** `asset('./assets/lightmap/level-aux.ktx2')` (pages like `light`) — sun / sky visibility + light direction. */
219
+ aux: string | string[]
220
+ /** DEBUG: the page sets a `lecodes lightmap bake --split` wrote (`<stem>-direct[_n].ktx2`, `<stem>-indirect[_n].ktx2`),
221
+ * pages like `light` — the `Lightmap.debug("direct" | "indirect")` views bind them in place of `light`. */
222
+ direct?: string | string[]
223
+ indirect?: string | string[]
224
+ /** `asset('./assets/lightmap/level-probes.ktx2')` — the bake's reflection probes (one prefiltered cubemap of the level's
225
+ * radiance per probe; every static reflects the nearest). Absent = statics reflect the sky's SH alone. */
226
+ probes?: string
227
+ /** FOR THE BAKE: the radiance in cd / m² of emission 1.0 on this level (emissive factor x strength x map) - every lit
228
+ * region of an emissive surface bakes as a rectangle lamp. An imported pack's emission is a LOOK (the school's panels
229
+ * say 10); this is where the level says what they give: a 1.3 m ceiling panel of ~8000 lm is ~1600 nits. Default: what
230
+ * the camera makes of 1.0 (`1.2 x 2^EV100`). 0 = emissive surfaces light nothing. `--emissive-nits` overrides. */
231
+ emissiveNits?: number
232
+ /** `false` = the emissive surfaces light nothing in the bake - the level is lit by its lamps (`Light.point` with a
233
+ * `bakeArea` next to every panel, the Unity packs' way) and the panels are decoration; `emissiveNits` then only says how
234
+ * bright their glow is DRAWN (scene-referred: nits through the camera's exposure). Default true. */
235
+ emissiveBake?: boolean
236
+ /** FOR THE BAKE: materials that let light THROUGH them, by glTF material name (`"name*"` = every name with that start) →
237
+ * the share of a shadow ray that passes, tinted by the material's base colour x map: `{ mat_awning: 0.3 }` puts the
238
+ * awning's warm, patterned light on the sand. The surface stays opaque - baked, drawn as before, a shadow caster.
239
+ * `{ through, diffuse }` splits it: `through` goes straight (the picture of the map), `diffuse` is scattered by the
240
+ * fibres (the underside glows, a soft fill with no picture) - dense canvas: `{ through: 0.08, diffuse: 0.25 }`. */
241
+ transmit?: Record<string, LightmapTransmit>
242
+ /** `asset('./assets/lightmap/level.lgrid')` — THE LIGHT GRID for movers: every node a Physics aspect or a
243
+ * CharacterController moves (and what code marks with `Lightmap.track`) takes its ambient light from the bake at the
244
+ * place it is at, whatever its shader. Absent = movers keep the scene's IBL. */
219
245
  volume?: string
220
- /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
221
- sunStrength?: number
222
- /** Multiplier on the ambient share in the shadow math (1 = filament's own darkness). Default 1. */
223
- ambientScale?: number
224
- /** How much of the baked ambient occlusion applies: 1 = all of it, 0 = none. An interior lit by its
225
- * ambient probe alone can want less than the geometric truth. Default 1. */
226
- aoStrength?: number
227
- /** Multiplier on the baked point lights' irradiance (default 1) - dial the lamps up or down
228
- * without a re-bake, since their irradiance lives in the atlas. Scales the light volume with the
229
- * atlas, so movers match the statics. See `LightmapLoadOptions.lightBoost`. */
230
- lightBoost?: number
231
- /** Multiplier on the light VOLUME's irradiance alone (default = `lightBoost`). Since the volume is an
232
- * ambient cube (2026-08-31) movers shade like the statics and the default is right; it stays as a
233
- * trim. See `LightmapLoadOptions.volumeBoost`. */
234
- volumeBoost?: number
235
- /** Occluder-only statics (props without lightmap UVs): `"baked"` (default) = their shadow is in the
236
- * atlas, they stop casting in real time and the shadow pass draws only the movers; `"realtime"` =
237
- * they keep casting (their shadow on other non-receiver props). See `LightmapLoadOptions.occluderShadows`. */
238
- occluderShadows?: "baked" | "realtime"
246
+ /** A DEBUG multiplier on the atlas' lux (default 1). */
247
+ lightScale?: number
239
248
  }
240
249
 
241
250
  /** `env.navmesh` — the level's navigation mesh (see navmesh.md): the file `lecodes navmesh bake`
@@ -250,7 +259,7 @@ export type SceneNavmeshDef = {
250
259
  }
251
260
 
252
261
  export type SceneDef = {
253
- env?: SceneOptions & { lightmap?: SceneLightmapDef, navmesh?: SceneNavmeshDef }
262
+ env?: SceneOptions & { lightmap?: SceneLightmapDef, navmesh?: SceneNavmeshDef, foliage?: FoliageOptions }
254
263
  camera?: SceneCameraDef
255
264
  nodes?: Record<string, SceneNodeDef>
256
265
  }
@@ -422,7 +431,7 @@ const createMesh = async (def: MeshDef, material?: MaterialDef): Promise<Mesh> =
422
431
  // rename honestly invalidates the rect. make() subtrees are code — they register themselves with
423
432
  // Lightmap.add. Edit mode never bakes or applies (the editor shows real-time shadows).
424
433
 
425
- type LightmapCtx = { prefix: string, volume: boolean, dynamicOnly?: boolean }
434
+ type LightmapCtx = { prefix: string }
426
435
 
427
436
  // A CharacterController moves its node as surely as a dynamic body does (2026-09-11: the hero's
428
437
  // capsule was read as static, so its sword baked a shadow into the terrain at the spawn point).
@@ -435,12 +444,12 @@ const movesByPhysics = (def: SceneNodeDef): boolean =>
435
444
  * Children of a mover are movers too — buildNodes carries that down, this is the node's own verdict. */
436
445
  export const isLightmapStatic = (def: SceneNodeDef): boolean => def.lightmap ?? !movesByPhysics(def)
437
446
 
438
- // `lightmap` undefined = Model.load's own default ('dynamic' while a level with a light volume runs —
439
- // this is how a weapon or arms scene instantiated from code reads the volume; an explicit false would win)
440
447
  /** A terrain node: from its file or a fresh flat grid; textures load before the node appears (like a
441
448
  * mesh's material). Stamps and holes apply later in buildNodes, once every target node exists. */
442
- const createTerrain = async (def: TerrainNodeDef): Promise<Node> => {
443
- const { data, stamps: _s, holes: _h, ...opts } = def
449
+ const createTerrain = async (def: TerrainNodeDef, lightmap: boolean): Promise<Node> => {
450
+ const { data, stamps: _s, holes: _h, ...rest } = def
451
+ // a lightmap static is built with the baked material from the start, like a model is loaded lightmapped
452
+ const opts = lightmap ? { ...rest, material: Material.terrainLightmap() } : rest
444
453
  const t = data !== undefined ? await Terrain.load(data, opts) : Terrain.create(opts)
445
454
  await t.ready
446
455
  return t.node
@@ -457,14 +466,11 @@ const createTerrain = async (def: TerrainNodeDef): Promise<Node> => {
457
466
  // cannot leak into the next node that leaves the flag alone.
458
467
  const modelTemplates = new Map<string, Promise<Model>>()
459
468
 
460
- const loadModel = (source: string, lightmap: boolean | "dynamic" | undefined): Promise<Model> => {
461
- // `Model.load` resolves an omitted mode against Model._lightmapDefault, which the running level
462
- // sets — so the KEY has to hold the resolved mode, not the argument.
463
- const mode = lightmap ?? Model._lightmapDefault
464
- const key = `${mode}\u0000${source}`
469
+ const loadModel = (source: string, lightmap: boolean, foliage: boolean): Promise<Model> => {
470
+ const key = `${lightmap}\u0000${foliage ? "foliage" : ""}\u0000${source}`
465
471
  let template = modelTemplates.get(key)
466
472
  if (template === undefined) {
467
- template = Model.load(source, { lightmap: mode })
473
+ template = Model.load(source, { lightmap, foliage })
468
474
  modelTemplates.set(key, template)
469
475
  // a failed load must not poison the cache — the next node retries
470
476
  void template.catch(() => modelTemplates.delete(key))
@@ -472,12 +478,12 @@ const loadModel = (source: string, lightmap: boolean | "dynamic" | undefined): P
472
478
  return template.then((t) => t.clone())
473
479
  }
474
480
 
475
- const createSource = (path: string, def: SceneNodeDef, lightmap: boolean | "dynamic" | undefined): Node | Promise<Node> => {
481
+ const createSource = (path: string, def: SceneNodeDef, lightmap: boolean): Node | Promise<Node> => {
476
482
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera, def.terrain ].filter((s) => s !== undefined).length
477
483
  if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera/terrain)`)
478
- if (def.model !== undefined) return loadModel(def.model, lightmap)
484
+ if (def.model !== undefined) return loadModel(def.model, lightmap, !!def.foliage)
479
485
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
480
- if (def.terrain !== undefined) return createTerrain(def.terrain)
486
+ if (def.terrain !== undefined) return createTerrain(def.terrain, lightmap)
481
487
  if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
482
488
  // make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
483
489
  // during the build (attachPrefab) or in phase 2 (attachMake); a camera node drives scene.camera
@@ -531,6 +537,8 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
531
537
  if (t && def.receiveShadows !== undefined) t.receiveShadows = def.receiveShadows
532
538
  }
533
539
  if (def.overrides && def.model !== undefined) applyModelOverrides(node, def.overrides)
540
+ // the foliage fade is per asset — one node's range is every copy's (the last def wins)
541
+ if (typeof def.foliage === "object" && def.foliage.fade && node instanceof Model && node._foliage) Foliage.fade(node, def.foliage.fade[0], def.foliage.fade[1])
534
542
  }
535
543
 
536
544
  // ---- edit-mode node markers + the play-mode camera rig -------------------------------------------
@@ -870,6 +878,7 @@ const buildNodes = async (
870
878
  lm: LightmapCtx | null = null,
871
879
  ): Promise<void> => {
872
880
  const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
881
+ const moverRoots: Node[] = []
873
882
 
874
883
  // `parentMoves`: a child of a mover moves with it (the body under the hero's capsule) — it never
875
884
  // bakes, whatever its own def says short of an explicit `lightmap: true`
@@ -881,10 +890,9 @@ const buildNodes = async (
881
890
  // the path is derived from def keys BEFORE any await, so it is deterministic even though
882
891
  // Promise.all makes build completion (and record insertion) order nondeterministic
883
892
  const path = parentPath === "" ? name : `${parentPath}/${name}`
884
- const lmStatic = lm !== null && !lm.dynamicOnly && (nd.lightmap ?? (!parentMoves && isLightmapStatic(nd)))
893
+ const lmStatic = lm !== null && (nd.lightmap ?? (!parentMoves && isLightmapStatic(nd)))
885
894
  // a mover in a level with a light volume reads the volume (models: the engine binds it; meshes: registered below)
886
- const lmDynamic = lm !== null && !lmStatic && lm.volume
887
- const node = await createSource(path, nd, lmStatic ? true : lmDynamic ? "dynamic" : undefined)
895
+ const node = await createSource(path, nd, lmStatic)
888
896
  // a `mount` def parents to an internal part of the parent asset — safe here: the parent's
889
897
  // model was awaited and attachPrefab completed before its children build
890
898
  if (parentNode) attachHost(parentNode, nd, path).add(node)
@@ -892,11 +900,12 @@ const buildNodes = async (
892
900
  scene.add(node)
893
901
  applyNode(node, name, nd) // engine-side name stays the bare sibling segment
894
902
  if (lmStatic && (node instanceof Model || node instanceof Mesh)) Lightmap._register(node, lm!.prefix + path)
895
- else if (lmStatic && Terrain.of(node)) Lightmap._registerTerrain(Terrain.of(node)!, lm!.prefix + path)
896
- else if (lmDynamic && node instanceof Mesh) Lightmap._registerDynamic(node)
903
+ else if (lmStatic && nd.terrain !== undefined) Lightmap._registerGround(node, lm!.prefix + path)
904
+ // the top of a moving subtree: everything under it reads the level's light grid (Lightmap.track walks it at load)
905
+ if (lm !== null && !lmStatic && !parentMoves) moverRoots.push(node)
897
906
  if (isEditMode()) addEditorMarker(node, nd)
898
907
  // a prefab under a static wrapper bakes; under a mover its internals stay movers (volume-lit)
899
- if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/`, volume: lm!.volume } : lm && lm.volume ? { prefix: `${lm.prefix}${path}/`, volume: true, dynamicOnly: true } : null)
908
+ if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/` } : null)
900
909
  pending.push({ path, node, def: nd })
901
910
  nodes[path] = node
902
911
  await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path, parentMoves || (lm !== null && !lmStatic))))
@@ -926,6 +935,8 @@ const buildNodes = async (
926
935
  for (const h of td.holes ?? []) t.hole(h)
927
936
  t.commit()
928
937
  }
938
+ // the movers read the level's light grid: their subtrees are whole by now (prefab internals, children)
939
+ for (const n of moverRoots) Lightmap.track(n)
929
940
  }
930
941
 
931
942
  // ---- navigation (env.navmesh) ------------------------------------------------------------------
@@ -954,14 +965,14 @@ const registerNavOverrides = (defs: Record<string, SceneNodeDef> | undefined, no
954
965
  }
955
966
 
956
967
  const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
957
- const { lightmap, navmesh, ...env } = def.env ?? {}
968
+ const { lightmap, navmesh, foliage, ...env } = def.env ?? {}
958
969
  const scene = new Scene(env)
970
+ // the vegetation tier's level-wide knobs (wind, sway, variation) — before the models load
971
+ if (foliage) Foliage.configure(foliage)
959
972
  const nodes: Record<string, Node> = {}
960
973
  // baked lighting is a play-mode concern; the level owns the registry (a rebuild starts clean)
961
- const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "", volume: !!lightmap.volume } : null
974
+ const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "" } : null
962
975
  if (lm) Lightmap.clear()
963
- // with a light volume every model loaded from now on (weapons, spawns, make() subtrees) is a mover by default
964
- if (lm) Model._lightmapDefault = lm.volume ? "dynamic" : false
965
976
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
966
977
  if (navmesh && !isEditMode()) registerNavOverrides(def.nodes, nodes)
967
978
 
@@ -994,9 +1005,8 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
994
1005
 
995
1006
  // the level is complete: apply the bake — or, under `lecodes lightmap bake`, run it
996
1007
  if (lm && lightmap) {
997
- await Lightmap.load(scene, { data: lightmap.data, texture: lightmap.texture, light: lightmap.light, volume: lightmap.volume },
998
- { sunStrength: lightmap.sunStrength, ambientScale: lightmap.ambientScale, aoStrength: lightmap.aoStrength,
999
- lightBoost: lightmap.lightBoost, volumeBoost: lightmap.volumeBoost, occluderShadows: lightmap.occluderShadows })
1008
+ await Lightmap.load(scene, { data: lightmap.data, light: lightmap.light, aux: lightmap.aux, direct: lightmap.direct, indirect: lightmap.indirect, probes: lightmap.probes, volume: lightmap.volume },
1009
+ { lightScale: lightmap.lightScale, emissiveNits: lightmap.emissiveNits, emissiveBake: lightmap.emissiveBake, transmit: lightmap.transmit })
1000
1010
  }
1001
1011
  // navigation: load the baked mesh — or, under `lecodes navmesh bake`, dump the level's collision
1002
1012
  // geometry for it (the static bodies exist by now; edit mode never loads it — v1)
@@ -1125,7 +1135,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
1125
1135
  if (parent) attachHost(parent, d, p).add(existing)
1126
1136
  return existing
1127
1137
  }
1128
- const node = await createSource(p, d, undefined)
1138
+ const node = await createSource(p, d, false)
1129
1139
  if (parent) attachHost(parent, d, p).add(node)
1130
1140
  scene.add(node)
1131
1141
  applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
@@ -1,12 +1,12 @@
1
1
  import { ClickEvent, TouchStartEvent, LongPressEvent } from "../runtime/touch"
2
2
  import type { ClickEvent as ClickEventType, TouchStartEvent as TouchStartEventType, LongPressEvent as LongPressEventType } from "../runtime/touch"
3
- import { type ElementStyle, type ContainerStyle, ContainerElement, buildUI, type DrawableStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn, type BaseStyle, type Color } from "./UINode"
3
+ import { type ElementStyle, type ContainerStyle, ContainerElement, buildUI, type DrawableStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn, type BaseStyle, type Color, type LayerEasing } from "./UINode"
4
4
 
5
5
  export type UIButtonStyle = ElementStyle & DrawableStyle & ContainerStyle & { rippleColor?: Color | "default" }
6
6
 
7
7
  /** A tappable container — row + centered children by default. Press feedback via the `onPressed`
8
8
  * style block, the `$pressed` class, or `rippleColor`. */
9
- export interface UIButton extends UIContainerBase<UIButtonStyle & { onPressed?: UIButtonStyle & { duration?: number } }, DrawableStyle & BaseStyle> {
9
+ export interface UIButton extends UIContainerBase<UIButtonStyle & { onPressed?: UIButtonStyle & { duration?: number, delay?: number, easing?: LayerEasing } }, DrawableStyle & BaseStyle> {
10
10
  readonly type: "button",
11
11
 
12
12
  /** Fired on a completed tap/click. */
package/src/ui/UIInput.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ElementStyle, type DrawableStyle, Element, type TextStyle, type Color, type BaseStyle, type UIElementBase } from "./UINode";
1
+ import { type ElementStyle, type DrawableStyle, Element, type TextStyle, type Color, type BaseStyle, type UIElementBase, type LayerEasing } from "./UINode";
2
2
 
3
3
  type InputStyle = {
4
4
  placeholder?: string,
@@ -51,7 +51,7 @@ type SubmitStyle = {
51
51
 
52
52
  /** A single-line text input. Keyboard/picker kind via the `type` style, return-key behavior via
53
53
  * `enterKey` + `onSubmit`; keyboard policy on `keyboardShrink`/`keyboardDismiss`. */
54
- export interface UIInput extends UIElementBase<UIInputStyle & SubmitStyle & { onFocused?: UIInputStyle & { duration?: number } }, DrawableStyle & BaseStyle> {
54
+ export interface UIInput extends UIElementBase<UIInputStyle & SubmitStyle & { onFocused?: UIInputStyle & { duration?: number, delay?: number, easing?: LayerEasing } }, DrawableStyle & BaseStyle> {
55
55
  readonly type: "input",
56
56
 
57
57
  /**
@@ -78,7 +78,7 @@ export interface UIInput extends UIElementBase<UIInputStyle & SubmitStyle & { on
78
78
  }
79
79
 
80
80
  /** A multi-line text field — see the {@link UITextArea} factory for the auto-grow contract. */
81
- export interface UITextArea extends UIElementBase<UIInputStyle & { onFocused?: UIInputStyle & { duration?: number } }, DrawableStyle & BaseStyle> {
81
+ export interface UITextArea extends UIElementBase<UIInputStyle & { onFocused?: UIInputStyle & { duration?: number, delay?: number, easing?: LayerEasing } }, DrawableStyle & BaseStyle> {
82
82
  readonly type: "textarea",
83
83
 
84
84
  /** The field's text — same contract as {@link UIInput.value}: settable before mount to pre-fill. */
package/src/ui/UINode.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { FetchResponse } from "../runtime/fetch"
2
2
  import { createBinding, disposeBinding } from "../core/signals"
3
+ import type { Animation } from "../animate/tween/Animation"
4
+ import { CLOCK_UI, DOM_UI, KIND_DISCRETE, KIND_TRANSFORM, KIND_TRANSFORM_MATRIX, parseTransformList, uiValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
5
+ import { tweenBag } from "../animate/tween/Timeline"
3
6
 
4
7
  export interface UINode {
5
8
  readonly type: string
@@ -330,8 +333,13 @@ const classHandler: ProxyHandler<any> = {
330
333
  * static — bindings are extracted at the top level only. */
331
334
  export type Reactive<T> = { [K in keyof T]: NonNullable<T[K]> extends object ? T[K] : T[K] | (() => T[K]) }
332
335
 
336
+ /** The curve of a state block's swap (`$class` / `onPressed` / `onFocused` with a `duration`): a
337
+ * curve name (`outCubic`, `inOutSine`, `smoothstep` — the default — …), `cubic-bezier(x1,y1,x2,y2)`
338
+ * or `steps(n)`. A function cannot ride a style block; use `animateTo` for those. */
339
+ export type LayerEasing = string
340
+
333
341
  /** User-defined style classes: any `$`-prefixed key in `.style()` declares a state block toggled
334
- * from code via the `el.class` proxy; `duration`/`delay` animate the swap. Class state INHERITS
342
+ * from code via the `el.class` proxy; `duration`/`delay`/`easing` animate the swap. Class state INHERITS
335
343
  * down the tree (CSS-`.dark`-on-body style): a class set on a node also activates same-name `$`
336
344
  * blocks on all descendants within the same root (docs/style-class-cascade-plan.md).
337
345
  * `$pressed`/`$focused` are reserved — hosts toggle them on press/focus (they beat other
@@ -340,7 +348,7 @@ export type Reactive<T> = { [K in keyof T]: NonNullable<T[K]> extends object ? T
340
348
  * Instantiated with the node's FULL style `T` — a class block accepts everything the node's
341
349
  * `.style()` does, like `onLandscape`. Narrowing it wrongly rejects valid props such as
342
350
  * `$active: { color }`; guarded by tests/ui-types.test.ts. */
343
- export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number } }
351
+ export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number, easing?: LayerEasing } }
344
352
 
345
353
  /** Value accepted by a `el.class` write: a boolean sets the class, a `() => boolean` binds it —
346
354
  * the class then tracks the signals the function reads (re-evaluated on change). Reads through
@@ -425,28 +433,15 @@ export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationSt
425
433
  * installs a reactive binding). */
426
434
  export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
427
435
 
428
- /** The `animateTo`/`animateFrom` surface: target style props plus the flat meta keys below
429
- * (stripped before the commit into the element's style). */
430
- export type AnimateStyle<R, T extends object> = ((style: T & {
431
- /** Tween length in **milliseconds** (default 225). */
432
- duration?: number,
433
- /** Wait before starting, in **milliseconds**. A looping animation delays once, not per cycle. */
434
- delay?: number,
435
- /** Escape hatch: target an internal style layer (the onPressed/orientation mechanism). Not part of the supported surface. */
436
- layer?: number,
437
- /** `false` = animate without writing the targets into the element's style (e.g. a fade-out right before `.hide()`). */
438
- commit?: boolean,
439
- /** Repeat the animation: `true` = forever, a number = that many cycles. A looping animation is an
440
- * effect, not a state change — it never commits, and the element ends back at its base style.
441
- * The loop stops when a later `animateTo`/`animateFrom` runs on the element or it leaves the screen. */
442
- loop?: boolean | number,
443
- /** `"ping-pong"` (default) animates there and back each cycle — no jump. `"restart"` snaps back and
444
- * replays forward — for full-turn spinners (`transform: "rotate(360deg)"`) and shimmers. Only meaningful with `loop`. */
445
- loopMode?: "restart" | "ping-pong",
446
- }) => R)
447
-
448
- /** Meta keys of the animateTo/animateFrom options bag — everything that is not a style target. */
449
- const ANIMATE_META = new Set(["duration", "delay", "layer", "commit", "loop", "loopMode"])
436
+ /** Style props of an animate bag: a single value (tween from the current value) or an array of
437
+ * KEYFRAMES (`opacity: [1, 0.3, 1]`, offsets via `times`). */
438
+ export type AnimateProps<T extends object> = { [K in keyof T]?: T[K] | T[K][] }
439
+
440
+ /** The `animateTo`/`animateFrom` surface: target style props (single values or keyframe arrays) plus
441
+ * the flat meta keys — `duration` / `delay` / `easing` / `times` / `loop` / `loopMode` / `commit` /
442
+ * `clock`, see {@link TweenMeta}. Returns the {@link Animation} handle (seek, rate, `finished`). */
443
+ export type AnimateStyle<R, T extends object> = ((style: AnimateProps<T> & TweenMeta) => Animation)
444
+
450
445
 
451
446
  export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
452
447
  export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
@@ -518,20 +513,50 @@ export class Element<T extends string> {
518
513
  }
519
514
 
520
515
  /** Tween to the target style and commit it (unless `commit: false` or looping — a loop is an
521
- * effect, not a state change). Meta keys: `duration`/`delay`/`loop`/… — see {@link AnimateStyle}. */
522
- animateTo(style: any): this{
523
- _creatorUI.animateTo(this._id, style)
524
- if (style.commit !== false && !style.loop) {
525
- for (const key in style) {
526
- if (!ANIMATE_META.has(key)) this._style[key] = style[key]
516
+ * effect, not a state change). A prop value may be an array of keyframes; meta keys
517
+ * `duration`/`delay`/`easing`/`times`/`loop`/… — see {@link AnimateStyle}. Returns the
518
+ * {@link Animation} handle. */
519
+ animateTo(style: any): Animation {
520
+ // The last keyframe commits into the stored style at play (Animation.play) — reads and later
521
+ // `.style()` merges see the final state while the host is still tweening, as before.
522
+ return tweenBag(this, style, true)
523
+ }
524
+ /** Tween FROM the given style to the element's current one (entrance animations): the element's
525
+ * own style is the implicit last keyframe. Never modifies the stored style. */
526
+ animateFrom(style: any): Animation {
527
+ return tweenBag(this, style, false)
528
+ }
529
+
530
+ /** @internal Keyframe-core target protocol (animate/tween/spec.ts): every style prop is a channel
531
+ * on the UI domain; `text` is a discrete channel on text elements. */
532
+ readonly _tweenClock = CLOCK_UI
533
+ /** @internal */
534
+ _tweenChannel(prop: string): TweenChannel | null {
535
+ if (prop === "text") {
536
+ if (!("text" in this)) return null
537
+ return {
538
+ domain: DOM_UI, id: () => this._id,
539
+ value: (v) => (typeof v === "string" || typeof v === "number" ? { kind: KIND_DISCRETE, str: String(v) } : null),
540
+ commit: (v) => { (this as any).text = String(v) },
527
541
  }
528
542
  }
529
- return this
530
- }
531
- /** Tween FROM the given style to the element's current one (entrance animations). */
532
- animateFrom(style: any): this{
533
- _creatorUI.animateFrom(this._id, style)
534
- return this
543
+ if (prop === "transform") {
544
+ // A function list tweens per argument when every key shares the list; anything else is handed
545
+ // to the host as a raw string for matrix decomposition (its old path).
546
+ return {
547
+ domain: DOM_UI, id: () => this._id,
548
+ value: (v) => {
549
+ if (typeof v !== "string") return null
550
+ const list = parseTransformList(v)
551
+ return list ? { kind: KIND_TRANSFORM, sig: list.sig, lanes: list.lanes } : { kind: KIND_TRANSFORM_MATRIX, str: v }
552
+ },
553
+ commit: (v) => { this._style.transform = v },
554
+ }
555
+ }
556
+ return {
557
+ domain: DOM_UI, id: () => this._id, value: uiValue,
558
+ commit: (v) => { this._style[prop] = v },
559
+ }
535
560
  }
536
561
 
537
562
  /** @internal Active style-class names (stored without the `$`). Kept on the element — not just in
@@ -1,20 +0,0 @@
1
- type mat4 = Float32Array | number[];
2
- type quat = Float32Array | number[];
3
- type vec2 = Float32Array | number[];
4
- type vec3 = Float32Array | number[];
5
- import type { Easing } from "./easings";
6
- type AnimateProps = number | string | vec2 | vec3 | quat;
7
- export type AnimateOptions<T> = {
8
- from: T;
9
- to: T;
10
- duration: number;
11
- onUpdate(val: T): void;
12
- onComplete?(): void;
13
- easing?: Easing;
14
- };
15
- export declare const animate: <T extends AnimateProps>(options: AnimateOptions<T>) => number;
16
- export declare const stopAnimation: (id: number) => void;
17
- export declare const pauseAnimation: (id: number) => void;
18
- export declare const resumeAnimation: (id: number) => void;
19
- export declare const animateMat4: (options: AnimateOptions<mat4>) => number;
20
- export {};