lecodes-cli 0.11.0 → 0.12.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 +48454 -7370
- package/package.json +4 -3
- 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 +394 -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/inject.ts +16 -0
- package/runtime/sdk/runtime/app.ts +21 -3
- package/runtime/sdk/scene/defineScene.ts +10 -70
- 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
|
@@ -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/inject.ts
CHANGED
|
@@ -51,6 +51,22 @@ 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 type {
|
|
61
|
+
TilesetDef, TileMaterial, PatchMaterial, PathMaterial, PatchSlots, EdgeSlot, FillBlock, BlockEntry,
|
|
62
|
+
TileBlock, TilePart,
|
|
63
|
+
} from "./g2/autotile"
|
|
64
|
+
// 2D scenes as data (.scene2d.ts files — see src/g2/defineScene2d.ts) + the cells('…') payload
|
|
65
|
+
export { defineScene2d, Scene2dHandle } from "./g2/defineScene2d"
|
|
66
|
+
export type { Scene2dDef, Scene2dNodeDef, Scene2dEnv, SpriteSourceDef, TilemapSourceDef, Camera2dNodeDef, LoadedScene2d } from "./g2/defineScene2d"
|
|
67
|
+
export { cells, encodeCells, type CellsData } from "./g2/cells"
|
|
68
|
+
// no-code 2D scenario aspects (attachable in scene files — see src/g2/scenarios2d.ts)
|
|
69
|
+
export { CameraFollow } from "./g2/scenarios2d"
|
|
54
70
|
|
|
55
71
|
// ---- UI (creator-ui) ----
|
|
56
72
|
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)
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
|
|
35
35
|
import { Aspect, type AspectCtor, type With } from "../core/Aspect"
|
|
36
36
|
import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
|
|
37
|
+
import {
|
|
38
|
+
collectRefDeps, isEditMode, isNodeRef, resolveRefs, use, ref, make, EDIT_FLAG,
|
|
39
|
+
type AspectEntry, type MakeEntry as SharedMakeEntry,
|
|
40
|
+
} from "./grammar"
|
|
37
41
|
import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
|
|
38
42
|
import type { ColorInput } from "../core/color"
|
|
39
43
|
import type { Vec3Like } from "../math/vec"
|
|
@@ -63,65 +67,13 @@ export type MaterialDef =
|
|
|
63
67
|
// An imported/shared Material instance — valid at runtime; the editor shows it read-only.
|
|
64
68
|
| Material
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
ctor: AspectCtor<A>
|
|
71
|
-
props?: Partial<A>
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
|
|
75
|
-
* typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
|
|
76
|
-
export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
|
|
77
|
-
({ __use: true, ctor, props })
|
|
78
|
-
|
|
79
|
-
/** A `make(fn, args)` source entry — see {@link make}. */
|
|
80
|
-
export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = {
|
|
81
|
-
/** @internal grammar marker. */
|
|
82
|
-
readonly __make: true
|
|
83
|
-
fn: (args: A) => Node | Promise<Node>
|
|
84
|
-
args?: A
|
|
85
|
-
}
|
|
70
|
+
// The grammar markers (`use`/`ref`/`make`) live in ./grammar.ts, shared with defineScene2d —
|
|
71
|
+
// re-exported here so this module remains the one import site for scene-file machinery.
|
|
72
|
+
export { use, ref, make }
|
|
73
|
+
export type { AspectEntry }
|
|
86
74
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
* The factory runs after every scene node exists (so `ref()` args resolve, forward references
|
|
90
|
-
* included) and its returned subtree mounts under the def node — the def's transform stays
|
|
91
|
-
* editor-owned, so moving the node never re-calls the factory. Args must be literal data (same
|
|
92
|
-
* grammar as aspect props); in the editor they edit as fields, and an arg change re-CALLS the
|
|
93
|
-
* factory live — the code is already in the bundle, so no compile happens. Keep factories pure
|
|
94
|
-
* builders: same args → same subtree, no side effects outside the returned nodes.
|
|
95
|
-
*/
|
|
96
|
-
export const make = <A extends Record<string, unknown>>(
|
|
97
|
-
fn: (args: A) => Node | Promise<Node>, args?: A,
|
|
98
|
-
): MakeEntry<A> => ({ __make: true, fn, args })
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Reference another scene node by name in an aspect's props:
|
|
102
|
-
* `use(Road, { from: ref('pointA'), to: ref('pointB') })`. Resolves to the live Node when aspects
|
|
103
|
-
* attach — after EVERY node of the scene exists, so forward references work. An unknown name
|
|
104
|
-
* resolves to `null` (type your aspect field `Node | null`). Scene files only — hand-written code
|
|
105
|
-
* passes nodes directly: `node.aspect(Road, { from: nodes.pointA })`.
|
|
106
|
-
*/
|
|
107
|
-
export const ref = <T extends Node = Node>(name: string): T => ({ $ref: name } as unknown as T)
|
|
108
|
-
|
|
109
|
-
const isNodeRef = (v: unknown): v is { $ref: string } =>
|
|
110
|
-
typeof v === "object" && v !== null && typeof (v as { $ref?: unknown }).$ref === "string"
|
|
111
|
-
|
|
112
|
-
/** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep). */
|
|
113
|
-
const resolveRefs = (props: Record<string, unknown> | undefined, nodes: Record<string, Node>): Record<string, unknown> | undefined => {
|
|
114
|
-
if (!props) return props
|
|
115
|
-
let out: Record<string, unknown> | undefined
|
|
116
|
-
for (const [ k, v ] of Object.entries(props)) {
|
|
117
|
-
if (isNodeRef(v)) {
|
|
118
|
-
;(out ??= { ...props })[k] = nodes[v.$ref] ?? null
|
|
119
|
-
} else if (Array.isArray(v) && v.some(isNodeRef)) {
|
|
120
|
-
;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? nodes[el.$ref] ?? null : el))
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out ?? props
|
|
124
|
-
}
|
|
75
|
+
/** A `make(fn, args)` source entry whose factory returns a 3D {@link Node}. */
|
|
76
|
+
export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = SharedMakeEntry<A, Node>
|
|
125
77
|
|
|
126
78
|
/** Transform overrides for one INTERNAL node of a GLB model or a prefab instance
|
|
127
79
|
* (`overrides` on a model/prefab node, keyed by part path). */
|
|
@@ -272,9 +224,6 @@ const applyModelOverrides = (root: Node, overrides: Record<string, ModelOverride
|
|
|
272
224
|
|
|
273
225
|
// ---- runtime -------------------------------------------------------------------
|
|
274
226
|
|
|
275
|
-
const EDIT_FLAG = "__lecodesSceneEdit"
|
|
276
|
-
const isEditMode = (): boolean => (globalThis as Record<string, unknown>)[EDIT_FLAG] === true
|
|
277
|
-
|
|
278
227
|
const resolveMaterial = (def: MaterialDef): Material => {
|
|
279
228
|
if (def instanceof Material) return def
|
|
280
229
|
if ("lit" in def) return Material.lit(def.lit)
|
|
@@ -429,15 +378,6 @@ type EditorRun = {
|
|
|
429
378
|
deps: Set<string>
|
|
430
379
|
}
|
|
431
380
|
|
|
432
|
-
const collectRefDeps = (props: Record<string, unknown>): Set<string> => {
|
|
433
|
-
const deps = new Set<string>()
|
|
434
|
-
for (const v of Object.values(props)) {
|
|
435
|
-
if (isNodeRef(v)) deps.add(v.$ref)
|
|
436
|
-
else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) deps.add(el.$ref)
|
|
437
|
-
}
|
|
438
|
-
return deps
|
|
439
|
-
}
|
|
440
|
-
|
|
441
381
|
const safeRebuild = (run: EditorRun): void => {
|
|
442
382
|
// a throwing generator must not take the editor session down with it
|
|
443
383
|
try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostName}":`, e) }
|