lecodes-cli 0.18.2 → 0.19.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 (57) hide show
  1. package/dist/index.js +1240 -170
  2. package/package.json +3 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/aspectMacro.ts +86 -0
  5. package/runtime/sdk/compile/assetIconMacro.ts +384 -0
  6. package/runtime/sdk/compile/assetMacro.ts +146 -0
  7. package/runtime/sdk/compile/assetName.ts +50 -0
  8. package/runtime/sdk/compile/bundler.ts +287 -0
  9. package/runtime/sdk/compile/compileProject.ts +129 -0
  10. package/runtime/sdk/compile/detectEntry.ts +128 -0
  11. package/runtime/sdk/compile/fontMacro.ts +459 -0
  12. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  13. package/runtime/sdk/compile/header.ts +67 -0
  14. package/runtime/sdk/compile/index.ts +100 -0
  15. package/runtime/sdk/compile/libraryImports.ts +52 -0
  16. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +88 -0
  18. package/runtime/sdk/compile/serverSplit.ts +233 -0
  19. package/runtime/sdk/compile/serverTypes.ts +227 -0
  20. package/runtime/sdk/compile/sfnt.ts +98 -0
  21. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  22. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  23. package/runtime/sdk/compile/sourcemap.ts +25 -0
  24. package/runtime/sdk/core/Aspect.ts +65 -9
  25. package/runtime/sdk/core/StateMachine.ts +308 -0
  26. package/runtime/sdk/core/time.ts +3 -0
  27. package/runtime/sdk/gl/CameraPlace.ts +52 -52
  28. package/runtime/sdk/gl/Light.ts +12 -4
  29. package/runtime/sdk/gl/Lightmap.ts +87 -10
  30. package/runtime/sdk/gl/Material.ts +15 -0
  31. package/runtime/sdk/gl/Model.ts +68 -8
  32. package/runtime/sdk/gl/NavAgent.ts +337 -0
  33. package/runtime/sdk/gl/NavMesh.ts +397 -0
  34. package/runtime/sdk/gl/Ragdoll.ts +270 -0
  35. package/runtime/sdk/gl/Scene.ts +7 -0
  36. package/runtime/sdk/gl/Shape.ts +37 -5
  37. package/runtime/sdk/gl/Terrain.ts +1092 -0
  38. package/runtime/sdk/gl/Texture.ts +17 -0
  39. package/runtime/sdk/gl/Trigger.ts +45 -45
  40. package/runtime/sdk/gl/animation/Animator.ts +35 -2
  41. package/runtime/sdk/gl/animation/Layer.ts +3 -1
  42. package/runtime/sdk/gl/animation/Loop.ts +5 -0
  43. package/runtime/sdk/gl/animation/Playback.ts +4 -3
  44. package/runtime/sdk/gl/animation/core.ts +52 -10
  45. package/runtime/sdk/gl/scenarios.ts +291 -291
  46. package/runtime/sdk/gl/state.ts +6 -6
  47. package/runtime/sdk/gl/terrainMesh.ts +219 -0
  48. package/runtime/sdk/inject.ts +12 -1
  49. package/runtime/sdk/net/codec.ts +119 -0
  50. package/runtime/sdk/net/core.ts +384 -0
  51. package/runtime/sdk/net/index.ts +181 -0
  52. package/runtime/sdk/net/replication.ts +622 -0
  53. package/runtime/sdk/scene/defineScene.ts +140 -14
  54. package/runtime/sdk/scene/editorPlugins.ts +18 -0
  55. package/runtime/sdk/scene/gizmos.ts +148 -148
  56. package/runtime/sdk/ui/UINode.ts +4 -0
  57. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,219 @@
