lecodes-cli 0.1.7 → 0.2.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.
@@ -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
  }
@@ -0,0 +1,126 @@
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 Shape aspect on the same node (its geometry becomes the body's solid fixture).
3
+ //
4
+ // Physics2D.configure({ gravity: [0, -980] }) // once, before creating bodies
5
+ // ground.aspect(Shape, { segment: { from: [-500,0], to: [500,0] } }).aspect(Physics2D, { motion: 'static' })
6
+ // hero.aspect(Shape, { box: [12, 20] }).aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
7
+ // hero.physics.applyImpulse([0, 400]) // jump
8
+ // hero.addEventListener('enter', other => …) // contact / sensor overlap began
9
+ //
10
+ // For a `dynamic` body, physics OWNS the transform — don't set node.position per frame; drive it via
11
+ // velocity / applyImpulse. Reading node.position works: the node is flagged _physicsDriven and lazily
12
+ // syncs its cache from native on read. Kinematic bodies are driven with moveTo. Best practice: keep
13
+ // bodies on root nodes (see creator-2d PLAN.md).
14
+
15
+ import { Aspect } from "../core/Aspect"
16
+ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
17
+ import { node2dRegistry, type Node2D } from "./Node2D"
18
+ import { Shape } from "./Shape"
19
+
20
+ export type MotionType = "static" | "kinematic" | "dynamic"
21
+ const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
22
+
23
+ export interface PhysicsConfig {
24
+ /** Gravity in world units/s² (Y-up: down is negative). Default [0, -980]. */
25
+ gravity?: Vec2Like
26
+ /** Pixels per meter — tunes Box2D's internal tolerances; you still author in world units. Default 64. */
27
+ pixelsPerMeter?: number
28
+ /** Solver sub-steps per fixed step. Default 4. */
29
+ subSteps?: number
30
+ }
31
+
32
+ /** Closest raycast hit. */
33
+ export interface RayHit {
34
+ node: Node2D | null
35
+ point: Vec2
36
+ normal: Vec2
37
+ fraction: number
38
+ }
39
+
40
+ // Resolve a native entity id to its Node2D (0 = none).
41
+ const node2dById = (id: number): Node2D | null => (id ? node2dRegistry.get(id) ?? null : null)
42
+
43
+ export class Physics2D extends Aspect<"physics", Node2D> {
44
+ static readonly aspect = "physics"
45
+
46
+ /** Default "dynamic". */
47
+ motion: MotionType = "dynamic"
48
+ density = 1
49
+ friction = 0.3
50
+ restitution = 0
51
+ /** Lock rotation (essential for platformer characters). */
52
+ fixedRotation = false
53
+ gravityScale = 1
54
+ /** Continuous collision for fast-moving bodies (projectiles). */
55
+ bullet = false
56
+
57
+ private _h = 0 // native body handle (0 = none / no physics support)
58
+
59
+ /** Native body handle. 0 until attached, or if the build has no physics support. */
60
+ get handle(): number { return this._h }
61
+
62
+ onAttach(): void {
63
+ if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
64
+ const shape = this.node.get(Shape)
65
+ if (!shape) {
66
+ throw new Error("Physics2D requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics2D, {…})")
67
+ }
68
+ this._h = _creator2d.physicsCreateBody(this.node.id, MOTION[this.motion])
69
+ if (!this._h) return
70
+ shape._addTo(this._h, false /* solid */, this.density, this.friction, this.restitution)
71
+ // dynamic/kinematic bodies are moved by the native step → the node must re-sync its transform
72
+ // cache from native on read. Static bodies never move, so they keep the free cache.
73
+ if (this.motion !== "static") this.node._physicsDriven = true
74
+ if (this.fixedRotation) _creator2d.physicsSetFixedRotation(this._h, true)
75
+ if (this.gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
76
+ if (this.bullet) _creator2d.physicsSetBullet(this._h, true)
77
+ }
78
+
79
+ onDetach(): void {
80
+ if (this._h) { _creator2d.physicsRemoveBody(this._h); this._h = 0 }
81
+ this.node._physicsDriven = false
82
+ }
83
+
84
+ /** Linear velocity in world units/second (fresh Vec2 on read). */
85
+ get velocity(): Vec2 {
86
+ const o = this._h ? _creator2d.physicsGetLinearVelocity(this._h) : null
87
+ return o ? new Vec2(o[0], o[1]) : new Vec2(0, 0)
88
+ }
89
+ set velocity(v: Vec2Like) { if (this._h) _creator2d.physicsSetLinearVelocity(this._h, cx(v), cy(v)) }
90
+
91
+ /** Angular velocity in degrees/second. */
92
+ set angularVelocity(degPerSec: number) { if (this._h) _creator2d.physicsSetAngularVelocity(this._h, degPerSec) }
93
+
94
+ set gravityFactor(scale: number) { this.gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }
95
+ set linearDamping(d: number) { if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }
96
+ set enabled(v: boolean) { if (this._h) _creator2d.physicsSetEnabled(this._h, v) }
97
+
98
+ /** Apply an instantaneous impulse (world units) and wake the body. */
99
+ applyImpulse(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyLinearImpulse(this._h, cx(v), cy(v)); return this }
100
+ /** Apply a continuous force (world units) and wake the body. */
101
+ applyForce(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyForce(this._h, cx(v), cy(v)); return this }
102
+ /** Teleport / drive a kinematic (or dynamic) body to a world position (+ optional rotation degrees). */
103
+ moveTo(p: Vec2Like, deg = 0): this { if (this._h) _creator2d.physicsSetTransform(this._h, cx(p), cy(p), deg); return this }
104
+
105
+ // --- world (static) -------------------------------------------------------------------------
106
+ /** Create / reset the physics world. Call once before creating bodies. */
107
+ static configure(config: PhysicsConfig = {}): void {
108
+ const g = config.gravity ?? [0, -980]
109
+ _creator2d.physicsConfigure(cx(g), cy(g), config.pixelsPerMeter ?? 64, config.subSteps ?? 4)
110
+ }
111
+
112
+ /** Whether this build has physics support (CREATOR_2D_PHYSICS). */
113
+ static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }
114
+
115
+ /** Closest body hit by the segment from→to, or null. `node` resolves the hit entity. */
116
+ static raycast(from: Vec2Like, to: Vec2Like): RayHit | null {
117
+ const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to))
118
+ if (o[0] < 0.5) return null
119
+ return { node: node2dById(o[1]), point: new Vec2(o[2], o[3]), normal: new Vec2(o[4], o[5]), fraction: o[6] }
120
+ }
121
+
122
+ /** Topmost body (solid or sensor) whose shape contains the world point, or null. */
123
+ static overlapPoint(p: Vec2Like): Node2D | null {
124
+ return node2dById(_creator2d.physicsOverlapPoint(cx(p), cy(p)))
125
+ }
126
+ }
@@ -2,8 +2,11 @@
2
2
  // 2D engine and starts its RAF loop; only the active scene renders. Grown from the Phase 0 seed.
