lecodes-cli 0.1.7 → 0.3.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.
@@ -0,0 +1,207 @@
1
+ // The Aspect system: attachable capabilities (animation, collider, physics, and user behaviors)
2
+ // that hang off a node WITHOUT bloating the node class. Composition with the terseness of methods.
3
+ //
4
+ // const hero = new Sprite({ texture })
5
+ // .aspect(SpriteAnimation, { fps: 8, clips: { walk: [1, 2, 3] } }) // attach + configure
6
+ // .aspect(Collider, { box: [16, 8], trigger: true }) // chains: returns the node
7
+ // hero.anim.play('walk') // access by name (the chain result is typed as having it)
8
+ // if (hero.has(Physics)) hero.physics.velocity = [0, 0] // has() is a type guard
9
+ //
10
+ // A user aspect declares its name + target node in the generic and reads `this.node`:
11
+ // class Health extends Aspect<'health', Sprite> { hp = 100; hurt(n: number) { this.hp -= n } }
12
+ // sprite.aspect(Health, { hp: 80 }).health.hurt(10)
13
+ //
14
+ // The accessor name lives in the generic (Aspect<'name', Node>). The `chisel` bundler extracts it at
15
+ // compile time and injects the runtime `static aspect` on user aspects + the virtual type accessor;
16
+ // the SDK's own built-in aspects declare `static readonly aspect` directly (see SpriteAnimation etc.).
17
+
18
+ import { Emitter, type EventMap } from "./events"
19
+ import type { Node } from "../gl/Node"
20
+
21
+ // ---- type helpers -----------------------------------------------------------
22
+ /** The class object of an aspect — accepted even when its constructor is protected/abstract. */
23
+ export type AspectCtor<A extends Aspect<any, any>> = abstract new () => A
24
+ /** The accessor name carried in the aspect's generic. */
25
+ export type KeyOf<A> = A extends Aspect<infer K, any> ? K : never
26
+ /** The node kind an aspect targets. */
27
+ export type TargetOf<A> = A extends Aspect<any, infer P> ? P : never
28
+ /** `{ name: Aspect }` — the field an aspect contributes to its node. */
29
+ export type FieldOf<A extends Aspect<any, any>> = { [P in KeyOf<A>]: A }
30
+
31
+ type UnionToIntersection<U> =
32
+ (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
33
+
34
+ /**
35
+ * A variable typed as a node KNOWN to carry the given aspect(s). Union, not a tuple — reads as
36
+ * English and mirrors the runtime guard `node.has(Health)`:
37
+ * let boss: With<Sprite, Health | Physics>
38
+ */
39
+ export type With<N, A extends Aspect<any, any>> =
40
+ N & UnionToIntersection<A extends any ? FieldOf<A> : never>
41
+
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 && policyOf(list[i - 1]).order > order) i--
77
+ list.splice(i, 0, a)
78
+ }
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)
106
+ }
107
+
108
+ // ---- the host: mixed into every node kind (Node2D, Node) --------------------
109
+ /**
110
+ * Base for anything that can carry aspects. Extends Emitter, so node kinds get events too. Provides
111
+ * the four verbs; the named accessor (node.physics, node.health, …) is set as an own property at
112
+ * attach time, keyed by the aspect class's runtime `aspect` name.
113
+ */
114
+ export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<E> {
115
+ /** @internal class → instance, the authoritative store (named accessors mirror this). */
116
+ readonly _aspects = new Map<Function, Aspect<any, any>>()
117
+
118
+ /** Attach (and configure) an aspect, or reconfigure it if already present. Returns the node typed
119
+ * as now-having that aspect (so no guard/`?.` is needed afterwards). Rejects a wrong target node. */
120
+ aspect<Self extends TargetOf<A>, A extends Aspect<any, any>>(
121
+ this: Self,
122
+ ctor: AspectCtor<A>,
123
+ opts?: Partial<A>,
124
+ ): Self & FieldOf<A> {
125
+ const host = this as unknown as AspectHost
126
+ let inst = host._aspects.get(ctor) as A | undefined
127
+ const fresh = inst === undefined
128
+ if (!inst) {
129
+ inst = new (ctor as unknown as new () => A)()
130
+ ;(inst as { node: unknown }).node = this
131
+ host._aspects.set(ctor, inst)
132
+ const name = (ctor as { aspect?: string }).aspect
133
+ if (name) (host as unknown as Record<string, unknown>)[name] = inst
134
+ }
135
+ if (opts) Object.assign(inst, opts)
136
+ if (fresh) {
137
+ inst.onAttach?.()
138
+ if (typeof inst.update === "function") registerUpdater(inst)
139
+ }
140
+ return this as Self & FieldOf<A>
141
+ }
142
+
143
+ /** Safe access — undefined if the aspect isn't attached. */
144
+ get<A extends Aspect<any, any>>(ctor: AspectCtor<A>): A | undefined {
145
+ return this._aspects.get(ctor) as A | undefined
146
+ }
147
+
148
+ /** Existence check AND type guard: inside `if (node.has(Physics))`, `node.physics` is present. */
149
+ has<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
150
+ return this._aspects.has(ctor)
151
+ }
152
+
153
+ /** Detach an aspect (runs its onDetach). Named `removeAspect` so it never collides with the
154
+ * child-management `add`/`remove` semantics some node kinds expose. */
155
+ removeAspect<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this {
156
+ const inst = this._aspects.get(ctor)
157
+ if (inst) {
158
+ inst.onDetach?.()
159
+ unregisterUpdater(inst)
160
+ this._aspects.delete(ctor)
161
+ const name = (ctor as { aspect?: string }).aspect
162
+ if (name) delete (this as unknown as Record<string, unknown>)[name]
163
+ }
164
+ return this
165
+ }
166
+ }
167
+
168
+ // ---- the aspect base --------------------------------------------------------
169
+ /**
170
+ * Base for everything you attach. `K` = accessor name (extracted by chisel for the runtime). `P` =
171
+ * the target node kind: `this.node` is typed to it, AND attaching to a wrong node is a compile error.
172
+ * Initialize in `onAttach` (the node is set by then) — not a constructor; aspects are created by the
173
+ * engine via `node.aspect()`, never `new`.
174
+ */
175
+ export abstract class Aspect<K extends string, P = Node> {
176
+ // NOTE: P has no `extends AspectHost` constraint on purpose — Emitter<E>'s generic
177
+ // addEventListener makes a specific-event node (AspectHost<NodeEvents>) not assignable to
178
+ // AspectHost<any>, which would reject every real node. P only types `this.node`; target
179
+ // enforcement happens in aspect() via `this: Self extends TargetOf<A>`. Defaults to Node
180
+ // (3D is the default engine) — a 2D aspect declares its own P, e.g. Aspect<'x', Sprite>.
181
+ /** The node this aspect is attached to (set at attach time). */
182
+ readonly node!: P
183
+ /** @internal phantom — lets the type system recover the accessor name `K`. Never read at runtime. */
184
+ declare protected readonly __key__?: K
185
+ onAttach?(): void
186
+ onDetach?(): void
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
+ */
195
+ update?(dt: number): void
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. */
206
+ updateWhenVisible = false
207
+ }
@@ -1,16 +1,8 @@
1
- // Orthographic camera for a Scene2D. Position/zoom/rotation live natively; `follow` adds a
2
- // render-synced per-frame update that tracks a node.
1
+ // Orthographic camera for a Scene2D. Position / zoom / rotation live natively. To make the camera
2
+ // track a node, attach a small update(dt) aspect that eases camera.position toward the node — there's
3
+ // no built-in follow() (and so nothing to dispose).
3
4
 