1
+ // The terrain grid as geometry, in TypeScript — used two ways (docs/terrain-plan.md §1.3, §3.5):
2
+ // • `heightAt` / `normalAt` — the SDK's own queries, identical on every host (bilinear on the SAME
3
+ // triangle split the engines draw and collide);
4
+ // • `buildTerrainChunk` — chunk meshes for hosts WITHOUT `_creator.terrainCreate` (web-lite, headless,
5
+ // a wasm/AAR/Apple build predating the feature): the same grid → chunk → triangles rule
6
+ // creator-gl/src/terrain.cpp applies (the i00–i11 diagonal, CCW seen from +Y, a triangle only when
7
+ // its three samples are valid), as plain Geometry for setMesh. Normals use central differences over
8
+ // the FULL grid (seamless chunks); the tangent frame is then the host's arbitrary one, so normal maps
9
+ // read wrong on those hosts — the accepted fallback cost.
10
+ // Keep the hole / split rule in step with terrain.cpp buildChunk and Jolt's HeightFieldShape.
11
+
12
+ import { Geometry } from "./Geometry"
13
+
14
+ export type TerrainGrid = {
15
+ sizeX: number
16
+ sizeZ: number
17
+ cellSize: number
18
+ /** sizeX × sizeZ heights, row-major (z rows). */
19
+ heights: Float32Array
20
+ /** One byte per sample, 1 = hole; null = no holes. */
21
+ holes: Uint8Array | null
22
+ }
23
+
24
+ const clampI = (v: number, hi: number): number => (v < 0 ? 0 : v > hi ? hi : v)
25
+
26
+ /** The sample at integer (x, z), clamped to the grid. */
27
+ export const sampleAt = (g: TerrainGrid, x: number, z: number): number =>
28
+ g.heights[clampI(z, g.sizeZ - 1) * g.sizeX + clampI(x, g.sizeX - 1)]
29
+
30
+ export const isHole = (g: TerrainGrid, x: number, z: number): boolean =>
31
+ g.holes !== null && g.holes[z * g.sizeX + x] !== 0
32
+
33
+ /** Surface normal at a sample from central differences (one-sided at the borders). Writes `out` (xyz). */
34
+ export const normalAt = (g: TerrainGrid, x: number, z: number, out: Float32Array | number[] = [ 0, 0, 0 ]): typeof out => {
35
+ const x0 = x > 0 ? x - 1 : x, x1 = x + 1 < g.sizeX ? x + 1 : x
36
+ const z0 = z > 0 ? z - 1 : z, z1 = z + 1 < g.sizeZ ? z + 1 : z
37
+ const dx = (sampleAt(g, x1, z) - sampleAt(g, x0, z)) / ((x1 - x0) * g.cellSize)
38
+ const dz = (sampleAt(g, x, z1) - sampleAt(g, x, z0)) / ((z1 - z0) * g.cellSize)
39
+ const len = Math.hypot(dx, 1, dz)
40
+ out[0] = -dx / len; out[1] = 1 / len; out[2] = -dz / len
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * Height at a LOCAL (x, z) in metres, interpolated on the drawn triangles (the i00–i11 diagonal), so a
46
+ * character's feet and a raycast agree with what is rendered to the millimetre. NaN inside a hole
47
+ * triangle or outside the grid.
48
+ */
49
+ export const heightAt = (g: TerrainGrid, x: number, z: number): number => {
50
+ const fx = x / g.cellSize, fz = z / g.cellSize
51
+ if (!(fx >= 0 && fz >= 0 && fx <= g.sizeX - 1 && fz <= g.sizeZ - 1)) return NaN
52
+ let ix = Math.floor(fx), iz = Math.floor(fz)
53
+ if (ix >= g.sizeX - 1) ix = g.sizeX - 2
54
+ if (iz >= g.sizeZ - 1) iz = g.sizeZ - 2
55
+ const u = fx - ix, v = fz - iz
56
+ const i00 = iz * g.sizeX + ix
57
+ const h00 = g.heights[i00], h10 = g.heights[i00 + 1], h01 = g.heights[i00 + g.sizeX], h11 = g.heights[i00 + g.sizeX + 1]
58
+ if (g.holes !== null) {
59
+ const holes = g.holes
60
+ if (holes[i00] || holes[i00 + g.sizeX + 1]) return NaN
61
+ // lower-left triangle (i00, i01, i11) when v >= u, else upper-right (i00, i11, i10)
62
+ if (v >= u ? holes[i00 + g.sizeX] !== 0 : holes[i00 + 1] !== 0) return NaN
63
+ }
64
+ // plane of the triangle the point falls in
65
+ return v >= u
66
+ ? h00 + (h11 - h01) * u + (h01 - h00) * v
67
+ : h00 + (h10 - h00) * u + (h11 - h10) * v
68
+ }
69
+
70
+ /** The geometric normal at a LOCAL (x, z): the drawn triangle's normal (not the smoothed vertex one). */
71
+ export const faceNormalAt = (g: TerrainGrid, x: number, z: number, out: Float32Array | number[] = [ 0, 0, 0 ]): typeof out => {
72
+ const fx = x / g.cellSize, fz = z / g.cellSize
73
+ let ix = Math.floor(fx), iz = Math.floor(fz)
74
+ ix = clampI(ix, g.sizeX - 2); iz = clampI(iz, g.sizeZ - 2)
75
+ const u = fx - ix, v = fz - iz
76
+ const i00 = iz * g.sizeX + ix
77
+ const h00 = g.heights[i00], h10 = g.heights[i00 + 1], h01 = g.heights[i00 + g.sizeX], h11 = g.heights[i00 + g.sizeX + 1]
78
+ const c = g.cellSize
79
+ // gradients of the two planes (see heightAt)
80
+ const dx = (v >= u ? h11 - h01 : h10 - h00) / c
81
+ const dz = (v >= u ? h01 - h00 : h11 - h10) / c
82
+ const len = Math.hypot(dx, 1, dz)
83
+ out[0] = -dx / len; out[1] = 1 / len; out[2] = -dz / len
84
+ return out
85
+ }
86
+
87
+ /** Chunks along X and Z for a grid: a chunk covers `chunk` cells (chunk + 1 samples). */
88
+ export const chunkCounts = (g: TerrainGrid, chunk: number): [number, number] =>
89
+ [ Math.floor((g.sizeX - 2) / chunk) + 1, Math.floor((g.sizeZ - 2) / chunk) + 1 ]
90
+
91
+ /**
92
+ * One chunk as a Geometry (positions, smooth normals, UV0 = local XZ metres, UV1 = the terrain's unit
93
+ * square, u16 indices), or null when every cell of it is a hole.
94
+ */
95
+ export const buildTerrainChunk = (g: TerrainGrid, cx: number, cz: number, chunk: number): Geometry | null => {
96
+ const x0 = cx * chunk, z0 = cz * chunk
97
+ const x1 = Math.min(x0 + chunk, g.sizeX - 1), z1 = Math.min(z0 + chunk, g.sizeZ - 1)
98
+ const nx = x1 - x0 + 1, nz = z1 - z0 + 1
99
+ const idx: number[] = []
100
+ for (let z = z0; z < z1; z++) {
101
+ for (let x = x0; x < x1; x++) {
102
+ const i00 = (z - z0) * nx + (x - x0), i10 = i00 + 1, i01 = i00 + nx, i11 = i01 + 1
103
+ const v00 = !isHole(g, x, z), v10 = !isHole(g, x + 1, z), v01 = !isHole(g, x, z + 1), v11 = !isHole(g, x + 1, z + 1)
104
+ if (!(v00 && v11)) continue
105
+ if (v01) idx.push(i00, i01, i11)
106
+ if (v10) idx.push(i00, i11, i10)
107
+ }
108
+ }
109
+ if (idx.length === 0) return null
110
+ const count = nx * nz
111
+ const vertices = new Float32Array(count * 3)
112
+ const normals = new Float32Array(count * 3)
113
+ const uv = new Float32Array(count * 2)
114
+ const uv1 = new Float32Array(count * 2)
115
+ const n = [ 0, 0, 0 ]
116
+ const invX = 1 / (g.sizeX - 1), invZ = 1 / (g.sizeZ - 1)
117
+ for (let z = z0; z <= z1; z++) {
118
+ for (let x = x0; x <= x1; x++) {
119
+ const i = (z - z0) * nx + (x - x0)
120
+ vertices[i * 3] = x * g.cellSize
121
+ vertices[i * 3 + 1] = g.heights[z * g.sizeX + x]
122
+ vertices[i * 3 + 2] = z * g.cellSize
123
+ normalAt(g, x, z, n)
124
+ normals[i * 3] = n[0]; normals[i * 3 + 1] = n[1]; normals[i * 3 + 2] = n[2]
125
+ uv[i * 2] = x * g.cellSize; uv[i * 2 + 1] = z * g.cellSize
126
+ uv1[i * 2] = x * invX; uv1[i * 2 + 1] = z * invZ
127
+ }
128
+ }
129
+ const geom = new Geometry(vertices, normals, Uint16Array.from(idx), uv)
130
+ geom.uv1 = uv1
131
+ return geom
132
+ }
133
+
134
+ // ---- ray queries ---------------------------------------------------------------------------------
135
+
136
+ export type GridHit = { x: number, y: number, z: number, distance: number, nx: number, ny: number, nz: number }
137
+
138
+ /** Möller–Trumbore against one triangle; the distance along the ray, or -1. */
139
+ const rayTriangle = (
140
+ ox: number, oy: number, oz: number, dx: number, dy: number, dz: number,
141
+ ax: number, ay: number, az: number, bx: number, by: number, bz: number, qx: number, qy: number, qz: number,
142
+ ): number => {
143
+ const e1x = bx - ax, e1y = by - ay, e1z = bz - az
144
+ const e2x = qx - ax, e2y = qy - ay, e2z = qz - az
145
+ const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x
146
+ const det = e1x * px + e1y * py + e1z * pz
147
+ if (Math.abs(det) < 1e-12) return -1
148
+ const inv = 1 / det
149
+ const tx = ox - ax, ty = oy - ay, tz = oz - az
150
+ const u = (tx * px + ty * py + tz * pz) * inv
151
+ if (u < -1e-6 || u > 1 + 1e-6) return -1
152
+ const qxx = ty * e1z - tz * e1y, qyy = tz * e1x - tx * e1z, qzz = tx * e1y - ty * e1x
153
+ const v = (dx * qxx + dy * qyy + dz * qzz) * inv
154
+ if (v < -1e-6 || u + v > 1 + 1e-6) return -1
155
+ const t = (e2x * qxx + e2y * qyy + e2z * qzz) * inv
156
+ return t >= 0 ? t : -1
157
+ }
158
+
159
+ /**
160
+ * Nearest hit of a LOCAL ray (origin + direction, metres) on the drawn triangles — the same
161
+ * i00–i11 split and hole rule as `heightAt`, so a pick lands on what is rendered. Walks the cells
162
+ * the ray crosses (2D DDA on XZ), testing each cell's two triangles; holes let the ray through.
163
+ * Null when it misses (or never enters the grid within `maxDistance`).
164
+ */
165
+ export const gridRaycast = (g: TerrainGrid, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, maxDistance = 1e5): GridHit | null => {
166
+ const c = g.cellSize, W = (g.sizeX - 1) * c, D = (g.sizeZ - 1) * c
167
+ let t0 = 0, t1 = maxDistance
168
+ const clip = (o: number, d: number, hi: number): boolean => {
169
+ if (Math.abs(d) < 1e-12) return o >= 0 && o <= hi
170
+ let a = -o / d, b = (hi - o) / d
171
+ if (a > b) { const s = a; a = b; b = s }
172
+ if (a > t0) t0 = a
173
+ if (b < t1) t1 = b
174
+ return t0 <= t1
175
+ }
176
+ if (!clip(ox, dx, W) || !clip(oz, dz, D)) return null
177
+ const sx = ox + dx * t0, sz = oz + dz * t0
178
+ let ix = Math.min(g.sizeX - 2, Math.max(0, Math.floor(sx / c)))
179
+ let iz = Math.min(g.sizeZ - 2, Math.max(0, Math.floor(sz / c)))
180
+ const stepX = dx > 1e-12 ? 1 : dx < -1e-12 ? -1 : 0
181
+ const stepZ = dz > 1e-12 ? 1 : dz < -1e-12 ? -1 : 0
182
+ const tDeltaX = stepX ? Math.abs(c / dx) : Infinity, tDeltaZ = stepZ ? Math.abs(c / dz) : Infinity
183
+ let tMaxX = stepX > 0 ? t0 + ((ix + 1) * c - sx) / dx : stepX < 0 ? t0 + (ix * c - sx) / dx : Infinity
184
+ let tMaxZ = stepZ > 0 ? t0 + ((iz + 1) * c - sz) / dz : stepZ < 0 ? t0 + (iz * c - sz) / dz : Infinity
185
+ const holes = g.holes
186
+ let t = t0
187
+ for (let guard = 0; guard < g.sizeX + g.sizeZ + 2 && t <= t1 + 1e-9; guard++) {
188
+ const i00 = iz * g.sizeX + ix, i10 = i00 + 1, i01 = i00 + g.sizeX, i11 = i01 + 1
189
+ const x0 = ix * c, x1 = x0 + c, z0 = iz * c, z1 = z0 + c
190
+ const h00 = g.heights[i00], h10 = g.heights[i10], h01 = g.heights[i01], h11 = g.heights[i11]
191
+ const ok00 = !holes || holes[i00] === 0, ok11 = !holes || holes[i11] === 0
192
+ let best = -1, tri = 0
193
+ if (ok00 && ok11) {
194
+ // lower-left (i00, i01, i11) and upper-right (i00, i11, i10) — both CCW seen from +Y
195
+ if (!holes || holes[i01] === 0) {
196
+ const d = rayTriangle(ox, oy, oz, dx, dy, dz, x0, h00, z0, x0, h01, z1, x1, h11, z1)
197
+ if (d >= 0 && (best < 0 || d < best)) { best = d; tri = 0 }
198
+ }
199
+ if (!holes || holes[i10] === 0) {
200
+ const d = rayTriangle(ox, oy, oz, dx, dy, dz, x0, h00, z0, x1, h11, z1, x1, h10, z0)
201
+ if (d >= 0 && (best < 0 || d < best)) { best = d; tri = 1 }
202
+ }
203
+ }
204
+ if (best >= 0 && best <= t1 + 1e-6) {
205
+ // the hit triangle's normal
206
+ let ax: number, ay: number, az: number, bx: number, by: number, bz: number
207
+ if (tri === 0) { ax = 0; ay = h01 - h00; az = c; bx = c; by = h11 - h00; bz = c }
208
+ else { ax = c; ay = h11 - h00; az = c; bx = c; by = h10 - h00; bz = 0 }
209
+ let nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx
210
+ const l = Math.hypot(nx, ny, nz) || 1
211
+ nx /= l; ny /= l; nz /= l
212
+ return { x: ox + dx * best, y: oy + dy * best, z: oz + dz * best, distance: best, nx, ny, nz }
213
+ }
214
+ if (tMaxX < tMaxZ) { t = tMaxX; ix += stepX; tMaxX += tDeltaX }
215
+ else { t = tMaxZ; iz += stepZ; tMaxZ += tDeltaZ }
216
+ if (ix < 0 || iz < 0 || ix > g.sizeX - 2 || iz > g.sizeZ - 2) break
217
+ }
218
+ return null
219
+ }
@@ -18,6 +18,7 @@ export { Color } from "./core/color"
18
18
  export { Aspect, System, type With, type SystemHost } from "./core/Aspect"
19
19
  export { type EventMap } from "./core/events"
20
20
  export { Time } from "./core/time"
21
+ export { StateMachine, defineStates, type StateDef, type StatesDef, type StateGuard, type StateHandler, type StateMachineEvents } from "./core/StateMachine"
21
22
  export { InspectorUI } from "./core/InspectorUI"
22
23
 
23
24
  // ---- reactivity (signals; UI text/style props also accept `() => value` bindings) ----
@@ -37,6 +38,8 @@ export { localStorage } from "./runtime/storage"
37
38
  export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
38
39
  export { Input, InputChannel, type InputKeyEvent, type InputGamepadEvent, type InputEventName, type GamepadState, type GamepadAxisName } from "./runtime/input"
39
40
  export { WebSocket } from "./runtime/net"
41
+ // Multiplayer (docs/multiplayer-plan.md): roles, players, JSON messages over the _creatorNet transport.
42
+ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform } from "./net"
40
43
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
41
44
  // only ever imports its server functions (docs/backend-plan.md §4).
