lecodes-sdk 2.4.1 → 2.4.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.
Files changed (58) hide show
  1. package/dist/types/core/Aspect.d.ts +34 -5
  2. package/dist/types/g2/OneWay2D.d.ts +0 -1
  3. package/dist/types/g2/Physics2D.d.ts +8 -6
  4. package/dist/types/g2/Scene2D.d.ts +1 -1
  5. package/dist/types/g2/Shape2D.d.ts +43 -15
  6. package/dist/types/gl/Model.d.ts +3 -2
  7. package/dist/types/gl/Scene.d.ts +1 -1
  8. package/dist/types/gl/animation/DynamicBone.d.ts +99 -35
  9. package/dist/types/gl/animation/IK.d.ts +0 -1
  10. package/dist/types/gl/nav/NavAgent.d.ts +3 -2
  11. package/dist/types/gl/physics/Physics.d.ts +4 -5
  12. package/dist/types/gl/physics/Shape.d.ts +40 -15
  13. package/dist/types/gl/vehicle/Vehicle.d.ts +54 -35
  14. package/dist/types/gl/vehicle/Wheel.d.ts +31 -10
  15. package/dist/types/plugins/gen/ar/contract.d.ts +152 -0
  16. package/dist/types/plugins/gen/ar/sdk/ar.d.ts +47 -21
  17. package/dist/types/plugins/gen/ar/sdk/ar.gen.d.ts +17 -3
  18. package/dist/types/plugins/gen/audio-recorder/contract.d.ts +64 -0
  19. package/dist/types/plugins/gen/browser/contract.d.ts +108 -0
  20. package/dist/types/plugins/gen/browser/sdk/browser.d.ts +2 -1
  21. package/dist/types/plugins/gen/camera/contract.d.ts +27 -0
  22. package/dist/types/plugins/gen/geolocation/contract.d.ts +52 -0
  23. package/dist/types/plugins/gen/map/contract.d.ts +144 -0
  24. package/dist/types/plugins/gen/push/contract.d.ts +83 -0
  25. package/dist/types/plugins/gen/qr-scanner/contract.d.ts +16 -0
  26. package/dist/types/plugins/gen/speech/contract.d.ts +67 -0
  27. package/dist/types/plugins/gen/webrtc/contract.d.ts +342 -0
  28. package/dist/types/version.d.ts +1 -1
  29. package/dist/types.json +1 -1
  30. package/package.json +1 -1
  31. package/src/animate/tween/Timeline.ts +175 -175
  32. package/src/bridges/gl.d.ts +5 -3
  33. package/src/compile/compileProject.ts +45 -10
  34. package/src/compile/index.ts +7 -0
  35. package/src/compile/webHalf.ts +255 -0
  36. package/src/core/Aspect.ts +118 -35
  37. package/src/g2/OneWay2D.ts +0 -2
  38. package/src/g2/Physics2D.ts +8 -16
  39. package/src/g2/Scene2D.ts +1 -1
  40. package/src/g2/Shape2D.ts +51 -22
  41. package/src/gl/CameraPlace.ts +52 -52
  42. package/src/gl/Light.ts +257 -257
  43. package/src/gl/Mesh.ts +120 -120
  44. package/src/gl/Model.ts +3 -2
  45. package/src/gl/Scene.ts +1 -1
  46. package/src/gl/animation/DynamicBone.ts +549 -486
  47. package/src/gl/animation/IK.ts +0 -2
  48. package/src/gl/nav/NavAgent.ts +14 -12
  49. package/src/gl/physics/Physics.ts +4 -5
  50. package/src/gl/physics/Shape.ts +49 -23
  51. package/src/gl/scenarios.ts +291 -291
  52. package/src/gl/vehicle/Vehicle.ts +102 -58
  53. package/src/gl/vehicle/Wheel.ts +45 -11
  54. package/src/plugins/gen/ar/contract.d.ts +35 -1
  55. package/src/plugins/gen/ar/sdk/ar.gen.ts +25 -6
  56. package/src/plugins/gen/ar/sdk/ar.ts +102 -35
  57. package/src/plugins/gen/browser/sdk/browser.ts +11 -1
  58. package/src/version.ts +1 -1
