lecodes-cli 0.10.4 → 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/README.md +1 -1
- package/dist/index.js +48887 -6261
- package/package.json +4 -3
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +42 -0
- package/runtime/sdk/compile/assetIconMacro.ts +382 -0
- package/runtime/sdk/compile/assetMacro.ts +45 -0
- package/runtime/sdk/compile/assetName.ts +50 -0
- package/runtime/sdk/compile/bundler.ts +244 -0
- package/runtime/sdk/compile/compileProject.ts +106 -0
- package/runtime/sdk/compile/detectEntry.ts +125 -0
- package/runtime/sdk/compile/fontMacro.ts +459 -0
- package/runtime/sdk/compile/fontRegistry.ts +78 -0
- package/runtime/sdk/compile/header.ts +55 -0
- package/runtime/sdk/compile/index.ts +53 -0
- package/runtime/sdk/compile/libraryImports.ts +52 -0
- package/runtime/sdk/compile/sceneEditor.ts +78 -0
- package/runtime/sdk/compile/sfnt.ts +98 -0
- package/runtime/sdk/compile/sourcemap.ts +25 -0
- 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 +18 -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 +9 -12
- package/runtime/sdk/ui/UI.ts +9 -1
- package/runtime/sdk/ui/UIBottomSheet.ts +10 -20
- package/runtime/sdk/ui/UIButton.ts +19 -36
- package/runtime/sdk/ui/UIContainer.ts +41 -103
- package/runtime/sdk/ui/UIImage.ts +10 -18
- package/runtime/sdk/ui/UIInput.ts +14 -26
- package/runtime/sdk/ui/UIModal.ts +10 -20
- package/runtime/sdk/ui/UINode.ts +142 -36
- package/runtime/sdk/ui/UIPager.ts +29 -28
- package/runtime/sdk/ui/UIPopover.ts +10 -16
- package/runtime/sdk/ui/UIScreen.ts +23 -40
- package/runtime/sdk/ui/UIScrollable.ts +37 -47
- package/runtime/sdk/ui/UISpacer.ts +4 -8
- package/runtime/sdk/ui/UITabs.ts +175 -0
- package/runtime/sdk/ui/UIText.ts +7 -13
- package/runtime/sdk/ui/UIVideo.ts +11 -13
- package/runtime/sdk/ui/UIVirtualizedList.ts +43 -49
- package/runtime/sdk/ui/UIWidget.ts +24 -36
- package/runtime/sdk/ui/fonts.ts +3 -0
- package/runtime/sdk/ui/router.ts +13 -0
- package/runtime/sdk/ui/theme.ts +21 -30
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Pre-chisel rewrite of user-library bare-specifier imports. chisel's resolver hard-errors on any
|
|
2
|
+
// bare import (the whole SDK is globals), so a project that imports a component library by its slug
|
|
3
|
+
// —`import { Card } from "acme-ui"`— is rewritten here to the injected library tree under
|
|
4
|
+
// `/__lib/<slug>/…` BEFORE chisel runs. Only libraries present in the dependency map are known
|
|
5
|
+
// slugs; any other bare specifier is an author error, surfaced with a friendly message instead of
|
|
6
|
+
// chisel's generic one. Static string-literal specifiers only — dynamic `import()` is unsupported
|
|
7
|
+
// (documented). Chisel itself is untouched. See docs/library-system-plan.md §4.
|
|
8
|
+
|
|
9
|
+
/** Cheap pre-check so the regexes don't run over files with no imports at all. */
|
|
10
|
+
export const hasImportStatement = (src: string): boolean => src.includes("import") || src.includes("from")
|
|
11
|
+
|
|
12
|
+
// A static `from "…"` clause (import-from OR export-from). Anchored at a statement boundary
|
|
13
|
+
// (line start / `;` / `}`) and disallowing quotes/backticks/semicolons between the keyword and
|
|
14
|
+
// `from`, so it can never span into a string literal or a neighbouring statement. Named-import
|
|
15
|
+
// lists may span multiple lines (newlines are allowed between the keyword and `from`).
|
|
16
|
+
const IMPORT_FROM_RE = /(^|[;\n}])(\s*(?:import|export)\b[^"'`;]*?\bfrom\s*)(["'])([^"'\n]+)\3/g
|
|
17
|
+
|
|
18
|
+
// A side-effect `import "…"` (a bare quote directly after the keyword — not `import x`, not `import(`).
|
|
19
|
+
const SIDE_EFFECT_RE = /(^|[;\n}])(\s*import\s*)(["'])([^"'\n]+)\3/g
|
|
20
|
+
|
|
21
|
+
const isRelativeOrAbsolute = (spec: string): boolean =>
|
|
22
|
+
spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../")
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rewrite every known-library bare import in `src` to its `/__lib/<slug>/…` path. Relative and
|
|
26
|
+
* absolute specifiers pass through untouched; an unknown bare specifier throws a friendly Error.
|
|
27
|
+
* `slugs` is the set of library slugs available to this project (its dependency map's keys).
|
|
28
|
+
*/
|
|
29
|
+
export const rewriteLibraryImports = (src: string, slugs: Set<string>): string => {
|
|
30
|
+
const rewriteSpec = (spec: string): string => {
|
|
31
|
+
if (isRelativeOrAbsolute(spec)) return spec
|
|
32
|
+
const slash = spec.indexOf("/")
|
|
33
|
+
const slug = slash === -1 ? spec : spec.slice(0, slash)
|
|
34
|
+
if (slugs.has(slug)) {
|
|
35
|
+
// `acme-ui` → `/__lib/acme-ui/index`; `acme-ui/theme` → `/__lib/acme-ui/theme`.
|
|
36
|
+
const sub = slash === -1 ? "/index" : spec.slice(slash)
|
|
37
|
+
return `/__lib/${slug}${sub}`
|
|
38
|
+
}
|
|
39
|
+
// Legacy worker-module imports (`import { UIScreen } from "ui"`) predate the globals-only SDK —
|
|
40
|
+
// chisel would reject them anyway; say so here instead of "Unknown library".
|
|
41
|
+
if (slug === "ui" || slug === "creator" || slug === "utils") {
|
|
42
|
+
throw new Error(`import from "${slug}": the SDK needs no imports — UIScreen, Scene, etc. are globals; remove this import line.`)
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Unknown library "${slug}" — add it in Settings → Libraries, or use a relative path like "./${slug}".`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let out = src.replace(IMPORT_FROM_RE, (_m, boundary, head, quote, spec) =>
|
|
48
|
+
`${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`)
|
|
49
|
+
out = out.replace(SIDE_EFFECT_RE, (m, boundary, head, quote, spec) =>
|
|
50
|
+
isRelativeOrAbsolute(spec) ? m : `${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`)
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The scene-editor compile composition, shared by every host that compiles an edit-mode bundle
|
|
2
|
+
// for a .scene.ts file: the platform backend (src/scene/compileSceneEditor.ts), the standalone
|
|
3
|
+
// scene-editor dev server, and (later) the lecodes CLI. One bundle on purpose: chisel's
|
|
4
|
+
// method-granular DCE trims each bundle's SDK copy to its own calls, so a separately-compiled
|
|
5
|
+
// harness could not call scene methods on objects the scene bundle created.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import type { CompileEntry } from "./compileProject"
|
|
10
|
+
|
|
11
|
+
/** Where the harness sources land in the compile's file map. The dotfolder can't collide with
|
|
12
|
+
* project assets (the tree forbids leading-dot names) and reads clearly in compile errors. */
|
|
13
|
+
export const SCENE_HARNESS_DIR = "./.scene-editor"
|
|
14
|
+
|
|
15
|
+
/** The virtual entrypoint path (`entryOverride`). */
|
|
16
|
+
export const SCENE_ENTRY_PATH = "./.scene-entry.ts"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read the harness sources (packages/projects/src/scene-editor) from a directory:
|
|
20
|
+
* `LECODES_SCENE_HARNESS_DIR` env override first (deployments vendor the sources, like
|
|
21
|
+
* LECODES_SDK_INJECT does for the SDK), else the given fallback dir. Returns name → source.
|
|
22
|
+
*/
|
|
23
|
+
export const loadSceneHarness = (fallbackDir?: string): Record<string, string> => {
|
|
24
|
+
const override = process.env.LECODES_SCENE_HARNESS_DIR
|
|
25
|
+
const dir = override && existsSync(override) ? override
|
|
26
|
+
: fallbackDir && existsSync(fallbackDir) ? fallbackDir
|
|
27
|
+
: null
|
|
28
|
+
if (!dir) throw new Error("scene-editor harness sources not found (set LECODES_SCENE_HARNESS_DIR)")
|
|
29
|
+
const out: Record<string, string> = {}
|
|
30
|
+
for (const f of readdirSync(dir)) {
|
|
31
|
+
if (f.endsWith(".ts")) out[f] = readFileSync(join(dir, f), "utf8")
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The extra entries that turn a project compile into a scene-editor compile: the harness modules
|
|
38
|
+
* plus a virtual entry that (1) sets the edit-mode flag in its own module — imports hoist, so the
|
|
39
|
+
* flag must run as a module, first — (2) imports the scene, which registers its handle on
|
|
40
|
+
* `__lecodesScenes`, (3) imports the project's `*.editor.ts` plugin files (editor bundles ONLY —
|
|
41
|
+
* production compiles never import them, so they cost zero bytes in shipped `app.js`), and
|
|
42
|
+
* (4) imports the harness main, which installs `__lecodesSceneHarness`.
|
|
43
|
+
* FULL extensions everywhere — exact-match specifiers need no resolver guesswork on dotted names,
|
|
44
|
+
* so even the unpatched published chisel resolves the `.scene.ts` import.
|
|
45
|
+
*
|
|
46
|
+
* `scenePath` is the project path of the scene file ("./scenes/city.scene.ts"); `projectPaths` is
|
|
47
|
+
* the project's file list — `.editor.ts` entries are picked out of it. Compile with
|
|
48
|
+
* `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`, `editor: true`.
|
|
49
|
+
*/
|
|
50
|
+
export const sceneEditorEntries = (
|
|
51
|
+
scenePath: string,
|
|
52
|
+
harness: Record<string, string>,
|
|
53
|
+
projectPaths: string[] = [],
|
|
54
|
+
): CompileEntry[] => {
|
|
55
|
+
const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
|
|
56
|
+
path: `${SCENE_HARNESS_DIR}/${name}`,
|
|
57
|
+
type: "text" as const,
|
|
58
|
+
text,
|
|
59
|
+
}))
|
|
60
|
+
entries.push({
|
|
61
|
+
path: `${SCENE_HARNESS_DIR}/.edit-mode.ts`,
|
|
62
|
+
type: "text",
|
|
63
|
+
text: `;(globalThis as any).__lecodesSceneEdit = true\nexport {}\n`,
|
|
64
|
+
})
|
|
65
|
+
const editorImports = projectPaths
|
|
66
|
+
.filter((p) => p.endsWith(".editor.ts"))
|
|
67
|
+
.map((p) => `import "${p}"\n`)
|
|
68
|
+
.join("")
|
|
69
|
+
entries.push({
|
|
70
|
+
path: SCENE_ENTRY_PATH,
|
|
71
|
+
type: "text",
|
|
72
|
+
text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
|
|
73
|
+
+ `import "${scenePath}"\n`
|
|
74
|
+
+ editorImports
|
|
75
|
+
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n`,
|
|
76
|
+
})
|
|
77
|
+
return entries
|
|
78
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Minimal sfnt (TTF/OTF) reader for the `font()` compile macro: family name from the `name` table,
|
|
2
|
+
// weight/italic from `OS/2` (with a `head.macStyle` fallback). Reads only what the macro needs —
|
|
3
|
+
// this is not a font sanitizer or validator.
|
|
4
|
+
|
|
5
|
+
export type SfntInfo = { family: string, weight: number, italic: boolean }
|
|
6
|
+
|
|
7
|
+
const TAG_TTF = 0x00010000
|
|
8
|
+
const TAG_OTTO = 0x4f54544f // 'OTTO' (CFF outlines)
|
|
9
|
+
const TAG_TTCF = 0x74746366 // 'ttcf' (collection)
|
|
10
|
+
const TAG_WOFF = 0x774f4646 // 'wOFF'
|
|
11
|
+
const TAG_WOF2 = 0x774f4632 // 'wOF2'
|
|
12
|
+
|
|
13
|
+
/** Parse a .ttf/.otf. Throws a message meant to surface verbatim in a compile error. */
|
|
14
|
+
export const parseSfnt = (bytes: Uint8Array): SfntInfo => {
|
|
15
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
16
|
+
if (bytes.byteLength < 12) throw new Error("not a font file (truncated)")
|
|
17
|
+
const version = dv.getUint32(0)
|
|
18
|
+
if (version === TAG_TTCF) throw new Error("font collections (.ttc) aren't supported — export a single face")
|
|
19
|
+
if (version === TAG_WOFF || version === TAG_WOF2) throw new Error("WOFF/WOFF2 project fonts aren't supported — use the .ttf/.otf")
|
|
20
|
+
if (version !== TAG_TTF && version !== TAG_OTTO) throw new Error("not a TTF/OTF font file")
|
|
21
|
+
|
|
22
|
+
const numTables = dv.getUint16(4)
|
|
23
|
+
const tables = new Map<string, { offset: number, length: number }>()
|
|
24
|
+
for (let i = 0; i < numTables; i++) {
|
|
25
|
+
const rec = 12 + i * 16
|
|
26
|
+
if (rec + 16 > bytes.byteLength) throw new Error("not a font file (truncated table directory)")
|
|
27
|
+
const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3])
|
|
28
|
+
tables.set(tag, { offset: dv.getUint32(rec + 8), length: dv.getUint32(rec + 12) })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const family = readFamily(dv, bytes, tables.get("name"))
|
|
32
|
+
if (!family) throw new Error("no family name in the font's `name` table")
|
|
33
|
+
|
|
34
|
+
let weight = 400
|
|
35
|
+
let italic = false
|
|
36
|
+
const os2 = tables.get("OS/2")
|
|
37
|
+
if (os2 && os2.offset + 64 <= bytes.byteLength) {
|
|
38
|
+
weight = dv.getUint16(os2.offset + 4) // usWeightClass
|
|
39
|
+
italic = (dv.getUint16(os2.offset + 62) & 1) === 1 // fsSelection ITALIC
|
|
40
|
+
} else {
|
|
41
|
+
const head = tables.get("head")
|
|
42
|
+
if (head && head.offset + 46 <= bytes.byteLength) {
|
|
43
|
+
const macStyle = dv.getUint16(head.offset + 44)
|
|
44
|
+
if (macStyle & 1) weight = 700
|
|
45
|
+
italic = (macStyle & 2) === 2
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (weight < 1 || weight > 1000) weight = 400
|
|
49
|
+
|
|
50
|
+
return { family, weight, italic }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Best family name: typographic family (nameID 16) over legacy family (1); Windows-English
|
|
54
|
+
* records over the rest. Returns "" when the table yields nothing decodable. */
|
|
55
|
+
const readFamily = (dv: DataView, bytes: Uint8Array, name?: { offset: number, length: number }): string => {
|
|
56
|
+
if (!name || name.offset + 6 > bytes.byteLength) return ""
|
|
57
|
+
const base = name.offset
|
|
58
|
+
const count = dv.getUint16(base + 2)
|
|
59
|
+
const stringOffset = base + dv.getUint16(base + 4)
|
|
60
|
+
|
|
61
|
+
let best = ""
|
|
62
|
+
let bestScore = -1
|
|
63
|
+
for (let i = 0; i < count; i++) {
|
|
64
|
+
const rec = base + 6 + i * 12
|
|
65
|
+
if (rec + 12 > bytes.byteLength) break
|
|
66
|
+
const platformID = dv.getUint16(rec)
|
|
67
|
+
const languageID = dv.getUint16(rec + 4)
|
|
68
|
+
const nameID = dv.getUint16(rec + 6)
|
|
69
|
+
const length = dv.getUint16(rec + 8)
|
|
70
|
+
const offset = stringOffset + dv.getUint16(rec + 10)
|
|
71
|
+
if (nameID !== 16 && nameID !== 1) continue
|
|
72
|
+
if (offset + length > bytes.byteLength) continue
|
|
73
|
+
|
|
74
|
+
// nameID 16 beats 1; within an id, Windows-English > Windows > Unicode > Mac.
|
|
75
|
+
let score = nameID === 16 ? 100 : 0
|
|
76
|
+
if (platformID === 3) score += languageID === 0x409 ? 30 : 20
|
|
77
|
+
else if (platformID === 0) score += 10
|
|
78
|
+
if (score <= bestScore) continue
|
|
79
|
+
|
|
80
|
+
const text = platformID === 1 ? decodeLatin1(bytes, offset, length) : decodeUtf16be(bytes, offset, length)
|
|
81
|
+
if (!text.trim()) continue
|
|
82
|
+
best = text.trim()
|
|
83
|
+
bestScore = score
|
|
84
|
+
}
|
|
85
|
+
return best
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const decodeUtf16be = (bytes: Uint8Array, offset: number, length: number): string => {
|
|
89
|
+
let out = ""
|
|
90
|
+
for (let i = 0; i + 1 < length; i += 2) out += String.fromCharCode((bytes[offset + i] << 8) | bytes[offset + i + 1])
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const decodeLatin1 = (bytes: Uint8Array, offset: number, length: number): string => {
|
|
95
|
+
let out = ""
|
|
96
|
+
for (let i = 0; i < length; i++) out += String.fromCharCode(bytes[offset + i])
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Helpers for serving the bundler's source map alongside the bundle. The map's generated positions
|
|
2
|
+
// are relative to the bundle code; when the deploy/preview prepends a comment header, the map must be
|
|
3
|
+
// shifted down by that many lines, then inlined into the bundle as a data URL (the preview executes
|
|
4
|
+
// the bundle via `new Function(string)`, which has no URL identity, so an external `.map` can't be
|
|
5
|
+
// fetched — it must be inline).
|
|
6
|
+
|
|
7
|
+
/** Shift every generated position in `mapJson` down by `lines` (each VLQ line is `;`-separated). */
|
|
8
|
+
export const offsetSourceMap = (mapJson: string, lines: number): string => {
|
|
9
|
+
if (lines <= 0) return mapJson
|
|
10
|
+
const map = JSON.parse(mapJson) as { mappings: string }
|
|
11
|
+
map.mappings = ";".repeat(lines) + map.mappings
|
|
12
|
+
return JSON.stringify(map)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inline the map into a bundle string under a **non-standard** directive (`lecodesMap`, not
|
|
17
|
+
* `sourceMappingURL`), so the browser does NOT auto-apply it. The bundle runs via `new Function`, and
|
|
18
|
+
* the browser maps the raw stack position *including* the `new Function` wrapper (~2-3 lines) which the
|
|
19
|
+
* map can't subtract — so its native source location would be off by those lines. Our preview mapper
|
|
20
|
+
* reads `lecodesMap` and subtracts the (calibrated) wrapper itself, so its locations are correct.
|
|
21
|
+
*/
|
|
22
|
+
export const inlineSourceMapComment = (mapJson: string): string => {
|
|
23
|
+
const b64 = Buffer.from(mapJson, "utf8").toString("base64")
|
|
24
|
+
return `//# lecodesMap=data:application/json;charset=utf-8;base64,${b64}`
|
|
25
|
+
}
|
package/runtime/sdk/g2/Node2D.ts
CHANGED
|
@@ -39,6 +39,9 @@ export class Node2D extends AspectHost<Node2DEvents> {
|
|
|
39
39
|
/** Native entity handle. */
|
|
40
40
|
readonly id: number
|
|
41
41
|
|
|
42
|
+
/** Optional debug/editor name (plain JS data — scene2d files stamp their node names here). */
|
|
43
|
+
name?: string
|
|
44
|
+
|
|
42
45
|
protected _x = 0
|
|
43
46
|
protected _y = 0
|
|
44
47
|
protected _rotation = 0
|
|
@@ -4,32 +4,55 @@
|
|
|
4
4
|
// sprite.anim.play('walk')
|
|
5
5
|
// Once defined, clips advance entirely in native code; the aspect just translates clip ids back to
|
|
6
6
|
// names for the node's 'loopReached' / 'completed' events.
|
|
7
|
+
//
|
|
8
|
+
// Directional sheets (one texture row per facing): set `directions` in row order and mark clips
|
|
9
|
+
// `perDirection` — each expands to `name_DIR` with the frames offset by that row. Then
|
|
10
|
+
// `play('walk', 'SE')` or `play('walk', moveVector)` picks the row (compass tokens: E/NE/N/NW/W/SW/S/SE),
|
|
11
|
+
// and the last direction sticks, so a later `play('idle')` keeps facing.
|
|
12
|
+
//
|
|
13
|
+
// The native clips need the texture's dimensions, so when the Sprite has no texture yet (SpriteSheet's
|
|
14
|
+
// deferred load), the define is postponed: play() calls queue by name and start once define() runs.
|
|
7
15
|
|
|
8
16
|
import { Aspect } from "../core/Aspect"
|
|
17
|
+
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
9
18
|
import { ensureAnimEvents } from "./loop"
|
|
10
19
|
import type { Sprite } from "./Sprite"
|
|
11
20
|
|
|
12
|
-
/** Per-clip frame list, or an object for per-clip fps/loop overrides. */
|
|
13
|
-
export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean }
|
|
21
|
+
/** Per-clip frame list, or an object for per-clip fps/loop/direction overrides. */
|
|
22
|
+
export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean, perDirection?: boolean }
|
|
23
|
+
|
|
24
|
+
// Compass angles in Y-up world degrees (E = +x, N = +y) for vector → direction resolution.
|
|
25
|
+
const COMPASS: Record<string, number> = { E: 0, NE: 45, N: 90, NW: 135, W: 180, SW: 225, S: 270, SE: 315 }
|
|
14
26
|
|
|
15
27
|
export class SpriteAnimation extends Aspect<"anim", Sprite> {
|
|
16
28
|
static readonly aspect = "anim"
|
|
17
29
|
|
|
18
30
|
/** Grid cell size in texture pixels (defaults to the full texture). */
|
|
19
31
|
size?: [number, number]
|
|
32
|
+
/** Grid columns override — for sheets with padding; defaults to floor(texWidth / cellWidth). */
|
|
33
|
+
cols?: number
|
|
34
|
+
/** Pixel origin of the grid in the texture — cells index from here (a SpriteSheet region). */
|
|
35
|
+
origin?: [number, number]
|
|
20
36
|
/** Default fps for clips that don't override it. */
|
|
21
37
|
fps = 12
|
|
22
38
|
/** Default loop for clips that don't override it. */
|
|
23
39
|
loop = true
|
|
24
40
|
/** Named clips; frame indices are row-major into the grid. Set via the attach opts. */
|
|
25
41
|
clips: Record<string, Clip> = {}
|
|
42
|
+
/** Facing names in texture-row order (row i = directions[i]) for `perDirection` clips. */
|
|
43
|
+
directions?: string[]
|
|
26
44
|
|
|
27
45
|
private _clipIds: Record<string, number> = {}
|
|
28
46
|
private _clipNames: Record<number, string> = {}
|
|
29
47
|
private _current: string | null = null
|
|
48
|
+
private _defined = false
|
|
49
|
+
private _queued: string | null = null
|
|
50
|
+
private _dir: string | null = null
|
|
30
51
|
|
|
31
52
|
onAttach(): void {
|
|
32
|
-
|
|
53
|
+
// No texture yet (deferred sheet load): postpone the native define — SpriteSheet (or the user)
|
|
54
|
+
// calls define() after assigning the texture; play() calls queue until then.
|
|
55
|
+
if (this.node.texture) this._define()
|
|
33
56
|
// route native animation events for this entity to named 'loopReached'/'completed' events.
|
|
34
57
|
;(this.node as { _dispatchAnimEvent: (clipId: number, type: number) => void })._dispatchAnimEvent =
|
|
35
58
|
(clipId, type) => this.node._emitAnim(type === 1 ? "completed" : "loopReached", this._clipNames[clipId] ?? "")
|
|
@@ -42,20 +65,16 @@ export class SpriteAnimation extends Aspect<"anim", Sprite> {
|
|
|
42
65
|
private _define(): void {
|
|
43
66
|
const node = this.node
|
|
44
67
|
const tex = node.texture
|
|
45
|
-
if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before
|
|
68
|
+
if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before defining clips")
|
|
46
69
|
const [ fw, fh ] = this.size ?? [ tex.width, tex.height ]
|
|
47
|
-
const cols = Math.max(1, Math.floor(tex.width / fw))
|
|
48
|
-
|
|
49
|
-
for (const name of Object.keys(this.clips)) {
|
|
50
|
-
const value = this.clips[name]
|
|
51
|
-
const indices = Array.isArray(value) ? value : value.frames
|
|
52
|
-
const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
|
|
53
|
-
const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
|
|
70
|
+
const cols = this.cols ?? Math.max(1, Math.floor(tex.width / fw))
|
|
71
|
+
const [ ox, oy ] = this.origin ?? [ 0, 0 ]
|
|
54
72
|
|
|
73
|
+
const defineOne = (name: string, indices: number[], fps: number, loop: boolean): void => {
|
|
55
74
|
const frames = new Float32Array(indices.length * 4)
|
|
56
75
|
indices.forEach((idx, i) => {
|
|
57
|
-
const px = (idx % cols) * fw
|
|
58
|
-
const py = Math.floor(idx / cols) * fh
|
|
76
|
+
const px = ox + (idx % cols) * fw
|
|
77
|
+
const py = oy + Math.floor(idx / cols) * fh
|
|
59
78
|
frames[i * 4 + 0] = px / tex.width
|
|
60
79
|
frames[i * 4 + 1] = py / tex.height
|
|
61
80
|
frames[i * 4 + 2] = (px + fw) / tex.width
|
|
@@ -65,26 +84,88 @@ export class SpriteAnimation extends Aspect<"anim", Sprite> {
|
|
|
65
84
|
this._clipIds[name] = clipId
|
|
66
85
|
this._clipNames[clipId] = name
|
|
67
86
|
}
|
|
87
|
+
|
|
88
|
+
for (const name of Object.keys(this.clips)) {
|
|
89
|
+
const value = this.clips[name]
|
|
90
|
+
const indices = Array.isArray(value) ? value : value.frames
|
|
91
|
+
const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
|
|
92
|
+
const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
|
|
93
|
+
const dirs = !Array.isArray(value) && value.perDirection ? this.directions : undefined
|
|
94
|
+
|
|
95
|
+
if (dirs && dirs.length > 0) {
|
|
96
|
+
// one clip per facing: row i's frames are the base (row-0) frames shifted down i rows.
|
|
97
|
+
dirs.forEach((dir, row) => defineOne(`${name}_${dir}`, indices.map((f) => f + row * cols), fps, loop))
|
|
98
|
+
} else {
|
|
99
|
+
defineOne(name, indices, fps, loop)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
68
102
|
if (this.size) _creator2d.setSpriteSize(node.id, fw, fh)
|
|
103
|
+
this._defined = true
|
|
104
|
+
|
|
105
|
+
if (this._queued) {
|
|
106
|
+
const queued = this._queued
|
|
107
|
+
this._queued = null
|
|
108
|
+
const id = this._clipIds[queued]
|
|
109
|
+
if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${queued}"`)
|
|
110
|
+
_creator2d.playAnimation(node.id, id)
|
|
111
|
+
}
|
|
69
112
|
}
|
|
70
113
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly).
|
|
116
|
+
* For a `perDirection` clip, `dir` picks the facing — a direction name or a movement vector
|
|
117
|
+
* (nearest compass row wins); omitted, the last direction (or the first row) is kept.
|
|
118
|
+
*/
|
|
119
|
+
play(name: string, dir?: string | Vec2Like): this {
|
|
120
|
+
let target = name
|
|
121
|
+
const clip = this.clips[name]
|
|
122
|
+
if (clip && !Array.isArray(clip) && clip.perDirection && this.directions?.length) {
|
|
123
|
+
const resolved = this._resolveDir(dir)
|
|
124
|
+
if (resolved) { this._dir = resolved; target = `${name}_${resolved}` }
|
|
125
|
+
}
|
|
126
|
+
if (this._current === target) return this
|
|
127
|
+
this._current = target
|
|
128
|
+
if (!this._defined) { this._queued = target; return this }
|
|
129
|
+
const id = this._clipIds[target]
|
|
130
|
+
if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${target}"`)
|
|
77
131
|
_creator2d.playAnimation(this.node.id, id)
|
|
78
132
|
return this
|
|
79
133
|
}
|
|
80
134
|
|
|
81
135
|
stop(): this {
|
|
82
136
|
this._current = null
|
|
83
|
-
|
|
137
|
+
this._queued = null
|
|
138
|
+
if (this._defined) _creator2d.stopAnimation(this.node.id)
|
|
84
139
|
return this
|
|
85
140
|
}
|
|
86
141
|
|
|
142
|
+
/** Current facing (last direction resolved by play()), or null before the first directional play. */
|
|
143
|
+
get direction(): string | null { return this._dir }
|
|
144
|
+
|
|
87
145
|
set speed(value: number) { _creator2d.setAnimationSpeed(this.node.id, value) }
|
|
88
146
|
get current(): string | null { return this._current }
|
|
89
147
|
get frame(): number { return _creator2d.getAnimationFrame(this.node.id) }
|
|
148
|
+
|
|
149
|
+
// A direction name passes through; a vector resolves to the nearest compass row this sheet has.
|
|
150
|
+
// A zero vector (standing still) keeps the current facing.
|
|
151
|
+
private _resolveDir(dir?: string | Vec2Like): string | null {
|
|
152
|
+
const dirs = this.directions!
|
|
153
|
+
if (typeof dir === "string") return dir
|
|
154
|
+
if (dir !== undefined) {
|
|
155
|
+
const x = cx(dir), y = cy(dir)
|
|
156
|
+
if (x !== 0 || y !== 0) {
|
|
157
|
+
const angle = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360
|
|
158
|
+
let best: string | null = null
|
|
159
|
+
let bestDist = Infinity
|
|
160
|
+
for (const d of dirs) {
|
|
161
|
+
const a = COMPASS[d]
|
|
162
|
+
if (a === undefined) continue
|
|
163
|
+
const dist = Math.min(Math.abs(a - angle), 360 - Math.abs(a - angle))
|
|
164
|
+
if (dist < bestDist) { bestDist = dist; best = d }
|
|
165
|
+
}
|
|
166
|
+
if (best) return best
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return this._dir ?? dirs[0] ?? null
|
|
170
|
+
}
|
|
90
171
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Sprite sheets as data: one `.sprite.ts` file per image, `export default defineSpriteSheet({...})`.
|
|
2
|
+
// ONE model: every named entry is a region of the image (a rect), optionally SLICED into an
|
|
3
|
+
// animation grid, optionally carrying named clips over that grid — so game code (and the scene
|
|
4
|
+
// editor) reference frames by NAME and never hardcode rects:
|
|
5
|
+
//
|
|
6
|
+
// import props from './props.sprite'
|
|
7
|
+
// import hero from './hero.sprite'
|
|
8
|
+
// scene.add(props.make('tree_orange', { position: [80, 144], layer: 1 }))
|
|
9
|
+
// scene.add(props.make('water', { position: [0, 0] })) // sliced, loops on its own
|
|
10
|
+
// const player = hero.make('hero', { clip: 'idle', layer: 1 })
|
|
11
|
+
// player.anim.play('walk', inputVector)
|
|
12
|
+
//
|
|
13
|
+
// A sheet describes ART only — rects, slices, clips, anchors. World semantics (colliders, bodies)
|
|
14
|
+
// are the SCENE's job: attach Shape2D / Physics2D / Trigger2D aspects to the placed node (a
|
|
15
|
+
// `.scene2d.ts` `aspects:` list, or `.aspect(...)` in code). Each tool owns its own part.
|
|
16
|
+
//
|
|
17
|
+
// The texture loads lazily on the first make()/load(); sprites created before it resolves get their
|
|
18
|
+
// texture/frame applied on arrival (the world size is known up front from the rect / slice cell, so
|
|
19
|
+
// layout doesn't wait). The sprite editor reads and writes these files — hand edits are preserved
|
|
20
|
+
// (see packages/scene-doc).
|
|
21
|
+
|
|
22
|
+
import { type Vec2Like } from "../math/vec"
|
|
23
|
+
import { Sprite } from "./Sprite"
|
|
24
|
+
import { SpriteAnimation, type Clip } from "./SpriteAnimation"
|
|
25
|
+
import { Texture2D } from "./Texture2D"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A named region of the image. Just a rect is a static sprite; `slice` subdivides the rect into an
|
|
29
|
+
* animation grid ([cols, rows] — the cell is rect size / counts); `clips` names frame runs over
|
|
30
|
+
* that grid (local, row-major). A sliced entry with NO clips loops all of its cells in order.
|
|
31
|
+
*/
|
|
32
|
+
export type SheetSprite = {
|
|
33
|
+
/** Pixel rect [x, y, w, h] in the image. */
|
|
34
|
+
rect?: [number, number, number, number]
|
|
35
|
+
/** Subdivide the rect into an animation grid: [cols, rows]. */
|
|
36
|
+
slice?: [number, number]
|
|
37
|
+
/** Playback fps for this sprite's animation (defaults to the sheet fps, then 12). */
|
|
38
|
+
fps?: number
|
|
39
|
+
/** Named clips over the local grid (see SpriteAnimation). Omit to loop every cell. */
|
|
40
|
+
clips?: Record<string, Clip>
|
|
41
|
+
/** Facing names in local-row order for `perDirection` clips (compass tokens: S/SE/E/…). */
|
|
42
|
+
directions?: string[]
|
|
43
|
+
/** Overrides the sheet anchor. */
|
|
44
|
+
anchor?: Vec2Like
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SpriteSheetDef = {
|
|
48
|
+
/** The image URL — write `asset('./sheet.png')`. */
|
|
49
|
+
image: string
|
|
50
|
+
/** Default pivot for every sprite made from this sheet ([0.5, 1] = feet, for Y-sorted worlds). */
|
|
51
|
+
anchor?: Vec2Like
|
|
52
|
+
/** Default fps for sliced sprites / clips that don't override it. */
|
|
53
|
+
fps?: number
|
|
54
|
+
/** The named sprites. */
|
|
55
|
+
sprites?: Record<string, SheetSprite>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type SpriteMakeOptions = {
|
|
59
|
+
position?: Vec2Like
|
|
60
|
+
layer?: number
|
|
61
|
+
/** Overrides the sheet/sprite anchor. */
|
|
62
|
+
anchor?: Vec2Like
|
|
63
|
+
/** Start this clip immediately (sliced sprites with clips). */
|
|
64
|
+
clip?: string
|
|
65
|
+
/** Initial facing for `perDirection` clips. */
|
|
66
|
+
direction?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The name a sliced-but-clipless sprite's implicit everything-loop plays under. */
|
|
70
|
+
const LOOP_ALL = "all"
|
|
71
|
+
|
|
72
|
+
export class SpriteSheet {
|
|
73
|
+
private _def: SpriteSheetDef
|
|
74
|
+
private _tex: Texture2D | null = null
|
|
75
|
+
private _loading: Promise<Texture2D> | null = null
|
|
76
|
+
|
|
77
|
+
constructor(def: SpriteSheetDef) {
|
|
78
|
+
this._def = def
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The sheet definition (read-only by convention — the editor owns the file). */
|
|
82
|
+
get def(): SpriteSheetDef { return this._def }
|
|
83
|
+
|
|
84
|
+
/** The loaded texture, or null before load() resolves. */
|
|
85
|
+
get texture(): Texture2D | null { return this._tex }
|
|
86
|
+
|
|
87
|
+
/** Load the sheet's texture (idempotent). make() starts this automatically. */
|
|
88
|
+
load(): Promise<Texture2D> {
|
|
89
|
+
if (!this._loading) {
|
|
90
|
+
this._loading = Texture2D.load(this._def.image).then((tex) => { this._tex = tex; return tex })
|
|
91
|
+
}
|
|
92
|
+
return this._loading
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Names of the sheet's sprites. */
|
|
96
|
+
get spriteNames(): string[] { return Object.keys(this._def.sprites ?? {}) }
|
|
97
|
+
|
|
98
|
+
/** Build a named sprite; starting a clip hands back the animation handle. */
|
|
99
|
+
make(name: string, opts: SpriteMakeOptions & { clip: string }): Sprite & { anim: SpriteAnimation }
|
|
100
|
+
make(name: string, opts?: SpriteMakeOptions): Sprite
|
|
101
|
+
make(name: string, opts: SpriteMakeOptions = {}): Sprite {
|
|
102
|
+
const def = this._def
|
|
103
|
+
const named = def.sprites?.[name]
|
|
104
|
+
if (!named) {
|
|
105
|
+
throw new Error(`SpriteSheet.make(): unknown sprite "${name}" (have: ${this.spriteNames.join(", ") || "none"})`)
|
|
106
|
+
}
|
|
107
|
+
const rect = named.rect
|
|
108
|
+
const slice = named.slice
|
|
109
|
+
|
|
110
|
+
const sprite = new Sprite({
|
|
111
|
+
anchor: opts.anchor ?? named.anchor ?? def.anchor,
|
|
112
|
+
layer: opts.layer,
|
|
113
|
+
position: opts.position,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// The world size is known without the texture (the rect, or its slice cell) — set it up front
|
|
117
|
+
// so layout, Y-sort and colliders are correct even while the image is still loading.
|
|
118
|
+
if (rect) sprite.size = slice ? [ rect[2] / slice[0], rect[3] / slice[1] ] : [ rect[2], rect[3] ]
|
|
119
|
+
|
|
120
|
+
// sliced sprite: all animation flows through SpriteAnimation, anchored at the rect's origin.
|
|
121
|
+
// No clips declared = one implicit clip looping every cell (animated tiles).
|
|
122
|
+
if (rect && slice) {
|
|
123
|
+
const [ cols, rows ] = slice
|
|
124
|
+
const clips = named.clips && Object.keys(named.clips).length > 0
|
|
125
|
+
? named.clips
|
|
126
|
+
: { [LOOP_ALL]: Array.from({ length: cols * rows }, (_, i) => i) }
|
|
127
|
+
const animOpts: Partial<SpriteAnimation> = {
|
|
128
|
+
size: [ rect[2] / cols, rect[3] / rows ],
|
|
129
|
+
cols,
|
|
130
|
+
origin: [ rect[0], rect[1] ],
|
|
131
|
+
clips,
|
|
132
|
+
}
|
|
133
|
+
const fps = named.fps ?? def.fps
|
|
134
|
+
if (fps !== undefined) animOpts.fps = fps
|
|
135
|
+
if (named.directions) animOpts.directions = named.directions
|
|
136
|
+
sprite.aspect(SpriteAnimation, animOpts)
|
|
137
|
+
const start = opts.clip ?? (named.clips ? undefined : LOOP_ALL)
|
|
138
|
+
if (start) (sprite as Sprite & { anim: SpriteAnimation }).anim.play(start, opts.direction)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// texture + frame: now if loaded, else on arrival
|
|
142
|
+
if (this._tex) this._applyTexture(sprite, named, this._tex)
|
|
143
|
+
else this.load().then((tex) => this._applyTexture(sprite, named, tex))
|
|
144
|
+
return sprite
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The pixel rect [x, y, w, h] of a named sprite's REGION (the whole rect, sliced or not). */
|
|
148
|
+
rectOf(name: string): [number, number, number, number] | null {
|
|
149
|
+
const rect = this._def.sprites?.[name]?.rect
|
|
150
|
+
return rect ? [ ...rect ] : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private _applyTexture(sprite: Sprite, named: SheetSprite, tex: Texture2D): void {
|
|
154
|
+
sprite.texture = tex // native resets UV/size; the Sprite setter re-applies our explicit size
|
|
155
|
+
const rect = named.rect
|
|
156
|
+
if (rect) {
|
|
157
|
+
const [ cw, ch ] = named.slice ? [ rect[2] / named.slice[0], rect[3] / named.slice[1] ] : [ rect[2], rect[3] ]
|
|
158
|
+
sprite.setFramePx(rect[0], rect[1], cw, ch) // cell 0 (or the whole static rect)
|
|
159
|
+
}
|
|
160
|
+
const anim = (sprite as Partial<{ anim: SpriteAnimation }>).anim
|
|
161
|
+
if (anim) anim.define()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Declare a sprite sheet (the default export of a `.sprite.ts` file). */
|
|
166
|
+
export const defineSpriteSheet = (def: SpriteSheetDef): SpriteSheet => new SpriteSheet(def)
|