lecodes-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Clone, edit and push LeCodes projects from your machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "@letary/chisel": "^0.1.2"
31
31
  },
32
32
  "peerDependencies": {
33
- "lecodes-renderer": "^0.2.0"
33
+ "lecodes-renderer": "^0.3.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "lecodes-renderer": {
@@ -0,0 +1,184 @@
1
+ // A core 2D drawing surface that bakes to a texture — see docs/canvas-contract.md. It is NOT part of
2
+ // creator-2d/creator-gl: the texture it produces is consumed by 2D sprites, 3D materials and UI
3
+ // images alike ("a canvas is just a texture"), so world/label text and arbitrary 2D graphics are
4
+ // drawn once, per platform, on that platform's native 2D stack (web OffscreenCanvas, iOS CoreGraphics,
5
+ // Android Canvas).
6
+ //
7
+ // The API looks immediate but RECORDS commands into a compact buffer and flushes them to the backend
8
+ // ONCE per bake (one bridge crossing, not one per lineTo). Colors and fonts are CSS strings, resolved
9
+ // by the platform. Do all drawing AFTER `await registerFont(...)` — an unloaded font mis-measures.
10
+ //
11
+ // State (fillStyle, font, textAlign, …) is get/set like the browser; drawing calls are methods (they
12
+ // return `this`, so drawing still chains). `pixelRatio` is the device-pixel multiplier (browser-style
13
+ // `scale(sx, sy)` is a transform).
14
+ //
15
+ // const c = new Canvas(160, 40, { pixelRatio: 2 })
16
+ // c.font = 'bold 24px Inter'
17
+ // const w = c.measureText('Alice').width + 24
18
+ // c.resize(w, 40)
19
+ // c.fillStyle = '#000a'; c.roundRect(0, 0, w, 40, 12).fill()
20
+ // c.fillStyle = '#fff'; c.textAlign = 'center'; c.fillText('Alice', w / 2, 27)
21
+ // new Sprite({ texture: c }) // Canvas is a valid texture source everywhere
22
+
23
+ import { Texture2D } from "../g2/Texture2D"
24
+
25
+ // Opcodes — MUST stay in sync with docs/canvas-contract.md.
26
+ const OP = {
27
+ SAVE: 0, RESTORE: 1, TRANSLATE: 2, SCALE: 3, ROTATE: 4, GLOBAL_ALPHA: 5,
28
+ FILL_STYLE: 6, STROKE_STYLE: 7, LINE_WIDTH: 8, LINE_JOIN: 9, LINE_CAP: 10,
29
+ FONT: 11, TEXT_ALIGN: 12, TEXT_BASELINE: 13,
30
+ BEGIN_PATH: 14, CLOSE_PATH: 15, MOVE_TO: 16, LINE_TO: 17, QUADRATIC_TO: 18, BEZIER_TO: 19,
31
+ ARC: 20, RECT: 21, ROUND_RECT: 22, FILL: 23, STROKE: 24,
32
+ FILL_RECT: 25, STROKE_RECT: 26, CLEAR_RECT: 27, FILL_TEXT: 28, STROKE_TEXT: 29,
33
+ } as const
34
+
35
+ const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
36
+ const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
37
+ const JOIN = { miter: 0, round: 1, bevel: 2 } as const
38
+ const CAP = { butt: 0, round: 1, square: 2 } as const
39
+
40
+ export type TextAlign = keyof typeof ALIGN
41
+ export type TextBaseline = keyof typeof BASELINE
42
+ export type LineJoin = keyof typeof JOIN
43
+ export type LineCap = keyof typeof CAP
44
+ export interface CanvasTextMetrics { width: number, ascent: number, descent: number }
45
+
46
+ export class Canvas {
47
+ /** Brand so `Sprite`/`UIImage`/`Material` can accept a Canvas anywhere a texture source is expected. */
48
+ readonly __canvas = true as const
49
+
50
+ width: number
51
+ height: number
52
+ /** Device-pixel multiplier (draw at 2 for crisp retina text). Applied by the backend. */
53
+ pixelRatio: number
54
+
55
+ private _cmd: number[] = []
56
+ private _refs: string[] = []
57
+ // Shadowed drawing state so the get/set accessors read back the current value (like the browser).
58
+ private _globalAlpha = 1
59
+ private _fillStyle = "#000000"
60
+ private _strokeStyle = "#000000"
61
+ private _lineWidth = 1
62
+ private _lineJoin: LineJoin = "miter"
63
+ private _lineCap: LineCap = "butt"
64
+ private _font = "10px sans-serif"
65
+ private _textAlign: TextAlign = "start"
66
+ private _textBaseline: TextBaseline = "alphabetic"
67
+ private _surfaceId = 0
68
+ private _tex2d?: Texture2D // cached 2D texture, re-uploaded on update()
69
+ /** @internal — cached 3D (creator-gl) texture id, set by gl/Texture.fromCanvas; re-uploaded on
70
+ * update(). Kept as a bare id (not a Texture import) so a 2D-only canvas never pulls the 3D engine. */
71
+ _tex3dId = 0
72
+
73
+ constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
74
+ this.width = width
75
+ this.height = height
76
+ this.pixelRatio = opts.pixelRatio ?? 1
77
+ }
78
+
79
+ private _ref(s: string): number {
80
+ const i = this._refs.indexOf(s)
81
+ if (i >= 0) return i
82
+ this._refs.push(s)
83
+ return this._refs.length - 1
84
+ }
85
+
86
+ /** Discard the recorded drawing to start a new one (for dynamic canvases). */
87
+ reset(): this { this._cmd.length = 0; this._refs.length = 0; return this }
88
+ /** Change the surface size; takes effect on the next bake. */
89
+ resize(width: number, height: number): this { this.width = width; this.height = height; return this }
90
+
91
+ // --- transforms & state stack (actions; chainable) ---
92
+ save(): this { this._cmd.push(OP.SAVE); return this }
93
+ restore(): this { this._cmd.push(OP.RESTORE); return this }
94
+ translate(x: number, y: number): this { this._cmd.push(OP.TRANSLATE, x, y); return this }
95
+ scale(sx: number, sy: number): this { this._cmd.push(OP.SCALE, sx, sy); return this }
96
+ rotate(rad: number): this { this._cmd.push(OP.ROTATE, rad); return this }
97
+
98
+ // --- drawing state (get/set like the browser; colors/fonts are CSS strings resolved by the platform).
99
+ // The setter records the state op AND shadows the value so the getter reads it back.
100
+ get globalAlpha(): number { return this._globalAlpha }
101
+ set globalAlpha(a: number) { this._globalAlpha = a; this._cmd.push(OP.GLOBAL_ALPHA, a) }
102
+ get fillStyle(): string { return this._fillStyle }
103
+ set fillStyle(color: string) { this._fillStyle = color; this._cmd.push(OP.FILL_STYLE, this._ref(color)) }
104
+ get strokeStyle(): string { return this._strokeStyle }
105
+ set strokeStyle(color: string) { this._strokeStyle = color; this._cmd.push(OP.STROKE_STYLE, this._ref(color)) }
106
+ get lineWidth(): number { return this._lineWidth }
107
+ set lineWidth(w: number) { this._lineWidth = w; this._cmd.push(OP.LINE_WIDTH, w) }
108
+ get lineJoin(): LineJoin { return this._lineJoin }
109
+ set lineJoin(j: LineJoin) { this._lineJoin = j; this._cmd.push(OP.LINE_JOIN, JOIN[j]) }
110
+ get lineCap(): LineCap { return this._lineCap }
111
+ set lineCap(c: LineCap) { this._lineCap = c; this._cmd.push(OP.LINE_CAP, CAP[c]) }
112
+ get font(): string { return this._font }
113
+ set font(f: string) { this._font = f; this._cmd.push(OP.FONT, this._ref(f)) }
114
+ get textAlign(): TextAlign { return this._textAlign }
115
+ set textAlign(a: TextAlign) { this._textAlign = a; this._cmd.push(OP.TEXT_ALIGN, ALIGN[a]) }
116
+ get textBaseline(): TextBaseline { return this._textBaseline }
117
+ set textBaseline(b: TextBaseline) { this._textBaseline = b; this._cmd.push(OP.TEXT_BASELINE, BASELINE[b]) }
118
+
119
+ // --- paths ---
120
+ beginPath(): this { this._cmd.push(OP.BEGIN_PATH); return this }
121
+ closePath(): this { this._cmd.push(OP.CLOSE_PATH); return this }
122
+ moveTo(x: number, y: number): this { this._cmd.push(OP.MOVE_TO, x, y); return this }
123
+ lineTo(x: number, y: number): this { this._cmd.push(OP.LINE_TO, x, y); return this }
124
+ quadraticCurveTo(cx: number, cy: number, x: number, y: number): this { this._cmd.push(OP.QUADRATIC_TO, cx, cy, x, y); return this }
125
+ bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
126
+ this._cmd.push(OP.BEZIER_TO, c1x, c1y, c2x, c2y, x, y); return this
127
+ }
128
+ arc(x: number, y: number, r: number, a0: number, a1: number, ccw = false): this {
129
+ this._cmd.push(OP.ARC, x, y, r, a0, a1, ccw ? 1 : 0); return this
130
+ }
131
+ rect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.RECT, x, y, w, h); return this }
132
+ roundRect(x: number, y: number, w: number, h: number, r: number): this { this._cmd.push(OP.ROUND_RECT, x, y, w, h, r); return this }
133
+ fill(evenOdd = false): this { this._cmd.push(OP.FILL, evenOdd ? 1 : 0); return this }
134
+ stroke(): this { this._cmd.push(OP.STROKE); return this }
135
+
136
+ // --- shape/text sugar ---
137
+ fillRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.FILL_RECT, x, y, w, h); return this }
138
+ strokeRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.STROKE_RECT, x, y, w, h); return this }
139
+ clearRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.CLEAR_RECT, x, y, w, h); return this }
140
+ fillText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.FILL_TEXT, this._ref(text), x, y, maxWidth); return this }
141
+ strokeText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.STROKE_TEXT, this._ref(text), x, y, maxWidth); return this }
142
+
143
+ /** Measure `text` in the current font on THIS platform. The one synchronous round-trip. */
144
+ measureText(text: string): CanvasTextMetrics {
145
+ const o = _creatorCanvas.measureText(text, this._font)
146
+ return { width: o[0], ascent: o[1], descent: o[2] }
147
+ }
148
+
149
+ /** @internal — rasterize the recorded commands into the surface; returns the surface id. */
150
+ _bakeSurface(): number {
151
+ const buf = new Float32Array(this._cmd)
152
+ this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
153
+ return this._surfaceId
154
+ }
155
+
156
+ /** Bake and return the 2D texture (created once, then cached). */
157
+ texture(): Texture2D {
158
+ if (!this._tex2d) this._tex2d = Texture2D.fromCanvas(this)
159
+ return this._tex2d
160
+ }
161
+
162
+ /** Re-rasterize and re-upload to every texture this canvas produced (call after redrawing) — the
163
+ * cached 2D and/or 3D texture, whichever engines this canvas was baked into. */
164
+ update(): this {
165
+ const surfaceId = this._bakeSurface()
166
+ if (this._tex2d) _creator2d.updateTextureFromCanvas(this._tex2d.id, surfaceId)
167
+ if (this._tex3dId) _creator.updateTextureFromCanvas(this._tex3dId, surfaceId)
168
+ return this
169
+ }
170
+
171
+ destroy(): void {
172
+ if (this._surfaceId) _creatorCanvas.destroySurface(this._surfaceId)
173
+ this._tex2d?.destroy()
174
+ // The 3D texture (if any) is owned by creator-gl's engine and freed at its teardown — no per-texture
175
+ // destroy in the 3D API — so we just drop our cached id here.
176
+ this._surfaceId = 0
177
+ this._tex2d = undefined
178
+ this._tex3dId = 0
179
+ }
180
+ }
181
+
182
+ /** Duck-type guard so consumers can accept a Canvas anywhere a texture source is expected. */
183
+ export const isCanvas = (v: unknown): v is Canvas =>
184
+ typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
@@ -0,0 +1,57 @@
1
+ // Convenience recipe (userland-style TS, not a native primitive): draw a text pill into a Canvas and
2
+ // hand back a Sprite you can drop over an NPC / actor. The same Canvas texture also works as a 3D
3
+ // billboard or a UIImage — Label just wraps the common 2D case.
4
+ //
5
+ // const tag = Label('Alice')
6
+ // tag.position = npc.worldPosition // float it above the NPC (offset in y as needed)
7
+ //
8
+ // Fonts must be registered first: `await registerFont('Inter', url)` before Label(...).
9
+
10
+ import { Canvas } from "./Canvas"
11
+ import { Sprite } from "../g2/Sprite"
12
+
13
+ export interface LabelOptions {
14
+ /** CSS font shorthand. Default 'bold 24px sans-serif'. */
15
+ font?: string
16
+ /** Text color. Default '#fff'. */
17
+ color?: string
18
+ /** Pill background; null for no pill (bare text). Default '#000a'. */
19
+ background?: string | null
20
+ /** Horizontal / vertical padding around the text (logical px). Default 12 / 6. */
21
+ padX?: number
22
+ padY?: number
23
+ /** Corner radius of the pill. Default 10. */
24
+ radius?: number
25
+ /** Device-pixel multiplier for crisp text. Default 2. */
26
+ pixelRatio?: number
27
+ /** Sprite draw layer. */
28
+ layer?: number
29
+ }
30
+
31
+ export function Label(text: string, opts: LabelOptions = {}): Sprite {
32
+ const font = opts.font ?? "bold 24px sans-serif"
33
+ const color = opts.color ?? "#fff"
34
+ const bg = opts.background === undefined ? "#000a" : opts.background
35
+ const padX = opts.padX ?? 12
36
+ const padY = opts.padY ?? 6
37
+ const radius = opts.radius ?? 10
38
+ const pixelRatio = opts.pixelRatio ?? 2
39
+
40
+ // measure first (native metrics) to size the pill to the text
41
+ const c = new Canvas(1, 1, { pixelRatio })
42
+ c.font = font
43
+ const m = c.measureText(text)
44
+ const lineH = m.ascent + m.descent
45
+ const w = Math.ceil(m.width + padX * 2)
46
+ const h = Math.ceil(lineH + padY * 2)
47
+ c.reset().resize(w, h)
48
+ c.font = font // reset() cleared the buffer — re-record the font for the actual draw
49
+
50
+ if (bg !== null) { c.fillStyle = bg; c.roundRect(0, 0, w, h, radius).fill() }
51
+ c.fillStyle = color
52
+ c.textAlign = "center"
53
+ c.textBaseline = "middle"
54
+ c.fillText(text, w / 2, h / 2)
55
+
56
+ return new Sprite({ texture: c, anchor: [0.5, 1], layer: opts.layer })
57
+ }
@@ -39,28 +39,70 @@ type UnionToIntersection<U> =
39
39
  export type With<N, A extends Aspect<any, any>> =
