lecodes-cli 0.1.6 → 0.1.7

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 (62) hide show
  1. package/dist/index.js +166 -14
  2. package/package.json +3 -2
  3. package/runtime/sdk/animate/animate.ts +238 -0
  4. package/runtime/sdk/animate/bezier.ts +138 -0
  5. package/runtime/sdk/animate/easings.ts +126 -0
  6. package/runtime/sdk/core/color.ts +66 -0
  7. package/runtime/sdk/core/events.ts +38 -0
  8. package/runtime/sdk/core/registry.ts +23 -0
  9. package/runtime/sdk/g2/Camera2D.ts +73 -0
  10. package/runtime/sdk/g2/Node2D.ts +104 -0
  11. package/runtime/sdk/g2/Scene2D.ts +73 -0
  12. package/runtime/sdk/g2/Sprite.ts +164 -0
  13. package/runtime/sdk/g2/Texture2D.ts +37 -0
  14. package/runtime/sdk/g2/Tilemap.ts +41 -0
  15. package/runtime/sdk/g2/loop.ts +22 -0
  16. package/runtime/sdk/gl/Body.ts +101 -0
  17. package/runtime/sdk/gl/Camera.ts +40 -0
  18. package/runtime/sdk/gl/Geometry.ts +218 -0
  19. package/runtime/sdk/gl/Light.ts +37 -0
  20. package/runtime/sdk/gl/Material.ts +124 -0
  21. package/runtime/sdk/gl/Mesh.ts +123 -0
  22. package/runtime/sdk/gl/Node.ts +307 -0
  23. package/runtime/sdk/gl/Noise.ts +30 -0
  24. package/runtime/sdk/gl/Particles.ts +185 -0
  25. package/runtime/sdk/gl/Plane.ts +53 -0
  26. package/runtime/sdk/gl/Ray.ts +16 -0
  27. package/runtime/sdk/gl/Scene.ts +179 -0
  28. package/runtime/sdk/gl/Texture.ts +35 -0
  29. package/runtime/sdk/gl/controls.ts +95 -0
  30. package/runtime/sdk/gl/state.ts +6 -0
  31. package/runtime/sdk/gl/touch.ts +56 -0
  32. package/runtime/sdk/inject.ts +72 -0
  33. package/runtime/sdk/math/Mathf.ts +118 -0
  34. package/runtime/sdk/math/mat4.ts +278 -0
  35. package/runtime/sdk/math/quat.ts +232 -0
  36. package/runtime/sdk/math/vec.ts +255 -0
  37. package/runtime/sdk/plugins/qr.ts +36 -0
  38. package/runtime/sdk/runtime/device.ts +33 -0
  39. package/runtime/sdk/runtime/fetch.ts +71 -0
  40. package/runtime/sdk/runtime/files.ts +23 -0
  41. package/runtime/sdk/runtime/input.ts +12 -0
  42. package/runtime/sdk/runtime/media.ts +111 -0
  43. package/runtime/sdk/runtime/misc.ts +12 -0
  44. package/runtime/sdk/runtime/net.ts +36 -0
  45. package/runtime/sdk/runtime/storage.ts +13 -0
  46. package/runtime/sdk/runtime/touch.ts +44 -0
  47. package/runtime/sdk/ui/UI.ts +19 -0
  48. package/runtime/sdk/ui/UIButton.ts +84 -0
  49. package/runtime/sdk/ui/UIContainer.ts +100 -0
  50. package/runtime/sdk/ui/UIImage.ts +68 -0
  51. package/runtime/sdk/ui/UIInput.ts +78 -0
  52. package/runtime/sdk/ui/UINode.ts +342 -0
  53. package/runtime/sdk/ui/UIScreen.ts +122 -0
  54. package/runtime/sdk/ui/UIScrollable.ts +70 -0
  55. package/runtime/sdk/ui/UISpacer.ts +14 -0
  56. package/runtime/sdk/ui/UIText.ts +40 -0
  57. package/runtime/sdk/ui/UIVideo.ts +38 -0
  58. package/runtime/sdk/ui/UIVirtualizedList.ts +222 -0
  59. package/runtime/sdk/ui/UIWidget.ts +97 -0
  60. package/runtime/sdk/ui/fonts.ts +10 -0
  61. package/runtime/sdk/ui/router.ts +73 -0
  62. package/runtime/inject.js +0 -4629
