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
@@ -74,17 +74,47 @@ export type CompileOptions = {
74
74
  * unless `header: false`; `map` is already shifted past that header. */
75
75
  export type CompileResult = { code: string, map: string | null, entry: string }
76
76
 
77
+ /** The basename a local resource would be looked up by. */
78
+ export const localAssetBase = (path: string): string => path.split(/[\\/]/).pop() ?? path
79
+
80
+ /**
81
+ * Runtime lookup names for `localAssets` resources — what the bundle passes to `fetchLocal` and what
82
+ * a host caller must copy the file to.
83
+ *
84
+ * The basename, unless two project files SHARE one: then every sharer takes its whole root-relative
85
+ * path with the separators folded into `__`. A flat name space is fine until it isn't, and when it
86
+ * isn't the failure is silent and absurd — the files are staged into one directory, so the last one
87
+ * copied answers for every `asset()` that ends in that name. Which one wins depends on the scan
88
+ * order, i.e. on nothing. (2026-09-17: a viewer project carried two asset packs that each ship
89
+ * `SM_Sign_01..03.glb`; the school's floor-standing "CLEANING IN PROGRESS" A-frame was drawn inside
90
+ * the shooting range's wall, where a flat 4 mm plate belonged.)
91
+ */
92
+ export const localAssetNames = (entries: readonly { path: string, type: string }[]): Map<string, string> => {
93
+ const seen = new Map<string, number>()
94
+ for (const e of entries) if (e.type === "resource") {
95
+ const b = localAssetBase(e.path)
96
+ seen.set(b, (seen.get(b) ?? 0) + 1)
97
+ }
98
+ const names = new Map<string, string>()
99
+ for (const e of entries) if (e.type === "resource") {
100
+ const b = localAssetBase(e.path)
101
+ names.set(e.path, (seen.get(b) ?? 0) > 1 ? e.path.replace(/^[./]+/, "").replace(/[\\/]/g, "__") : b)
102
+ }
103
+ return names
104
+ }
105
+
77
106
  /** Turn compile entries into the bundler's module map — the per-kind rules of the deployable
78
107
  * bundle (resource → URL export, shader → per-backend URL template, .svg → SvgSource, .json →
79
108
  * data, code verbatim). Exported for callers that build a `libraries` map from entries. */
80
109
  export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: string, localAssets?: boolean }): Record<string, string> => {
81
110
  const files: Record<string, string> = {}
111
+ const localNames = localAssetNames(entries)
82
112
  for (const e of entries) {
83
113
  if (e.type === "resource") {
84
114
  if (opts.localAssets && !e.remote) {
85
115
  // The host resolves the asset by name at runtime (reads the local file → systemId); the
86
116
  // "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
87
- const name = e.path.split(/[\/]/).pop() ?? e.path
117
+ const name = localNames.get(e.path) ?? localAssetBase(e.path)
88
118
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
89
119
  } else {
90
120
  files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
@@ -37,9 +37,14 @@ export type DetectedEntry = {
37
37
  export const ENTRY_CONVENTION = "/main.ts"
38
38
 
39
39
  const isCode = (path: string) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts")
40
- /** Never entries: editor plugins and server modules (docs/backend-plan.md — a `*.server.ts` nobody
41
- * imports is still not the app; server files compile into their own bundle). */
42
- const isNonEntry = (path: string) => path.endsWith(".editor.ts") || /\.server\.[tj]s$/.test(path)
40
+ /** Never entries: editor plugins, server modules (docs/backend-plan.md — a `*.server.ts` nobody
41
+ * imports is still not the app; server files compile into their own bundle) and anything under
42
+ * `design/` — the board's screens are import-graph roots by construction (nothing imports a screen
43
+ * file; the board runs them), so without this a project whose app code is still to come had its
44
+ * largest design screen picked as the app and failed deep inside it ("asset not found:
45
+ * ../assets/figma/…"). A design-only project has no entry, and says so. */
46
+ const isNonEntry = (path: string) =>
47
+ path.endsWith(".editor.ts") || /\.server\.[tj]s$/.test(path) || /^\/design\//.test(path)
43
48
 
44
49
  const normalize = (path: string): string => {
45
50
  const parts: string[] = []
@@ -65,6 +65,8 @@ export {
65
65
  compileProject,
66
66
  compileProjectWithMap,
67
67
  entriesToFiles,
68
+ localAssetNames,
69
+ localAssetBase,
68
70
  type CompileEntry,
69
71
  type CompileOptions,
70
72
  type CompileFileType,
@@ -210,11 +210,17 @@ export type CompileServerOptions = {
210
210
  export const compileServerBundle = async (opts: CompileServerOptions): Promise<{ code: string, manifest: ServerManifest } | null> => {
211
211
  const modules = scanServerModules(opts.entries)
212
212
  if (Object.keys(modules).length === 0) return null
213
+ // The server bundle sees the whole project map (a server module may import shared code), but the
214
+ // bundler's asset check runs over EVERY file in the map — so binaries and svgs must be present as
215
+ // stubs, or a client screen's `asset("./x.png")` fails the SERVER build ("asset not found", seen
216
+ // with a Figma-exported design next to app.server.ts). The board's `design/` files are never
217
+ // server code and stay out entirely.
213
218
  const files: MemoryFiles = {}
214
219
  for (const e of opts.entries) {
215
- if (e.type !== "text") continue
216
- if (e.path.toLowerCase().endsWith(".svg")) continue
217
- files[normalizePath(e.path)] = e.text ?? ""
220
+ const path = normalizePath(e.path)
221
+ if (/^\/design\//.test(path)) continue
222
+ if (e.type !== "text" || path.toLowerCase().endsWith(".svg")) { files[path] = `export default ""`; continue }
223
+ files[path] = e.text ?? ""
218
224
  }
219
225
  let params = opts.params
220
226
  if (!params && opts.validate !== false) {
package/src/g2/Node2D.ts CHANGED
@@ -16,6 +16,19 @@ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
16
16
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
17
17
  import { ensurePointerEvents } from "./touch"
18
18
  import { ensurePhysicsEvents } from "./loop"
19
+ import type { Animation } from "../animate/tween/Animation"
20
+ import { CLOCK_GAME, DOM_NODE2D, floatValue, vec2Value, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
21
+ import { tweenBag } from "../animate/tween/Timeline"
22
+
23
+ /** Animatable transform props of a 2D node; an array of values is a keyframe list. */
24
+ export type Node2DTweenProps = {
25
+ x?: number | number[],
26
+ y?: number | number[],
27
+ position?: Vec2Like | Vec2Like[],
28
+ /** Degrees. */
29
+ rotation?: number | number[],
30
+ scale?: number | Vec2Like | (number | Vec2Like)[],
31
+ }
19
32
 
20
33
  // re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
21
34
  export { Vec2, type Vec2Like }
@@ -135,6 +148,31 @@ export class Node2D extends AspectHost<Node2DEvents> implements CompWriter {
135
148
  _creator2d.setScale(this.id, sx, sy)
136
149
  }
137
150
 
151
+ // --- keyframe animation (docs/timeline-plan.md) ---
152
+ /** Tween the transform — `node.animateTo({ x: 120, rotation: 90, duration: 400, easing: 'outBack' })`;
153
+ * arrays are keyframes. Game clock unless `clock: 'ui'`. Returns the {@link Animation} handle. */
154
+ animateTo(props: Node2DTweenProps & TweenMeta): Animation {
155
+ return tweenBag(this, props as Record<string, unknown>, true)
156
+ }
157
+ /** Tween FROM the given values to the node's current transform. */
158
+ animateFrom(props: Node2DTweenProps & TweenMeta): Animation {
159
+ return tweenBag(this, props as Record<string, unknown>, false)
160
+ }
161
+ /** @internal keyframe-core target protocol */
162
+ readonly _tweenClock: number = CLOCK_GAME
163
+ /** @internal */
164
+ _tweenChannel(prop: string): TweenChannel | null {
165
+ const id = () => this.id
166
+ switch (prop) {
167
+ case "x": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._x = v as number } }
168
+ case "y": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._y = v as number } }
169
+ case "position": return { domain: DOM_NODE2D, id, value: vec2Value, commit: (v) => { this._x = cx(v as Vec2Like); this._y = cy(v as Vec2Like) } }
170
+ case "rotation": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._rotation = v as number } }
171
+ case "scale": return { domain: DOM_NODE2D, id, value: vec2Value, commit: (v) => { const s = typeof v === "number" ? [v, v] : v as Vec2Like; this._sx = cx(s); this._sy = cy(s) } }
172
+ default: return null
173
+ }
174
+ }
175
+
138
176
  /** Draw layer (int). Higher layers render on top. */
139
177
  get layer(): number { return this._layer }
140
178
  set layer(v: number) { this._layer = v; _creator2d.setLayer(this.id, v) }
package/src/g2/Sprite.ts CHANGED
@@ -5,8 +5,16 @@
5
5
  import { Color, type ColorInput } from "../core/color"
6
6
  import { Vec2, type Vec2Like } from "../math/vec"
7
7
  import { Canvas, isCanvas } from "../canvas/Canvas"
8
- import { Node2D } from "./Node2D"
8
+ import { Node2D, type Node2DTweenProps } from "./Node2D"
9
9
  import type { Texture2D } from "./Texture2D"
10
+ import { DOM_SPRITE2D, colorValue, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
11
+ import type { Animation } from "../animate/tween/Animation"
12
+
13
+ /** Animatable sprite props on top of the 2D transform. */
14
+ export type SpriteTweenProps = Node2DTweenProps & {
15
+ opacity?: number | number[],
16
+ color?: ColorInput | ColorInput[],
17
+ }
10
18
 
11
19
  /** Anything that can back a sprite: a texture, or a Canvas (baked to a texture on assign). */
12
20
  export type TextureSource = Texture2D | Canvas
@@ -76,6 +84,17 @@ export class Sprite extends Node2D {
76
84
  return this
77
85
  }
78
86
 
87
+ /** Tween `opacity` / `color` and the transform — see {@link Node2D.animateTo}. */
88
+ override animateTo(props: SpriteTweenProps & TweenMeta): Animation { return super.animateTo(props) }
89
+ override animateFrom(props: SpriteTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
90
+ /** @internal */
91
+ override _tweenChannel(prop: string): TweenChannel | null {
92
+ const id = () => this.id
93
+ if (prop === "opacity") return { domain: DOM_SPRITE2D, id, value: floatValue, commit: (v) => { this._opacity = v as number } }
94
+ if (prop === "color") return { domain: DOM_SPRITE2D, id, value: (v) => colorValue(v, Color.toRgba01), commit: (v) => { this._color = v as ColorInput } }
95
+ return super._tweenChannel(prop)
96
+ }
97
+
79
98
  get color(): ColorInput { return this._color }
80
99
  set color(c: ColorInput) { this._color = c; const [ r, g, b ] = Color.toRgb01(c); _creator2d.setSpriteColor(this.id, r, g, b) }
81
100
 
package/src/gl/Camera.ts CHANGED
@@ -3,8 +3,17 @@
3
3
 
4
4
  import { Vec3 } from "../math/vec"
5
5
  import { Aspect } from "../core/Aspect"
6
- import { Node } from "./Node"
6
+ import { Node, type NodeTweenProps } from "./Node"
7
7
  import { Ray } from "./Ray"
8
+ import { DOM_GL_CAMERA, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
9
+ import type { Animation } from "../animate/tween/Animation"
10
+
11
+ /** Animatable camera props on top of the node transform. */
12
+ export type CameraTweenProps = NodeTweenProps & {
13
+ fov?: number | number[],
14
+ near?: number | number[],
15
+ far?: number | number[],
16
+ }
8
17
 
9
18
  /** @internal `camera.follow(node)`: copies the node's world pose onto the camera every LATE frame
10
19
  * (order 1000 — after every other aspect has moved things). One per camera; lives on the target. */
@@ -24,6 +33,24 @@ export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }
24
33
 
25
34
  export class Camera extends Node {
26
35
  private _sceneId = -1
36
+
37
+ /** Tween `fov` / `near` / `far` (a zoom, a dolly) and the transform — see {@link Node.animateTo}. */
38
+ override animateTo(props: CameraTweenProps & TweenMeta): Animation { return super.animateTo(props) }
39
+ override animateFrom(props: CameraTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
40
+ /** @internal The projection channels address the SCENE (one camera per scene on the host). */
41
+ override _tweenChannel(prop: string): TweenChannel | null {
42
+ if (prop === "fov" || prop === "near" || prop === "far") {
43
+ const id = () => (this._sceneId < 0 ? 0 : this._sceneId + 1) // scene ids start at 0; 0 = unaddressable
44
+ const commit = (v: unknown) => {
45
+ if (prop === "fov") this._fov = v as number
46
+ else if (prop === "near") this._near = v as number
47
+ else this._far = v as number
48
+ this._projSet = true
49
+ }
50
+ return { domain: DOM_GL_CAMERA, id, value: floatValue, commit }
51
+ }
52
+ return super._tweenChannel(prop)
53
+ }
27
54
  /** Live projection matrix (updated by the host when the viewport/fov changes). */
28
55
  readonly projectionMatrix = new Float32Array(16)
29
56
 
@@ -57,6 +84,12 @@ export class Camera extends Node {
57
84
  * adaptation when the sun swings into view (drop it, and the sun stays at the display's peak
58
85
  * while everything else darkens). Scene light follows it; particle `emissive` is post-exposure
59
86
  * and does not, so a fireball keeps its on-screen brightness. Cheap to animate every frame. */
87
+ /** The camera's EV100 with the compensation in: log2(aperture² / shutterSpeed x 100 / iso) - compensation. Sunny 16 is
88
+ * ~15; a lit surface of luminance L lands on screen at L / (1.2 x 2^ev100). */
89
+ get ev100(): number {
90
+ const e = this._exposure
91
+ return Math.log2((e.aperture * e.aperture) / e.shutterSpeed * 100 / e.iso) - this._ev
92
+ }
60
93
  get exposureCompensation(): number { return this._ev }
61
94
  set exposureCompensation(stops: number) {
62
95
  this._ev = Number.isFinite(stops) ? stops : 0
@@ -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
+ }