lecodes-cli 0.1.6 → 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.
Files changed (73) hide show
  1. package/README.md +7 -1
  2. package/dist/index.js +202 -84
  3. package/package.json +4 -4
  4. package/runtime/sdk/animate/animate.ts +238 -0
  5. package/runtime/sdk/animate/bezier.ts +138 -0
  6. package/runtime/sdk/animate/easings.ts +126 -0
  7. package/runtime/sdk/core/Aspect.ts +151 -0
  8. package/runtime/sdk/core/color.ts +66 -0
  9. package/runtime/sdk/core/events.ts +38 -0
  10. package/runtime/sdk/core/registry.ts +23 -0
  11. package/runtime/sdk/g2/Camera2D.ts +33 -0
  12. package/runtime/sdk/g2/CharacterController2D.ts +75 -0
  13. package/runtime/sdk/g2/Node2D.ts +194 -0
  14. package/runtime/sdk/g2/Physics2D.ts +126 -0
  15. package/runtime/sdk/g2/Scene2D.ts +101 -0
  16. package/runtime/sdk/g2/Shape.ts +63 -0
  17. package/runtime/sdk/g2/Sprite.ts +79 -0
  18. package/runtime/sdk/g2/SpriteAnimation.ts +90 -0
  19. package/runtime/sdk/g2/Texture2D.ts +37 -0
  20. package/runtime/sdk/g2/Tilemap.ts +41 -0
  21. package/runtime/sdk/g2/Trigger.ts +47 -0
  22. package/runtime/sdk/g2/loop.ts +39 -0
  23. package/runtime/sdk/g2/touch.ts +83 -0
  24. package/runtime/sdk/gl/Camera.ts +40 -0
  25. package/runtime/sdk/gl/Collider.ts +29 -0
  26. package/runtime/sdk/gl/Geometry.ts +218 -0
  27. package/runtime/sdk/gl/Light.ts +37 -0
  28. package/runtime/sdk/gl/Material.ts +124 -0
  29. package/runtime/sdk/gl/Mesh.ts +100 -0
  30. package/runtime/sdk/gl/Model.ts +40 -0
  31. package/runtime/sdk/gl/ModelAnimation.ts +64 -0
  32. package/runtime/sdk/gl/Node.ts +234 -0
  33. package/runtime/sdk/gl/Noise.ts +30 -0
  34. package/runtime/sdk/gl/Particles.ts +185 -0
  35. package/runtime/sdk/gl/Physics.ts +95 -0
  36. package/runtime/sdk/gl/Plane.ts +53 -0
  37. package/runtime/sdk/gl/Ray.ts +16 -0
  38. package/runtime/sdk/gl/Scene.ts +179 -0
  39. package/runtime/sdk/gl/Texture.ts +35 -0
  40. package/runtime/sdk/gl/controls.ts +95 -0
  41. package/runtime/sdk/gl/state.ts +6 -0
  42. package/runtime/sdk/gl/touch.ts +56 -0
  43. package/runtime/sdk/inject.ts +85 -0
  44. package/runtime/sdk/math/Mathf.ts +118 -0
  45. package/runtime/sdk/math/mat4.ts +278 -0
  46. package/runtime/sdk/math/quat.ts +232 -0
  47. package/runtime/sdk/math/vec.ts +255 -0
  48. package/runtime/sdk/plugins/qr.ts +36 -0
  49. package/runtime/sdk/runtime/device.ts +33 -0
  50. package/runtime/sdk/runtime/fetch.ts +71 -0
  51. package/runtime/sdk/runtime/files.ts +23 -0
  52. package/runtime/sdk/runtime/input.ts +12 -0
  53. package/runtime/sdk/runtime/media.ts +111 -0
  54. package/runtime/sdk/runtime/misc.ts +12 -0
  55. package/runtime/sdk/runtime/net.ts +36 -0
  56. package/runtime/sdk/runtime/storage.ts +13 -0
  57. package/runtime/sdk/runtime/touch.ts +50 -0
  58. package/runtime/sdk/ui/UI.ts +19 -0
  59. package/runtime/sdk/ui/UIButton.ts +84 -0
  60. package/runtime/sdk/ui/UIContainer.ts +100 -0
  61. package/runtime/sdk/ui/UIImage.ts +68 -0
  62. package/runtime/sdk/ui/UIInput.ts +78 -0
  63. package/runtime/sdk/ui/UINode.ts +342 -0
  64. package/runtime/sdk/ui/UIScreen.ts +122 -0
  65. package/runtime/sdk/ui/UIScrollable.ts +70 -0
  66. package/runtime/sdk/ui/UISpacer.ts +14 -0
  67. package/runtime/sdk/ui/UIText.ts +40 -0
  68. package/runtime/sdk/ui/UIVideo.ts +38 -0
  69. package/runtime/sdk/ui/UIVirtualizedList.ts +222 -0
  70. package/runtime/sdk/ui/UIWidget.ts +97 -0
  71. package/runtime/sdk/ui/fonts.ts +10 -0
  72. package/runtime/sdk/ui/router.ts +73 -0
  73. package/runtime/inject.js +0 -4629
