lecodes-cli 0.3.0 → 0.5.1
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 +889 -507
- package/package.json +9 -4
- package/runtime/sdk/canvas/Canvas.ts +105 -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 +96 -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 +2 -2
- 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 +12 -0
- package/runtime/sdk/g2/{Shape.ts → Shape2D.ts} +10 -10
- package/runtime/sdk/g2/Sprite.ts +7 -2
- package/runtime/sdk/g2/Texture2D.ts +4 -2
- package/runtime/sdk/g2/{Trigger.ts → Trigger2D.ts} +9 -9
- package/runtime/sdk/gl/CharacterController.ts +101 -0
- 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 +2 -2
- package/runtime/sdk/gl/Trigger.ts +46 -0
- package/runtime/sdk/gl/physicsEvents.ts +20 -0
- package/runtime/sdk/inject.ts +12 -5
- 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 +5 -0
- package/runtime/sdk/ui/UIInput.ts +10 -0
- package/runtime/sdk/ui/UINode.ts +206 -8
- package/runtime/sdk/ui/UIScreen.ts +10 -5
- 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/canvas/Label.ts +0 -57
- package/runtime/sdk/gl/Collider.ts +0 -29
|
@@ -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))
|
|
@@ -28,6 +28,12 @@ const installRenderSyncedFrames = (): void => {
|
|
|
28
28
|
export type Scene2DOptions = {
|
|
29
29
|
/** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
|
|
30
30
|
background?: ColorInput
|
|
31
|
+
/** Texture sampling for the 2D renderer: `'nearest'` (default — crisp pixel art) or `'linear'`
|
|
32
|
+
* (smooth — for hi-res / non-pixel-art assets). Applied when the scene is opened, so switching
|
|
33
|
+
* scenes restores the active scene's choice. NOTE: this is a GLOBAL renderer setting (one sampler
|
|
34
|
+
* for ALL textures, not per-texture), so the currently-open scene's filter wins. Omit to leave the
|
|
35
|
+
* current setting (engine default is nearest). */
|
|
36
|
+
filter?: 'nearest' | 'linear'
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
|
|
@@ -54,10 +60,14 @@ export class Scene2D {
|
|
|
54
60
|
readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
|
|
55
61
|
readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
|
|
56
62
|
|
|
63
|
+
/** Texture filter to (re)assert on open(); the sampler is global, so each scene reclaims it. */
|
|
64
|
+
private readonly _filter?: 'nearest' | 'linear'
|
|
65
|
+
|
|
57
66
|
constructor(options: Scene2DOptions = {}) {
|
|
58
67
|
installRenderSyncedFrames()
|
|
59
68
|
this.id = _creator2d.createScene()
|
|
60
69
|
this.camera = new Camera2D(this.id)
|
|
70
|
+
this._filter = options.filter
|
|
61
71
|
if (options.background !== undefined) this.background = options.background
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -103,6 +113,8 @@ export class Scene2D {
|
|
|
103
113
|
|
|
104
114
|
/** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
|
|
105
115
|
open(): this {
|
|
116
|
+
// Reassert this scene's texture filter (the sampler is global — see Scene2DOptions.filter).
|
|
117
|
+
if (this._filter !== undefined) _creator2d.setDefaultFilter(this._filter === 'linear')
|
|
106
118
|
_creator2d.openScene(this.id)
|
|
107
119
|
Scene2D._active = this
|
|
108
120
|
return this
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a
|
|
2
|
-
// make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a
|
|
3
|
-
// of which require a
|
|
4
|
-
// picking hits, so a node is clickable once it has a
|
|
1
|
+
// A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a Shape2D does not by itself
|
|
2
|
+
// make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a Trigger2D (sensor), both
|
|
3
|
+
// of which require a Shape2D and turn its geometry into a Box2D fixture. The shape is also what pointer
|
|
4
|
+
// picking hits, so a node is clickable once it has a Shape2D + Physics2D/Trigger2D (a body to query).
|
|
5
5
|
//
|
|
6
|
-
// wall.aspect(
|
|
7
|
-
// goal.aspect(
|
|
8
|
-
// hero.aspect(
|
|
6
|
+
// wall.aspect(Shape2D, { box: [200, 16] }).aspect(Physics2D, { motion: 'static' })
|
|
7
|
+
// goal.aspect(Shape2D, { circle: 20 }).aspect(Trigger2D)
|
|
8
|
+
// hero.aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
|
|
9
9
|
//
|
|
10
|
-
// With no geometry set,
|
|
10
|
+
// With no geometry set, Shape2D derives a box from the node's sprite size ("auto").
|
|
11
11
|
|
|
12
12
|
import { Aspect } from "../core/Aspect"
|
|
13
13
|
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
|
|
14
14
|
import type { Node2D } from "./Node2D"
|
|
15
15
|
|
|
16
|
-
export class
|
|
16
|
+
export class Shape2D extends Aspect<"shape", Node2D> {
|
|
17
17
|
static readonly aspect = "shape"
|
|
18
18
|
|
|
19
19
|
/** Box half-extents [hw, hh] (world units). */
|
|
@@ -30,7 +30,7 @@ export class Shape extends Aspect<"shape", Node2D> {
|
|
|
30
30
|
offset?: Vec2Like
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/
|
|
33
|
+
* @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/Trigger2D).
|
|
34
34
|
* `sensor` selects a sensor (trigger) vs solid fixture; density/friction/restitution are the
|
|
35
35
|
* material (ignored for sensors / segments only use friction+restitution).
|
|
36
36
|
*/
|
package/runtime/sdk/g2/Sprite.ts
CHANGED
|
@@ -52,11 +52,16 @@ export class Sprite extends Node2D {
|
|
|
52
52
|
const tex = isCanvas(t) ? t.texture() : t // a Canvas bakes to a texture on assign
|
|
53
53
|
this._texture = tex
|
|
54
54
|
if (!tex) return
|
|
55
|
-
_creator2d.setSprite(this.id, tex.id) // native resets UV + size to the full texture
|
|
55
|
+
_creator2d.setSprite(this.id, tex.id) // native resets UV + size to the full (pixel) texture
|
|
56
|
+
// A Canvas is authored in LOGICAL units but baked at logical × pixelRatio, so default the sprite's
|
|
57
|
+
// WORLD size to the canvas's LOGICAL size — pixelRatio then only affects crispness, not on-screen
|
|
58
|
+
// size. (A plain texture keeps the native default: the texture's pixel dimensions.)
|
|
59
|
+
if (!this._size && isCanvas(t)) this._size = new Vec2(t.width, t.height)
|
|
56
60
|
if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
/** Current world size: explicit override if set, else the texture's pixel dimensions
|
|
63
|
+
/** Current world size: explicit override if set, else the texture's pixel dimensions (a Canvas-backed
|
|
64
|
+
* sprite defaults to the canvas's LOGICAL size, so pixelRatio is crispness only, not size). */
|
|
60
65
|
get size(): Vec2 { return this._size ?? (this._texture ? new Vec2(this._texture.width, this._texture.height) : new Vec2(0, 0)) }
|
|
61
66
|
set size(v: Vec2Like) { this._size = new Vec2(v); _creator2d.setSpriteSize(this.id, this._size.x, this._size.y) }
|
|
62
67
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// Internal SDK code imports injected globals explicitly (the bare-global inject is only for USER
|
|
5
5
|
// code; here `fetch` would otherwise resolve to the node/bun ambient one).
|
|
6
|
-
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
6
|
+
import { fetch, type FetchResponse, type File } from "../runtime/fetch"
|
|
7
7
|
|
|
8
8
|
export class Texture2D {
|
|
9
9
|
/** Native texture handle. */
|
|
@@ -25,7 +25,9 @@ export class Texture2D {
|
|
|
25
25
|
return new Texture2D(texId, Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio))
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Accepts a URL, an already-fetched response, or a File (e.g. from Canvas.toFile() / openFilePicker) —
|
|
29
|
+
// anything backed by a host buffer. The buffer's encoded bytes are decoded + uploaded by the engine.
|
|
30
|
+
static load(source: string | FetchResponse | File): Promise<Texture2D> {
|
|
29
31
|
if (typeof source === "string") {
|
|
30
32
|
return fetch(source, { useOnce: true }).then((resp) => {
|
|
31
33
|
if (resp.status >= 400) {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// A trigger zone, as an aspect on a Node2D. Requires a
|
|
1
|
+
// A trigger zone, as an aspect on a Node2D. Requires a Shape2D (its geometry); creates a static SENSOR
|
|
2
2
|
// body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
|
|
3
3
|
// instead of colliding. A trigger does not block movement.
|
|
4
4
|
//
|
|
5
5
|
// const goal = new Node2D({ ... })
|
|
6
|
-
// .aspect(
|
|
7
|
-
// .aspect(
|
|
6
|
+
// .aspect(Shape2D, { box: [32, 64] })
|
|
7
|
+
// .aspect(Trigger2D)
|
|
8
8
|
// goal.addEventListener('enter', other => win(other))
|
|
9
9
|
// goal.addEventListener('exit', other => ...)
|
|
10
10
|
//
|
|
@@ -13,21 +13,21 @@
|
|
|
13
13
|
import { Aspect } from "../core/Aspect"
|
|
14
14
|
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
15
15
|
import { ensurePhysicsEvents } from "./loop"
|
|
16
|
-
import {
|
|
16
|
+
import { Shape2D } from "./Shape2D"
|
|
17
17
|
import type { Node2D } from "./Node2D"
|
|
18
18
|
|
|
19
|
-
export class
|
|
19
|
+
export class Trigger2D extends Aspect<"trigger", Node2D> {
|
|
20
20
|
static readonly aspect = "trigger"
|
|
21
21
|
|
|
22
22
|
private _h = 0
|
|
23
|
-
/** Native body
|
|
24
|
-
get
|
|
23
|
+
/** Native physics body id (0 if no physics support). Mirrors 3D `Trigger.id`. */
|
|
24
|
+
get id(): number { return this._h }
|
|
25
25
|
|
|
26
26
|
onAttach(): void {
|
|
27
27
|
if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
|
|
28
|
-
const shape = this.node.get(
|
|
28
|
+
const shape = this.node.get(Shape2D)
|
|
29
29
|
if (!shape) {
|
|
30
|
-
throw new Error("
|
|
30
|
+
throw new Error("Trigger2D requires a Shape2D aspect — add it first: node.aspect(Shape2D, {…}).aspect(Trigger2D)")
|
|
31
31
|
}
|
|
32
32
|
this._h = _creator2d.physicsCreateBody(this.node.id, 0 /* static */)
|
|
33
33
|
if (!this._h) return
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A character controller, as an aspect on a 3D Node, backed by Jolt's CharacterVirtual — a kinematic
|
|
2
|
+
// collide-and-slide controller (NOT a rigid body: no bouncing/tipping, crisp control, built-in stair
|
|
3
|
+
// stepping + slope handling). Requires a Shape (a capsule is recommended) and does NOT use Physics.
|
|
4
|
+
//
|
|
5
|
+
// const hero = new Mesh(capsuleGeometry(), material)
|
|
6
|
+
// .aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
|
|
7
|
+
// .aspect(CharacterController, { speed: 6, jumpSpeed: 8 })
|
|
8
|
+
// Input.on('move', (x, z) => hero.controller.move(x, z))
|
|
9
|
+
// Input.on('jump', () => hero.controller.jump())
|
|
10
|
+
//
|
|
11
|
+
// You drive horizontal intent + jump; the controller integrates gravity and Jolt handles collision
|
|
12
|
+
// against the world. `grounded` reports whether it's on walkable ground. It runs in the EARLY update
|
|
13
|
+
// phase (feeds this frame's step). Note: it collides with solid bodies but PASSES THROUGH triggers,
|
|
14
|
+
// and (v1) is not itself detected by triggers and isn't pointer-pickable while active.
|
|
15
|
+
|
|
16
|
+
import { Aspect } from "../core/Aspect"
|
|
17
|
+
import { Vec3 } from "../math/vec"
|
|
18
|
+
import { Shape } from "./Shape"
|
|
19
|
+
import type { Node } from "./Node"
|
|
20
|
+
|
|
21
|
+
const clamp1 = (v: number): number => (v < -1 ? -1 : v > 1 ? 1 : v)
|
|
22
|
+
|
|
23
|
+
export class CharacterController extends Aspect<"controller", Node> {
|
|
24
|
+
static readonly aspect = "controller"
|
|
25
|
+
|
|
26
|
+
/** Horizontal move speed (world units/s). */
|
|
27
|
+
speed = 5
|
|
28
|
+
/** Jump take-off speed (world units/s). */
|
|
29
|
+
jumpSpeed = 7
|
|
30
|
+
/** Character-tuned gravity (world units/s²), separate from the world gravity — usually stronger for
|
|
31
|
+
* snappier feel. Applied to the vertical velocity each frame. */
|
|
32
|
+
gravity = -20
|
|
33
|
+
/** Max ground slope (degrees) the character treats as walkable. Set before attach. */
|
|
34
|
+
maxSlope = 45
|
|
35
|
+
|
|
36
|
+
updateBeforePhysics = true // EARLY phase — set velocity so THIS frame's step consumes it
|
|
37
|
+
|
|
38
|
+
private _charId = 0
|
|
39
|
+
private _mx = 0
|
|
40
|
+
private _mz = 0
|
|
41
|
+
private _jumpQueued = false
|
|
42
|
+
private _vy = 0
|
|
43
|
+
|
|
44
|
+
onAttach(): void {
|
|
45
|
+
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
46
|
+
const shape = this.node.get(Shape)
|
|
47
|
+
if (!shape) {
|
|
48
|
+
throw new Error("CharacterController requires a Shape aspect (a capsule is recommended) — add it first: node.aspect(Shape, { capsule: {…} }).aspect(CharacterController)")
|
|
49
|
+
}
|
|
50
|
+
const shapeId = shape._claim() // the character owns the shape; it is not a rigid body
|
|
51
|
+
this._charId = _creator.characterCreate(this.node.id, shapeId, this.maxSlope)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onDetach(): void {
|
|
55
|
+
if (this._charId) { _creator.characterDestroy(this._charId); this._charId = 0 }
|
|
56
|
+
this.node.get(Shape)?._recreatePickBody()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Native character id (0 until attached / no physics support). */
|
|
60
|
+
get id(): number { return this._charId }
|
|
61
|
+
|
|
62
|
+
/** Horizontal move intent; each component in [-1, 1] (e.g. from input). Sticky — set 0 to stop. */
|
|
63
|
+
move(x: number, z: number): void { this._mx = clamp1(x); this._mz = clamp1(z) }
|
|
64
|
+
|
|
65
|
+
/** Queue a jump; consumed on the next frame if grounded. */
|
|
66
|
+
jump(): void { this._jumpQueued = true }
|
|
67
|
+
|
|
68
|
+
/** True while standing on walkable ground (OnGround). */
|
|
69
|
+
get grounded(): boolean {
|
|
70
|
+
return this._charId ? _creator.characterGetGroundState(this._charId) === 0 : false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Ground state: 0 on-ground, 1 on-steep-slope, 2 touching-but-unsupported, 3 in-air. */
|
|
74
|
+
get groundState(): number {
|
|
75
|
+
return this._charId ? _creator.characterGetGroundState(this._charId) : 3
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Current velocity (world units/s, fresh Vec3). */
|
|
79
|
+
get velocity(): Vec3 {
|
|
80
|
+
const o = new Float32Array(3)
|
|
81
|
+
if (this._charId) _creator.characterGetVelocity(this._charId, o)
|
|
82
|
+
return new Vec3(o[0], o[1], o[2])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Teleport to a world position (clears vertical velocity). */
|
|
86
|
+
teleport(x: number, y: number, z: number): this {
|
|
87
|
+
if (this._charId) _creator.characterSetPosition(this._charId, x, y, z)
|
|
88
|
+
this._vy = 0
|
|
89
|
+
return this
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
update(dt: number): void {
|
|
93
|
+
if (!this._charId) return
|
|
94
|
+
const grounded = _creator.characterGetGroundState(this._charId) === 0
|
|
95
|
+
if (grounded && this._vy < 0) this._vy = 0 // landed
|
|
96
|
+
if (this._jumpQueued && grounded) this._vy = this.jumpSpeed
|
|
97
|
+
this._jumpQueued = false
|
|
98
|
+
this._vy += this.gravity * dt // integrate gravity (CharacterVirtual is kinematic)
|
|
99
|
+
_creator.characterSetVelocity(this._charId, this._mx * this.speed, this._vy, this._mz * this.speed)
|
|
100
|
+
}
|
|
101
|
+
}
|