lecodes-cli 1.0.0 → 2.0.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/dist/index.js +6211 -3649
- package/package.json +12 -8
- package/runtime/materials/lightmap.mat +115 -139
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/cli/help.ts +25 -0
- package/src/cli/index.ts +7 -7
- package/src/commands/app/android.ts +3 -1
- package/src/commands/app/desktop.ts +1 -1
- package/src/commands/app/desktopMac.ts +10 -9
- package/src/commands/app/icon.ts +9 -6
- package/src/commands/app/index.ts +3 -1
- package/src/commands/app/shared.ts +13 -5
- package/src/commands/app/templates/android.ts +10 -11
- package/src/commands/app/templates/ios.ts +1 -1
- package/src/commands/assets.ts +1 -1
- package/src/commands/compile.ts +15 -2
- package/src/commands/design/index.ts +4 -4
- package/src/commands/design/snapshot.ts +3 -3
- package/src/commands/desktop.ts +5 -1
- package/src/commands/dev.ts +82 -29
- package/src/commands/index.ts +64 -60
- package/src/commands/init/index.ts +11 -15
- package/src/commands/init/templates.ts +64 -21
- package/src/commands/lightmap.ts +459 -200
- package/src/commands/navmesh.ts +2 -2
- package/src/commands/plugin.ts +46 -0
- package/src/commands/render.ts +22 -16
- package/src/commands/scene.ts +6 -1
- package/src/commands/serve.ts +58 -0
- package/src/commands/shaders/new.ts +1 -1
- package/src/commands/test.ts +402 -398
- package/src/commands/update/index.ts +3 -3
- package/src/compile/designCompile.ts +1 -1
- package/src/compile/headlessBundle.ts +133 -133
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +10 -2
- package/src/compile/sceneCompile.ts +8 -22
- package/src/declarations/lecodes-3d-editor.d.ts +4 -0
- package/src/declarations/lecodes-assets.d.ts +1 -1
- package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +75 -38
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +82 -14
- package/src/hosts/cmgenTool.ts +1 -1
- package/src/hosts/desktopRenderer.ts +98 -27
- package/src/hosts/desktopScript.ts +150 -35
- package/src/hosts/distRoot.ts +1 -1
- package/src/hosts/matcTool.ts +1 -1
- package/src/hosts/peerInstall.ts +5 -5
- package/src/hosts/peers.ts +2 -2
- package/src/index.ts +4 -1
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/project/ignore.ts +164 -163
- package/runtime/materials/lightmap-baked-lite.mat +0 -180
- package/runtime/materials/lightmap-baked.mat +0 -181
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-Q06c6oHx.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
package/src/commands/navmesh.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { loadCreatorNav, NavBuilder, readNgeo, navMeshTriangles, type BuildParam
|
|
|
22
22
|
*
|
|
23
23
|
* Running the build in the CLI keeps the host generic (a geometry dump, useful on its own),
|
|
24
24
|
* lets the bake parameters iterate without a host rebuild, and is the same code a future headless
|
|
25
|
-
* bake reuses. The desktop host must know the dump (built from
|
|
25
|
+
* bake reuses. The desktop host must know the dump (built from hosts/desktop, or a release that
|
|
26
26
|
* ships it: LECODES_DESKTOP_EXE for a local build until then).
|
|
27
27
|
*
|
|
28
28
|
* The agent size (--radius/--height/--climb/--slope) defaults to what the scene declares — the
|
|
@@ -183,7 +183,7 @@ const bake = defineCommand({
|
|
|
183
183
|
}
|
|
184
184
|
if (!/\[navmesh\] done/.test(output)) {
|
|
185
185
|
if (lines.some((l) => /cannot dump collision geometry/.test(l))) {
|
|
186
|
-
throw new CliError("This desktop host has no collision-geometry dump. Build
|
|
186
|
+
throw new CliError("This desktop host has no collision-geometry dump. Build hosts/desktop and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
|
|
187
187
|
}
|
|
188
188
|
if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
|
|
189
189
|
warnErr("The host ran to its frame cap without dumping geometry. Make sure the level declares `env.navmesh` (a scene file) or calls NavMesh.load(...) after its static bodies exist, and skips menus when NavMesh.baking is true. More load headroom: --frames <n>. Host output: --logs.")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CliError, bool, c, defineCommand, info, log, success } from "../cli"
|
|
2
|
+
import { generate } from "../plugin/generate"
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* `lecodes plugin` — for the AUTHOR of a native plugin (the app's side is `lecodes app`, which
|
|
6
|
+
* installs plugins into a shell).
|
|
7
|
+
*
|
|
8
|
+
* plugin gen a plugin's contract.d.ts → the generated files beside its hand-written ones.
|
|
9
|
+
* The contract is the one description of the channel; what is generated from it is
|
|
10
|
+
* committed, and `--check` is what a CI runs to refuse a stale file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const gen = defineCommand({
|
|
14
|
+
name: "gen",
|
|
15
|
+
summary: "Generate a plugin's files from its contract.d.ts (--check: fail on a stale one)",
|
|
16
|
+
usage: "[path]",
|
|
17
|
+
description: "The path is one plugin (a directory with a contract.d.ts) or a repo that keeps its plugins in plugins/<id>/; the current directory when omitted.\n\nWrites sdk/<id>.gen.ts — the wrapper an app calls: the wire call by call, the class with the wire's methods as its own, and the public export when the plugin has no hand-written sdk/<id>.ts. Where the plugin has an ios/ folder, writes ios/<Name>.gen.swift — the contract's types, the protocol the plugin implements and the glue that reads a call off the wire. Where it has an android/ folder, writes the same as <Name>.gen.kt into the folder of the plugin's Kotlin package (android/ is laid out as a Gradle module: the package is the folder the path under android/src/main/java narrows down to). Where it has a web/ folder, writes web/<id>.gen.ts — the same for the web half, in a file that imports nothing; a web half that imports a LeCodes package is refused, because it would run on that host only. A contract the generator does not understand is an error with its line, and every problem of a file is reported at once.\n\nGenerated files are committed. Reading a contract needs TypeScript in the plugin's repo.",
|
|
18
|
+
flags: {
|
|
19
|
+
check: bool("write nothing; exit 1 when a generated file is missing or stale"),
|
|
20
|
+
},
|
|
21
|
+
examples: ["lecodes plugin gen", "lecodes plugin gen plugins/camera", "lecodes plugin gen --check"],
|
|
22
|
+
run: ({ args, flags }) => {
|
|
23
|
+
const report = generate(args[0] ?? ".", { check: flags.check })
|
|
24
|
+
|
|
25
|
+
for (const p of report.plugins) {
|
|
26
|
+
const channels = p.contract.channels.map(ch => `${ch.kind} "${ch.name}" v${ch.version}`).join(", ")
|
|
27
|
+
info(`${c.bold(p.id)} ${c.dim(channels)}`)
|
|
28
|
+
for (const f of p.written) log(c.green(` written ${f}`))
|
|
29
|
+
for (const f of p.stale) log(c.red(` stale ${f}`))
|
|
30
|
+
for (const f of p.fresh) log(c.dim(` fresh ${f}`))
|
|
31
|
+
}
|
|
32
|
+
for (const failure of report.failures) log(c.red(failure))
|
|
33
|
+
|
|
34
|
+
const stale = report.plugins.reduce((n, p) => n + p.stale.length, 0)
|
|
35
|
+
if (report.failures.length) throw new CliError(`${report.failures.length} contract${report.failures.length === 1 ? "" : "s"} did not generate.`)
|
|
36
|
+
if (stale) throw new CliError(`${stale} generated file${stale === 1 ? " is" : "s are"} stale — run \`lecodes plugin gen\` and commit.`)
|
|
37
|
+
success(flags.check ? "Every generated file is fresh." : "Generated.")
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export default defineCommand({
|
|
42
|
+
name: "plugin",
|
|
43
|
+
summary: "Write a native plugin: gen",
|
|
44
|
+
description: "For the author of a plugin — a capability an app reaches the same way on iOS, Android and the web. `lecodes app` is the other side: it installs plugins into an app's shell.",
|
|
45
|
+
commands: [gen],
|
|
46
|
+
})
|
package/src/commands/render.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { loadPeer } from "../hosts/peerInstall"
|
|
|
14
14
|
/*
|
|
15
15
|
* `lecodes render` — compile the local project and render it headless. By default it emits a
|
|
16
16
|
* semantic JSON description (stdout or -o/--out); `--png [file]` also rasterizes a PNG. Rendering
|
|
17
|
-
* runs locally via the optional `lecodes-
|
|
17
|
+
* runs locally via the optional `lecodes-headless` package (canvas-ui + the layout WASM, and the
|
|
18
18
|
* GPU-free creator-2d WASM for 2D scenes); PNG also needs @napi-rs/canvas. UI and 2D (creator-2d)
|
|
19
19
|
* scenes are both rendered — the 2D scene composites UNDER the UI. 3D (Filament) isn't rasterized,
|
|
20
20
|
* but the scene graph is recorded and serialized into the JSON output as `scene3d` (hierarchy,
|
|
@@ -30,12 +30,12 @@ import { loadPeer } from "../hosts/peerInstall"
|
|
|
30
30
|
*
|
|
31
31
|
* `--script <file>` switches to an interactive run: the file lists steps (tap / type / scroll /
|
|
32
32
|
* wait / expect / screenshot / json) executed against a live session — see the scenario schema in
|
|
33
|
-
* lecodes-
|
|
33
|
+
* lecodes-headless (headless/scenario.ts). Step outputs land under --out-dir (default: the CWD);
|
|
34
34
|
* the run stops at the first failed step and dumps the failing frame next to them.
|
|
35
35
|
*
|
|
36
36
|
* `--desktop` renders through the NATIVE desktop host instead of the headless renderer — the real
|
|
37
37
|
* tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG only
|
|
38
|
-
* (the host has no semantic-JSON serializer), no lecodes-
|
|
38
|
+
* (the host has no semantic-JSON serializer), no lecodes-headless / @napi-rs/canvas needed. The
|
|
39
39
|
* host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now.
|
|
40
40
|
*
|
|
41
41
|
* Works in both server-backed projects (login) and local `lecodes init` projects (no login) — see
|
|
@@ -65,6 +65,7 @@ export const viewportFlags = {
|
|
|
65
65
|
/** `--desktop`: the NATIVE host in place of the headless renderer. */
|
|
66
66
|
export const desktopRenderFlags = {
|
|
67
67
|
desktop: bool("render through the NATIVE desktop host instead: real Filament 3D + tgfx UI + 2D, composited. PNG only (--png). Downloads the LeCodes Desktop renderer on first use into ~/.lecodes/renderer (Windows/Linux; LECODES_DESKTOP_EXE overrides)"),
|
|
68
|
+
sim: bool("run a --script scenario through the WINDOWLESS native host (lecodes-server, beside the desktop host; LECODES_SERVER_EXE overrides): the real engine — Jolt, animators, navmesh — with no GPU and no pixels, so it runs on a CI box or a container and in parallel with an open desktop window. expect.log / expect.paint only; input is scene input (key/look/stick/drag/tap by { x, y } straight to the scene — no UI, so no names, hover or type); no screenshots, no --png"),
|
|
68
69
|
frames: num("(--desktop) frames to run before the shot — headroom for async loads", { default: 60 }),
|
|
69
70
|
"fixed-dt": num("(--desktop) virtual app clock, ms per frame — deterministic game time (scripted runs default to 16; plain renders to wall clock)", { value: "<ms>" }),
|
|
70
71
|
renderer: str("(--desktop) host + shader variant: gl | vulkan (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
|
|
@@ -100,8 +101,8 @@ export const resolveSafeAreaChoice = (explicit: string | undefined, device: stri
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
/** The optional renderer package — installed into ~/.lecodes/peers on first use (peerInstall.ts). */
|
|
103
|
-
export const requireRenderer = async (): Promise<typeof import("lecodes-
|
|
104
|
-
loadPeer<typeof import("lecodes-
|
|
104
|
+
export const requireRenderer = async (): Promise<typeof import("lecodes-headless/headless")> =>
|
|
105
|
+
loadPeer<typeof import("lecodes-headless/headless")>("lecodes-headless", "headless", { for: "lecodes render" })
|
|
105
106
|
|
|
106
107
|
/** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
|
|
107
108
|
export const fileScenarioIO = (dir: string) => ({
|
|
@@ -130,7 +131,7 @@ type ScenarioFile = {
|
|
|
130
131
|
export const resolveFixtures = (
|
|
131
132
|
value: Record<string, unknown> | string | undefined,
|
|
132
133
|
baseDir: string,
|
|
133
|
-
): import("lecodes-
|
|
134
|
+
): import("lecodes-headless/headless").Fixtures | undefined => {
|
|
134
135
|
if (value === undefined) return undefined
|
|
135
136
|
let map: unknown = value
|
|
136
137
|
if (typeof value === "string") {
|
|
@@ -144,7 +145,7 @@ export const resolveFixtures = (
|
|
|
144
145
|
if (typeof map !== "object" || map === null || Array.isArray(map)) {
|
|
145
146
|
throw new CliError(`Fixtures must be an object mapping URL patterns to { status?, json?, text? }.`)
|
|
146
147
|
}
|
|
147
|
-
return map as import("lecodes-
|
|
148
|
+
return map as import("lecodes-headless/headless").Fixtures
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
const readScenarioFile = (path: string): ScenarioFile => {
|
|
@@ -188,7 +189,7 @@ const renderFlags = {
|
|
|
188
189
|
map: str("draw the 3D scene's annotated top-down map (grid + XZ footprints + named markers) as <file>.png + <file>.json world-space data (default map.png; also a { \"map\": \"f.png\" } script step)", { value: "[file]", optional: true }),
|
|
189
190
|
"label-all": bool("(--map) label every object, not only the named markers"),
|
|
190
191
|
"max-px": num("(--map) longest edge of the map image, px"),
|
|
191
|
-
script: str("drive the app through a step list (tap/type/scroll/wait/expect/screenshot/json steps; games: frames/drag/hold/key/keyDown/keyUp, expect.log, screenshot clip). With --desktop
|
|
192
|
+
script: str("drive the app through a step list (tap/type/fill/scroll/hover/wait/expect/screenshot/json steps; games: frames/drag/hold/key/keyDown/keyUp, expect.log, screenshot clip). With --desktop the same steps, targets (a name or { x, y }) and expects run on the native host — only expect.audio and the json output are headless-only; per-step screenshots + final --png", { value: "<file>" }),
|
|
192
193
|
"out-dir": str("where --script step outputs land", { value: "<dir>", default: "." }),
|
|
193
194
|
clip: str("(--png) crop to one node's rect or a logical-px rect — a component / region render", { value: "<name|x,y,w,h>" }),
|
|
194
195
|
fixtures: str("canned fetch responses (URL pattern → { status?, json?, text? }) — make backend-gated screens reachable headless; merged over the scenario file's own", { value: "<file>" }),
|
|
@@ -211,7 +212,7 @@ export default defineCommand({
|
|
|
211
212
|
name: "render",
|
|
212
213
|
summary: "Render the local project headless (JSON / PNG), or through the desktop host",
|
|
213
214
|
usage: "[file]",
|
|
214
|
-
description: "Needs the optional lecodes-
|
|
215
|
+
description: "Needs the optional lecodes-headless package (installed on first use). [file] renders ONE screen module instead of the app: a file that default-exports a UIScreen (or a (state) => UIScreen) — every `lecodes design` screen is written that way. A bare id (\"login\") resolves in design/screens/, and a design screen renders at its meta.json frame.\n\nasset() resources load from the working tree by default; --remote-assets / --public-url use the server's copies (then data needs --wait-network).",
|
|
215
216
|
flags: renderFlags,
|
|
216
217
|
examples: [
|
|
217
218
|
"lecodes render -o ui.json",
|
|
@@ -220,7 +221,7 @@ export default defineCommand({
|
|
|
220
221
|
"lecodes render --script tests/hero.flow.json --out-dir tests/.shots --logs",
|
|
221
222
|
"lecodes render --desktop --png --view iso",
|
|
222
223
|
],
|
|
223
|
-
run: ({ args, flags }) => flags.desktop ? renderDesktop(flags, args[0]) : renderHeadless(flags, args[0]),
|
|
224
|
+
run: ({ args, flags }) => flags.desktop || flags.sim ? renderDesktop(flags, args[0]) : renderHeadless(flags, args[0]),
|
|
224
225
|
})
|
|
225
226
|
|
|
226
227
|
// ---- --desktop -------------------------------------------------------------------------------
|
|
@@ -259,6 +260,10 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
|
|
|
259
260
|
// runs the same bundle, so a design screen renders through the real tgfx UI here.
|
|
260
261
|
const screen = screenTargetFrom(root, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
|
|
261
262
|
const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
|
|
263
|
+
// --sim: the windowless host — a scripted run judged by its log; nothing to render without a script.
|
|
264
|
+
const sim = flags.sim
|
|
265
|
+
if (sim && !scenario) throw new CliError("--sim runs a --script scenario (the windowless host paints nothing to render) — add --script <file>, or use --desktop for a PNG.")
|
|
266
|
+
if (sim && flags.png) throw new CliError("--sim produces no PNG (the windowless host paints nothing) — drop --png, or use --desktop.")
|
|
262
267
|
const width = flags.width || scenario?.width || screen?.frame?.[0] || win?.width || 390
|
|
263
268
|
const height = flags.height || scenario?.height || screen?.frame?.[1] || win?.height || 844
|
|
264
269
|
const frames = flags.frames || 60
|
|
@@ -285,18 +290,19 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
|
|
|
285
290
|
extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
|
|
286
291
|
})
|
|
287
292
|
// The host writes PNGs but never creates folders — make every output dir up front.
|
|
288
|
-
mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
|
|
293
|
+
if (!sim) mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
|
|
289
294
|
|
|
290
295
|
if (scenario) {
|
|
291
296
|
// Scripted native run: translate the scenario into the host's command file, let the host drive
|
|
292
297
|
// it (it self-terminates), then read its markers back into per-step results.
|
|
293
298
|
const outDir = flags["out-dir"]
|
|
294
|
-
const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames })
|
|
299
|
+
const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames, sim })
|
|
295
300
|
for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
|
|
296
301
|
const timeoutMs = flags.timeout || (60000 + plan.steps.length * 5000)
|
|
297
|
-
|
|
302
|
+
const label = sim ? "sim" : "native"
|
|
303
|
+
note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (${label})…`)
|
|
298
304
|
const { output } = await runDesktopRender(js, resources, {
|
|
299
|
-
renderer: desktopRenderer, pluginDirs,
|
|
305
|
+
renderer: desktopRenderer, host: sim ? "server" : "desktop", pluginDirs,
|
|
300
306
|
width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
|
|
301
307
|
})
|
|
302
308
|
const result = evaluateDesktopRun(plan, output)
|
|
@@ -310,8 +316,8 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
|
|
|
310
316
|
const skippedCount = result.steps.reduce((n, s) => n + s.skipped.length, 0)
|
|
311
317
|
if (skippedCount > 0) logErr(c.dim(` ${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped — the native host has no UI tree; run the same scenario headless to verify them.`))
|
|
312
318
|
if (!result.ended) warnErr("The host stopped before the script finished (frame cap / crash) — later steps are reported as incomplete.")
|
|
313
|
-
logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (
|
|
314
|
-
note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
|
|
319
|
+
logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (${label})`) : c.red(`✗ ${result.steps.filter((s) => !s.ok).length} of ${result.steps.length} steps failed (${label})`))
|
|
320
|
+
if (!sim) note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
|
|
315
321
|
process.exit(result.ok ? 0 : 1)
|
|
316
322
|
}
|
|
317
323
|
|
package/src/commands/scene.ts
CHANGED
|
@@ -124,13 +124,18 @@ export default defineCommand({
|
|
|
124
124
|
run: async ({ args, flags }) => {
|
|
125
125
|
const ctx = getContext(args[0])
|
|
126
126
|
const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes scene" })
|
|
127
|
+
// The harness (the editor's half inside the scene's world) ships with the editor: the bundle is
|
|
128
|
+
// compiled with the one of the package the UI is served from.
|
|
129
|
+
if (typeof sceneServer.sceneHarness !== "function") {
|
|
130
|
+
throw new CliError("The installed lecodes-3d-editor is older than this CLI: it carries no scene harness (0.9.0 has one). Run `lecodes update`.")
|
|
131
|
+
}
|
|
127
132
|
if (countScenes(ctx.root) === 0) warnErr(`No .scene.ts file under ${ctx.root} yet — add one and the editor will pick it up.`)
|
|
128
133
|
|
|
129
134
|
const server = await sceneServer.startSceneServer({
|
|
130
135
|
root: ctx.root,
|
|
131
136
|
port: flags.port,
|
|
132
137
|
log: note,
|
|
133
|
-
compile: (scenePath: string) => compileSceneEditor({ root: ctx.root, manifest: ctx.manifest, name: ctx.name, scenePath }),
|
|
138
|
+
compile: (scenePath: string) => compileSceneEditor({ root: ctx.root, manifest: ctx.manifest, name: ctx.name, scenePath, harness: sceneServer.sceneHarness() }),
|
|
134
139
|
// live material patches load a shader's compiled artifact (matc'd for the Rendered mode,
|
|
135
140
|
// the lite JSON otherwise) from the local shader cache
|
|
136
141
|
shaderArtifact: (shaderText, platform) => localShaderArtifact(ctx.root, shaderText, platform),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { basename } from "node:path"
|
|
2
|
+
import { CliError, defineCommand, note, num, str } from "../cli"
|
|
3
|
+
import { runDesktopApp } from "../hosts/desktopRenderer"
|
|
4
|
+
import { desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
|
|
5
|
+
import { buildBundle } from "./compile"
|
|
6
|
+
import { lanCandidates } from "./dev"
|
|
7
|
+
import { bundleFlags, bundleOptions, localProject } from "./shared"
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `lecodes serve` — run the current project as a dedicated multiplayer server (docs/multiplayer-plan.md):
|
|
11
|
+
* the WINDOWLESS native host (lecodes-server, beside the desktop host) with `--server`, so the app's
|
|
12
|
+
* `Net.launch.role` is "server" and its `Net.listen()` opens the port. No window, no GPU: the same
|
|
13
|
+
* engine as the desktop (Jolt, animators, navmesh) on Filament's NOOP backend, paced at 60 Hz on
|
|
14
|
+
* the wall clock. The process is the server — Ctrl+C tears the endpoint down (every client gets
|
|
15
|
+
* `disconnect`) and exits. `--connect` runs the same host as a headless CLIENT instead: a bot, or a
|
|
16
|
+
* scripted test player for a server under test.
|
|
17
|
+
*/
|
|
18
|
+
export default defineCommand({
|
|
19
|
+
name: "serve",
|
|
20
|
+
summary: "Run the current project as a dedicated multiplayer server (windowless host: no window, no GPU)",
|
|
21
|
+
description: "Compiles the project like `desktop run` and starts it in the windowless host with --server, so the app's Net.launch.role is \"server\" and its Net.listen() opens the port for clients (`desktop run --connect <ip:port>` on the same LAN, or a published game). Prints the LAN addresses clients can dial. The host runs the real engine without a GPU, so it also runs on a CI box or a container.\n\n--connect turns the same host into a headless client (a bot / a scripted test player) instead of a server.",
|
|
22
|
+
flags: {
|
|
23
|
+
port: num("listen port", { default: 40000 }),
|
|
24
|
+
"max-clients": num("client slots", { default: 16 }),
|
|
25
|
+
connect: str("join a server as a headless client instead of serving", { value: "<ip:port>" }),
|
|
26
|
+
...bundleFlags,
|
|
27
|
+
},
|
|
28
|
+
examples: ["lecodes serve", "lecodes serve --port 7777 --max-clients 32", "lecodes serve --connect 192.168.1.10:40000"],
|
|
29
|
+
run: async ({ flags }) => {
|
|
30
|
+
const { root } = localProject()
|
|
31
|
+
const appCfg = readAppConfigAt(root)
|
|
32
|
+
if (flags.port < 1 || flags.port > 65535) throw new CliError(`--port must be 1..65535 (got ${flags.port}).`)
|
|
33
|
+
if (flags["max-clients"] < 1 || flags["max-clients"] > 64) throw new CliError(`--max-clients must be 1..64 (yojimbo's cap; got ${flags["max-clients"]}).`)
|
|
34
|
+
// The renderer decides which .filamat gets staged; the NOOP backend accepts either, so the
|
|
35
|
+
// project's usual choice is fine — and it keeps the server bundle byte-identical to the desktop's.
|
|
36
|
+
const renderer = resolveDesktopRenderer(root, undefined)
|
|
37
|
+
const { js, resources } = await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer: renderer })
|
|
38
|
+
const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
|
|
39
|
+
if (pluginFiles.length > 0) note(`Native plugins: ${pluginFiles.map((f) => basename(f)).join(", ")}`)
|
|
40
|
+
const client = flags.connect !== undefined
|
|
41
|
+
if (client) {
|
|
42
|
+
note(`Joining ${flags.connect} as a headless client… (Ctrl+C to leave)`)
|
|
43
|
+
} else {
|
|
44
|
+
const lan = lanCandidates()
|
|
45
|
+
note(`Serving on port ${flags.port} (${flags["max-clients"]} slots)… (Ctrl+C to stop)`)
|
|
46
|
+
if (lan.length > 0) note(`Clients dial: ${lan.map((l) => `${l.address}:${flags.port}`).join(" ")} (127.0.0.1:${flags.port} on this machine)`)
|
|
47
|
+
}
|
|
48
|
+
await runDesktopApp(js, resources, {
|
|
49
|
+
host: "server",
|
|
50
|
+
renderer,
|
|
51
|
+
pluginDirs,
|
|
52
|
+
storageScope: appCfg?.name,
|
|
53
|
+
net: client
|
|
54
|
+
? { role: "client", address: flags.connect }
|
|
55
|
+
: { role: "server", port: flags.port, maxClients: flags["max-clients"] },
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
})
|
|
@@ -19,7 +19,7 @@ import { CliError } from "../../cli"
|
|
|
19
19
|
export const engineMaterialsDir = (): string | null => {
|
|
20
20
|
const candidates = [
|
|
21
21
|
join(distRoot, "runtime", "materials"),
|
|
22
|
-
join(distRoot, "..", "
|
|
22
|
+
join(distRoot, "..", "..", "engines", "gl", "materials", "src"),
|
|
23
23
|
]
|
|
24
24
|
for (const c of candidates) if (existsSync(join(c, "unlit.mat"))) return c
|
|
25
25
|
return null
|