dotframe 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6
4
+
5
+ From the Craft Ones native port.
6
+
7
+ - Native: mouse buttons read 0 while the window lacks input focus, so clicks in other apps no longer reach the game. The pointer position stays global, so aiming is unchanged.
8
+ - Templates: `src/setup.ts` reads the pointer and touches with a hold-to-charge, release-to-act pattern (a charge meter while holding, a tone on release), on web and native. Each `game.ts` declares the `POINTER_BIT` a release sets.
9
+ - Skills: the macos guide gains "Porting existing TypeScript" (what scriptc rejects and the fix for each, plus runtime traps such as structural coercion copying and out-of-range reads) and "Debugging a native crash" with lldb. Netplay warns against checksums built on `JSON.stringify`.
10
+
11
+ ## 0.1.5
12
+
13
+ - Native builds follow the entry's import graph instead of copying the game folder: relative imports that leave the game root and workspace packages that resolve to source are staged with their relative layout and rewritten to relative paths, so ports inside monorepos compile.
14
+
3
15
  ## 0.1.4
4
16
 
5
17
  Fixes from rounds 2 and 3 of the Craft Ones dogfood.
package/cli/native.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, relative, resolve } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { CliError, type Ctx, exec, home, print, type RunResult, which } from "./lib";
5
5
 
6
6
  // The engine this CLI belongs to: a git checkout or node_modules/dotframe.
@@ -44,42 +44,104 @@ export function vendorFix(platform: NativePlatform): string {
44
44
  return `dotframe vendor ${platform}`;
45
45
  }
46
46
 