42
45
  export { __rpc, __channel, __serverOnly, RpcError, type ClientChannel } from "./runtime/rpc"
@@ -161,6 +164,8 @@ export { CharacterController } from "./gl/CharacterController"
161
164
  export { Vehicle } from "./gl/Vehicle"
162
165
  export type { DriveLayout, DifferentialMode, EngineConfig, SteeringConfig, AeroConfig } from "./gl/Vehicle"
163
166
  export { Wheel } from "./gl/Wheel"
167
+ export { Ragdoll } from "./gl/Ragdoll"
168
+ export type { RagdollPart, RagdollHinge } from "./gl/Ragdoll"
164
169
  export type { TireCurve, WheelSuspension, WheelTire, WheelVisual } from "./gl/Wheel"
165
170
  export { Gearbox } from "./gl/Gearbox"
166
171
  export { AnimationClip } from "./gl/animation/AnimationClip"
@@ -169,11 +174,17 @@ export { Animator } from "./gl/animation/Animator"
169
174
  export { Playback } from "./gl/animation/Playback"
170
175
  export { Loop } from "./gl/animation/Loop"
171
176
  export { Layer } from "./gl/animation/Layer"
172
- export type { PlayOptions, StopOptions, LayerOptions, LoopOptions, LoopDef, BlendPosition } from "./gl/animation/core"
177
+ export type { PlayOptions, StopOptions, LayerOptions, LoopOptions, LoopDef, BlendPosition, ActiveClip } from "./gl/animation/core"
173
178
  export { IK } from "./gl/IK"
