dotframe 0.1.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +57 -0
  4. package/assets/audio/hit.mp3 +0 -0
  5. package/assets/audio/loop.mp3 +0 -0
  6. package/assets/crafter.png +0 -0
  7. package/assets/fonts/archivo-black.json +133 -0
  8. package/assets/fonts/archivo-black.png +0 -0
  9. package/assets/fonts/bangers.json +133 -0
  10. package/assets/fonts/bangers.png +0 -0
  11. package/assets/suzanne.glb +0 -0
  12. package/cli/commands/config.ts +35 -0
  13. package/cli/commands/doctor.ts +71 -0
  14. package/cli/commands/new.ts +73 -0
  15. package/cli/commands/play.ts +244 -0
  16. package/cli/commands/ship.ts +94 -0
  17. package/cli/commands/skills.ts +66 -0
  18. package/cli/commands/snap.ts +88 -0
  19. package/cli/lib.ts +148 -0
  20. package/cli/main.ts +95 -0
  21. package/cli/simkit.ts +152 -0
  22. package/cli/tsconfig.json +8 -0
  23. package/native/df_audio.c +266 -0
  24. package/native/df_native.c +605 -0
  25. package/native/ffi.macos.json +318 -0
  26. package/native/ffi.windows.json +322 -0
  27. package/native/third_party/dr_mp3.h +5430 -0
  28. package/native/third_party/stb_image.h +7988 -0
  29. package/native/third_party/stb_image_write.h +1724 -0
  30. package/native/third_party/stb_truetype.h +5079 -0
  31. package/package.json +47 -0
  32. package/scripts/build-native.sh +33 -0
  33. package/scripts/build-web.sh +10 -0
  34. package/scripts/vendor.sh +36 -0
  35. package/skills/assets/SKILL.md +11 -0
  36. package/skills/core/SKILL.md +51 -0
  37. package/skills/core/references/errors.md +22 -0
  38. package/skills/discord/SKILL.md +12 -0
  39. package/skills/export-web/SKILL.md +20 -0
  40. package/skills/game-design/SKILL.md +27 -0
  41. package/skills/game-design/references/sim-walkthrough.md +32 -0
  42. package/skills/ios/SKILL.md +19 -0
  43. package/skills/macos/SKILL.md +16 -0
  44. package/skills/netplay/SKILL.md +34 -0
  45. package/skills/relay/SKILL.md +18 -0
  46. package/src/audio.ts +19 -0
  47. package/src/draw2d.ts +901 -0
  48. package/src/ecs.ts +30 -0
  49. package/src/gltf.ts +82 -0
  50. package/src/gpu.ts +86 -0
  51. package/src/input.ts +215 -0
  52. package/src/math.ts +113 -0
  53. package/src/native/backend.ts +142 -0
  54. package/src/native/ffi.ts +42 -0
  55. package/src/native/library.ts +65 -0
  56. package/src/native/run.ts +88 -0
  57. package/src/physics.ts +57 -0
  58. package/src/platform.ts +12 -0
  59. package/src/raster2d.ts +348 -0
  60. package/src/render.ts +132 -0
  61. package/src/shapes.ts +37 -0
  62. package/src/sim.ts +56 -0
  63. package/src/storage.ts +6 -0
  64. package/src/web/run.ts +338 -0
  65. package/templates/_base/AGENTS.md +7 -0
  66. package/templates/_base/dotframe.json +15 -0
  67. package/templates/_base/gitignore +5 -0
  68. package/templates/_base/index.html +14 -0
  69. package/templates/_base/main.web.ts +27 -0
  70. package/templates/_base/package.json +12 -0
  71. package/templates/_base/sim.ts +31 -0
  72. package/templates/blank/game.ts +64 -0
  73. package/templates/fighter/game.ts +131 -0
  74. package/templates/platformer/game.ts +120 -0
  75. package/tools/check-audio.ts +57 -0
  76. package/tools/gen-library-glue.ts +83 -0
  77. package/tools/make-sprite.ts +92 -0