@@ -0,0 +1,126 @@
1
+ export type Easing = (v: number) => number
2
+ export type EasingModifier = (easing: Easing) => Easing
3
+
4
+ const DEFAULT_OVERSHOOT_STRENGTH = 1.525
5
+ const BOUNCE_FIRST_THRESHOLD = 4.0 / 11.0
6
+ const BOUNCE_SECOND_THRESHOLD = 8.0 / 11.0
7
+ const BOUNCE_THIRD_THRESHOLD = 9.0 / 10.0
8
+
9
+ // helper constants
10
+ const ca = 4356.0 / 361.0
11
+ const cb = 35442.0 / 1805.0
12
+ const cc = 16061.0 / 1805.0
13
+
14
+ export function linear(p: number): number {
15
+ return p
16
+ }
17
+
18
+ export function bounceOut(p: number): number {
19
+ if (p === 1 || p === 0) return p
20
+ const p2 = p * p
21
+
22
+ return p < BOUNCE_FIRST_THRESHOLD
23
+ ? 7.5625 * p2
24
+ : p < BOUNCE_SECOND_THRESHOLD
25
+ ? 9.075 * p2 - 9.9 * p + 3.4
26
+ : p < BOUNCE_THIRD_THRESHOLD
27
+ ? ca * p2 - cb * p + cc
28
+ : 10.8 * p * p - 20.52 * p + 10.72
29
+ }
30
+
31
+ export function bounceInOut(p: number): number {
32
+ return p < 0.5
33
+ ? 0.5 * (1.0 - bounceOut(1.0 - p * 2.0))
34
+ : 0.5 * bounceOut(p * 2.0 - 1.0) + 0.5
35
+ }
36
+
37
+ // Accepts an easing function and returns a new one that outputs reversed values.
38
+ // Turns easeIn into easeOut.
39
+ export function reverseEasing(easing: Easing): Easing {
40
+ return p => 1 - easing(1 - p)
41
+ }
42
+
43
+ // Accepts an easing function and returns a new one that outputs mirrored values for
44
+ // the second half of the animation. Turns easeIn into easeInOut.
45
+ export function mirrorEasing(easing: Easing): Easing {
46
+ return p => (p <= 0.5 ? easing(2 * p) / 2 : (2 - easing(2 * (1 - p))) / 2)
47
+ }
48
+
49
+ // Creates an easing function that is based on the exponent of the provided `power`.
50
+ // The higher the `power`, the stronger the easing.
51
+ export function createExpoIn(power: number): Easing {
52
+ return p => p ** power
53
+ }
54
+
55
+ // Creates an easing function that has a stronger overshoot the higher the provided `power`.
56
+ export function createBackIn(power: number): Easing {
57
+ return p => p * p * ((power + 1) * p - power)
58
+ }
59
+
60
+ // Creates an easing function that pulls back a little before moving, and then
61
+ // has a `createBackIn`-based overshoot
62
+ export function createAnticipate(power: number): Easing {
63
+ const backEasing = createBackIn(power)
64
+
65
+ return p =>
66
+ (p *= 2) < 1
67
+ ? 0.5 * backEasing(p)
68
+ : 0.5 * (2 - Math.pow(2, -10 * (p - 1)))
69
+ }
70
+
71
+ export function bounceIn(p: number): number {
72
+ return 1 - bounceOut(1 - p)
73
+ }
74
+
75
+ export function easeIn(p: number): number {
76
+ return p * p
77
+ }
78
+
79
+ export function easeOut(p: number): number {
80
+ return 1 - easeIn(1 - p)
81
+ }
82
+
83
+ export function easeInOut(p: number): number {
84
+ return p <= 0.5
85
+ ? easeIn(2 * p) / 2
86
+ : (2 - easeIn(2 * (1 - p))) / 2
87
+ }
88
+
89
+ export function circIn(p: number): number {
90
+ return 1 - Math.sin(Math.acos(p))
91
+ }
92
+
93
+ export function circOut(p: number): number {
94
+ return 1 - circIn(1 - p)
95
+ }
96
+
97
+ export function circInOut(p: number): number {
98
+ return p <= 0.5
99
+ ? circOut(2 * p) / 2
100
+ : (2 - circOut(2 * (1 - p))) / 2
101
+ }
102
+
103
+ export function backIn(p: number): number {
104
+ const power = DEFAULT_OVERSHOOT_STRENGTH
105
+ return p * p * ((power + 1) * p - power)
106
+ }
107
+
108
+ export function backOut(p: number): number {
109
+ return 1 - backIn(1 - p)
110
+ }
111
+
112
+ export function backInOut(p: number): number {
113
+ return p <= 0.5
114
+ ? backIn(2 * p) / 2
115
+ : (2 - backIn(2 * (1 - p))) / 2
116
+ }
117
+
118
+ export function anticipate(p: number): number {
119
+ const doubled = p * 2
120
+
121
+ if (doubled < 1) {
122
+ return 0.5 * backIn(doubled)
123
+ }
124
+
125
+ return 0.5 * (2 - Math.pow(2, -10 * (doubled - 1)))
126
+ }
@@ -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,73 @@
1
+ // Orthographic camera for a Scene2D. Position/zoom/rotation live natively; `follow` adds a
2
+ // render-synced per-frame update that tracks a node.
3
+
4
+ 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
+
15
+ export class Camera2D {
16
+ private _x = 0
17
+ private _y = 0
18
+ private _zoom = 1
19
+ private _rotation = 0
20
+
21
+ private _followTarget: Node2D | null = null
22
+ private _followOpts: FollowOptions = {}
23
+ private _followCb: ((dt: number) => void) | null = null
24
+
25
+ private readonly sceneId: number
26
+ constructor(sceneId: number) { this.sceneId = sceneId }
27
+
28
+ get position(): Vec2 { return new Vec2(this._x, this._y) }
29
+ set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.cameraSetPosition(this.sceneId, this._x, this._y) }
30
+
31
+ get zoom(): number { return this._zoom }
32
+ set zoom(z: number) { this._zoom = z; _creator2d.cameraSetZoom(this.sceneId, z) }
33
+
34
+ get rotation(): number { return this._rotation }
35
+ set rotation(deg: number) { this._rotation = deg; _creator2d.cameraSetRotation(this.sceneId, deg) }
36
+
37
+ screenToWorld(screenX: number, screenY: number): Vec2 {
38
+ const o = _creator2d.cameraScreenToWorld(this.sceneId, screenX, screenY)
39
+ return new Vec2(o[0], o[1])
40
+ }
41
+ worldToScreen(worldX: number, worldY: number): Vec2 {
42
+ const o = _creator2d.cameraWorldToScreen(this.sceneId, worldX, worldY)
43
+ return new Vec2(o[0], o[1])
44
+ }
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
+ }
@@ -0,0 +1,104 @@
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 is the sole writer (no 2D physics yet) so a local cache makes reads free.
4
+ //
5
+ // Empty Node2Ds are useful as: grouping parents, world-space markers/spawn points, and trigger /
6
+ // logic objects you attach a .collider() to.
7
+
8
+ import { Emitter } from "../core/events"
9
+ import { Registry } from "../core/registry"
10
+ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
11
+
12
+ // re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
13
+ export { Vec2, type Vec2Like }
14
+
15
+ export type Node2DEvents = {
16
+ /** A looping animation clip wrapped around (fires each loop). */
17
+ loopReached: (clip: string) => void
18
+ /** A non-looping animation clip finished. */
19
+ completed: (clip: string) => void
20
+ /** A collider overlap began / ended (deferred — native 2D triggers are not built yet). */
21
+ enter: (other: Node2D) => void
22
+ exit: (other: Node2D) => void
23
+ }
24
+
25
+ /** id → Node2D, so host callbacks (animation events) route back to the owning JS object. */
26
+ export const node2dRegistry = new Registry<Node2D>()
27
+
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
+ /** Native entity handle. */
39
+ readonly id: number
40
+
41
+ protected _x = 0
42
+ protected _y = 0
43
+ protected _rotation = 0
44
+ protected _sx = 1
45
+ protected _sy = 1
46
+ protected _layer = 0
47
+ protected _z = 0
48
+ protected _visible = true
49
+
50
+ constructor(internalId?: number) {
51
+ super()
52
+ this.id = internalId ?? _creator2d.createEntity()
53
+ node2dRegistry.set(this.id, this)
54
+ }
55
+
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) }
61
+
62
+ get position(): Vec2 { return new Vec2(this._x, this._y) }
63
+ set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.setPosition(this.id, this._x, this._y) }
64
+
65
+ /** Rotation in degrees (CCW). */
66
+ get rotation(): number { return this._rotation }
67
+ set rotation(deg: number) { this._rotation = deg; _creator2d.setRotation(this.id, deg) }
68
+
69
+ get scale(): Vec2 { return new Vec2(this._sx, this._sy) }
70
+ set scale(v: Vec2Like | number) {
71
+ const sx = typeof v === "number" ? v : cx(v)
72
+ const sy = typeof v === "number" ? v : cy(v)
73
+ this._sx = sx; this._sy = sy
74
+ _creator2d.setScale(this.id, sx, sy)
75
+ }
76
+
77
+ /** Draw layer (int). Higher layers render on top. */
78
+ get layer(): number { return this._layer }
79
+ set layer(v: number) { this._layer = v; _creator2d.setLayer(this.id, v) }
80
+
81
+ /** Intra-layer depth for non-Y-sorted layers (higher z = on top). */
82
+ get z(): number { return this._z }
83
+ set z(v: number) { this._z = v; _creator2d.setZ(this.id, v) }
84
+
85
+ get visible(): boolean { return this._visible }
86
+ set visible(v: boolean) { this._visible = v; _creator2d.setVisible(this.id, v) }
87
+
88
+ /**
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.
92
+ */
93
+ collider(_options: ColliderOptions = {}): this {
94
+ return this
95
+ }
96
+
97
+ /** @internal — overridden by Sprite to translate native clip ids into named animation events. */
98
+ _dispatchAnimEvent(_clipId: number, _type: number): void {}
99
+
100
+ destroy(): void {
101
+ node2dRegistry.delete(this.id)
102
+ _creator2d.destroyEntity(this.id)
103
+ }
104
+ }
@@ -0,0 +1,73 @@
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 { Camera2D } from "./Camera2D"
6
+ import type { Node2D } from "./Node2D"
7
+
8
+ export type Scene2DOptions = {
9
+ /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
10
+ background?: ColorInput
11
+ }
12
+
13
+ /** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
14
+ class LayerHandle {
15
+ private readonly sceneId: number
16
+ private readonly layer: number
17
+ constructor(sceneId: number, layer: number) { this.sceneId = sceneId; this.layer = layer }
18
+ /** In this layer, lower-on-screen sprites overlap higher ones (top-down feet sorting). */
19
+ ySort(enabled = true): this {
20
+ _creator2d.setLayerYSort(this.sceneId, this.layer, enabled)
21
+ return this
22
+ }
23
+ }
24
+
25
+ export class Scene2D {
26
+ /** Native scene handle. */
27
+ readonly id: number
28
+ readonly camera: Camera2D
29
+
30
+ private static _active: Scene2D | null = null
31
+ static get active(): Scene2D | null { return Scene2D._active }
32
+
33
+ constructor(options: Scene2DOptions = {}) {
34
+ this.id = _creator2d.createScene()
35
+ this.camera = new Camera2D(this.id)
36
+ if (options.background !== undefined) this.background = options.background
37
+ }
38
+
39
+ set background(color: ColorInput) {
40
+ const [ r, g, b, a ] = Color.toRgba01(color)
41
+ _creator2d.sceneSetClearColor(this.id, r, g, b, a)
42
+ }
43
+
44
+ /** Address a draw layer to configure it, e.g. scene2d.layer(1).ySort(). */
45
+ layer(n: number): LayerHandle {
46
+ return new LayerHandle(this.id, n)
47
+ }
48
+
49
+ add(...nodes: Node2D[]): this {
50
+ for (const n of nodes) _creator2d.addEntityToScene(this.id, n.id)
51
+ return this
52
+ }
53
+ remove(...nodes: Node2D[]): this {
54
+ for (const n of nodes) _creator2d.removeEntityFromScene(this.id, n.id)
55
+ return this
56
+ }
57
+
58
+ /** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
59
+ open(): this {
60
+ _creator2d.openScene(this.id)
61
+ Scene2D._active = this
62
+ return this
63
+ }
64
+ close(): this {
65
+ _creator2d.closeScene()
66
+ if (Scene2D._active === this) Scene2D._active = null
67
+ return this
68
+ }
69
+ destroy(): void {
70
+ this.close()
71
+ _creator2d.destroyScene(this.id)
72
+ }
73
+ }
@@ -0,0 +1,164 @@
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')
4
+
5
+ import { Color, type ColorInput } from "../core/color"
6
+ import { Vec2, type Vec2Like } from "../math/vec"
7
+ import { ensureAnimEvents } from "./loop"
8
+ import { Node2D } from "./Node2D"
9
+ import type { Texture2D } from "./Texture2D"
10
+
11
+ export type SpriteOptions = {
12
+ texture?: Texture2D
13
+ /** Pivot, [0,0] = top-left .. [1,1] = bottom-right. Use [0.5, 1] (feet) for Y-sorted depth. */
14
+ anchor?: Vec2Like
15
+ /** World size; defaults to the texture's pixel dimensions (or the clip frame size once set). */
16
+ size?: Vec2Like
17
+ /** Normalized UV sub-rect [u0,v0,u1,v1]. */
18
+ frame?: [number, number, number, number]
19
+ /** Tint multiplied with the texture. */
20
+ color?: ColorInput
21
+ opacity?: number
22
+ layer?: number
23
+ position?: Vec2Like
24
+ }
25
+
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
+ export class Sprite extends Node2D {
41
+ private _texture?: Texture2D
42
+ private _anchor = new Vec2(0.5, 0.5)
43
+ private _color: ColorInput = 0xffffff
44
+ private _opacity = 1
45
+ private _flipX = false
46
+ private _flipY = false
47
+ private _size: Vec2 | null = null
48
+
49
+ private _clipIds: Record<string, number> = {}
50
+ private _clipNames: Record<number, string> = {}
51
+ private _current: string | null = null
52
+
53
+ constructor(options: SpriteOptions = {}) {
54
+ super()
55
+ if (options.texture) this.texture = options.texture
56
+ if (options.size) this.size = options.size
57
+ if (options.anchor) this.anchor = options.anchor
58
+ if (options.frame) this.frame = options.frame
59
+ if (options.color !== undefined) this.color = options.color
60
+ if (options.opacity !== undefined) this.opacity = options.opacity
61
+ if (options.layer !== undefined) this.layer = options.layer
62
+ if (options.position) this.position = options.position
63
+ }
64
+
65
+ get texture(): Texture2D | undefined { return this._texture }
66
+ set texture(t: Texture2D | undefined) {
67
+ this._texture = t
68
+ if (!t) return
69
+ _creator2d.setSprite(this.id, t.id) // native resets UV + size to the full texture
70
+ if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
71
+ }
72
+
73
+ /** Current world size: explicit override if set, else the texture's pixel dimensions. */
74
+ get size(): Vec2 { return this._size ?? (this._texture ? new Vec2(this._texture.width, this._texture.height) : new Vec2(0, 0)) }
75
+ set size(v: Vec2Like) { this._size = new Vec2(v); _creator2d.setSpriteSize(this.id, this._size.x, this._size.y) }
76
+
77
+ get anchor(): Vec2 { return this._anchor.clone() }
78
+ set anchor(v: Vec2Like) { this._anchor = new Vec2(v); _creator2d.setSpriteAnchor(this.id, this._anchor.x, this._anchor.y) }
79
+
80
+ /** Set the UV sub-rect (normalized). */
81
+ set frame(uv: [number, number, number, number]) { _creator2d.setSpriteFrame(this.id, uv[0], uv[1], uv[2], uv[3]) }
82
+ /** Set the sub-rect in texture pixels. */
83
+ setFramePx(px: number, py: number, pw: number, ph: number): this {
84
+ _creator2d.setSpriteFramePx(this.id, px, py, pw, ph)
85
+ return this
86
+ }
87
+
88
+ get color(): ColorInput { return this._color }
89
+ set color(c: ColorInput) { this._color = c; const [ r, g, b ] = Color.toRgb01(c); _creator2d.setSpriteColor(this.id, r, g, b) }
90
+
91
+ get opacity(): number { return this._opacity }
92
+ set opacity(a: number) { this._opacity = a; _creator2d.setSpriteOpacity(this.id, a) }
93
+
94
+ get flipX(): boolean { return this._flipX }
95
+ set flipX(v: boolean) { this._flipX = v; _creator2d.setSpriteFlip(this.id, v, this._flipY) }
96
+ get flipY(): boolean { return this._flipY }
97
+ 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
+ }