@arcane-engine/runtime 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 (57) hide show
  1. package/README.md +38 -0
  2. package/index.ts +19 -0
  3. package/package.json +53 -0
  4. package/src/agent/agent.test.ts +384 -0
  5. package/src/agent/describe.ts +72 -0
  6. package/src/agent/index.ts +20 -0
  7. package/src/agent/protocol.ts +125 -0
  8. package/src/agent/types.ts +73 -0
  9. package/src/pathfinding/astar.test.ts +208 -0
  10. package/src/pathfinding/astar.ts +193 -0
  11. package/src/pathfinding/index.ts +2 -0
  12. package/src/pathfinding/types.ts +21 -0
  13. package/src/physics/aabb.ts +54 -0
  14. package/src/physics/index.ts +2 -0
  15. package/src/rendering/animation.test.ts +119 -0
  16. package/src/rendering/animation.ts +132 -0
  17. package/src/rendering/audio.test.ts +33 -0
  18. package/src/rendering/audio.ts +70 -0
  19. package/src/rendering/camera.ts +35 -0
  20. package/src/rendering/index.ts +56 -0
  21. package/src/rendering/input.test.ts +70 -0
  22. package/src/rendering/input.ts +82 -0
  23. package/src/rendering/lighting.ts +38 -0
  24. package/src/rendering/loop.ts +21 -0
  25. package/src/rendering/sprites.ts +60 -0
  26. package/src/rendering/text.test.ts +91 -0
  27. package/src/rendering/text.ts +184 -0
  28. package/src/rendering/texture.ts +31 -0
  29. package/src/rendering/tilemap.ts +46 -0
  30. package/src/rendering/types.ts +54 -0
  31. package/src/rendering/validate.ts +132 -0
  32. package/src/state/error.test.ts +45 -0
  33. package/src/state/error.ts +20 -0
  34. package/src/state/index.ts +70 -0
  35. package/src/state/observe.test.ts +173 -0
  36. package/src/state/observe.ts +110 -0
  37. package/src/state/prng.test.ts +221 -0
  38. package/src/state/prng.ts +162 -0
  39. package/src/state/query.test.ts +208 -0
  40. package/src/state/query.ts +144 -0
  41. package/src/state/store.test.ts +211 -0
  42. package/src/state/store.ts +109 -0
  43. package/src/state/transaction.test.ts +235 -0
  44. package/src/state/transaction.ts +280 -0
  45. package/src/state/types.test.ts +33 -0
  46. package/src/state/types.ts +30 -0
  47. package/src/systems/index.ts +2 -0
  48. package/src/systems/system.test.ts +217 -0
  49. package/src/systems/system.ts +150 -0
  50. package/src/systems/types.ts +35 -0
  51. package/src/testing/harness.ts +271 -0
  52. package/src/testing/mock-renderer.test.ts +93 -0
  53. package/src/testing/mock-renderer.ts +178 -0
  54. package/src/ui/index.ts +3 -0
  55. package/src/ui/primitives.test.ts +105 -0
  56. package/src/ui/primitives.ts +260 -0
  57. package/src/ui/types.ts +57 -0
