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 +2 -2
- package/runtime/sdk/canvas/Canvas.ts +184 -0
- package/runtime/sdk/canvas/Label.ts +57 -0
- package/runtime/sdk/core/Aspect.ts +81 -25
- package/runtime/sdk/g2/Scene2D.ts +18 -0
- package/runtime/sdk/g2/Sprite.ts +10 -5
- package/runtime/sdk/g2/Texture2D.ts +8 -0
- package/runtime/sdk/gl/Material.ts +10 -3
- package/runtime/sdk/gl/Texture.ts +11 -0
- package/runtime/sdk/inject.ts +4 -0
- package/runtime/sdk/ui/UIImage.ts +26 -11
- package/runtime/sdk/ui/UIScreen.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "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.
|
|
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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
*
|
|
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
|
package/runtime/sdk/g2/Sprite.ts
CHANGED
|
@@ -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?:
|
|
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:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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) => {
|
package/runtime/sdk/inject.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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",
|
|
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:
|
|
61
|
-
export function UIImage(style: UIImageStyle, src:
|
|
62
|
-
export function UIImage(...args: [
|
|
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
|
-
|
|
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
|
|
39
|
+
onTouchStart(callback: (ev: TouchStartEvent<UIScreen<boolean>>) => void): this,
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export class ScreenElement extends ContainerElement<"screen"> {
|