dotframe 0.1.8 → 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.
@@ -0,0 +1,95 @@
1
+ // The relay transport for native builds (macOS, Windows, iOS): the same RelayLink as connectRelay on the web, over
2
+ // the native WebSocket client, polled from the game's frame because library mode (iOS) has no promises or events.
3
+ import type { InputMessage, NetMessage, RelayLink, SumMessage } from "../netplay";
4
+ import { dfWs, dfWsText } from "./ffi";
5
+
6
+ const dfWsOpen = (url: string): number => dfWsText(0, -1, url);
7
+ const dfWsState = (socket: number): number => dfWs(0, socket, 0);
8
+ const dfWsNext = (socket: number): number => dfWs(1, socket, 0);
9
+ const dfWsByte = (socket: number, index: number): number => dfWs(2, socket, index);
10
+ const dfWsPop = (socket: number): number => dfWs(3, socket, 0);
11
+ const dfWsClose = (socket: number): number => dfWs(4, socket, 0);
12
+
13
+ const STATE_OPEN = 1;
14
+ const STATE_CLOSED = 2;
15
+ const STATE_ERROR = 3;
16
+
17
+ // Loops instead of splice: scriptc cannot splice an array of a generic instantiated with unknown, and generic
18
+ // helpers must live at module scope.
19
+ function drain<T>(queue: T[]): T[] {
20
+ const out: T[] = [];
21
+ for (let i = 0; i < queue.length; i++) out.push(queue[i]);
22
+ queue.length = 0;
23
+ return out;
24
+ }
25
+
26
+ export function connectRelayNative<A = unknown>(url: string, room: string): RelayLink<A> {
27
+ const socket = dfWsOpen(`${url}${url.includes("?") ? "&" : "?"}room=${encodeURIComponent(room)}`);
28
+ const decoder = new TextDecoder();
29
+ let status = socket < 0 ? "closed" : "connecting";
30
+ let slot = -1;
31
+ const app: A[] = [];
32
+ const net: NetMessage[] = [];
33
+
34
+ // Drains the native inbox into the game and netplay queues. Called by every read, so polling happens per frame.
35
+ const pump = (): void => {
36
+ if (socket < 0) return;
37
+ const state = dfWsState(socket);
38
+ if (state === STATE_CLOSED || state === STATE_ERROR) status = "closed";
39
+ for (let len = dfWsNext(socket); len >= 0; len = dfWsNext(socket)) {
40
+ const bytes = new Uint8Array(len);
41
+ for (let i = 0; i < len; i++) bytes[i] = dfWsByte(socket, i);
42
+ dfWsPop(socket);
43
+ const text = decoder.decode(bytes);
44
+ // scriptc builds a parsed value in the asserted shape and cannot re-shape it later, so each message is parsed
45
+ // once more as its final type, chosen by its "t".
46
+ const header = JSON.parse(text) as { t?: string; slot?: number; here?: boolean };
47
+ if (header.t === "hello") {
48
+ slot = header.slot ?? -1;
49
+ status = "waiting";
50
+ } else if (header.t === "peer") {
51
+ status = header.here ? "paired" : "waiting";
52
+ if (!header.here) net.length = 0;
53
+ } else if (header.t === "input") net.push(JSON.parse(text) as InputMessage);
54
+ else if (header.t === "sum") net.push(JSON.parse(text) as SumMessage);
55
+ else app.push(JSON.parse(text) as A);
56
+ }
57
+ };
58
+ const send = (message: unknown): void => {
59
+ if (socket >= 0 && dfWsState(socket) === STATE_OPEN) dfWsText(1, socket, JSON.stringify(message));
60
+ };
61
+ return {
62
+ room,
63
+ status: (): string => {
64
+ pump();
65
+ return status;
66
+ },
67
+ slot: (): number => {
68
+ pump();
69
+ return slot;
70
+ },
71
+ send: (message: A): void => send(message),
72
+ receive: (): A[] => {
73
+ pump();
74
+ return drain(app);
75
+ },
76
+ transport: {
77
+ send: (message: NetMessage): void => send(message),
78
+ receive: (): NetMessage[] => {
79
+ pump();
80
+ return drain(net);
81
+ },
82
+ },
83
+ close: (): void => {
84
+ if (socket >= 0) dfWsClose(socket);
85
+ status = "closed";
86
+ },
87
+ };
88
+ }
89
+
90
+ // Shares text, such as a room link: the share sheet on iOS, the clipboard on macOS and Windows.
91
+ // Returns "sheet", "clipboard" or "failed".
92
+ export function shareText(text: string): string {
93
+ const result = dfWsText(2, -1, text);
94
+ return result === 1 ? "sheet" : result === 2 ? "clipboard" : "failed";
95
+ }
package/src/native/run.ts CHANGED
@@ -52,6 +52,7 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
52
52
  createPipeline: render.createPipeline,
