lecodes-cli 0.1.7 → 0.2.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.2.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.2.0"
35
34
  },
36
35
  "peerDependenciesMeta": {
37
36
  "lecodes-renderer": {
@@ -0,0 +1,151 @@
1
+ // The Aspect system: attachable capabilities (animation, collider, physics, and user behaviors)
2
+ // that hang off a node WITHOUT bloating the node class. Composition with the terseness of methods.
3
+ //
4
+ // const hero = new Sprite({ texture })
5
+ // .aspect(SpriteAnimation, { fps: 8, clips: { walk: [1, 2, 3] } }) // attach + configure
6
+ // .aspect(Collider, { box: [16, 8], trigger: true }) // chains: returns the node
7
+ // hero.anim.play('walk') // access by name (the chain result is typed as having it)
8
+ // if (hero.has(Physics)) hero.physics.velocity = [0, 0] // has() is a type guard
9
+ //
10
+ // A user aspect declares its name + target node in the generic and reads `this.node`:
11
+ // class Health extends Aspect<'health', Sprite> { hp = 100; hurt(n: number) { this.hp -= n } }
12
+ // sprite.aspect(Health, { hp: 80 }).health.hurt(10)
13
+ //
14
+ // The accessor name lives in the generic (Aspect<'name', Node>). The `chisel` bundler extracts it at
15
+ // compile time and injects the runtime `static aspect` on user aspects + the virtual type accessor;
16
+ // the SDK's own built-in aspects declare `static readonly aspect` directly (see SpriteAnimation etc.).
17
+
18
+ import { Emitter, type EventMap } from "./events"
19
+ import type { Node } from "../gl/Node"
20
+
21
+ // ---- type helpers -----------------------------------------------------------
22
+ /** The class object of an aspect — accepted even when its constructor is protected/abstract. */
23
+ export type AspectCtor<A extends Aspect<any, any>> = abstract new () => A
24
+ /** The accessor name carried in the aspect's generic. */
25
+ export type KeyOf<A> = A extends Aspect<infer K, any> ? K : never
26
+ /** The node kind an aspect targets. */
27
+ export type TargetOf<A> = A extends Aspect<any, infer P> ? P : never
28
+ /** `{ name: Aspect }` — the field an aspect contributes to its node. */
29
+ export type FieldOf<A extends Aspect<any, any>> = { [P in KeyOf<A>]: A }
30
+
31
+ type UnionToIntersection<U> =
32
+ (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
33
+
34
+ /**
35
+ * A variable typed as a node KNOWN to carry the given aspect(s). Union, not a tuple — reads as
36
+ * English and mirrors the runtime guard `node.has(Health)`:
37
+ * let boss: With<Sprite, Health | Physics>
38
+ */
39
+ export type With<N, A extends Aspect<any, any>> =
40
+ N & UnionToIntersection<A extends any ? FieldOf<A> : never>
41
+
42
+ // ---- per-frame update dispatch ----------------------------------------------
43
+ // Aspects that define update(dt) are ticked every frame from a SINGLE global loop, created LAZILY on
44
+ // the first such aspect (a project with no behavior aspects starts no loop). dt is seconds, like
45
+ // setLoop. No visibility/frustum culling yet — every registered aspect ticks regardless of on-screen
46
+ // state.
47
+ const updaters: Aspect<any, any>[] = []
48
+ let updateLoopId: number | undefined
49
+
50
+ const ensureUpdateLoop = (): void => {
51
+ if (updateLoopId !== undefined) return
52
+ updateLoopId = setLoop((dt) => {
53
+ // iterate a snapshot so an update() that attaches/detaches aspects can't corrupt this frame's pass
54
+ for (const a of updaters.slice()) {
55
+ // later (visibility culling): if (a.updateWhenVisible && nodeOffScreen(a)) continue
56
+ a.update?.(dt)
57
+ }
58
+ })
59
+ }
60
+ const registerUpdater = (a: Aspect<any, any>): void => { updaters.push(a); ensureUpdateLoop() }
61
+ const unregisterUpdater = (a: Aspect<any, any>): void => {
62
+ const i = updaters.indexOf(a)
63
+ if (i >= 0) updaters.splice(i, 1)
64
+ }
65
+
66
+ // ---- the host: mixed into every node kind (Node2D, Node) --------------------
67
+ /**
68
+ * Base for anything that can carry aspects. Extends Emitter, so node kinds get events too. Provides
69
+ * the four verbs; the named accessor (node.physics, node.health, …) is set as an own property at
70
+ * attach time, keyed by the aspect class's runtime `aspect` name.
71
+ */
72
+ export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<E> {
73
+ /** @internal class → instance, the authoritative store (named accessors mirror this). */
74
+ readonly _aspects = new Map<Function, Aspect<any, any>>()
75
+
76
+ /** Attach (and configure) an aspect, or reconfigure it if already present. Returns the node typed
77
+ * as now-having that aspect (so no guard/`?.` is needed afterwards). Rejects a wrong target node. */
78
+ aspect<Self extends TargetOf<A>, A extends Aspect<any, any>>(
79
+ this: Self,
80
+ ctor: AspectCtor<A>,
81
+ opts?: Partial<A>,
82
+ ): Self & FieldOf<A> {
83
+ const host = this as unknown as AspectHost
84
+ let inst = host._aspects.get(ctor) as A | undefined
85
+ const fresh = inst === undefined
86
+ if (!inst) {
87
+ inst = new (ctor as unknown as new () => A)()
88
+ ;(inst as { node: unknown }).node = this
89
+ host._aspects.set(ctor, inst)
90
+ const name = (ctor as { aspect?: string }).aspect
91
+ if (name) (host as unknown as Record<string, unknown>)[name] = inst
92
+ }
93
+ if (opts) Object.assign(inst, opts)
94
+ if (fresh) {
95
+ inst.onAttach?.()
96
+ if (typeof inst.update === "function") registerUpdater(inst)
97
+ }
98
+ return this as Self & FieldOf<A>
99
+ }
100
+
101
+ /** Safe access — undefined if the aspect isn't attached. */
102
+ get<A extends Aspect<any, any>>(ctor: AspectCtor<A>): A | undefined {
103
+ return this._aspects.get(ctor) as A | undefined
104
+ }
105
+
106
+ /** Existence check AND type guard: inside `if (node.has(Physics))`, `node.physics` is present. */
107
+ has<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
108
+ return this._aspects.has(ctor)
109
+ }
110
+
111
+ /** Detach an aspect (runs its onDetach). Named `removeAspect` so it never collides with the
112
+ * child-management `add`/`remove` semantics some node kinds expose. */
113
+ removeAspect<A extends Aspect<any, any>>(ctor: AspectCtor<A>): this {
114
+ const inst = this._aspects.get(ctor)
115
+ if (inst) {
116
+ inst.onDetach?.()
117
+ unregisterUpdater(inst)
118
+ this._aspects.delete(ctor)
119
+ const name = (ctor as { aspect?: string }).aspect
120
+ if (name) delete (this as unknown as Record<string, unknown>)[name]
121
+ }
122
+ return this
123
+ }
124
+ }
125
+
126
+ // ---- the aspect base --------------------------------------------------------
127
+ /**
128
+ * Base for everything you attach. `K` = accessor name (extracted by chisel for the runtime). `P` =
129
+ * the target node kind: `this.node` is typed to it, AND attaching to a wrong node is a compile error.
130
+ * Initialize in `onAttach` (the node is set by then) — not a constructor; aspects are created by the
131
+ * engine via `node.aspect()`, never `new`.
132
+ */
133
+ export abstract class Aspect<K extends string, P = Node> {
134
+ // NOTE: P has no `extends AspectHost` constraint on purpose — Emitter<E>'s generic
135
+ // addEventListener makes a specific-event node (AspectHost<NodeEvents>) not assignable to
136
+ // AspectHost<any>, which would reject every real node. P only types `this.node`; target
137
+ // enforcement happens in aspect() via `this: Self extends TargetOf<A>`. Defaults to Node
138
+ // (3D is the default engine) — a 2D aspect declares its own P, e.g. Aspect<'x', Sprite>.
139
+ /** The node this aspect is attached to (set at attach time). */
140
+ readonly node!: P
141
+ /** @internal phantom — lets the type system recover the accessor name `K`. Never read at runtime. */
142
+ declare protected readonly __key__?: K
143
+ onAttach?(): void
144
+ onDetach?(): void
145
+ /** Called every frame while attached (dt = seconds since the last frame), via a lazy global loop. */
146
+ update?(dt: number): void
147
+ /** Opt-in: only run update(dt) while the node is on-screen. Lives on the aspect (not the node) so
148
+ * aspects on the same node can choose independently. NOOP for now — visibility culling isn't wired
149
+ * yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
150
+ updateWhenVisible = false
151
+ }
@@ -1,16 +1,8 @@
1
- // Orthographic camera for a Scene2D. Position/zoom/rotation live natively; `follow` adds a
2
- // render-synced per-frame update that tracks a node.
1
+ // Orthographic camera for a Scene2D. Position / zoom / rotation live natively. To make the camera
2
+ // track a node, attach a small update(dt) aspect that eases camera.position toward the node — there's
3
+ // no built-in follow() (and so nothing to dispose).
3
4
 
4
5
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
5
- import type { Node2D } from "./Node2D"
6
- import { offUpdate, onUpdate } from "./loop"
7
-
8
- export type FollowOptions = {
9
- /** World-space offset added to the target's position. */
10
- offset?: Vec2Like
11
- /** 0 = snap (default). >0 = exponential smoothing time constant in seconds (smaller = snappier). */
12
- smooth?: number
13
- }
14
6
 
15
7
  export class Camera2D {
16
8
  private _x = 0
@@ -18,10 +10,6 @@ export class Camera2D {
18
10
  private _zoom = 1
19
11
  private _rotation = 0
20
12
 
21
- private _followTarget: Node2D | null = null
22
- private _followOpts: FollowOptions = {}
23
- private _followCb: ((dt: number) => void) | null = null
24
-
25
13
  private readonly sceneId: number
26
14
  constructor(sceneId: number) { this.sceneId = sceneId }
27
15
 
@@ -42,32 +30,4 @@ export class Camera2D {
42
30
  const o = _creator2d.cameraWorldToScreen(this.sceneId, worldX, worldY)
43
31
  return new Vec2(o[0], o[1])
44
32
  }
45
-
46
- /** Track a node each frame (snaps by default; pass `smooth` for eased follow). */
47
- follow(node: Node2D, options: FollowOptions = {}): this {
48
- this._followTarget = node
49
- this._followOpts = options
50
- if (this._followCb) return this
51
- this._followCb = (dt: number) => {
52
- const t = this._followTarget
53
- if (!t) return
54
- const [ ox, oy ] = this._followOpts.offset ?? [ 0, 0 ]
55
- const tx = t.x + ox
56
- const ty = t.y + oy
57
- const s = this._followOpts.smooth ?? 0
58
- if (s > 0) {
59
- const k = 1 - Math.exp(-dt / s)
60
- this.position = [ this._x + (tx - this._x) * k, this._y + (ty - this._y) * k ]
61
- } else {
62
- this.position = [ tx, ty ]
63
- }
64
- }
65
- onUpdate(this._followCb)
66
- return this
67
- }
68
-
69
- unfollow(): void {
70
- this._followTarget = null
71
- if (this._followCb) { offUpdate(this._followCb); this._followCb = null }
72
- }
73
33
  }
@@ -0,0 +1,75 @@
1
+ // A platformer-friendly character controller, as an aspect on a Node2D. Built on a fixed-rotation
2
+ // dynamic Physics2D body: you drive horizontal intent + jump, and Box2D handles gravity, falling, and
3
+ // wall/floor collision response; a short downward ray reports `grounded`.
4
+ //
5
+ // const hero = new Sprite({ texture, anchor: [0.5, 1] })
6
+ // .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true, shape: { type: 'capsule', from:[0,6], to:[0,26], radius:6 } })
7
+ // .aspect(CharacterController2D, { speed: 200, jumpSpeed: 520 })
8
+ // Input.on('left', d => hero.controller.move(d ? -1 : 0))
9
+ // Input.on('jump', () => hero.controller.jump())
10
+ //
11
+ // Velocity-control (set velocity each frame) is the standard, robust approach for platformers and
12
+ // reuses the physics solver. For pixel-tight movement without any solver bounce/seam quirks, a
13
+ // kinematic move-and-slide controller can be added later (see creator-2d PLAN.md, Phase G).
14
+
15
+ import { Aspect } from "../core/Aspect"
16
+ import { Vec2 } from "../math/vec"
17
+ import { Physics2D } from "./Physics2D"
18
+ import { Shape } from "./Shape"
19
+ import type { Node2D } from "./Node2D"
20
+
21
+ export class CharacterController2D extends Aspect<"controller", Node2D> {
22
+ static readonly aspect = "controller"
23
+
24
+ /** Horizontal move speed (world units/s). */
25
+ speed = 200
26
+ /** Jump take-off speed (world units/s). */
27
+ jumpSpeed = 500
28
+ /** Extra probe distance below the body's feet used for the grounded check (world units). */
29
+ groundProbe = 6
30
+ /** Distance from the node origin down to the feet. Defaults to half the sprite height. */
31
+ footOffset?: number
32
+
33
+ private _moveX = 0
34
+ private _jumpQueued = false
35
+ private _grounded = false
36
+
37
+ /** Horizontal intent in [-1, 1] (e.g. from input). Sticky — set 0 to stop. Applied next frame. */
38
+ move(dir: number): void { this._moveX = dir < -1 ? -1 : dir > 1 ? 1 : dir }
39
+ /** Queue a jump; consumed on the next frame if grounded. */
40
+ jump(): void { this._jumpQueued = true }
41
+ /** True while standing on something (updated each frame). */
42
+ get grounded(): boolean { return this._grounded }
43
+
44
+ private get phys(): Physics2D | undefined { return this.node.get(Physics2D) }
45
+
46
+ onAttach(): void {
47
+ // Ensure a Shape (auto box from the sprite) + a dynamic, fixed-rotation body if not already set up.
48
+ if (!this.node.has(Shape)) this.node.aspect(Shape, {})
49
+ if (!this.node.has(Physics2D)) this.node.aspect(Physics2D, { motion: "dynamic", fixedRotation: true })
50
+ }
51
+
52
+ update(_dt: number): void {
53
+ const phys = this.phys
54
+ if (!phys) return
55
+
56
+ // Grounded probe: a short ray straight down from the feet. The ray originates inside the body,
57
+ // so Box2D won't report the body's own shape; we exclude self defensively too.
58
+ const pos = this.node.worldPosition
59
+ const foot = this.footOffset ?? this._spriteHalfHeight()
60
+ const hit = Physics2D.raycast([pos.x, pos.y - foot * 0.5], [pos.x, pos.y - foot - this.groundProbe])
61
+ this._grounded = !!hit && hit.node !== this.node
62
+
63
+ let vy = phys.velocity.y
64
+ if (this._jumpQueued && this._grounded) { vy = this.jumpSpeed; this._grounded = false }
65
+ this._jumpQueued = false
66
+
67
+ // Drive horizontal velocity directly; let the sim own vertical (gravity / jump / landing).
68
+ phys.velocity = new Vec2(this._moveX * this.speed, vy)
69
+ }
70
+
71
+ private _spriteHalfHeight(): number {
72
+ const size = (this.node as { size?: Vec2 }).size
73
+ return size ? size.y * 0.5 : 8
74
+ }
75
+ }