@@ -0,0 +1,255 @@
1
+ // A project's OWN web half of a plugin, built for Web Lite: a folder of the project app.json names
2
+ // for a plugin on the web — a half folder over a first-party plugin (`"ar": { "web":
3
+ // "./plugins/ar-8thwall" }`, its manifest `implements: "ar"`) or a plugin of the project's own with a
4
+ // web half (`"dial": "./plugins/dial"`). ONE implementation of what such a half is built into, for the
5
+ // two that build one: the CLI (`lecodes web build | dev`, from the project's folder) and the build
6
+ // service (`POST /compile/project` with `webHalves`, from the entries it is sent, staged in a folder
7
+ // of its own) — so the page the platform deploys runs the half `web build` would have built.
8
+ //
9
+ // The half is plain TypeScript of its own folder, bundled by Bun into ONE ES module that exports
10
+ // `register(host)`:
11
+ // - an import of a package (a LeCodes one too) is refused: the half runs on the page by itself —
12
+ // the browser and the host it is handed are its world; `lecodes plugin gen` writes the glue it
13
+ // imports (`web/<id>.gen.ts`);
14
+ // - an import of a file OUTSIDE the folder is refused: a half is built from its folder, and the
15
+ // build service stages only that — a path out of it would read the build machine's files;
16
+ // - a macro import is refused, never run: a Bun macro is code run AT BUILD TIME on the machine that
17
+ // builds, and the build service builds what any project sends it. `macros: false` alone does not
18
+ // hold — Bun 1.3 (the build service's image) ignores it — so every file goes through the
19
+ // plugin's onLoad, parsed with macros off before Bun parses it;
20
+ // - the build machine's environment is never inlined (`process.env.X` stays what it is).
21
+ // What a first-party half asks of its bundler (`web.dependencies`, `web.resources`) is Web Lite's
22
+ // page build's, not a project's: a file the half fetches goes in its `web.static` folder, put beside
23
+ // the module as it is by whoever serves the page.
24
+ //
25
+ // Bun is reached at call time, never at import time: the module stays importable on Node and in
26
+ // the editor (index.ts), and only a build needs Bun.
27
+
28
+ import { realpathSync } from "node:fs"
29
+ import { readFile } from "node:fs/promises"
30
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
31
+ import { isLocalPluginSpec, pluginSpecsOf } from "./compileProject"
32
+
33
+ /** The plugins app.json names a folder of the project for on the web, in its order: id → the spec
34
+ * (`./plugins/ar-8thwall`, `file:plugins/dial`). `appJson` is app.json's parsed value, in any of its
35
+ * `"plugins"` shapes; anything else names none. */
36
+ export const webHalfSpecsOf = (appJson: unknown): { id: string, spec: string }[] => {
37
+ const plugins = appJson && typeof appJson === "object" ? (appJson as { plugins?: unknown }).plugins : undefined
38
+ return Object.entries(pluginSpecsOf(plugins, "web"))
39
+ .filter(([, spec]) => isLocalPluginSpec(spec))
40
+ .map(([id, spec]) => ({ id, spec }))
41
+ }
42
+
43
+ /** What a build takes of a half folder's manifest. */
44
+ export type WebHalfManifest = {
45
+ /** The first-party plugin the folder implements (a half folder); null for a plugin of the
46
+ * project's own (its folder's name is its id). */
47
+ implements: string | null
48
+ /** The module that exports `register(host)`, by its path in the folder (`web/ar.ts`). */
49
+ register: string
50
+ /** `web.static`: a folder of the half, by its path in it, put beside the built module as it is
51
+ * (names unhashed — a runtime the half loads by its own url fetches its files relative to
52
+ * itself); null when none. */
53
+ static: string | null
54
+ }
55
+
56
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
57
+
58
+ /** A path INSIDE the folder: relative, no empty / `.` / `..` segment. */
59
+ const isInnerPath = (path: string): boolean =>
60
+ path.trim() !== "" && !/^[\\/]/.test(path) && !/^[A-Za-z]:/.test(path) &&
61
+ path.split(/[\\/]/).every((s) => s !== "" && s !== "." && s !== "..")
62
+
63
+ /**
64
+ * The manifest of the half folder `folder` (its name) that app.json names for the plugin `id`, as a
65
+ * build reads it: the checks that decide what is built, and nothing else — a manifest is validated
66
+ * whole by `lecodes plugin gen` and the CLI (tools/cli/src/plugin/manifest.ts), which name every
67
+ * problem of the file at once. Throws an Error that says what to change.
68
+ */
69
+ export const webHalfManifestOf = (text: string, folder: string, id: string): WebHalfManifest => {
70
+ const file = `Plugin "${id}": ${folder}/lecodes-plugin.json`
71
+ let raw: unknown
72
+ try { raw = JSON.parse(text.replace(/^/, "")) } catch (e) {
73
+ throw new Error(`${file} is not JSON: ${e instanceof Error ? e.message : String(e)}`)
74
+ }
75
+ if (!isObject(raw)) throw new Error(`${file}: a manifest is an object`)
76
+
77
+ const impl = raw.implements
78
+ if (impl !== undefined && typeof impl !== "string") throw new Error(`${file}: implements is the id of the first-party plugin whose contract the folder implements ("ar")`)
79
+ if (impl !== undefined ? impl !== id : folder !== id) {
80
+ throw new Error(impl !== undefined
81
+ ? `Plugin "${id}": the folder "${folder}" implements "${impl}" — app.json names it under that plugin: "${impl}": { "web": "./…/${folder}" }`
82
+ : `Plugin "${id}": the folder "${folder}" is the plugin "${folder}" — app.json names a plugin of the project by its folder's name`)
83
+ }
84
+
85
+ const web = raw.web
86
+ if (web === null || web === undefined) throw new Error(`Plugin "${id}": the folder "${folder}" has no web half (its manifest says "web": null) — app.json names it for the web.`)
87
+ if (!isObject(web)) throw new Error(`${file}: web is an object, or null`)
88
+ if (web.dependencies !== undefined || web.resources !== undefined) {
89
+ throw new Error(`Plugin "${id}": a project's web half is built with no packages — web.dependencies and web.resources are the first-party halves' (built in Web Lite's page); a file the half fetches goes in its web.static folder.`)
90
+ }
91
+ const register = web.register
92
+ if (typeof register !== "string" || !register.startsWith("web/") || !isInnerPath(register)) {
93
+ throw new Error(`${file}: web.register ${JSON.stringify(register)} — the module of the folder's web/ that exports register(host) ("web/${impl ?? id}.ts")`)
94
+ }
95
+ const folderStatic = web.static
96
+ if (folderStatic !== undefined) {
97
+ if (typeof folderStatic !== "string" || !isInnerPath(folderStatic)) throw new Error(`${file}: web.static — a folder of the plugin, by its path in it ("xr-runtime")`)
98
+ if (folderStatic === "index.js") throw new Error(`${file}: web.static "index.js" is the built module's name`)
99
+ }
100
+ return { implements: impl ?? null, register, static: folderStatic ?? null }
101
+ }
102
+
103
+ /** What of Bun a half's build uses — typed here rather than by bun-types, so the module type-checks
104
+ * wherever lecodes-sdk/compile is imported (the editor's world has no Bun). */
105
+ type ResolveArgs = { path: string, importer: string, kind: string }
106
+ type LoadArgs = { path: string, loader: string }
107
+ type Loaded = { contents: string | Uint8Array, loader: string }
108
+ type BuildPlugin = {
109
+ name: string
110
+ setup(build: {
111
+ onResolve(options: { filter: RegExp }, callback: (args: ResolveArgs) => { path: string, external: boolean } | undefined): void
112
+ onLoad(options: { filter: RegExp }, callback: (args: LoadArgs) => Promise<Loaded>): void
113
+ }): void
114
+ }
115
+ type BuildConfig = {
116
+ entrypoints: string[]
117
+ format: "esm"
118
+ target: "browser"
119
+ minify: boolean
120
+ sourcemap: "inline" | "none"
121
+ env: "disable"
122
+ /** Not in bun-types; read by Bun.build from 1.4 (`bun build --no-macros`), IGNORED by 1.3 — the
123
+ * onLoad gate below is what holds on every Bun. */
124
+ macros: false
125
+ plugins: BuildPlugin[]
126
+ }
127
+ type BuildResult = { success: boolean, logs: { message: string }[], outputs: { kind: string, text(): Promise<string> }[] }
128
+ /** `macro: false` is not in bun-types either: the parse refuses a macro import ("Macros are
129
+ * disabled") instead of running it, on 1.3 and 1.4 alike. */
130
+ type Transpiler = new (options: { loader: string, target: "browser", macro: false }) => { transformSync(code: string): string }
131
+ type BunApi = { build(config: BuildConfig): Promise<BuildResult>, Transpiler: Transpiler }
132
+
133
+ /** A file's loader, by its extension alone (Bun's defaults; anything else is a `file`, an asset):
134
+ * onLoad's own `args.loader` says "js" for an extension it does not know. */
135
+ const LOADERS: Record<string, string> = {
136
+ js: "jsx", jsx: "jsx", mjs: "js", cjs: "js", ts: "ts", mts: "ts", cts: "ts", tsx: "tsx",
137
+ json: "json", jsonc: "json", toml: "toml", txt: "text", css: "css",
138
+ }
139
+ const loaderOf = (path: string): string => LOADERS[/\.([^./\\]+)$/.exec(path)?.[1]?.toLowerCase() ?? ""] ?? "file"
140
+ /** The loaders that parse a file as code — where an import, and so a macro, can be. */
141
+ const CODE_LOADERS = new Set(["js", "jsx", "ts", "tsx"])
142
+
143
+ /** A build's or a parse's errors, one line each (`web/ar.ts:2:10: Expected ";"`). */
144
+ const messagesOf = (e: unknown, file?: string): string[] =>
145
+ ((e as { errors?: unknown[] }).errors ?? [e]).map((m) => {
146
+ const { message, position } = m as { message?: unknown, position?: { line?: number, column?: number } | null }
147
+ const text = String(message ?? m).replace(/^BuildMessage: /, "")
148
+ return file ? `${file}${position?.line ? `:${position.line}:${position.column ?? 0}` : ""}: ${text}` : text
149
+ })
150
+
151
+ export type WebHalfBuild = {
152
+ /** The plugin's id (app.json's key), named in every error. */
153
+ id: string
154
+ /** The half's folder: what it is built from, and all it may import. */
155
+ dir: string
156
+ /** The module that exports `register(host)` (the manifest's `web.register`): a path in `dir`, or
157
+ * relative to it. */
158
+ entry: string
159
+ /** Not minified, its source map inline (`lecodes web dev`). */
160
+ dev?: boolean
161
+ /** Default true; `dev` turns it off. */
162
+ minify?: boolean
163
+ }
164
+
165
+ /** The half as ONE ES module's text. Throws an Error that names the plugin and what to change. */
166
+ export const bundleWebHalf = async (half: WebHalfBuild): Promise<string> => {
167
+ // (through unknown: where bun-types are loaded, their `Bun` is not this structural one)
168
+ const bun = (globalThis as unknown as { Bun?: BunApi }).Bun
169
+ if (!bun) throw new Error(`Plugin "${half.id}": a project's web half is built with Bun — run the CLI of a checkout or the standalone binary (bun), not on Node.`)
170
+ // The folder as the path given AND as the file system names it: Bun reports a module by its real
171
+ // path (macOS's temporary folders are /var → /private/var).
172
+ const roots = [resolve(half.dir)]
173
+ try { roots.push(realpathSync(half.dir)) } catch { /* no such folder: the build says so */ }
174
+ const inner = (path: string): string | null => {
175
+ for (const root of roots) {
176
+ const rel = relative(root, path)
177
+ if (rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) return rel.split(sep).join("/")
178
+ }
179
+ return null
180
+ }
181
+ const entry = resolve(half.dir, half.entry)
182
+ if (inner(entry) === null) throw new Error(`Plugin "${half.id}": its web.register ${half.entry} is no file of its folder`)
183
+ const shown = (path: string) => inner(path) || path
184
+ const foreign: string[] = [] // packages, by the module that imports one
185
+ const outside: string[] = [] // files out of the folder
186
+ const macros: string[] = [] // modules that import a macro
187
+ const unparsed: string[] = [] // modules the macro-free parse refused otherwise
188
+ let result: BuildResult
189
+ try {
190
+ result = await bun.build({
191
+ entrypoints: [entry],
192
+ format: "esm",
193
+ target: "browser",
194
+ minify: half.dev ? false : half.minify !== false,
195
+ sourcemap: half.dev ? "inline" : "none",
196
+ env: "disable",
197
+ macros: false,
198
+ plugins: [{
199
+ name: "lecodes-project-half",
200
+ setup(build) {
201
+ build.onResolve({ filter: /.*/ }, (args) => {
202
+ if (args.kind.startsWith("entry-point")) return undefined
203
+ if (/^\.{0,2}\//.test(args.path)) {
204
+ if (inner(resolve(dirname(args.importer), args.path)) !== null) return undefined
205
+ outside.push(`${shown(args.importer)}: "${args.path}"`)
206
+ } else {
207
+ foreign.push(`${shown(args.importer)}: "${args.path}"`)
208
+ }
209
+ return { path: args.path, external: true }
210
+ })
211
+ // Every file the build reads is read HERE, never by Bun: one out of the folder is not read
212
+ // (a resolution that left it — a package.json `browser` map — is caught by its path); a
213
+ // file is read by its extension's loader, whatever an import attribute asks (`with { type:
214
+ // "ts" }` would parse a .txt as code); code is parsed with macros off first, and a module
215
+ // that fails it is handed over EMPTY, so no Bun ever parses it with macros on.
216
+ build.onLoad({ filter: /.*/ }, async (args) => {
217
+ const name = inner(args.path)
218
+ if (name === null) { outside.push(`"${args.path}"`); return { contents: "", loader: "js" } }
219
+ const bytes = await readFile(args.path)
220
+ const loader = loaderOf(args.path)
221
+ if (CODE_LOADERS.has(loader)) {
222
+ try {
223
+ new bun.Transpiler({ loader, target: "browser", macro: false }).transformSync(bytes.toString("utf8"))
224
+ } catch (e) {
225
+ const messages = messagesOf(e, name)
226
+ if (messages.some((m) => /macros are disabled/i.test(m))) macros.push(name)
227
+ else unparsed.push(...messages)
228
+ return { contents: "", loader: "js" }
229
+ }
230
+ }
231
+ return { contents: bytes, loader }
232
+ })
233
+ },
234
+ }],
235
+ })
236
+ } catch (e) {
237
+ // what the gate refused is said first (a module it emptied fails the build after it); else these
238
+ result = { success: false, logs: messagesOf(e).map((message) => ({ message })), outputs: [] }
239
+ }
240
+ if (macros.length) {
241
+ throw new Error(`Plugin "${half.id}": its web half imports a macro (\`with { type: "macro" }\` — code Bun runs at build time, on the machine that builds); a half imports its own modules:\n${macros.map((f) => ` ${f}`).join("\n")}`)
242
+ }
243
+ if (foreign.length) {
244
+ throw new Error(`Plugin "${half.id}": its web half imports a package — a project's half is built from its own files (the generated web/*.gen.ts, the host it is handed, the browser):\n${foreign.map((f) => ` ${f}`).join("\n")}`)
245
+ }
246
+ if (outside.length) {
247
+ throw new Error(`Plugin "${half.id}": its web half imports a file outside its folder — a half is built from its folder's files alone:\n${outside.map((f) => ` ${f}`).join("\n")}`)
248
+ }
249
+ if (unparsed.length) throw new Error(`Plugin "${half.id}": its web half did not build:\n${unparsed.map((m) => ` ${m}`).join("\n")}`)
250
+ if (!result.success) throw new Error(`Plugin "${half.id}": its web half did not build:\n${result.logs.map((l) => ` ${l.message}`).join("\n")}`)
251
+ if (result.outputs.length !== 1 || result.outputs[0]!.kind !== "entry-point") {
252
+ throw new Error(`Plugin "${half.id}": its web half builds into ${result.outputs.length} files — a half is ONE module: it imports its own .ts / .js only (a file it fetches goes in its web.static folder).`)
253
+ }
254
+ return await result.outputs[0]!.text()
255
+ }
@@ -75,6 +75,7 @@ export type With<N, A extends Aspect<any, any, any>> =
75
75
  interface Updatable {
76
76
  _phaseSeq: number
77
77
  _phases: number
78
+ _live: boolean
78
79
  updateOrder: number
79
80
  updateWhilePaused: boolean
80
81
  update?(dt: number): void
@@ -82,7 +83,7 @@ interface Updatable {
82
83
  updateFixed?(dt: number): void
83
84
  onAttach?(): void
84
85
  onDetach?(): void
85
- onReconfigure?(): void
86
+ flush?(): void
86
87
  }
87
88
  const internals = (a: Aspect<any, any, any>): Updatable => a as unknown as Updatable
88
89
  type PhaseFn = (dt: number) => void
@@ -218,6 +219,31 @@ const rankConstraints = (items: (Updatable | null)[]): Map<Function, number> =>
218
219
  const earlyUpdaters = new PhaseList()
219
220
  const lateUpdaters = new PhaseList()
220
221
  const fixedUpdaters = new PhaseList()
222
+
223
+ // ---- the dirty set: a live property changed, its aspect flushes ONCE, before the step ------------
224
+ // A setter of a live property stores the value and calls `this.dirty()`; the aspect lands here and
225
+ // its `flush()` runs before the physics step: the set is drained at the start of the early phase
226
+ // (writes made since the last frame — events, the late phase) and again right after the early
227
+ // updaters (writes made in an `updateBefore`), so the step runs on the new numbers however many
228
+ // properties changed. A flush may dirty another aspect (a Wheel hands its change to its Vehicle):
229
+ // a drain repeats until the set is empty, so a dependent flushes in the SAME pass. A write before
230
+ // attach only stores (onAttach sends everything); a detach drops the aspect from the set.
231
+ const dirtyAspects = new Set<Updatable>()
232
+ const MAX_FLUSH_ROUNDS = 8
233
+ const drainDirty = (): void => {
234
+ for (let round = 0; dirtyAspects.size > 0; round++) {
235
+ if (round === MAX_FLUSH_ROUNDS) {
236
+ warnOnce("[aspects] flush() keeps dirtying aspects — the remaining ones flush next frame")
237
+ return
238
+ }
239
+ const batch = [ ...dirtyAspects ]
240
+ dirtyAspects.clear()
241
+ for (const a of batch) if (a._live) a.flush?.()
242
+ }
243
+ }
244
+ /** @internal Tests: run the pending flushes now, the way the next early phase would. */
245
+ export const _flushDirtyAspects = (): void => drainDirty()
246
+
221
247
  let dispatchInstalled = false
222
248
  let fixedInstalled = false
223
249
  let fixedFallbackOn = false
@@ -240,23 +266,29 @@ const fixedFallback = (): void => {
240
266
  if (steps === 4) fixedFallbackAcc = 0
241
267
  }
242
268
 
269
+ // The two phase passes. Both scale their OWN raw dt (the engine hands the same value to both; a host
270
+ // or test that drives them apart still gets each phase's dt right). The frame counter / clocks
271
+ // advance once, early. The pending flushes go out right before the early updaters.
272
+ const early: PhaseFn = (dt) => {
273
+ _phaseStamp.value++
274
+ Time._beginFrame(dt)
275
+ _phaseHooks.early?.(dt)
276
+ drainDirty()
277
+ earlyUpdaters.run("updateBefore", Time.dt)
278
+ drainDirty()
279
+ if (fixedFallbackOn) fixedFallback()
280
+ }
281
+ const late: PhaseFn = (dt) => {
282
+ _phaseStamp.value++ // the physics step + transform sync ran since the early pass
283
+ lateUpdaters.run("update", Time._phaseDt(dt))
284
+ _phaseHooks.late?.(dt)
285
+ }
286
+ /** @internal Tests: the phase passes themselves, whatever frame source is installed. */
287
+ export const _runPhase = { early, late }
288
+
243
289
  const ensureDispatch = (): void => {
244
290
  if (dispatchInstalled) return
245
291
  dispatchInstalled = true
246
- // Both phases scale their OWN raw dt (the engine hands the same value to both; a host or test that
247
- // drives them apart still gets each phase's dt right). The frame counter / clocks advance once, early.
248
- const early: PhaseFn = (dt) => {
249
- _phaseStamp.value++
250
- Time._beginFrame(dt)
251
- _phaseHooks.early?.(dt)
252
- earlyUpdaters.run("updateBefore", Time.dt)
253
- if (fixedFallbackOn) fixedFallback()
254
- }
255
- const late: PhaseFn = (dt) => {
256
- _phaseStamp.value++ // the physics step + transform sync ran since the early pass
257
- lateUpdaters.run("update", Time._phaseDt(dt))
258
- _phaseHooks.late?.(dt)
259
- }
260
292
  // render-synced source if an engine layer installed one; else one host loop (early then late).
261
293
  if (!(frameInstaller && frameInstaller(early, late))) {
262
294
  setLoop((dt) => { early(dt); late(dt) })
@@ -317,25 +349,26 @@ const untrackInstance = (a: Aspect<any, any, any>): void => {
317
349
  // (a node's `_aspects`, a scene's `_systems`).
318
350
  type Store = Map<Function, Aspect<any, any, any>>
319
351
 
352
+ // `aspect(Ctor, opts)` always BUILDS: a class already on the host is detached first and a fresh
353
+ // instance is made from exactly `opts` (every other field at its class default). Changing a live
354
+ // value is a property write on the instance — a setter, or `Object.assign(node.x, {…})` for several
355
+ // at once — never a second `aspect()` call, which resets the aspect's state (and strands any
356
+ // reference to the old instance: take `node.<accessor>` again).
320
357
  const attachTo = <Self, A extends Aspect<any, any, any>>(
321
358
  host: Self, store: Store, ctor: AspectCtor<A>, opts: Partial<A> | undefined, scene: unknown,
322
359
  ): Self & FieldOf<A> => {
323
- let inst = store.get(ctor) as A | undefined
324
- const fresh = inst === undefined
325
- if (!inst) {
326
- inst = new (ctor as unknown as new () => A)()
327
- ;(inst as { node: unknown }).node = host
328
- if (scene !== undefined) (inst as { scene: unknown }).scene = scene
329
- store.set(ctor, inst)
330
- const name = (ctor as Ctor).aspect
331
- if (name) (host as unknown as Record<string, unknown>)[name] = inst
332
- }
360
+ if (store.has(ctor)) detachFrom(host, store, ctor)
361
+ const inst = new (ctor as unknown as new () => A)()
362
+ ;(inst as { node: unknown }).node = host
363
+ if (scene !== undefined) (inst as { scene: unknown }).scene = scene
364
+ store.set(ctor, inst)
365
+ const name = (ctor as Ctor).aspect
366
+ if (name) (host as unknown as Record<string, unknown>)[name] = inst
333
367
  if (opts) Object.assign(inst, opts)
334
- if (fresh) {
335
- trackInstance(inst)
336
- internals(inst).onAttach?.()
337
- registerUpdater(inst)
338
- } else if (opts) internals(inst).onReconfigure?.()
368
+ trackInstance(inst)
369
+ internals(inst)._live = true
370
+ internals(inst).onAttach?.()
371
+ registerUpdater(inst)
339
372
  return host as Self & FieldOf<A>
340
373
  }
341
374
 
@@ -343,6 +376,8 @@ const detachFrom = (host: unknown, store: Store, ctor: Function): void => {
343
376
  const inst = store.get(ctor)
344
377
  if (!inst) return
345
378
  internals(inst).onDetach?.()
379
+ internals(inst)._live = false
380
+ dirtyAspects.delete(internals(inst))
346
381
  unregisterUpdater(inst)
347
382
  untrackInstance(inst)
348
383
  inst._clearEvents()
@@ -368,8 +403,10 @@ export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<
368
403
  /** @internal class → instance, the authoritative store (named accessors mirror this). */
369
404
  readonly _aspects = new Map<Function, Aspect<any, any, any>>()
370
405
 
371
- /** Attach (and configure) an aspect, or reconfigure it if already present. Returns the node typed
372
- * as now-having that aspect (so no guard/`?.` is needed afterwards). Rejects a wrong target node. */
406
+ /** Attach an aspect built from `opts` (a class already on the node is detached first and REBUILT
407
+ * from exactly these options — a structural change; a live value is a property write on
408
+ * `node.<accessor>`). Returns the node typed as now-having that aspect (so no guard/`?.` is
409
+ * needed afterwards). Rejects a wrong target node. */
373
410
  aspect<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
374
411
  this: Self,
375
412
  ctor: AspectCtor<A>,
@@ -496,6 +533,8 @@ export abstract class Aspect<K extends string, P = Node, E extends EventMap = {}
496
533
  _phaseSeq = 0
497
534
  /** @internal bit 1 = registered early, bit 2 = registered late. */
498
535
  _phases = 0
536
+ /** @internal true between onAttach and onDetach: the only time `dirty()` schedules a flush. */
537
+ _live = false
499
538
  private _listeners?: Map<string, Function[]>
500
539
 
501
540
  // ---- lifecycle: the engine calls these, game code never does (hence `protected`; a subclass may
@@ -504,9 +543,53 @@ export abstract class Aspect<K extends string, P = Node, E extends EventMap = {}
504
543
  protected onAttach?(): void
505
544
  /** Release what onAttach acquired. */
506
545
  protected onDetach?(): void
507
- /** Called after `node.aspect(Ctor, opts)` re-assigns options on an ALREADY attached aspect (a
508
- * pre-attached one like `model.anim`): rebuild whatever was derived from the options at attach. */
509
- protected onReconfigure?(): void
546
+ /**
547
+ * LIVE PROPERTIES. A value the engine must hear about is a get/set pair whose setter stores it and
548
+ * calls `this.dirty()`; `flush()` then runs ONCE before the next physics step — at the start of
549
+ * the early phase for a write made since the last frame, right after the early updaters for one
550
+ * made in an `updateBefore` — however many properties changed, one send. `node.aspect(Ctor, opts)`
551
+ * and the scene file write the same setters. Before attach a setter only stores (onAttach sends
552
+ * everything); after detach nothing flushes. A value with a bridge call of its own, or one whose
553
+ * result the caller reads back at once (a Shape's dimensions: `controller.resizing`), skips the
554
+ * batch and pushes from its setter.
555
+ *
556
+ * get radius(): number { return this._radius }
557
+ * set radius(v: number) { this._radius = v; this.dirty() }
558
+ * protected flush(): void { _creator.thingSetTuning(this._id, this._settings()) }
559
+ */
560
+ protected dirty(): void {
561
+ if (!this._live) return
562
+ dirtyAspects.add(internals(this))
563
+ ensureDispatch()
564
+ }
565
+ /** Send what the setters changed — see `dirty()`. */
566
+ protected flush?(): void
567
+ /**
568
+ * A nested config object as a LIVE VIEW: every key of `defaults` becomes an accessor whose setter
569
+ * stores and calls `this.dirty()`, so `wheel.suspension.stiffness = 2` reaches the engine like any
570
+ * live property. `value` is taken WHOLE: a key it leaves out is the default, nothing of a previous
571
+ * object survives (`wheel.suspension = { stiffness: 2 }` is stiffness 2 and the default travel and
572
+ * damping). Writing `undefined` to a key restores its default. One level deep: a value that is
573
+ * itself an object is replaced, not watched.
574
+ *
575
+ * private _suspension = this.live(SUSPENSION_DEFAULTS)
576
+ * get suspension(): WheelSuspension { return this._suspension }
577
+ * set suspension(v: WheelSuspension) { this._suspension = this.live(SUSPENSION_DEFAULTS, v); this.dirty() }
578
+ */
579
+ protected live<T extends object>(defaults: T, value?: Partial<T>): T {
580
+ const view = {} as Record<string, unknown>
581
+ const store = { ...defaults } as Record<string, unknown>
582
+ const given = value as Record<string, unknown> | undefined
583
+ for (const key of Object.keys(defaults)) {
584
+ if (given && given[key] !== undefined) store[key] = given[key]
585
+ Object.defineProperty(view, key, {
586
+ enumerable: true,
587
+ get: () => store[key],
588
+ set: (v: unknown) => { store[key] = v === undefined ? (defaults as Record<string, unknown>)[key] : v; this.dirty() },
589
+ })
590
+ }
591
+ return view as T
592
+ }
510
593
  /**
511
594
  * GENERATOR aspects: (re)build the derived output under `this.generated` — must be idempotent
512
595
  * (clear, then create). Call it from `onAttach()` for play mode; a class opting in with
@@ -55,8 +55,6 @@ export class OneWay2D extends Aspect<"oneWay", Node2D> {
55
55
  if (id) _creator2d.physicsSetOneWay(id, 0, 1, 90, false)
56
56
  }
57
57
 
58
- onReconfigure(): void { this._push() }
59
-
60
58
  private _push(): void {
61
59
  const id = this.node.get(Physics2D)?.id
62
60
  // via the public accessors on purpose: an accessor pair that is only ever an object-literal key
@@ -65,10 +65,14 @@ export class Physics2D extends Aspect<"physics", Node2D> {
65
65
  this.node._physicsDriven = m !== "static"
66
66
  if (this._h) _creator2d.physicsSetMotionType(this._h, MOTION[m])
67
67
  }
68
- /** Lock rotation (essential for platformer characters). */
69
- fixedRotation = false
70
- /** Continuous collision for fast-moving bodies (projectiles). */
71
- bullet = false
68
+ private _fixedRotation = false
69
+ /** Lock rotation (essential for platformer characters). Live. */
70
+ get fixedRotation(): boolean { return this._fixedRotation }
71
+ set fixedRotation(v: boolean) { this._fixedRotation = v; if (this._h) _creator2d.physicsSetFixedRotation(this._h, v) }
72
+ private _bullet = false
73
+ /** Continuous collision for fast-moving bodies (projectiles). Live. */
74
+ get bullet(): boolean { return this._bullet }
75
+ set bullet(v: boolean) { this._bullet = v; if (this._h) _creator2d.physicsSetBullet(this._h, v) }
72
76
 
73
77
  private _mass = 0 // 0 = derive from the collider area
74
78
  private _friction = DEFAULT_FRICTION
@@ -164,18 +168,6 @@ export class Physics2D extends Aspect<"physics", Node2D> {
164
168
  this.node._xfKind = 0
165
169
  }
166
170
 
167
- /** Re-applying options (`node.aspect(Physics2D, {…})`) pushes the live ones through. */
168
- onReconfigure(): void {
169
- if (!this._h) return
170
- this._pushMaterial()
171
- _creator2d.physicsSetFixedRotation(this._h, this.fixedRotation)
172
- _creator2d.physicsSetBullet(this._h, this.bullet)
173
- _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
174
- _creator2d.physicsSetLinearDamping(this._h, this.linearDamping)
175
- _creator2d.physicsSetAngularDamping(this._h, this.angularDamping)
176
- if (this._mass > 0) _creator2d.physicsSetMass(this._h, this.mass)
177
- }
178
-
179
171
  /** Linear velocity in world units/second (fresh Vec2 on read). */
180
172
  get velocity(): Vec2 {
181
173
  const o = this._h ? _creator2d.physicsGetLinearVelocity(this._h) : null
package/src/g2/Scene2D.ts CHANGED
@@ -132,7 +132,7 @@ export class Scene2D implements Presentable {
132
132
  * types: it is what makes a Scene2D a `SystemHost` there. */
133
133
  readonly _systems = new Map<Function, Aspect<any, any, any>>()
134
134
 
135
- /** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
135
+ /** Build a system from `opts` (a class already on the scene is detached and built afresh). Returns the scene typed
136
136
  * as now-having it (`scene.system(Hud).hud`). A 3D `System<'x'>` (target Scene) is rejected here. */
137
137
  // `this: Self` (not `Self & Scene2D`): with the intersection TS infers Self = Scene2D and a chain
138
138
  // `scene.system(A).system(B)` loses A's accessor from the result type.