lecodes-cli 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/index.js +883 -507
- package/package.json +9 -4
- package/runtime/sdk/canvas/Canvas.ts +270 -0
- package/runtime/sdk/compile/aspectMacro.ts +42 -0
- package/runtime/sdk/compile/assetMacro.ts +44 -0
- package/runtime/sdk/compile/bundler.ts +173 -0
- package/runtime/sdk/compile/compileProject.ts +84 -0
- package/runtime/sdk/compile/header.ts +46 -0
- package/runtime/sdk/compile/index.ts +12 -0
- package/runtime/sdk/compile/sourcemap.ts +25 -0
- package/runtime/sdk/core/Aspect.ts +82 -26
- package/runtime/sdk/core/signals.ts +277 -0
- package/runtime/sdk/g2/CharacterController2D.ts +5 -4
- package/runtime/sdk/g2/Node2D.ts +1 -1
- package/runtime/sdk/g2/Physics2D.ts +22 -12
- package/runtime/sdk/g2/Scene2D.ts +30 -0
- package/runtime/sdk/g2/{Shape.ts → Shape2D.ts} +10 -10
- package/runtime/sdk/g2/Sprite.ts +16 -6
- package/runtime/sdk/g2/Texture2D.ts +12 -2
- package/runtime/sdk/g2/{Trigger.ts → Trigger2D.ts} +9 -9
- package/runtime/sdk/gl/CharacterController.ts +101 -0
- package/runtime/sdk/gl/Material.ts +10 -3
- package/runtime/sdk/gl/Node.ts +11 -2
- package/runtime/sdk/gl/Physics.ts +65 -41
- package/runtime/sdk/gl/Scene.ts +18 -0
- package/runtime/sdk/gl/Shape.ts +98 -0
- package/runtime/sdk/gl/Texture.ts +13 -2
- package/runtime/sdk/gl/Trigger.ts +46 -0
- package/runtime/sdk/gl/physicsEvents.ts +20 -0
- package/runtime/sdk/inject.ts +14 -3
- package/runtime/sdk/runtime/camera.ts +31 -0
- package/runtime/sdk/runtime/device.ts +26 -0
- package/runtime/sdk/runtime/share.ts +9 -0
- package/runtime/sdk/ui/UI.ts +5 -1
- package/runtime/sdk/ui/UIButton.ts +11 -5
- package/runtime/sdk/ui/UIContainer.ts +28 -13
- package/runtime/sdk/ui/UIImage.ts +31 -11
- package/runtime/sdk/ui/UIInput.ts +10 -0
- package/runtime/sdk/ui/UINode.ts +206 -8
- package/runtime/sdk/ui/UIScreen.ts +17 -7
- package/runtime/sdk/ui/UIScrollable.ts +10 -5
- package/runtime/sdk/ui/UISpacer.ts +4 -0
- package/runtime/sdk/ui/UIText.ts +21 -5
- package/runtime/sdk/ui/UIVideo.ts +5 -0
- package/runtime/sdk/ui/UIVirtualizedList.ts +5 -0
- package/runtime/sdk/ui/UIWidget.ts +10 -5
- package/runtime/sdk/gl/Collider.ts +0 -29
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The metadata header the launcher reads from the compiled bundle: name / icon / preload / gl / 2d /
|
|
2
|
+
// ar. Single source of truth — the backend and CLI both call this (today they mirror it by hand).
|
|
3
|
+
//
|
|
4
|
+
// Detection keys on the *bundled* `_creator*` calls, which the SDK's thin wrappers still emit
|
|
5
|
+
// (new Scene() → _creator.createScene, new Scene2D() → _creator2d.createScene), so the re-skin keeps
|
|
6
|
+
// the exact same signals the old worker bundles produced.
|
|
7
|
+
|
|
8
|
+
export type HeaderOptions = {
|
|
9
|
+
/** Generated-icon URL (backend supplies it; the CLI omits it). */
|
|
10
|
+
icon?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}): string => {
|
|
14
|
+
let header = `// name: ${name.slice(0, 20)}\n`
|
|
15
|
+
if (opts.icon) header += `// icon: ${opts.icon}\n`
|
|
16
|
+
header += `// last-updated: ${new Date().toISOString()}\n`
|
|
17
|
+
|
|
18
|
+
// Local assets to preload: explicit fetchLocal() calls + implicit engine archives.
|
|
19
|
+
const localAssets = new Set<string>()
|
|
20
|
+
for (const m of code.matchAll(/_creatorUtils\.fetchLocal\((.+?)\)/g)) {
|
|
21
|
+
localAssets.add(m[1].replaceAll(/['"]/g, "").trim())
|
|
22
|
+
}
|
|
23
|
+
if (/_creator\.createGlb/.test(code)) localAssets.add("uberarchive")
|
|
24
|
+
if (/_creator\.captureImage/.test(code)) localAssets.add("capture")
|
|
25
|
+
if (localAssets.size > 0) header += `// preload: ${[ ...localAssets ].join(";")}\n`
|
|
26
|
+
|
|
27
|
+
// GL: disabled unless the project touches the 3D engine; deferred if it lazily creates the engine.
|
|
28
|
+
if (!code.includes("_creator.createScene") && !code.includes("_creator.createEntity")) header += `// gl: disable\n`
|
|
29
|
+
else if (code.includes("_creator.createEngine")) header += `// gl: defer\n`
|
|
30
|
+
|
|
31
|
+
// 2D (creator-2d) — non-exclusive: emitted alongside `gl: disable` so hosts skip Filament and a
|
|
32
|
+
// 2D-aware host brings up the lightweight sokol engine instead.
|
|
33
|
+
if (code.includes("_creator2d.")) header += `// 2d: enable\n`
|
|
34
|
+
|
|
35
|
+
if (code.includes("_creator.createARController")) header += "// ar: enable"
|
|
36
|
+
header += "\n"
|
|
37
|
+
return header
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Same engine signals the header uses, for callers that need the project kind (e.g. icon type). */
|
|
41
|
+
export const detectProjectKind = (code: string): "ar" | "ui" | "2d" | "3d" => {
|
|
42
|
+
if (code.includes("_creator.createARController")) return "ar"
|
|
43
|
+
if (code.includes("_creator2d.")) return "2d"
|
|
44
|
+
if (!code.includes("_creator.")) return "ui"
|
|
45
|
+
return "3d"
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The public entry of the SDK's bundle build system. The backend, CLI, and local renderer import
|
|
2
|
+
// from here instead of re-forking a bundler. (Node + Bun safe — no top-level host globals.)
|
|
3
|
+
|
|
4
|
+
export { bundleProject, bundleProjectWithMap, type MemoryFiles } from "./bundler"
|
|
5
|
+
export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
|
|
6
|
+
export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
|
|
7
|
+
export {
|
|
8
|
+
compileProject,
|
|
9
|
+
type CompileEntry,
|
|
10
|
+
type CompileOptions,
|
|
11
|
+
type CompileFileType,
|
|
12
|
+
} from "./compileProject"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Helpers for serving the bundler's source map alongside the bundle. The map's generated positions
|
|
2
|
+
// are relative to the bundle code; when the deploy/preview prepends a comment header, the map must be
|
|
3
|
+
// shifted down by that many lines, then inlined into the bundle as a data URL (the preview executes
|
|
4
|
+
// the bundle via `new Function(string)`, which has no URL identity, so an external `.map` can't be
|
|
5
|
+
// fetched — it must be inline).
|
|
6
|
+
|
|
7
|
+
/** Shift every generated position in `mapJson` down by `lines` (each VLQ line is `;`-separated). */
|
|
8
|
+
export const offsetSourceMap = (mapJson: string, lines: number): string => {
|
|
9
|
+
if (lines <= 0) return mapJson
|
|
10
|
+
const map = JSON.parse(mapJson) as { mappings: string }
|
|
11
|
+
map.mappings = ";".repeat(lines) + map.mappings
|
|
12
|
+
return JSON.stringify(map)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inline the map into a bundle string under a **non-standard** directive (`lecodesMap`, not
|
|
17
|
+
* `sourceMappingURL`), so the browser does NOT auto-apply it. The bundle runs via `new Function`, and
|
|
18
|
+
* the browser maps the raw stack position *including* the `new Function` wrapper (~2-3 lines) which the
|
|
19
|
+
* map can't subtract — so its native source location would be off by those lines. Our preview mapper
|
|
20
|
+
* reads `lecodesMap` and subtracts the (calibrated) wrapper itself, so its locations are correct.
|
|
21
|
+
*/
|
|
22
|
+
export const inlineSourceMapComment = (mapJson: string): string => {
|
|
23
|
+
const b64 = Buffer.from(mapJson, "utf8").toString("base64")
|
|
24
|
+
return `//# lecodesMap=data:application/json;charset=utf-8;base64,${b64}`
|
|
25
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// const hero = new Sprite({ texture })
|
|
5
5
|
// .aspect(SpriteAnimation, { fps: 8, clips: { walk: [1, 2, 3] } }) // attach + configure
|
|
6
|
-
// .aspect(
|
|
6
|
+
// .aspect(Shape2D, { box: [16, 8] }) // chains: returns the node
|
|
7
7
|
// hero.anim.play('walk') // access by name (the chain result is typed as having it)
|
|
8
8
|
// if (hero.has(Physics)) hero.physics.velocity = [0, 0] // has() is a type guard
|
|
9
9
|
//
|
|
@@ -39,28 +39,70 @@ type UnionToIntersection<U> =
|
|
|
39
39
|
export type With<N, A extends Aspect<any, any>> =
|
|
40
40
|
N & UnionToIntersection<A extends any ? FieldOf<A> : never>
|
|
41
41
|
|
|
42
|
-
// ---- per-frame update dispatch
|
|
43
|
-
// Aspects that define update(dt) are ticked every frame
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
42
|
+
// ---- per-frame update dispatch (two phases) ---------------------------------
|
|
43
|
+
// Aspects that define update(dt) are ticked every frame in ONE of two phases, chosen by the aspect's
|
|
44
|
+
// `updateBeforePhysics` field:
|
|
45
|
+
// • LATE (default): after the physics step + transform sync, right before the frame draws. Reads of
|
|
46
|
+
// node.worldPosition are the FINAL drawn position — cameras/followers land exactly, no 1-frame lag.
|
|
47
|
+
// • EARLY (updateBeforePhysics = true): before the physics step, so writing velocity/force/kinematic
|
|
48
|
+
// transforms is consumed by the SAME frame's step (zero input latency).
|
|
49
|
+
// The FRAME SOURCE is pluggable so core never names a specific engine bridge (a 3D-only bundle must not
|
|
50
|
+
// pull in the 2D one): the 2D layer installs a render-synced source via _installAspectFrames (the native
|
|
51
|
+
// c2dRender early/late hooks), so dispatch is in lockstep with the draw. With none installed (3D-only,
|
|
52
|
+
// or a runtime without the hooks) it falls back to a single host setLoop, early-then-late — no true
|
|
53
|
+
// post-physics slot. Both lists stay sorted by the aspect's `order` field (default 0; ties keep
|
|
54
|
+
// insertion order). Lazy: the first update-bearing aspect installs the dispatch; a project with none
|
|
55
|
+
// starts nothing. No visibility culling yet — every registered aspect ticks regardless of on-screen state.
|
|
56
|
+
type Updatable = Aspect<any, any>
|
|
57
|
+
type PhaseFn = (dt: number) => void
|
|
58
|
+
const earlyUpdaters: Updatable[] = []
|
|
59
|
+
const lateUpdaters: Updatable[] = []
|
|
60
|
+
let dispatchInstalled = false
|
|
61
|
+
|
|
62
|
+
// A render-synced frame source: given the two phase runners, wire them to the engine and return true;
|
|
63
|
+
// return false if unavailable (→ setLoop fallback). Installed by the 2D layer (see Scene2D) so core
|
|
64
|
+
// never references a concrete engine bridge — that keeps the 2D bridge out of a 3D-only bundle.
|
|
65
|
+
type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
|
|
66
|
+
let frameInstaller: FrameInstaller | undefined
|
|
67
|
+
/** @internal Install a render-synced frame source for aspect update(dt). Called from the 2D layer. */
|
|
68
|
+
export const _installAspectFrames = (fn: FrameInstaller): void => { frameInstaller = fn }
|
|
69
|
+
|
|
70
|
+
const policyOf = (a: Updatable): { early: boolean; order: number } =>
|
|
71
|
+
({ early: a.updateBeforePhysics === true, order: a.order ?? 0 })
|
|
72
|
+
|
|
73
|
+
// Stable insert by ascending order: place after the last element whose order is <= this one's.
|
|
74
|
+
const addSorted = (list: Updatable[], a: Updatable, order: number): void => {
|
|
75
|
+
let i = list.length
|
|
76
|
+
while (i > 0 && list[i - 1].order > order) i--
|
|
77
|
+
list.splice(i, 0, a)
|
|
59
78
|
}
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
|
|
80
|
+
const runPhase = (list: Updatable[], dt: number): void => {
|
|
81
|
+
// iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
|
|
82
|
+
for (const a of list.slice()) a.update?.(dt)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ensureDispatch = (): void => {
|
|
86
|
+
if (dispatchInstalled) return
|
|
87
|
+
dispatchInstalled = true
|
|
88
|
+
const early: PhaseFn = (dt) => runPhase(earlyUpdaters, dt)
|
|
89
|
+
const late: PhaseFn = (dt) => runPhase(lateUpdaters, dt)
|
|
90
|
+
// render-synced source if the engine layer installed one; else one host loop (early then late).
|
|
91
|
+
if (!(frameInstaller && frameInstaller(early, late))) {
|
|
92
|
+
setLoop((dt) => { early(dt); late(dt) })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const registerUpdater = (a: Updatable): void => {
|
|
97
|
+
const { early, order } = policyOf(a)
|
|
98
|
+
addSorted(early ? earlyUpdaters : lateUpdaters, a, order)
|
|
99
|
+
ensureDispatch()
|
|
100
|
+
}
|
|
101
|
+
const unregisterUpdater = (a: Updatable): void => {
|
|
102
|
+
let i = earlyUpdaters.indexOf(a)
|
|
103
|
+
if (i >= 0) { earlyUpdaters.splice(i, 1); return }
|
|
104
|
+
i = lateUpdaters.indexOf(a)
|
|
105
|
+
if (i >= 0) lateUpdaters.splice(i, 1)
|
|
64
106
|
}
|
|
65
107
|
|
|
66
108
|
// ---- the host: mixed into every node kind (Node2D, Node) --------------------
|
|
@@ -142,10 +184,24 @@ export abstract class Aspect<K extends string, P = Node> {
|
|
|
142
184
|
declare protected readonly __key__?: K
|
|
143
185
|
onAttach?(): void
|
|
144
186
|
onDetach?(): void
|
|
145
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* Called every frame while attached (dt = seconds since the last frame). Render-synced: runs in the
|
|
189
|
+
* LATE phase by default — after the physics step + transform sync, right before the frame draws — so
|
|
190
|
+
* reads of `node.worldPosition` are the final drawn position (cameras/followers have no 1-frame lag).
|
|
191
|
+
* Set `updateBeforePhysics = true` (an instance field) to run in the EARLY phase (before the step)
|
|
192
|
+
* instead — for aspects that WRITE velocity/force/kinematic transforms and want the same frame's
|
|
193
|
+
* step to consume them. Cameras/followers READ, so they stay in the default LATE phase.
|
|
194
|
+
*/
|
|
146
195
|
update?(dt: number): void
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
*
|
|
196
|
+
|
|
197
|
+
/** Run update(dt) in the EARLY phase (before the physics step) rather than the default LATE phase.
|
|
198
|
+
* An instance field (like `updateWhenVisible`) — set it as a class field: `updateBeforePhysics = true`.
|
|
199
|
+
* Use only when you FEED the simulation (velocity/force/kinematic transform). Read once, at attach. */
|
|
200
|
+
updateBeforePhysics = false
|
|
201
|
+
/** Tick order within a phase — ascending; default 0, ties keep attach order. Read once, at attach. */
|
|
202
|
+
order = 0
|
|
203
|
+
|
|
204
|
+
/** Opt-in: only run update(dt) while the node is on-screen. NOOP for now — visibility culling isn't
|
|
205
|
+
* wired yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
|
|
150
206
|
updateWhenVisible = false
|
|
151
207
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Fine-grained reactivity for the retained-mode UI: signal() / computed() / effect() globals plus
|
|
2
|
+
// the element-binding helpers UINode/UIText use for function-valued style props and text.
|
|
3
|
+
//
|
|
4
|
+
// Design notes (the parts that are deliberate, not incidental):
|
|
5
|
+
// - Dependents (effects, computeds) hold their sources STRONGLY; sources hold dependents WEAKLY
|
|
6
|
+
// (a WeakRef in the subscriber set). An element owns its binding effects, the effect's closure
|
|
7
|
+
// owns the element — so when user code drops a subtree, the whole element+effect subgraph
|
|
8
|
+
// becomes unreachable and collects, and the signal prunes the dead WeakRef on its next write.
|
|
9
|
+
// No unmount hook is needed and re-appending a kept subtree keeps its bindings alive.
|
|
10
|
+
// - Free-standing effect() computations are additionally held in a strong root set (otherwise a
|
|
11
|
+
// `effect(() => ...)` whose handle the user discards would be GC'd mid-flight); dispose()
|
|
12
|
+
// releases them. Element bindings are NOT in the root set — the element is their owner.
|
|
13
|
+
// - Writes are batched: a signal write schedules a microtask flush; each dirty effect runs once
|
|
14
|
+
// per flush. The first run of any effect/binding is synchronous, so element styles hold
|
|
15
|
+
// concrete values immediately after construction (hosts serialize _style at mount).
|
|
16
|
+
// - QuickJS-safe: promise microtasks (the hosts drain pending jobs), WeakRef with a strong-ref
|
|
17
|
+
// fallback for engines without it (then stale subscribers just live until the run ends —
|
|
18
|
+
// every run executes in a fresh JSRuntime anyway).
|
|
19
|
+
|
|
20
|
+
interface WeakRefLike<T extends object> { deref(): T | undefined }
|
|
21
|
+
|
|
22
|
+
const WeakRefImpl: new <T extends object>(target: T) => WeakRefLike<T> =
|
|
23
|
+
typeof WeakRef !== "undefined" ? WeakRef : class StrongRef<T extends object> {
|
|
24
|
+
private _t: T
|
|
25
|
+
constructor(target: T) { this._t = target }
|
|
26
|
+
deref(): T | undefined { return this._t }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Computation {
|
|
30
|
+
_ref: WeakRefLike<Computation>
|
|
31
|
+
_deps: Dep[]
|
|
32
|
+
_disposed: boolean
|
|
33
|
+
_invalidate(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let activeComputation: Computation | null = null
|
|
37
|
+
|
|
38
|
+
/** Removes `c` from every source it subscribed to on its last run. */
|
|
39
|
+
const cleanupDeps = (c: Computation) => {
|
|
40
|
+
for (const dep of c._deps) dep._subs.delete(c._ref)
|
|
41
|
+
c._deps.length = 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class Dep {
|
|
45
|
+
_subs = new Set<WeakRefLike<Computation>>()
|
|
46
|
+
|
|
47
|
+
_track() {
|
|
48
|
+
const c = activeComputation
|
|
49
|
+
if (c === null || this._subs.has(c._ref)) return
|
|
50
|
+
this._subs.add(c._ref)
|
|
51
|
+
c._deps.push(this)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_notify() {
|
|
55
|
+
for (const ref of [ ...this._subs ]) {
|
|
56
|
+
const c = ref.deref()
|
|
57
|
+
if (c === undefined || c._disposed) {
|
|
58
|
+
this._subs.delete(ref) // lazy pruning of collected/disposed subscribers
|
|
59
|
+
} else {
|
|
60
|
+
c._invalidate()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- effect scheduling (microtask batch) ----
|
|
67
|
+
|
|
68
|
+
const queue = new Set<EffectImpl>()
|
|
69
|
+
let flushScheduled = false
|
|
70
|
+
|
|
71
|
+
const flush = () => {
|
|
72
|
+
// Effects may write signals, re-filling the queue mid-flush; drain until empty with a guard
|
|
73
|
+
// against write-loops (effect A invalidating itself / a cycle of effects).
|
|
74
|
+
let runs = 0
|
|
75
|
+
while (queue.size > 0) {
|
|
76
|
+
if (++runs > 10000) {
|
|
77
|
+
queue.clear()
|
|
78
|
+
console.warn("signals: effect flush exceeded 10000 runs — possible infinite update loop, remaining effects dropped")
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
const first: EffectImpl = queue.values().next().value!
|
|
82
|
+
queue.delete(first)
|
|
83
|
+
first._run()
|
|
84
|
+
}
|
|
85
|
+
flushScheduled = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const schedule = (fx: EffectImpl) => {
|
|
89
|
+
queue.add(fx)
|
|
90
|
+
if (!flushScheduled) {
|
|
91
|
+
flushScheduled = true
|
|
92
|
+
Promise.resolve().then(flush)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Runs every pending effect now instead of waiting for the microtask (test/host hook). */
|
|
97
|
+
export const flushEffects = (): void => {
|
|
98
|
+
if (queue.size > 0) flush()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class EffectImpl implements Computation {
|
|
102
|
+
_ref: WeakRefLike<Computation>
|
|
103
|
+
_deps: Dep[] = []
|
|
104
|
+
_disposed = false
|
|
105
|
+
private _fn: () => void
|
|
106
|
+
|
|
107
|
+
constructor(fn: () => void) {
|
|
108
|
+
this._fn = fn
|
|
109
|
+
this._ref = new WeakRefImpl(this)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_run() {
|
|
113
|
+
if (this._disposed) return
|
|
114
|
+
cleanupDeps(this)
|
|
115
|
+
const prev = activeComputation
|
|
116
|
+
activeComputation = this
|
|
117
|
+
try {
|
|
118
|
+
this._fn()
|
|
119
|
+
} finally {
|
|
120
|
+
activeComputation = prev
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_invalidate() {
|
|
125
|
+
schedule(this)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_dispose() {
|
|
129
|
+
if (this._disposed) return
|
|
130
|
+
this._disposed = true
|
|
131
|
+
cleanupDeps(this)
|
|
132
|
+
queue.delete(this)
|
|
133
|
+
rootEffects.delete(this)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Free-standing effect() handles the user may never store — kept alive here until disposed.
|
|
138
|
+
const rootEffects = new Set<EffectImpl>()
|
|
139
|
+
|
|
140
|
+
// ---- public surface ----
|
|
141
|
+
|
|
142
|
+
export interface Signal<T> {
|
|
143
|
+
value: T,
|
|
144
|
+
/** Read without subscribing the current effect/computed. */
|
|
145
|
+
peek(): T,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface Computed<T> {
|
|
149
|
+
readonly value: T,
|
|
150
|
+
/** Read without subscribing the current effect/computed (still recomputes if stale). */
|
|
151
|
+
peek(): T,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class SignalImpl<T> {
|
|
155
|
+
private _dep = new Dep()
|
|
156
|
+
private _v: T
|
|
157
|
+
|
|
158
|
+
constructor(value: T) {
|
|
159
|
+
this._v = value
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get value(): T {
|
|
163
|
+
this._dep._track()
|
|
164
|
+
return this._v
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
set value(next: T) {
|
|
168
|
+
if (Object.is(next, this._v)) return
|
|
169
|
+
this._v = next
|
|
170
|
+
this._dep._notify()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
peek(): T {
|
|
174
|
+
return this._v
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class ComputedImpl<T> implements Computation {
|
|
179
|
+
_ref: WeakRefLike<Computation>
|
|
180
|
+
_deps: Dep[] = []
|
|
181
|
+
_disposed = false
|
|
182
|
+
private _dep = new Dep()
|
|
183
|
+
private _fn: () => T
|
|
184
|
+
private _stale = true
|
|
185
|
+
private _v!: T
|
|
186
|
+
|
|
187
|
+
constructor(fn: () => T) {
|
|
188
|
+
this._fn = fn
|
|
189
|
+
this._ref = new WeakRefImpl(this)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
get value(): T {
|
|
193
|
+
this._dep._track()
|
|
194
|
+
if (this._stale) this._recompute()
|
|
195
|
+
return this._v
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
peek(): T {
|
|
199
|
+
if (this._stale) this._recompute()
|
|
200
|
+
return this._v
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private _recompute() {
|
|
204
|
+
cleanupDeps(this)
|
|
205
|
+
const prev = activeComputation
|
|
206
|
+
activeComputation = this
|
|
207
|
+
try {
|
|
208
|
+
this._v = this._fn()
|
|
209
|
+
this._stale = false
|
|
210
|
+
} finally {
|
|
211
|
+
activeComputation = prev
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Lazy: a dep change only marks the memo stale and passes the wave downstream; the next read
|
|
216
|
+
// recomputes.
|
|
217
|
+
_invalidate() {
|
|
218
|
+
if (this._stale) return
|
|
219
|
+
this._stale = true
|
|
220
|
+
this._dep._notify()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A reactive value. Reading `.value` inside an `effect`/`computed` (or a function-valued style
|
|
226
|
+
* prop / `UIText` content) subscribes it; writing `.value` re-runs subscribers (batched — once
|
|
227
|
+
* per microtask).
|
|
228
|
+
*/
|
|
229
|
+
export function signal<T>(initialValue: T): Signal<T>
|
|
230
|
+
export function signal<T = undefined>(): Signal<T | undefined>
|
|
231
|
+
export function signal<T>(initialValue?: T): Signal<T | undefined> {
|
|
232
|
+
return new SignalImpl(initialValue)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A lazily-cached derived value: recomputes on read after any of its dependencies changed. */
|
|
236
|
+
export function computed<T>(fn: () => T): Computed<T> {
|
|
237
|
+
return new ComputedImpl(fn)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Runs `fn` now and again whenever any signal/computed it read changes (batched per microtask).
|
|
242
|
+
* Returns a dispose function; an undisposed effect lives for the rest of the app run.
|
|
243
|
+
*/
|
|
244
|
+
export function effect(fn: () => void): () => void {
|
|
245
|
+
const fx = new EffectImpl(fn)
|
|
246
|
+
rootEffects.add(fx)
|
|
247
|
+
fx._run()
|
|
248
|
+
return () => fx._dispose()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---- element bindings (internal — used by UINode/UIText for function-valued props) ----
|
|
252
|
+
|
|
253
|
+
/** The shape UINode's Element exposes for binding ownership. */
|
|
254
|
+
export interface BindingHost {
|
|
255
|
+
/** @internal */ _fx?: Map<string, EffectImpl>
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @internal Creates (or replaces) the reactive binding stored under `key` on the element. The
|
|
260
|
+
* effect is owned by the element (strong ref in `_fx`), NOT by the root set — discard the element
|
|
261
|
+
* and the binding collects with it. Runs synchronously once.
|
|
262
|
+
*/
|
|
263
|
+
export function createBinding(el: BindingHost, key: string, run: () => void): void {
|
|
264
|
+
const map = el._fx ?? (el._fx = new Map())
|
|
265
|
+
map.get(key)?._dispose()
|
|
266
|
+
const fx = new EffectImpl(run)
|
|
267
|
+
map.set(key, fx)
|
|
268
|
+
fx._run()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @internal Disposes the binding stored under `key`, if any (static value overriding a binding). */
|
|
272
|
+
export function disposeBinding(el: BindingHost, key: string): void {
|
|
273
|
+
const fx = el._fx?.get(key)
|
|
274
|
+
if (fx === undefined) return
|
|
275
|
+
fx._dispose()
|
|
276
|
+
el._fx!.delete(key)
|
|
277
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// wall/floor collision response; a short downward ray reports `grounded`.
|
|
4
4
|
//
|
|
5
5
|
// const hero = new Sprite({ texture, anchor: [0.5, 1] })
|
|
6
|
-
// .aspect(
|
|
6
|
+
// .aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } })
|
|
7
|
+
// .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
|
|
7
8
|
// .aspect(CharacterController2D, { speed: 200, jumpSpeed: 520 })
|
|
8
9
|
// Input.on('left', d => hero.controller.move(d ? -1 : 0))
|
|
9
10
|
// Input.on('jump', () => hero.controller.jump())
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
import { Aspect } from "../core/Aspect"
|
|
16
17
|
import { Vec2 } from "../math/vec"
|
|
17
18
|
import { Physics2D } from "./Physics2D"
|
|
18
|
-
import {
|
|
19
|
+
import { Shape2D } from "./Shape2D"
|
|
19
20
|
import type { Node2D } from "./Node2D"
|
|
20
21
|
|
|
21
22
|
export class CharacterController2D extends Aspect<"controller", Node2D> {
|
|
@@ -44,8 +45,8 @@ export class CharacterController2D extends Aspect<"controller", Node2D> {
|
|
|
44
45
|
private get phys(): Physics2D | undefined { return this.node.get(Physics2D) }
|
|
45
46
|
|
|
46
47
|
onAttach(): void {
|
|
47
|
-
// Ensure a
|
|
48
|
-
if (!this.node.has(
|
|
48
|
+
// Ensure a Shape2D (auto box from the sprite) + a dynamic, fixed-rotation body if not already set up.
|
|
49
|
+
if (!this.node.has(Shape2D)) this.node.aspect(Shape2D, {})
|
|
49
50
|
if (!this.node.has(Physics2D)) this.node.aspect(Physics2D, { motion: "dynamic", fixedRotation: true })
|
|
50
51
|
}
|
|
51
52
|
|
package/runtime/sdk/g2/Node2D.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// under physics without any per-frame bookkeeping.
|
|
8
8
|
//
|
|
9
9
|
// Empty Node2Ds are useful as: grouping parents, world-space markers/spawn points, and physics /
|
|
10
|
-
// trigger objects — attach a
|
|
10
|
+
// trigger objects — attach a Shape2D (geometry) plus a Physics2D (rigid body) or Trigger2D (sensor).
|
|
11
11
|
|
|
12
12
|
import { AspectHost } from "../core/Aspect"
|
|
13
13
|
import { Registry } from "../core/registry"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// A rigid body, as an aspect on a Node2D, backed by Box2D v3 in creator-2d. Mirrors the 3D Physics
|
|
2
|
-
// aspect. Requires a
|
|
2
|
+
// aspect. Requires a Shape2D aspect on the same node (its geometry becomes the body's solid fixture).
|
|
3
3
|
//
|
|
4
4
|
// Physics2D.configure({ gravity: [0, -980] }) // once, before creating bodies
|
|
5
|
-
// ground.aspect(
|
|
6
|
-
// hero.aspect(
|
|
5
|
+
// ground.aspect(Shape2D, { segment: { from: [-500,0], to: [500,0] } }).aspect(Physics2D, { motion: 'static' })
|
|
6
|
+
// hero.aspect(Shape2D, { box: [12, 20] }).aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
|
|
7
7
|
// hero.physics.applyImpulse([0, 400]) // jump
|
|
8
8
|
// hero.addEventListener('enter', other => …) // contact / sensor overlap began
|
|
9
9
|
//
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { Aspect } from "../core/Aspect"
|
|
16
16
|
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
|
|
17
17
|
import { node2dRegistry, type Node2D } from "./Node2D"
|
|
18
|
-
import {
|
|
18
|
+
import { Shape2D } from "./Shape2D"
|
|
19
19
|
|
|
20
20
|
export type MotionType = "static" | "kinematic" | "dynamic"
|
|
21
21
|
const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
|
|
@@ -50,20 +50,20 @@ export class Physics2D extends Aspect<"physics", Node2D> {
|
|
|
50
50
|
restitution = 0
|
|
51
51
|
/** Lock rotation (essential for platformer characters). */
|
|
52
52
|
fixedRotation = false
|
|
53
|
-
gravityScale = 1
|
|
54
53
|
/** Continuous collision for fast-moving bodies (projectiles). */
|
|
55
54
|
bullet = false
|
|
56
55
|
|
|
57
|
-
private
|
|
56
|
+
private _gravityScale = 1
|
|
57
|
+
private _h = 0 // native body id (0 = none / no physics support)
|
|
58
58
|
|
|
59
|
-
/** Native body
|
|
60
|
-
get
|
|
59
|
+
/** Native physics body id. 0 until attached, or if the build has no physics support. Mirrors 3D `Physics.id`. */
|
|
60
|
+
get id(): number { return this._h }
|
|
61
61
|
|
|
62
62
|
onAttach(): void {
|
|
63
63
|
if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
|
|
64
|
-
const shape = this.node.get(
|
|
64
|
+
const shape = this.node.get(Shape2D)
|
|
65
65
|
if (!shape) {
|
|
66
|
-
throw new Error("Physics2D requires a
|
|
66
|
+
throw new Error("Physics2D requires a Shape2D aspect — add it first: node.aspect(Shape2D, {…}).aspect(Physics2D, {…})")
|
|
67
67
|
}
|
|
68
68
|
this._h = _creator2d.physicsCreateBody(this.node.id, MOTION[this.motion])
|
|
69
69
|
if (!this._h) return
|
|
@@ -72,7 +72,7 @@ export class Physics2D extends Aspect<"physics", Node2D> {
|
|
|
72
72
|
// cache from native on read. Static bodies never move, so they keep the free cache.
|
|
73
73
|
if (this.motion !== "static") this.node._physicsDriven = true
|
|
74
74
|
if (this.fixedRotation) _creator2d.physicsSetFixedRotation(this._h, true)
|
|
75
|
-
if (this.
|
|
75
|
+
if (this._gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this._gravityScale)
|
|
76
76
|
if (this.bullet) _creator2d.physicsSetBullet(this._h, true)
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -91,7 +91,10 @@ export class Physics2D extends Aspect<"physics", Node2D> {
|
|
|
91
91
|
/** Angular velocity in degrees/second. */
|
|
92
92
|
set angularVelocity(degPerSec: number) { if (this._h) _creator2d.physicsSetAngularVelocity(this._h, degPerSec) }
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
/** Per-body gravity multiplier: 1 = full world gravity, 0 = floats, <0 = repelled. Set at attach
|
|
95
|
+
* time (as an option) or live. */
|
|
96
|
+
get gravityScale(): number { return this._gravityScale }
|
|
97
|
+
set gravityScale(scale: number) { this._gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }
|
|
95
98
|
set linearDamping(d: number) { if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }
|
|
96
99
|
set enabled(v: boolean) { if (this._h) _creator2d.physicsSetEnabled(this._h, v) }
|
|
97
100
|
|
|
@@ -112,6 +115,13 @@ export class Physics2D extends Aspect<"physics", Node2D> {
|
|
|
112
115
|
/** Whether this build has physics support (CREATOR_2D_PHYSICS). */
|
|
113
116
|
static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }
|
|
114
117
|
|
|
118
|
+
/** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
|
|
119
|
+
* off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
|
|
120
|
+
* smoothness — bodies then advance in discrete steps. */
|
|
121
|
+
private static _interpolation = true
|
|
122
|
+
static get interpolation(): boolean { return Physics2D._interpolation }
|
|
123
|
+
static set interpolation(on: boolean) { Physics2D._interpolation = on; _creator2d.setInterpolation(on) }
|
|
124
|
+
|
|
115
125
|
/** Closest body hit by the segment from→to, or null. `node` resolves the hit entity. */
|
|
116
126
|
static raycast(from: Vec2Like, to: Vec2Like): RayHit | null {
|
|
117
127
|
const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to))
|