@@ -0,0 +1,42 @@
1
+ // Signature-only declarations bound to native/df_native.c through native/ffi.*.json.
2
+ export declare function dfOpen(width: number, height: number, title: string): number;
3
+ export declare function dfKeyDown(scancode: number): boolean;
4
+ export declare function dfGamepadAxis(pad: number, axis: number): number;
5
+ export declare function dfGamepadButton(pad: number, button: number): boolean;
6
+ export declare function dfMouse(field: number): number;
7
+ export declare function dfPrefPath(org: string, app: string, out: Uint8Array): number;
8
+ export declare function dfWidth(): number;
9
+ export declare function dfHeight(): number;
10
+ export declare function dfBuffer(usage: number, data: Uint8Array): number;
11
+ export declare function dfBufferWrite(buffer: number, data: Uint8Array): void;
12
+ export declare function dfBufferDestroy(buffer: number): void;
13
+ export declare function dfPipeline(wgsl: string, stride: number, attributes: Uint8Array, flags: number): number;
14
+ export declare function dfBind(pipeline: number, buffer: number, texture: number): number;
15
+ export declare function dfTexture(width: number, height: number, rgba: Uint8Array, smooth: boolean): number;
16
+ export declare function dfImage(png: Uint8Array, smooth: boolean): number;
17
+ export declare function dfTextureSize(texture: number, axis: number): number;
18
+ export declare function dfPoll(): boolean;
19
+ export declare function dfTouchCount(): number;
20
+ export declare function dfTouch(index: number, field: number): number;
21
+ export declare function dfBegin(r: number, g: number, b: number, depth: boolean): number;
22
+ export declare function dfDraw(
23
+ pipeline: number,
24
+ bindGroup: number,
25
+ vertexBuffer: number,
26
+ indexBuffer: number,
27
+ first: number,
28
+ count: number,
29
+ ): void;
30
+ export declare function dfEnd(): void;
31
+ export declare function dfClose(): void;
32
+ export declare function dfAudioOpen(): number;
33
+ export declare function dfSound(mp3: Uint8Array): number;
34
+ export declare function dfPlay(sound: number, volume: number, rate: number): void;
35
+ export declare function dfTone(frequency: number, duration: number, volume: number): void;
36
+ export declare function dfTrack(mp3: Uint8Array): number;
37
+ export declare function dfMusicPlay(track: number, loop: boolean, volume: number): number;
38
+ export declare function dfMusicStop(): void;
39
+ export declare function dfMusicPause(paused: boolean): void;
40
+ export declare function dfMusicVolume(volume: number): void;
41
+ export declare function dfMasterVolume(volume: number): void;
42
+ export declare function dfAudioActive(): number;
@@ -0,0 +1,65 @@
1
+ // Runtime for scriptc library mode (iOS): the native host owns the app lifecycle and event loop and calls the
2
+ // library's exported init and frame functions. The df* declarations in ffi.ts become profile callbacks the host
3
+ // registers (tools/gen-library-glue.ts), since library mode has no FFI. Library mode also has no promises, so
4
+ // loading is synchronous.
5
+ import { readFileSync } from "node:fs";
6
+ import type { AudioPlayer } from "../audio";
7
+ import type { RenderGpu, Texture, WindowOptions } from "../gpu";
8
+ import type { Input } from "../input";
9
+ import type { Storage } from "../storage";
10
+ import {
11
+ createNativeAudioPlayer,
12
+ createNativeImage,
13
+ createNativeInput,
14
+ createNativeRenderGpu,
15
+ decodeNativeSound,
16
+ openNativeAudio,
17
+ storeNativeTrack,
18
+ } from "./backend";
19
+ import { dfHeight, dfOpen, dfWidth } from "./ffi";
20
+
21
+ export interface LibraryPlatform {
22
+ gpu: RenderGpu;
23
+ input: Input;
24
+ audio: AudioPlayer;
25
+ storage: Storage;
26
+ // Surface size in pixels, to pick a logical resolution with the device's aspect ratio.
27
+ width: number;
28
+ height: number;
29
+ image: (png: Uint8Array, smooth: boolean) => Texture;
30
+ sound: (mp3: Uint8Array) => number;
31
+ track: (mp3: Uint8Array) => number;
32
+ readFile: (path: string) => Uint8Array;
33
+ }
34
+
35
+ // In-memory for now: library hosts pass no writable preferences path yet.
36
+ function memoryStorage(): Storage {
37
+ const values = new Map<string, string>();
38
+ return {
39
+ get: (key: string): string | null => values.get(key) ?? null,
40
+ set: (key: string, value: string): void => {
41
+ values.set(key, value);
42
+ },
43
+ };
44
+ }
45
+
46
+ export function openLibraryPlatform(options: WindowOptions): LibraryPlatform {
47
+ const status = dfOpen(options.width, options.height, options.title);
48
+ if (status !== 0) throw new Error(`dfOpen failed: ${status}`);
49
+ const audioReady = openNativeAudio();
50
+ return {
51
+ gpu: createNativeRenderGpu(),
52
+ input: createNativeInput(),
53
+ audio: createNativeAudioPlayer(),
54
+ storage: memoryStorage(),
55
+ width: dfWidth(),
56
+ height: dfHeight(),
57
+ image: (png: Uint8Array, smooth: boolean): Texture => createNativeImage(png, smooth),
58
+ sound: (mp3: Uint8Array): number => (audioReady ? decodeNativeSound(mp3) : -1),
59
+ track: (mp3: Uint8Array): number => storeNativeTrack(mp3),
60
+ readFile: (path: string): Uint8Array => {
61
+ const data = readFileSync(path);
62
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import type { Audio } from "../audio";
3
+ import type { Gpu, Setup, Texture, WindowOptions } from "../gpu";
4
+ import type { Storage } from "../storage";
5
+ import {
6
+ createNativeAudioPlayer,
7
+ createNativeImage,
8
+ createNativeInput,
9
+ createNativeRenderGpu,
10
+ decodeNativeSound,
11
+ openNativeAudio,
12
+ storeNativeTrack,
13
+ } from "./backend";
14
+ import { dfClose, dfOpen, dfPoll, dfPrefPath } from "./ffi";
15
+
16
+ export async function loadBytes(path: string): Promise<Uint8Array> {
17
+ const data = readFileSync(path);
18
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
19
+ }
20
+
21
+ // Stores values as one JSON object in <prefs>/dotframe/<app>/storage.json, rewritten on every set.
22
+ function openStorage(app: string): Storage {
23
+ const pathBytes = new Uint8Array(1024);
24
+ const length = dfPrefPath("dotframe", app.split(":").join("").split("/").join("-"), pathBytes);
25
+ const file = length > 0 ? `${new TextDecoder().decode(pathBytes.subarray(0, length))}storage.json` : "";
26
+ const values = new Map<string, string>();
27
+ if (file !== "" && existsSync(file)) {
28
+ const saved = JSON.parse(readFileSync(file, "utf8")) as Record<string, string>;
29
+ for (const key of Object.keys(saved)) values.set(key, saved[key]);
30
+ }
31
+ return {
32
+ get: (key: string): string | null => values.get(key) ?? null,
33
+ set: (key: string, value: string): void => {
34
+ values.set(key, value);
35
+ if (file === "") return;
36
+ const out: Record<string, string> = {};
37
+ for (const [k, v] of values) out[k] = v;
38
+ writeFileSync(file, JSON.stringify(out));
39
+ },
40
+ };
41
+ }
42
+
43
+ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
44
+ const status = dfOpen(options.width, options.height, options.title);
45
+ if (status !== 0) throw new Error(`dfOpen failed: ${status}`);
46
+
47
+ const render = createNativeRenderGpu();
48
+ const gpu: Gpu = {
49
+ createBuffer: render.createBuffer,
50
+ writeBuffer: render.writeBuffer,
51
+ destroyBuffer: render.destroyBuffer,
52
+ createPipeline: render.createPipeline,
53
+ bind: render.bind,
54
+ createTexture: render.createTexture,
55
+ frame: render.frame,
56
+ aspect: render.aspect,
57
+ createImage: async (png: Uint8Array, smooth: boolean): Promise<Texture> => createNativeImage(png, smooth),
58
+ };
59
+ const input = createNativeInput();
60
+ const storage = openStorage(options.title);
61
+ const audioReady = openNativeAudio();
62
+ const player = createNativeAudioPlayer();
63
+ const audio: Audio = {
64
+ play: player.play,
65
+ tone: player.tone,
66
+ playMusic: player.playMusic,
67
+ stopMusic: player.stopMusic,
68
+ pauseMusic: player.pauseMusic,
69
+ setMusicVolume: player.setMusicVolume,
70
+ setMasterVolume: player.setMasterVolume,
71
+ loadSound: async (mp3: Uint8Array): Promise<number> => (audioReady ? decodeNativeSound(mp3) : -1),
72
+ loadMusic: async (mp3: Uint8Array): Promise<number> => storeNativeTrack(mp3),
73
+ };
74
+
75
+ const frame = setup({ gpu, input, audio, storage });
76
+ // DF_FRAMES bounds the run for headless checks.
77
+ const maxFrames = Number(process.env.DF_FRAMES ?? "0");
78
+ const start = performance.now();
79
+ let index = 0;
80
+ while (dfPoll()) {
81
+ if (!frame((performance.now() - start) / 1000)) break;
82
+ // Let promise callbacks queued by game code (asset loads, timers) run, as the browser does between frames.
83
+ await Promise.resolve();
84
+ index++;
85
+ if (maxFrames > 0 && index >= maxFrames) break;
86
+ }
87
+ dfClose();
88
+ }
package/src/physics.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { Vec3 } from "./math";
2
+
3
+ // Axis-aligned boxes in the XY plane (side view), integrated at a fixed step.
4
+
5
+ export interface Body {
6
+ position: Vec3;
7
+ velocity: Vec3;
8
+ halfWidth: number;
9
+ halfHeight: number;
10
+ onGround: boolean;
11
+ }
12
+
13
+ export interface Solid {
14
+ x: number;
15
+ y: number;
16
+ halfWidth: number;
17
+ halfHeight: number;
18
+ }
19
+
20
+ export const FIXED_STEP = 1 / 60;
21
+
22
+ function overlaps(body: Body, solid: Solid): boolean {
23
+ return (
24
+ Math.abs(body.position.x - solid.x) < body.halfWidth + solid.halfWidth &&
25
+ Math.abs(body.position.y - solid.y) < body.halfHeight + solid.halfHeight
26
+ );
27
+ }
28
+
29
+ // Moves the body one step and resolves collisions against static solids, X then Y.
30
+ export function stepBody(body: Body, solids: Solid[], gravity: number, dt: number): void {
31
+ body.velocity = { x: body.velocity.x, y: body.velocity.y + gravity * dt, z: 0 };
32
+
33
+ body.position = { x: body.position.x + body.velocity.x * dt, y: body.position.y, z: body.position.z };
34
+ for (const solid of solids) {
35
+ if (!overlaps(body, solid)) continue;
36
+ // A shallow vertical overlap (resting on or grazing a solid) belongs to the Y pass.
37
+ const overlapX = body.halfWidth + solid.halfWidth - Math.abs(body.position.x - solid.x);
38
+ const overlapY = body.halfHeight + solid.halfHeight - Math.abs(body.position.y - solid.y);
39
+ if (overlapY < overlapX) continue;
40
+ const side = body.position.x < solid.x ? -1 : 1;
41
+ body.position = { x: solid.x + side * (solid.halfWidth + body.halfWidth), y: body.position.y, z: body.position.z };
42
+ body.velocity = { x: 0, y: body.velocity.y, z: 0 };
43
+ }
44
+
45
+ body.onGround = false;
46
+ body.position = { x: body.position.x, y: body.position.y + body.velocity.y * dt, z: body.position.z };
47
+ for (const solid of solids) {
48
+ if (!overlaps(body, solid)) continue;
49
+ if (body.velocity.y <= 0 && body.position.y > solid.y) {
50
+ body.position = { x: body.position.x, y: solid.y + solid.halfHeight + body.halfHeight, z: body.position.z };
51
+ body.onGround = true;
52
+ } else {
53
+ body.position = { x: body.position.x, y: solid.y - solid.halfHeight - body.halfHeight, z: body.position.z };
54
+ }
55
+ body.velocity = { x: body.velocity.x, y: 0, z: 0 };
56
+ }
57
+ }
@@ -0,0 +1,12 @@
1
+ import type { Audio } from "./audio";
2
+ import type { Gpu } from "./gpu";
3
+ import type { Input } from "./input";
4
+ import type { Storage } from "./storage";
5
+
6
+ // Everything a game gets from the backend it runs on.
7
+ export interface Platform {
8
+ gpu: Gpu;
9
+ input: Input;
10
+ audio: Audio;
11
+ storage: Storage;
12
+ }
@@ -0,0 +1,348 @@
1
+ import { parseColor } from "./draw2d";
2
+
3
+ // CPU canvas for baking art at load time (pixel-art backgrounds, procedural stages, portraits).
4
+ // Implements the Canvas2D subset baking code uses, writes straight RGBA8, and renders identically on every target.
5
+ // Upload the result with gpu.createTexture(raster.width, raster.height, raster.pixels, false).
6
+
7
+ export interface Gradient {
8
+ x0: number;
9
+ y0: number;
10
+ x1: number;
11
+ y1: number;
12
+ stops: GradientStop[];
13
+ }
14
+
15
+ export interface GradientStop {
16
+ offset: number;
17
+ r: number;
18
+ g: number;
19
+ b: number;
20
+ a: number;
21
+ }
22
+
23
+ export interface Raster {
24
+ width: number;
25
+ height: number;
26
+ // Straight (not premultiplied) RGBA8, row-major.
27
+ pixels: Uint8Array;
28
+ }
29
+
30
+ export interface Raster2D {
31
+ raster: Raster;
32
+ setFillStyle: (color: string) => void;
33
+ setFillGradient: (gradient: Gradient) => void;
34
+ setGlobalAlpha: (alpha: number) => void;
35
+ fillRect: (x: number, y: number, width: number, height: number) => void;
36
+ clearRect: (x: number, y: number, width: number, height: number) => void;
37
+ beginPath: () => void;
38
+ moveTo: (x: number, y: number) => void;
39
+ lineTo: (x: number, y: number) => void;
40
+ arc: (x: number, y: number, radius: number, start: number, end: number, counterclockwise: boolean) => void;
41
+ arcTo: (x1: number, y1: number, x2: number, y2: number, radius: number) => void;
42
+ rect: (x: number, y: number, width: number, height: number) => void;
43
+ closePath: () => void;
44
+ // Nonzero winding, pixel centers sampled, no antialiasing (pixel art).
45
+ fill: () => void;
46
+ // Nearest-neighbor copy of a source rectangle, alpha-blended.
47
+ drawImage: (
48
+ source: Raster,
49
+ sx: number,
50
+ sy: number,
51
+ sw: number,
52
+ sh: number,
53
+ dx: number,
54
+ dy: number,
55
+ dw: number,
56
+ dh: number,
57
+ ) => void;
58
+ }
59
+
60
+ export function createRaster(width: number, height: number): Raster {
61
+ return { width, height, pixels: new Uint8Array(width * height * 4) };
62
+ }
63
+
64
+ export function createLinearGradient(x0: number, y0: number, x1: number, y1: number): Gradient {
65
+ return { x0, y0, x1, y1, stops: [] };
66
+ }
67
+
68
+ export function addColorStop(gradient: Gradient, offset: number, color: string): void {
69
+ const c = parseColor(color);
70
+ gradient.stops.push({ offset, r: c.r, g: c.g, b: c.b, a: c.a });
71
+ gradient.stops.sort((a: GradientStop, b: GradientStop): number => a.offset - b.offset);
72
+ }
73
+
74
+ export function createRaster2D(raster: Raster): Raster2D {
75
+ const { width, height, pixels } = raster;
76
+ let fillR = 0;
77
+ let fillG = 0;
78
+ let fillB = 0;
79
+ let fillA = 1;
80
+ let gradient: Gradient | null = null;
81
+ let alpha = 1;
82
+ // Path as subpaths of flat points.
83
+ const subpaths: number[][] = [];
84
+ let subpathCount = 0;
85
+ let penX = 0;
86
+ let penY = 0;
87
+
88
+ const startSubpath = (x: number, y: number): number[] => {
89
+ if (subpathCount === subpaths.length) subpaths.push([]);
90
+ const points = subpaths[subpathCount];
91
+ points.length = 0;
92
+ points.push(x, y);
93
+ subpathCount++;
94
+ return points;
95
+ };
96
+ const current = (): number[] => (subpathCount === 0 ? startSubpath(penX, penY) : subpaths[subpathCount - 1]);
97
+ const addPoint = (x: number, y: number): void => {
98
+ current().push(x, y);
99
+ penX = x;
100
+ penY = y;
101
+ };
102
+
103
+ // Writes one pixel with source-over blending of the current fill at (x, y).
104
+ const blendFill = (index: number, x: number, y: number): void => {
105
+ let r = fillR;
106
+ let g = fillG;
107
+ let b = fillB;
108
+ let a = fillA;
109
+ const grad = gradient;
110
+ if (grad) {
111
+ const dx = grad.x1 - grad.x0;
112
+ const dy = grad.y1 - grad.y0;
113
+ const lengthSquared = dx * dx + dy * dy;
114
+ let t = lengthSquared > 0 ? ((x + 0.5 - grad.x0) * dx + (y + 0.5 - grad.y0) * dy) / lengthSquared : 0;
115
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
116
+ const stops = grad.stops;
117
+ if (stops.length > 0) {
118
+ let k = 0;
119
+ while (k + 1 < stops.length && stops[k + 1].offset < t) k++;
120
+ const from = stops[k];
121
+ const to = stops[k + 1 < stops.length ? k + 1 : k];
122
+ const span = to.offset - from.offset;
123
+ const local = span > 0 ? (t - from.offset) / span : 0;
124
+ const u = local < 0 ? 0 : local > 1 ? 1 : local;
125
+ r = from.r + (to.r - from.r) * u;
126
+ g = from.g + (to.g - from.g) * u;
127
+ b = from.b + (to.b - from.b) * u;
128
+ a = from.a + (to.a - from.a) * u;
129
+ }
130
+ }
131
+ blend(index, r * 255, g * 255, b * 255, a * alpha);
132
+ };
133
+
134
+ const blend = (index: number, r: number, g: number, b: number, a: number): void => {
135
+ if (a <= 0) return;
136
+ if (a >= 1) {
137
+ pixels[index] = r;
138
+ pixels[index + 1] = g;
139
+ pixels[index + 2] = b;
140
+ pixels[index + 3] = 255;
141
+ return;
142
+ }
143
+ const dstA = pixels[index + 3] / 255;
144
+ const outA = a + dstA * (1 - a);
145
+ if (outA <= 0) return;
146
+ pixels[index] = (r * a + pixels[index] * dstA * (1 - a)) / outA;
147
+ pixels[index + 1] = (g * a + pixels[index + 1] * dstA * (1 - a)) / outA;
148
+ pixels[index + 2] = (b * a + pixels[index + 2] * dstA * (1 - a)) / outA;
149
+ pixels[index + 3] = outA * 255;
150
+ };
151
+
152
+ return {
153
+ raster,
154
+ setFillStyle: (color: string): void => {
155
+ const c = parseColor(color);
156
+ fillR = c.r;
157
+ fillG = c.g;
158
+ fillB = c.b;
159
+ fillA = c.a;
160
+ gradient = null;
161
+ },
162
+ setFillGradient: (value: Gradient): void => {
163
+ gradient = value;
164
+ },
165
+ setGlobalAlpha: (value: number): void => {
166
+ alpha = value;
167
+ },
168
+ fillRect: (x: number, y: number, w: number, h: number): void => {
169
+ const x0 = Math.max(0, Math.round(Math.min(x, x + w)));
170
+ const y0 = Math.max(0, Math.round(Math.min(y, y + h)));
171
+ const x1 = Math.min(width, Math.round(Math.max(x, x + w)));
172
+ const y1 = Math.min(height, Math.round(Math.max(y, y + h)));
173
+ for (let py = y0; py < y1; py++) {
174
+ for (let px = x0; px < x1; px++) blendFill((py * width + px) * 4, px, py);
175
+ }
176
+ },
177
+ clearRect: (x: number, y: number, w: number, h: number): void => {
178
+ const x0 = Math.max(0, Math.round(x));
179
+ const y0 = Math.max(0, Math.round(y));
180
+ const x1 = Math.min(width, Math.round(x + w));
181
+ const y1 = Math.min(height, Math.round(y + h));
182
+ // TypedArray.fill has no scriptc lowering yet; clear byte by byte.
183
+ for (let py = y0; py < y1; py++) {
184
+ for (let i = (py * width + x0) * 4; i < (py * width + x1) * 4; i++) pixels[i] = 0;
185
+ }
186
+ },
187
+ beginPath: (): void => {
188
+ subpathCount = 0;
189
+ },
190
+ moveTo: (x: number, y: number): void => {
191
+ startSubpath(x, y);
192
+ penX = x;
193
+ penY = y;
194
+ },
195
+ lineTo: (x: number, y: number): void => addPoint(x, y),
196
+ arc: (x: number, y: number, radius: number, start: number, end: number, counterclockwise: boolean): void => {
197
+ const full = Math.PI * 2;
198
+ let sweep = end - start;
199
+ if (!counterclockwise && sweep < 0) sweep = (sweep % full) + full;
200
+ if (counterclockwise && sweep > 0) sweep = (sweep % full) - full;
201
+ if (Math.abs(end - start) >= full) sweep = counterclockwise ? -full : full;
202
+ const steps = Math.max(4, Math.ceil((Math.abs(sweep) / full) * Math.max(12, radius * 2)));
203
+ for (let i = 0; i <= steps; i++) {
204
+ const angle = start + (sweep * i) / steps;
205
+ addPoint(x + Math.cos(angle) * radius, y + Math.sin(angle) * radius);
206
+ }
207
+ },
208
+ // Canvas2D arcTo: a line toward (x1, y1) ending in an arc tangent to both segments.
209
+ arcTo: (x1: number, y1: number, x2: number, y2: number, radius: number): void => {
210
+ const x0 = penX;
211
+ const y0 = penY;
212
+ const ax = x0 - x1;
213
+ const ay = y0 - y1;
214
+ const bx = x2 - x1;
215
+ const by = y2 - y1;
216
+ const la = Math.hypot(ax, ay);
217
+ const lb = Math.hypot(bx, by);
218
+ const cross = ax * by - ay * bx;
219
+ if (radius <= 0 || la === 0 || lb === 0 || Math.abs(cross) < 1e-9) {
220
+ addPoint(x1, y1);
221
+ return;
222
+ }
223
+ const angle = Math.acos(Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))));
224
+ const tangent = radius / Math.tan(angle / 2);
225
+ const t0x = x1 + (ax / la) * tangent;
226
+ const t0y = y1 + (ay / la) * tangent;
227
+ const t1x = x1 + (bx / lb) * tangent;
228
+ const t1y = y1 + (by / lb) * tangent;
229
+ // Center lies along the bisector, at radius from both tangent points.
230
+ const bisX = ax / la + bx / lb;
231
+ const bisY = ay / la + by / lb;
232
+ const bisLength = Math.hypot(bisX, bisY);
233
+ const centerDistance = radius / Math.sin(angle / 2);
234
+ const cx = x1 + (bisX / bisLength) * centerDistance;
235
+ const cy = y1 + (bisY / bisLength) * centerDistance;
236
+ addPoint(t0x, t0y);
237
+ const startAngle = Math.atan2(t0y - cy, t0x - cx);
238
+ let endAngle = Math.atan2(t1y - cy, t1x - cx);
239
+ const clockwise = cross < 0;
240
+ if (clockwise && endAngle < startAngle) endAngle += Math.PI * 2;
241
+ if (!clockwise && endAngle > startAngle) endAngle -= Math.PI * 2;
242
+ const steps = Math.max(2, Math.ceil(Math.abs(endAngle - startAngle) * Math.max(2, radius)));
243
+ for (let i = 1; i <= steps; i++) {
244
+ const a = startAngle + ((endAngle - startAngle) * i) / steps;
245
+ addPoint(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius);
246
+ }
247
+ },
248
+ rect: (x: number, y: number, w: number, h: number): void => {
249
+ const points = startSubpath(x, y);
250
+ points.push(x + w, y, x + w, y + h, x, y + h);
251
+ penX = x;
252
+ penY = y;
253
+ },
254
+ closePath: (): void => {
255
+ if (subpathCount > 0) {
256
+ const points = subpaths[subpathCount - 1];
257
+ penX = points[0];
258
+ penY = points[1];
259
+ }
260
+ },
261
+ fill: (): void => {
262
+ let minY = height;
263
+ let maxY = 0;
264
+ for (let s = 0; s < subpathCount; s++) {
265
+ const points = subpaths[s];
266
+ for (let i = 1; i < points.length; i += 2) {
267
+ minY = Math.min(minY, points[i]);
268
+ maxY = Math.max(maxY, points[i]);
269
+ }
270
+ }
271
+ const yStart = Math.max(0, Math.floor(minY));
272
+ const yEnd = Math.min(height - 1, Math.ceil(maxY));
273
+ const crossings: number[] = [];
274
+ const windings: number[] = [];
275
+ for (let py = yStart; py <= yEnd; py++) {
276
+ const sampleY = py + 0.5;
277
+ crossings.length = 0;
278
+ windings.length = 0;
279
+ for (let s = 0; s < subpathCount; s++) {
280
+ const points = subpaths[s];
281
+ const count = points.length / 2;
282
+ for (let i = 0; i < count; i++) {
283
+ const j = (i + 1) % count;
284
+ const ay = points[i * 2 + 1];
285
+ const by = points[j * 2 + 1];
286
+ if (ay === by) continue;
287
+ const up = ay < by;
288
+ const lowY = up ? ay : by;
289
+ const highY = up ? by : ay;
290
+ if (sampleY < lowY || sampleY >= highY) continue;
291
+ const ax = points[i * 2];
292
+ const bx = points[j * 2];
293
+ crossings.push(ax + ((sampleY - ay) / (by - ay)) * (bx - ax));
294
+ windings.push(up ? 1 : -1);
295
+ }
296
+ }
297
+ // Sort crossings with their windings (insertion sort: few edges per scanline).
298
+ for (let i = 1; i < crossings.length; i++) {
299
+ const x = crossings[i];
300
+ const w = windings[i];
301
+ let j = i - 1;
302
+ while (j >= 0 && crossings[j] > x) {
303
+ crossings[j + 1] = crossings[j];
304
+ windings[j + 1] = windings[j];
305
+ j--;
306
+ }
307
+ crossings[j + 1] = x;
308
+ windings[j + 1] = w;
309
+ }
310
+ let winding = 0;
311
+ for (let i = 0; i + 1 < crossings.length; i++) {
312
+ winding += windings[i];
313
+ if (winding === 0) continue;
314
+ const x0 = Math.max(0, Math.ceil(crossings[i] - 0.5));
315
+ const x1 = Math.min(width - 1, Math.ceil(crossings[i + 1] - 0.5) - 1);
316
+ for (let px = x0; px <= x1; px++) blendFill((py * width + px) * 4, px, py);
317
+ }
318
+ }
319
+ },
320
+ drawImage: (
321
+ source: Raster,
322
+ sx: number,
323
+ sy: number,
324
+ sw: number,
325
+ sh: number,
326
+ dx: number,
327
+ dy: number,
328
+ dw: number,
329
+ dh: number,
330
+ ): void => {
331
+ const x0 = Math.max(0, Math.round(dx));
332
+ const y0 = Math.max(0, Math.round(dy));
333
+ const x1 = Math.min(width, Math.round(dx + dw));
334
+ const y1 = Math.min(height, Math.round(dy + dh));
335
+ const src = source.pixels;
336
+ for (let py = y0; py < y1; py++) {
337
+ const v = Math.floor(sy + ((py + 0.5 - dy) / dh) * sh);
338
+ if (v < 0 || v >= source.height) continue;
339
+ for (let px = x0; px < x1; px++) {
340
+ const u = Math.floor(sx + ((px + 0.5 - dx) / dw) * sw);
341
+ if (u < 0 || u >= source.width) continue;
342
+ const si = (v * source.width + u) * 4;
343
+ blend((py * width + px) * 4, src[si], src[si + 1], src[si + 2], (src[si + 3] / 255) * alpha);
344
+ }
345
+ }
346
+ },
347
+ };
348
+ }