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/dist/types/core/Aspect.d.ts +7 -0
- package/dist/types/gl/Statics.d.ts +12 -1
- package/dist/types/gl/physics/CharacterController.d.ts +9 -4
- package/dist/types/gl/physics/Physics.d.ts +7 -1
- package/dist/types/gl/physics/Shape.d.ts +4 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/bridges/gl.d.ts +7 -0
- package/src/bridges/tree.d.ts +10 -8
- package/src/core/Aspect.ts +7 -0
- package/src/g2/Scene2D.ts +5 -2
- package/src/gl/Node.ts +21 -2
- package/src/gl/Scene.ts +8 -2
- package/src/gl/Statics.ts +48 -2
- package/src/gl/physics/CharacterController.ts +12 -5
- package/src/gl/physics/Physics.ts +17 -3
- package/src/gl/physics/Shape.ts +29 -9
- package/src/gl/physics/physicsEvents.ts +9 -4
- package/src/gl/touch.ts +4 -1
- package/src/scene/defineScene.ts +5 -2
- package/src/scene/grammar.ts +10 -2
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/bridges/gl.d.ts
CHANGED
|
@@ -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 */
|
package/src/bridges/tree.d.ts
CHANGED
|
@@ -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
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
|
|
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
|
package/src/core/Aspect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
164
|
-
* hero.
|
|
165
|
-
* if (hero.controller.resizing) … // still crouched;
|
|
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 {
|
|
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
|
-
/**
|
|
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:
|
|
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],
|
package/src/gl/physics/Shape.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
137
|
-
|
|
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 (
|
|
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
|
|
14
|
-
const
|
|
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
|
|
package/src/scene/defineScene.ts
CHANGED
|
@@ -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)
|
package/src/scene/grammar.ts
CHANGED
|
@@ -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
|
-
(
|
|
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.
|
|
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 => {
|