53
53
  bind: render.bind,
54
54
  createTexture: render.createTexture,
55
+ destroyTexture: render.destroyTexture,
55
56
  frame: render.frame,
56
57
  aspect: render.aspect,
57
58
  createImage: async (png: Uint8Array, smooth: boolean): Promise<Texture> => createNativeImage(png, smooth),
@@ -0,0 +1,7 @@
1
+ // Web only: exposes a probe's state as globalThis.__dotframe for dotframe play --online. Native builds must not import
2
+ // this (assigning globalThis traps in scriptc); they write the state to a file.
3
+ import type { ProbeState } from "./probe";
4
+
5
+ export function publishProbe(state: ProbeState): void {
6
+ (globalThis as { __dotframe?: ProbeState }).__dotframe = state;
7
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,53 @@
1
+ // The state `dotframe play --online` reads from each browser: frame, confirmed frame, link status, and this peer's
2
+ // checksum at every 30th confirmed frame. Call update() once per fixed step. On the web, publishProbe (probe-web.ts)
3
+ // exposes the state as globalThis.__dotframe; native builds write it to a file instead (writing globalThis traps in
4
+ // scriptc).
5
+ import type { Rollback } from "./netplay";
6
+
7
+ export interface ProbeState {
8
+ frame: number;
9
+ confirmed: number;
10
+ status: string;
11
+ sums: Record<number, number>;
12
+ }
13
+
14
+ export interface Probe {
15
+ state: ProbeState;
16
+ // Online: pass the rollback and the link status. Local: pass null and the frame just simulated.
17
+ update: (rollback: Rollback | null, status: string, localFrame?: number) => void;
18
+ }
19
+
20
+ const EVERY = 30;
21
+
22
+ export function createProbe(): Probe {
23
+ const state: ProbeState = { frame: 0, confirmed: 0, status: "local", sums: {} };
24
+ return {
25
+ state,
26
+ update: (rollback: Rollback | null, status: string, localFrame?: number): void => {
27
+ state.status = status;
28
+ if (!rollback) {
29
+ if (localFrame !== undefined) state.frame = localFrame;
30
+ return;
31
+ }
32
+ state.frame = rollback.stats().frame;
33
+ state.confirmed = rollback.confirmedFrame();
34
+ for (let f = EVERY; f < state.confirmed; f += EVERY) if (state.sums[f] === undefined) state.sums[f] = rollback.sumAt(f) ?? 0;
35
+ },
36
+ };
37
+ }
38
+
39
+ // A scripted masher for tests: a new random input every 6 frames from a seed, like the CLI's --mash.
40
+ export function createMasher(seed: number, random: (next: () => number) => number): () => number {
41
+ let s = seed >>> 0;
42
+ const next = (): number => {
43
+ s = (Math.imul(s, 1103515245) + 12345) >>> 0;
44
+ return s / 4294967296;
45
+ };
46
+ let frame = 0;
47
+ let input = 0;
48
+ return (): number => {
49
+ if (frame % 6 === 0) input = random(next);
50
+ frame += 1;
51
+ return input;
52
+ };
53
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,191 @@
1
+ // Menus that draw and hit-test from one layout, so a button's rectangle exists once. Buttons and arrow rows
2
+ // (prev, value, next), focus for keyboard and gamepad, and taps from mouse or touch in logical coordinates.
3
+ import type { Draw2D } from "./draw2d";
4
+ import type { Input } from "./input";
5
+
6
+ export interface Rect {
7
+ x: number;
8
+ y: number;
9
+ w: number;
10
+ h: number;
11
+ }
12
+
13
+ export interface UiItem {
14
+ id: string;
15
+ kind: "button" | "row";
16
+ label: string;
17
+ // Rows show a value between their arrows.
18
+ value: string;
19
+ disabled: boolean;
20
+ rect: Rect;
21
+ }
22
+
23
+ export interface UiSpec {
24
+ id: string;
25
+ kind: "button" | "row";
26
+ label: string;
27
+ value?: string;
28
+ disabled?: boolean;
29
+ }
30
+
31
+ // "prev" and "next" are a row's arrow zones (the outer fifth on each side); everything else is "body".
32
+ export interface UiHit {
33
+ id: string;
34
+ part: "body" | "prev" | "next";
35
+ }
36
+
37
+ export interface ColumnArea {
38
+ x: number;
39
+ y: number;
40
+ width: number;
41
+ itemHeight: number;
42
+ gap: number;
43
+ }
44
+
45
+ // Lays items out top to bottom. Recompute it whenever labels or values change; it is cheap.
46
+ export function column(specs: UiSpec[], area: ColumnArea): UiItem[] {
47
+ const items: UiItem[] = [];
48
+ for (let i = 0; i < specs.length; i++) {
49
+ const s = specs[i];
50
+ items.push({
51
+ id: s.id,
52
+ kind: s.kind,
53
+ label: s.label,
54
+ value: s.value ?? "",
55
+ disabled: s.disabled === true,
56
+ rect: { x: area.x, y: area.y + i * (area.itemHeight + area.gap), w: area.width, h: area.itemHeight },
57
+ });
58
+ }
59
+ return items;
60
+ }
61
+
62
+ export function hit(items: UiItem[], x: number, y: number): UiHit | null {
63
+ for (let i = 0; i < items.length; i++) {
64
+ const it = items[i];
65
+ const r = it.rect;
66
+ if (it.disabled || x < r.x || x > r.x + r.w || y < r.y || y > r.y + r.h) continue;
67
+ if (it.kind === "row") {
68
+ const zone = r.w / 5;
69
+ if (x < r.x + zone) return { id: it.id, part: "prev" };
70
+ if (x > r.x + r.w - zone) return { id: it.id, part: "next" };
71
+ }
72
+ return { id: it.id, part: "body" };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // Next enabled item in direction delta (+1 down, -1 up), wrapping; the first enabled item when focus is unknown.
78
+ export function moveFocus(items: UiItem[], focus: string, delta: number): string {
79
+ if (items.length === 0) return focus;
80
+ let index = -1;
81
+ for (let i = 0; i < items.length; i++) if (items[i].id === focus) index = i;
82
+ for (let step = 1; step <= items.length; step++) {
83
+ const i = (((index < 0 ? (delta > 0 ? -1 : 0) : index) + step * delta) % items.length + items.length) % items.length;
84
+ if (!items[i].disabled) return items[i].id;
85
+ }
86
+ return focus;
87
+ }
88
+
89
+ export interface UiStyle {
90
+ // A CSS font for draw.setFont; the family must be registered with draw.addFont.
91
+ font: string;
92
+ fill: string;
93
+ focusFill: string;
94
+ text: string;
95
+ disabledText: string;
96
+ }
97
+
98
+ export const DEFAULT_STYLE: UiStyle = { font: "24px sans-serif", fill: "#24213a", focusFill: "#3d3866", text: "#ffffff", disabledText: "#77748f" };
99
+
100
+ export function drawUi(draw: Draw2D, items: UiItem[], focus: string, style: UiStyle = DEFAULT_STYLE): void {
101
+ draw.setFont(style.font);
102
+ draw.setTextBaseline("middle");
103
+ for (let i = 0; i < items.length; i++) {
104
+ const it = items[i];
105
+ const r = it.rect;
106
+ draw.setFillStyle(it.id === focus && !it.disabled ? style.focusFill : style.fill);
107
+ draw.fillRect(r.x, r.y, r.w, r.h);
108
+ draw.setFillStyle(it.disabled ? style.disabledText : style.text);
109
+ const cy = r.y + r.h / 2;
110
+ if (it.kind === "row") {
111
+ draw.setTextAlign("left");
112
+ draw.fillText("<", r.x + 12, cy);
113
+ draw.setTextAlign("right");
114
+ draw.fillText(">", r.x + r.w - 12, cy);
115
+ draw.setTextAlign("center");
116
+ draw.fillText(it.value === "" ? it.label : `${it.label}: ${it.value}`, r.x + r.w / 2, cy);
117
+ } else {
118
+ draw.setTextAlign("center");
119
+ draw.fillText(it.label, r.x + r.w / 2, cy);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Maps the window-normalized pointer ([0, 1] across the window) to game coordinates, for example undoing an
125
+ // aspect letterbox: (nx, ny) => ({ x: nx * logicalWidth - offsetX, y: ny * logicalHeight - offsetY }).
126
+ export type PointerMap = (nx: number, ny: number) => { x: number; y: number };
127
+
128
+ export interface Tap {
129
+ x: number;
130
+ y: number;
131
+ }
132
+
133
+ export interface Tapper {
134
+ // Call once per step. Returns a tap on the step the press is released, at the release point.
135
+ update: (input: Input) => Tap | null;
136
+ // Ignore input until every button and finger is up: call it on a screen change so the press that caused it does
137
+ // not also count on the next screen.
138
+ disarm: () => void;
139
+ }
140
+
141
+ function pressed(input: Input): { down: boolean; nx: number; ny: number } {
142
+ const touches = input.touches();
143
+ if (touches.length > 0) return { down: true, nx: touches[0].x, ny: touches[0].y };
144
+ const p = input.pointer();
145
+ return { down: p.buttons !== 0, nx: p.x, ny: p.y };
146
+ }
147
+
148
+ export function createTap(map: PointerMap): Tapper {
149
+ let armed = true;
150
+ let wasDown = false;
151
+ let last = { x: 0, y: 0 };
152
+ return {
153
+ update: (input: Input): Tap | null => {
154
+ const now = pressed(input);
155
+ if (now.down) last = map(now.nx, now.ny);
156
+ if (!armed) {
157
+ if (!now.down) armed = true;
158
+ wasDown = false;
159
+ return null;
160
+ }
161
+ const tap = wasDown && !now.down ? { x: last.x, y: last.y } : null;
162
+ wasDown = now.down;
163
+ return tap;
164
+ },
165
+ disarm: (): void => {
166
+ armed = false;
167
+ wasDown = false;
168
+ },
169
+ };
170
+ }
171
+
172
+ export interface ReleaseGate {
173
+ // True once nothing has been held since the last disarm().
174
+ ready: (input: Input) => boolean;
175
+ disarm: () => void;
176
+ }
177
+
178
+ // Arm-after-release for gameplay: disarm when a match starts, and read game input only once ready() is true, so the
179
+ // click or tap that pressed START does not keep going into the match (and, say, charge a shot).
180
+ export function createReleaseGate(): ReleaseGate {
181
+ let armed = true;
182
+ return {
183
+ ready: (input: Input): boolean => {
184
+ if (!armed && !pressed(input).down) armed = true;
185
+ return armed;
186
+ },
187
+ disarm: (): void => {
188
+ armed = false;
189
+ },
190
+ };
191
+ }
package/src/web/run.ts CHANGED
@@ -22,7 +22,13 @@ export async function loadBytes(path: string): Promise<Uint8Array> {
22
22
  return new Uint8Array(await response.arrayBuffer());
23
23
  }
24
24
 
25
- export async function run(options: WindowOptions, setup: Setup): Promise<void> {
25
+ export interface RunOptions {
26
+ // "fixed" (default): a canvas of the window options' size. "window": the canvas fills the browser window and
27
+ // follows its size; gpu.aspect() reports the current shape.
28
+ fit?: "fixed" | "window";
29
+ }
30
+
31
+ export async function run(options: WindowOptions, setup: Setup, runOptions: RunOptions = {}): Promise<void> {
26
32
  if (!navigator.gpu) throw new Error("WebGPU is not available in this browser");
27
33
  const adapter = await navigator.gpu.requestAdapter();
28
34
  if (!adapter) throw new Error("No WebGPU adapter");
@@ -31,26 +37,43 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
31
37
  document.title = options.title;
32
38
  const canvas = document.createElement("canvas");
33
39
  const dpr = globalThis.devicePixelRatio ?? 1;
34
- canvas.style.width = `${options.width}px`;
35
- canvas.style.height = `${options.height}px`;
36
- canvas.width = Math.round(options.width * dpr);
37
- canvas.height = Math.round(options.height * dpr);
40
+ const fill = runOptions.fit === "window";
41
+ const cssSize = (): [number, number] => (fill ? [window.innerWidth, window.innerHeight] : [options.width, options.height]);
42
+ const sizeCanvas = (): void => {
43
+ const [w, h] = cssSize();
44
+ canvas.style.width = `${w}px`;
45
+ canvas.style.height = `${h}px`;
46
+ canvas.width = Math.max(1, Math.round(w * dpr));
47
+ canvas.height = Math.max(1, Math.round(h * dpr));
48
+ };
49
+ if (fill) {
50
+ document.body.style.margin = "0";
51
+ document.body.style.overflow = "hidden";
52
+ canvas.style.display = "block";
53
+ }
54
+ sizeCanvas();
38
55
  document.body.appendChild(canvas);
39
56
 
40
57
  const context = canvas.getContext("webgpu");
41
58
  if (!context) throw new Error("Could not create a WebGPU canvas context");
42
59
  const format = navigator.gpu.getPreferredCanvasFormat();
43
60
  context.configure({ device, format, alphaMode: "opaque" });
44
- const depthTexture = device.createTexture({
45
- size: [canvas.width, canvas.height],
46
- format: "depth24plus",
47
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
48
- });
61
+ const createDepth = (): GPUTexture =>
62
+ device.createTexture({ size: [canvas.width, canvas.height], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
63
+ let depthTexture = createDepth();
64
+ if (fill) {
65
+ window.addEventListener("resize", (): void => {
66
+ sizeCanvas();
67
+ depthTexture.destroy();
68
+ depthTexture = createDepth();
69
+ });
70
+ }
49
71
 
50
72
  const pipelines: GPURenderPipeline[] = [];
51
73
  const buffers: GPUBuffer[] = [];
52
74
  const bindGroups: GPUBindGroup[] = [];
53
75
  const textureViews: GPUTextureView[] = [];
76
+ const textures: (GPUTexture | null)[] = [];
54
77
  const textureSmooth: boolean[] = [];
55
78
  // Nearest keeps pixel art crisp; linear suits fonts and photos.
56
79
  const nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
@@ -67,6 +90,7 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
67
90
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
68
91
  });
69
92
  write(texture);
93
+ textures.push(texture);
70
94
  textureViews.push(texture.createView());
71
95
  textureSmooth.push(smooth);
72
96
  return { id: textureViews.length - 1, width, height };
@@ -144,6 +168,10 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
144
168
  bindGroups.push(device.createBindGroup({ layout: pipelines[pipeline].getBindGroupLayout(0), entries }));
145
169
  return bindGroups.length - 1;
146
170
  },
171
+ destroyTexture: (texture: Texture): void => {
172
+ textures[texture.id]?.destroy();
173
+ textures[texture.id] = null;
174
+ },
147
175
  createTexture: (width: number, height: number, rgba: Uint8Array, smooth: boolean): Texture =>
148
176
  uploadTexture(width, height, smooth, (texture) =>
149
177
  device.queue.writeTexture({ texture }, rgba as Uint8Array<ArrayBuffer>, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]),
@@ -10,7 +10,7 @@ let tick: Frame | null = null;
10
10
 
11
11
  export function init(_base: string): void {
12
12
  const platform = openLibraryPlatform(WINDOW);
13
- tick = createSetup({ link: null, mash: null })(platform);
13
+ tick = createSetup({ link: null, mash: null, onProbe: (): void => {} })(platform);
14
14
  }
15
15
 
16
16
  // Returns false to ask the host to quit.
@@ -1,7 +1,32 @@
1
1
  // Native entry for dotframe build macos|windows. The CLI stages it with the engine and compiles it with scriptc.
2
- // Online play is web only for now (native builds have no WebSocket).
2
+ // DOTFRAME_ROOM plays online through a relay (DOTFRAME_RELAY, default the local relay), DOTFRAME_MASH scripts player
3
+ // 1, and DOTFRAME_PROBE is where dotframe play --online --native reads this peer's checksums.
4
+ import { writeFileSync } from "node:fs";
5
+ import { connectRelayNative } from "dotframe/src/native/relay";
6
+ import type { Frame } from "dotframe/src/gpu";
3
7
  import { run } from "dotframe/src/native/run";
8
+ import type { Platform } from "dotframe/src/platform";
9
+ import type { ProbeState } from "dotframe/src/probe";
4
10
  import { WINDOW } from "./src/game";
5
11
  import { createSetup } from "./src/setup";
6
12
 
7
- await run(WINDOW, createSetup({ link: null, mash: null }));
13
+ const room = process.env.DOTFRAME_ROOM ?? "";
14
+ const relay = process.env.DOTFRAME_RELAY ?? "ws://localhost:8787";
15
+ const mash = process.env.DOTFRAME_MASH ?? "";
16
+ const probeFile = process.env.DOTFRAME_PROBE ?? "";
17
+ let written = -1;
18
+
19
+ const setup = createSetup({
20
+ link: room === "" ? null : connectRelayNative(relay, room),
21
+ mash: mash === "" ? null : Number(mash),
22
+ onProbe: (state: ProbeState): void => {
23
+ // Every 30 confirmed frames is enough for the CLI, which compares every 30th checksum.
24
+ if (probeFile === "" || state.confirmed === 0 || state.confirmed - written < 30) return;
25
+ written = state.confirmed;
26
+ writeFileSync(probeFile, JSON.stringify(state));
27
+ },
28
+ });
29
+
30
+ // run() takes (platform: Platform) => Frame. Pass a function of exactly that type: scriptc cannot convert a function
31
+ // value whose parameter type differs (setup takes the narrower SetupPlatform), though it can pass a wider argument.
32
+ await run(WINDOW, (platform: Platform): Frame => setup(platform));
@@ -1,3 +1,4 @@
1
+ import { publishProbe } from "dotframe/src/probe-web";
1
2
  import { connectRelay } from "dotframe/src/relay-client";
2
3
  import { run } from "dotframe/src/web/run";
3
4
  import { WINDOW } from "./src/game";
@@ -10,4 +11,5 @@ const room = params.get("room");
10
11
  const relay = params.get("relay") ?? `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/relay`;
11
12
  const mash = params.get("mash");
12
13
 
13
- await run(WINDOW, createSetup({ link: room ? connectRelay(relay, room) : null, mash: mash === null ? null : Number(mash) }));
14
+ // fit: "window" fills the browser window; the setup refits the canvas when its shape changes.
15
+ await run(WINDOW, createSetup({ link: room ? connectRelay(relay, room) : null, mash: mash === null ? null : Number(mash), onProbe: publishProbe }), { fit: "window" });
@@ -6,6 +6,7 @@ import type { Frame, RenderGpu } from "dotframe/src/gpu";
6
6
  import type { Input } from "dotframe/src/input";
7
7
  import { Key, MouseButton } from "dotframe/src/input";
8
8
  import { createRollback, type RelayLink, type Rollback } from "dotframe/src/netplay";
9
+ import { createMasher, createProbe, type ProbeState } from "dotframe/src/probe";
9
10
  import { checksum, createGame, type Game, keyInput, PLAYERS, POINTER_BIT, randomInput, render, restore, snapshot, step, WINDOW } from "./game";
10
11
 
11
12
  export interface SetupOptions {
@@ -13,15 +14,11 @@ export interface SetupOptions {
13
14
  link: RelayLink | null;
14
15
  // A seed makes player 1 a scripted masher (tests); null reads the keyboard and pointer.
15
16
  mash: number | null;
17
+ // Called after each step with the probe state: the web publishes it as globalThis.__dotframe, native builds write
18
+ // it to a file for dotframe play --native. Required, not optional: scriptc cannot represent an optional function.
19
+ onProbe: (state: ProbeState) => void;
16
20
  }
17
21
 
18
- // What `dotframe play --online` reads from each browser.
19
- interface Probe {
20
- frame: number;
21
- confirmed: number;
22
- status: string;
23
- sums: Record<number, number>;
24
- }
25
22
 
26
23
  const STEP = 1 / 60;
27
24
  // A full charge takes one second of holding.
@@ -38,24 +35,28 @@ export function createSetup(options: SetupOptions): (platform: SetupPlatform) =>
38
35
  return ({ gpu, input, audio }: SetupPlatform): Frame => {
39
36
  // The logical canvas keeps the game's aspect and grows to fill the screen: on a 2.16 phone the game area is
40
37
  // centered with extra width around it instead of being stretched.
41
- const aspect = gpu.aspect();
42
- const logicalWidth = Math.max(WINDOW.width, Math.round(WINDOW.height * aspect));
43
- const logicalHeight = Math.max(WINDOW.height, Math.round(WINDOW.width / aspect));
44
- const offsetX = (logicalWidth - WINDOW.width) / 2;
45
- const offsetY = (logicalHeight - WINDOW.height) / 2;
46
- const draw = createDraw2D(gpu, logicalWidth, logicalHeight);
38
+ // Recomputed when the surface changes shape (a resizable window, run(..., { fit: "window" }) on the web).
39
+ let aspect = 0;
40
+ let offsetX = 0;
41
+ let offsetY = 0;
42
+ const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
43
+ const fit = (): void => {
44
+ const now = gpu.aspect();
45
+ if (now === aspect) return;
46
+ aspect = now;
47
+ const logicalWidth = Math.max(WINDOW.width, Math.round(WINDOW.height * aspect));
48
+ const logicalHeight = Math.max(WINDOW.height, Math.round(WINDOW.width / aspect));
49
+ offsetX = (logicalWidth - WINDOW.width) / 2;
50
+ offsetY = (logicalHeight - WINDOW.height) / 2;
51
+ draw.resize(logicalWidth, logicalHeight);
52
+ };
47
53
  // Online peers must start from the same state, so the seed is fixed there.
48
54
  const game: Game = createGame(options.link ? 1 : Math.floor(Math.random() * 1e9));
49
55
  let rollback: Rollback | null = null;
50
- let mashState = options.mash ?? 0;
51
- const nextRandom = (): number => {
52
- mashState = (Math.imul(mashState, 1103515245) + 12345) >>> 0;
53
- return mashState / 4294967296;
54
- };
55
- let mashed = 0;
56
- let mashInput = 0;
57
- const probe: Probe = { frame: 0, confirmed: 0, status: options.link ? "connecting" : "local", sums: {} };
58
- (globalThis as { __dotframe?: Probe }).__dotframe = probe;
56
+ const mash = options.mash === null ? null : createMasher(options.mash, randomInput);
57
+ // What dotframe play --online reads from each browser.
58
+ const probe = createProbe();
59
+ let localFrame = 0;
59
60
  let simulated = -1;
60
61
  // Hold to charge, release to act: the pattern most mouse and touch games need.
61
62
  let charge = 0;
@@ -77,15 +78,9 @@ export function createSetup(options: SetupOptions): (platform: SetupPlatform) =>
77
78
  wasDown = down;
78
79
  const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
79
80
  if (released) keys[0] = keys[0] | POINTER_BIT;
80
- if (options.mash !== null) {
81
- // A new random input every 6 frames, like a mashing player.
82
- if (mashed % 6 === 0) mashInput = randomInput(nextRandom);
83
- keys[0] = mashInput;
84
- mashed += 1;
85
- }
81
+ if (mash) keys[0] = mash();
86
82
  const link = options.link;
87
83
  if (link) {
88
- probe.status = link.status();
89
84
  if (!rollback && link.status() === "paired") {
90
85
  rollback = createRollback({
91
86
  sim: {
@@ -102,18 +97,18 @@ export function createSetup(options: SetupOptions): (platform: SetupPlatform) =>
102
97
  });
103
98
  }
104
99
  // Online, each browser controls its own player with player 1's keys.
105
- if (rollback) {
106
- rollback.tick(keys[0]);
107
- probe.frame = rollback.stats().frame;
108
- probe.confirmed = rollback.confirmedFrame();
109
- for (let f = 30; f < probe.confirmed; f += 30) if (probe.sums[f] === undefined) probe.sums[f] = rollback.sumAt(f) ?? 0;
110
- }
100
+ if (rollback) rollback.tick(keys[0]);
101
+ probe.update(rollback, link.status());
102
+ options.onProbe(probe.state);
111
103
  } else {
112
104
  step(game, keys.slice(0, PLAYERS));
113
- probe.frame += 1;
105
+ localFrame += 1;
106
+ probe.update(null, "local", localFrame);
107
+ options.onProbe(probe.state);
114
108
  }
115
109
  simulated += STEP;
116
110
  }
111
+ fit();
117
112
  draw.begin();
118
113
  draw.translate(offsetX, offsetY);
119
114
  render(game, draw);
@@ -1,7 +1,8 @@
1
1
  // Generates what a scriptc library-mode host needs from an app spec and native/ffi.<target>.json:
2
2
  // <out>/<name>.profile.json library profile: the app's exports plus every df* function as a callback channel
3
3
  // <out>/<name>_glue.c C trampolines (scriptc calls callbacks as fn(ctx, args...)) and a registration function
4
- // Usage: bun tools/gen-library-glue.ts <app.json> <out-dir> [ffi manifest]
4
+ // Usage: bun tools/gen-library-glue.ts <app.json> <out-dir> [ffi manifest] [used names, comma-separated]
5
+ // With used names, only those functions become callbacks: library mode caps a library at 32.
5
6
  // app.json: { "name": "smash", "entry": "app.ts" (relative to app.json), "exports": [{ "export": "frame", "params": ["f64"], "returns": "bool" }] }
6
7
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import { dirname, join, resolve } from "node:path";
@@ -25,7 +26,8 @@ interface AppSpec {
25
26
  exports: AppExport[];
26
27
  }
27
28
 
28
- const [appPath, outDir, manifestPath = join(import.meta.dir, "..", "native", "ffi.macos.json")] = process.argv.slice(2);
29
+ const [appPath, outDir, manifestPath = join(import.meta.dir, "..", "native", "ffi.macos.json"), usedList = ""] = process.argv.slice(2);
30
+ const used = usedList === "" ? null : new Set(usedList.split(","));
29
31
  const app = JSON.parse(readFileSync(appPath, "utf8")) as AppSpec;
30
32
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { functions: ManifestFunction[] };
31
33
 
@@ -35,9 +37,13 @@ const CALLBACK_RETURNS = new Set(["f64", "bool", "u8", "u32", "i32", "void"]);
35
37
  // The host drives the event loop and shutdown itself, so these stay out of the 32-channel callback budget.
36
38
  const HOST_OWNED = new Set(["dfPoll", "dfClose"]);
37
39
  const usable = manifest.functions.filter(
38
- (f) => !HOST_OWNED.has(f.name) && f.params.every((p) => CALLBACK_PARAMS.has(p)) && CALLBACK_RETURNS.has(f.returns),
40
+ (f) => !HOST_OWNED.has(f.name) && (!used || used.has(f.name)) && f.params.every((p) => CALLBACK_PARAMS.has(p)) && CALLBACK_RETURNS.has(f.returns),
39
41
  );
40
- const skipped = manifest.functions.filter((f) => !usable.includes(f) && !HOST_OWNED.has(f.name)).map((f) => f.name);
42
+ const skipped = manifest.functions.filter((f) => !usable.includes(f) && !HOST_OWNED.has(f.name) && (!used || used.has(f.name))).map((f) => f.name);
43
+ if (usable.length > 32) {
44
+ console.error(`${usable.length} callbacks exceed scriptc library mode's 32: ${usable.map((f) => f.name).join(", ")}`);
45
+ process.exit(1);
46
+ }
41
47
 
42
48
  const cType: Record<string, string> = { f64: "double", bool: "uint8_t", u8: "uint8_t", u32: "uint32_t", i32: "int32_t", void: "void" };
43
49
  const cParams = (params: string[]): string[] =>