lecodes-sdk 2.8.1 → 2.8.3

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/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.18.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.8.1",
46
+ "version": "2.8.3",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -317,6 +317,10 @@ declare global {
317
317
  /** @count count=pages */
318
318
  staticsBuildKind?(kind: u32, templateEntity: u32, pages: Uint32Array): u32
319
319
  staticsEnd?(): u32
320
+ /** The cached placement an engine-built static came from: its full node path, "" = not one. The paths outlive
321
+ * `staticsEnd` (until the next `staticsBegin`; a destroyed entity is forgotten) so gl/Node.ts `nodeOf` can give
322
+ * a hit wall its passive aspects from the scene's def. */
323
+ staticsPathOf?(entityId: u32): string
320
324
  setCulling(entityId: u32, culling: boolean): void
321
325
  /** Filament RenderableManager::setPriority — the coarse draw order, 0..7 (Mesh.renderPriority).
322
326
  * Optional: an older host leaves everything at the default 4. */
@@ -912,6 +916,9 @@ declare global {
912
916
  physicsCreateBody(entityId: u32, shapeId: u32, motion: i32, mass: f32, sensor: boolean, pickOnly: boolean): u32
913
917
  /** @ifdef CREATOR_GL_PHYSICS */
914
918
  physicsSetPickable(bodyId: u32, pickable: boolean): void
919
+ /** The motion of the body on `entityId`: 0 static, 1 kinematic, 2 dynamic, -1 no body — read off the engine, so it
920
+ * answers for a body the SDK never made (a static the level cache built). @ifdef CREATOR_GL_PHYSICS */
921
+ physicsMotionOf?(entityId: u32): i32
915
922
  /** Surface friction of one body (0 = ice, ~1 = grippy asphalt). Values COMBINE as sqrt(a * b), so
916
923
  * a low value on either side dominates. New bodies start at 0.6 — a neutral solid surface.
917
924
  * The vehicle wheel cast reads the GROUND body's value — this is what caps a car's cornering. @ifdef CREATOR_GL_PHYSICS */
@@ -267,14 +267,16 @@ export interface HostUI {
267
267
  * dispatch bridge — `onComplete` with the result as ONE value, or with none.
268
268
  * @immediate its callbacks and the borrowed `args` cannot wait for a commit */
269
269
  viewCall?(viewId: i32, method: string, args: value, onComplete: (result?: value) => void, onReject: (message: string) => void): void
270
- /** A LAYER root is shown: a pass-through root the runtime lays out at the frame, above every
271
- * destination (the app layer) or over the presented surface (a scene's / a native view's / a
272
- * video's content layer). Its own box is never hit — only its children are — so a touch outside
273
- * them reaches what is under the layer; it is painted after the destination, layers in open
274
- * order. Its children are widgets (the runtime inserts them with insertNode, as under a screen
275
- * root): a widget child with an overlay layer (`overlayColor`, any color — "transparent" too)
276
- * intercepts every touch outside its own box within its root and reports it as the overlay tap. */
277
- layerOpen(node: nodeid): void
270
+ /** A LAYER root is shown: a pass-through root the runtime lays out at the frame. `surface` false =
271
+ * the app layer, above everything, screens included; true = the content layer of the presented
272
+ * surface (a scene's / a native view's / a video's widgets): right over the surface and UNDER
273
+ * every screen — a screen that covers the surface covers its widgets, the screen that leaves
274
+ * when the surface is pushed stays over them until its drop. Its own box is never hit — only its
275
+ * children are — so a touch outside them reaches what is under the layer; layers of a kind paint
276
+ * in open order. Its children are widgets (the runtime inserts them with insertNode, as under a
277
+ * screen root): a widget child with an overlay layer (`overlayColor`, any color — "transparent"
278
+ * too) intercepts every touch outside its own box within its root and reports it as the overlay tap. */
279
+ layerOpen(node: nodeid, surface: boolean): void
278
280
  /** The layer root is hidden (its view stays; releaseNode says when it is gone). */
279
281
  layerClose(node: nodeid): void
280
282
  /** Load a font face for `fontFamily` (weight 100–900, style 0 normal / 2 italic) from `url`; settle
@@ -533,6 +533,13 @@ export abstract class Aspect<K extends string, P = Node, E extends EventMap = {}
533
533
  static after?: Function[]
534
534
  /** Tick BEFORE these classes within the same phase (`static before = [Camera]`). */
535
535
  static before?: Function[]
536
+ /** DATA ONLY. A `use(...)` of this aspect in a scene file leaves its node STATIC for the level cache (gl/Statics.ts):
537
+ * the engine builds the node without a JS object, and the aspect is attached the first time a hit, a contact or
538
+ * a pick reaches the node — `hit.node.get(Surface)` answers then. So: fields and the methods the game calls on a
539
+ * hit (a tag like `Surface { kind }`, a breakable's hit points), no `update()` (`use()` refuses one) and nothing in
540
+ * `onAttach()` the level needs from its first frame. Without it a custom aspect keeps its node on the JS path,
541
+ * built at load like any mover. */
542
+ static passive?: boolean
536
543
 
537
544
  /** Every live instance of an aspect/system class, in attach order — the registry a game used to
538
545
  * hand-roll as `static all[]`. Live, read-only: copy it (`[...Aspect.all(Barrel)]`) before a loop
package/src/g2/Scene2D.ts CHANGED
@@ -203,9 +203,12 @@ export class Scene2D implements Presentable {
203
203
  return _surfaceWidgets(this)
204
204
  }
205
205
  /** @internal presentation lifecycle from the runtime */
206
- _emitOpen(): void { for (const cb of this.ol.slice()) cb() }
206
+ // The ACTIVE scene — what the pointer dispatch (g2/touch.ts) routes to — follows the runtime's
207
+ // presentation, not only open(): a Router.push / pop presents a scene without it, and the first
208
+ // push of a world had no active scene at all (every touch was dropped until an open() set one).
209
+ _emitOpen(): void { Scene2D._active = this; for (const cb of this.ol.slice()) cb() }
207
210
  /** @internal */
208
- _emitClose(): void { for (const cb of this.cl.slice()) cb() }
211
+ _emitClose(): void { if (Scene2D._active === this) Scene2D._active = null; for (const cb of this.cl.slice()) cb() }
209
212
  /** @internal */
210
213
  _emitBack(): boolean {
211
214
  if (!this._backButtonCallback) return false
package/src/gl/Node.ts CHANGED
@@ -51,8 +51,14 @@ const registerLoose = (n: Node): void => {
51
51
  sinceSweep = 0
52
52
  for (const [id, ref] of loose) { const w = ref.deref(); if (!w || w._h.id !== id) loose.delete(id) }
53
53
  }
54
+ /** @internal The static level cache's hand into `nodeOf` (gl/Statics.ts sets it while a cached level is up):
55
+ * `pathOf` names the placement an engine-built entity came from ("" = not one), `attach` gives that entity's
56
+ * fresh wrapper the PASSIVE aspects of the placement's def (Aspect.passive). A wall the engine built has no JS
57
+ * node until a hit, a contact or a pick reaches it — this is where it gets one, and its `Surface`, and only then. */
58
+ export const lazyPlacements: { current: { pathOf(id: number): string, attach(node: Node, path: string): void } | null } = { current: null }
59
+
54
60
  /** @internal The wrapper for an entity id the runtime handed back: the pinned one, else the
55
- * app's live loose handle, else a throwaway over the engine-owned id. */
61
+ * app's live loose handle, else a wrapper over the engine-owned id (pinned: the next ask finds it). */
56
62
  export const nodeOf = (id: number): Node => {
57
63
  const pinned = nodes.get(id)
58
64
  if (pinned) return pinned
@@ -62,7 +68,18 @@ export const nodeOf = (id: number): Node => {
62
68
  if (w && w._h.id === id) return w
63
69
  loose.delete(id)
64
70
  }
65
- return new Node(id)
71
+ const n = new Node(id)
72
+ const lazy = lazyPlacements.current
73
+ if (lazy) { const path = lazy.pathOf(id); if (path) lazy.attach(n, path) }
74
+ return n
75
+ }
76
+
77
+ /** @internal `nodeOf(id)` when `id` is a placement the level cache built, else null — a hit inside a model walks
78
+ * up to the placement's own entity (gl/touch.ts). */
79
+ export const placementNodeOf = (id: number): Node | null => {
80
+ if (nodes.get(id)) return nodeOf(id)
81
+ const lazy = lazyPlacements.current
82
+ return lazy && lazy.pathOf(id) ? nodeOf(id) : null
66
83
  }
67
84
 
68
85
  export type NodeEvents = {
@@ -472,6 +489,8 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
472
489
 
473
490
  /** @internal — emit a physics contact/trigger event (the physics-event router can't reach dispatch). */
474
491
  _emitCollision(channel: "enter" | "exit", other: Node): void { this.dispatch(channel, other) }
492
+ /** @internal — does anyone listen on `channel` (the physics-event router asks before it makes a wrapper). */
493
+ _hasListeners(channel: keyof NodeEvents): boolean { return this.hasListeners(channel) }
475
494
 
476
495
 
477
496
  /** Destroy this node and its whole subtree. Every aspect in the subtree is detached first (its
package/src/gl/Scene.ts CHANGED
@@ -526,9 +526,15 @@ export class Scene implements Presentable {
526
526
  return _surfaceWidgets(this)
527
527
  }
528
528
  /** @internal presentation lifecycle from the runtime */
529
- _emitOpen(): void { for (const cb of this.ol.slice()) cb() }
529
+ // The ACTIVE scene follows the runtime's presentation, not only open(): a Router.push / pop
530
+ // presents a scene without it (the 2D twin: Scene2D._emitOpen).
531
+ _emitOpen(): void { Scene._active = this; glState.activeScene = this; for (const cb of this.ol.slice()) cb() }
530
532
  /** @internal */
531
- _emitClose(): void { for (const cb of this.cl.slice()) cb() }
533
+ _emitClose(): void {
534
+ if (Scene._active === this) Scene._active = null
535
+ if (glState.activeScene === this) glState.activeScene = null
536
+ for (const cb of this.cl.slice()) cb()
537
+ }
532
538
  /** @internal */
533
539
  _emitBack(): boolean {
534
540
  if (!this._backButtonCallback) return false
package/src/gl/Statics.ts CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { fetch, assetNameOf, assetUrlOf, type FetchResponse } from "../runtime/fetch"
26
26
  import { Model, foliageKindOf, type FoliageKind } from "./Model"
27
- import { Node } from "./Node"
27
+ import { Node, lazyPlacements } from "./Node"
28
28
  import { Scene } from "./Scene"
29
29
  import { Physics } from "./physics/Physics"
30
30
  import { Shape } from "./physics/Shape"
@@ -57,11 +57,49 @@ const movesByPhysics = (def: SceneNodeDef): boolean =>
57
57
  ((e.ctor as unknown) === Physics && ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") !== "static")
58
58
  || (e.ctor as unknown) === CharacterController)
59
59
 
60
+ /** A passive aspect (Aspect.passive): data the engine does not need — attached to the placement's wrapper on the
61
+ * first hit (`attachPassive`), never built at load. */
62
+ export const isPassive = (e: AspectEntry<any>): boolean => (e.ctor as { passive?: boolean }).passive === true
63
+
60
64
  const dataAspect = (e: AspectEntry<any>): boolean => {
61
65
  const c = e.ctor as unknown
62
66
  if (c === Shape) return true
63
67
  if (c === Physics) return ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") === "static"
64
- return false
68
+ return isPassive(e)
69
+ }
70
+
71
+ /** Attach the passive aspects of `def` to `node` (props as written: a passive aspect is data, `ref()`s are not
72
+ * resolved). Who gets them: the entity a hit comes back with. A placement's clone, on the first hit, through
73
+ * `lazyPlacements` — a covered root with a model included, since its clone is what the ray strikes, not the plain
74
+ * JS node the clone hangs under. A root WITHOUT a model (a group tagged `Surface` over its walls) has no clone:
75
+ * its JS node takes them at load, and a hit on a wall under it finds them one `parent` up (a game's surface lookup
76
+ * walks `hit.node.parent`). */
77
+ export const attachPassive = (node: Node, def: SceneNodeDef): void => {
78
+ for (const e of def.aspects ?? []) if (isPassive(e)) (node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(e.ctor, e.props)
79
+ }
80
+
81
+ /** The covered placements that carry passive aspects, by path: every def under the roots, and a root itself when
82
+ * it has a model (its clone is a placement at the root's own path). A model-less root is not here — `attachPassive`
83
+ * put its tags on its JS node at load. */
84
+ const passiveByPath = (roots: CoveredRoot[], scene: SceneDef): Map<string, SceneNodeDef> => {
85
+ const out = new Map<string, SceneNodeDef>()
86
+ const walk = (defs: Record<string, SceneNodeDef> | undefined, prefix: string, stack: Set<SceneDef>): void => {
87
+ for (const [ name, nd ] of Object.entries(defs ?? {})) {
88
+ const path = prefix === "" ? name : `${prefix}/${name}`
89
+ if ((nd.aspects ?? []).some(isPassive)) out.set(path, nd)
90
+ walk(nd.children, path, stack)
91
+ const pdef = nd.prefab !== undefined ? (nd.prefab as { def?: SceneDef }).def : undefined
92
+ if (pdef && !stack.has(pdef)) walk(pdef.nodes, path, new Set(stack).add(pdef))
93
+ }
94
+ }
95
+ const stack = new Set<SceneDef>([ scene ])
96
+ for (const r of roots) {
97
+ if (r.def.model !== undefined && (r.def.aspects ?? []).some(isPassive)) out.set(r.path, r.def)
98
+ walk(r.def.children, r.path, stack)
99
+ const pdef = r.def.prefab !== undefined ? (r.def.prefab as { def?: SceneDef }).def : undefined
100
+ if (pdef && !stack.has(pdef)) walk(pdef.nodes, r.path, new Set(stack).add(pdef))
101
+ }
102
+ return out
65
103
  }
66
104
 
67
105
  const cacheable = (def: SceneNodeDef, stack: Set<SceneDef>): boolean => {
@@ -288,8 +326,16 @@ const templates: Model[] = []
288
326
  export const build = async (
289
327
  opened: OpenedCache, scene: Scene, roots: Node[], pages: Uint32Array,
290
328
  template: (url: string, lightmap: boolean, foliage: FoliageKind) => Promise<Model>,
329
+ covered: CoveredRoot[], def: SceneDef,
291
330
  ): Promise<number> => {
292
331
  const sceneId = (scene as unknown as { _id: number })._id
332
+ // the hand into nodeOf for this level's life (the next cached level replaces it; a level without passive
333
+ // aspects under its roots installs none — nodeOf then costs what it did)
334
+ const passive = passiveByPath(covered, def)
335
+ lazyPlacements.current = passive.size > 0 && _creator.staticsPathOf ? {
336
+ pathOf: (id) => _creator.staticsPathOf!(id),
337
+ attach: (node, path) => { const d = passive.get(path); if (d) attachPassive(node, d) },
338
+ } : null
293
339
  const kinds = _creator.staticsBegin!(sceneId, Uint32Array.from(roots.map((r) => r.id)), (opened.resp as unknown as { _h: { id: number } })._h.id)
294
340
  if (kinds !== opened.kinds.length) { console.warn(`[static] the engine declined the cache (${kinds} of ${opened.kinds.length} kinds)`); _creator.staticsEnd!(); return 0 }
295
341
  await Promise.all(opened.kinds.map(async (k, i) => {
@@ -33,7 +33,9 @@
33
33
  // scaled directly instead of needing a child node for the visuals (a capsule is symmetric about its up
34
34
  // axis, so its yaw is physically irrelevant either way).
35
35
  //
36
- // CROUCHING goes through the Shape aspect (`node.aspect(Shape, { capsule: … })`) — see `resizing`.
36
+ // CROUCHING is a property write on the Shape aspect (`hero.shape.capsule = …`) — see `resizing`. The
37
+ // node (the capsule's centre) has moved by the feet difference when the write returns; a first-person
38
+ // camera is placed from the FEET plus an eased eye height, never from the node (docs/3d/physics.md).
37
39
  //
38
40
  // Note: it collides with solid bodies but PASSES THROUGH triggers, and (v1) is not itself detected by
39
41
  // triggers and isn't pointer-pickable while active.
@@ -160,13 +162,18 @@ export class CharacterController extends Aspect<"controller", Node> implements C
160
162
  * True while a requested collider resize hasn't taken — you asked to stand up and there is something
161
163
  * overhead. Resizing goes through the `Shape` aspect itself:
162
164
  *
163
- * hero.aspect(Shape, { capsule: CROUCHED }) // always fits — you are shrinking
164
- * hero.aspect(Shape, { capsule: STANDING }) // may be refused under a low ceiling
165
- * if (hero.controller.resizing) … // still crouched; call it again next frame
165
+ * hero.shape.capsule = CROUCHED // always fits — you are shrinking
166
+ * hero.shape.capsule = STANDING // may be refused under a low ceiling
167
+ * if (hero.controller.resizing) … // still crouched; write it again next frame
166
168
  *
167
169
  * A refusal changes nothing, so the retry is just the same call again — and it is an exact headroom
168
170
  * test against the real capsule, unlike a hand-rolled raycast (a ray is a line; a capsule has girth).
169
- * The FEET stay planted across a resize, so the character neither hovers nor sinks.
171
+ * The FEET stay planted across a resize, so the character neither hovers nor sinks — which means the
172
+ * node (the capsule's centre) has ALREADY moved by the feet difference when the write returns. Place
173
+ * a first-person camera from the feet (`node.y − (halfHeight + radius)` of the capsule worn) plus an
174
+ * eased eye height; placed from the node it pops by that difference. Write the property: a second
175
+ * `node.aspect(Shape, …)` builds a fresh Shape (it is handed to the character too, but it is a
176
+ * rebuild per stance, not a resize).
170
177
  */
171
178
  get resizing(): boolean {
172
179
  return this.node.get(Shape)?._resizeRefused ?? false
@@ -15,7 +15,7 @@ import { Aspect } from "../../core/Aspect"
15
15
  import type { FieldMeta } from "../../core/fields"
16
16
  import type { CompAxis, CompWriter } from "../../core/compWrite"
17
17
  import { Vec3, cx, cy, cz, type Vec3Like } from "../../math/vec"
18
- import { nodes, type Node } from "../Node"
18
+ import { nodeOf, type Node } from "../Node"
19
19
  import { Shape } from "./Shape"
20
20
 
21
21
  export type MotionType = "static" | "dynamic" | "kinematic"
@@ -214,13 +214,27 @@ export class Physics extends Aspect<"physics", Node> implements CompWriter {
214
214
  return typeof _creator !== "undefined" && !!_creator && typeof _creator.physicsHasSupport === "function" && _creator.physicsHasSupport()
215
215
  }
216
216
 
217
- /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
217
+ /** The motion of the body on `node`, read off the engine: it answers for a body the SDK never made (a static the
218
+ * level cache built, which has no `Physics` aspect until a hit reaches it, and no body-making one ever). null = no
219
+ * body on this node. The way to tell a wall from a prop on a hit: `Physics.motionOf(hit.node) === "static"`. */
220
+ static motionOf(node: Node | null): MotionType | null {
221
+ if (!node) return null
222
+ if (_creator.physicsMotionOf) {
223
+ const m = _creator.physicsMotionOf(node.id)
224
+ return m === 0 ? "static" : m === 1 ? "kinematic" : m === 2 ? "dynamic" : null
225
+ }
226
+ return node.get(Physics)?.motion ?? null // an older host: the aspect is all there is
227
+ }
228
+
229
+ /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. `node` is the body's
230
+ * node — for a static the level cache built, a wrapper made on this hit (Node.ts nodeOf), carrying the placement's
231
+ * passive aspects; its `Physics` is read with `Physics.motionOf`. */
218
232
  static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
219
233
  const out = new Float32Array(7)
220
234
  const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
221
235
  if (!id) return null
222
236
  return {
223
- node: nodes.get(id) ?? null,
237
+ node: nodeOf(id),
224
238
  point: new Vec3(out[0], out[1], out[2]),
225
239
  normal: new Vec3(out[3], out[4], out[5]),
226
240
  fraction: out[6],
@@ -127,14 +127,25 @@ export class Shape extends Aspect<"shape", Node> {
127
127
  onAttach(): void {
128
128
  if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
129
129
  this._shapeId = this._build()
130
- if (this._shapeId) this._createPickBody()
130
+ if (!this._shapeId) return
131
+ // A fresh Shape on a node a live CharacterController owns: `node.aspect(Shape, { capsule })`
132
+ // RE-ATTACHES (aspect() always builds), which is how a game is tempted to crouch. Hand the geometry
133
+ // to the character exactly as a property write does. A pick body here would instead take the
134
+ // node's position routing away from the character, and the capsule it wears would never change —
135
+ // the player's "how tall am I" switches while the body stays, and the eye pops by the difference.
136
+ if (this.node._xfKind === 1) {
137
+ if (!this._offerToCharacter(this.node._xf, this._shapeId)) this._sig = "" // uncommitted: the next write retries
138
+ return
139
+ }
140
+ this._createPickBody()
131
141
  }
132
142
 
133
143
  onDetach(): void {
134
144
  if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
135
145
  if (this._shapeId) { _creator.physicsDestroyShape(this._shapeId); this._shapeId = 0 }
136
- this.node._xf = 0
137
- this.node._xfKind = 0
146
+ // The routing is this Shape's to clear only while one of ITS bodies carries it. A live
147
+ // CharacterController keeps its own, and the geometry it wears (Jolt holds a reference to it).
148
+ if (this.node._xfKind !== 1) { this.node._xf = 0; this.node._xfKind = 0 }
138
149
  }
139
150
 
140
151
  /** A dimension, the kind or the origin was written: rebuild the geometry when it actually changed.
@@ -298,17 +309,12 @@ export class Shape extends Aspect<"shape", Node> {
298
309
  // and tries again. That retry is the headroom test, and it is exact (the real capsule, not a ray).
299
310
  const charId = this.node._xfKind === 1 ? this.node._xf : 0
300
311
  if (charId) {
301
- if (_creator.characterSetShape?.(charId, id)) {
312
+ if (this._offerToCharacter(charId, id)) {
302
313
  this._shapeId = id
303
- this._resizeRefused = false
304
314
  _creator.physicsDestroyShape(previous)
305
315
  } else {
306
316
  _creator.physicsDestroyShape(id) // drop what we just built; the old collider stays live
307
317
  this._sig = previousSig
308
- this._resizeRefused = !!_creator.characterSetShape
309
- if (!_creator.characterSetShape) {
310
- console.warn(`Shape: this host can't resize a live CharacterController — "${this.node.name}" keeps its current collider`)
311
- }
312
318
  }
313
319
  return
314
320
  }
@@ -327,6 +333,20 @@ export class Shape extends Aspect<"shape", Node> {
327
333
  * is still using another. Surfaced as `controller.resizing`. */
328
334
  _resizeRefused = false
329
335
 
336
+ /** The character this node belongs to tries the geometry on. Jolt validates the fit (standing up
337
+ * under a pipe) and can refuse; a refusal changes nothing in the engine. The feet stay planted, so
338
+ * on success the node has already moved by the change in the shape's lower bound when this returns. */
339
+ private _offerToCharacter(charId: number, shapeId: number): boolean {
340
+ if (!_creator.characterSetShape) {
341
+ console.warn(`Shape: this host can't resize a live CharacterController — "${this.node.name}" keeps its current collider`)
342
+ this._resizeRefused = false
343
+ return false
344
+ }
345
+ const ok = _creator.characterSetShape(charId, shapeId)
346
+ this._resizeRefused = !ok
347
+ return ok
348
+ }
349
+
330
350
  private _build(): number {
331
351
  this._autoOrigin = null
332
352
  this._sig = this._signature()
@@ -2,7 +2,7 @@
2
2
  // is delivered to BOTH nodes with the other as the argument. type: 0 = enter (contact/overlap began),
3
3
  // 1 = exit (ended). Registered lazily on the first trigger/physics body or 'enter'/'exit' listener.
4
4
 
5
- import { nodes } from "../Node"
5
+ import { nodes, nodeOf } from "../Node"
6
6
 
7
7
  let registered = false
8
8
  export const ensurePhysicsEvents = (): void => {
@@ -10,10 +10,15 @@ export const ensurePhysicsEvents = (): void => {
10
10
  if (!_creator.setOnPhysicsEvent) return // physics-less build / bridge without the hook
11
11
  registered = true
12
12
  _creator.setOnPhysicsEvent((a, b, type) => {
13
- const na = nodes.get(a)
14
- const nb = nodes.get(b)
15
- if (!na || !nb) return
13
+ const ka = nodes.get(a)
14
+ const kb = nodes.get(b)
16
15
  const channel = type === 1 ? "exit" : "enter"
16
+ // nobody listening on either side: nothing to deliver, and no wrapper made for the other side (a car's
17
+ // wheels touch every tile of a road the level cache built — a wrapper each would be 15k for nothing)
18
+ if (!ka?._hasListeners(channel) && !kb?._hasListeners(channel)) return
19
+ // the side nobody holds (a static the engine built) gets its wrapper now, passive aspects and all (Node.ts nodeOf)
20
+ const na = ka ?? nodeOf(a)
21
+ const nb = kb ?? nodeOf(b)
17
22
  na._emitCollision(channel, nb)
18
23
  nb._emitCollision(channel, na)
19
24
  })
package/src/gl/touch.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { ClickEvent, TouchStartEvent, type TrackHandler } from "../runtime/touch"
5
5
  import { ensureEmitter, setSceneTouchHandlers } from "../ui/tree"
6
- import { nodes } from "./Node"
6
+ import { nodes, placementNodeOf } from "./Node"
7
7
  import { glState } from "./state"
8
8
 
9
9
  // Hits inside a loaded model (GLB) land on sub-entities the user never created a Node for — walk up
@@ -15,6 +15,9 @@ const resolveHitNode = (entityId: number) => {
15
15
  if (id === 0) break
16
16
  node = nodes.get(id)
17
17
  }
18
+ // nobody holds any of them: a static the level cache built — its placement's entity up the chain gets a wrapper
19
+ // now (Node.ts nodeOf), passive aspects and all
20
+ for (let id = entityId, i = 0; !node && id !== 0 && i < 32; i++, id = _creator.getParent(id)) node = placementNodeOf(id) ?? undefined
18
21
  return node ?? null
19
22
  }
20
23
 
@@ -53,7 +53,7 @@ import { Model, foliageKindOf, type FoliageKind } from "../gl/Model"
53
53
  import { Physics } from "../gl/physics/Physics"
54
54
  import { CharacterController } from "../gl/physics/CharacterController"
55
55
  import { Lightmap, type LightmapDef, type LightmapPrepared } from "../gl/Lightmap"
56
- import { coveredRoots, defsDigest, bakeDigest, findNode, open as staticsOpen, build as staticsBuild, snapshot as staticsSnapshot, staticsConfig, toolRun, type CoveredRoot, type OpenedCache } from "../gl/Statics"
56
+ import { coveredRoots, defsDigest, bakeDigest, findNode, open as staticsOpen, build as staticsBuild, snapshot as staticsSnapshot, staticsConfig, toolRun, attachPassive, type CoveredRoot, type OpenedCache } from "../gl/Statics"
57
57
  import { Foliage, type FoliageOptions } from "../gl/Foliage"
58
58
  import { NavMesh } from "../gl/nav/NavMesh"
59
59
  import { Terrain, type TerrainLayer, type TerrainRegion, type TerrainOptions } from "../gl/terrain/Terrain"
@@ -918,6 +918,9 @@ const buildNodes = async (
918
918
  if (parentNode) attachHost(parentNode, nd, path).add(node)
919
919
  scene.add(node)
920
920
  applyNode(node, name, nd)
921
+ // a root with a model is itself a placement: its clone takes the tags on the first hit (Statics.ts). A group
922
+ // root has no clone — its tags go here, one `parent` up from a hit on a wall under it
923
+ if (nd.model === undefined) attachPassive(node, nd)
921
924
  cov.node = node
922
925
  nodes[path] = node
923
926
  return
@@ -1028,7 +1031,7 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
1028
1031
  let cachedMade = 0
1029
1032
  if (opened) {
1030
1033
  const pages = prepared ? Lightmap.pageIds(prepared) : new Uint32Array(0)
1031
- cachedMade = await staticsBuild(opened, scene, roots.map((r) => covered.get(r.path)!.node!), pages, templateModel)
1034
+ cachedMade = await staticsBuild(opened, scene, roots.map((r) => covered.get(r.path)!.node!), pages, templateModel, roots, def)
1032
1035
  }
1033
1036
  const nodesMs = Date.now() - tLoad
1034
1037
  if (navmesh && !(EDITOR && isEditMode())) registerNavOverrides(def.nodes, nodes)
@@ -17,8 +17,16 @@ export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
17
17
 
18
18
  /** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
19
19
  * typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
20
- export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
21
- ({ __use: true, ctor, props })
20
+ export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> => {
21
+ // a passive aspect (Aspect.passive) is data: the level cache attaches it on the first hit, so an update() on it
22
+ // would never have run from the level's first frame — refused here, at the scene's own line, not in a raycast
23
+ if ((ctor as { passive?: boolean }).passive === true) {
24
+ const proto = (ctor as unknown as { prototype: Record<string, unknown> }).prototype
25
+ if (typeof proto.update === "function" || typeof proto.updateBefore === "function" || typeof proto.updateFixed === "function")
26
+ throw new Error(`use(${(ctor as { name?: string }).name ?? "?"}): a passive aspect carries data only — it cannot have update(); drop \`static passive\` or the update`)
27
+ }
28
+ return { __use: true, ctor, props }
29
+ }
22
30
 
23
31
  /** A `make(fn, args)` source entry — see {@link make}. `N` is the node family the factory returns
24
32
  * (3D `Node` by default; a `.scene2d.ts` file's factories return `Node2D`). */
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.8.1"
7
+ export const SDK_VERSION = "2.8.3"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {