lecodes-cli 0.18.1 → 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.
- package/dist/index.js +2096 -506
- package/package.json +3 -2
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/core/Aspect.ts +65 -9
- package/runtime/sdk/core/StateMachine.ts +308 -0
- package/runtime/sdk/core/time.ts +3 -0
- package/runtime/sdk/gl/Camera.ts +39 -0
- package/runtime/sdk/gl/Light.ts +12 -4
- package/runtime/sdk/gl/Lightmap.ts +87 -10
- package/runtime/sdk/gl/Material.ts +15 -0
- package/runtime/sdk/gl/Model.ts +68 -8
- package/runtime/sdk/gl/NavAgent.ts +337 -0
- package/runtime/sdk/gl/NavMesh.ts +397 -0
- package/runtime/sdk/gl/Ragdoll.ts +270 -0
- package/runtime/sdk/gl/Scene.ts +20 -0
- package/runtime/sdk/gl/Shape.ts +37 -5
- package/runtime/sdk/gl/Terrain.ts +1092 -0
- package/runtime/sdk/gl/Texture.ts +17 -0
- package/runtime/sdk/gl/animation/Animator.ts +35 -2
- package/runtime/sdk/gl/animation/Layer.ts +3 -1
- package/runtime/sdk/gl/animation/Loop.ts +5 -0
- package/runtime/sdk/gl/animation/Playback.ts +4 -3
- package/runtime/sdk/gl/animation/core.ts +52 -10
- package/runtime/sdk/gl/terrainMesh.ts +219 -0
- package/runtime/sdk/inject.ts +12 -1
- package/runtime/sdk/net/codec.ts +119 -0
- package/runtime/sdk/net/core.ts +384 -0
- package/runtime/sdk/net/index.ts +181 -0
- package/runtime/sdk/net/replication.ts +622 -0
- package/runtime/sdk/runtime/device.ts +102 -1
- package/runtime/sdk/scene/defineScene.ts +1353 -1227
- package/runtime/sdk/scene/editorPlugins.ts +18 -0
- package/runtime/sdk/ui/UINode.ts +4 -0
- package/runtime/sdk-types.json +1 -1
|
@@ -30,6 +30,23 @@ export class Texture {
|
|
|
30
30
|
return new Texture(Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio), canvas._tex3dId)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** A texture from raw pixels: `channels` (1–4, default 4) UBYTE per pixel, row-major, `width ×
|
|
34
|
+
* height × channels` bytes; `srgb` (default true for colour) picks the sRGB internal format —
|
|
35
|
+
* pass false for data (a heightmap, a mask). No mips. Throws on a host without the call. */
|
|
36
|
+
static fromPixels(width: number, height: number, data: Uint8Array, opts: { channels?: 1 | 2 | 3 | 4, srgb?: boolean } = {}): Texture {
|
|
37
|
+
if (!_creator.createTexturePixels) throw new Error("Texture.fromPixels needs a newer host")
|
|
38
|
+
const channels = opts.channels ?? 4
|
|
39
|
+
if (data.length < width * height * channels) throw new Error(`Texture.fromPixels: ${data.length} bytes for ${width}×${height}×${channels}`)
|
|
40
|
+
const id = _creator.createTexturePixels(width, height, channels, data, opts.srgb ?? true)
|
|
41
|
+
if (id === 0xFFFFFFFF) throw new Error("Texture.fromPixels: the host refused the texture")
|
|
42
|
+
return new Texture(width, height, id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Re-upload a rectangle of a `fromPixels` texture (same channel count). */
|
|
46
|
+
update(x: number, y: number, width: number, height: number, data: Uint8Array): void {
|
|
47
|
+
_creator.updateTexturePixels?.(this._id, x, y, width, height, data)
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
static load(source: string | FetchResponse | File): Promise<Texture> {
|
|
34
51
|
if (typeof source === "string") {
|
|
35
52
|
return fetch(source, { useOnce: true }).then((resp) => {
|
|
@@ -21,15 +21,38 @@
|
|
|
21
21
|
import { Aspect } from "../../core/Aspect"
|
|
22
22
|
import type { Node } from "../Node"
|
|
23
23
|
import type { AnimationClip } from "./AnimationClip"
|
|
24
|
-
import { Core, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
|
|
24
|
+
import { Core, type ActiveClip, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
|
|
25
25
|
import type { Loop } from "./Loop"
|
|
26
26
|
import type { Layer } from "./Layer"
|
|
27
27
|
import type { Playback } from "./Playback"
|
|
28
28
|
|
|
29
|
+
/** Level of detail for a GLB instance (docs/lod-plan.md): `'auto'` = the engine's pick by screen size and
|
|
30
|
+
* visibility, or a fixed level 0 (full) … 3 (coarsest mesh, animation every 4th frame without fingers). */
|
|
31
|
+
export type LodMode = "auto" | 0 | 1 | 2 | 3
|
|
32
|
+
|
|
33
|
+
/** @internal Push a node's LOD override to the host: the Model's mesh level and the Animator's rate
|
|
34
|
+
* (an Animator set to 'full' wins over a model level — the pose stays exact, the mesh may still coarsen). */
|
|
35
|
+
export const _pushLod = (node: Node): void => {
|
|
36
|
+
if (!_creator.setLod) return
|
|
37
|
+
const mesh = (node as { _lodMesh?: number })._lodMesh ?? -1
|
|
38
|
+
const anim = node.get(Animator)
|
|
39
|
+
_creator.setLod(node.id, mesh, anim?._lod === "full" ? 0 : mesh)
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
export class Animator extends Aspect<"anim", Node> {
|
|
30
43
|
static readonly aspect = "anim"
|
|
31
44
|
private _c!: Core
|
|
32
45
|
private _rootMotion = false
|
|
46
|
+
private _rootRotation = false
|
|
47
|
+
/** @internal */
|
|
48
|
+
_lod: "auto" | "full" = "auto"
|
|
49
|
+
|
|
50
|
+
/** Animation level of detail. `'auto'` (default): a character small on screen or out of view is
|
|
51
|
+
* evaluated every 2nd / 4th frame and stops sampling its finger, toe and twist joints; `'full'`:
|
|
52
|
+
* every frame, every joint, whatever the distance — a hero seen through a scope, a cutscene actor.
|
|
53
|
+
* Independent of `Model.lod` (the mesh level). No-op on hosts without the LOD pass. */
|
|
54
|
+
get lod(): "auto" | "full" { return this._lod }
|
|
55
|
+
set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }
|
|
33
56
|
|
|
34
57
|
onAttach(): void { this._c = new Core(this.node) }
|
|
35
58
|
onDetach(): void { this._c.destroy() }
|
|
@@ -66,6 +89,8 @@ export class Animator extends Aspect<"anim", Node> {
|
|
|
66
89
|
get loop(): Loop | undefined { return this._c.layers[0].loop?.view }
|
|
67
90
|
/** A one-shot on the base layer hasn't handed over yet. */
|
|
68
91
|
get busy(): boolean { return this._c.busy(this._c.layers[0]) }
|
|
92
|
+
/** Every clip shown this frame, all layers, with its weight — the crossfades' state (a debug overlay's list). */
|
|
93
|
+
get active(): ActiveClip[] { return this._c.layers.flatMap((L) => this._c.active(L)) }
|
|
69
94
|
|
|
70
95
|
// ---- layers ----
|
|
71
96
|
/** A new layer on top (masked override / additive). The returned object is its handle. */
|
|
@@ -83,5 +108,13 @@ export class Animator extends Aspect<"anim", Node> {
|
|
|
83
108
|
* or its CharacterController (on this node or an ancestor) as a velocity, so it collides. For
|
|
84
109
|
* clips whose hips actually travel (Mixamo without "In Place", lunges, rolls). */
|
|
85
110
|
get rootMotion(): boolean { return this._rootMotion }
|
|
86
|
-
set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on) }
|
|
111
|
+
set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on, this._rootRotation) }
|
|
112
|
+
/** With `rootMotion`, the root bone's TURN (its yaw about the node's up, since the clip's first
|
|
113
|
+
* frame) is root motion too: it comes off the pose and turns the node the travel lands on — the
|
|
114
|
+
* character (or the model) faces where a turn-start / turn-in-place / arc clip took it, and the
|
|
115
|
+
* crossfade into the next clip keeps that heading instead of swinging back. Off by default: a
|
|
116
|
+
* walk cycle's hip sway is a turn too, and most rigs want it in the pose; turn it on for a rig
|
|
117
|
+
* whose root bone carries the heading (`lecodes assets retarget --root-rotation yaw`). */
|
|
118
|
+
get rootRotation(): boolean { return this._rootRotation }
|
|
119
|
+
set rootRotation(on: boolean) { this._rootRotation = on; if (this._rootMotion) this._c.setRootMotion(true, on) }
|
|
87
120
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `{ additive: true }` for deltas stacked on top). The Animator's own play/stop/playLoop ARE its base
|
|
3
3
|
// layer; extra layers composite over it in creation order. The object is the layer's only handle.
|
|
4
4
|
|
|
5
|
-
import type { Core, LayerRec, LoopDef, LoopOptions, PlayOptions, StopOptions } from "./core"
|
|
5
|
+
import type { ActiveClip, Core, LayerRec, LoopDef, LoopOptions, PlayOptions, StopOptions } from "./core"
|
|
6
6
|
import type { Loop } from "./Loop"
|
|
7
7
|
import type { Playback } from "./Playback"
|
|
8
8
|
|
|
@@ -26,4 +26,6 @@ export class Layer {
|
|
|
26
26
|
set weight(v: number) { this._r.weight = v; this._c.pushLayer(this._r) }
|
|
27
27
|
/** A one-shot on this layer hasn't handed over yet. */
|
|
28
28
|
get busy(): boolean { return this._c.busy(this._r) }
|
|
29
|
+
/** Every clip this layer shows this frame with its weight — the crossfades' state, for a debug overlay. */
|
|
30
|
+
get active(): ActiveClip[] { return this._c.active(this._r) }
|
|
29
31
|
}
|
|
@@ -16,6 +16,11 @@ export class Loop {
|
|
|
16
16
|
* even while a one-shot plays (the loop returns at the new mix). */
|
|
17
17
|
get value(): BlendPosition { return this._r.value }
|
|
18
18
|
set value(v: BlendPosition) { this._c.setLoopValue(this._r, v) }
|
|
19
|
+
/** Where the loop is in its cycle, 0–1 — one clock for every member (a blend cycles over its
|
|
20
|
+
* weighted period). Set it to jump the cycle: `playLoop(def, { phase })` on the way in, or here
|
|
21
|
+
* to re-sync with something else (a clip whose pose matches the cycle at that phase). */
|
|
22
|
+
get phase(): number { return this._c.loopPhase(this._r) }
|
|
23
|
+
set phase(v: number) { this._c.setLoopPhase(this._r, v) }
|
|
19
24
|
/** Each member's current contribution, 0–1 (fades included). */
|
|
20
25
|
get weights(): Record<string, number> {
|
|
21
26
|
const out: Record<string, number> = {}
|
|
@@ -21,13 +21,14 @@ export class Playback implements PromiseLike<boolean> {
|
|
|
21
21
|
/** The layer it plays on. */
|
|
22
22
|
get layer(): Layer { return this._r.slot.layer.view }
|
|
23
23
|
/** Clock (s). */
|
|
24
|
-
get time(): number { return this._c.slotTime(this._r.slot) }
|
|
24
|
+
get time(): number { return this._r.slot.last === this._r ? this._c.slotTime(this._r.slot) : 0 }
|
|
25
25
|
/** 0–1 through the clip (the current cycle, when looping). */
|
|
26
26
|
get progress(): number { const d = this.clip.duration; return d > 0 ? this.time / d : 0 }
|
|
27
27
|
/** Not handed over yet (the clip still owns the layer). */
|
|
28
28
|
get playing(): boolean { return this._r.playing }
|
|
29
|
-
/** Current contribution to the pose, 0–1 — moves during crossfades
|
|
30
|
-
|
|
29
|
+
/** Current contribution to the pose, 0–1 — moves during crossfades, still falling after the
|
|
30
|
+
* hand-over while the clip fades beneath what took over. */
|
|
31
|
+
get weight(): number { return this._r.slot.last === this._r ? this._c.slotWeight(this._r.slot) : 0 }
|
|
31
32
|
/** Resolves at the hand-over (`true`) or when cut short (`false`) — the promise `await playback`
|
|
32
33
|
* uses; the one to return from async functions or put in `Promise.all`. */
|
|
33
34
|
get done(): Promise<boolean> { return this._r.done }
|
|
@@ -36,10 +36,25 @@ export type LoopOptions = {
|
|
|
36
36
|
fade?: number
|
|
37
37
|
/** Playback rate of the loop's clips (default 1). */
|
|
38
38
|
speed?: number
|
|
39
|
+
/** Where in the cycle to start, 0–1 (default 0): a blend's members share one clock, so this is the
|
|
40
|
+
* phase of every member — e.g. the phase whose pose matches the clip the loop takes over from. */
|
|
41
|
+
phase?: number
|
|
39
42
|
}
|
|
40
43
|
/** What a layer loops: one clip (name / index), or a blend space `{ name: position }`. */
|
|
41
44
|
export type LoopDef = string | number | Record<string, BlendPosition>
|
|
42
45
|
|
|
46
|
+
/** One clip the layer shows this frame — `layer.active` / `anim.active` (a debug overlay's list). */
|
|
47
|
+
export type ActiveClip = {
|
|
48
|
+
name: string
|
|
49
|
+
clip: AnimationClip
|
|
50
|
+
/** contribution to the pose, 0–1 — the crossfades' state */
|
|
51
|
+
weight: number
|
|
52
|
+
/** clock (s) */
|
|
53
|
+
time: number
|
|
54
|
+
/** a member of the layer's loop (else a one-shot — playing, or fading after its hand-over) */
|
|
55
|
+
loop: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
export type LayerOptions = {
|
|
44
59
|
/** Bone name(s): the layer drives only these subtrees (`'Spine1'` = upper body). Default: the whole rig. */
|
|
45
60
|
mask?: string | string[]
|
|
@@ -54,9 +69,13 @@ export type ClipEventHandler = (clip: string, layer: Layer) => void
|
|
|
54
69
|
|
|
55
70
|
// ---- records -------------------------------------------------------------------------------------
|
|
56
71
|
|
|
57
|
-
|
|
72
|
+
/** One INSTANCE of a clip on a layer (an engine slot). A clip has as many as it needs at once: a
|
|
73
|
+
* clip started again while its last instance still fades gets another slot — the old one keeps
|
|
74
|
+
* fading beneath (a rewind in place would snap the pose). `playback` = the one-shot that owns it
|
|
75
|
+
* until its hand-over; `last` = the one that took it most recently (its weight / time after that). */
|
|
76
|
+
export type SlotRec = { name: string, clip: AnimationClip, slot: number, layer: LayerRec, inBlend: boolean, playback?: PlaybackRec, last?: PlaybackRec }
|
|
58
77
|
export type PlaybackRec = { slot: SlotRec, playing: boolean, done: Promise<boolean>, resolve: (natural: boolean) => void }
|
|
59
|
-
export type LayerRec = { index: number, view: Layer, slots: SlotRec[],
|
|
78
|
+
export type LayerRec = { index: number, view: Layer, slots: SlotRec[], loop?: LoopRec, weight: number, mask: string, additive: boolean }
|
|
60
79
|
export type LoopRec = { layer: LayerRec, members: { name: string, slot: SlotRec }[], value: BlendPosition, view: Loop, dead: boolean }
|
|
61
80
|
|
|
62
81
|
const handlers = new Map<number, (slot: number, type: number) => void>()
|
|
@@ -145,12 +164,12 @@ export class Core {
|
|
|
145
164
|
destroy(): void {
|
|
146
165
|
for (const L of this.layers) for (const s of L.slots) if (s.playback) this.settle(s.playback, false)
|
|
147
166
|
if (this.id) { _creator.animatorDestroy(this.id); handlers.delete(this.id); this.id = 0 }
|
|
148
|
-
for (const L of this.layers) { L.slots = [];
|
|
167
|
+
for (const L of this.layers) { L.slots = []; if (L.loop) { L.loop.dead = true; L.loop = undefined } }
|
|
149
168
|
}
|
|
150
169
|
|
|
151
170
|
// ---- layers ----
|
|
152
171
|
newLayer(options?: LayerOptions): LayerRec {
|
|
153
|
-
const rec: LayerRec = { index: this.layers.length, view: undefined as unknown as Layer, slots: [],
|
|
172
|
+
const rec: LayerRec = { index: this.layers.length, view: undefined as unknown as Layer, slots: [], weight: options?.weight ?? 1, mask: "", additive: options?.additive ?? false }
|
|
154
173
|
if (options?.mask !== undefined) rec.mask = Array.isArray(options.mask) ? options.mask.join("\n") : options.mask
|
|
155
174
|
rec.view = new Layer(rec, this)
|
|
156
175
|
return rec
|
|
@@ -169,10 +188,11 @@ export class Core {
|
|
|
169
188
|
}
|
|
170
189
|
|
|
171
190
|
// ---- slots ----
|
|
172
|
-
/**
|
|
191
|
+
/** A slot for the clip on the layer: one of its own that is silent now (never shown, or faded
|
|
192
|
+
* out), else a new binding — so a clip still fading can start again OVER itself. */
|
|
173
193
|
slotFor(L: LayerRec, name: string, clip: AnimationClip): SlotRec | undefined {
|
|
174
|
-
const have = L.
|
|
175
|
-
if (have
|
|
194
|
+
const have = L.slots.find((s) => s.name === name && s.clip === clip && !s.inBlend && !s.playback && this.slotWeight(s) <= 0)
|
|
195
|
+
if (have) return have
|
|
176
196
|
const slot = _creator.animatorBind(this.id, clip._set, clip._index, L.index)
|
|
177
197
|
if (slot < 0) { console.warn(`Animator: could not bind clip '${name}'`); return undefined }
|
|
178
198
|
const bound = _creator.animatorBoundTracks(this.id, slot)
|
|
@@ -180,7 +200,6 @@ export class Core {
|
|
|
180
200
|
else if (bound < clip.trackCount) console.warn(`Animator: '${name}' — ${clip.trackCount - bound}/${clip.trackCount} tracks unbound (bone names not found)`)
|
|
181
201
|
const rec: SlotRec = { name, clip, slot, layer: L, inBlend: false }
|
|
182
202
|
L.slots.push(rec)
|
|
183
|
-
L.byName.set(name, rec)
|
|
184
203
|
return rec
|
|
185
204
|
}
|
|
186
205
|
|
|
@@ -203,7 +222,7 @@ export class Core {
|
|
|
203
222
|
let resolve = (_n: boolean): void => {}
|
|
204
223
|
const done = new Promise<boolean>((r) => { resolve = r })
|
|
205
224
|
const rec: PlaybackRec = { slot: s, playing: true, done, resolve }
|
|
206
|
-
s.playback = rec
|
|
225
|
+
s.playback = rec; s.last = rec
|
|
207
226
|
_creator.animatorPlay(this.id, s.slot, false, o.speed ?? 1, fadeIn, fadeOut, o.restart ?? false)
|
|
208
227
|
return new Playback(rec, this)
|
|
209
228
|
}
|
|
@@ -255,8 +274,21 @@ export class Core {
|
|
|
255
274
|
}
|
|
256
275
|
L.loop = rec
|
|
257
276
|
_creator.animatorSetBlend(this.id, L.index, is2D ? 2 : 1, new Uint16Array(slots), new Float32Array(positions), fade, o.speed ?? 1)
|
|
277
|
+
if (o.phase !== undefined) this.setLoopPhase(rec, o.phase)
|
|
258
278
|
return rec.view
|
|
259
279
|
}
|
|
280
|
+
/** The loop's phase through its cycle, 0–1 (the members share one clock). */
|
|
281
|
+
loopPhase(b: LoopRec): number {
|
|
282
|
+
const m = b.members[0]
|
|
283
|
+
if (!m || b.dead) return 0
|
|
284
|
+
const d = m.slot.clip.duration
|
|
285
|
+
return d > 0 ? this.slotTime(m.slot) / d : 0
|
|
286
|
+
}
|
|
287
|
+
setLoopPhase(b: LoopRec, phase: number): void {
|
|
288
|
+
const m = b.members[0]
|
|
289
|
+
if (!m || b.dead) return
|
|
290
|
+
this.seek(m.slot, (((phase % 1) + 1) % 1) * m.slot.clip.duration) // seeking one member moves the group
|
|
291
|
+
}
|
|
260
292
|
setLoopValue(b: LoopRec, v: BlendPosition): void {
|
|
261
293
|
if (b.dead) { console.warn("Animator: this loop was replaced — use the object the latest playLoop() returned"); return }
|
|
262
294
|
b.value = v
|
|
@@ -264,13 +296,23 @@ export class Core {
|
|
|
264
296
|
const [x, y] = typeof v === "number" ? [ v, 0 ] : [ v[0], v[1] ]
|
|
265
297
|
_creator.animatorSetBlendValue(this.id, b.layer.index, x, y)
|
|
266
298
|
}
|
|
299
|
+
/** What the layer shows this frame: every instance with a weight, in binding order (stable rows). */
|
|
300
|
+
active(L: LayerRec): ActiveClip[] {
|
|
301
|
+
const out: ActiveClip[] = []
|
|
302
|
+
for (const s of L.slots) {
|
|
303
|
+
const weight = this.slotWeight(s)
|
|
304
|
+
if (weight <= 0) continue
|
|
305
|
+
out.push({ name: s.name, clip: s.clip, weight, time: this.slotTime(s), loop: s.inBlend })
|
|
306
|
+
}
|
|
307
|
+
return out
|
|
308
|
+
}
|
|
267
309
|
slotWeight(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotWeight(this.id, s.slot) : 0 }
|
|
268
310
|
slotTime(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotTime(this.id, s.slot) : 0 }
|
|
269
311
|
seek(s: SlotRec, time: number): void { if (this.id && s.slot >= 0) _creator.animatorSeek(this.id, s.slot, time) }
|
|
270
312
|
|
|
271
313
|
// ---- props ----
|
|
272
314
|
setSpeed(v: number): void { this.speed = v; if (this.id) _creator.animatorSetGlobal(this.id, v, false) }
|
|
273
|
-
setRootMotion(on: boolean): void { if (this.ensure()) _creator.animatorSetRootMotion(this.id, on ? "*" : "", on ? 2 : 0) }
|
|
315
|
+
setRootMotion(on: boolean, rotation = false): void { if (this.ensure()) _creator.animatorSetRootMotion(this.id, on ? "*" : "", (on ? 2 : 0) | (on && rotation ? 4 : 0)) }
|
|
274
316
|
|
|
275
317
|
// ---- events ----
|
|
276
318
|
on(event: string, cb: ClipEventHandler): void { (this._listeners.get(event) ?? this._listeners.set(event, new Set()).get(event)!).add(cb) }
|
|
@@ -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
|
+
}
|
package/runtime/sdk/inject.ts
CHANGED
|
@@ -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
|
+
}
|