lecodes-cli 0.19.2 → 0.20.1
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 -0
- package/dist/index.js +1324 -658
- package/package.json +9 -9
- package/runtime/materials/decal-relief.mat +178 -0
- package/runtime/materials/decal.mat +172 -0
- package/runtime/materials/lightmap-baked-lite.mat +175 -0
- package/runtime/materials/lightmap-baked.mat +176 -0
- package/runtime/materials/lightmap.mat +181 -0
- package/runtime/materials/lit.mat +43 -0
- package/runtime/materials/particles-quad.mat +171 -0
- package/runtime/materials/particles.mat +121 -0
- package/runtime/materials/shadow.mat +21 -0
- package/runtime/materials/terrain.mat +134 -0
- package/runtime/materials/unlit-transparent.mat +42 -0
- package/runtime/materials/unlit.mat +44 -0
- package/runtime/materials/video.mat +31 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/runtime/web/assets/basis-C64VHDVD.js +1 -0
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-C_bkKXLg.js +852 -0
- package/runtime/web/assets/draco-BiISTFcR.js +118 -0
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BTB5KV9x.js +2 -0
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
- package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
- package/runtime/web/embed.html +32 -0
- package/runtime/web/embed.js +171 -0
- package/src/cmgenTool.ts +17 -9
- package/src/commands/app.ts +6 -1
- package/src/commands/appDesktop.ts +27 -4
- package/src/commands/appShared.ts +145 -3
- package/src/commands/appTemplates.ts +28 -4
- package/src/commands/assets.ts +3 -7
- package/src/commands/design.ts +9 -26
- package/src/commands/desktop.ts +9 -1
- package/src/commands/dev.ts +64 -0
- package/src/commands/projectTemplates.ts +17 -4
- package/src/commands/render.ts +9 -11
- package/src/commands/scene.ts +2 -6
- package/src/commands/shaders.ts +17 -1
- package/src/commands/shadersNew.ts +133 -0
- package/src/commands/test.ts +2 -1
- package/src/commands/thumbs.ts +3 -6
- package/src/commands/update.ts +42 -21
- package/src/desktopRenderer.ts +32 -2
- package/src/desktopScript.ts +1 -1
- package/src/dev/androidDev.ts +154 -0
- package/src/dev/devServer.ts +53 -6
- package/src/dev/webRunner.ts +22 -0
- package/src/index.ts +24 -4
- package/src/matcTool.ts +31 -19
- package/src/peerInstall.ts +160 -0
- package/src/peers.ts +48 -14
- package/src/projectEnv.ts +4 -0
- package/src/releases.ts +16 -0
|
@@ -16,14 +16,14 @@ from \`main.ts\`.
|
|
|
16
16
|
|
|
17
17
|
\`\`\`
|
|
18
18
|
lecodes render # semantic JSON of the launched UI (structure, text, names, values)
|
|
19
|
-
lecodes render --png shot.png # pixels (
|
|
19
|
+
lecodes render --png shot.png # pixels (the renderer + @napi-rs/canvas install themselves on first use; --yes skips the prompt)
|
|
20
20
|
lecodes render --png card.png --clip card # ONE component/region: a node name or x,y,w,h
|
|
21
21
|
lecodes render src/screens/cart.ts # ONE screen module, without booting the app: any file that
|
|
22
22
|
# \`export default\`s a UIScreen (or a (state) => UIScreen —
|
|
23
23
|
# --state <name> picks the branch). Same flags as above.
|
|
24
24
|
lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
|
|
25
25
|
# use this to SEE 3D scenes / procedural geometry (headless
|
|
26
|
-
# renders record 3D as JSON
|
|
26
|
+
# renders record 3D as JSON — simulated, not drawn). Windows/Linux; downloads
|
|
27
27
|
# the renderer on first use. --frames <n> for slow loads.
|
|
28
28
|
lecodes render --script <file> # drive the app through a scenario (format below)
|
|
29
29
|
lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
|
|
@@ -36,6 +36,11 @@ lecodes render --desktop --script <file> # the same scenario on the NATIVE host
|
|
|
36
36
|
lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
|
|
37
37
|
lecodes test --desktop # the file flows on the NATIVE host (3D/Jolt/real GPU; coordinate
|
|
38
38
|
# flows only — design flows stay headless)
|
|
39
|
+
lecodes dev --android --time 12 --png phone.png # the REAL phone over adb (USB): launches the app on
|
|
40
|
+
# the dev url via adb reverse, streams device logs as [device:log],
|
|
41
|
+
# screenshots after 12 s, force-stops, exit 1 on a crash. --serial
|
|
42
|
+
# <id> with several devices, --apk <file> to install a build first.
|
|
43
|
+
# Without --time it runs until Ctrl+C (which stops the app).
|
|
39
44
|
\`\`\`
|
|
40
45
|
|
|
41
46
|
Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
|
|
@@ -72,8 +77,9 @@ advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — n
|
|
|
72
77
|
so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds and
|
|
73
78
|
\`Input.on('keydown' | 'keyup', e => e.code)\` fires for each \`key\`/\`keyDown\`/\`keyUp\` step
|
|
74
79
|
(events land before the next loop tick); \`stick\` values are what \`Input.gamepad(0).axis()\` reads.
|
|
75
|
-
Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON
|
|
76
|
-
|
|
80
|
+
Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON with REAL Jolt
|
|
81
|
+
physics (bodies fall, characters collide, triggers fire, ragdolls drop — a character with no ground
|
|
82
|
+
under it falls, like on device) — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
|
|
77
83
|
channel for game state that has no UI.
|
|
78
84
|
|
|
79
85
|
## Make the app testable
|
|
@@ -89,6 +95,13 @@ channel for game state that has no UI.
|
|
|
89
95
|
with \`"fixtures": "fixtures.json"\` in its envelope.
|
|
90
96
|
- \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
|
|
91
97
|
|
|
98
|
+
## 3D scenes
|
|
99
|
+
|
|
100
|
+
- A HUD over a \`Scene\` is a widget ATTACHED to it: \`const hud = UIWidget(...).style({ left: 16,
|
|
101
|
+
bottom: 16, pointerEvents: "none" }); hud.attachTo(scene); hud.show()\`. Never a \`UIScreen\`:
|
|
102
|
+
a screen is a destination, so \`UIScreen(...).open()\` after \`scene.open()\` CLOSES the scene
|
|
103
|
+
(the host logs "3D scene closed" and you see only the screen) — no background makes it transparent.
|
|
104
|
+
|
|
92
105
|
## SDK documentation
|
|
93
106
|
|
|
94
107
|
\`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
|
package/src/commands/render.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
|
4
4
|
import { screenTargetFromArgs } from "../compile/screenEntry"
|
|
5
5
|
import { findProjectRoot } from "../manifest"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
|
-
import { desktopDefaultViewport, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
7
|
+
import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
8
8
|
import { runDesktopRender } from "../desktopRenderer"
|
|
9
9
|
import { evaluateDesktopRun, translateScenario } from "../desktopScript"
|
|
10
10
|
import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
|
|
11
|
+
import { loadPeer } from "../peerInstall"
|
|
11
12
|
|
|
12
13
|
/*
|
|
13
14
|
* `lecodes render` — compile the local project and render it headless. By default it emits a
|
|
@@ -81,14 +82,9 @@ export const resolveSafeAreaChoice = (explicit: string | undefined, device: stri
|
|
|
81
82
|
return undefined
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/**
|
|
85
|
-
export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> =>
|
|
86
|
-
|
|
87
|
-
return await import("lecodes-renderer/headless")
|
|
88
|
-
} catch {
|
|
89
|
-
throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
|
|
90
|
-
}
|
|
91
|
-
}
|
|
85
|
+
/** The optional renderer package — installed into ~/.lecodes/peers on first use (peerInstall.ts). */
|
|
86
|
+
export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> =>
|
|
87
|
+
loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes render" })
|
|
92
88
|
|
|
93
89
|
/** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
|
|
94
90
|
export const fileScenarioIO = (dir: string) => ({
|
|
@@ -196,6 +192,8 @@ const renderDesktop = async (args: Args) => {
|
|
|
196
192
|
|
|
197
193
|
// One renderer decision: the host binary AND the staged .filamat variant (see appShared).
|
|
198
194
|
const desktopRenderer = resolveDesktopRenderer(root, args)
|
|
195
|
+
// Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
|
|
196
|
+
const { dirs: pluginDirs } = desktopPluginLibraries(root)
|
|
199
197
|
const { js, resources } = await buildBundle(args, undefined, {
|
|
200
198
|
localAssets: true, desktopRenderer,
|
|
201
199
|
extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
|
|
@@ -212,7 +210,7 @@ const renderDesktop = async (args: Args) => {
|
|
|
212
210
|
const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
|
|
213
211
|
note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
|
|
214
212
|
const { output } = await runDesktopRender(js, resources, {
|
|
215
|
-
renderer: desktopRenderer,
|
|
213
|
+
renderer: desktopRenderer, pluginDirs,
|
|
216
214
|
width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
|
|
217
215
|
})
|
|
218
216
|
const result = evaluateDesktopRun(plan, output)
|
|
@@ -234,7 +232,7 @@ const renderDesktop = async (args: Args) => {
|
|
|
234
232
|
const timeoutMs = Number(flagStr(args, "timeout")) || 60000
|
|
235
233
|
note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
|
|
236
234
|
await runDesktopRender(js, resources, {
|
|
237
|
-
renderer: desktopRenderer,
|
|
235
|
+
renderer: desktopRenderer, pluginDirs,
|
|
238
236
|
width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
|
|
239
237
|
})
|
|
240
238
|
note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
|
package/src/commands/scene.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { compileSceneEditor } from "../compile/sceneCompile"
|
|
|
5
5
|
import { localShaderArtifact } from "../compile/shaders"
|
|
6
6
|
import { openBrowser } from "../browserAuth"
|
|
7
7
|
import { CliError, c, flagBool, flagStr, log, note, warnErr, type Args } from "../util"
|
|
8
|
+
import { loadPeer } from "../peerInstall"
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
* `lecodes scene [path]` — the 3D scene editor (lecodes-tools/packages/lecodes-3d-editor) hosted on a local project,
|
|
@@ -112,12 +113,7 @@ const countScenes = (dir: string): number => {
|
|
|
112
113
|
export const scene = async (args: Args) => {
|
|
113
114
|
const ctx = getContext(args)
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
sceneServer = await import("lecodes-3d-editor/server")
|
|
118
|
-
} catch {
|
|
119
|
-
throw new CliError("The scene editor isn't installed. Run: npm install -g lecodes-3d-editor")
|
|
120
|
-
}
|
|
116
|
+
const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes scene" })
|
|
121
117
|
|
|
122
118
|
const port = Number(flagStr(args, "port")) || DEFAULT_PORT
|
|
123
119
|
|
package/src/commands/shaders.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
|
+
import { relative } from "node:path"
|
|
2
3
|
import { findProjectRoot, readManifest, type Manifest } from "../manifest"
|
|
3
4
|
import { collectLocalEntries } from "../compile/collectLocal"
|
|
4
5
|
import {
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
import { fetchMatcManifest, installedMatcVersions, resolveMatcExe, resolveMatcExeCached, updateMatc } from "../matcTool"
|
|
9
10
|
import { semverNewer } from "../releases"
|
|
10
11
|
import { CliError, c, flagStr, log, note, warnErr, type Args } from "../util"
|
|
12
|
+
import { engineMaterialNames, writeNewShader } from "./shadersNew"
|
|
11
13
|
import { parseShaderSchema, type AnyShaderPlatform } from "lecodes-sdk/compile"
|
|
12
14
|
|
|
13
15
|
/*
|
|
@@ -17,6 +19,9 @@ import { parseShaderSchema, type AnyShaderPlatform } from "lecodes-sdk/compile"
|
|
|
17
19
|
* compile compile every shader in the project into .lecodes/shaders (downloads matc on
|
|
18
20
|
* first use). `--platform a,b` compiles only those targets.
|
|
19
21
|
* update download the newest published matc (run after an engine upgrade)
|
|
22
|
+
* new <name> a fresh .mat + its .material.ts asset in the current dir (--dir elsewhere): the
|
|
23
|
+
* starter template, or `--ref lit|unlit|particles|…` = a copy of that engine source
|
|
24
|
+
* to start from (shadersNew.ts); --force overwrites
|
|
20
25
|
* matc (Filament's material compiler) caches in ~/.lecodes/matc/; LECODES_MATC overrides it.
|
|
21
26
|
*
|
|
22
27
|
* A shader compiles to one artifact per target: `opengl` + `metal` (mobile profile, Android/Apple),
|
|
@@ -148,10 +153,21 @@ const compile = async (args: Args) => {
|
|
|
148
153
|
note(`${shaders.length} shader${shaders.length === 1 ? "" : "s"} compiled${scope} → ${shadersCacheDir(root)}`)
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
const newShader = (args: Args) => {
|
|
157
|
+
const name = args._[1]
|
|
158
|
+
if (!name) throw new CliError(`lecodes shaders new <name> [--ref ${engineMaterialNames().join("|") || "lit"}] [--dir <folder>] [--force]`)
|
|
159
|
+
const res = writeNewShader({ name, ref: flagStr(args, "ref"), dir: flagStr(args, "dir"), force: args.flags.force === true })
|
|
160
|
+
const rel = (p: string) => relative(process.cwd(), p)
|
|
161
|
+
log(`${rel(res.mat)} ${c.dim(res.ref ? `(from the engine's ${res.ref}.mat)` : "(the starter template)")}`)
|
|
162
|
+
log(`${rel(res.asset)} ${c.dim("(defineMaterial asset — a scene takes it as `material:`)")}`)
|
|
163
|
+
note(`next: edit it, then \`lecodes shaders compile\` (or just \`lecodes dev\`); in code: Material.load(asset('./${name.replace(/\.mat$/, "")}.mat'))`)
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
export const shaders = async (args: Args) => {
|
|
152
167
|
const sub = args._[0]
|
|
153
168
|
if (sub === undefined) return status()
|
|
154
169
|
if (sub === "compile") return compile(args)
|
|
155
170
|
if (sub === "update") return updateMatc()
|
|
156
|
-
|
|
171
|
+
if (sub === "new") return newShader(args)
|
|
172
|
+
throw new CliError(`Unknown subcommand "shaders ${sub}". Use \`lecodes shaders\` (status), \`lecodes shaders compile [--platform a,b]\`, \`lecodes shaders new <name> [--ref lit]\` or \`lecodes shaders update\`.`)
|
|
157
173
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// `lecodes shaders new <name> [--ref lit] [--dir assets/shaders] [--force]` — a fresh .mat in the
|
|
2
|
+
// project, ready to compile: either the starter template (an unlit material with an annotated
|
|
3
|
+
// parameter of each kind) or a COPY of one of the engine's own sources (`--ref lit`, `--ref unlit`,
|
|
4
|
+
// `--ref particles`…) renamed after the project's file — the way to get vertex colours, an emissive
|
|
5
|
+
// term or a second map onto an otherwise standard material without touching the engine. A
|
|
6
|
+
// `<name>.material.ts` asset goes next to it (defineMaterial with the parameters the .mat declares).
|
|
7
|
+
//
|
|
8
|
+
// The engine's sources come from the CLI's runtime/materials (vendored at build time from
|
|
9
|
+
// creator-gl/materials/src, see scripts/vendor-runtime.ts); in the monorepo the source dir itself
|
|
10
|
+
// is used, so a dev CLI sees the sources as they are.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
13
|
+
import { basename, join, relative, resolve } from "node:path"
|
|
14
|
+
import { parseShaderSchema } from "lecodes-sdk/compile"
|
|
15
|
+
import { distRoot } from "../distRoot"
|
|
16
|
+
import { CliError } from "../util"
|
|
17
|
+
|
|
18
|
+
/** where the engine's .mat sources are, or null when this CLI carries none */
|
|
19
|
+
export const engineMaterialsDir = (): string | null => {
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(distRoot, "runtime", "materials"),
|
|
22
|
+
join(distRoot, "..", "creator-gl", "materials", "src"),
|
|
23
|
+
]
|
|
24
|
+
for (const c of candidates) if (existsSync(join(c, "unlit.mat"))) return c
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** the sources a `--ref` may name (the common set every host ships; the AR camera ones are not materials to start from) */
|
|
29
|
+
export const engineMaterialNames = (): string[] => {
|
|
30
|
+
const dir = engineMaterialsDir()
|
|
31
|
+
if (!dir) return []
|
|
32
|
+
return readdirSync(dir).filter((f) => f.endsWith(".mat") && !f.startsWith("camera") && f !== "shader360.mat").map((f) => f.slice(0, -4)).sort()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NAME_RE = /^[a-z][a-z0-9_-]*$/i
|
|
36
|
+
/** the `name :` inside the .mat — an identifier (Filament's rule), so a dash in the file name becomes an underscore */
|
|
37
|
+
const matName = (name: string): string => name.replace(/-/g, "_")
|
|
38
|
+
|
|
39
|
+
/** the starter: unlit, one parameter of each kind with the editor annotations spelled out */
|
|
40
|
+
export const starterMat = (name: string): string => `// ${name}.mat — a custom material (lecodes shaders new).
|
|
41
|
+
//
|
|
42
|
+
// Filament's material language: the \`material\` block declares the parameters and the render state,
|
|
43
|
+
// \`fragment\` computes the surface. Read it with \`Material.load(asset('./${name}.mat'))\` or the
|
|
44
|
+
// ${name}.material.ts asset beside it; set parameters with material.set('tint', '#ff8800').
|
|
45
|
+
// \`lecodes shaders compile\` (or \`lecodes dev\`) compiles it for every host into .lecodes/shaders.
|
|
46
|
+
//
|
|
47
|
+
// The comment on a parameter's line is what the scene editor shows for it:
|
|
48
|
+
// @color a colour picker (also inferred from a name like tint / color / emissive)
|
|
49
|
+
// @range(min, max) a slider, @step(s) its step, @default(v) the value a new material starts with
|
|
50
|
+
// the rest of the line is the tooltip
|
|
51
|
+
|
|
52
|
+
material {
|
|
53
|
+
name : ${matName(name)},
|
|
54
|
+
shadingModel : unlit,
|
|
55
|
+
parameters : [
|
|
56
|
+
{ type : float3, name : tint }, // @color @default(#ffffff) the flat colour
|
|
57
|
+
{ type : float, name : glow }, // @range(0, 2) @step(0.05) @default(1) brightness multiplier
|
|
58
|
+
{ type : sampler2d, name : map } // the texture, white when unset
|
|
59
|
+
],
|
|
60
|
+
requires : [
|
|
61
|
+
uv0
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fragment {
|
|
66
|
+
void material(inout MaterialInputs material) {
|
|
67
|
+
prepareMaterial(material);
|
|
68
|
+
vec4 base = texture(materialParams_map, getUV0());
|
|
69
|
+
material.baseColor.rgb = base.rgb * materialParams.tint * materialParams.glow;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`
|
|
73
|
+
|
|
74
|
+
/** the engine's source renamed after the file, with a header saying where it came from */
|
|
75
|
+
export const refMat = (name: string, ref: string, source: string): string => {
|
|
76
|
+
const renamed = source.replace(/(\bname\s*:\s*)([A-Za-z_][A-Za-z0-9_]*)/, `$1${matName(name)}`)
|
|
77
|
+
return `// ${name}.mat — started from the engine's ${ref}.mat (lecodes shaders new --ref ${ref}); edit freely,
|
|
78
|
+
// the engine keeps its own. \`lecodes shaders compile\` builds it for every host into .lecodes/shaders.
|
|
79
|
+
// Load it with Material.load(asset('./${name}.mat')) or the ${name}.material.ts asset beside it.
|
|
80
|
+
|
|
81
|
+
${renamed}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** the defineMaterial asset for a .mat: every non-sampler parameter with the value the schema gives it */
|
|
85
|
+
export const materialAsset = (name: string, mat: string): string => {
|
|
86
|
+
const schema = parseShaderSchema(mat)
|
|
87
|
+
const lines: string[] = []
|
|
88
|
+
for (const p of schema.params) {
|
|
89
|
+
if (p.editor === "texture") { lines.push(` // ${p.name}: asset('./texture.png'), // a sampler takes an image asset`); continue }
|
|
90
|
+
if (p.default === null || p.default === undefined) continue
|
|
91
|
+
lines.push(` ${p.name}: ${JSON.stringify(p.default)},${p.tooltip ? ` // ${p.tooltip}` : ""}`)
|
|
92
|
+
}
|
|
93
|
+
return `// ${name}.material.ts — the material as a project asset: ONE shared instance a scene file takes as
|
|
94
|
+
// \`material: ${camel(name)}\` and code loads with \`await ${camel(name)}.load()\`. The parameters and their
|
|
95
|
+
// widgets come from the annotations in ${name}.mat.
|
|
96
|
+
export default defineMaterial({
|
|
97
|
+
shader: asset('./${name}.mat'),
|
|
98
|
+
params: {
|
|
99
|
+
${lines.join("\n")}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
`
|
|
103
|
+
}
|
|
104
|
+
const camel = (name: string): string => name.replace(/[-_]+(.)/g, (_, ch: string) => ch.toUpperCase())
|
|
105
|
+
|
|
106
|
+
export type NewShaderOptions = { name: string, ref?: string, dir?: string, force?: boolean, cwd?: string }
|
|
107
|
+
export type NewShaderResult = { mat: string, asset: string, ref: string | null }
|
|
108
|
+
|
|
109
|
+
/** Write `<name>.mat` and `<name>.material.ts`; returns the paths. Throws a CliError on a bad name,
|
|
110
|
+
* an unknown --ref, or files already there (unless `force`). */
|
|
111
|
+
export const writeNewShader = (o: NewShaderOptions): NewShaderResult => {
|
|
112
|
+
const name = o.name.endsWith(".mat") ? o.name.slice(0, -4) : o.name
|
|
113
|
+
if (!NAME_RE.test(name)) throw new CliError(`"${o.name}" is not a shader name — letters, digits, - and _ (starting with a letter), e.g. hologram or rim-light`)
|
|
114
|
+
const dir = resolve(o.cwd ?? process.cwd(), o.dir ?? ".")
|
|
115
|
+
const matPath = join(dir, `${name}.mat`), assetPath = join(dir, `${name}.material.ts`)
|
|
116
|
+
if (!o.force) for (const p of [ matPath, assetPath ]) if (existsSync(p)) throw new CliError(`${relative(o.cwd ?? process.cwd(), p) || basename(p)} exists — pick another name or pass --force to overwrite`)
|
|
117
|
+
|
|
118
|
+
let mat: string
|
|
119
|
+
let ref: string | null = null
|
|
120
|
+
if (o.ref) {
|
|
121
|
+
ref = o.ref.endsWith(".mat") ? o.ref.slice(0, -4) : o.ref
|
|
122
|
+
const srcDir = engineMaterialsDir()
|
|
123
|
+
if (!srcDir) throw new CliError("this CLI carries no engine material sources (runtime/materials) — --ref needs a CLI built with them")
|
|
124
|
+
const known = engineMaterialNames()
|
|
125
|
+
if (!known.includes(ref)) throw new CliError(`no engine material "${ref}". Known: ${known.join(", ")}`)
|
|
126
|
+
mat = refMat(name, ref, readFileSync(join(srcDir, `${ref}.mat`), "utf8"))
|
|
127
|
+
} else mat = starterMat(name)
|
|
128
|
+
|
|
129
|
+
mkdirSync(dir, { recursive: true })
|
|
130
|
+
writeFileSync(matPath, mat)
|
|
131
|
+
writeFileSync(assetPath, materialAsset(name, mat))
|
|
132
|
+
return { mat: matPath, asset: assetPath, ref }
|
|
133
|
+
}
|
package/src/commands/test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"
|
|
2
2
|
import { dirname, resolve } from "node:path"
|
|
3
3
|
import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
4
|
-
import { desktopDefaultViewport, resolveDesktopRenderer } from "./appShared"
|
|
4
|
+
import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./appShared"
|
|
5
5
|
import { fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice } from "./render"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
7
|
import { findProjectRoot } from "../manifest"
|
|
@@ -261,6 +261,7 @@ export const test = async (args: Args) => {
|
|
|
261
261
|
const finalPng = resolve(caseDir, "final.png")
|
|
262
262
|
const { output } = await runDesktopRender(native!.js, native!.resources, {
|
|
263
263
|
renderer: desktopRenderer,
|
|
264
|
+
pluginDirs: desktopPluginLibraries(root).dirs,
|
|
264
265
|
width: Number(flagStr(args, "width")) || kase.width || projectViewport?.width || 390,
|
|
265
266
|
height: Number(flagStr(args, "height")) || kase.height || projectViewport?.height || 844,
|
|
266
267
|
frames: plan.frameCap, outPng: finalPng, logs: flagBool(args, "logs"),
|
package/src/commands/thumbs.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"
|
|
|
3
3
|
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import { extname, join, relative, resolve } from "node:path"
|
|
6
|
+
import { loadPeer } from "../peerInstall"
|
|
6
7
|
import { projectRootOf } from "./scene"
|
|
7
8
|
import { CliError, c, endProgress, flagBool, flagStr, log, note, progress, toKey, warnErr, type Args } from "../util"
|
|
8
9
|
|
|
@@ -102,12 +103,8 @@ const collect = (dir: string): string[] => {
|
|
|
102
103
|
export const thumbs = async (args: Args) => {
|
|
103
104
|
const root = projectRootOf(process.cwd())
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
sceneServer = await import("lecodes-3d-editor/server")
|
|
108
|
-
} catch {
|
|
109
|
-
throw new CliError("The scene editor isn't installed (it owns the preview cache). Run: npm install -g lecodes-3d-editor")
|
|
110
|
-
}
|
|
106
|
+
// The scene editor owns the preview cache — installed into ~/.lecodes/peers on first use.
|
|
107
|
+
const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes thumbs" })
|
|
111
108
|
|
|
112
109
|
const size = Number(flagStr(args, "size")) || DEFAULT_SIZE
|
|
113
110
|
const force = flagBool(args, "force")
|
package/src/commands/update.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { homedir } from "node:os"
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path"
|
|
5
5
|
import pkg from "../../package.json"
|
|
6
6
|
import { CliError, c, flagBool, log, note, type Args } from "../util"
|
|
7
|
-
import { OPTIONAL_PEERS, peerVersion } from "../peers"
|
|
7
|
+
import { OPTIONAL_PEERS, peerSource, peerVersion } from "../peers"
|
|
8
|
+
import { installPeers } from "../peerInstall"
|
|
8
9
|
import { updateDesktop } from "../desktopRenderer"
|
|
9
10
|
import { updateMatc } from "../matcTool"
|
|
10
11
|
import { types } from "./types"
|
|
@@ -20,10 +21,12 @@ import { app } from "./app"
|
|
|
20
21
|
* 3. shaders recompile the project's .mat files with that matc (= `shaders compile`)
|
|
21
22
|
* 4. types the IDE types (.lecodes/types + tsconfig) from the server (= `types`)
|
|
22
23
|
* 5. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
|
|
23
|
-
* 6. cli the
|
|
24
|
-
* lecodes-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* 6. cli the npm installs — every installed optional peer (lecodes-renderer, lecodes-design,
|
|
25
|
+
* lecodes-3d-editor, lecodes-assets, @napi-rs/canvas) and the CLI itself that is behind:
|
|
26
|
+
* peers in ~/.lecodes/peers (first-use installs, peerInstall.ts) are re-installed there
|
|
27
|
+
* — the renderer at THIS CLI's version, the rest at npm's latest in range; global ones
|
|
28
|
+
* (`npm install -g`, the old way) via `npm install -g …@latest`; the CLI itself last
|
|
29
|
+
* (the standalone binary can't come from npm — it says so and does the peers only)
|
|
27
30
|
*
|
|
28
31
|
* Every step is the existing subcommand, so nothing here can drift from what the individual
|
|
29
32
|
* commands do; this only sequences them and keeps going when one fails (the summary says which).
|
|
@@ -148,35 +151,53 @@ export const update = async (args: Args) => {
|
|
|
148
151
|
await run("cli", "lecodes-cli + its optional peers (global npm installs)", async () => {
|
|
149
152
|
if (flagBool(args, "no-cli")) return ["skipped", "--no-cli"]
|
|
150
153
|
if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
|
|
151
|
-
|
|
154
|
+
// Where each peer lives decides how it moves: the CLI's own prefix (~/.lecodes/peers, filled on
|
|
155
|
+
// first use) is re-installed by peerInstall — the renderer there tracks THIS CLI's version, not
|
|
156
|
+
// npm's latest, because compiler and headless host are one ABI set; a global install
|
|
157
|
+
// (`npm install -g`, the old way) moves through npm like before.
|
|
158
|
+
const rows: { name: string, installed: string | null, latest: string | null, where: "global" | "prefix" | null }[] = []
|
|
152
159
|
for (const [name, probe] of OPTIONAL_PEERS) {
|
|
160
|
+
const where = peerSource(name, probe)
|
|
153
161
|
const installed = peerVersion(name, probe)
|
|
154
|
-
|
|
162
|
+
const latest = !installed ? null
|
|
163
|
+
: where === "prefix" && name === "lecodes-renderer" ? pkg.version
|
|
164
|
+
: await npmLatest(name)
|
|
165
|
+
rows.push({ name, installed, latest, where })
|
|
155
166
|
}
|
|
156
|
-
rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli") })
|
|
167
|
+
rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
|
|
157
168
|
if (rows.every((r) => r.latest === null)) return ["skipped", "npm unreachable"]
|
|
158
169
|
const behind = rows.filter((r) => r.installed && r.latest && semverNewer(r.latest, r.installed) > 0)
|
|
159
170
|
for (const r of rows) {
|
|
160
|
-
const state = !r.installed ? c.dim("not installed (optional —
|
|
171
|
+
const state = !r.installed ? c.dim("not installed (optional — installed on first use)")
|
|
161
172
|
: !r.latest ? c.dim("(npm: unknown)")
|
|
162
173
|
: behind.includes(r) ? c.yellow("→ " + r.latest)
|
|
163
174
|
: c.dim("(up to date)")
|
|
164
|
-
log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + state)
|
|
175
|
+
log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + (r.where === "prefix" ? c.dim("peers ") : "") + state)
|
|
165
176
|
}
|
|
166
177
|
if (behind.length === 0) return "ok"
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
if (
|
|
178
|
+
const prefixBehind = behind.filter((r) => r.where === "prefix")
|
|
179
|
+
const globalSpecs = behind.filter((r) => r.where !== "prefix").map((r) => r.name + "@latest")
|
|
180
|
+
if (flagBool(args, "check")) {
|
|
181
|
+
return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
|
|
182
|
+
}
|
|
183
|
+
const done: string[] = []
|
|
184
|
+
if (prefixBehind.length) {
|
|
185
|
+
await installPeers(prefixBehind.map((r) => r.name), "lecodes update", { yes: true })
|
|
186
|
+
done.push(...prefixBehind.map((r) => r.name))
|
|
187
|
+
}
|
|
188
|
+
if (STANDALONE && globalSpecs.some((s) => s.startsWith("lecodes-cli@"))) {
|
|
170
189
|
note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
|
|
171
190
|
}
|
|
172
|
-
const toInstall = STANDALONE ?
|
|
173
|
-
if (toInstall.length
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return ["
|
|
191
|
+
const toInstall = STANDALONE ? globalSpecs.filter((s) => !s.startsWith("lecodes-cli@")) : globalSpecs
|
|
192
|
+
if (toInstall.length) {
|
|
193
|
+
log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
|
|
194
|
+
const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
|
|
195
|
+
if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
|
|
196
|
+
done.push(...toInstall.map((s) => s.replace("@latest", "")))
|
|
197
|
+
}
|
|
198
|
+
if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
|
|
199
|
+
const cliToo = done.includes("lecodes-cli")
|
|
200
|
+
return ["updated", done.join(", ") + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
|
|
180
201
|
})
|
|
181
202
|
|
|
182
203
|
// Summary — one line per step so a scrolled-away failure can't hide.
|
package/src/desktopRenderer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { homedir, tmpdir } from "node:os"
|
|
4
|
-
import { join, resolve } from "node:path"
|
|
4
|
+
import { delimiter, join, resolve } from "node:path"
|
|
5
5
|
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
6
6
|
import { CliError, note, warnErr } from "./util"
|
|
7
7
|
import { makeStackDemangler } from "./compile/nativeStack"
|
|
@@ -284,6 +284,10 @@ export type DesktopWindowOpts = {
|
|
|
284
284
|
minHeight?: number
|
|
285
285
|
resizable?: boolean
|
|
286
286
|
title?: string
|
|
287
|
+
/** localStorage scope (CREATOR_STORAGE_SCOPE, host ≥1.5.0): the app's stable identity — app.json
|
|
288
|
+
* `name` — so a `desktop run` keeps its saved state across runs (the host would otherwise key the
|
|
289
|
+
* storage by the launch path, which is a fresh temp dir every run). */
|
|
290
|
+
storageScope?: string
|
|
287
291
|
/** Borderless fullscreen on the primary monitor (host ≥1.3.0; older hosts ignore the env). */
|
|
288
292
|
fullscreen?: boolean
|
|
289
293
|
/** Display scale: a fixed density (0.5–4) or "fit" (derive from design frame vs monitor/work
|
|
@@ -293,22 +297,39 @@ export type DesktopWindowOpts = {
|
|
|
293
297
|
renderScale?: number
|
|
294
298
|
/** 3D buffer cap [W, H] in px (host ≥1.4.0) — e.g. [1920, 1080] keeps a 4K fullscreen at 1080p 3D. */
|
|
295
299
|
maxRenderSize?: [number, number]
|
|
300
|
+
/** The mouse button acts as a finger — click-drag scrolls/flings like touch (CREATOR_MOUSE_TOUCH;
|
|
301
|
+
* every host reads the env). A CREATOR_MOUSE_TOUCH already in the environment (shell / .env) wins. */
|
|
302
|
+
mouseEmulateTouch?: boolean
|
|
303
|
+
/** On-screen keyboard for every input (app.json desktop.virtualKeyboard, host ≥1.5.5): `true` or
|
|
304
|
+
* the block, forwarded verbatim as CREATOR_VIRTUAL_KEYBOARD JSON. A set env wins, like the mouse knob. */
|
|
305
|
+
virtualKeyboard?: boolean | object
|
|
296
306
|
/** Draw the host's frame counter in the corner (host ≥1.5.0; older hosts ignore the env). Costs
|
|
297
307
|
* nothing to leave off and next to nothing on — the digits are rectangles, no UI behind them. */
|
|
298
308
|
fps?: boolean
|
|
299
309
|
/** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
|
|
300
310
|
* the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
|
|
301
311
|
net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
|
|
312
|
+
/** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS; the loader was added
|
|
313
|
+
* after host 1.4.0 — an older host ignores the env). See appShared.desktopPluginLibraries. */
|
|
314
|
+
pluginDirs?: string[]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** CREATOR_PLUGIN_DIRS: the project's plugin dirs first, then whatever the environment already
|
|
318
|
+
* carried (a `.env` CREATOR_PLUGIN_DIRS keeps working alongside the project's `plugins/`). */
|
|
319
|
+
const pluginEnv = (dirs: string[] | undefined): Record<string, string> => {
|
|
320
|
+
const all = [...(dirs ?? []), ...(process.env.CREATOR_PLUGIN_DIRS ? [process.env.CREATOR_PLUGIN_DIRS] : [])]
|
|
321
|
+
return all.length > 0 ? { CREATOR_PLUGIN_DIRS: all.join(delimiter) } : {}
|
|
302
322
|
}
|
|
303
323
|
|
|
304
324
|
const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
|
|
305
|
-
const env: Record<string, string> = {}
|
|
325
|
+
const env: Record<string, string> = { ...pluginEnv(opts.pluginDirs) }
|
|
306
326
|
// A lone width/height fills the other side from the host default so it isn't silently ignored.
|
|
307
327
|
const width = opts.width ?? (opts.height !== undefined ? 1280 : undefined)
|
|
308
328
|
const height = opts.height ?? (opts.width !== undefined ? 720 : undefined)
|
|
309
329
|
if (width && height) env.CREATOR_WINDOW_SIZE = `${width}x${height}`
|
|
310
330
|
if (opts.minWidth && opts.minHeight) env.CREATOR_MIN_WINDOW_SIZE = `${opts.minWidth}x${opts.minHeight}`
|
|
311
331
|
if (opts.title) env.CREATOR_WINDOW_TITLE = opts.title
|
|
332
|
+
if (opts.storageScope) env.CREATOR_STORAGE_SCOPE = opts.storageScope
|
|
312
333
|
if (opts.resizable === false) env.CREATOR_WINDOW_FIXED = "1"
|
|
313
334
|
if (opts.fullscreen !== undefined) env.CREATOR_WINDOW_FULLSCREEN = opts.fullscreen ? "1" : "0"
|
|
314
335
|
if (opts.scale === "fit") env.CREATOR_UI_SCALE = "fit"
|
|
@@ -318,6 +339,12 @@ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
|
|
|
318
339
|
env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
|
|
319
340
|
}
|
|
320
341
|
if (opts.fps) env.CREATOR_FPS = "1"
|
|
342
|
+
if (opts.mouseEmulateTouch !== undefined && process.env.CREATOR_MOUSE_TOUCH === undefined) {
|
|
343
|
+
env.CREATOR_MOUSE_TOUCH = opts.mouseEmulateTouch ? "1" : "0"
|
|
344
|
+
}
|
|
345
|
+
if (opts.virtualKeyboard !== undefined && process.env.CREATOR_VIRTUAL_KEYBOARD === undefined) {
|
|
346
|
+
env.CREATOR_VIRTUAL_KEYBOARD = typeof opts.virtualKeyboard === "boolean" ? (opts.virtualKeyboard ? "1" : "0") : JSON.stringify(opts.virtualKeyboard)
|
|
347
|
+
}
|
|
321
348
|
return env
|
|
322
349
|
}
|
|
323
350
|
|
|
@@ -356,6 +383,8 @@ export type DesktopRenderOptions = {
|
|
|
356
383
|
/** Inspection camera: CREATOR_CAMERA ("ex,ey,ez;tx,ty,tz[;fov]") or CREATOR_VIEW ("iso[;fov]"). */
|
|
357
384
|
camera?: string
|
|
358
385
|
view?: string
|
|
386
|
+
/** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS). */
|
|
387
|
+
pluginDirs?: string[]
|
|
359
388
|
}
|
|
360
389
|
|
|
361
390
|
export type DesktopRenderOutcome = {
|
|
@@ -482,6 +511,7 @@ export const runDesktopRender = async (
|
|
|
482
511
|
if (opts.fixedDtMs) env.CREATOR_FIXED_DT = String(opts.fixedDtMs)
|
|
483
512
|
if (opts.camera) env.CREATOR_CAMERA = opts.camera
|
|
484
513
|
if (opts.view) env.CREATOR_VIEW = opts.view
|
|
514
|
+
Object.assign(env, pluginEnv(opts.pluginDirs))
|
|
485
515
|
// Frames the host prints name the minified bundle (`module.js:5:51631`); map them back to
|
|
486
516
|
// project positions. Chunks stay raw so `[script]` marker parsing sees exactly what was sent —
|
|
487
517
|
// the rewrite happens on the joined text (and best-effort per chunk for live --logs).
|
package/src/desktopScript.ts
CHANGED
|
@@ -40,7 +40,7 @@ export type DesktopPlan = {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "key", "keyDown", "keyUp", "look", "mouseTo", "wheel", "stick", "gamepad", "camera", "view"] as const
|
|
43
|
-
const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value"]
|
|
43
|
+
const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value", "audio"]
|
|
44
44
|
const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
|
|
45
45
|
const isVec3 = (v: unknown): v is [number, number, number] => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number")
|
|
46
46
|
|