4
5
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
5
- import type { Node2D } from "./Node2D"
6
- import { offUpdate, onUpdate } from "./loop"
7
-
8
- export type FollowOptions = {
9
- /** World-space offset added to the target's position. */
10
- offset?: Vec2Like
11
- /** 0 = snap (default). >0 = exponential smoothing time constant in seconds (smaller = snappier). */
12
- smooth?: number
13
- }
14
6
 
15
7
  export class Camera2D {
16
8
  private _x = 0
@@ -18,10 +10,6 @@ export class Camera2D {
18
10
  private _zoom = 1
19
11
  private _rotation = 0
20
12
 
21
- private _followTarget: Node2D | null = null
22
- private _followOpts: FollowOptions = {}
23
- private _followCb: ((dt: number) => void) | null = null
24
-
25
13
  private readonly sceneId: number
26
14
  constructor(sceneId: number) { this.sceneId = sceneId }
27
15
 
@@ -42,32 +30,4 @@ export class Camera2D {
42
30
  const o = _creator2d.cameraWorldToScreen(this.sceneId, worldX, worldY)
43
31
  return new Vec2(o[0], o[1])
44
32
  }
45
-
46
- /** Track a node each frame (snaps by default; pass `smooth` for eased follow). */
47
- follow(node: Node2D, options: FollowOptions = {}): this {
48
- this._followTarget = node
49
- this._followOpts = options
50
- if (this._followCb) return this
51
- this._followCb = (dt: number) => {
52
- const t = this._followTarget
53
- if (!t) return
54
- const [ ox, oy ] = this._followOpts.offset ?? [ 0, 0 ]
55
- const tx = t.x + ox
56
- const ty = t.y + oy
57
- const s = this._followOpts.smooth ?? 0
58
- if (s > 0) {
59
- const k = 1 - Math.exp(-dt / s)
60
- this.position = [ this._x + (tx - this._x) * k, this._y + (ty - this._y) * k ]
61
- } else {
62
- this.position = [ tx, ty ]
63
- }
64
- }
65
- onUpdate(this._followCb)
66
- return this
67
- }
68
-
69
- unfollow(): void {
70
- this._followTarget = null
71
- if (this._followCb) { offUpdate(this._followCb); this._followCb = null }
72
- }
73
33
  }
@@ -0,0 +1,75 @@
1
+ // A platformer-friendly character controller, as an aspect on a Node2D. Built on a fixed-rotation
2
+ // dynamic Physics2D body: you drive horizontal intent + jump, and Box2D handles gravity, falling, and
3
+ // wall/floor collision response; a short downward ray reports `grounded`.
4
+ //
5
+ // const hero = new Sprite({ texture, anchor: [0.5, 1] })
6
+ // .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true, shape: { type: 'capsule', from:[0,6], to:[0,26], radius:6 } })
7
+ // .aspect(CharacterController2D, { speed: 200, jumpSpeed: 520 })
8
+ // Input.on('left', d => hero.controller.move(d ? -1 : 0))
9
+ // Input.on('jump', () => hero.controller.jump())
10
+ //
11
+ // Velocity-control (set velocity each frame) is the standard, robust approach for platformers and
12
+ // reuses the physics solver. For pixel-tight movement without any solver bounce/seam quirks, a
13
+ // kinematic move-and-slide controller can be added later (see creator-2d PLAN.md, Phase G).
14
+
15
+ import { Aspect } from "../core/Aspect"
16
+ import { Vec2 } from "../math/vec"
17
+ import { Physics2D } from "./Physics2D"
18
+ import { Shape } from "./Shape"
19
+ import type { Node2D } from "./Node2D"
20
+
21
+ export class CharacterController2D extends Aspect<"controller", Node2D> {
22
+ static readonly aspect = "controller"
23
+
24
+ /** Horizontal move speed (world units/s). */
25
+ speed = 200
26
+ /** Jump take-off speed (world units/s). */
27
+ jumpSpeed = 500
28
+ /** Extra probe distance below the body's feet used for the grounded check (world units). */
29
+ groundProbe = 6
30
+ /** Distance from the node origin down to the feet. Defaults to half the sprite height. */
31
+ footOffset?: number
32
+
33
+ private _moveX = 0
34
+ private _jumpQueued = false
35
+ private _grounded = false
36
+
37
+ /** Horizontal intent in [-1, 1] (e.g. from input). Sticky — set 0 to stop. Applied next frame. */
38
+ move(dir: number): void { this._moveX = dir < -1 ? -1 : dir > 1 ? 1 : dir }
39
+ /** Queue a jump; consumed on the next frame if grounded. */
40
+ jump(): void { this._jumpQueued = true }
41
+ /** True while standing on something (updated each frame). */
42
+ get grounded(): boolean { return this._grounded }
43
+
44
+ private get phys(): Physics2D | undefined { return this.node.get(Physics2D) }
45
+
46
+ onAttach(): void {
47
+ // Ensure a Shape (auto box from the sprite) + a dynamic, fixed-rotation body if not already set up.
48
+ if (!this.node.has(Shape)) this.node.aspect(Shape, {})
49
+ if (!this.node.has(Physics2D)) this.node.aspect(Physics2D, { motion: "dynamic", fixedRotation: true })
50
+ }
51
+
52
+ update(_dt: number): void {
53
+ const phys = this.phys
54
+ if (!phys) return
55
+
56
+ // Grounded probe: a short ray straight down from the feet. The ray originates inside the body,
57
+ // so Box2D won't report the body's own shape; we exclude self defensively too.
58
+ const pos = this.node.worldPosition
59
+ const foot = this.footOffset ?? this._spriteHalfHeight()
60
+ const hit = Physics2D.raycast([pos.x, pos.y - foot * 0.5], [pos.x, pos.y - foot - this.groundProbe])
61
+ this._grounded = !!hit && hit.node !== this.node
62
+
63
+ let vy = phys.velocity.y
64
+ if (this._jumpQueued && this._grounded) { vy = this.jumpSpeed; this._grounded = false }
65
+ this._jumpQueued = false
66
+
67
+ // Drive horizontal velocity directly; let the sim own vertical (gravity / jump / landing).
68
+ phys.velocity = new Vec2(this._moveX * this.speed, vy)
69
+ }
70
+
71
+ private _spriteHalfHeight(): number {
72
+ const size = (this.node as { size?: Vec2 }).size
73
+ return size ? size.y * 0.5 : 8
74
+ }
75
+ }
@@ -1,13 +1,19 @@
1
1
  // Base 2D scene node — an empty transform. Sprite and Tilemap extend it, so every node operation
2
2
  // (position, layer, visibility, events) works on them. Native (creator-2d) owns the authoritative
3
- // transform; JS is the sole writer (no 2D physics yet) so a local cache makes reads free.
3
+ // transform. JS caches it for free reads (JS is normally the sole writer); the exception is a node
4
+ // driven by a dynamic/kinematic Physics2D body — there the native physics step also writes the
5
+ // transform, so such nodes are flagged `_physicsDriven` and their transform getters sync the cache
6
+ // from native first (lazy: only on read, only for moving bodies). So node.position stays correct
7
+ // under physics without any per-frame bookkeeping.
4
8
  //
5
- // Empty Node2Ds are useful as: grouping parents, world-space markers/spawn points, and trigger /
6
- // logic objects you attach a .collider() to.
9
+ // Empty Node2Ds are useful as: grouping parents, world-space markers/spawn points, and physics /
10
+ // trigger objects — attach a Shape (geometry) plus a Physics2D (rigid body) or Trigger (sensor).
7
11
 
8
- import { Emitter } from "../core/events"
12
+ import { AspectHost } from "../core/Aspect"
9
13
  import { Registry } from "../core/registry"
10
14
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
15
+ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
16
+ import { ensurePointerEvents } from "./touch"
11
17
 
12
18
  // re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
13
19
  export { Vec2, type Vec2Like }
@@ -17,24 +23,19 @@ export type Node2DEvents = {
17
23
  loopReached: (clip: string) => void
18
24
  /** A non-looping animation clip finished. */
19
25
  completed: (clip: string) => void
20
- /** A collider overlap began / ended (deferred — native 2D triggers are not built yet). */
26
+ /** A physics contact / sensor overlap began / ended (the other node). */
21
27
  enter: (other: Node2D) => void
22
28
  exit: (other: Node2D) => void
29
+ /** Pointer up over this node's Physics2D shape (a tap/click). */
30
+ click: (ev: ClickEvent<Node2D | null>) => void
31
+ /** Pointer down on this node's Physics2D shape. Call ev.track(...) to capture the drag. */
32
+ touchstart: (ev: TouchStartEvent<Node2D | null>) => void
23
33
  }
24
34
 
25
35
  /** id → Node2D, so host callbacks (animation events) route back to the owning JS object. */
26
36
  export const node2dRegistry = new Registry<Node2D>()
27
37
 
28
- export type ColliderOptions = {
29
- /** Box half-extents (world units). Defaults to the sprite size when omitted. */
30
- size?: Vec2Like
31
- /** Center offset. */
32
- offset?: Vec2Like
33
- /** Report overlaps via 'enter'/'exit' instead of blocking (always true for now). */
34
- trigger?: boolean
35
- }
36
-
37
- export class Node2D extends Emitter<Node2DEvents> {
38
+ export class Node2D extends AspectHost<Node2DEvents> {
38
39
  /** Native entity handle. */
39
40
  readonly id: number
40
41
 
@@ -47,23 +48,34 @@ export class Node2D extends Emitter<Node2DEvents> {
47
48
  protected _z = 0
48
49
  protected _visible = true
49
50
 
51
+ /** @internal — set by Physics2D for a dynamic/kinematic body: the native step owns this node's
52
+ * transform, so the transform getters re-sync the cache from native before returning. */
53
+ _physicsDriven = false
54
+
55
+ private _parent: Node2D | null = null
56
+ private _children: Node2D[] = []
57
+
50
58
  constructor(internalId?: number) {
51
59
  super()
52
60
  this.id = internalId ?? _creator2d.createEntity()
53
61
  node2dRegistry.set(this.id, this)
54
62
  }
55
63
 
56
- // --- transform (Y-up world; at zoom 1, units == pixels) ---
57
- get x(): number { return this._x }
58
- set x(v: number) { this._x = v; _creator2d.setPosition(this.id, v, this._y) }
59
- get y(): number { return this._y }
60
- set y(v: number) { this._y = v; _creator2d.setPosition(this.id, this._x, v) }
64
+ // --- transform (Y-up, LOCAL to the parent; at zoom 1, units == logical pixels) ---
65
+ // Pull the native transform into the cache iff physics owns it. No-op for the common case (plain
66
+ // nodes keep free reads); one native read for a moving body, and only when actually read.
67
+ private _pull(): void { if (this._physicsDriven) this._syncLocalFromNative() }
61
68
 
62
- get position(): Vec2 { return new Vec2(this._x, this._y) }
69
+ get x(): number { this._pull(); return this._x }
70
+ set x(v: number) { this._pull(); this._x = v; _creator2d.setPosition(this.id, v, this._y) }
71
+ get y(): number { this._pull(); return this._y }
72
+ set y(v: number) { this._pull(); this._y = v; _creator2d.setPosition(this.id, this._x, v) }
73
+
74
+ get position(): Vec2 { this._pull(); return new Vec2(this._x, this._y) }
63
75
  set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.setPosition(this.id, this._x, this._y) }
64
76
 
65
77
  /** Rotation in degrees (CCW). */
66
- get rotation(): number { return this._rotation }
78
+ get rotation(): number { this._pull(); return this._rotation }
67
79
  set rotation(deg: number) { this._rotation = deg; _creator2d.setRotation(this.id, deg) }
68
80
 
69
81
  get scale(): Vec2 { return new Vec2(this._sx, this._sy) }
@@ -85,20 +97,98 @@ export class Node2D extends Emitter<Node2DEvents> {
85
97
  get visible(): boolean { return this._visible }
86
98
  set visible(v: boolean) { this._visible = v; _creator2d.setVisible(this.id, v) }
87
99
 
100
+ // --- hierarchy (parent/child; transforms above are LOCAL to the parent) ---
101
+ get parent(): Node2D | null { return this._parent }
102
+ set parent(p: Node2D | null) { this.setParent(p) }
103
+
104
+ get children(): readonly Node2D[] { return this._children }
105
+
106
+ /** Attach children to this node (each keeps its current world transform). Returns this. */
107
+ add(...children: Node2D[]): this {
108
+ for (const c of children) c.setParent(this)
109
+ return this
110
+ }
111
+
112
+ /** Detach a direct child (reparents it to root, keeping its world transform). Returns this. */
113
+ remove(child: Node2D): this {
114
+ if (child._parent === this) child.setParent(null)
115
+ return this
116
+ }
117
+
88
118
  /**
89
- * Attach a collider for overlap detection. DEFERRED: native 2D physics/triggers are not built yet,
90
- * so this records intent and is a no-op until the engine lands — the 'enter'/'exit' events won't
91
- * fire. The API is here so game code that uses it compiles and stays stable.
119
+ * Reparent this node. `keepWorld` (default true) recomputes the local transform so the on-screen
120
+ * position/rotation/scale is unchanged; pass false to keep the local transform and move with the
121
+ * new parent. Self/descendant parents are rejected natively (no cycles).
92
122
  */
93
- collider(_options: ColliderOptions = {}): this {
123
+ setParent(parent: Node2D | null, keepWorld = true): this {
124
+ if (parent === this) return this
125
+ if (this._parent) {
126
+ const arr = this._parent._children
127
+ const i = arr.indexOf(this)
128
+ if (i >= 0) arr.splice(i, 1)
129
+ }
130
+ this._parent = parent
131
+ if (parent) parent._children.push(this)
132
+ _creator2d.setParent(this.id, parent?.id ?? 0, keepWorld)
133
+ if (keepWorld) this._syncLocalFromNative() // native recomputed our local transform
94
134
  return this
95
135
  }
96
136
 
97
- /** @internal — overridden by Sprite to translate native clip ids into named animation events. */
137
+ /** World-space position (composes all ancestors). */
138
+ get worldPosition(): Vec2 {
139
+ const o = _creator2d.getWorldPosition(this.id)
140
+ return new Vec2(o[0], o[1])
141
+ }
142
+
143
+ /** Convert a world-space point into this node's local space (and back). */
144
+ worldToLocal(p: Vec2Like): Vec2 {
145
+ const o = _creator2d.worldToLocal(this.id, cx(p), cy(p))
146
+ return new Vec2(o[0], o[1])
147
+ }
148
+ localToWorld(p: Vec2Like): Vec2 {
149
+ const o = _creator2d.localToWorld(this.id, cx(p), cy(p))
150
+ return new Vec2(o[0], o[1])
151
+ }
152
+
153
+ private _syncLocalFromNative(): void {
154
+ const t = _creator2d.getLocalTransform(this.id) // [x, y, rotDeg, sx, sy]
155
+ this._x = t[0]; this._y = t[1]; this._rotation = t[2]; this._sx = t[3]; this._sy = t[4]
156
+ }
157
+
158
+ /** @internal — host animation events route here by id; the SpriteAnimation aspect installs an
159
+ * instance override that translates clip ids to names and emits 'loopReached' / 'completed'. */
98
160
  _dispatchAnimEvent(_clipId: number, _type: number): void {}
99
161
 
162
+ /** @internal — emit a translated animation event (the SpriteAnimation aspect can't reach the
163
+ * protected dispatch directly). */
164
+ _emitAnim(channel: "loopReached" | "completed", clip: string): void { this.dispatch(channel, clip) }
165
+
166
+ /** @internal — physics contact/sensor events route here (loop.ts) → the node's 'enter'/'exit'. */
167
+ _emitCollision(channel: "enter" | "exit", other: Node2D): void { this.dispatch(channel, other) }
168
+
169
+ // Lazily register the host pointer callback when the first click/touchstart listener is added.
170
+ override addEventListener<K extends keyof Node2DEvents>(channel: K, callback: Node2DEvents[K]): void {
171
+ if (channel === "click" || channel === "touchstart") ensurePointerEvents()
172
+ super.addEventListener(channel, callback)
173
+ }
174
+
175
+ /** @internal — the pointer system (g2/touch.ts) fires click/touchstart here. */
176
+ _dispatchEvent<K extends keyof Node2DEvents>(channel: K, ...args: Parameters<Node2DEvents[K]>): void {
177
+ this.dispatch(channel, ...args)
178
+ }
179
+
100
180
  destroy(): void {
181
+ // Detach from parent; native reparents our children to root (keepWorld), so resync their caches.
182
+ if (this._parent) {
183
+ const arr = this._parent._children
184
+ const i = arr.indexOf(this)
185
+ if (i >= 0) arr.splice(i, 1)
186
+ this._parent = null
187
+ }
188
+ const orphans = this._children.slice()
189
+ this._children.length = 0
101
190
  node2dRegistry.delete(this.id)
102
191
  _creator2d.destroyEntity(this.id)
192
+ for (const c of orphans) { c._parent = null; c._syncLocalFromNative() }
103
193
  }
104
194
  }