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 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** project. 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). `--width`/`--height` set the viewport (default 390×844). Requires the optional `lecodes-renderer` package (`npm i -D lecodes-renderer`); PNG also needs `@napi-rs/canvas`. 3D scenes aren't rendered. |
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 createRequire2 } from "node:module";
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
- const dist = join8(here, "..", "..", "dist", "inject.js");
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 a prebuilt inject at ${inject}. ` + `Set LECODES_SDK_INJECT to the source, or use esbuild (unset LECODES_BUNDLER).`);
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
- const here = dirname4(fileURLToPath(import.meta.url));
1075
- for (const rel of ["release/chisel", "debug/chisel"]) {
1076
- const p = join8(here, "..", "..", "..", "..", "..", "chisel", "target", rel);
1077
- if (existsSync5(p))
1078
- return p;
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
- projFiles[path] = /\.[tj]sx?$/.test(path) && hasAssetMacro(v) ? desugarAssetMacros(v, path) : v;
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 createRequire2(import.meta.url)("@letary/chisel").binaryPath() ?? null;
1198
+ return createRequire3(import.meta.url)("@letary/chisel").binaryPath() ?? null;
1238
1199
  } catch {
1239
1200
  return null;
1240
1201
  }
1241
1202
  };
1242
- if (!process.env.LECODES_BUNDLER) {
1243
- const chiselBin = resolveChiselBin2();
1244
- if (chiselBin) {
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: flagStr(args, "public-url") ?? apiUrl,
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.1.7",
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 esbuild --external @letary/chisel --external lecodes-renderer --banner=\"#!/usr/bin/env node\"",
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.1.1"
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
+ }