@@ -0,0 +1,66 @@
1
+ // Shared color parsing for both engines. Users pass colors the natural ways — a CSS-ish hex string
2
+ // ('#10131a', '#abc', '#rrggbbaa'), a packed number (0xff0000), or normalized components
3
+ // ([r,g,b] / [r,g,b,a] in 0..1). The engines want different forms (2D wants 0..1 floats, 3D wants a
4
+ // packed int), so this module is the single place that converts.
5
+
6
+ export type ColorInput =
7
+ | string
8
+ | number
9
+ | readonly [number, number, number]
10
+ | readonly [number, number, number, number]
11
+
12
+ const parseHexString = (input: string): [number, number, number, number] => {
13
+ let s = input.trim()
14
+ if (s[0] === "#") s = s.slice(1)
15
+ // #rgb / #rgba shorthand → double each nibble
16
+ if (s.length === 3 || s.length === 4) s = s.split("").map((c) => c + c).join("")
17
+ const int = parseInt(s, 16)
18
+ if (s.length === 8) return [ (int >>> 24) & 255, (int >> 16) & 255, (int >> 8) & 255, int & 255 ]
19
+ // default 6-digit (rrggbb); anything malformed falls back to opaque black
20
+ if (s.length === 6) return [ (int >> 16) & 255, (int >> 8) & 255, int & 255, 255 ]
21
+ return [ 0, 0, 0, 255 ]
22
+ }
23
+
24
+ /** Normalize any ColorInput to four 0..1 float components (array inputs pass through losslessly). */
25
+ const toFloats = (c: ColorInput): [number, number, number, number] => {
26
+ if (typeof c === "number") return [ ((c >> 16) & 255) / 255, ((c >> 8) & 255) / 255, (c & 255) / 255, 1 ]
27
+ if (typeof c === "string") {
28
+ const b = parseHexString(c)
29
+ return [ b[0] / 255, b[1] / 255, b[2] / 255, b[3] / 255 ]
30
+ }
31
+ return [ c[0], c[1], c[2], (c as readonly number[])[3] ?? 1 ]
32
+ }
33
+
34
+ const toBytes = (c: ColorInput): [number, number, number, number] => {
35
+ const f = toFloats(c)
36
+ return [ Math.round(f[0] * 255), Math.round(f[1] * 255), Math.round(f[2] * 255), Math.round(f[3] * 255) ]
37
+ }
38
+
39
+ export const Color = {
40
+ /** [r,g,b] in 0..1 — for the 2D engine's float color setters. */
41
+ toRgb01(c: ColorInput): [number, number, number] {
42
+ const f = toFloats(c)
43
+ return [ f[0], f[1], f[2] ]
44
+ },
45
+ /** [r,g,b,a] in 0..1. */
46
+ toRgba01(c: ColorInput): [number, number, number, number] {
47
+ return toFloats(c)
48
+ },
49
+ /** 0xRRGGBB packed int — for the 3D engine's color uniforms / skybox. */
50
+ toPackedRgb(c: ColorInput): number {
51
+ const b = toBytes(c)
52
+ return (b[0] << 16) | (b[1] << 8) | b[2]
53
+ },
54
+ /** 0xRRGGBBAA packed int (unsigned). */
55
+ toPackedRgba(c: ColorInput): number {
56
+ const b = toBytes(c)
57
+ return ((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]) >>> 0
58
+ },
59
+ /** '#rrggbb' (or '#rrggbbaa' when alpha < 1) — for the 3D material color uniforms. */
60
+ toHexString(c: ColorInput): string {
61
+ const b = toBytes(c)
62
+ const hex = (n: number) => n.toString(16).padStart(2, "0")
63
+ const base = `#${hex(b[0])}${hex(b[1])}${hex(b[2])}`
64
+ return b[3] >= 255 ? base : base + hex(b[3])
65
+ },
66
+ }
@@ -0,0 +1,38 @@
1
+ // Tiny typed event dispatcher shared by media players, sockets, and (later) nodes. Keeps the
2
+ // addEventListener / removeEventListener / dispatch boilerplate in one place.
3
+
4
+ export type EventMap = Record<string, (...args: any[]) => void>
5
+
6
+ export class Emitter<E extends EventMap> {
7
+ private _listeners: { [K in keyof E]?: E[K][] } = {}
8
+
9
+ addEventListener<K extends keyof E>(channel: K, callback: E[K]): void {
10
+ const list = this._listeners[channel]
11
+ if (list) list.push(callback)
12
+ else this._listeners[channel] = [ callback ]
13
+ }
14
+
15
+ removeEventListener<K extends keyof E>(channel: K, callback: E[K]): void {
16
+ const list = this._listeners[channel]
17
+ if (!list) return
18
+ const i = list.indexOf(callback)
19
+ if (i >= 0) list.splice(i, 1)
20
+ }
21
+
22
+ /** Fire all listeners on a channel. Protected: only the owning class emits. */
23
+ protected dispatch<K extends keyof E>(channel: K, ...args: Parameters<E[K]>): void {
24
+ const list = this._listeners[channel]
25
+ if (!list) return
26
+ for (const fn of list.slice()) fn(...args)
27
+ }
28
+
29
+ protected hasListeners<K extends keyof E>(channel: K): boolean {
30
+ const list = this._listeners[channel]
31
+ return !!list && list.length > 0
32
+ }
33
+
34
+ /** Drop every listener on every channel (used when the owning object is disposed). */
35
+ protected clearListeners(): void {
36
+ this._listeners = {}
37
+ }
38
+ }
@@ -0,0 +1,23 @@
1
+ // Maps a native handle (the integer id the engine returns) back to its JS wrapper, so callbacks
2
+ // the host fires by id (animation events, touch hits, media events) can be routed to the owning
3
+ // object. Each engine/domain keeps its own Registry instance.
4
+
5
+ export class Registry<T> {
6
+ private map = new Map<number, T>()
7
+
8
+ set(id: number, value: T): void {
9
+ this.map.set(id, value)
10
+ }
11
+
12
+ get(id: number): T | undefined {
13
+ return this.map.get(id)
14
+ }
15
+
16
+ delete(id: number): void {
17
+ this.map.delete(id)
18
+ }
19
+
20
+ has(id: number): boolean {
21
+ return this.map.has(id)
22
+ }
23
+ }
@@ -0,0 +1,33 @@
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).
4
+
5
+ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
6
+
7
+ export class Camera2D {
8
+ private _x = 0
9
+ private _y = 0
10
+ private _zoom = 1
11
+ private _rotation = 0
12
+
13
+ private readonly sceneId: number
14
+ constructor(sceneId: number) { this.sceneId = sceneId }
15
+
16
+ get position(): Vec2 { return new Vec2(this._x, this._y) }
17
+ set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.cameraSetPosition(this.sceneId, this._x, this._y) }
18
+
19
+ get zoom(): number { return this._zoom }
20
+ set zoom(z: number) { this._zoom = z; _creator2d.cameraSetZoom(this.sceneId, z) }
21
+
22
+ get rotation(): number { return this._rotation }
23
+ set rotation(deg: number) { this._rotation = deg; _creator2d.cameraSetRotation(this.sceneId, deg) }
24
+
25
+ screenToWorld(screenX: number, screenY: number): Vec2 {
26
+ const o = _creator2d.cameraScreenToWorld(this.sceneId, screenX, screenY)
27
+ return new Vec2(o[0], o[1])
28
+ }
29
+ worldToScreen(worldX: number, worldY: number): Vec2 {
30
+ const o = _creator2d.cameraWorldToScreen(this.sceneId, worldX, worldY)
31
+ return new Vec2(o[0], o[1])
32
+ }
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
+ }
@@ -0,0 +1,194 @@
1
+ // Base 2D scene node — an empty transform. Sprite and Tilemap extend it, so every node operation
2
+ // (position, layer, visibility, events) works on them. Native (creator-2d) owns the authoritative
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.
8
+ //
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).
11
+
12
+ import { AspectHost } from "../core/Aspect"
13
+ import { Registry } from "../core/registry"
14
+ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
15
+ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
16
+ import { ensurePointerEvents } from "./touch"
17
+
18
+ // re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
19
+ export { Vec2, type Vec2Like }
20
+
21
+ export type Node2DEvents = {
22
+ /** A looping animation clip wrapped around (fires each loop). */
23
+ loopReached: (clip: string) => void
24
+ /** A non-looping animation clip finished. */
25
+ completed: (clip: string) => void
26
+ /** A physics contact / sensor overlap began / ended (the other node). */
27
+ enter: (other: Node2D) => void
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
33
+ }
34
+
35
+ /** id → Node2D, so host callbacks (animation events) route back to the owning JS object. */
36
+ export const node2dRegistry = new Registry<Node2D>()
37
+
38
+ export class Node2D extends AspectHost<Node2DEvents> {
39
+ /** Native entity handle. */
40
+ readonly id: number
41
+
42
+ protected _x = 0
43
+ protected _y = 0
44
+ protected _rotation = 0
45
+ protected _sx = 1
46
+ protected _sy = 1
47
+ protected _layer = 0
48
+ protected _z = 0
49
+ protected _visible = true
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
+
58
+ constructor(internalId?: number) {
59
+ super()
60
+ this.id = internalId ?? _creator2d.createEntity()
61
+ node2dRegistry.set(this.id, this)
62
+ }
63
+
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() }
68
+
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) }
75
+ set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.setPosition(this.id, this._x, this._y) }
76
+
77
+ /** Rotation in degrees (CCW). */
78
+ get rotation(): number { this._pull(); return this._rotation }
79
+ set rotation(deg: number) { this._rotation = deg; _creator2d.setRotation(this.id, deg) }
80
+
81
+ get scale(): Vec2 { return new Vec2(this._sx, this._sy) }
82
+ set scale(v: Vec2Like | number) {
83
+ const sx = typeof v === "number" ? v : cx(v)
84
+ const sy = typeof v === "number" ? v : cy(v)
85
+ this._sx = sx; this._sy = sy
86
+ _creator2d.setScale(this.id, sx, sy)
87
+ }
88
+
89
+ /** Draw layer (int). Higher layers render on top. */
90
+ get layer(): number { return this._layer }
91
+ set layer(v: number) { this._layer = v; _creator2d.setLayer(this.id, v) }
92
+
93
+ /** Intra-layer depth for non-Y-sorted layers (higher z = on top). */
94
+ get z(): number { return this._z }
95
+ set z(v: number) { this._z = v; _creator2d.setZ(this.id, v) }
96
+
97
+ get visible(): boolean { return this._visible }
98
+ set visible(v: boolean) { this._visible = v; _creator2d.setVisible(this.id, v) }
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
+
118
+ /**
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).
122
+ */
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
134
+ return this
135
+ }
136
+
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'. */
160
+ _dispatchAnimEvent(_clipId: number, _type: number): void {}
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
+
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
190
+ node2dRegistry.delete(this.id)
191
+ _creator2d.destroyEntity(this.id)
192
+ for (const c of orphans) { c._parent = null; c._syncLocalFromNative() }
193
+ }
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
+ }
@@ -0,0 +1,101 @@
1
+ // A 2D scene: a draw set + an orthographic Camera2D. Opening a scene asks the host to bring up the
2
+ // 2D engine and starts its RAF loop; only the active scene renders. Grown from the Phase 0 seed.
3
+
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"
7
+ import { Camera2D } from "./Camera2D"
8
+ import { node2dRegistry, type Node2D } from "./Node2D"
9
+ import { ensurePointerEvents } from "./touch"
10
+
11
+ export type Scene2DOptions = {
12
+ /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
13
+ background?: ColorInput
14
+ }
15
+
16
+ /** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
17
+ class LayerHandle {
18
+ private readonly sceneId: number
19
+ private readonly layer: number
20
+ constructor(sceneId: number, layer: number) { this.sceneId = sceneId; this.layer = layer }
21
+ /** In this layer, lower-on-screen sprites overlap higher ones (top-down feet sorting). */
22
+ ySort(enabled = true): this {
23
+ _creator2d.setLayerYSort(this.sceneId, this.layer, enabled)
24
+ return this
25
+ }
26
+ }
27
+
28
+ export class Scene2D {
29
+ /** Native scene handle. */
30
+ readonly id: number
31
+ readonly camera: Camera2D
32
+
33
+ private static _active: Scene2D | null = null
34
+ static get active(): Scene2D | null { return Scene2D._active }
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
+
40
+ constructor(options: Scene2DOptions = {}) {
41
+ this.id = _creator2d.createScene()
42
+ this.camera = new Camera2D(this.id)
43
+ if (options.background !== undefined) this.background = options.background
44
+ }
45
+
46
+ set background(color: ColorInput) {
47
+ const [ r, g, b, a ] = Color.toRgba01(color)
48
+ _creator2d.sceneSetClearColor(this.id, r, g, b, a)
49
+ }
50
+
51
+ /** Address a draw layer to configure it, e.g. scene2d.layer(1).ySort(). */
52
+ layer(n: number): LayerHandle {
53
+ return new LayerHandle(this.id, n)
54
+ }
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
+
77
+ add(...nodes: Node2D[]): this {
78
+ for (const n of nodes) _creator2d.addEntityToScene(this.id, n.id)
79
+ return this
80
+ }
81
+ remove(...nodes: Node2D[]): this {
82
+ for (const n of nodes) _creator2d.removeEntityFromScene(this.id, n.id)
83
+ return this
84
+ }
85
+
86
+ /** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
87
+ open(): this {
88
+ _creator2d.openScene(this.id)
89
+ Scene2D._active = this
90
+ return this
91
+ }
92
+ close(): this {
93
+ _creator2d.closeScene()
94
+ if (Scene2D._active === this) Scene2D._active = null
95
+ return this
96
+ }
97
+ destroy(): void {
98
+ this.close()
99
+ _creator2d.destroyScene(this.id)
100
+ }
101
+ }