47
- const SKIP = new Set(["node_modules", ".git", ".dotframe", "dist", "build", ".vercel"]);
48
-
49
- function copyTs(from: string, to: string): number {
50
- let n = 0;
51
- for (const name of readdirSync(from)) {
52
- if (SKIP.has(name)) continue;
53
- const src = join(from, name);
54
- if (statSync(src).isDirectory()) n += copyTs(src, join(to, name));
55
- else if (name.endsWith(".ts") || name.endsWith(".json")) {
56
- mkdirSync(to, { recursive: true });
57
- cpSync(src, join(to, name));
58
- n += 1;
47
+ const SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)(["'])([^"']+)\1/g;
48
+ const EXTENSIONS = ["", ".ts", ".tsx", ".json", "/index.ts", "/index.tsx"];
49
+
50
+ function resolveFile(base: string): string | null {
51
+ const candidates = [base.replace(/\.js$/, ".ts"), base];
52
+ for (const c of candidates) for (const ext of EXTENSIONS) if (existsSync(c + ext) && statSync(c + ext).isFile()) return realpathSync(c + ext);
53
+ return null;
54
+ }
55
+
56
+ // A bare specifier that resolves, through node_modules, to TypeScript source outside node_modules: a workspace
57
+ // package (bun and npm link those). Published packages stay packages.
58
+ function resolveWorkspace(specifier: string, fromDir: string): string | null {
59
+ const parts = specifier.split("/");
60
+ const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
61
+ const sub = parts.slice(name.split("/").length).join("/");
62
+ for (let dir = fromDir; ; dir = dirname(dir)) {
63
+ const pkgDir = join(dir, "node_modules", name);
64
+ if (existsSync(pkgDir)) {
65
+ const real = realpathSync(pkgDir);
66
+ if (real.split(sep).includes("node_modules")) return null;
67
+ if (sub) return resolveFile(join(real, sub));
68
+ const pkg = JSON.parse(readFileSync(join(real, "package.json"), "utf8")) as Record<string, unknown>;
69
+ const exp = pkg.exports as Record<string, unknown> | string | undefined;
70
+ const dot = typeof exp === "string" ? exp : (exp?.["."] as Record<string, string> | string | undefined);
71
+ const entry = typeof dot === "string" ? dot : (dot?.import ?? dot?.default ?? dot?.types ?? (pkg.module as string) ?? (pkg.main as string) ?? "index.ts");
72
+ return resolveFile(join(real, entry));
59
73
  }
74
+ if (dirname(dir) === dir) return null;
60
75
  }
61
- return n;
62
76
  }
63
77
 
64
- // scriptc's static build takes relative imports only, and treats anything under node_modules as package code
65
- // for its dynamic engine. Staging copies the engine and the game side by side and rewrites "dotframe/..."
66
- // imports to relative paths.
67
- function rewriteImports(dir: string, engineRoot: string): void {
68
- for (const name of readdirSync(dir)) {
69
- const path = join(dir, name);
70
- if (statSync(path).isDirectory()) {
71
- rewriteImports(path, engineRoot);
72
- continue;
78
+ interface Graph {
79
+ files: string[];
80
+ // Per file, the specifiers to rewrite and the absolute file each one points at.
81
+ links: Map<string, Map<string, string>>;
82
+ }
83
+
84
+ // Every source file the entry reaches: relative imports (including ones that leave the game root) and workspace
85
+ // packages. "dotframe/..." is the engine, staged separately.
86
+ function importGraph(entry: string): Graph {
87
+ const files: string[] = [];
88
+ const links = new Map<string, Map<string, string>>();
89
+ const queue = [realpathSync(entry)];
90
+ const seen = new Set(queue);
91
+ while (queue.length > 0) {
92
+ const file = queue.shift() as string;
93
+ files.push(file);
94
+ if (!/\.tsx?$/.test(file)) continue;
95
+ const map = new Map<string, string>();
96
+ for (const m of readFileSync(file, "utf8").matchAll(SPECIFIER)) {
97
+ const spec = m[2];
98
+ if (spec.startsWith("dotframe/") || spec.startsWith("node:")) continue;
99
+ const target = spec.startsWith(".") ? resolveFile(resolve(dirname(file), spec)) : resolveWorkspace(spec, dirname(file));
100
+ if (!target) continue;
101
+ if (!spec.startsWith(".")) map.set(spec, target);
102
+ if (!seen.has(target)) {
103
+ seen.add(target);
104
+ queue.push(target);
105
+ }
106
+ }
107
+ links.set(file, map);
108
+ }
109
+ return { files, links };
110
+ }
111
+
112
+ function commonDir(paths: string[]): string {
113
+ let base = dirname(paths[0]);
114
+ for (const p of paths) while (!(p + sep).startsWith(base === sep ? sep : base + sep)) base = dirname(base);
115
+ return base;
116
+ }
117
+
118
+ function rel(fromFile: string, to: string): string {
119
+ const r = relative(dirname(fromFile), to);
120
+ return r.startsWith(".") ? r : `./${r}`;
121
+ }
122
+
123
+ // scriptc's static build takes relative imports only, and treats anything under node_modules as package code for
124
+ // its dynamic engine. Staging copies the engine and every reached game file side by side, keeping their relative
125
+ // layout, and rewrites "dotframe/..." and workspace imports to relative paths.
126
+ function stageGame(root: string, entry: string, tree: string, engineStage: string): string {
127
+ const graph = importGraph(join(root, entry));
128
+ const base = commonDir([realpathSync(root) + sep + "x", ...graph.files]);
129
+ const staged = (file: string): string => join(tree, relative(base, file));
130
+ for (const file of graph.files) {
131
+ const to = staged(file);
132
+ mkdirSync(dirname(to), { recursive: true });
133
+ let text = readFileSync(file, "utf8");
134
+ if (/\.tsx?$/.test(file)) {
135
+ const map = graph.links.get(file) ?? new Map<string, string>();
136
+ text = text.replace(SPECIFIER, (whole: string, q: string, spec: string): string => {
137
+ if (spec.startsWith("dotframe/")) return whole.replace(`${q}${spec}${q}`, `${q}${rel(to, join(engineStage, spec.slice("dotframe/".length)))}${q}`);
138
+ const target = map.get(spec);
139
+ return target ? whole.replace(`${q}${spec}${q}`, `${q}${rel(to, staged(target)).replace(/\.tsx?$/, "")}${q}`) : whole;
140
+ });
73
141
  }
74
- if (!name.endsWith(".ts")) continue;
75
- const text = readFileSync(path, "utf8");
76
- const next = text.replace(/(from\s+|import\s*\(\s*)(["'])dotframe\/([^"']+)\2/g, (_m: string, head: string, q: string, rest: string): string => {
77
- let rel = relative(dirname(path), join(engineRoot, rest));
78
- if (!rel.startsWith(".")) rel = `./${rel}`;
79
- return `${head}${q}${rel}${q}`;
80
- });
81
- if (next !== text) writeFileSync(path, next);
142
+ writeFileSync(to, text);
82
143
  }
144
+ return join(tree, relative(base, realpathSync(root)));
83
145
  }
84
146
 
85
147
  export async function buildNative(ctx: Ctx, root: string, gameName: string, targetName: string, t: NativeTarget): Promise<{ binary: string; log: string; steps: RunResult[] }> {
@@ -95,6 +157,7 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
95
157
  const log = join(logDir, `build-${targetName}.log`);
96
158
  const out = join(root, "dist", targetName);
97
159
  const binary = join(out, `${t.name ?? gameName}${platform === "windows" ? ".exe" : ""}`);
160
+ if (!existsSync(join(root, t.entry))) throw new CliError("ENTRY_MISSING", `native entry ${t.entry} does not exist`, `add ${t.entry} (see the macos skill) or fix targets.${targetName}.native.entry`, skill);
98
161
  if (ctx.dryRun) {
99
162
  print(ctx, { dryRun: true, platform, stage, vendor: vendor.dir, binary }, (): string => `would stage the engine and game in ${stage} and build ${binary}`);
100
163
  return { binary, log, steps: [] };
@@ -104,9 +167,7 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
104
167
  mkdirSync(out, { recursive: true });
105
168
  const engineStage = join(stage, "dotframe");
106
169
  cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
107
- const gameStage = join(stage, "game");
108
- copyTs(root, gameStage);
109
- rewriteImports(gameStage, engineStage);
170
+ const gameStage = stageGame(root, t.entry, join(stage, "game"), engineStage);
110
171
 
111
172
  const inc = [`-I${join(vendor.dir, vendor.sdl ?? "", "include")}`, `-I${join(vendor.dir, "wgpu", platform, "include")}`];
112
173
  const lib = join(stage, "lib");
@@ -120,8 +181,6 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
120
181
  const ffi = JSON.parse(readFileSync(join(ENGINE, "native", `ffi.${platform}.json`), "utf8")) as { libraries: string[] };
121
182
  ffi.libraries = [join(lib, "libdf_native.a"), join(vendor.dir, "build", `sdl-${platform}`, "libSDL3.a"), join(vendor.dir, "wgpu", platform, "lib", "libwgpu_native.a")];
122
183
  writeFileSync(join(stage, "ffi.json"), JSON.stringify(ffi, null, 2));
123
- const entry = join(gameStage, t.entry);
124
- if (!existsSync(entry)) throw new CliError("ENTRY_MISSING", `native entry ${t.entry} does not exist`, `add ${t.entry} (see the macos skill) or fix targets.${targetName}.native.entry`, skill);
125
184
  let env: Record<string, string> = {};
126
185
  if (platform === "windows") {
127
186
  // A devDependency of dotframe, so an npm install of dotframe does not bring it; the game can.
@@ -227,6 +227,8 @@ static void mouse_relative(double *nx, double *ny) {
227
227
  // Field 0 x, 1 y (normalized), 2 buttons (bit 0 left, bit 1 middle, bit 2 right).
228
228
  double df_mouse(int32_t field) {
229
229
  if (field == 2) {
230
+ // Clicks in other apps must not reach the game: buttons count only while the window has input focus.
231
+ if (!(SDL_GetWindowFlags(g_window) & SDL_WINDOW_INPUT_FOCUS)) return 0;
230
232
  SDL_MouseButtonFlags flags = SDL_GetGlobalMouseState(NULL, NULL);
231
233
  return (flags & SDL_BUTTON_LMASK ? 1 : 0) | (flags & SDL_BUTTON_MMASK ? 2 : 0) | (flags & SDL_BUTTON_RMASK ? 4 : 0);
232
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotframe",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "TS-first game engine and agent-first CLI: one codebase for web, Discord, iOS and native binaries",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,7 @@ A target with `"native"` is built by the CLI itself:
20
20
  "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } }
21
21
  ```
22
22
 
23
- The CLI copies the engine (`src/`) and the game's `.ts` and `.json` files into `.dotframe/native/<target>/`, rewrites `dotframe/...` imports to relative paths, compiles the C shim against the vendored SDL3 and wgpu-native, and runs scriptc. This is why it works from `node_modules`: scriptc treats code under `node_modules` as package code for its dynamic engine and takes only relative imports in a static build, so building in place fails.
23
+ The CLI copies the engine (`src/`) and every file the entry reaches into `.dotframe/native/<target>/`: relative imports (including ones that leave the game folder, as in a monorepo port importing `../../packages/shared/src`) and workspace packages that resolve to source (`@my/shared` linked by bun or npm workspaces). It keeps their relative layout, rewrites `dotframe/...` and workspace imports to relative paths, compiles the C shim against the vendored SDL3 and wgpu-native, and runs scriptc. This is why it works from `node_modules`: scriptc treats code under `node_modules` as package code for its dynamic engine and takes only relative imports in a static build, so building in place fails.
24
24
 
25
25
  - Vendor lives in `DOTFRAME_VENDOR`, else a dotframe git checkout's `vendor/` when populated, else `~/.dotframe/vendor`. It survives reinstalling dotframe and is shared by every game.
26
26
  - Output goes to the game's `dist/<target>/`, never into `node_modules`.
@@ -39,6 +39,33 @@ scriptc compiles a subset of TypeScript. What tripped the templates:
39
39
 
40
40
  Run `dotframe build macos` early and after each feature: one scriptc error is a quick fix, ninety are a port.
41
41
 
42
+ ## Porting existing TypeScript
43
+
44
+ What a real port (Craft Ones, 66 errors) hit, and the fix for each:
45
+
46
+ | scriptc rejects | Do instead |
47
+ |---|---|
48
+ | `Object.assign(target, ...)` | Assign fields one by one |
49
+ | `ArrayLike<T>` / `Iterable<T>` parameters | Take `T[]` |
50
+ | `readonly T[]`, `.find`/`.filter` on readonly tuples, `for...of` over tuples | Plain arrays (`T[]`), indexed loops |
51
+ | Destructuring an `unknown` network payload | Narrow field by field with `typeof` checks |
52
+ | Non-literal index into a tuple | Make it an array |
53
+ | Dynamic keyed reads on a record whose entries have different shapes | Give every entry the same shape (optional fields), or a `switch` |
54
+ | `string[i]` | `s.charAt(i)` or `s.charCodeAt(i)` |
55
+ | `Number.parseInt` in a static build | `parseInt` or `Math.trunc(Number(s))` |
56
+ | `++`/`--` inside an expression | Its own statement |
57
+ | `satisfies Record<K, Record<string, T>>` | A plain type annotation |
58
+
59
+ Runtime traps (compile clean, fail when run):
60
+
61
+ - **Structural coercion copies.** Passing a class instance, or a wider record, to a parameter typed as a narrower structural type passes a copy: the callee's writes to scalar fields are lost (arrays inside still alias). In Craft Ones every projectile froze mid-flight. Pass the exact type, or return the updated record and copy it back. Reported to scriptc.
62
+ - **Out-of-range reads.** `rows[y - 1]?.[x]` typed as `string` but `undefined` at runtime crashes with "undefined is not representable in the target union". Bounds-check before reading.
63
+ - **`JSON.stringify` key order** differed from JavaScript in Craft Ones (not reproduced in a minimal case). Never build checksums or netplay comparisons from it.
64
+
65
+ ## Debugging a native crash
66
+
67
+ `--optimization dev` builds failed to link in Craft Ones (undefined `_main`; a template game links fine). When that happens, debug the release binary with lldb: `lldb ./dist/macos/<name>`, `breakpoint set -n scr_error_new`, `run`, then `bt` shows the TypeScript call site of a runtime error.
68
+
42
69
  ## Other notes
43
70
 
44
71
  - Benchmarks mean nothing while other processes load the machine. Check `top` first and say what else was running.
@@ -30,6 +30,8 @@ Run several seeds and a high latency (474 ms is what a transatlantic relay measu
30
30
 
31
31
  ## Rules
32
32
 
33
+ - Build checksums from numbers in a fixed order, never from `JSON.stringify`: native builds can order keys differently from JavaScript, so a web peer and a native peer would report false desyncs.
34
+
33
35
  - The camera, effects, and HUD timers are simulation state if the simulation ever reads them. Update them once per step, never per drawn frame.
34
36
  - Render must restore any random state it uses, or use a separate visual generator.
35
37
  - Input delay (`--delay`, default 2) trades latency for fewer rollbacks.
@@ -1,27 +1,49 @@
1
- // Shared by main.web.ts and main.native.ts: fixed 60 Hz steps from the keyboard, then one render per frame.
1
+ // Shared by main.web.ts and main.native.ts: keyboard and pointer to inputs, fixed 60 Hz steps, one render per frame.
2
2
  import { createDraw2D } from "dotframe/src/draw2d";
3
3
  import type { Frame, Setup } from "dotframe/src/gpu";
4
- import { Key } from "dotframe/src/input";
4
+ import { Key, MouseButton } from "dotframe/src/input";
5
5
  import type { Platform } from "dotframe/src/platform";
6
- import { createGame, keyInput, PLAYERS, render, step, WINDOW } from "./game";
6
+ import { createGame, keyInput, PLAYERS, POINTER_BIT, render, step, WINDOW } from "./game";
7
7
 
8
8
  const STEP = 1 / 60;
9
+ // A full charge takes one second of holding.
10
+ const FULL_CHARGE = 60;
9
11
 
10
12
  export function createSetup(): Setup {
11
- return ({ gpu, input }: Platform): Frame => {
13
+ return ({ gpu, input, audio }: Platform): Frame => {
12
14
  const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
13
15
  const game = createGame(Math.floor(Math.random() * 1e9));
14
16
  let simulated = -1;
17
+ // Hold to charge, release to act: the pattern most mouse and touch games need.
18
+ let charge = 0;
19
+ let wasDown = false;
15
20
  return (time: number): boolean => {
16
21
  if (simulated < 0) simulated = time;
17
- // Fixed steps, so every target plays exactly what dotframe sim simulates.
18
22
  for (let n = 0; simulated + STEP <= time && n < 5; n++) {
23
+ const pointer = input.pointer();
24
+ const inside = pointer.x >= 0 && pointer.x <= 1 && pointer.y >= 0 && pointer.y <= 1;
25
+ const down = inside && ((pointer.buttons & MouseButton.Left) !== 0 || input.touches().length > 0);
26
+ let released = false;
27
+ if (down) charge = Math.min(FULL_CHARGE, charge + 1);
28
+ else if (wasDown) {
29
+ released = true;
30
+ // Audio is presentation, never simulation state: the pitch says how charged the release was.
31
+ audio.tone(220 + (charge / FULL_CHARGE) * 660, 0.12, 0.25);
32
+ charge = 0;
33
+ }
34
+ wasDown = down;
19
35
  const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
36
+ if (released) keys[0] = keys[0] | POINTER_BIT;
20
37
  step(game, keys.slice(0, PLAYERS));
21
38
  simulated += STEP;
22
39
  }
23
40
  draw.begin();
24
41
  render(game, draw);
42
+ if (charge > 0) {
43
+ // Charge meter under the play area.
44
+ draw.setFillStyle("#ffffff");
45
+ draw.fillRect(20, WINDOW.height - 16, (WINDOW.width - 40) * (charge / FULL_CHARGE), 6);
46
+ }
25
47
  draw.end({ r: 0, g: 0, b: 0 });
26
48
  return true;
27
49
  };
@@ -4,6 +4,8 @@ import type { Draw2D } from "dotframe/src/draw2d";
4
4
  import type { Input } from "dotframe/src/input";
5
5
 
6
6
  export const WINDOW = { width: 960, height: 540, title: "__NAME__" };
7
+ // The input bit a released mouse or touch press sets for player 1 (fires the action bit); see src/setup.ts.
8
+ export const POINTER_BIT = 8;
7
9
  export const PLAYERS = 2;
8
10
 
9
11
  export interface Game {
@@ -11,6 +11,8 @@ const RIGHT = 2;
11
11
  const JUMP = 4;
12
12
  const ATTACK = 8;
13
13
  const GROUND = 440;
14
+ // The input bit a released mouse or touch press sets for player 1 (attacks); see src/setup.ts.
15
+ export const POINTER_BIT = ATTACK;
14
16
  const GRAVITY = 0.8;
15
17
  const SPEED = 5;
16
18
  const JUMP_SPEED = 15;
@@ -9,6 +9,8 @@ export const PLAYERS = 1;
9
9
  const LEFT = 1;
10
10
  const RIGHT = 2;
11
11
  const JUMP = 4;
12
+ // The input bit a released mouse or touch press sets for player 1 (jumps); see src/setup.ts.
13
+ export const POINTER_BIT = JUMP;
12
14
  const GRAVITY = 0.7;
13
15
  const SPEED = 4.5;
14
16
  const JUMP_SPEED = 13;