lecodes-cli 0.1.7 → 0.3.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 +7 -1
- package/dist/index.js +67 -101
- package/package.json +4 -5
- package/runtime/sdk/canvas/Canvas.ts +184 -0
- package/runtime/sdk/canvas/Label.ts +57 -0
- package/runtime/sdk/core/Aspect.ts +207 -0
- package/runtime/sdk/g2/Camera2D.ts +3 -43
- package/runtime/sdk/g2/CharacterController2D.ts +75 -0
- package/runtime/sdk/g2/Node2D.ts +117 -27
- package/runtime/sdk/g2/Physics2D.ts +126 -0
- package/runtime/sdk/g2/Scene2D.ts +47 -1
- package/runtime/sdk/g2/Shape.ts +63 -0
- package/runtime/sdk/g2/Sprite.ts +13 -93
- package/runtime/sdk/g2/SpriteAnimation.ts +90 -0
- package/runtime/sdk/g2/Texture2D.ts +8 -0
- package/runtime/sdk/g2/Trigger.ts +47 -0
- package/runtime/sdk/g2/loop.ts +20 -3
- package/runtime/sdk/g2/touch.ts +83 -0
- package/runtime/sdk/gl/Collider.ts +29 -0
- package/runtime/sdk/gl/Material.ts +10 -3
- package/runtime/sdk/gl/Mesh.ts +2 -25
- package/runtime/sdk/gl/Model.ts +40 -0
- package/runtime/sdk/gl/ModelAnimation.ts +64 -0
- package/runtime/sdk/gl/Node.ts +13 -86
- package/runtime/sdk/gl/{Body.ts → Physics.ts} +18 -24
- package/runtime/sdk/gl/Texture.ts +11 -0
- package/runtime/sdk/inject.ts +17 -0
- package/runtime/sdk/runtime/touch.ts +6 -0
- package/runtime/sdk/ui/UIImage.ts +26 -11
- package/runtime/sdk/ui/UIScreen.ts +7 -2
package/README.md
CHANGED
|
@@ -13,6 +13,12 @@ npm i -g lecodes-cli # then run: lecodes <command>
|
|
|
13
13
|
npx lecodes-cli <command>
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
The `render` command additionally needs the optional peer **[`lecodes-renderer`](https://www.npmjs.com/package/lecodes-renderer)** (`>= 0.2.0`, for UI + 2D rendering), plus `@napi-rs/canvas` for PNG output:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm i -D lecodes-renderer @napi-rs/canvas
|
|
20
|
+
```
|
|
21
|
+
|
|
16
22
|
## Quick start
|
|
17
23
|
|
|
18
24
|
```sh
|
|
@@ -35,7 +41,7 @@ lecodes preview > app.js # compile the local project to JS
|
|
|
35
41
|
| `pull` | Overwrite local files with the project's current remote state. `--force` to discard local edits. |
|
|
36
42
|
| `push` | Push all local changes as **one** new checkpoint. `-m`/`--message` for the message, `--force` to override the "server moved on" guard. |
|
|
37
43
|
| `preview` | Compile the local project to a single JS bundle, printed to **stdout** (pipeable). `-o`/`--out <file>` to write a file, `--entry <path>` to override the entrypoint, `--no-header` to omit the metadata header, `--public-url <url>` for the asset URL prefix. Diagnostics go to stderr. |
|
|
38
|
-
| `render` | Render the local **UI**
|
|
44
|
+
| `render` | Render the local project (**UI + 2D**). By default emits a semantic JSON description (great for AI/automated checks) to stdout or `-o`/`--out <file>`; `--png [file]` rasterizes a PNG (default `screenshot.png`, `--scale <n>` for DPR). A creator-2d scene is composited **under** the UI; `--time <ms>` advances its physics/animation. `asset('./x.png')` resources (images, sprite textures) load from the local working tree by default — use `--remote-assets` (or an explicit `--public-url`) to fetch them from the server instead (which then needs `--wait-network`). `--width`/`--height` set the viewport (default 390×844). Requires the optional `lecodes-renderer` package (`npm i -D lecodes-renderer@^0.2.0`); PNG also needs `@napi-rs/canvas`. 3D scenes aren't rendered. |
|
|
39
45
|
|
|
40
46
|
## How it works
|
|
41
47
|
|
package/dist/index.js
CHANGED
|
@@ -892,7 +892,7 @@ var collectEntries = (root, manifest, project) => {
|
|
|
892
892
|
const src = serverByPath.get(file.path)?.fileSrc;
|
|
893
893
|
if (!src)
|
|
894
894
|
warnings.push(`No server URL for ${file.path} — push it so its URL resolves.`);
|
|
895
|
-
entries.push({ path: file.path, type, fileSrc: src });
|
|
895
|
+
entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath });
|
|
896
896
|
} else if (type === "shader") {
|
|
897
897
|
const shaders = serverByPath.get(file.path)?.shaders;
|
|
898
898
|
if (!shaders?.length)
|
|
@@ -907,13 +907,13 @@ var collectEntries = (root, manifest, project) => {
|
|
|
907
907
|
|
|
908
908
|
// src/compile/projectCompile.ts
|
|
909
909
|
import { existsSync as existsSync6 } from "node:fs";
|
|
910
|
-
import { createRequire as
|
|
910
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
911
911
|
import { dirname as dirname5, join as join9 } from "node:path";
|
|
912
912
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
913
913
|
// ../sdk/src/compile/bundler.ts
|
|
914
|
-
import * as esbuild from "esbuild";
|
|
915
914
|
import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "node:fs";
|
|
916
915
|
import { spawnSync } from "node:child_process";
|
|
916
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
917
917
|
import { dirname as dirname4, join as join8, relative as relative2 } from "node:path";
|
|
918
918
|
import { fileURLToPath } from "node:url";
|
|
919
919
|
|
|
@@ -942,6 +942,36 @@ var desugarAssetMacros = (src, filePath) => {
|
|
|
942
942
|
` + out;
|
|
943
943
|
};
|
|
944
944
|
|
|
945
|
+
// ../sdk/src/compile/aspectMacro.ts
|
|
946
|
+
var ASPECT_RE = /\bclass\s+\w+\s+extends\s+Aspect\s*<\s*(["'])((?:[^"'\\]|\\.)*)\1[^{};]*>\s*\{/g;
|
|
947
|
+
var hasAspectExtends = (src) => /\bextends\s+Aspect\s*</.test(src);
|
|
948
|
+
var injectAspectNames = (src) => {
|
|
949
|
+
let out = "";
|
|
950
|
+
let last = 0;
|
|
951
|
+
ASPECT_RE.lastIndex = 0;
|
|
952
|
+
let m;
|
|
953
|
+
while ((m = ASPECT_RE.exec(src)) !== null) {
|
|
954
|
+
const braceEnd = m.index + m[0].length;
|
|
955
|
+
if (!classBodyDeclaresAspect(src, braceEnd)) {
|
|
956
|
+
out += src.slice(last, braceEnd) + ` static aspect = ${JSON.stringify(m[2])};`;
|
|
957
|
+
last = braceEnd;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return last === 0 ? src : out + src.slice(last);
|
|
961
|
+
};
|
|
962
|
+
var classBodyDeclaresAspect = (src, from) => {
|
|
963
|
+
let depth = 1;
|
|
964
|
+
let i = from;
|
|
965
|
+
for (;i < src.length && depth > 0; i++) {
|
|
966
|
+
const c2 = src[i];
|
|
967
|
+
if (c2 === "{")
|
|
968
|
+
depth++;
|
|
969
|
+
else if (c2 === "}")
|
|
970
|
+
depth--;
|
|
971
|
+
}
|
|
972
|
+
return /\bstatic\b[^;{}()=]*\baspect\b/.test(src.slice(from, i));
|
|
973
|
+
};
|
|
974
|
+
|
|
945
975
|
// ../sdk/src/compile/bundler.ts
|
|
946
976
|
var resolveInject = () => {
|
|
947
977
|
const override = process.env.LECODES_SDK_INJECT;
|
|
@@ -951,10 +981,7 @@ var resolveInject = () => {
|
|
|
951
981
|
const src = join8(here, "..", "inject.ts");
|
|
952
982
|
if (existsSync5(src))
|
|
953
983
|
return src;
|
|
954
|
-
|
|
955
|
-
if (existsSync5(dist))
|
|
956
|
-
return dist;
|
|
957
|
-
throw new Error("sdk: could not locate the inject entry (LECODES_SDK_INJECT / src/inject.ts / dist/inject.js)");
|
|
984
|
+
throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)");
|
|
958
985
|
};
|
|
959
986
|
var normalize = (path) => {
|
|
960
987
|
const parts = [];
|
|
@@ -968,79 +995,12 @@ var normalize = (path) => {
|
|
|
968
995
|
}
|
|
969
996
|
return "/" + parts.join("/");
|
|
970
997
|
};
|
|
971
|
-
var dirOf = (path) => {
|
|
972
|
-
const i = path.lastIndexOf("/");
|
|
973
|
-
return i <= 0 ? "/" : path.slice(0, i);
|
|
974
|
-
};
|
|
975
|
-
var hasExtension = (path) => /\.[a-zA-Z0-9]+$/.test(path);
|
|
976
|
-
var resolveBase = (importer, specifier) => {
|
|
977
|
-
if (specifier.startsWith("/"))
|
|
978
|
-
return normalize(specifier);
|
|
979
|
-
if (specifier.startsWith("./") || specifier.startsWith("../"))
|
|
980
|
-
return normalize(dirOf(importer) + "/" + specifier);
|
|
981
|
-
throw new Error(`Bare imports are not allowed — the whole SDK is available as globals (no imports): ${specifier}`);
|
|
982
|
-
};
|
|
983
|
-
var resolveWithExtensions = (files, importer, specifier) => {
|
|
984
|
-
const base = resolveBase(importer, specifier);
|
|
985
|
-
if (hasExtension(base)) {
|
|
986
|
-
if (files.has(base))
|
|
987
|
-
return base;
|
|
988
|
-
throw new Error(`Module not found: ${base}`);
|
|
989
|
-
}
|
|
990
|
-
for (const candidate of [base, `${base}.ts`, `${base}.js`]) {
|
|
991
|
-
if (files.has(candidate))
|
|
992
|
-
return candidate;
|
|
993
|
-
}
|
|
994
|
-
throw new Error(`Module not found: ${specifier} from ${importer}`);
|
|
995
|
-
};
|
|
996
|
-
var getLoader = (path) => path.endsWith(".ts") ? "ts" : "js";
|
|
997
|
-
var bundleProjectEsbuild = async (entrypoint, files, format = "esm") => {
|
|
998
|
-
const fileMap = new Map(Object.entries(files).map(([k, v]) => [normalize(k), v]));
|
|
999
|
-
const entry = normalize(entrypoint);
|
|
1000
|
-
if (!fileMap.has(entry))
|
|
1001
|
-
throw new Error(`Entrypoint not found: ${entry}`);
|
|
1002
|
-
const plugin = {
|
|
1003
|
-
name: "memfs",
|
|
1004
|
-
setup(build2) {
|
|
1005
|
-
build2.onResolve({ filter: /^entry$/ }, () => ({ path: entry, namespace: "mem" }));
|
|
1006
|
-
build2.onResolve({ filter: /.*/, namespace: "mem" }, (args) => {
|
|
1007
|
-
try {
|
|
1008
|
-
return { path: resolveWithExtensions(fileMap, args.importer || entry, args.path), namespace: "mem" };
|
|
1009
|
-
} catch (error) {
|
|
1010
|
-
return { errors: [{ text: error instanceof Error ? error.message : String(error) }] };
|
|
1011
|
-
}
|
|
1012
|
-
});
|
|
1013
|
-
build2.onLoad({ filter: /.*/, namespace: "mem" }, (args) => {
|
|
1014
|
-
const contents = fileMap.get(args.path);
|
|
1015
|
-
if (contents == null)
|
|
1016
|
-
return { errors: [{ text: `Module not found: ${args.path}` }] };
|
|
1017
|
-
const finalContents = /\.[tj]sx?$/.test(args.path) && hasAssetMacro(contents) ? desugarAssetMacros(contents, args.path) : contents;
|
|
1018
|
-
return { contents: finalContents, loader: getLoader(args.path), resolveDir: dirOf(args.path) };
|
|
1019
|
-
});
|
|
1020
|
-
}
|
|
1021
|
-
};
|
|
1022
|
-
const result = await esbuild.build({
|
|
1023
|
-
stdin: { contents: `import "entry";`, loader: "js", sourcefile: "bootstrap.js" },
|
|
1024
|
-
inject: [resolveInject()],
|
|
1025
|
-
define: { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" },
|
|
1026
|
-
bundle: true,
|
|
1027
|
-
write: false,
|
|
1028
|
-
format,
|
|
1029
|
-
platform: "browser",
|
|
1030
|
-
plugins: [plugin],
|
|
1031
|
-
charset: "utf8"
|
|
1032
|
-
});
|
|
1033
|
-
const output = result.outputFiles?.[0];
|
|
1034
|
-
if (!output)
|
|
1035
|
-
throw new Error("Compilation produced no output");
|
|
1036
|
-
return output.text;
|
|
1037
|
-
};
|
|
1038
998
|
var CHISEL_DEFINE = { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" };
|
|
1039
999
|
var CHISEL_KEEP = ["_*"];
|
|
1040
1000
|
var resolveSdkSrc = () => {
|
|
1041
1001
|
const inject = resolveInject();
|
|
1042
1002
|
if (!inject.endsWith("inject.ts")) {
|
|
1043
|
-
throw new Error(`chisel needs the SDK source tree (src/inject.ts); got
|
|
1003
|
+
throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`);
|
|
1044
1004
|
}
|
|
1045
1005
|
return dirname4(inject);
|
|
1046
1006
|
};
|
|
@@ -1057,7 +1017,7 @@ var collectSdkFiles = (sdkSrc) => {
|
|
|
1057
1017
|
if (statSync(p).isDirectory())
|
|
1058
1018
|
walk(p);
|
|
1059
1019
|
else if (isSdkSource(p))
|
|
1060
|
-
out["/__sdk/" + relative2(sdkSrc, p)] = readFileSync7(p, "utf8");
|
|
1020
|
+
out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync7(p, "utf8");
|
|
1061
1021
|
}
|
|
1062
1022
|
};
|
|
1063
1023
|
walk(sdkSrc);
|
|
@@ -1071,20 +1031,26 @@ var resolveChiselBin = () => {
|
|
|
1071
1031
|
return override;
|
|
1072
1032
|
throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`);
|
|
1073
1033
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
throw new Error("chisel binary not found. Build it (`cargo build --release` in the chisel repo) or set LECODES_CHISEL_BIN.");
|
|
1034
|
+
try {
|
|
1035
|
+
const bin = createRequire2(import.meta.url)("@letary/chisel").binaryPath();
|
|
1036
|
+
if (bin && existsSync5(bin))
|
|
1037
|
+
return bin;
|
|
1038
|
+
} catch {}
|
|
1039
|
+
throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
|
|
1081
1040
|
};
|
|
1082
1041
|
var bundleProjectChisel = async (entrypoint, files, format, sourcemap) => {
|
|
1083
1042
|
const entry = normalize(entrypoint);
|
|
1084
1043
|
const projFiles = {};
|
|
1085
1044
|
for (const [k, v] of Object.entries(files)) {
|
|
1086
1045
|
const path = normalize(k);
|
|
1087
|
-
|
|
1046
|
+
let src = v;
|
|
1047
|
+
if (/\.[tj]sx?$/.test(path)) {
|
|
1048
|
+
if (hasAssetMacro(src))
|
|
1049
|
+
src = desugarAssetMacros(src, path);
|
|
1050
|
+
if (hasAspectExtends(src))
|
|
1051
|
+
src = injectAspectNames(src);
|
|
1052
|
+
}
|
|
1053
|
+
projFiles[path] = src;
|
|
1088
1054
|
}
|
|
1089
1055
|
if (!projFiles[entry])
|
|
1090
1056
|
throw new Error(`Entrypoint not found: ${entry}`);
|
|
@@ -1118,12 +1084,7 @@ var bundleProjectChisel = async (entrypoint, files, format, sourcemap) => {
|
|
|
1118
1084
|
throw new Error("chisel produced no code");
|
|
1119
1085
|
return { code: parsed.code, map: parsed.map ?? null };
|
|
1120
1086
|
};
|
|
1121
|
-
var bundleProjectWithMap = async (entrypoint, files, format = "esm") =>
|
|
1122
|
-
if (process.env.LECODES_BUNDLER === "chisel") {
|
|
1123
|
-
return bundleProjectChisel(entrypoint, files, format, true);
|
|
1124
|
-
}
|
|
1125
|
-
return { code: await bundleProjectEsbuild(entrypoint, files, format), map: null };
|
|
1126
|
-
};
|
|
1087
|
+
var bundleProjectWithMap = async (entrypoint, files, format = "esm") => bundleProjectChisel(entrypoint, files, format, true);
|
|
1127
1088
|
|
|
1128
1089
|
// ../sdk/src/compile/header.ts
|
|
1129
1090
|
var buildHeader = (name, code, opts = {}) => {
|
|
@@ -1234,18 +1195,14 @@ var resolveChiselBin2 = () => {
|
|
|
1234
1195
|
if (process.env.LECODES_CHISEL_BIN)
|
|
1235
1196
|
return process.env.LECODES_CHISEL_BIN;
|
|
1236
1197
|
try {
|
|
1237
|
-
return
|
|
1198
|
+
return createRequire3(import.meta.url)("@letary/chisel").binaryPath() ?? null;
|
|
1238
1199
|
} catch {
|
|
1239
1200
|
return null;
|
|
1240
1201
|
}
|
|
1241
1202
|
};
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
process.env.LECODES_CHISEL_BIN = chiselBin;
|
|
1246
|
-
process.env.LECODES_BUNDLER = "chisel";
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1203
|
+
var chiselBin = resolveChiselBin2();
|
|
1204
|
+
if (chiselBin)
|
|
1205
|
+
process.env.LECODES_CHISEL_BIN = chiselBin;
|
|
1249
1206
|
var compileProject2 = (opts) => compileProject({
|
|
1250
1207
|
entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, shaders: e.shaders })),
|
|
1251
1208
|
name: opts.name,
|
|
@@ -1293,6 +1250,7 @@ var preview = async (args) => {
|
|
|
1293
1250
|
// src/commands/render.ts
|
|
1294
1251
|
import { writeFileSync as writeFileSync8 } from "node:fs";
|
|
1295
1252
|
import { resolve as resolve4 } from "node:path";
|
|
1253
|
+
var LOCAL_ASSET_SCHEME = "lecodesfile://";
|
|
1296
1254
|
var DEVICE_SAFE_AREA = {
|
|
1297
1255
|
none: [0, 0, 0, 0],
|
|
1298
1256
|
iphone: [47, 0, 34, 0],
|
|
@@ -1329,12 +1287,16 @@ var render = async (args) => {
|
|
|
1329
1287
|
const token = requireToken(config);
|
|
1330
1288
|
note("Resolving project assets…");
|
|
1331
1289
|
const project = await getProject(apiUrl, token, manifest.uuid);
|
|
1332
|
-
const { entries, warnings } = collectEntries(root, manifest, project);
|
|
1290
|
+
const { entries, warnings: collectWarnings } = collectEntries(root, manifest, project);
|
|
1291
|
+
const publicUrlFlag = flagStr(args, "public-url");
|
|
1292
|
+
const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
|
|
1293
|
+
const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
|
|
1294
|
+
const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
|
|
1333
1295
|
note("Compiling…");
|
|
1334
1296
|
const js = await compileProject2({
|
|
1335
|
-
entries,
|
|
1297
|
+
entries: compileEntries,
|
|
1336
1298
|
name: manifest.name,
|
|
1337
|
-
publicUrl:
|
|
1299
|
+
publicUrl: useLocalAssets ? LOCAL_ASSET_SCHEME : publicUrlFlag ?? apiUrl,
|
|
1338
1300
|
entryOverride: flagStr(args, "entry"),
|
|
1339
1301
|
header: false,
|
|
1340
1302
|
format: "iife"
|
|
@@ -1342,6 +1304,9 @@ var render = async (args) => {
|
|
|
1342
1304
|
if (js.includes("_creator.createScene") || js.includes("_creator.createEntity")) {
|
|
1343
1305
|
warnings.push("This looks like a 3D/GL project — only the UI layer is rendered.");
|
|
1344
1306
|
}
|
|
1307
|
+
if (js.includes("_creator2d") && !useLocalAssets && !flagBool(args, "wait-network")) {
|
|
1308
|
+
warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).");
|
|
1309
|
+
}
|
|
1345
1310
|
let renderer;
|
|
1346
1311
|
try {
|
|
1347
1312
|
renderer = await import("lecodes-renderer/headless");
|
|
@@ -1356,6 +1321,7 @@ var render = async (args) => {
|
|
|
1356
1321
|
const timing = {
|
|
1357
1322
|
settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
|
|
1358
1323
|
waitNetwork: flagBool(args, "wait-network"),
|
|
1324
|
+
localAssets: useLocalAssets,
|
|
1359
1325
|
timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
|
|
1360
1326
|
afterAnimations: !flagBool(args, "no-after-animations"),
|
|
1361
1327
|
safeArea: resolveSafeArea(args),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Clone, edit and push LeCodes projects from your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,17 +21,16 @@
|
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"vendor-runtime": "bun scripts/vendor-runtime.ts",
|
|
24
|
-
"build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external
|
|
24
|
+
"build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --banner=\"#!/usr/bin/env node\"",
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
26
|
"dev": "bun run src/index.ts",
|
|
27
27
|
"prepublishOnly": "bun run build"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@letary/chisel": "^0.1.2"
|
|
31
|
-
"esbuild": "^0.27.3"
|
|
30
|
+
"@letary/chisel": "^0.1.2"
|
|
32
31
|
},
|
|
33
32
|
"peerDependencies": {
|
|
34
|
-
"lecodes-renderer": "^0.
|
|
33
|
+
"lecodes-renderer": "^0.3.0"
|
|
35
34
|
},
|
|
36
35
|
"peerDependenciesMeta": {
|
|
37
36
|
"lecodes-renderer": {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// A core 2D drawing surface that bakes to a texture — see docs/canvas-contract.md. It is NOT part of
|
|
2
|
+
// creator-2d/creator-gl: the texture it produces is consumed by 2D sprites, 3D materials and UI
|
|
3
|
+
// images alike ("a canvas is just a texture"), so world/label text and arbitrary 2D graphics are
|
|
4
|
+
// drawn once, per platform, on that platform's native 2D stack (web OffscreenCanvas, iOS CoreGraphics,
|
|
5
|
+
// Android Canvas).
|
|
6
|
+
//
|
|
7
|
+
// The API looks immediate but RECORDS commands into a compact buffer and flushes them to the backend
|
|
8
|
+
// ONCE per bake (one bridge crossing, not one per lineTo). Colors and fonts are CSS strings, resolved
|
|
9
|
+
// by the platform. Do all drawing AFTER `await registerFont(...)` — an unloaded font mis-measures.
|
|
10
|
+
//
|
|
11
|
+
// State (fillStyle, font, textAlign, …) is get/set like the browser; drawing calls are methods (they
|
|
12
|
+
// return `this`, so drawing still chains). `pixelRatio` is the device-pixel multiplier (browser-style
|
|
13
|
+
// `scale(sx, sy)` is a transform).
|
|
14
|
+
//
|
|
15
|
+
// const c = new Canvas(160, 40, { pixelRatio: 2 })
|
|
16
|
+
// c.font = 'bold 24px Inter'
|
|
17
|
+
// const w = c.measureText('Alice').width + 24
|
|
18
|
+
// c.resize(w, 40)
|
|
19
|
+
// c.fillStyle = '#000a'; c.roundRect(0, 0, w, 40, 12).fill()
|
|
20
|
+
// c.fillStyle = '#fff'; c.textAlign = 'center'; c.fillText('Alice', w / 2, 27)
|
|
21
|
+
// new Sprite({ texture: c }) // Canvas is a valid texture source everywhere
|
|
22
|
+
|
|
23
|
+
import { Texture2D } from "../g2/Texture2D"
|
|
24
|
+
|
|
25
|
+
// Opcodes — MUST stay in sync with docs/canvas-contract.md.
|
|
26
|
+
const OP = {
|
|
27
|
+
SAVE: 0, RESTORE: 1, TRANSLATE: 2, SCALE: 3, ROTATE: 4, GLOBAL_ALPHA: 5,
|
|
28
|
+
FILL_STYLE: 6, STROKE_STYLE: 7, LINE_WIDTH: 8, LINE_JOIN: 9, LINE_CAP: 10,
|
|
29
|
+
FONT: 11, TEXT_ALIGN: 12, TEXT_BASELINE: 13,
|
|
30
|
+
BEGIN_PATH: 14, CLOSE_PATH: 15, MOVE_TO: 16, LINE_TO: 17, QUADRATIC_TO: 18, BEZIER_TO: 19,
|
|
31
|
+
ARC: 20, RECT: 21, ROUND_RECT: 22, FILL: 23, STROKE: 24,
|
|
32
|
+
FILL_RECT: 25, STROKE_RECT: 26, CLEAR_RECT: 27, FILL_TEXT: 28, STROKE_TEXT: 29,
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
|
|
36
|
+
const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
|
|
37
|
+
const JOIN = { miter: 0, round: 1, bevel: 2 } as const
|
|
38
|
+
const CAP = { butt: 0, round: 1, square: 2 } as const
|
|
39
|
+
|
|
40
|
+
export type TextAlign = keyof typeof ALIGN
|
|
41
|
+
export type TextBaseline = keyof typeof BASELINE
|
|
42
|
+
export type LineJoin = keyof typeof JOIN
|
|
43
|
+
export type LineCap = keyof typeof CAP
|
|
44
|
+
export interface CanvasTextMetrics { width: number, ascent: number, descent: number }
|
|
45
|
+
|
|
46
|
+
export class Canvas {
|
|
47
|
+
/** Brand so `Sprite`/`UIImage`/`Material` can accept a Canvas anywhere a texture source is expected. */
|
|
48
|
+
readonly __canvas = true as const
|
|
49
|
+
|
|
50
|
+
width: number
|
|
51
|
+
height: number
|
|
52
|
+
/** Device-pixel multiplier (draw at 2 for crisp retina text). Applied by the backend. */
|
|
53
|
+
pixelRatio: number
|
|
54
|
+
|
|
55
|
+
private _cmd: number[] = []
|
|
56
|
+
private _refs: string[] = []
|
|
57
|
+
// Shadowed drawing state so the get/set accessors read back the current value (like the browser).
|
|
58
|
+
private _globalAlpha = 1
|
|
59
|
+
private _fillStyle = "#000000"
|
|
60
|
+
private _strokeStyle = "#000000"
|
|
61
|
+
private _lineWidth = 1
|
|
62
|
+
private _lineJoin: LineJoin = "miter"
|
|
63
|
+
private _lineCap: LineCap = "butt"
|
|
64
|
+
private _font = "10px sans-serif"
|
|
65
|
+
private _textAlign: TextAlign = "start"
|
|
66
|
+
private _textBaseline: TextBaseline = "alphabetic"
|
|
67
|
+
private _surfaceId = 0
|
|
68
|
+
private _tex2d?: Texture2D // cached 2D texture, re-uploaded on update()
|
|
69
|
+
/** @internal — cached 3D (creator-gl) texture id, set by gl/Texture.fromCanvas; re-uploaded on
|
|
70
|
+
* update(). Kept as a bare id (not a Texture import) so a 2D-only canvas never pulls the 3D engine. */
|
|
71
|
+
_tex3dId = 0
|
|
72
|
+
|
|
73
|
+
constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
|
|
74
|
+
this.width = width
|
|
75
|
+
this.height = height
|
|
76
|
+
this.pixelRatio = opts.pixelRatio ?? 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private _ref(s: string): number {
|
|
80
|
+
const i = this._refs.indexOf(s)
|
|
81
|
+
if (i >= 0) return i
|
|
82
|
+
this._refs.push(s)
|
|
83
|
+
return this._refs.length - 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Discard the recorded drawing to start a new one (for dynamic canvases). */
|
|
87
|
+
reset(): this { this._cmd.length = 0; this._refs.length = 0; return this }
|
|
88
|
+
/** Change the surface size; takes effect on the next bake. */
|
|
89
|
+
resize(width: number, height: number): this { this.width = width; this.height = height; return this }
|
|
90
|
+
|
|
91
|
+
// --- transforms & state stack (actions; chainable) ---
|
|
92
|
+
save(): this { this._cmd.push(OP.SAVE); return this }
|
|
93
|
+
restore(): this { this._cmd.push(OP.RESTORE); return this }
|
|
94
|
+
translate(x: number, y: number): this { this._cmd.push(OP.TRANSLATE, x, y); return this }
|
|
95
|
+
scale(sx: number, sy: number): this { this._cmd.push(OP.SCALE, sx, sy); return this }
|
|
96
|
+
rotate(rad: number): this { this._cmd.push(OP.ROTATE, rad); return this }
|
|
97
|
+
|
|
98
|
+
// --- drawing state (get/set like the browser; colors/fonts are CSS strings resolved by the platform).
|
|
99
|
+
// The setter records the state op AND shadows the value so the getter reads it back.
|
|
100
|
+
get globalAlpha(): number { return this._globalAlpha }
|
|
101
|
+
set globalAlpha(a: number) { this._globalAlpha = a; this._cmd.push(OP.GLOBAL_ALPHA, a) }
|
|
102
|
+
get fillStyle(): string { return this._fillStyle }
|
|
103
|
+
set fillStyle(color: string) { this._fillStyle = color; this._cmd.push(OP.FILL_STYLE, this._ref(color)) }
|
|
104
|
+
get strokeStyle(): string { return this._strokeStyle }
|
|
105
|
+
set strokeStyle(color: string) { this._strokeStyle = color; this._cmd.push(OP.STROKE_STYLE, this._ref(color)) }
|
|
106
|
+
get lineWidth(): number { return this._lineWidth }
|
|
107
|
+
set lineWidth(w: number) { this._lineWidth = w; this._cmd.push(OP.LINE_WIDTH, w) }
|
|
108
|
+
get lineJoin(): LineJoin { return this._lineJoin }
|
|
109
|
+
set lineJoin(j: LineJoin) { this._lineJoin = j; this._cmd.push(OP.LINE_JOIN, JOIN[j]) }
|
|
110
|
+
get lineCap(): LineCap { return this._lineCap }
|
|
111
|
+
set lineCap(c: LineCap) { this._lineCap = c; this._cmd.push(OP.LINE_CAP, CAP[c]) }
|
|
112
|
+
get font(): string { return this._font }
|
|
113
|
+
set font(f: string) { this._font = f; this._cmd.push(OP.FONT, this._ref(f)) }
|
|
114
|
+
get textAlign(): TextAlign { return this._textAlign }
|
|
115
|
+
set textAlign(a: TextAlign) { this._textAlign = a; this._cmd.push(OP.TEXT_ALIGN, ALIGN[a]) }
|
|
116
|
+
get textBaseline(): TextBaseline { return this._textBaseline }
|
|
117
|
+
set textBaseline(b: TextBaseline) { this._textBaseline = b; this._cmd.push(OP.TEXT_BASELINE, BASELINE[b]) }
|
|
118
|
+
|
|
119
|
+
// --- paths ---
|
|
120
|
+
beginPath(): this { this._cmd.push(OP.BEGIN_PATH); return this }
|
|
121
|
+
closePath(): this { this._cmd.push(OP.CLOSE_PATH); return this }
|
|
122
|
+
moveTo(x: number, y: number): this { this._cmd.push(OP.MOVE_TO, x, y); return this }
|
|
123
|
+
lineTo(x: number, y: number): this { this._cmd.push(OP.LINE_TO, x, y); return this }
|
|
124
|
+
quadraticCurveTo(cx: number, cy: number, x: number, y: number): this { this._cmd.push(OP.QUADRATIC_TO, cx, cy, x, y); return this }
|
|
125
|
+
bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
|
|
126
|
+
this._cmd.push(OP.BEZIER_TO, c1x, c1y, c2x, c2y, x, y); return this
|
|
127
|
+
}
|
|
128
|
+
arc(x: number, y: number, r: number, a0: number, a1: number, ccw = false): this {
|
|
129
|
+
this._cmd.push(OP.ARC, x, y, r, a0, a1, ccw ? 1 : 0); return this
|
|
130
|
+
}
|
|
131
|
+
rect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.RECT, x, y, w, h); return this }
|
|
132
|
+
roundRect(x: number, y: number, w: number, h: number, r: number): this { this._cmd.push(OP.ROUND_RECT, x, y, w, h, r); return this }
|
|
133
|
+
fill(evenOdd = false): this { this._cmd.push(OP.FILL, evenOdd ? 1 : 0); return this }
|
|
134
|
+
stroke(): this { this._cmd.push(OP.STROKE); return this }
|
|
135
|
+
|
|
136
|
+
// --- shape/text sugar ---
|
|
137
|
+
fillRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.FILL_RECT, x, y, w, h); return this }
|
|
138
|
+
strokeRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.STROKE_RECT, x, y, w, h); return this }
|
|
139
|
+
clearRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.CLEAR_RECT, x, y, w, h); return this }
|
|
140
|
+
fillText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.FILL_TEXT, this._ref(text), x, y, maxWidth); return this }
|
|
141
|
+
strokeText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.STROKE_TEXT, this._ref(text), x, y, maxWidth); return this }
|
|
142
|
+
|
|
143
|
+
/** Measure `text` in the current font on THIS platform. The one synchronous round-trip. */
|
|
144
|
+
measureText(text: string): CanvasTextMetrics {
|
|
145
|
+
const o = _creatorCanvas.measureText(text, this._font)
|
|
146
|
+
return { width: o[0], ascent: o[1], descent: o[2] }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @internal — rasterize the recorded commands into the surface; returns the surface id. */
|
|
150
|
+
_bakeSurface(): number {
|
|
151
|
+
const buf = new Float32Array(this._cmd)
|
|
152
|
+
this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
|
|
153
|
+
return this._surfaceId
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Bake and return the 2D texture (created once, then cached). */
|
|
157
|
+
texture(): Texture2D {
|
|
158
|
+
if (!this._tex2d) this._tex2d = Texture2D.fromCanvas(this)
|
|
159
|
+
return this._tex2d
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Re-rasterize and re-upload to every texture this canvas produced (call after redrawing) — the
|
|
163
|
+
* cached 2D and/or 3D texture, whichever engines this canvas was baked into. */
|
|
164
|
+
update(): this {
|
|
165
|
+
const surfaceId = this._bakeSurface()
|
|
166
|
+
if (this._tex2d) _creator2d.updateTextureFromCanvas(this._tex2d.id, surfaceId)
|
|
167
|
+
if (this._tex3dId) _creator.updateTextureFromCanvas(this._tex3dId, surfaceId)
|
|
168
|
+
return this
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
destroy(): void {
|
|
172
|
+
if (this._surfaceId) _creatorCanvas.destroySurface(this._surfaceId)
|
|
173
|
+
this._tex2d?.destroy()
|
|
174
|
+
// The 3D texture (if any) is owned by creator-gl's engine and freed at its teardown — no per-texture
|
|
175
|
+
// destroy in the 3D API — so we just drop our cached id here.
|
|
176
|
+
this._surfaceId = 0
|
|
177
|
+
this._tex2d = undefined
|
|
178
|
+
this._tex3dId = 0
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Duck-type guard so consumers can accept a Canvas anywhere a texture source is expected. */
|
|
183
|
+
export const isCanvas = (v: unknown): v is Canvas =>
|
|
184
|
+
typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Convenience recipe (userland-style TS, not a native primitive): draw a text pill into a Canvas and
|
|
2
|
+
// hand back a Sprite you can drop over an NPC / actor. The same Canvas texture also works as a 3D
|
|
3
|
+
// billboard or a UIImage — Label just wraps the common 2D case.
|
|
4
|
+
//
|
|
5
|
+
// const tag = Label('Alice')
|
|
6
|
+
// tag.position = npc.worldPosition // float it above the NPC (offset in y as needed)
|
|
7
|
+
//
|
|
8
|
+
// Fonts must be registered first: `await registerFont('Inter', url)` before Label(...).
|
|
9
|
+
|
|
10
|
+
import { Canvas } from "./Canvas"
|
|
11
|
+
import { Sprite } from "../g2/Sprite"
|
|
12
|
+
|
|
13
|
+
export interface LabelOptions {
|
|
14
|
+
/** CSS font shorthand. Default 'bold 24px sans-serif'. */
|
|
15
|
+
font?: string
|
|
16
|
+
/** Text color. Default '#fff'. */
|
|
17
|
+
color?: string
|
|
18
|
+
/** Pill background; null for no pill (bare text). Default '#000a'. */
|
|
19
|
+
background?: string | null
|
|
20
|
+
/** Horizontal / vertical padding around the text (logical px). Default 12 / 6. */
|
|
21
|
+
padX?: number
|
|
22
|
+
padY?: number
|
|
23
|
+
/** Corner radius of the pill. Default 10. */
|
|
24
|
+
radius?: number
|
|
25
|
+
/** Device-pixel multiplier for crisp text. Default 2. */
|
|
26
|
+
pixelRatio?: number
|
|
27
|
+
/** Sprite draw layer. */
|
|
28
|
+
layer?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Label(text: string, opts: LabelOptions = {}): Sprite {
|
|
32
|
+
const font = opts.font ?? "bold 24px sans-serif"
|
|
33
|
+
const color = opts.color ?? "#fff"
|
|
34
|
+
const bg = opts.background === undefined ? "#000a" : opts.background
|
|
35
|
+
const padX = opts.padX ?? 12
|
|
36
|
+
const padY = opts.padY ?? 6
|
|
37
|
+
const radius = opts.radius ?? 10
|
|
38
|
+
const pixelRatio = opts.pixelRatio ?? 2
|
|
39
|
+
|
|
40
|
+
// measure first (native metrics) to size the pill to the text
|
|
41
|
+
const c = new Canvas(1, 1, { pixelRatio })
|
|
42
|
+
c.font = font
|
|
43
|
+
const m = c.measureText(text)
|
|
44
|
+
const lineH = m.ascent + m.descent
|
|
45
|
+
const w = Math.ceil(m.width + padX * 2)
|
|
46
|
+
const h = Math.ceil(lineH + padY * 2)
|
|
47
|
+
c.reset().resize(w, h)
|
|
48
|
+
c.font = font // reset() cleared the buffer — re-record the font for the actual draw
|
|
49
|
+
|
|
50
|
+
if (bg !== null) { c.fillStyle = bg; c.roundRect(0, 0, w, h, radius).fill() }
|
|
51
|
+
c.fillStyle = color
|
|
52
|
+
c.textAlign = "center"
|
|
53
|
+
c.textBaseline = "middle"
|
|
54
|
+
c.fillText(text, w / 2, h / 2)
|
|
55
|
+
|
|
56
|
+
return new Sprite({ texture: c, anchor: [0.5, 1], layer: opts.layer })
|
|
57
|
+
}
|