3
3
 
4
4
  import { Color, type ColorInput } from "../core/color"
5
+ import { cx, cy, type Vec2Like } from "../math/vec"
6
+ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
5
7
  import { Camera2D } from "./Camera2D"
6
- import type { Node2D } from "./Node2D"
8
+ import { node2dRegistry, type Node2D } from "./Node2D"
9
+ import { ensurePointerEvents } from "./touch"
7
10
 
8
11
  export type Scene2DOptions = {
9
12
  /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
@@ -30,6 +33,10 @@ export class Scene2D {
30
33
  private static _active: Scene2D | null = null
31
34
  static get active(): Scene2D | null { return Scene2D._active }
32
35
 
36
+ /** @internal — scene-level pointer listeners (the catch-all; target = hit node or null). */
37
+ readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
38
+ readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
39
+
33
40
  constructor(options: Scene2DOptions = {}) {
34
41
  this.id = _creator2d.createScene()
35
42
  this.camera = new Camera2D(this.id)
@@ -46,6 +53,27 @@ export class Scene2D {
46
53
  return new LayerHandle(this.id, n)
47
54
  }
48
55
 
56
+ /** Topmost visible sprite whose world bounds contain the world point, or null. Body-free
57
+ * hit-test for tap/click picking — pair with camera.screenToWorld(). */
58
+ pick(worldPoint: Vec2Like): Node2D | null {
59
+ const id = _creator2d.pickSprite(this.id, cx(worldPoint), cy(worldPoint))
60
+ return id ? node2dRegistry.get(id) ?? null : null
61
+ }
62
+
63
+ /** Scene-level pointer events. The handler fires for every tap/press in the scene, with
64
+ * `ev.target` = the hit Physics2D node (or null). On 'touchstart' you can ev.track(...) a drag. */
65
+ addEventListener(channel: "click", callback: (ev: ClickEvent<Node2D | null>) => void): void
66
+ addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<Node2D | null>) => void): void
67
+ addEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
68
+ const list = channel === "click" ? this._clickListeners : this._touchStartListeners
69
+ if (!list.includes(callback)) { list.push(callback); ensurePointerEvents() }
70
+ }
71
+ removeEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
72
+ const list = channel === "click" ? this._clickListeners : this._touchStartListeners
73
+ const i = list.indexOf(callback)
74
+ if (i >= 0) list.splice(i, 1)
75
+ }
76
+
49
77
  add(...nodes: Node2D[]): this {
50
78
  for (const n of nodes) _creator2d.addEntityToScene(this.id, n.id)
51
79
  return this
@@ -0,0 +1,63 @@
1
+ // A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a Shape does not by itself
2
+ // make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a Trigger (sensor), both
3
+ // of which require a Shape 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 Shape + Physics2D/Trigger (a body to query).
5
+ //
6
+ // wall.aspect(Shape, { box: [200, 16] }).aspect(Physics2D, { motion: 'static' })
7
+ // goal.aspect(Shape, { circle: 20 }).aspect(Trigger)
8
+ // hero.aspect(Shape, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
9
+ //
10
+ // With no geometry set, Shape derives a box from the node's sprite size ("auto").
11
+
12
+ import { Aspect } from "../core/Aspect"
13
+ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
14
+ import type { Node2D } from "./Node2D"
15
+
16
+ export class Shape extends Aspect<"shape", Node2D> {
17
+ static readonly aspect = "shape"
18
+
19
+ /** Box half-extents [hw, hh] (world units). */
20
+ box?: Vec2Like
21
+ /** Circle radius. */
22
+ circle?: number
23
+ /** Capsule between two local points, with a radius. */
24
+ capsule?: { from: Vec2Like, to: Vec2Like, radius: number }
25
+ /** Thin segment (an edge — for static ground/walls/slopes) between two local points. */
26
+ segment?: { from: Vec2Like, to: Vec2Like }
27
+ /** Convex polygon, up to 8 local points (a convex hull is computed). */
28
+ polygon?: Vec2Like[]
29
+ /** Local offset of the shape from the node origin. */
30
+ offset?: Vec2Like
31
+
32
+ /**
33
+ * @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/Trigger).
34
+ * `sensor` selects a sensor (trigger) vs solid fixture; density/friction/restitution are the
35
+ * material (ignored for sensors / segments only use friction+restitution).
36
+ */
37
+ _addTo(handle: number, sensor: boolean, density: number, friction: number, restitution: number): void {
38
+ const ox = this.offset ? cx(this.offset) : 0
39
+ const oy = this.offset ? cy(this.offset) : 0
40
+ if (this.circle !== undefined) {
41
+ _creator2d.physicsAddCircle(handle, this.circle, ox, oy, density, friction, restitution, sensor)
42
+ } else if (this.capsule) {
43
+ const { from, to, radius } = this.capsule
44
+ _creator2d.physicsAddCapsule(handle, cx(from), cy(from), cx(to), cy(to), radius, density, friction, restitution, sensor)
45
+ } else if (this.segment) {
46
+ _creator2d.physicsAddSegment(handle, cx(this.segment.from), cy(this.segment.from), cx(this.segment.to), cy(this.segment.to), friction, restitution)
47
+ } else if (this.polygon) {
48
+ const pts = new Float32Array(this.polygon.length * 2)
49
+ this.polygon.forEach((p, i) => { pts[i * 2] = cx(p); pts[i * 2 + 1] = cy(p) })
50
+ _creator2d.physicsAddPolygon(handle, pts, this.polygon.length, density, friction, restitution, sensor)
51
+ } else {
52
+ // box: explicit half-extents, or "auto" from the node's sprite size (fallback 8×8).
53
+ let hw = 8, hh = 8
54
+ if (this.box) {
55
+ hw = cx(this.box); hh = cy(this.box)
56
+ } else {
57
+ const size = (this.node as { size?: Vec2 }).size
58
+ if (size) { hw = size.x * 0.5; hh = size.y * 0.5 }
59
+ }
60
+ _creator2d.physicsAddBox(handle, hw, hh, ox, oy, density, friction, restitution, sensor)
61
+ }
62
+ }
63
+ }
@@ -1,10 +1,9 @@
1
1
  // A textured 2D node. Cheap setters proxy straight to the native sprite; the data lives in
2
- // creator-2d. Sprite-sheet animation is built in as chainable methods (no separate component):
3
- // new Sprite({ texture }).clips({ size: [32, 48], idle: [0,1,2,3], walk: [4,5,6,7] }).play('idle')
2
+ // creator-2d. Sprite-sheet animation is no longer built in — attach the SpriteAnimation aspect:
3
+ // new Sprite({ texture }).aspect(SpriteAnimation, { size: [32, 48], idle: [0,1,2,3] }).anim.play('idle')
4
4
 
5
5
  import { Color, type ColorInput } from "../core/color"
6
6
  import { Vec2, type Vec2Like } from "../math/vec"
7
- import { ensureAnimEvents } from "./loop"
8
7
  import { Node2D } from "./Node2D"
9
8
  import type { Texture2D } from "./Texture2D"
10
9
 
@@ -12,7 +11,7 @@ export type SpriteOptions = {
12
11
  texture?: Texture2D
13
12
  /** Pivot, [0,0] = top-left .. [1,1] = bottom-right. Use [0.5, 1] (feet) for Y-sorted depth. */
14
13
  anchor?: Vec2Like
15
- /** World size; defaults to the texture's pixel dimensions (or the clip frame size once set). */
14
+ /** World size; defaults to the texture's pixel dimensions. */
16
15
  size?: Vec2Like
17
16
  /** Normalized UV sub-rect [u0,v0,u1,v1]. */
18
17
  frame?: [number, number, number, number]
@@ -23,20 +22,6 @@ export type SpriteOptions = {
23
22
  position?: Vec2Like
24
23
  }
25
24
 
26
- /** Per-clip frame list, or an object for per-clip fps/loop overrides. */
27
- export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean }
28
-
29
- /** Animation definition: the grid frame size + named clips (+ optional shared fps/loop defaults). */
30
- export type SpriteClips = {
31
- /** Grid cell size in texture pixels. */
32
- size: [number, number]
33
- /** Default fps for clips that don't override it (default 12). */
34
- fps?: number
35
- /** Default loop for clips that don't override it (default true). */
36
- loop?: boolean
37
- [clip: string]: Clip | [number, number] | number | boolean | undefined
38
- }
39
-
40
25
  export class Sprite extends Node2D {
41
26
  private _texture?: Texture2D
42
27
  private _anchor = new Vec2(0.5, 0.5)
@@ -46,10 +31,6 @@ export class Sprite extends Node2D {
46
31
  private _flipY = false
47
32
  private _size: Vec2 | null = null
48
33
 
49
- private _clipIds: Record<string, number> = {}
50
- private _clipNames: Record<number, string> = {}
51
- private _current: string | null = null
52
-
53
34
  constructor(options: SpriteOptions = {}) {
54
35
  super()
55
36
  if (options.texture) this.texture = options.texture
@@ -95,70 +76,4 @@ export class Sprite extends Node2D {
95
76
  set flipX(v: boolean) { this._flipX = v; _creator2d.setSpriteFlip(this.id, v, this._flipY) }
96
77
  get flipY(): boolean { return this._flipY }
97
78
  set flipY(v: boolean) { this._flipY = v; _creator2d.setSpriteFlip(this.id, this._flipX, v) }
98
-
99
- // --- animation (chainable) ---
100
-
101
- /**
102
- * Define named sprite-sheet clips from a uniform grid. Frame indices are row-major into the grid.
103
- * Sets the sprite's display size to one cell. Once defined, clips advance entirely in native code.
104
- */
105
- clips(def: SpriteClips): this {
106
- const tex = this._texture
107
- if (!tex) throw new Error("Sprite.clips(): set a texture before defining clips")
108
- const [ fw, fh ] = def.size
109
- const defFps = typeof def.fps === "number" ? def.fps : 12
110
- const defLoop = typeof def.loop === "boolean" ? def.loop : true
111
- const cols = Math.max(1, Math.floor(tex.width / fw))
112
-
113
- for (const name of Object.keys(def)) {
114
- if (name === "size" || name === "fps" || name === "loop") continue
115
- const value = def[name] as Clip
116
- const indices = Array.isArray(value) ? value : value.frames
117
- const fps = Array.isArray(value) ? defFps : (value.fps ?? defFps)
118
- const loop = Array.isArray(value) ? defLoop : (value.loop ?? defLoop)
119
-
120
- const frames = new Float32Array(indices.length * 4)
121
- indices.forEach((idx, i) => {
122
- const cx = (idx % cols) * fw
123
- const cy = Math.floor(idx / cols) * fh
124
- frames[i * 4 + 0] = cx / tex.width
125
- frames[i * 4 + 1] = cy / tex.height
126
- frames[i * 4 + 2] = (cx + fw) / tex.width
127
- frames[i * 4 + 3] = (cy + fh) / tex.height
128
- })
129
- const clipId = _creator2d.defineAnimation(this.id, frames, fps, loop)
130
- this._clipIds[name] = clipId
131
- this._clipNames[clipId] = name
132
- }
133
-
134
- if (!this._size) _creator2d.setSpriteSize(this.id, fw, fh)
135
- ensureAnimEvents()
136
- return this
137
- }
138
-
139
- /** Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly). */
140
- play(name: string): this {
141
- const id = this._clipIds[name]
142
- if (id === undefined) throw new Error(`Sprite.play(): unknown clip "${name}"`)
143
- if (this._current === name) return this
144
- this._current = name
145
- _creator2d.playAnimation(this.id, id)
146
- return this
147
- }
148
-
149
- stop(): this {
150
- this._current = null
151
- _creator2d.stopAnimation(this.id)
152
- return this
153
- }
154
-
155
- set animationSpeed(value: number) { _creator2d.setAnimationSpeed(this.id, value) }
156
- get currentClip(): string | null { return this._current }
157
- get animationFrame(): number { return _creator2d.getAnimationFrame(this.id) }
158
-
159
- /** @internal — translate a native clip id back to its name and emit the animation event. */
160
- override _dispatchAnimEvent(clipId: number, type: number): void {
161
- const name = this._clipNames[clipId] ?? ""
162
- this.dispatch(type === 1 ? "completed" : "loopReached", name)
163
- }
164
79
  }
@@ -0,0 +1,90 @@
1
+ // Sprite-sheet animation as an aspect on a Sprite. Configured as DATA (frame size + named clips) in
2
+ // the attach call, then driven by name:
3
+ // sprite.aspect(SpriteAnimation, { size: [32, 48], fps: 10, clips: { idle: [0,1], walk: [4,5,6,7] } })
4
+ // sprite.anim.play('walk')
5
+ // Once defined, clips advance entirely in native code; the aspect just translates clip ids back to
6
+ // names for the node's 'loopReached' / 'completed' events.
7
+
8
+ import { Aspect } from "../core/Aspect"
9
+ import { ensureAnimEvents } from "./loop"
10
+ import type { Sprite } from "./Sprite"
11
+
12
+ /** Per-clip frame list, or an object for per-clip fps/loop overrides. */
13
+ export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean }
14
+
15
+ export class SpriteAnimation extends Aspect<"anim", Sprite> {
16
+ static readonly aspect = "anim"
17
+
18
+ /** Grid cell size in texture pixels (defaults to the full texture). */
19
+ size?: [number, number]
20
+ /** Default fps for clips that don't override it. */
21
+ fps = 12
22
+ /** Default loop for clips that don't override it. */
23
+ loop = true
24
+ /** Named clips; frame indices are row-major into the grid. Set via the attach opts. */
25
+ clips: Record<string, Clip> = {}
26
+
27
+ private _clipIds: Record<string, number> = {}
28
+ private _clipNames: Record<number, string> = {}
29
+ private _current: string | null = null
30
+
31
+ onAttach(): void {
32
+ this._define()
33
+ // route native animation events for this entity to named 'loopReached'/'completed' events.
34
+ ;(this.node as { _dispatchAnimEvent: (clipId: number, type: number) => void })._dispatchAnimEvent =
35
+ (clipId, type) => this.node._emitAnim(type === 1 ? "completed" : "loopReached", this._clipNames[clipId] ?? "")
36
+ ensureAnimEvents()
37
+ }
38
+
39
+ /** (Re)build native clips from `this.clips`. Called on attach; call again after changing clips. */
40
+ define(): this { this._define(); return this }
41
+
42
+ private _define(): void {
43
+ const node = this.node
44
+ const tex = node.texture
45
+ if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before adding the aspect")
46
+ const [ fw, fh ] = this.size ?? [ tex.width, tex.height ]
47
+ const cols = Math.max(1, Math.floor(tex.width / fw))
48
+
49
+ for (const name of Object.keys(this.clips)) {
50
+ const value = this.clips[name]
51
+ const indices = Array.isArray(value) ? value : value.frames
52
+ const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
53
+ const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
54
+
55
+ const frames = new Float32Array(indices.length * 4)
56
+ indices.forEach((idx, i) => {
57
+ const px = (idx % cols) * fw
58
+ const py = Math.floor(idx / cols) * fh
59
+ frames[i * 4 + 0] = px / tex.width
60
+ frames[i * 4 + 1] = py / tex.height
61
+ frames[i * 4 + 2] = (px + fw) / tex.width
62
+ frames[i * 4 + 3] = (py + fh) / tex.height
63
+ })
64
+ const clipId = _creator2d.defineAnimation(node.id, frames, fps, loop)
65
+ this._clipIds[name] = clipId
66
+ this._clipNames[clipId] = name
67
+ }
68
+ if (this.size) _creator2d.setSpriteSize(node.id, fw, fh)
69
+ }
70
+
71
+ /** Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly). */
72
+ play(name: string): this {
73
+ const id = this._clipIds[name]
74
+ if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${name}"`)
75
+ if (this._current === name) return this
76
+ this._current = name
77
+ _creator2d.playAnimation(this.node.id, id)
78
+ return this
79
+ }
80
+
81
+ stop(): this {
82
+ this._current = null
83
+ _creator2d.stopAnimation(this.node.id)
84
+ return this
85
+ }
86
+
87
+ set speed(value: number) { _creator2d.setAnimationSpeed(this.node.id, value) }
88
+ get current(): string | null { return this._current }
89
+ get frame(): number { return _creator2d.getAnimationFrame(this.node.id) }
90
+ }