174
179
  export type { IKTwoBone, IKLookAt } from "./gl/IK"
175
180
  export { Light } from "./gl/Light"
176
181
  export { Lightmap } from "./gl/Lightmap"
182
+ export { Terrain } from "./gl/Terrain"
183
+ export type { TerrainOptions, TerrainLayer, TerrainRegion, ConformOptions, RibbonOptions, AutoPaintRules, SculptOptions, TerrainHit, TerrainSnapshot } from "./gl/Terrain"
184
+ export { NavMesh, NavCrowd } from "./gl/NavMesh"
185
+ export type { NavMeshLoadOptions, NavMeshInfo, NavPath, NavQueryOptions, NavRaycastHit, NavAgentSize } from "./gl/NavMesh"
186
+ export { NavAgent } from "./gl/NavAgent"
187
+ export type { NavAgentState, NavAgentAvoidance, NavAgentDrive, NavAgentEvents } from "./gl/NavAgent"
177
188
  export { Material } from "./gl/Material"
178
189
  export { Geometry } from "./gl/Geometry"
179
190
  export { InstancedMesh } from "./gl/InstancedMesh"
@@ -0,0 +1,119 @@
1
+ // Defaults → wire layout (docs/multiplayer-plan.md §4 "input: defaults object = layout"). The hot
2
+ // stream — inputs and snapshot states — is binary and its shape comes from a plain defaults object:
3
+ // number → f32
4
+ // boolean → 1 bit (packed after the floats)
5
+ // number[] → f32 × length, fixed
6
+ // Nothing else: no strings, no nesting, no optionals — snapshot fields are flat and fixed-size by
7
+ // design. Both sides run the same bundle, so the layout is derived, never transmitted; the `sig`
8
+ // feeds the schema hash the handshake compares.
9
+
10
+ export type FieldKind = 0 | 1 | 2 // f32 · bool · f32[]
11
+ export type Field = { key: string; kind: FieldKind; len: number }
12
+ export type Layout = { fields: Field[]; floats: number; bools: number; bytes: number; sig: string }
13
+
14
+ const describe = (v: unknown): string => Array.isArray(v) ? `array of ${v.length}` : v === null ? "null" : typeof v
15
+
16
+ export const makeLayout = (defaults: Record<string, unknown>, what: string): Layout => {
17
+ const fields: Field[] = []
18
+ let floats = 0
19
+ let bools = 0
20
+ for (const key of Object.keys(defaults)) {
21
+ const v = defaults[key]
22
+ if (typeof v === "number") { fields.push({ key, kind: 0, len: 1 }); floats++ }
23
+ else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1 }); bools++ }
24
+ else if (Array.isArray(v) && v.length > 0 && v.length <= 64 && v.every((n) => typeof n === "number")) {
25
+ fields.push({ key, kind: 2, len: v.length }); floats += v.length
26
+ } else {
27
+ throw new Error(`${what}: field '${key}' must be a number, a boolean or a fixed-length number[] (got ${describe(v)})`)
28
+ }
29
+ }
30
+ const sig = fields.map((f) => `${f.key}:${f.kind}${f.len > 1 ? f.len : ""}`).join(",")
31
+ return { fields, floats, bools, bytes: floats * 4 + Math.ceil(bools / 8), sig }
32
+ }
33
+
34
+ /** A fresh state object shaped like the defaults (arrays copied, never shared). */
35
+ export const cloneDefaults = <T extends object>(defaults: T): T => {
36
+ const out: Record<string, unknown> = {}
37
+ for (const key of Object.keys(defaults)) {
38
+ const v = (defaults as Record<string, unknown>)[key]
39
+ out[key] = Array.isArray(v) ? v.slice() : v
40
+ }
41
+ return out as T
42
+ }
43
+
44
+ /** Copy the layout's fields from `from` into `into` (array elements copied in place; unknown keys
45
+ * ignored — a JSON spawn state or a partial `setInput`). */
46
+ export const assignState = (layout: Layout, into: any, from: any): void => {
47
+ if (!from) return
48
+ for (const f of layout.fields) {
49
+ const v = from[f.key]
50
+ if (v === undefined) continue
51
+ if (f.kind === 2) {
52
+ const a = into[f.key] as number[]
53
+ for (let i = 0; i < f.len; i++) a[i] = +(v[i] ?? 0)
54
+ } else if (f.kind === 1) into[f.key] = !!v
55
+ else into[f.key] = +v
56
+ }
57
+ }
58
+
59
+ export const packInto = (view: DataView, offset: number, layout: Layout, s: any): number => {
60
+ let o = offset
61
+ for (const f of layout.fields) {
62
+ if (f.kind === 0) { view.setFloat32(o, s[f.key], true); o += 4 }
63
+ else if (f.kind === 2) {
64
+ const a = s[f.key] as number[]
65
+ for (let i = 0; i < f.len; i++) { view.setFloat32(o, a[i] ?? 0, true); o += 4 }
66
+ }
67
+ }
68
+ let bits = 0
69
+ let n = 0
70
+ for (const f of layout.fields) {
71
+ if (f.kind !== 1) continue
72
+ if (s[f.key]) bits |= 1 << (n & 7)
73
+ n++
74
+ if ((n & 7) === 0) { view.setUint8(o++, bits); bits = 0 }
75
+ }
76
+ if (n & 7) view.setUint8(o++, bits)
77
+ return o
78
+ }
79
+
80
+ export const unpackFrom = (view: DataView, offset: number, layout: Layout, into: any): number => {
81
+ let o = offset
82
+ for (const f of layout.fields) {
83
+ if (f.kind === 0) { into[f.key] = view.getFloat32(o, true); o += 4 }
84
+ else if (f.kind === 2) {
85
+ const a = into[f.key] as number[]
86
+ for (let i = 0; i < f.len; i++) { a[i] = view.getFloat32(o, true); o += 4 }
87
+ }
88
+ }
89
+ let bits = 0
90
+ let n = 0
91
+ for (const f of layout.fields) {
92
+ if (f.kind !== 1) continue
93
+ if ((n & 7) === 0) bits = view.getUint8(o++)
94
+ into[f.key] = (bits & (1 << (n & 7))) !== 0
95
+ n++
96
+ }
97
+ return o
98
+ }
99
+
100
+ /** `out = a + (b − a) · t` for the numeric fields; booleans take `b`'s. */
101
+ export const lerpState = (layout: Layout, a: any, b: any, t: number, out: any): void => {
102
+ for (const f of layout.fields) {
103
+ if (f.kind === 0) out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
104
+ else if (f.kind === 2) {
105
+ const pa = a[f.key] as number[], pb = b[f.key] as number[], po = out[f.key] as number[]
106
+ for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * t
107
+ } else out[f.key] = b[f.key]
108
+ }
109
+ }
110
+
111
+ /** FNV-1a over a string → 8 hex chars. Enough to tell two bundles' schemas apart. */
112
+ export const hashString = (s: string): string => {
113
+ let h = 0x811c9dc5
114
+ for (let i = 0; i < s.length; i++) {
115
+ h ^= s.charCodeAt(i)
116
+ h = Math.imul(h, 0x01000193)
117
+ }
118
+ return (h >>> 0).toString(16).padStart(8, "0")
119
+ }