@@ -0,0 +1,119 @@
1
+ import { describe, it, assert } from "../../runtime/testing/harness.ts";
2
+ import {
3
+ createAnimation,
4
+ playAnimation,
5
+ updateAnimation,
6
+ getAnimationUV,
7
+ drawAnimatedSprite,
8
+ resetAnimation,
9
+ stopAnimation,
10
+ } from "./animation.ts";
11
+
12
+ describe("animation", () => {
13
+ it("createAnimation returns incrementing IDs", () => {
14
+ const id1 = createAnimation(1, 32, 32, 4, 10);
15
+ const id2 = createAnimation(2, 16, 16, 8, 12);
16
+ assert.equal(id2, id1 + 1);
17
+ });
18
+
19
+ it("playAnimation returns initial state", () => {
20
+ const id = createAnimation(1, 32, 32, 4, 10);
21
+ const state = playAnimation(id);
22
+ assert.equal(state.defId, id);
23
+ assert.equal(state.elapsed, 0);
24
+ assert.equal(state.frame, 0);
25
+ assert.equal(state.finished, false);
26
+ });
27
+
28
+ it("updateAnimation advances frame correctly", () => {
29
+ const id = createAnimation(1, 32, 32, 4, 10);
30
+ const state = playAnimation(id);
31
+ // At 10 fps, 0.15s = frame 1
32
+ const next = updateAnimation(state, 0.15);
33
+ assert.equal(next.frame, 1);
34
+ assert.equal(next.finished, false);
35
+ });
36
+
37
+ it("updateAnimation loops back to frame 0", () => {
38
+ const id = createAnimation(1, 32, 32, 4, 10);
39
+ const state = playAnimation(id);
40
+ // At 10 fps, 0.45s = frame 4, loops to 0
41
+ const next = updateAnimation(state, 0.45);
42
+ assert.equal(next.frame, 4 % 4);
43
+ assert.equal(next.finished, false);
44
+ });
45
+
46
+ it("updateAnimation non-loop stops at last frame", () => {
47
+ const id = createAnimation(1, 32, 32, 4, 10, { loop: false });
48
+ const state = playAnimation(id);
49
+ // At 10 fps, 1.0s = frame 10, clamped to frame 3
50
+ const next = updateAnimation(state, 1.0);
51
+ assert.equal(next.frame, 3);
52
+ });
53
+
54
+ it("updateAnimation non-loop sets finished=true", () => {
55
+ const id = createAnimation(1, 32, 32, 4, 10, { loop: false });
56
+ const state = playAnimation(id);
57
+ const next = updateAnimation(state, 1.0);
58
+ assert.equal(next.finished, true);
59
+ });
60
+
61
+ it("getAnimationUV returns correct UV for frame 0", () => {
62
+ const id = createAnimation(1, 32, 32, 4, 10);
63
+ const state = playAnimation(id);
64
+ const uv = getAnimationUV(state);
65
+ assert.equal(uv.x, 0);
66
+ assert.equal(uv.y, 0);
67
+ assert.equal(uv.w, 0.25);
68
+ assert.equal(uv.h, 1);
69
+ });
70
+
71
+ it("getAnimationUV returns correct UV for frame N", () => {
72
+ const id = createAnimation(1, 32, 32, 4, 10);
73
+ let state = playAnimation(id);
74
+ // Advance to frame 2: 0.25s at 10fps = frame 2
75
+ state = updateAnimation(state, 0.25);
76
+ assert.equal(state.frame, 2);
77
+ const uv = getAnimationUV(state);
78
+ assert.equal(uv.x, 0.5);
79
+ assert.equal(uv.y, 0);
80
+ assert.equal(uv.w, 0.25);
81
+ assert.equal(uv.h, 1);
82
+ });
83
+
84
+ it("resetAnimation resets elapsed and frame", () => {
85
+ const id = createAnimation(1, 32, 32, 4, 10);
86
+ let state = playAnimation(id);
87
+ state = updateAnimation(state, 0.25);
88
+ assert.ok(state.elapsed > 0);
89
+ const reset = resetAnimation(state);
90
+ assert.equal(reset.elapsed, 0);
91
+ assert.equal(reset.frame, 0);
92
+ assert.equal(reset.finished, false);
93
+ assert.equal(reset.defId, id);
94
+ });
95
+
96
+ it("stopAnimation sets finished", () => {
97
+ const id = createAnimation(1, 32, 32, 4, 10);
98
+ const state = playAnimation(id);
99
+ const stopped = stopAnimation(state);
100
+ assert.equal(stopped.finished, true);
101
+ assert.equal(stopped.defId, id);
102
+ });
103
+
104
+ it("updateAnimation returns new object (immutability)", () => {
105
+ const id = createAnimation(1, 32, 32, 4, 10);
106
+ const state = playAnimation(id);
107
+ const next = updateAnimation(state, 0.1);
108
+ assert.notEqual(state, next);
109
+ assert.equal(state.elapsed, 0);
110
+ assert.equal(state.frame, 0);
111
+ });
112
+
113
+ it("drawAnimatedSprite doesn't throw (smoke test in headless)", () => {
114
+ const id = createAnimation(1, 32, 32, 4, 10);
115
+ const state = playAnimation(id);
116
+ // Should not throw even in headless (drawSprite is a no-op)
117
+ drawAnimatedSprite(state, 100, 200, 32, 32, { layer: 1 });
118
+ });
119
+ });
@@ -0,0 +1,132 @@
1
+ import type { TextureId } from "./types.ts";
2
+ import { drawSprite } from "./sprites.ts";
3
+
4
+ export type AnimationId = number;
5
+
6
+ export type AnimationDef = {
7
+ textureId: TextureId;
8
+ frameW: number;
9
+ frameH: number;
10
+ frameCount: number;
11
+ fps: number;
12
+ loop: boolean;
13
+ };
14
+
15
+ export type AnimationState = {
16
+ defId: AnimationId;
17
+ elapsed: number;
18
+ frame: number;
19
+ finished: boolean;
20
+ };
21
+
22
+ const registry = new Map<number, AnimationDef>();
23
+ let nextId = 1;
24
+
25
+ export function createAnimation(
26
+ textureId: TextureId,
27
+ frameW: number,
28
+ frameH: number,
29
+ frameCount: number,
30
+ fps: number,
31
+ options?: { loop?: boolean },
32
+ ): AnimationId {
33
+ const id = nextId++;
34
+ registry.set(id, {
35
+ textureId,
36
+ frameW,
37
+ frameH,
38
+ frameCount,
39
+ fps,
40
+ loop: options?.loop ?? true,
41
+ });
42
+ return id;
43
+ }
44
+
45
+ export function playAnimation(defId: AnimationId): AnimationState {
46
+ return { defId, elapsed: 0, frame: 0, finished: false };
47
+ }
48
+
49
+ export function updateAnimation(
50
+ anim: AnimationState,
51
+ dt: number,
52
+ ): AnimationState {
53
+ const def = registry.get(anim.defId);
54
+ if (!def || anim.finished) return { ...anim };
55
+
56
+ const elapsed = anim.elapsed + dt;
57
+ const rawFrame = Math.floor(elapsed * def.fps);
58
+
59
+ if (def.loop) {
60
+ return {
61
+ defId: anim.defId,
62
+ elapsed,
63
+ frame: rawFrame % def.frameCount,
64
+ finished: false,
65
+ };
66
+ }
67
+
68
+ const lastFrame = def.frameCount - 1;
69
+ if (rawFrame >= lastFrame) {
70
+ return {
71
+ defId: anim.defId,
72
+ elapsed,
73
+ frame: lastFrame,
74
+ finished: true,
75
+ };
76
+ }
77
+
78
+ return {
79
+ defId: anim.defId,
80
+ elapsed,
81
+ frame: rawFrame,
82
+ finished: false,
83
+ };
84
+ }
85
+
86
+ export function getAnimationUV(
87
+ anim: AnimationState,
88
+ ): { x: number; y: number; w: number; h: number } {
89
+ const def = registry.get(anim.defId);
90
+ if (!def) return { x: 0, y: 0, w: 1, h: 1 };
91
+
92
+ return {
93
+ x: anim.frame / def.frameCount,
94
+ y: 0,
95
+ w: 1 / def.frameCount,
96
+ h: 1,
97
+ };
98
+ }
99
+
100
+ export function drawAnimatedSprite(
101
+ anim: AnimationState,
102
+ x: number,
103
+ y: number,
104
+ w: number,
105
+ h: number,
106
+ options?: {
107
+ layer?: number;
108
+ tint?: { r: number; g: number; b: number; a: number };
109
+ },
110
+ ): void {
111
+ const def = registry.get(anim.defId);
112
+ if (!def) return;
113
+
114
+ drawSprite({
115
+ textureId: def.textureId,
116
+ x,
117
+ y,
118
+ w,
119
+ h,
120
+ layer: options?.layer,
121
+ uv: getAnimationUV(anim),
122
+ tint: options?.tint,
123
+ });
124
+ }
125
+
126
+ export function resetAnimation(anim: AnimationState): AnimationState {
127
+ return { ...anim, elapsed: 0, frame: 0, finished: false };
128
+ }
129
+
130
+ export function stopAnimation(anim: AnimationState): AnimationState {
131
+ return { ...anim, finished: true };
132
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, assert } from "../../runtime/testing/harness.ts";
2
+ import { loadSound, playSound, playMusic, stopSound, stopAll, setVolume } from "./audio.ts";
3
+
4
+ describe("audio", () => {
5
+ it("loadSound returns 0 in headless mode", () => {
6
+ assert.equal(loadSound("test.wav"), 0);
7
+ });
8
+
9
+ it("playSound does not throw in headless mode", () => {
10
+ playSound(0);
11
+ playSound(1, { volume: 0.5, loop: true });
12
+ });
13
+
14
+ it("playMusic returns 0 in headless mode", () => {
15
+ const id = playMusic("music.ogg", 0.8);
16
+ assert.equal(id, 0);
17
+ });
18
+
19
+ it("stopSound does not throw in headless mode", () => {
20
+ stopSound(0);
21
+ stopSound(42);
22
+ });
23
+
24
+ it("stopAll does not throw in headless mode", () => {
25
+ stopAll();
26
+ });
27
+
28
+ it("setVolume does not throw in headless mode", () => {
29
+ setVolume(0.5);
30
+ setVolume(0);
31
+ setVolume(1);
32
+ });
33
+ });
@@ -0,0 +1,70 @@
1
+ /** Opaque handle to a loaded sound. */
2
+ export type SoundId = number;
3
+
4
+ export type PlayOptions = {
5
+ volume?: number;
6
+ loop?: boolean;
7
+ };
8
+
9
+ const hasRenderOps =
10
+ typeof (globalThis as any).Deno !== "undefined" &&
11
+ typeof (globalThis as any).Deno?.core?.ops?.op_load_sound === "function";
12
+
13
+ /**
14
+ * Load a sound file (WAV, OGG, MP3). Returns a sound handle.
15
+ * Caches by path — loading the same path twice returns the same handle.
16
+ * Returns 0 in headless mode.
17
+ */
18
+ export function loadSound(path: string): SoundId {
19
+ if (!hasRenderOps) return 0;
20
+ return (globalThis as any).Deno.core.ops.op_load_sound(path);
21
+ }
22
+
23
+ /**
24
+ * Play a loaded sound.
25
+ * No-op in headless mode.
26
+ */
27
+ export function playSound(id: SoundId, options?: PlayOptions): void {
28
+ if (!hasRenderOps) return;
29
+ const volume = options?.volume ?? 1.0;
30
+ const loop_ = options?.loop ?? false;
31
+ (globalThis as any).Deno.core.ops.op_play_sound(id, volume, loop_);
32
+ }
33
+
34
+ /**
35
+ * Convenience: load and play a sound file as looping music.
36
+ * Returns the sound ID for later stopping.
37
+ * Returns 0 in headless mode.
38
+ */
39
+ export function playMusic(path: string, volume: number = 1.0): SoundId {
40
+ const id = loadSound(path);
41
+ playSound(id, { volume, loop: true });
42
+ return id;
43
+ }
44
+
45
+ /**
46
+ * Stop a specific sound.
47
+ * No-op in headless mode.
48
+ */
49
+ export function stopSound(id: SoundId): void {
50
+ if (!hasRenderOps) return;
51
+ (globalThis as any).Deno.core.ops.op_stop_sound(id);
52
+ }
53
+
54
+ /**
55
+ * Stop all playing sounds.
56
+ * No-op in headless mode.
57
+ */
58
+ export function stopAll(): void {
59
+ if (!hasRenderOps) return;
60
+ (globalThis as any).Deno.core.ops.op_stop_all_sounds();
61
+ }
62
+
63
+ /**
64
+ * Set the master volume (0.0 = mute, 1.0 = full).
65
+ * No-op in headless mode.
66
+ */
67
+ export function setVolume(volume: number): void {
68
+ if (!hasRenderOps) return;
69
+ (globalThis as any).Deno.core.ops.op_set_master_volume(volume);
70
+ }
@@ -0,0 +1,35 @@
1
+ import type { CameraState } from "./types.ts";
2
+
3
+ const hasRenderOps =
4
+ typeof (globalThis as any).Deno !== "undefined" &&
5
+ typeof (globalThis as any).Deno?.core?.ops?.op_set_camera === "function";
6
+
7
+ /**
8
+ * Set the camera position and zoom.
9
+ * No-op in headless mode.
10
+ */
11
+ export function setCamera(x: number, y: number, zoom: number = 1): void {
12
+ if (!hasRenderOps) return;
13
+ (globalThis as any).Deno.core.ops.op_set_camera(x, y, zoom);
14
+ }
15
+
16
+ /**
17
+ * Get the current camera state.
18
+ * Returns default values in headless mode.
19
+ */
20
+ export function getCamera(): CameraState {
21
+ if (!hasRenderOps) return { x: 0, y: 0, zoom: 1 };
22
+ const [x, y, zoom] = (globalThis as any).Deno.core.ops.op_get_camera();
23
+ return { x, y, zoom };
24
+ }
25
+
26
+ /**
27
+ * Center the camera on a target position.
28
+ */
29
+ export function followTarget(
30
+ targetX: number,
31
+ targetY: number,
32
+ zoom: number = 1,
33
+ ): void {
34
+ setCamera(targetX, targetY, zoom);
35
+ }
@@ -0,0 +1,56 @@
1
+ // Types
2
+ export type {
3
+ TextureId,
4
+ SpriteOptions,
5
+ CameraState,
6
+ MousePosition,
7
+ } from "./types.ts";
8
+
9
+ // Sprites
10
+ export { drawSprite, clearSprites } from "./sprites.ts";
11
+
12
+ // Camera
13
+ export { setCamera, getCamera, followTarget } from "./camera.ts";
14
+
15
+ // Input
16
+ export {
17
+ isKeyDown,
18
+ isKeyPressed,
19
+ getMousePosition,
20
+ getViewportSize,
21
+ screenToWorld,
22
+ getMouseWorldPosition,
23
+ } from "./input.ts";
24
+
25
+ // Textures
26
+ export { loadTexture, createSolidTexture } from "./texture.ts";
27
+
28
+ // Game loop
29
+ export { onFrame, getDeltaTime } from "./loop.ts";
30
+
31
+ // Tilemap
32
+ export type { TilemapId, TilemapOptions } from "./types.ts";
33
+ export { createTilemap, setTile, getTile, drawTilemap } from "./tilemap.ts";
34
+
35
+ // Lighting
36
+ export { setAmbientLight, addPointLight, clearLights } from "./lighting.ts";
37
+
38
+ // Text
39
+ export type { BitmapFont, TextOptions, TextMeasurement } from "./text.ts";
40
+ export { loadFont, getDefaultFont, measureText, drawText } from "./text.ts";
41
+
42
+ // Animation
43
+ export type { AnimationId, AnimationDef, AnimationState } from "./animation.ts";
44
+ export {
45
+ createAnimation,
46
+ playAnimation,
47
+ updateAnimation,
48
+ getAnimationUV,
49
+ drawAnimatedSprite,
50
+ resetAnimation,
51
+ stopAnimation,
52
+ } from "./animation.ts";
53
+
54
+ // Audio
55
+ export type { SoundId, PlayOptions } from "./audio.ts";
56
+ export { loadSound, playSound, playMusic, stopSound, stopAll, setVolume } from "./audio.ts";
@@ -0,0 +1,70 @@
1
+ import { describe, it } from "../testing/harness.ts";
2
+ import { screenToWorld } from "./input.ts";
3
+
4
+ describe("input", () => {
5
+ describe("screenToWorld", () => {
6
+ it("converts screen center to camera position (headless defaults)", () => {
7
+ // In headless mode: camera at (0, 0), zoom 1.0, viewport 800x600
8
+ // Screen center (400, 300) should map to camera position (0, 0)
9
+ const world = screenToWorld(400, 300);
10
+
11
+ // Allow small floating point error
12
+ const epsilon = 0.01;
13
+ if (Math.abs(world.x - 0) > epsilon || Math.abs(world.y - 0) > epsilon) {
14
+ throw new Error(`Expected ~(0, 0), got (${world.x}, ${world.y})`);
15
+ }
16
+ });
17
+
18
+ it("converts screen corners correctly (headless defaults)", () => {
19
+ // In headless mode: camera at (0, 0), zoom 1.0, viewport 800x600
20
+ // Top-left (0, 0) should map to (-400, -300)
21
+ // Bottom-right (800, 600) should map to (400, 300)
22
+ const topLeft = screenToWorld(0, 0);
23
+ const bottomRight = screenToWorld(800, 600);
24
+
25
+ const epsilon = 0.01;
26
+ if (Math.abs(topLeft.x - (-400)) > epsilon || Math.abs(topLeft.y - (-300)) > epsilon) {
27
+ throw new Error(`Top-left: expected ~(-400, -300), got (${topLeft.x}, ${topLeft.y})`);
28
+ }
29
+ if (Math.abs(bottomRight.x - 400) > epsilon || Math.abs(bottomRight.y - 300) > epsilon) {
30
+ throw new Error(`Bottom-right: expected ~(400, 300), got (${bottomRight.x}, ${bottomRight.y})`);
31
+ }
32
+ });
33
+
34
+ it("converts arbitrary screen position correctly", () => {
35
+ // In headless mode: camera at (0, 0), zoom 1.0, viewport 800x600
36
+ // Screen position (600, 450) should map to:
37
+ // normX = 600/800 = 0.75, normY = 450/600 = 0.75
38
+ // halfW = 400, halfH = 300
39
+ // worldX = -400 + 0.75 * 800 = -400 + 600 = 200
40
+ // worldY = -300 + 0.75 * 600 = -300 + 450 = 150
41
+ const world = screenToWorld(600, 450);
42
+
43
+ const epsilon = 0.01;
44
+ if (Math.abs(world.x - 200) > epsilon || Math.abs(world.y - 150) > epsilon) {
45
+ throw new Error(`Expected ~(200, 150), got (${world.x}, ${world.y})`);
46
+ }
47
+ });
48
+
49
+ it("handles edge cases", () => {
50
+ // Test negative coordinates (should still work mathematically)
51
+ const negativeScreen = screenToWorld(-100, -50);
52
+ // normX = -100/800 = -0.125, normY = -50/600 = -0.0833
53
+ // worldX = -400 + (-0.125) * 800 = -400 - 100 = -500
54
+ // worldY = -300 + (-0.0833) * 600 = -300 - 50 = -350
55
+ const epsilon = 0.01;
56
+ if (Math.abs(negativeScreen.x - (-500)) > epsilon || Math.abs(negativeScreen.y - (-350)) > epsilon) {
57
+ throw new Error(`Negative coords: expected ~(-500, -350), got (${negativeScreen.x}, ${negativeScreen.y})`);
58
+ }
59
+
60
+ // Test large coordinates (beyond viewport)
61
+ const largeScreen = screenToWorld(1600, 1200);
62
+ // normX = 1600/800 = 2, normY = 1200/600 = 2
63
+ // worldX = -400 + 2 * 800 = -400 + 1600 = 1200
64
+ // worldY = -300 + 2 * 600 = -300 + 1200 = 900
65
+ if (Math.abs(largeScreen.x - 1200) > epsilon || Math.abs(largeScreen.y - 900) > epsilon) {
66
+ throw new Error(`Large coords: expected ~(1200, 900), got (${largeScreen.x}, ${largeScreen.y})`);
67
+ }
68
+ });
69
+ });
70
+ });
@@ -0,0 +1,82 @@
1
+ import type { MousePosition } from "./types.ts";
2
+ import { getCamera } from "./camera.ts";
3
+
4
+ const hasRenderOps =
5
+ typeof (globalThis as any).Deno !== "undefined" &&
6
+ typeof (globalThis as any).Deno?.core?.ops?.op_is_key_down === "function";
7
+
8
+ const hasViewportOp =
9
+ typeof (globalThis as any).Deno !== "undefined" &&
10
+ typeof (globalThis as any).Deno?.core?.ops?.op_get_viewport_size === "function";
11
+
12
+ /**
13
+ * Check if a key is currently held down.
14
+ * Key names match web standards: "ArrowUp", "ArrowDown", "a", "Space", etc.
15
+ * Returns false in headless mode.
16
+ */
17
+ export function isKeyDown(key: string): boolean {
18
+ if (!hasRenderOps) return false;
19
+ return (globalThis as any).Deno.core.ops.op_is_key_down(key);
20
+ }
21
+
22
+ /**
23
+ * Check if a key was pressed this frame (just went down).
24
+ * Returns false in headless mode.
25
+ */
26
+ export function isKeyPressed(key: string): boolean {
27
+ if (!hasRenderOps) return false;
28
+ return (globalThis as any).Deno.core.ops.op_is_key_pressed(key);
29
+ }
30
+
31
+ /**
32
+ * Get the current mouse position in window/screen coordinates.
33
+ * Returns (0, 0) in headless mode.
34
+ */
35
+ export function getMousePosition(): MousePosition {
36
+ if (!hasRenderOps) return { x: 0, y: 0 };
37
+ const [x, y] = (globalThis as any).Deno.core.ops.op_get_mouse_position();
38
+ return { x, y };
39
+ }
40
+
41
+ /**
42
+ * Get the current viewport size in pixels.
43
+ * Returns [800, 600] in headless mode.
44
+ */
45
+ export function getViewportSize(): { width: number; height: number } {
46
+ if (!hasViewportOp) return { width: 800, height: 600 };
47
+ const [w, h] = (globalThis as any).Deno.core.ops.op_get_viewport_size();
48
+ return { width: w, height: h };
49
+ }
50
+
51
+ /**
52
+ * Convert screen/window coordinates to world coordinates using the current camera.
53
+ * Screen coordinates: (0, 0) = top-left, (viewport_width, viewport_height) = bottom-right
54
+ * World coordinates: transformed by camera position and zoom
55
+ */
56
+ export function screenToWorld(screenX: number, screenY: number): MousePosition {
57
+ const viewport = getViewportSize();
58
+ const camera = getCamera();
59
+
60
+ // Calculate the world space bounds visible on screen
61
+ const halfW = viewport.width / (2.0 * camera.zoom);
62
+ const halfH = viewport.height / (2.0 * camera.zoom);
63
+
64
+ // Normalize screen position to 0..1
65
+ const normX = screenX / viewport.width;
66
+ const normY = screenY / viewport.height;
67
+
68
+ // Map to world space
69
+ const worldX = (camera.x - halfW) + normX * (2 * halfW);
70
+ const worldY = (camera.y - halfH) + normY * (2 * halfH);
71
+
72
+ return { x: worldX, y: worldY };
73
+ }
74
+
75
+ /**
76
+ * Get the mouse position in world coordinates (accounting for camera transform).
77
+ * This is a convenience function that combines getMousePosition() and screenToWorld().
78
+ */
79
+ export function getMouseWorldPosition(): MousePosition {
80
+ const screenPos = getMousePosition();
81
+ return screenToWorld(screenPos.x, screenPos.y);
82
+ }
@@ -0,0 +1,38 @@
1
+ const hasRenderOps =
2
+ typeof (globalThis as any).Deno !== "undefined" &&
3
+ typeof (globalThis as any).Deno?.core?.ops?.op_set_ambient_light ===
4
+ "function";
5
+
6
+ /** Set the ambient light color (0-1 per channel). Default is (1,1,1) = full white. */
7
+ export function setAmbientLight(r: number, g: number, b: number): void {
8
+ if (!hasRenderOps) return;
9
+ (globalThis as any).Deno.core.ops.op_set_ambient_light(r, g, b);
10
+ }
11
+
12
+ /** Add a point light at world position (x,y) with the given radius, color, and intensity. */
13
+ export function addPointLight(
14
+ x: number,
15
+ y: number,
16
+ radius: number,
17
+ r: number = 1,
18
+ g: number = 1,
19
+ b: number = 1,
20
+ intensity: number = 1,
21
+ ): void {
22
+ if (!hasRenderOps) return;
23
+ (globalThis as any).Deno.core.ops.op_add_point_light(
24
+ x,
25
+ y,
26
+ radius,
27
+ r,
28
+ g,
29
+ b,
30
+ intensity,
31
+ );
32
+ }
33
+
34
+ /** Clear all point lights for this frame. Called automatically at frame start. */
35
+ export function clearLights(): void {
36
+ if (!hasRenderOps) return;
37
+ (globalThis as any).Deno.core.ops.op_clear_lights();
38
+ }
@@ -0,0 +1,21 @@
1
+ const hasRenderOps =
2
+ typeof (globalThis as any).Deno !== "undefined" &&
3
+ typeof (globalThis as any).Deno?.core?.ops?.op_get_delta_time === "function";
4
+
5
+ /**
6
+ * Register a callback to be called each frame.
7
+ * Only one callback can be active at a time (last one wins).
8
+ * No-op in headless mode.
9
+ */
10
+ export function onFrame(callback: () => void): void {
11
+ (globalThis as any).__frameCallback = callback;
12
+ }
13
+
14
+ /**
15
+ * Get the time elapsed since the last frame, in seconds.
16
+ * Returns 0 in headless mode.
17
+ */
18
+ export function getDeltaTime(): number {
19
+ if (!hasRenderOps) return 0;
20
+ return (globalThis as any).Deno.core.ops.op_get_delta_time();
21
+ }