lecodes-cli 0.11.0 → 0.13.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/dist/index.js +48869 -7399
- package/package.json +6 -5
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/g2/Node2D.ts +3 -0
- package/runtime/sdk/g2/SpriteAnimation.ts +101 -20
- package/runtime/sdk/g2/SpriteSheet.ts +166 -0
- package/runtime/sdk/g2/Tileset.ts +71 -0
- package/runtime/sdk/g2/autotile.ts +433 -0
- package/runtime/sdk/g2/cells.ts +91 -0
- package/runtime/sdk/g2/defineScene2d.ts +381 -0
- package/runtime/sdk/g2/scenarios2d.ts +69 -0
- package/runtime/sdk/gl/Camera.ts +40 -0
- package/runtime/sdk/inject.ts +17 -0
- package/runtime/sdk/runtime/app.ts +21 -3
- package/runtime/sdk/scene/defineScene.ts +53 -83
- package/runtime/sdk/scene/editorPlugins.ts +1 -1
- package/runtime/sdk/scene/grammar.ts +85 -0
- package/runtime/sdk/ui/NativeView.ts +6 -12
- package/runtime/sdk/ui/UIBottomSheet.ts +0 -4
- package/runtime/sdk/ui/UIButton.ts +2 -21
- package/runtime/sdk/ui/UIContainer.ts +4 -58
- package/runtime/sdk/ui/UIImage.ts +2 -13
- package/runtime/sdk/ui/UIInput.ts +9 -21
- package/runtime/sdk/ui/UIModal.ts +0 -4
- package/runtime/sdk/ui/UINode.ts +37 -0
- package/runtime/sdk/ui/UIPager.ts +2 -13
- package/runtime/sdk/ui/UIScreen.ts +4 -20
- package/runtime/sdk/ui/UIScrollable.ts +2 -20
- package/runtime/sdk/ui/UISpacer.ts +2 -8
- package/runtime/sdk/ui/UIText.ts +2 -13
- package/runtime/sdk/ui/UIVideo.ts +6 -13
- package/runtime/sdk/ui/UIVirtualizedList.ts +10 -10
- package/runtime/sdk/ui/UIWidget.ts +3 -19
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// 2D scenes as data: the runtime behind `.scene2d.ts` files (docs/scene2d-editor-plan.md §4).
|
|
2
|
+
// The 2D sibling of scene/defineScene.ts — same literal grammar (one default-exported
|
|
3
|
+
// `defineScene2d({...})` call, nodes keyed by name, `use(...)` aspects, `make(...)` factories),
|
|
4
|
+
// with the 2D source vocabulary: `sprite` (a named entry of an imported `.sprite.ts` sheet),
|
|
5
|
+
// `tilemap` (texture + grid + a `cells('…')` payload), `camera`, `make`, or none = group.
|
|
6
|
+
//
|
|
7
|
+
// // forest.scene2d.ts
|
|
8
|
+
// import props from './props.sprite'
|
|
9
|
+
// import hero from './hero.sprite'
|
|
10
|
+
// export default defineScene2d({
|
|
11
|
+
// env: { background: '#5a6b3b', pixelPerfect: true, gravity: [0, 0] },
|
|
12
|
+
// layers: { 1: { ySort: true } },
|
|
13
|
+
// nodes: {
|
|
14
|
+
// ground: { tilemap: { texture: asset('./TileSet.png'), tile: 64, atlas: [64, 64],
|
|
15
|
+
// cells: cells('…') }, layer: 0 },
|
|
16
|
+
// tree1: { sprite: { sheet: props, name: 'tree_orange' }, position: [160, 288], layer: 1 },
|
|
17
|
+
// player: { sprite: { sheet: hero, clip: 'idle' }, position: [800, 200], layer: 1,
|
|
18
|
+
// aspects: [use(CameraFollow, { smooth: 0.12 })] },
|
|
19
|
+
// },
|
|
20
|
+
// })
|
|
21
|
+
//
|
|
22
|
+
// // main.ts
|
|
23
|
+
// import forest from './forest.scene2d'
|
|
24
|
+
// const { scene, nodes } = await forest.open()
|
|
25
|
+
//
|
|
26
|
+
// Colliders and bodies are SCENE semantics (sheets are art only): give a node `aspects:
|
|
27
|
+
// [use(Shape2D, {…}), use(Physics2D, {…})]`. Aspects attach normally in play mode; in edit mode
|
|
28
|
+
// (`globalThis.__lecodesSceneEdit`) they are held as data, so nothing falls or fires while
|
|
29
|
+
// editing — clip playback still runs (render-side).
|
|
30
|
+
// Prefabs (scene-in-scene) are a later phase — not in this grammar yet.
|
|
31
|
+
|
|
32
|
+
import type { ColorInput } from "../core/color"
|
|
33
|
+
import type { Vec2Like } from "../math/vec"
|
|
34
|
+
import {
|
|
35
|
+
EDIT_FLAG, isEditMode, resolveRefs, type AspectEntry, type MakeEntry as SharedMakeEntry,
|
|
36
|
+
} from "../scene/grammar"
|
|
37
|
+
import { Scene2D } from "./Scene2D"
|
|
38
|
+
import { Node2D } from "./Node2D"
|
|
39
|
+
import { Sprite } from "./Sprite"
|
|
40
|
+
import { Tilemap } from "./Tilemap"
|
|
41
|
+
import { Texture2D } from "./Texture2D"
|
|
42
|
+
import { Physics2D } from "./Physics2D"
|
|
43
|
+
import { SpriteSheet } from "./SpriteSheet"
|
|
44
|
+
import { Tileset, Autotile2D } from "./Tileset"
|
|
45
|
+
import type { SpriteAnimation } from "./SpriteAnimation"
|
|
46
|
+
import type { CellsData } from "./cells"
|
|
47
|
+
import type { With } from "../core/Aspect"
|
|
48
|
+
|
|
49
|
+
// ---- the literal grammar (what the visual editor reads and writes) -----------
|
|
50
|
+
|
|
51
|
+
export type Scene2dEnv = {
|
|
52
|
+
/** Background color — '#5a6b3b', 0x5a6b3b, or [r,g,b] in 0..1. */
|
|
53
|
+
background?: ColorInput
|
|
54
|
+
/** Texture sampling: 'nearest' (crisp pixel art, engine default) or 'linear'. */
|
|
55
|
+
filter?: "nearest" | "linear"
|
|
56
|
+
/** Pixel-art crispness: snap the camera to whole screen pixels (seeds CameraFollow's
|
|
57
|
+
* `pixelSnap`, and the initial camera position lands on whole world units). */
|
|
58
|
+
pixelPerfect?: boolean
|
|
59
|
+
/** Physics world gravity (Y-up: down is negative). Presence configures the world before any
|
|
60
|
+
* body is created — `[0, 0]` for top-down worlds. Omit to leave the world unconfigured. */
|
|
61
|
+
gravity?: Vec2Like
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A sprite node: one named entry of an imported `.sprite.ts` sheet. The sheet is ART only —
|
|
65
|
+
* colliders/bodies are scene semantics: put Shape2D / Physics2D / Trigger2D in `aspects`. */
|
|
66
|
+
export type SpriteSourceDef = {
|
|
67
|
+
/** The imported sheet handle: `import props from './props.sprite'` … `sheet: props`. */
|
|
68
|
+
sheet: SpriteSheet
|
|
69
|
+
/** The entry name; omit when the sheet declares exactly one sprite. */
|
|
70
|
+
name?: string
|
|
71
|
+
/** Start this clip immediately (sliced entries with clips). */
|
|
72
|
+
clip?: string
|
|
73
|
+
/** Initial facing for `perDirection` clips. */
|
|
74
|
+
direction?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TilemapSourceDef = {
|
|
78
|
+
/** The tileset image — `asset('./TileSet.png')`. */
|
|
79
|
+
texture: string
|
|
80
|
+
/** On-screen cell size in world units — a number (square) or [w, h]. */
|
|
81
|
+
tile: number | [number, number]
|
|
82
|
+
/** Atlas grid: how the texture slices into tiles, [atlasCols, atlasRows]. */
|
|
83
|
+
atlas: [number, number]
|
|
84
|
+
/** The map: a `cells('…')` payload — or any `{ cols, rows, data }` of your own. */
|
|
85
|
+
cells: CellsData | { cols: number, rows: number, data: number[] | Int32Array }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A camera marker node: seeds the scene camera's starting position (+ zoom). To MOVE the camera
|
|
89
|
+
* in play, attach `CameraFollow` to the node being followed — not aspects on this marker. */
|
|
90
|
+
export type Camera2dNodeDef = { zoom?: number }
|
|
91
|
+
|
|
92
|
+
export type Scene2dNodeDef = {
|
|
93
|
+
// -- source (at most one; none = plain group node) --
|
|
94
|
+
sprite?: SpriteSourceDef
|
|
95
|
+
tilemap?: TilemapSourceDef
|
|
96
|
+
camera?: Camera2dNodeDef
|
|
97
|
+
/** A code-built subtree — `make(factoryFn, { ...literal args })`, factory returns a Node2D. */
|
|
98
|
+
make?: SharedMakeEntry<any, Node2D>
|
|
99
|
+
// -- transform / render --
|
|
100
|
+
position?: Vec2Like
|
|
101
|
+
/** Degrees, CCW. */
|
|
102
|
+
rotation?: number
|
|
103
|
+
scale?: Vec2Like | number
|
|
104
|
+
/** Draw layer (int) — higher renders on top. */
|
|
105
|
+
layer?: number
|
|
106
|
+
/** Intra-layer depth for non-Y-sorted layers. */
|
|
107
|
+
z?: number
|
|
108
|
+
/** Sprite tint (sprite nodes only). */
|
|
109
|
+
color?: ColorInput
|
|
110
|
+
/** Sprite opacity 0..1 (sprite nodes only). */
|
|
111
|
+
opacity?: number
|
|
112
|
+
flipX?: boolean
|
|
113
|
+
flipY?: boolean
|
|
114
|
+
visible?: boolean
|
|
115
|
+
/** Editor-only: the move gizmo won't target this node (fields still edit). No runtime effect. */
|
|
116
|
+
locked?: boolean
|
|
117
|
+
// -- capabilities / hierarchy --
|
|
118
|
+
aspects?: readonly AspectEntry<any>[]
|
|
119
|
+
children?: Record<string, Scene2dNodeDef>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type Scene2dCameraDef = {
|
|
123
|
+
position?: Vec2Like
|
|
124
|
+
zoom?: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type Scene2dDef = {
|
|
128
|
+
env?: Scene2dEnv
|
|
129
|
+
/** Per-layer config, keyed by layer number: `layers: { 1: { ySort: true } }`. */
|
|
130
|
+
layers?: Record<number | string, { ySort?: boolean }>
|
|
131
|
+
/** Starting camera when there's no camera NODE (a camera node wins). */
|
|
132
|
+
camera?: Scene2dCameraDef
|
|
133
|
+
nodes?: Record<string, Scene2dNodeDef>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- typed handle -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
type SourceNodeOf<N extends Scene2dNodeDef> =
|
|
139
|
+
N extends { sprite: { clip: string } } ? Sprite & { anim: SpriteAnimation }
|
|
140
|
+
: N extends { sprite: SpriteSourceDef } ? Sprite
|
|
141
|
+
: N extends { tilemap: TilemapSourceDef } ? Tilemap
|
|
142
|
+
: Node2D
|
|
143
|
+
|
|
144
|
+
type AspectsOf<N extends Scene2dNodeDef> =
|
|
145
|
+
N extends { aspects: readonly AspectEntry<infer A>[] } ? A : never
|
|
146
|
+
|
|
147
|
+
type NodeOf<N extends Scene2dNodeDef> =
|
|
148
|
+
[AspectsOf<N>] extends [never] ? SourceNodeOf<N> : With<SourceNodeOf<N>, AspectsOf<N>>
|
|
149
|
+
|
|
150
|
+
type UnionToIntersection<U> =
|
|
151
|
+
(U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
|
|
152
|
+
|
|
153
|
+
type ChildMapsOf<T extends Record<string, Scene2dNodeDef>> =
|
|
154
|
+
{ [K in keyof T]: T[K] extends { children: infer C extends Record<string, Scene2dNodeDef> } ? NodesOf<C> : never }[keyof T]
|
|
155
|
+
|
|
156
|
+
type NodesOf<T extends Record<string, Scene2dNodeDef>> =
|
|
157
|
+
{ [K in keyof T]: NodeOf<T[K]> } &
|
|
158
|
+
([ChildMapsOf<T>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T>>)
|
|
159
|
+
|
|
160
|
+
export type Scene2dNodes<D extends Scene2dDef> =
|
|
161
|
+
D["nodes"] extends Record<string, Scene2dNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node2D>
|
|
162
|
+
|
|
163
|
+
export type LoadedScene2d<D extends Scene2dDef> = {
|
|
164
|
+
scene: Scene2D
|
|
165
|
+
nodes: Scene2dNodes<D>
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- runtime -------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const createSprite = (name: string, def: SpriteSourceDef): Sprite => {
|
|
171
|
+
const sheet = def.sheet
|
|
172
|
+
if (!(sheet instanceof SpriteSheet)) {
|
|
173
|
+
throw new Error(`Scene2d node "${name}": sprite.sheet is not a sheet handle (import the .sprite file's default export)`)
|
|
174
|
+
}
|
|
175
|
+
const entryName = def.name ?? (sheet.spriteNames.length === 1 ? sheet.spriteNames[0]! : undefined)
|
|
176
|
+
if (entryName === undefined) {
|
|
177
|
+
throw new Error(`Scene2d node "${name}": sprite.name is required — the sheet has ${sheet.spriteNames.length} sprites (${sheet.spriteNames.join(", ")})`)
|
|
178
|
+
}
|
|
179
|
+
return sheet.make(entryName, {
|
|
180
|
+
clip: def.clip,
|
|
181
|
+
direction: def.direction,
|
|
182
|
+
} as Parameters<SpriteSheet["make"]>[1])
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const createTilemap = async (
|
|
186
|
+
name: string, def: TilemapSourceDef, aspects?: readonly AspectEntry<any>[],
|
|
187
|
+
): Promise<Tilemap> => {
|
|
188
|
+
const grid = def.cells
|
|
189
|
+
if (!grid || typeof grid !== "object" || typeof (grid as CellsData).cols !== "number") {
|
|
190
|
+
throw new Error(`Scene2d node "${name}": tilemap.cells must be a cells('…') payload or a { cols, rows, data } value`)
|
|
191
|
+
}
|
|
192
|
+
// Autotile2D on the node flips `cells` from raw atlas indices to material ids — derive the
|
|
193
|
+
// display indices HERE, before the Tilemap exists (aspects are data on the def), so the native
|
|
194
|
+
// layer keeps receiving a dumb index grid and derived data is never persisted.
|
|
195
|
+
let data: number[] | Int32Array = grid.data
|
|
196
|
+
for (const entry of aspects ?? []) {
|
|
197
|
+
if ((entry.ctor as unknown) !== Autotile2D) continue
|
|
198
|
+
const props = entry.props as Partial<Autotile2D> | undefined
|
|
199
|
+
if (props?.tileset instanceof Tileset) {
|
|
200
|
+
data = props.tileset.derive(grid.cols, grid.rows, data, props.seed ?? 0)
|
|
201
|
+
} else {
|
|
202
|
+
console.error(`[scene2d] node "${name}": Autotile2D has no tileset handle — cells render as raw indices`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const texture = await Texture2D.load(def.texture)
|
|
206
|
+
return new Tilemap({
|
|
207
|
+
texture, cols: grid.cols, rows: grid.rows,
|
|
208
|
+
tile: def.tile, atlas: def.atlas, data,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const createSource = (name: string, def: Scene2dNodeDef): Node2D | Promise<Node2D> => {
|
|
213
|
+
const sources = [ def.sprite, def.tilemap, def.camera, def.make ].filter((s) => s !== undefined).length
|
|
214
|
+
if (sources > 1) throw new Error(`Scene2d node "${name}" declares more than one source (sprite/tilemap/camera/make)`)
|
|
215
|
+
if (def.sprite !== undefined) return createSprite(name, def.sprite)
|
|
216
|
+
if (def.tilemap !== undefined) return createTilemap(name, def.tilemap, def.aspects)
|
|
217
|
+
// make/camera/group nodes are plain wrappers — make subtrees mount under them in phase 2
|
|
218
|
+
return new Node2D()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const applyNode = (node: Node2D, name: string, def: Scene2dNodeDef): void => {
|
|
222
|
+
node.name = name
|
|
223
|
+
if (def.position) node.position = def.position
|
|
224
|
+
if (def.rotation !== undefined) node.rotation = def.rotation
|
|
225
|
+
if (def.scale !== undefined) node.scale = def.scale
|
|
226
|
+
if (def.layer !== undefined) node.layer = def.layer
|
|
227
|
+
if (def.z !== undefined) node.z = def.z
|
|
228
|
+
if (def.visible !== undefined) node.visible = def.visible
|
|
229
|
+
if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
|
|
230
|
+
if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
|
|
231
|
+
if (def.children) (node as { _sceneChildOrder?: string[] })._sceneChildOrder = Object.keys(def.children)
|
|
232
|
+
if (node instanceof Sprite) {
|
|
233
|
+
if (def.color !== undefined) node.color = def.color
|
|
234
|
+
if (def.opacity !== undefined) node.opacity = def.opacity
|
|
235
|
+
if (def.flipX !== undefined) node.flipX = def.flipX
|
|
236
|
+
if (def.flipY !== undefined) node.flipY = def.flipY
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The first camera-source def in file order (depth-first), or null. */
|
|
241
|
+
const findCamera = (defs?: Record<string, Scene2dNodeDef>): { name: string, def: Camera2dNodeDef } | null => {
|
|
242
|
+
for (const [ name, nd ] of Object.entries(defs ?? {})) {
|
|
243
|
+
if (nd.camera !== undefined) return { name, def: nd.camera }
|
|
244
|
+
const inner = findCamera(nd.children)
|
|
245
|
+
if (inner) return inner
|
|
246
|
+
}
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const attachAspects = (
|
|
251
|
+
node: Node2D, def: Scene2dNodeDef, nodes: Record<string, Node2D>, scene: Scene2D, env: Scene2dEnv,
|
|
252
|
+
): void => {
|
|
253
|
+
if (isEditMode()) {
|
|
254
|
+
// Data-only (the future harness reads [ctor, props] from here) — no onAttach side effects
|
|
255
|
+
// (bodies, loops) while editing.
|
|
256
|
+
;(node as unknown as { _sceneAspects: readonly AspectEntry<any>[] })._sceneAspects = def.aspects ?? []
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
for (const entry of def.aspects ?? []) {
|
|
260
|
+
let props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes)
|
|
261
|
+
// Aspects that declare `static wantsScene` (CameraFollow) get the scene wired in, and their
|
|
262
|
+
// pixel snapping defaults to env.pixelPerfect — explicit props always win.
|
|
263
|
+
if ((entry.ctor as { wantsScene?: boolean }).wantsScene) {
|
|
264
|
+
props = {
|
|
265
|
+
scene,
|
|
266
|
+
...(env.pixelPerfect !== undefined ? { pixelSnap: env.pixelPerfect } : {}),
|
|
267
|
+
...props,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
;(node as Node2D & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, props)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Run a def's make() factory (phase 2 — every node exists, so ref() args resolve). The subtree
|
|
275
|
+
* mounts under the wrapper node; the wrapper's transform stays editor-owned. */
|
|
276
|
+
const attachMake = (
|
|
277
|
+
node: Node2D, name: string, def: Scene2dNodeDef, nodes: Record<string, Node2D>, scene: Scene2D,
|
|
278
|
+
): Promise<void> | undefined => {
|
|
279
|
+
const entry = def.make
|
|
280
|
+
if (!entry) return undefined
|
|
281
|
+
const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes) ?? {}
|
|
282
|
+
return Promise.resolve(entry.fn(args as never)).then((made) => {
|
|
283
|
+
if (made instanceof Node2D) {
|
|
284
|
+
node.add(made)
|
|
285
|
+
// membership is per-entity in creator-2d — add the whole made subtree to the draw set
|
|
286
|
+
const addAll = (n: Node2D): void => { scene.add(n); n.children.forEach(addAll) }
|
|
287
|
+
addAll(made)
|
|
288
|
+
}
|
|
289
|
+
}).catch((e) => { console.error(`[scene2d] make() factory failed on "${name}":`, e) })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Build one defs record into `scene` under `parent`, two-phase (all nodes first, then
|
|
293
|
+
* aspects/make — so `ref()`s resolve regardless of declaration order). */
|
|
294
|
+
const buildNodes = async (
|
|
295
|
+
defs: Record<string, Scene2dNodeDef>, parent: Node2D | null, scene: Scene2D,
|
|
296
|
+
nodes: Record<string, Node2D>, env: Scene2dEnv,
|
|
297
|
+
): Promise<void> => {
|
|
298
|
+
const pending: { name: string, node: Node2D, def: Scene2dNodeDef }[] = []
|
|
299
|
+
|
|
300
|
+
const build = async (name: string, nd: Scene2dNodeDef, parentNode: Node2D | null): Promise<void> => {
|
|
301
|
+
const node = await createSource(name, nd)
|
|
302
|
+
if (parentNode) parentNode.add(node)
|
|
303
|
+
scene.add(node)
|
|
304
|
+
applyNode(node, name, nd)
|
|
305
|
+
pending.push({ name, node, def: nd })
|
|
306
|
+
nodes[name] = node
|
|
307
|
+
await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent)))
|
|
311
|
+
const waits: Promise<void>[] = []
|
|
312
|
+
for (const p of pending) {
|
|
313
|
+
attachAspects(p.node, p.def, nodes, scene, env)
|
|
314
|
+
const wait = attachMake(p.node, p.name, p.def, nodes, scene)
|
|
315
|
+
if (wait) waits.push(wait)
|
|
316
|
+
}
|
|
317
|
+
if (waits.length > 0) await Promise.all(waits)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const instantiate = async (def: Scene2dDef): Promise<{ scene: Scene2D, nodes: Record<string, Node2D> }> => {
|
|
321
|
+
const env = def.env ?? {}
|
|
322
|
+
// configure() resets the physics world, so it must run before any body is created (aspects
|
|
323
|
+
// create bodies right after the node build). Edit mode holds physics as data — skip.
|
|
324
|
+
if (env.gravity !== undefined && !isEditMode()) Physics2D.configure({ gravity: env.gravity })
|
|
325
|
+
|
|
326
|
+
const scene = new Scene2D({ background: env.background, filter: env.filter })
|
|
327
|
+
for (const [ key, layer ] of Object.entries(def.layers ?? {})) {
|
|
328
|
+
if (layer?.ySort) scene.layer(Number(key)).ySort(true)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const nodes: Record<string, Node2D> = {}
|
|
332
|
+
await buildNodes(def.nodes ?? {}, null, scene, nodes, env)
|
|
333
|
+
|
|
334
|
+
// a camera NODE wins over the top-level `camera:` block; both only SEED the starting view —
|
|
335
|
+
// attach CameraFollow to the tracked node to move the camera in play
|
|
336
|
+
const cam = findCamera(def.nodes)
|
|
337
|
+
const camNode = cam ? nodes[cam.name] : undefined
|
|
338
|
+
if (cam && camNode) {
|
|
339
|
+
const p = camNode.worldPosition
|
|
340
|
+
scene.camera.position = env.pixelPerfect ? [ Math.round(p.x), Math.round(p.y) ] : p
|
|
341
|
+
if (cam.def.zoom !== undefined) scene.camera.zoom = cam.def.zoom
|
|
342
|
+
} else if (def.camera) {
|
|
343
|
+
if (def.camera.position) scene.camera.position = def.camera.position
|
|
344
|
+
if (def.camera.zoom !== undefined) scene.camera.zoom = def.camera.zoom
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { scene, nodes }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export class Scene2dHandle<D extends Scene2dDef = Scene2dDef> {
|
|
351
|
+
readonly def: D
|
|
352
|
+
private _loading?: Promise<LoadedScene2d<D>>
|
|
353
|
+
|
|
354
|
+
constructor(def: D) { this.def = def }
|
|
355
|
+
|
|
356
|
+
/** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
|
|
357
|
+
load(): Promise<LoadedScene2d<D>> {
|
|
358
|
+
this._loading ??= instantiate(this.def).then((live) => live as LoadedScene2d<D>)
|
|
359
|
+
return this._loading
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Load and make active (only the active 2D scene renders). */
|
|
363
|
+
async open(): Promise<LoadedScene2d<D>> {
|
|
364
|
+
const loaded = await this.load()
|
|
365
|
+
loaded.scene.open()
|
|
366
|
+
return loaded
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Define a 2D scene as data — the default export of a `.scene2d.ts` file. Returns a typed handle:
|
|
372
|
+
* `const { scene, nodes } = await handle.open()` gives `nodes.<name>` typed by its source block
|
|
373
|
+
* (Sprite / Tilemap / Node2D) with its `use(...)`d aspects attached.
|
|
374
|
+
*/
|
|
375
|
+
export const defineScene2d = <const D extends Scene2dDef>(def: D): Scene2dHandle<D> => {
|
|
376
|
+
const handle = new Scene2dHandle(def)
|
|
377
|
+
const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes2d?: Scene2dHandle[] }
|
|
378
|
+
// Editor hook: expose defined handles to a host that runs the bundle in edit mode.
|
|
379
|
+
if (g[EDIT_FLAG]) (g.__lecodesScenes2d ??= []).push(handle as Scene2dHandle)
|
|
380
|
+
return handle
|
|
381
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// No-code 2D scenario aspects (the gl/scenarios.ts precedent): behaviors a scene builder attaches
|
|
2
|
+
// in a `.scene2d.ts` file without writing aspect code. First resident: CameraFollow — the eased,
|
|
3
|
+
// pixel-snapped follow camera every top-down/side-scroller needs (it absorbs the reference app's
|
|
4
|
+
// ~50 lines of snap/follow plumbing).
|
|
5
|
+
//
|
|
6
|
+
// Conventions: update() runs in the LATE phase (after physics, render-synced when the engine
|
|
7
|
+
// provides the hooks), so followers read final, drawn-this-frame positions.
|
|
8
|
+
|
|
9
|
+
import { Aspect } from "../core/Aspect"
|
|
10
|
+
import type { Camera2D } from "./Camera2D"
|
|
11
|
+
import type { Node2D } from "./Node2D"
|
|
12
|
+
import type { Scene2D } from "./Scene2D"
|
|
13
|
+
|
|
14
|
+
// SCREEN pixels per world unit, MEASURED from the native projection (zoom + world→screen mapping
|
|
15
|
+
// are applied natively, so "1 world unit = 1 px" can't be assumed — and worldToScreen is affine,
|
|
16
|
+
// so the per-unit delta is position-independent). Re-measured only when zoom changes.
|
|
17
|
+
type PixelGrid = { sx: number, sy: number, zoomAt: number }
|
|
18
|
+
|
|
19
|
+
const snapToGrid = (cam: Camera2D, grid: PixelGrid, x: number, y: number): [number, number] => {
|
|
20
|
+
if (grid.sx === 0 || cam.zoom !== grid.zoomAt) {
|
|
21
|
+
const o = cam.worldToScreen(0, 0)
|
|
22
|
+
grid.sx = Math.abs(cam.worldToScreen(1, 0).x - o.x) || 1
|
|
23
|
+
grid.sy = Math.abs(cam.worldToScreen(0, 1).y - o.y) || 1
|
|
24
|
+
grid.zoomAt = cam.zoom
|
|
25
|
+
}
|
|
26
|
+
return [ Math.round(x * grid.sx) / grid.sx, Math.round(y * grid.sy) / grid.sy ]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ease the scene camera toward this node every frame (attach it to the player). In a `.scene2d.ts`
|
|
31
|
+
* file the loader wires `scene` automatically; from hand-written code pass it yourself:
|
|
32
|
+
* `hero.aspect(CameraFollow, { scene })`.
|
|
33
|
+
*
|
|
34
|
+
* The eased position accumulates at full precision and only the value PUSHED to the camera is
|
|
35
|
+
* snapped — reading the snapped camera back as the ease-from value would let tiny sub-pixel steps
|
|
36
|
+
* round to zero near settle (the follow would stall a few px off-centre, then jump).
|
|
37
|
+
*/
|
|
38
|
+
export class CameraFollow extends Aspect<"cameraFollow", Node2D> {
|
|
39
|
+
static readonly aspect = "cameraFollow"
|
|
40
|
+
/** The scene2d loader injects the scene into aspects that declare this. */
|
|
41
|
+
static readonly wantsScene = true
|
|
42
|
+
|
|
43
|
+
/** The scene whose camera follows (wired by the scene file loader). */
|
|
44
|
+
scene: Scene2D | null = null
|
|
45
|
+
/** Exponential time constant in seconds; smaller = snappier, 0 = pin the node to centre. */
|
|
46
|
+
smooth = 0.12
|
|
47
|
+
/** Camera offset from the node, in world units. */
|
|
48
|
+
offset: [number, number] = [ 0, 0 ]
|
|
49
|
+
/** Quantize the camera to whole screen pixels so eased motion never shimmers pixel art.
|
|
50
|
+
* Defaults to the scene's `env.pixelPerfect` (true when unset). */
|
|
51
|
+
pixelSnap = true
|
|
52
|
+
|
|
53
|
+
private _x = NaN
|
|
54
|
+
private _y = NaN
|
|
55
|
+
private readonly _grid: PixelGrid = { sx: 0, sy: 0, zoomAt: NaN }
|
|
56
|
+
|
|
57
|
+
update(dt: number): void {
|
|
58
|
+
const scene = this.scene
|
|
59
|
+
if (!scene) return
|
|
60
|
+
const cam = scene.camera
|
|
61
|
+
const p = this.node.worldPosition
|
|
62
|
+
const tx = p.x + this.offset[0], ty = p.y + this.offset[1]
|
|
63
|
+
if (Number.isNaN(this._x)) { const c = cam.position; this._x = c.x; this._y = c.y } // seed from the camera's pose
|
|
64
|
+
const k = this.smooth > 0 ? 1 - Math.exp(-dt / this.smooth) : 1
|
|
65
|
+
this._x += (tx - this._x) * k
|
|
66
|
+
this._y += (ty - this._y) * k
|
|
67
|
+
cam.position = this.pixelSnap ? snapToGrid(cam, this._grid, this._x, this._y) : [ this._x, this._y ]
|
|
68
|
+
}
|
|
69
|
+
}
|
package/runtime/sdk/gl/Camera.ts
CHANGED
|
@@ -5,21 +5,61 @@ import { Vec3 } from "../math/vec"
|
|
|
5
5
|
import { Node } from "./Node"
|
|
6
6
|
import { Ray } from "./Ray"
|
|
7
7
|
|
|
8
|
+
/** Projection every host starts a scene with (creator-gl scene.cpp / the lite core agree on these). */
|
|
9
|
+
export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }
|
|
10
|
+
|
|
8
11
|
export class Camera extends Node {
|
|
9
12
|
private _sceneId = -1
|
|
10
13
|
/** Live projection matrix (updated by the host when the viewport/fov changes). */
|
|
11
14
|
readonly projectionMatrix = new Float32Array(16)
|
|
12
15
|
|
|
16
|
+
// Projection the app asked for (host defaults until it does). The host owns the aspect and
|
|
17
|
+
// re-applies these on every viewport change, so they are set, not maintained.
|
|
18
|
+
private _fov = CAMERA_DEFAULTS.fov
|
|
19
|
+
private _near = CAMERA_DEFAULTS.near
|
|
20
|
+
private _far = CAMERA_DEFAULTS.far
|
|
21
|
+
/** Stay quiet until the app actually sets something — hosts may be configured with their own
|
|
22
|
+
* defaults (the headless core takes a `fov` option) and a blind push would overwrite them. */
|
|
23
|
+
private _projSet = false
|
|
24
|
+
|
|
13
25
|
/** @internal — Scene wires the camera to its native scene id + projection callback. */
|
|
14
26
|
_attach(sceneId: number): void {
|
|
15
27
|
this._sceneId = sceneId
|
|
16
28
|
_creator.attachCameraToAR(sceneId, this.id, (mat) => {
|
|
17
29
|
for (let i = 0; i < 16; i++) this.projectionMatrix[i] = mat[i]
|
|
18
30
|
})
|
|
31
|
+
if (this._projSet) this._applyProjection()
|
|
19
32
|
}
|
|
20
33
|
|
|
34
|
+
/** Vertical field of view in degrees (default 60) — a smaller angle is a longer lens. */
|
|
21
35
|
get fov(): number { return _creator.getCameraFov(this._sceneId, 0) }
|
|
36
|
+
set fov(degrees: number) { this._fov = degrees; this._projSet = true; this._applyProjection() }
|
|
22
37
|
get horizontalFov(): number { return _creator.getCameraFov(this._sceneId, 1) }
|
|
38
|
+
|
|
39
|
+
/** Near clip distance (default 0.01) — nothing closer than this draws. */
|
|
40
|
+
get near(): number { return this._near }
|
|
41
|
+
set near(distance: number) { this._near = distance; this._projSet = true; this._applyProjection() }
|
|
42
|
+
|
|
43
|
+
/** Far clip distance / view range (default 1000) — geometry past it is culled. Shadows keep
|
|
44
|
+
* their own (shorter) range, so a long view distance doesn't cost shadow sharpness. */
|
|
45
|
+
get far(): number { return this._far }
|
|
46
|
+
set far(distance: number) { this._far = distance; this._projSet = true; this._applyProjection() }
|
|
47
|
+
|
|
48
|
+
/** Set any of fov / near / far in one call: `camera.setProjection({ fov: 45, far: 5000 })`. */
|
|
49
|
+
setProjection(projection: { fov?: number, near?: number, far?: number }): this {
|
|
50
|
+
if (projection.fov !== undefined) this._fov = projection.fov
|
|
51
|
+
if (projection.near !== undefined) this._near = projection.near
|
|
52
|
+
if (projection.far !== undefined) this._far = projection.far
|
|
53
|
+
this._projSet = true
|
|
54
|
+
this._applyProjection()
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hosts that predate the call keep the fixed defaults (older wasm / native builds). */
|
|
59
|
+
private _applyProjection(): void {
|
|
60
|
+
if (this._sceneId < 0 || typeof _creator.setCameraProjection !== "function") return
|
|
61
|
+
_creator.setCameraProjection(this._sceneId, this._fov, this._near, this._far)
|
|
62
|
+
}
|
|
23
63
|
get displaySize(): [number, number] {
|
|
24
64
|
const s = _creator.getDisplaySize()
|
|
25
65
|
return [ s[0], s[1] ]
|
package/runtime/sdk/inject.ts
CHANGED
|
@@ -51,6 +51,23 @@ export { Trigger2D } from "./g2/Trigger2D"
|
|
|
51
51
|
export { CharacterController2D } from "./g2/CharacterController2D"
|
|
52
52
|
export { Tilemap } from "./g2/Tilemap"
|
|
53
53
|
export { Texture2D } from "./g2/Texture2D"
|
|
54
|
+
// sprite sheets as data (.sprite.ts files — see src/g2/SpriteSheet.ts)
|
|
55
|
+
export { SpriteSheet, defineSpriteSheet } from "./g2/SpriteSheet"
|
|
56
|
+
export type { SpriteSheetDef, SheetSprite, SpriteMakeOptions } from "./g2/SpriteSheet"
|
|
57
|
+
// tilesets as data (.tiles.ts files — autotile materials, see src/g2/Tileset.ts + autotile.ts)
|
|
58
|
+
export { Tileset, defineTileset, Autotile2D } from "./g2/Tileset"
|
|
59
|
+
export { rawCell, isRawCell, rawIndex } from "./g2/autotile"
|
|
60
|
+
export { rot90, rot180, rot270, flipX, packTile, tileIndex, tileTurns, tileFlip, tileOriented } from "./g2/autotile"
|
|
61
|
+
export type {
|
|
62
|
+
TilesetDef, TileMaterial, PatchMaterial, PathMaterial, PatchSlots, EdgeSlot, FillBlock, BlockEntry,
|
|
63
|
+
TileBlock, TilePart,
|
|
64
|
+
} from "./g2/autotile"
|
|
65
|
+
// 2D scenes as data (.scene2d.ts files — see src/g2/defineScene2d.ts) + the cells('…') payload
|
|
66
|
+
export { defineScene2d, Scene2dHandle } from "./g2/defineScene2d"
|
|
67
|
+
export type { Scene2dDef, Scene2dNodeDef, Scene2dEnv, SpriteSourceDef, TilemapSourceDef, Camera2dNodeDef, LoadedScene2d } from "./g2/defineScene2d"
|
|
68
|
+
export { cells, encodeCells, type CellsData } from "./g2/cells"
|
|
69
|
+
// no-code 2D scenario aspects (attachable in scene files — see src/g2/scenarios2d.ts)
|
|
70
|
+
export { CameraFollow } from "./g2/scenarios2d"
|
|
54
71
|
|
|
55
72
|
// ---- UI (creator-ui) ----
|
|
56
73
|
export {
|
|
@@ -47,12 +47,30 @@ export const app = {
|
|
|
47
47
|
restart(url?: string | null): void {
|
|
48
48
|
_creatorUtils.restart?.(url)
|
|
49
49
|
},
|
|
50
|
-
/** Leave this app
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
/** Leave this app. Inside the LeCodes viewer that returns to the launcher (the LeCodes home
|
|
51
|
+
* screen); a standalone-built app leaves to the platform instead — Android backgrounds it,
|
|
52
|
+
* iOS ignores the call (the platform forbids programmatic exit). */
|
|
53
53
|
quit(): void {
|
|
54
54
|
_creatorUtils.quit?.()
|
|
55
55
|
},
|
|
56
|
+
/** Global back-press fallback (Android back button/gesture — iOS has no system back). Fires
|
|
57
|
+
* only when nothing else consumed the press: no open widget/modal claimed it, the current
|
|
58
|
+
* screen has no `onBackPressed`, every pager is at its tab root and the router is at its
|
|
59
|
+
* stack root. The place for app-wide policy — e.g. double-press-to-quit:
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* let armedAt = 0
|
|
63
|
+
* app.onBackPressed(() => {
|
|
64
|
+
* if (Date.now() - armedAt < 2000) { app.quit(); return }
|
|
65
|
+
* armedAt = Date.now()
|
|
66
|
+
* toast("Press back again to exit")
|
|
67
|
+
* })
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* Replaces the previous handler; pass `null` to remove. */
|
|
71
|
+
onBackPressed(callback: (() => void) | null): void {
|
|
72
|
+
_creatorUI._backButtonCallback = callback ?? undefined
|
|
73
|
+
},
|
|
56
74
|
addEventListener<E extends keyof AppEventMap>(event: E, callback: AppEventMap[E]): void {
|
|
57
75
|
if (event === "keyboard") {
|
|
58
76
|
keyboardEventsOn(callback as (height: number, duration: number) => void)
|