lecodes-sdk 2.4.1 → 2.4.2
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 +34 -5
- package/dist/types/g2/OneWay2D.d.ts +0 -1
- package/dist/types/g2/Physics2D.d.ts +8 -6
- package/dist/types/g2/Scene2D.d.ts +1 -1
- package/dist/types/g2/Shape2D.d.ts +43 -15
- package/dist/types/gl/Model.d.ts +3 -2
- package/dist/types/gl/Scene.d.ts +1 -1
- package/dist/types/gl/animation/DynamicBone.d.ts +99 -35
- package/dist/types/gl/animation/IK.d.ts +0 -1
- package/dist/types/gl/nav/NavAgent.d.ts +3 -2
- package/dist/types/gl/physics/Physics.d.ts +4 -5
- package/dist/types/gl/physics/Shape.d.ts +40 -15
- package/dist/types/gl/vehicle/Vehicle.d.ts +54 -35
- package/dist/types/gl/vehicle/Wheel.d.ts +31 -10
- package/dist/types/plugins/gen/ar/contract.d.ts +152 -0
- package/dist/types/plugins/gen/ar/sdk/ar.d.ts +47 -21
- package/dist/types/plugins/gen/ar/sdk/ar.gen.d.ts +17 -3
- package/dist/types/plugins/gen/audio-recorder/contract.d.ts +64 -0
- package/dist/types/plugins/gen/browser/contract.d.ts +108 -0
- package/dist/types/plugins/gen/camera/contract.d.ts +27 -0
- package/dist/types/plugins/gen/geolocation/contract.d.ts +52 -0
- package/dist/types/plugins/gen/map/contract.d.ts +144 -0
- package/dist/types/plugins/gen/push/contract.d.ts +83 -0
- package/dist/types/plugins/gen/qr-scanner/contract.d.ts +16 -0
- package/dist/types/plugins/gen/speech/contract.d.ts +67 -0
- package/dist/types/plugins/gen/webrtc/contract.d.ts +342 -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 +5 -3
- package/src/compile/compileProject.ts +45 -10
- package/src/compile/index.ts +7 -0
- package/src/compile/webHalf.ts +255 -0
- package/src/core/Aspect.ts +118 -35
- package/src/g2/OneWay2D.ts +0 -2
- package/src/g2/Physics2D.ts +8 -16
- package/src/g2/Scene2D.ts +1 -1
- package/src/g2/Shape2D.ts +51 -22
- package/src/gl/Model.ts +3 -2
- package/src/gl/Scene.ts +1 -1
- package/src/gl/animation/DynamicBone.ts +549 -486
- package/src/gl/animation/IK.ts +0 -2
- package/src/gl/nav/NavAgent.ts +14 -12
- package/src/gl/physics/Physics.ts +4 -5
- package/src/gl/physics/Shape.ts +49 -23
- package/src/gl/vehicle/Vehicle.ts +102 -58
- package/src/gl/vehicle/Wheel.ts +45 -11
- package/src/plugins/gen/ar/contract.d.ts +35 -1
- package/src/plugins/gen/ar/sdk/ar.gen.ts +25 -6
- package/src/plugins/gen/ar/sdk/ar.ts +102 -35
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/bridges/gl.d.ts
CHANGED
|
@@ -949,9 +949,11 @@ declare global {
|
|
|
949
949
|
* wakes a sleeping car. @ifdef CREATOR_GL_PHYSICS */
|
|
950
950
|
vehicleSetInput(vehicleId: u32, input: Float32Array): void
|
|
951
951
|
/** Re-apply the TUNABLE half of the blob (same layout) to a live car — differential, per-wheel tire
|
|
952
|
-
* curves / traction / circle,
|
|
953
|
-
*
|
|
954
|
-
*
|
|
952
|
+
* curves / traction / circle, per-wheel radius and spring (stiffness, damping), engine
|
|
953
|
+
* torque/RPM/curve, clutch strength + capacity, steer lock, the column, anti-roll stiffness, tilt
|
|
954
|
+
* limit; a changed radius or spring wakes a sleeping car. Structural values (mass, centre of mass,
|
|
955
|
+
* where the wheels hang, their width and travel, the driven shares, whether an axle has a bar) are
|
|
956
|
+
* ignored: those need a re-create. @ifdef CREATOR_GL_PHYSICS */
|
|
955
957
|
vehicleSetTuning?(vehicleId: u32, settings: Float32Array): void
|
|
956
958
|
/** out = [speed, rpm, wheelsInContact, vx, vy, vz, wx, wy, wz, steerDeg, steerTorque] (w = chassis
|
|
957
959
|
* angular velocity, rad/s; steerDeg = where the road wheels are, right-positive; steerTorque = the
|
|
@@ -168,20 +168,55 @@ export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: strin
|
|
|
168
168
|
return files
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
/** app.json's `"plugins"`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
/** The platforms an entry of app.json's `"plugins"` may give a spec of their own. */
|
|
172
|
+
export const PLUGIN_PLATFORMS = ["ios", "android", "web"] as const
|
|
173
|
+
export type PluginPlatform = (typeof PLUGIN_PLATFORMS)[number]
|
|
174
|
+
|
|
175
|
+
/** One entry of app.json's `"plugins"`: ONE spec for every platform (`"*"`, `"^2.0.0"`,
|
|
176
|
+
* `"github:owner/repo@v1"`, `"./plugins/<id>"`), or a spec per platform —
|
|
177
|
+
* `{ "web": "./plugins/ar-8thwall" }`: the project's own web half of a first-party plugin, the
|
|
178
|
+
* first-party halves elsewhere. A platform the object does not name is `"*"`. */
|
|
179
|
+
export type PluginEntry = string | { [P in PluginPlatform]?: string }
|
|
180
|
+
|
|
181
|
+
/** app.json's `"plugins"` in any shape — `["camera"]` (ids, every spec `*`), `{ "camera": "*" }`
|
|
182
|
+
* (id → spec), `{ "ar": { "web": "./plugins/ar-8thwall" } }` (id → a spec per platform) — as
|
|
183
|
+
* id → entry. Anything else is no plugin; a value of no known shape is `"*"` (whoever reads
|
|
184
|
+
* app.json as a config reports it). The list form is what CLAUDE.md and most hands write first;
|
|
185
|
+
* reading it as "no plugins" dropped the plugin from a web build and a shell with one warning
|
|
186
|
+
* nobody tied to the shape. */
|
|
187
|
+
export const pluginEntriesOf = (plugins: unknown): Record<string, PluginEntry> => {
|
|
176
188
|
if (Array.isArray(plugins)) return Object.fromEntries(plugins.filter((p): p is string => typeof p === "string").map((id) => [id, "*"]))
|
|
177
|
-
if (plugins
|
|
178
|
-
|
|
189
|
+
if (!plugins || typeof plugins !== "object") return {}
|
|
190
|
+
const entry = (v: unknown): PluginEntry => {
|
|
191
|
+
if (typeof v === "string") return v
|
|
192
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return "*"
|
|
193
|
+
const own: { [P in PluginPlatform]?: string } = {}
|
|
194
|
+
for (const p of PLUGIN_PLATFORMS) {
|
|
195
|
+
const spec = (v as Record<string, unknown>)[p]
|
|
196
|
+
if (typeof spec === "string") own[p] = spec
|
|
197
|
+
}
|
|
198
|
+
return own
|
|
179
199
|
}
|
|
180
|
-
return
|
|
200
|
+
return Object.fromEntries(Object.entries(plugins as Record<string, unknown>).map(([id, v]) => [id, entry(v)]))
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
/** The
|
|
184
|
-
|
|
203
|
+
/** The spec `platform` takes from one entry: the entry's own for it, else `"*"`. A platform an
|
|
204
|
+
* entry cannot name (the desktop's libraries) takes `"*"` of a per-platform entry. */
|
|
205
|
+
export const pluginSpecOf = (entry: PluginEntry, platform: string): string =>
|
|
206
|
+
typeof entry === "string" ? entry : (entry as Record<string, string | undefined>)[platform] ?? "*"
|
|
207
|
+
|
|
208
|
+
/** app.json's `"plugins"` (any shape) as id → the spec `platform` takes. */
|
|
209
|
+
export const pluginSpecsOf = (plugins: unknown, platform: string): Record<string, string> =>
|
|
210
|
+
Object.fromEntries(Object.entries(pluginEntriesOf(plugins)).map(([id, entry]) => [id, pluginSpecOf(entry, platform)]))
|
|
211
|
+
|
|
212
|
+
/** A plugin spec that names a folder of the project (`./plugins/x`, `../x`, `/abs`, `file:x`): a
|
|
213
|
+
* plugin of the project's own, or its half of a first-party one (webHalf.ts). */
|
|
214
|
+
export const isLocalPluginSpec = (spec: string): boolean =>
|
|
215
|
+
spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/") || spec.startsWith("file:")
|
|
216
|
+
|
|
217
|
+
/** The ids of app.json's `"plugins"` (any shape), in its order: the `// plugins:` line lists the
|
|
218
|
+
* KEY of a per-platform entry too (`ar`), whatever builds each of its halves. */
|
|
219
|
+
export const pluginIdsOf = (plugins: unknown): string[] => Object.keys(pluginEntriesOf(plugins))
|
|
185
220
|
|
|
186
221
|
/** The ids app.json lists under `"plugins"`, in its order. */
|
|
187
222
|
export const pluginsOfAppJson = (entries: CompileEntry[]): string[] => {
|
package/src/compile/index.ts
CHANGED
|
@@ -68,8 +68,14 @@ export {
|
|
|
68
68
|
compileProject,
|
|
69
69
|
compileProjectWithMap,
|
|
70
70
|
pluginsOfAppJson,
|
|
71
|
+
pluginEntriesOf,
|
|
72
|
+
pluginSpecOf,
|
|
71
73
|
pluginSpecsOf,
|
|
72
74
|
pluginIdsOf,
|
|
75
|
+
isLocalPluginSpec,
|
|
76
|
+
PLUGIN_PLATFORMS,
|
|
77
|
+
type PluginEntry,
|
|
78
|
+
type PluginPlatform,
|
|
73
79
|
pluginsOfBundle,
|
|
74
80
|
entriesToFiles,
|
|
75
81
|
localAssetNames,
|
|
@@ -79,6 +85,7 @@ export {
|
|
|
79
85
|
type CompileFileType,
|
|
80
86
|
type CompileResult,
|
|
81
87
|
} from "./compileProject"
|
|
88
|
+
export { bundleWebHalf, webHalfManifestOf, webHalfSpecsOf, type WebHalfBuild, type WebHalfManifest } from "./webHalf"
|
|
82
89
|
export {
|
|
83
90
|
sceneEditorEntries,
|
|
84
91
|
sceneImportsOf,
|
|
@@ -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
|
+
}
|
package/src/core/Aspect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
372
|
-
*
|
|
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
|
-
/**
|
|
508
|
-
*
|
|
509
|
-
|
|
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
|
package/src/g2/OneWay2D.ts
CHANGED
|
@@ -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
|
package/src/g2/Physics2D.ts
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|