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
@@ -35,6 +35,14 @@ export type DecalOptions = {
35
35
  fadeIn?: number
36
36
  /** Seconds of fade at the end of `life` (ignored with life 0). Default 0. */
37
37
  fadeOut?: number
38
+ /** Opacity multipliers at the image's BOTTOM and TOP edge (−Y / +Y), interpolated along it — a
39
+ * tire mark that darkens as the slide deepens. Default `[1, 1]`. */
40
+ gradient?: [number, number]
41
+ /** Tilt of the image's bottom and top edge, as a unit-space slope (Δy per Δx across the box):
42
+ * the edge pivots on the corner of the side it keeps and cuts INTO the box on the other, so two
43
+ * boxes can meet on one shared line — what `DecalTrail` uses to mitre its joints. `0` = square.
44
+ * Default `[0, 0]`. */
45
+ caps?: [number, number]
38
46
  }
39
47
 
40
48
  export type DecalSetOptions = DecalOptions & {
@@ -63,8 +71,10 @@ export type DecalSetOptions = DecalOptions & {
63
71
  /** HDR boost of the image (0 = none). Default material only. */
64
72
  emissive?: number
65
73
  name?: string
66
- /** Coarse draw order, 0 (first) … 7 (last); default 4 — see `Mesh.renderPriority`. Decals are
67
- * blended and sort with the other blended draws of their priority. */
74
+ /** Coarse draw order, 0 (first) … 7 (last) — see `Mesh.renderPriority`. Default 3: a decal is
75
+ * part of the surface it sits on, so it draws before every other blended thing (meshes 4,
76
+ * particles 5) — the engine's depth sort compares object centres, and a set spread over the
77
+ * level has no useful centre. */
68
78
  renderPriority?: number
69
79
  }
70
80
 
@@ -86,7 +96,7 @@ export type DecalPlacement = DecalOptions & {
86
96
  rotation?: QuatLike | Vec3Like
87
97
  }
88
98
 
89
- const RECORD = 23
99
+ const RECORD = 27
90
100
  const NO_SLOT = 0xFFFFFFFF
91
101
 
92
102
  export class DecalSet extends Node {
@@ -116,7 +126,7 @@ export class DecalSet extends Node {
116
126
  }
117
127
  if (_creator.createDecalSet) {
118
128
  _creator.createDecalSet(this.id, Material.idOf(this._material), this._max)
119
- if (options.renderPriority !== undefined) this.renderPriority = options.renderPriority
129
+ this.renderPriority = options.renderPriority ?? 3
120
130
  } else if (!DecalSet._warned) {
121
131
  DecalSet._warned = true
122
132
  console.warn("[decals] this host has no createDecalSet — decals are not drawn")
@@ -200,7 +210,8 @@ export class DecalSet extends Node {
200
210
  return this._write(slot, right, up, normal, new Vec3(p.position), p)
201
211
  }
202
212
 
203
- private _write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
213
+ /** @internal One decal's frame → record → host (`DecalTrail` writes its segments through here). */
214
+ _write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
204
215
  const d = this._defaults
205
216
  const size = o.size ?? d.size!
206
217
  const w = typeof size === "number" ? size : size[0]
@@ -222,6 +233,10 @@ export class DecalSet extends Node {
222
233
  const opacity = o.opacity ?? d.opacity!
223
234
  r[16] = tr; r[17] = tg; r[18] = tb; r[19] = ta * opacity
224
235
  r[20] = o.life ?? d.life!; r[21] = o.fadeIn ?? d.fadeIn!; r[22] = o.fadeOut ?? d.fadeOut!
236
+ const caps = o.caps ?? d.caps
237
+ r[23] = caps ? caps[0] : 0; r[24] = caps ? caps[1] : 0
238
+ const gradient = o.gradient ?? d.gradient
239
+ r[25] = gradient ? gradient[0] : 1; r[26] = gradient ? gradient[1] : 1
225
240
  if (slot === NO_SLOT) {
226
241
  if (!_creator.addDecal) return -1
227
242
  const s = _creator.addDecal(this.id, r)
@@ -231,3 +246,115 @@ export class DecalSet extends Node {
231
246
  return slot
232
247
  }
233
248
  }
249
+
250
+ /** A `DecalTrail`'s look: the strip's width, and what every segment carries. */
251
+ export type DecalTrailOptions = Omit<DecalOptions, "size" | "caps" | "gradient"> & {
252
+ /** Width of the strip on the surface, world units. */
253
+ width: number
254
+ }
255
+
256
+ // The mitre stops making sense past this turn between two segments (a sharp reversal would need a
257
+ // box longer than the strip is wide); the trail breaks there and starts afresh.
258
+ const TRAIL_MAX_SLOPE = 1.75 // tan 60°
259
+
260
+ /**
261
+ * A continuous strip of decals along a path — a tire's skid mark, a dragged body, a tread track.
262
+ * Feed it points (`add`) as the thing moves; every new point becomes one box-decal SEGMENT from the
263
+ * previous point, its image's up along the travel, and the joints between segments are MITRED: each
264
+ * box is cut along the bisector it shares with its neighbour (`caps`), so the strip has no overlaps
265
+ * darkening the outside of a bend and no wedges of gap on the inside. The opacity given with each
266
+ * point is interpolated along the segment (`gradient`), so a mark can darken as a slide deepens and
267
+ * fade as it ends. Segments are the set's decals — same ring, same `max` budget: a set of 900 holds
268
+ * 900 segments across every trail drawn from it.
269
+ *
270
+ * The previous segment is REWRITTEN when the next point arrives (its far cap becomes the shared
271
+ * mitre), through `DecalSet.update`'s path — a recycled slot is simply left alone by the engine.
272
+ */
273
+ export class DecalTrail {
274
+ private _prev: { point: Vec3, opacity: number } | null = null
275
+ private _seg: {
276
+ slot: number, start: Vec3, end: Vec3, dir: Vec3, right: Vec3, normal: Vec3,
277
+ slopeStart: number, a0: number, a1: number,
278
+ } | null = null
279
+
280
+ readonly set: DecalSet
281
+ readonly options: DecalTrailOptions
282
+
283
+ constructor(set: DecalSet, options: DecalTrailOptions) {
284
+ this.set = set
285
+ this.options = options
286
+ }
287
+
288
+ /** Extend the strip to `point` on a surface with `normal`, `opacity` (0..1) at that point. The
289
+ * first call after a start / `end()` only anchors the strip. Returns the new segment's slot, or
290
+ * −1 when nothing was drawn (the anchor, a point that did not move, no host support). */
291
+ add(point: Vec3Like, normal: Vec3Like, opacity = 1): number {
292
+ const p = new Vec3(point)
293
+ const n = new Vec3(normal).normalize()
294
+ const prev = this._prev
295
+ this._prev = { point: p, opacity }
296
+ if (!prev) return -1
297
+ // The segment, flattened onto the surface plane.
298
+ let d = p.sub(prev.point)
299
+ d = d.sub(n.scale(d.dot(n)))
300
+ const len = d.length()
301
+ if (len < 1e-3) { this._prev = prev; return -1 } // not moved: wait for a real step
302
+ const dir = d.scale(1 / len)
303
+ const right = dir.cross(n) // DecalSet.spawn's frame: right × up = normal, image unmirrored from the normal's side
304
+ const w = this.options.width
305
+
306
+ // Mitre with the previous segment: the joint's bisector is the sum of the two right normals,
307
+ // and each box takes it as a cap — the previous one at its END (rewritten now), this one at its
308
+ // START — with the slopes measured in each box's own frame. A trail's first segment, or one
309
+ // after a break, starts square.
310
+ let slopeStart = 0
311
+ const last = this._seg
312
+ if (last) {
313
+ const lastDir = last.dir.sub(n.scale(last.dir.dot(n))).normalize()
314
+ const lastRight = lastDir.cross(n)
315
+ const m = lastRight.add(right)
316
+ const denom = m.dot(right)
317
+ const slopeEnd = Math.abs(denom) > 1e-4 ? m.dot(lastDir) / denom : Infinity
318
+ if (Math.abs(slopeEnd) <= TRAIL_MAX_SLOPE) {
319
+ this._rewrite(last, slopeEnd)
320
+ slopeStart = -slopeEnd
321
+ } else {
322
+ this._rewrite(last, 0) // too sharp to mitre: leave the last one square, start afresh
323
+ }
324
+ }
325
+ const seg = { slot: -1, start: prev.point, end: p, dir, right, normal: n, slopeStart, a0: prev.opacity, a1: opacity }
326
+ seg.slot = this._box(seg, 0, -1)
327
+ this._seg = seg
328
+ return seg.slot
329
+ }
330
+
331
+ /** Finish the strip. With a `point`, one last segment runs out to it at opacity 0 — a mark that
332
+ * tapers away instead of stopping dead. The next `add` anchors a new strip. */
333
+ end(point?: Vec3Like, normal: Vec3Like = Vec3.up): this {
334
+ if (point && this._prev) this.add(point, normal, 0)
335
+ this._prev = null
336
+ this._seg = null
337
+ return this
338
+ }
339
+
340
+ private _rewrite(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number): void {
341
+ if (seg.slot >= 0) this._box(seg, slopeEnd, seg.slot)
342
+ }
343
+
344
+ /** Write one segment's box: the strip between its joints, extended past each mitred joint by the
345
+ * mitre's reach (w/2 · |slope|) so the tilted cap still passes through the joint's centre. */
346
+ private _box(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number, slot: number): number {
347
+ const w = this.options.width
348
+ const L = seg.end.sub(seg.start).length()
349
+ const extStart = 0.5 * w * Math.abs(seg.slopeStart)
350
+ const extEnd = 0.5 * w * Math.abs(slopeEnd)
351
+ const boxLen = L + extStart + extEnd
352
+ const centre = seg.start.add(seg.dir.scale(0.5 * boxLen - extStart))
353
+ // World slopes → unit-space slopes: x is measured in widths, y in box lengths.
354
+ const caps: [number, number] = [ seg.slopeStart * (w / boxLen), slopeEnd * (w / boxLen) ]
355
+ const { width: _w, ...rest } = this.options
356
+ const s = this.set._write(slot < 0 ? NO_SLOT : slot, seg.right, seg.dir, seg.normal, centre,
357
+ { ...rest, size: [ w, boxLen ], caps, gradient: [ seg.a0, seg.a1 ] })
358
+ return slot < 0 ? s : slot
359
+ }
360
+ }
@@ -0,0 +1,102 @@
1
+ // Foliage — the vegetation tier of the lightmap material family (creator-gl materials/src/foliage.mat).
2
+ //
3
+ // A model loaded with `Model.load(src, { foliage: true })` (scene files: `foliage: true` on a `model`
4
+ // node) takes a shader whose vertices sway in the wind, bend away from BENDERS (the hero, enemies —
5
+ // any node registered with `Foliage.bend`) and thin out over a distance range, and whose fragment
6
+ // tints every copy a little differently. All of it is level-wide state the engine keeps: the wind
7
+ // and the sway options apply to every foliage model, present and future; benders are read from
8
+ // their live world transforms each frame (the 8 nearest the camera reach the shader); a fade is per
9
+ // ASSET — every copy of that model, present and future.
10
+ //
11
+ // Hosts without the tier (no `foliageSet*` bridge) ignore every call; the models then load through
12
+ // the ordinary lightmap tier where that exists, else the standard shader.
13
+ import { Model } from "./Model"
14
+ import { Node } from "./Node"
15
+
16
+ export type FoliageWind = {
17
+ /** Direction on the ground (x, z); normalised by the engine. Default [1, 0.3]. */
18
+ direction?: [number, number]
19
+ /** Metres of sway at `bendHeight` (taller parts sway more). Default 0.07. 0 = still. */
20
+ strength?: number
21
+ /** Radians per second of the main sway. Default 1.2. */
22
+ speed?: number
23
+ /** 0..1: how much the strength breathes over a slow envelope. Default 0.6. */
24
+ gust?: number
25
+ }
26
+
27
+ export type FoliageOptions = {
28
+ /** The wind, or `false` for none. */
29
+ wind?: FoliageWind | false
30
+ /** Height above the instance origin (m) where the full sway / touch applies; the base never moves.
31
+ * Default 1.5. */
32
+ bendHeight?: number
33
+ /** Metres the TOP of a small plant leans away from a bender standing at its centre; the whole plant leans
34
+ * as one, rooted at its base. Plants over ~2 m react less, trees over ~5 m not at all (the engine
35
+ * measures each asset). Default 0.6. 0 = no touch reaction. */
36
+ touch?: number
37
+ /** 0..1: how far each copy's tint drifts from the texture (warmer/darker to cooler/lighter, by a hash
38
+ * of its position). Default 0.35. */
39
+ variation?: number
40
+ /** 0..1: wrapped sun lighting — thin leaves let light through. Default 0.5. */
41
+ sunWrap?: number
42
+ }
43
+
44
+ const state: Required<Omit<FoliageOptions, "wind">> & { wind: Required<FoliageWind> | false } = {
45
+ wind: { direction: [ 1, 0.3 ], strength: 0.07, speed: 1.2, gust: 0.6 },
46
+ bendHeight: 1.5,
47
+ touch: 0.6,
48
+ variation: 0.35,
49
+ sunWrap: 0.5,
50
+ }
51
+
52
+ const pushWind = (): void => {
53
+ const w = state.wind
54
+ if (w === false) _creator.foliageSetWind?.(1, 0, 0, 1, 0)
55
+ else _creator.foliageSetWind?.(w.direction[0], w.direction[1], w.strength, w.speed, w.gust)
56
+ }
57
+
58
+ export const Foliage = {
59
+ /** Level-wide options; each field given replaces the current value (a scene file's `env.foliage`
60
+ * goes through here). */
61
+ configure(options: FoliageOptions): void {
62
+ if (options.wind !== undefined) Foliage.wind(options.wind)
63
+ let sway = false
64
+ if (options.bendHeight !== undefined) { state.bendHeight = options.bendHeight; sway = true }
65
+ if (options.touch !== undefined) { state.touch = options.touch; sway = true }
66
+ if (options.variation !== undefined) { state.variation = options.variation; sway = true }
67
+ if (options.sunWrap !== undefined) { state.sunWrap = options.sunWrap; sway = true }
68
+ if (sway) _creator.foliageSetOptions?.(state.bendHeight, state.touch, state.variation, state.sunWrap)
69
+ },
70
+
71
+ /** The wind: fields given replace the current ones; `false` = calm. Live. */
72
+ wind(wind: FoliageWind | false): void {
73
+ if (wind === false) state.wind = false
74
+ else {
75
+ const cur = state.wind === false ? { direction: [ 1, 0.3 ] as [number, number], strength: 0.07, speed: 1.2, gust: 0.6 } : state.wind
76
+ state.wind = { direction: wind.direction ?? cur.direction, strength: wind.strength ?? cur.strength, speed: wind.speed ?? cur.speed, gust: wind.gust ?? cur.gust }
77
+ }
78
+ pushWind()
79
+ },
80
+
81
+ /** A node whose live position pushes the vegetation within `radius` metres (the hero: ~0.7; a
82
+ * boulder: its size). Any number may be registered; the 8 nearest the camera act each frame. The
83
+ * engine also tracks the node's velocity (smoothed over ~0.2 s): a plant you run through is brushed
84
+ * forward and settles behind you instead of flipping as you cross its centre.
85
+ * Returns the unregister function; a destroyed node drops out on its own. */
86
+ bend(node: Node, radius = 0.7): () => void {
87
+ _creator.foliageSetBender?.(node.id, radius > 0 ? radius : 0.01)
88
+ return () => Foliage.unbend(node)
89
+ },
90
+
91
+ /** Stop a node from bending the vegetation. */
92
+ unbend(node: Node): void {
93
+ _creator.foliageSetBender?.(node.id, 0)
94
+ },
95
+
96
+ /** Distance fade for EVERY copy of this model's asset (present and future): the cards thin out
97
+ * from `start` to `end` metres from the camera and past `end` the copy leaves the frame entirely.
98
+ * Small ground cover (grass, clover, flowers) is the target; `end` 0 = no fade. */
99
+ fade(model: Model, start: number, end: number): void {
100
+ _creator.foliageSetFade?.(model.id, start, end)
101
+ },
102
+ }
@@ -3,9 +3,25 @@
3
3
  // worker/src/primitives/*.
4
4
 
5
5
  import { cx, cy, cz, type Vec3Like } from "../math/vec"
6
+ import { fetch } from "../runtime/fetch"
6
7
 
7
8
  export type MeshKind = "triangles" | "vertices" | "edges"
8
9
 
10
+ /** A geometry file — what `lecodes assets geometry <model.glb>` writes (`<model>.geometry.json`):
11
+ * one merged, world-space triangle buffer. `Geometry.load(asset('./casing.geometry.json'))`. */
12
+ export type GeometryData = {
13
+ format?: "lecodes-geometry"
14
+ version?: number
15
+ /** xyz per vertex. */
16
+ vertices: ArrayLike<number>
17
+ /** xyz per vertex (unit length). */
18
+ normals: ArrayLike<number>
19
+ /** uv per vertex. */
20
+ uv: ArrayLike<number>
21
+ /** Triangle list, ≤ 65535 vertices addressed. */
22
+ indices: ArrayLike<number>
23
+ }
24
+
9
25
  // local tuple helpers — geometry building works on raw Float32Array buffers, so it stays out of the
10
26
  // Vec3 wrapper (no per-vertex allocation).
11
27
  type Tup3 = [number, number, number]
@@ -65,7 +81,39 @@ export class Geometry {
65
81
  static box(size: Vec3Like | number = 1): Geometry { return createCube(size) }
66
82
  static sphere(options?: SphereOptions): Geometry { return createSphere(options) }
67
83
  static cylinder(options?: CylinderOptions): Geometry { return createCylinder(options) }
84
+ /** A capsule along Y, centred at the origin: a cylinder `length` long (between the two cap centres)
85
+ * with a hemisphere of `radius` on each end — one closed surface, smooth across the seams. */
86
+ static capsule(options?: CapsuleOptions): Geometry { return createCapsule(options) }
68
87
  static plane(options?: PlaneOptions): Geometry { return createPlane(options) }
88
+
89
+ /** A Geometry from a geometry file's contents (`GeometryData` — see `Geometry.load`). The arrays
90
+ * are copied into typed buffers, so the source object can be dropped. */
91
+ static fromData(data: GeometryData): Geometry {
92
+ if (!data || !data.vertices || !data.indices) throw new Error("Geometry.fromData: not a geometry file (no vertices/indices)")
93
+ if (data.format !== undefined && data.format !== "lecodes-geometry") throw new Error(`Geometry.fromData: unknown format "${data.format}"`)
94
+ const nv = data.vertices.length / 3
95
+ if (nv > 65535) throw new Error(`Geometry.fromData: ${nv} vertices — at most 65535 (uint16 indices)`)
96
+ const vertices = Float32Array.from(data.vertices)
97
+ const normals = data.normals && data.normals.length === vertices.length ? Float32Array.from(data.normals) : new Float32Array(vertices.length)
98
+ const uv = data.uv && data.uv.length === nv * 2 ? Float32Array.from(data.uv) : new Float32Array(nv * 2)
99
+ return new Geometry(vertices, normals, Uint16Array.from(data.indices), uv)
100
+ }
101
+
102
+ /** Load a geometry file: `lecodes assets geometry casing.glb` → `casing.geometry.json` →
103
+ * `await Geometry.load(asset('./casing.geometry.json'))`. A GLB's triangles as a plain buffer,
104
+ * for `Particles({ mesh })` debris (shell casings, rubble) and `Mesh.from`.
105
+ * A `.json` project file is INLINED by the compiler — `asset('./x.json')` is the parsed data, not
106
+ * a URL — so that form (and `import data from './x.json'`) is read directly, no fetch; a string
107
+ * (a URL, a non-`.json` asset such as `.geo`) is fetched. */
108
+ static async load(source: string | GeometryData): Promise<Geometry> {
109
+ if (typeof source !== "string") return Geometry.fromData(source)
110
+ const url = source
111
+ const resp = await fetch(url, { useOnce: true })
112
+ if (resp.status >= 400) { resp.dispose(); throw new Error(`Geometry.load: ${url} → HTTP ${resp.status}`) }
113
+ const data = resp.json<GeometryData>()
114
+ resp.dispose()
115
+ return Geometry.fromData(data)
116
+ }
69
117
  }
70
118
 
71
119
  // Gutter between lightmap charts (fraction of the chart square): the bake dilates each rect by a
@@ -193,6 +241,67 @@ const createSphere = (options: SphereOptions = {}): Geometry => {
193
241
  return new Geometry(vertices, normals, indices, uvs)
194
242
  }
195
243
 
244
+ // --- capsule ---
245
+ export type CapsuleOptions = {
246
+ radius?: number
247
+ /** Distance between the two cap centres (the straight part); 0 = a sphere. Default 1. */
248
+ length?: number
249
+ /** Segments around the axis. */
250
+ widthSegments?: number
251
+ /** Rings per hemisphere (pole to equator). */
252
+ capSegments?: number
253
+ }
254
+ const createCapsule = (options: CapsuleOptions = {}): Geometry => {
255
+ const { radius = 0.5, length = 1, widthSegments = 32, capSegments = 8 } = options
256
+ const W = widthSegments, half = length / 2
257
+ // rings from the top cap's first ring down to the bottom cap's last: the equator appears twice (once per
258
+ // cap, `length` apart) — the band between those two rings is the cylinder; the poles are fans, like the sphere
259
+ const R = capSegments * 2
260
+ const verticesCount = (W + 1) * R + W * 2
261
+ const vertices = new Float32Array(verticesCount * 3)
262
+ const normals = new Float32Array(verticesCount * 3)
263
+ const uvs = new Float32Array(verticesCount * 2)
264
+ const indices = new Uint16Array(W * (R - 1) * 6 + W * 3 * 2)
265
+ const total = Math.PI * radius + length // arc from pole to pole, for v
266
+ let vertexCnt = 0, indicesCnt = 0
267
+ for (let j = 0; j < R; j++) {
268
+ const top = j < capSegments
269
+ const theta = (top ? j + 1 : j) / capSegments * (Math.PI / 2) // 0 = the top pole, π = the bottom
270
+ const yOff = top ? half : -half
271
+ const sinT = Math.sin(theta), cosT = Math.cos(theta)
272
+ const v = ((Math.PI - theta) * radius + (top ? length : 0)) / total
273
+ for (let ix = 0; ix <= W; ix++) {
274
+ const u = ix / W
275
+ const nx = -Math.cos(u * Math.PI * 2) * sinT, nz = Math.sin(u * Math.PI * 2) * sinT
276
+ vertices.set([ nx * radius, cosT * radius + yOff, nz * radius ], vertexCnt * 3)
277
+ normals.set([ nx, cosT, nz ], vertexCnt * 3)
278
+ uvs.set([ u, v ], vertexCnt * 2)
279
+ if (j > 0 && ix > 0) {
280
+ const s = vertexCnt - 1, s1 = vertexCnt - (W + 1) - 1
281
+ indices.set([ s + 1, s1, s, s1, s + 1, s1 + 1 ], indicesCnt)
282
+ indicesCnt += 6
283
+ }
284
+ vertexCnt++
285
+ }
286
+ }
287
+ const lastRing = vertexCnt - (W + 1)
288
+ for (const side of [ 1, -1 ]) {
289
+ const circleStart = side > 0 ? 0 : lastRing
290
+ for (let ix = 0; ix < W; ix++) {
291
+ const u = (ix + 0.5) / W
292
+ vertices.set([ 0, side * (radius + half), 0 ], vertexCnt * 3)
293
+ normals.set([ 0, side, 0 ], vertexCnt * 3)
294
+ uvs.set([ u, side > 0 ? 1 : 0 ], vertexCnt * 2)
295
+ const s = circleStart + ix
296
+ if (side > 0) indices.set([ s, s + 1, vertexCnt ], indicesCnt)
297
+ else indices.set([ vertexCnt, s + 1, s ], indicesCnt)
298
+ indicesCnt += 3
299
+ vertexCnt++
300
+ }
301
+ }
302
+ return new Geometry(vertices, normals, indices, uvs)
303
+ }
304
+
196
305
  // --- cylinder ---
197
306
  export type CylinderOptions = { radius?: number, radiusTop?: number, radiusBottom?: number, edges?: number, smooth?: boolean }
198
307
  const createCylinder = (options: CylinderOptions = {}): Geometry => {
package/src/gl/Light.ts CHANGED
@@ -3,7 +3,15 @@
3
3
 
4
4
  import { Color, type ColorInput } from "../core/color"
5
5
  import type { Vec3Like } from "../math/vec"
6
- import { Node } from "./Node"
6
+ import { Node, type NodeTweenProps } from "./Node"
7
+ import { DOM_GL_LIGHT, colorValue, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
8
+ import type { Animation } from "../animate/tween/Animation"
9
+
10
+ /** Animatable light props on top of the node transform. */
11
+ export type LightTweenProps = NodeTweenProps & {
12
+ intensity?: number | number[],
13
+ color?: ColorInput | ColorInput[],
14
+ }
7
15
 
8
16
  export type SunOptions = {
9
17
  /** Light direction (points where the light travels). Defaults to a typical key-light angle. */
@@ -46,26 +54,42 @@ export type PointOptions = {
46
54
  /** Point-light shadows are a cubemap render per light; off by default. */
47
55
  castShadows?: boolean
48
56
  /**
49
- * Whether `lecodes lightmap bake` bakes this light into the level's lightmap (docs/lightmap-plan.md).
50
- * A baked light is switched OFF at runtime once the bake applies — statics read it from the atlas,
51
- * movers from the light volume — so it costs nothing per frame; a light that must stay live (a
52
- * flicker, a lamp the player can shoot out) says `baked: false` and is kept out of the bake.
53
- * Lights created after the level loads (a muzzle flash) are never in a bake. Default true.
57
+ * Whether `lecodes lightmap bake` bakes this light into the level's lightmap. A baked lamp lights the statics
58
+ * from the atlas and the MOVERS from the level's light grid, and is held dark in real time while that grid is
59
+ * loaded (without a grid it keeps lighting the movers live); a light
60
+ * that must reach the statics live too — a flicker, a lamp the player can shoot out, a muzzle flash —
61
+ * says `baked: false`: it is kept out of the bake and put on the statics' light channel. Default true.
54
62
  */
55
63
  baked?: boolean
64
+ /**
65
+ * THE BAKE'S SHAPE of this lamp: `[width, height]` in metres = an AREA light - a rectangle in the light's local XZ
66
+ * plane, emitting along its local -Y (down, for an unrotated node) with the same lumens: a ceiling panel. The bake
67
+ * lights from the whole rectangle (soft shadows, light from where the panel is and not from a point inside the
68
+ * fixture). Real time it is still the point light above. Ignored with `baked: false`.
69
+ */
70
+ bakeArea?: readonly [number, number]
56
71
  }
57
72
 
58
73
  export class Light extends Node {
59
74
  private _intensity = 0
75
+
76
+ /** Tween `intensity` / `color` (a flash, a sunrise) and the transform — see {@link Node.animateTo}. */
77
+ override animateTo(props: LightTweenProps & TweenMeta): Animation { return super.animateTo(props) }
78
+ override animateFrom(props: LightTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
79
+ /** @internal */
80
+ override _tweenChannel(prop: string): TweenChannel | null {
81
+ const id = () => this.id
82
+ if (prop === "intensity") return { domain: DOM_GL_LIGHT, id, value: floatValue, commit: (v) => { this._intensity = v as number } }
83
+ if (prop === "color") return { domain: DOM_GL_LIGHT, id, value: (v) => colorValue(v, Color.toRgba01) }
84
+ return super._tweenChannel(prop)
85
+ }
60
86
  /** @internal PointOptions.baked — a candidate for the lightmap bake (default true). */
61
87
  _baked = true
62
- /** @internal the bake carries this light: the real-time one is held dark (Lightmap.load). */
63
- _bakedOff = false
64
88
  /** @internal every live point light, for the bake / the switch-off; a destroyed one leaves. */
65
89
  static _points = new Set<Light>()
66
- /** @internal sun direction as created (the engine keeps it; Lightmap.load reads it back). */
90
+ /** @internal sun direction as created (the engine keeps it). */
67
91
  _direction: [number, number, number] = [ 0, -1, 0 ]
68
- /** The most recently created sun — what `Lightmap.load` uses unless told otherwise. */
92
+ /** The most recently created sun. */
69
93
  static lastSun: Light | null = null
70
94
  /** @internal the colour as created / last set (a settings menu recreates a sun from these). */
71
95
  _color: ColorInput = 0xffffff
@@ -113,6 +137,9 @@ export class Light extends Node {
113
137
  options.range ?? 10,
114
138
  options.castShadows ?? false,
115
139
  )
140
+ if (options.bakeArea && light._baked) _creator.setLightBakeArea?.(light.id, options.bakeArea[0], options.bakeArea[1])
141
+ // an unbaked lamp lights the baked statics live: Filament channel 1 (see Lightmap)
142
+ if (!light._baked) _creator.setLightChannel?.(light.id, 1, true)
116
143
  return light
117
144
  }
118
145
 
@@ -123,14 +150,17 @@ export class Light extends Node {
123
150
  get intensity(): number { return this._intensity }
124
151
  set intensity(value: number) {
125
152
  this._intensity = value
126
- _creator.setLightIntensity?.(this.id, this._bakedOff ? 0 : value)
153
+ if (!this._heldDark) _creator.setLightIntensity?.(this.id, value)
127
154
  }
128
155
 
129
- /** @internal Hold a baked point light dark (the atlas / volume carries it) or hand it back to the real-time path. */
130
- _setBakedOff(off: boolean): void {
131
- if (this._bakedOff === off) return
132
- this._bakedOff = off
133
- _creator.setLightIntensity?.(this.id, off ? 0 : this._intensity)
156
+ /** @internal A BAKED lamp is held dark while the level's LIGHT GRID is loaded (Lightmap): the statics have it in the
157
+ * atlas and the movers in the grid's ambient cubes - left burning it lit every mover and unbaked prop a second time,
158
+ * without a shadow (through walls), and put a hot spot on its own fixture. `intensity` keeps the authored value. */
159
+ _heldDark = false
160
+ _holdDark(dark: boolean): void {
161
+ if (this._heldDark === dark) return
162
+ this._heldDark = dark
163
+ _creator.setLightIntensity?.(this.id, dark ? 0 : this._intensity)
134
164
  }
135
165
 
136
166
  destroy(): void {