40
40
  N & UnionToIntersection<A extends any ? FieldOf<A> : never>
41
41
 
42
- // ---- per-frame update dispatch ----------------------------------------------
43
- // Aspects that define update(dt) are ticked every frame from a SINGLE global loop, created LAZILY on
44
- // the first such aspect (a project with no behavior aspects starts no loop). dt is seconds, like
45
- // setLoop. No visibility/frustum culling yet — every registered aspect ticks regardless of on-screen
46
- // state.
47
- const updaters: Aspect<any, any>[] = []
48
- let updateLoopId: number | undefined
49
-
50
- const ensureUpdateLoop = (): void => {
51
- if (updateLoopId !== undefined) return
52
- updateLoopId = setLoop((dt) => {
53
- // iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
54
- for (const a of updaters.slice()) {
55
- // later (visibility culling): if (a.updateWhenVisible && nodeOffScreen(a)) continue
56
- a.update?.(dt)
57
- }
58
- })
42
+ // ---- per-frame update dispatch (two phases) ---------------------------------
43
+ // Aspects that define update(dt) are ticked every frame in ONE of two phases, chosen by the aspect's
44
+ // `updateBeforePhysics` field:
45
+ // • LATE (default): after the physics step + transform sync, right before the frame draws. Reads of
46
+ // node.worldPosition are the FINAL drawn position — cameras/followers land exactly, no 1-frame lag.
47
+ // • EARLY (updateBeforePhysics = true): before the physics step, so writing velocity/force/kinematic
48
+ // transforms is consumed by the SAME frame's step (zero input latency).
49
+ // The FRAME SOURCE is pluggable so core never names a specific engine bridge (a 3D-only bundle must not
50
+ // pull in the 2D one): the 2D layer installs a render-synced source via _installAspectFrames (the native
51
+ // c2dRender early/late hooks), so dispatch is in lockstep with the draw. With none installed (3D-only,
52
+ // or a runtime without the hooks) it falls back to a single host setLoop, early-then-late — no true
53
+ // post-physics slot. Both lists stay sorted by the aspect's `order` field (default 0; ties keep
54
+ // insertion order). Lazy: the first update-bearing aspect installs the dispatch; a project with none
55
+ // starts nothing. No visibility culling yet — every registered aspect ticks regardless of on-screen state.
56
+ type Updatable = Aspect<any, any>
57
+ type PhaseFn = (dt: number) => void
58
+ const earlyUpdaters: Updatable[] = []
59
+ const lateUpdaters: Updatable[] = []
60
+ let dispatchInstalled = false
61
+
62
+ // A render-synced frame source: given the two phase runners, wire them to the engine and return true;
63
+ // return false if unavailable (→ setLoop fallback). Installed by the 2D layer (see Scene2D) so core
64
+ // never references a concrete engine bridge — that keeps the 2D bridge out of a 3D-only bundle.
65
+ type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
66
+ let frameInstaller: FrameInstaller | undefined
67
+ /** @internal Install a render-synced frame source for aspect update(dt). Called from the 2D layer. */
68
+ export const _installAspectFrames = (fn: FrameInstaller): void => { frameInstaller = fn }
69
+
70
+ const policyOf = (a: Updatable): { early: boolean; order: number } =>
71
+ ({ early: a.updateBeforePhysics === true, order: a.order ?? 0 })
72
+
73
+ // Stable insert by ascending order: place after the last element whose order is <= this one's.
74
+ const addSorted = (list: Updatable[], a: Updatable, order: number): void => {
75
+ let i = list.length
76
+ while (i > 0 && policyOf(list[i - 1]).order > order) i--
77
+ list.splice(i, 0, a)
59
78
  }
60
- const registerUpdater = (a: Aspect<any, any>): void => { updaters.push(a); ensureUpdateLoop() }
61
- const unregisterUpdater = (a: Aspect<any, any>): void => {
62
- const i = updaters.indexOf(a)
63
- if (i >= 0) updaters.splice(i, 1)
79
+
80
+ const runPhase = (list: Updatable[], dt: number): void => {
81
+ // iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
82
+ for (const a of list.slice()) a.update?.(dt)
83
+ }
84
+
85
+ const ensureDispatch = (): void => {
86
+ if (dispatchInstalled) return
87
+ dispatchInstalled = true
88
+ const early: PhaseFn = (dt) => runPhase(earlyUpdaters, dt)
89
+ const late: PhaseFn = (dt) => runPhase(lateUpdaters, dt)
90
+ // render-synced source if the engine layer installed one; else one host loop (early then late).
91
+ if (!(frameInstaller && frameInstaller(early, late))) {
92
+ setLoop((dt) => { early(dt); late(dt) })
93
+ }
94
+ }
95
+
96
+ const registerUpdater = (a: Updatable): void => {
97
+ const { early, order } = policyOf(a)
98
+ addSorted(early ? earlyUpdaters : lateUpdaters, a, order)
99
+ ensureDispatch()
100
+ }
101
+ const unregisterUpdater = (a: Updatable): void => {
102
+ let i = earlyUpdaters.indexOf(a)
103
+ if (i >= 0) { earlyUpdaters.splice(i, 1); return }
104
+ i = lateUpdaters.indexOf(a)
105
+ if (i >= 0) lateUpdaters.splice(i, 1)
64
106
  }
65
107
 
66
108
  // ---- the host: mixed into every node kind (Node2D, Node) --------------------
@@ -142,10 +184,24 @@ export abstract class Aspect<K extends string, P = Node> {
142
184
  declare protected readonly __key__?: K
143
185
  onAttach?(): void
144
186
  onDetach?(): void
145
- /** Called every frame while attached (dt = seconds since the last frame), via a lazy global loop. */
187
+ /**
188
+ * Called every frame while attached (dt = seconds since the last frame). Render-synced: runs in the
189
+ * LATE phase by default — after the physics step + transform sync, right before the frame draws — so
190
+ * reads of `node.worldPosition` are the final drawn position (cameras/followers have no 1-frame lag).
191
+ * Set `updateBeforePhysics = true` (an instance field) to run in the EARLY phase (before the step)
192
+ * instead — for aspects that WRITE velocity/force/kinematic transforms and want the same frame's
193
+ * step to consume them. Cameras/followers READ, so they stay in the default LATE phase.
194
+ */
146
195
  update?(dt: number): void
147
- /** Opt-in: only run update(dt) while the node is on-screen. Lives on the aspect (not the node) so
148
- * aspects on the same node can choose independently. NOOP for now — visibility culling isn't wired
149
- * yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
196
+
197
+ /** Run update(dt) in the EARLY phase (before the physics step) rather than the default LATE phase.
198
+ * An instance field (like `updateWhenVisible`) — set it as a class field: `updateBeforePhysics = true`.
199
+ * Use only when you FEED the simulation (velocity/force/kinematic transform). Read once, at attach. */
200
+ updateBeforePhysics = false
201
+ /** Tick order within a phase — ascending; default 0, ties keep attach order. Read once, at attach. */
202
+ order = 0
203
+
204
+ /** Opt-in: only run update(dt) while the node is on-screen. NOOP for now — visibility culling isn't
205
+ * wired yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
150
206
  updateWhenVisible = false
151
207
  }
@@ -2,12 +2,29 @@
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 { _installAspectFrames } from "../core/Aspect"
5
6
  import { cx, cy, type Vec2Like } from "../math/vec"
6
7
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
7
8
  import { Camera2D } from "./Camera2D"
8
9
  import { node2dRegistry, type Node2D } from "./Node2D"
9
10
  import { ensurePointerEvents } from "./touch"
10
11
 
12
+ // Route aspect update(dt) through the render-synced native phases (c2dRender early = before physics,
13
+ // late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
14
+ // the 2D layer (not core) so a 3D-only bundle never pulls in the 2D bridge. Installed once, lazily on
15
+ // the first Scene2D. On an older wasm without the hooks the installer returns false → setLoop fallback.
16
+ let framesInstalled = false
17
+ const installRenderSyncedFrames = (): void => {
18
+ if (framesInstalled) return
19
+ framesInstalled = true
20
+ _installAspectFrames((early, late) => {
21
+ if (typeof _creator2d.setEarlyUpdate !== "function") return false
22
+ _creator2d.setEarlyUpdate(early)
23
+ _creator2d.setLateUpdate(late)
24
+ return true
25
+ })
26
+ }
27
+
11
28
  export type Scene2DOptions = {
12
29
  /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
13
30
  background?: ColorInput
@@ -38,6 +55,7 @@ export class Scene2D {
38
55
  readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
39
56
 
40
57
  constructor(options: Scene2DOptions = {}) {
58
+ installRenderSyncedFrames()
41
59
  this.id = _creator2d.createScene()
42
60
  this.camera = new Camera2D(this.id)
43
61
  if (options.background !== undefined) this.background = options.background
@@ -4,11 +4,15 @@
4
4
 
5
5
  import { Color, type ColorInput } from "../core/color"
6
6
  import { Vec2, type Vec2Like } from "../math/vec"
7
+ import { Canvas, isCanvas } from "../canvas/Canvas"
7
8
  import { Node2D } from "./Node2D"
8
9
  import type { Texture2D } from "./Texture2D"
9
10
 
11
+ /** Anything that can back a sprite: a texture, or a Canvas (baked to a texture on assign). */
12
+ export type TextureSource = Texture2D | Canvas
13
+
10
14
  export type SpriteOptions = {
11
- texture?: Texture2D
15
+ texture?: TextureSource
12
16
  /** Pivot, [0,0] = top-left .. [1,1] = bottom-right. Use [0.5, 1] (feet) for Y-sorted depth. */
13
17
  anchor?: Vec2Like
14
18
  /** World size; defaults to the texture's pixel dimensions. */
@@ -44,10 +48,11 @@ export class Sprite extends Node2D {
44
48
  }
45
49
 
46
50
  get texture(): Texture2D | undefined { return this._texture }
47
- set texture(t: Texture2D | undefined) {
48
- this._texture = t
49
- if (!t) return
50
- _creator2d.setSprite(this.id, t.id) // native resets UV + size to the full texture
51
+ set texture(t: TextureSource | undefined) {
52
+ const tex = isCanvas(t) ? t.texture() : t // a Canvas bakes to a texture on assign
53
+ this._texture = tex
54
+ if (!tex) return
55
+ _creator2d.setSprite(this.id, tex.id) // native resets UV + size to the full texture
51
56
  if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
52
57
  }
53
58
 
@@ -17,6 +17,14 @@ export class Texture2D {
17
17
  this.height = height
18
18
  }
19
19
 
20
+ // Wrap a Canvas's baked surface as a texture (RGBA8, already rasterized → synchronous, no decode).
21
+ // Structural type avoids a Canvas import cycle (Canvas imports Texture2D).
22
+ static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number }): Texture2D {
23
+ const surfaceId = canvas._bakeSurface()
24
+ const texId = _creator2d.createTextureFromCanvas(surfaceId)
25
+ return new Texture2D(texId, Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio))
26
+ }
27
+
20
28
  static load(source: string | FetchResponse): Promise<Texture2D> {
21
29
  if (typeof source === "string") {
22
30
  return fetch(source, { useOnce: true }).then((resp) => {
@@ -6,6 +6,12 @@
6
6
  import { Color, type ColorInput } from "../core/color"
7
7
  import { fetch, type FetchResponse } from "../runtime/fetch"
8
8
  import { Texture } from "./Texture"
9
+ // Type-only (erased at build) so a 3D material never pulls the 2D-canvas runtime; the guard is inline.
10
+ import type { Canvas } from "../canvas/Canvas"
11
+
12
+ /** Duck-type a Canvas without importing it (keeps 3D material free of 2D-canvas code). */
13
+ const isCanvasSource = (v: unknown): v is Canvas =>
14
+ typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
9
15
 
10
16
  type UniformValue = string | Texture | number | boolean | number[] | Float32Array | null
11
17
 
@@ -28,7 +34,7 @@ const applyUniform = (id: number, key: string, value: UniformValue): void => {
28
34
  }
29
35
  }
30
36
 
31
- export type MaterialColorOptions = { color?: ColorInput, map?: Texture | null }
37
+ export type MaterialColorOptions = { color?: ColorInput, map?: Texture | Canvas | null }
32
38
 
33
39
  export class Material {
34
40
  /** @internal native material-instance handle. */
@@ -65,11 +71,12 @@ export class Material {
65
71
  if (this._colorKey) this.uniforms[this._colorKey] = Color.toHexString(c)
66
72
  }
67
73
 
68
- set map(value: Texture | null) {
74
+ set map(value: Texture | Canvas | null) {
69
75
  if (value === null) {
70
76
  if (this._useMapFlag) this.uniforms.useBaseColorMap = false
71
77
  } else {
72
- this.uniforms.baseColorMap = value
78
+ // A Canvas bakes to a (cached) 3D texture on assign — same as Sprite accepting a Canvas in 2D.
79
+ this.uniforms.baseColorMap = isCanvasSource(value) ? Texture.fromCanvas(value) : value
73
80
  if (this._useMapFlag) this.uniforms.useBaseColorMap = true
74
81
  }
75
82
  }
@@ -19,6 +19,17 @@ export class Texture {
19
19
  this._id = id
20
20
  }
21
21
 
22
+ // Wrap a Canvas's baked surface as a 3D texture (RGBA8, already rasterized → synchronous, no decode).
23
+ // The id is cached on the canvas (opaque _tex3dId), so backing several materials shares one texture and
24
+ // canvas.update() re-uploads it. Structural type avoids a Canvas import (keeps 3D free of 2D-canvas code).
25
+ static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number, _tex3dId: number }): Texture {
26
+ if (!canvas._tex3dId) {
27
+ const surfaceId = canvas._bakeSurface()
28
+ canvas._tex3dId = _creator.createTextureFromCanvas(surfaceId)
29
+ }
30
+ return new Texture(Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio), canvas._tex3dId)
31
+ }
32
+
22
33
  static load(source: string | FetchResponse): Promise<Texture> {
23
34
  if (typeof source === "string") {
24
35
  return fetch(source, { useOnce: true }).then((resp) => {
@@ -17,6 +17,10 @@ export { Color } from "./core/color"
17
17
  // ---- aspects (attachable node capabilities) ----
18
18
  export { Aspect, type With } from "./core/Aspect"
19
19
 
20
+ // ---- canvas (core 2D drawing that bakes to a texture; shared by 2D/3D/UI) ----
21
+ export { Canvas } from "./canvas/Canvas"
22
+ export { Label } from "./canvas/Label"
23
+
20
24
  // ---- platform runtime (injected globals; the host provides the lower-level _creator* bridges) ----
21
25
  export { toast, SvgSource } from "./runtime/misc"
22
26
  export { fetch, fetchLocal, FormData, File } from "./runtime/fetch"
@@ -1,13 +1,28 @@
1
1
  import type { FetchResponse } from "../runtime/fetch"
2
2
  import type { SvgSourceValue as SvgSource } from "../runtime/misc"
3
3
  import type { File } from "../runtime/fetch"
4
+ import { Canvas, isCanvas } from "../canvas/Canvas"
4
5
  import { Element, type AnimateStyle, type Color, type ElementStyle, type OnLayoutCallback, type Style } from "./UINode"
5
6
 
6
7
  export type UIImageStyle = ElementStyle & { borderRadius?: number, objectFit?: "cover" | "contain" | "fill", tintColor?: Color }
7
8
 
9
+ /** A baked Canvas surface as an image source. The UI backend resolves it to a UI texture — see
10
+ * docs/canvas-contract.md ("Pending"). */
11
+ export type CanvasImageSource = { canvasSurface: number }
12
+
13
+ /** Accepted image sources. A Canvas is baked to a surface marker on assign. */
14
+ export type ImageSource = string | FetchResponse | File | SvgSource | Canvas
15
+
16
+ const toSrc = (src: ImageSource): string | FetchResponse | File | SvgSource | CanvasImageSource =>
17
+ isCanvas(src) ? { canvasSurface: src._bakeSurface() } : src
18
+
19
+ /** The stored/resolved image source (a Canvas is resolved to a surface marker). */
20
+ export type ResolvedImageSource = string | FetchResponse | File | SvgSource | CanvasImageSource
21
+
8
22
  export interface UIImage {
9
23
  readonly type: "image",
10
- src: string | FetchResponse | File | SvgSource,
24
+ get src(): ResolvedImageSource
25
+ set src(value: ImageSource)
11
26
  style: Style<this, UIImageStyle>
12
27
  animateTo: AnimateStyle<this, UIImageStyle>
13
28
  animateFrom: AnimateStyle<this, UIImageStyle>
@@ -23,23 +38,23 @@ export interface UIImage {
23
38
  }
24
39
 
25
40
  class ImageElement extends Element<"image"> {
26
- private _src: string | FetchResponse | File | SvgSource
41
+ private _src: ResolvedImageSource
27
42
  // Current source sub-rect; undefined until the first setSourceRect. Kept on the node (not
28
43
  // just sent as a command) so a crop set while detached (_id == 0) is reapplied at mount —
29
44
  // the viewer reads it when it creates the element.
30
45
  private _sourceRect?: [number, number, number, number]
31
- constructor(style: any, src: string | FetchResponse | File | SvgSource) {
46
+ constructor(style: any, src: ImageSource) {
32
47
  super("image", style)
33
- this._src = src
48
+ this._src = toSrc(src)
34
49
  }
35
50
 
36
- get src() {
51
+ get src(): ResolvedImageSource {
37
52
  return this._src
38
53
  }
39
- set src(value) {
40
- this._src = value
54
+ set src(value: ImageSource) {
55
+ this._src = toSrc(value)
41
56
  if (this._id !== 0) {
42
- _creatorUI.updateNode(this._id, "src", value)
57
+ _creatorUI.updateNode(this._id, "src", this._src)
43
58
  }
44
59
  }
45
60
 
@@ -57,9 +72,9 @@ class ImageElement extends Element<"image"> {
57
72
  }
58
73
  }
59
74
 
60
- export function UIImage(src: string | FetchResponse | File | SvgSource): UIImage
61
- export function UIImage(style: UIImageStyle, src: string | FetchResponse | File | SvgSource): UIImage
62
- export function UIImage(...args: [string | FetchResponse | File | SvgSource] | [UIImageStyle, string | FetchResponse | File | SvgSource]): UIImage {
75
+ export function UIImage(src: ImageSource): UIImage
76
+ export function UIImage(style: UIImageStyle, src: ImageSource): UIImage
77
+ export function UIImage(...args: [ImageSource] | [UIImageStyle, ImageSource]): UIImage {
63
78
  if (args.length === 1) {
64
79
  return new ImageElement({}, args[0])
65
80
  } else {
@@ -3,7 +3,12 @@ import { ContainerElement, type AnimateStyle, type BaseStyle, type Color, type C
3
3
 
4
4
  export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle & ScrollStyle & BaseStyle & ScrollStyle & { refreshControlColor?: Color }
5
5
 
6
- export interface UIScreen <Scrollable extends boolean = boolean> {
6
+ // Default to `false`: a bare `UIScreen` means the non-scrollable screen the `UIScreen(...)`
7
+ // constructor returns, so `const s: UIScreen = UIScreen([...])` type-checks. The type is invariant
8
+ // in `Scrollable` (the fluent `style()` puts `this` in a contravariant position), so a `boolean`
9
+ // default wouldn't accept the constructor's `UIScreen<false>`. Opt into scrolling via
10
+ // `.makeScrollable(): UIScreen<true>`; write `UIScreen<boolean>` explicitly when you need the union.
11
+ export interface UIScreen <Scrollable extends boolean = false> {
7
12
  readonly type: "screen",
8
13
  scrollable: Scrollable,
9
14
  style: Style<this, UIScreenStyle>,
@@ -31,7 +36,7 @@ export interface UIScreen <Scrollable extends boolean = boolean> {
31
36
 
32
37
  onLayout(onLayout: OnLayoutCallback): this
33
38
 
34
- onTouchStart(callback: (ev: TouchStartEvent<UIScreen>) => void): this,
39
+ onTouchStart(callback: (ev: TouchStartEvent<UIScreen<boolean>>) => void): this,
35
40
  }
36
41
 
37
42
  export class ScreenElement extends ContainerElement<"screen"> {