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 +7 -1
- package/dist/index.js +67 -101
- package/package.json +4 -5
- package/runtime/sdk/core/Aspect.ts +151 -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 +29 -1
- package/runtime/sdk/g2/Shape.ts +63 -0
- package/runtime/sdk/g2/Sprite.ts +3 -88
- package/runtime/sdk/g2/SpriteAnimation.ts +90 -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/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/inject.ts +13 -0
- package/runtime/sdk/runtime/touch.ts +6 -0
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.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
|
|
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.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
|
|
2
|
-
//
|
|
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
|
+
}
|