@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.
- package/README.md +38 -0
- package/index.ts +19 -0
- package/package.json +53 -0
- package/src/agent/agent.test.ts +384 -0
- package/src/agent/describe.ts +72 -0
- package/src/agent/index.ts +20 -0
- package/src/agent/protocol.ts +125 -0
- package/src/agent/types.ts +73 -0
- package/src/pathfinding/astar.test.ts +208 -0
- package/src/pathfinding/astar.ts +193 -0
- package/src/pathfinding/index.ts +2 -0
- package/src/pathfinding/types.ts +21 -0
- package/src/physics/aabb.ts +54 -0
- package/src/physics/index.ts +2 -0
- package/src/rendering/animation.test.ts +119 -0
- package/src/rendering/animation.ts +132 -0
- package/src/rendering/audio.test.ts +33 -0
- package/src/rendering/audio.ts +70 -0
- package/src/rendering/camera.ts +35 -0
- package/src/rendering/index.ts +56 -0
- package/src/rendering/input.test.ts +70 -0
- package/src/rendering/input.ts +82 -0
- package/src/rendering/lighting.ts +38 -0
- package/src/rendering/loop.ts +21 -0
- package/src/rendering/sprites.ts +60 -0
- package/src/rendering/text.test.ts +91 -0
- package/src/rendering/text.ts +184 -0
- package/src/rendering/texture.ts +31 -0
- package/src/rendering/tilemap.ts +46 -0
- package/src/rendering/types.ts +54 -0
- package/src/rendering/validate.ts +132 -0
- package/src/state/error.test.ts +45 -0
- package/src/state/error.ts +20 -0
- package/src/state/index.ts +70 -0
- package/src/state/observe.test.ts +173 -0
- package/src/state/observe.ts +110 -0
- package/src/state/prng.test.ts +221 -0
- package/src/state/prng.ts +162 -0
- package/src/state/query.test.ts +208 -0
- package/src/state/query.ts +144 -0
- package/src/state/store.test.ts +211 -0
- package/src/state/store.ts +109 -0
- package/src/state/transaction.test.ts +235 -0
- package/src/state/transaction.ts +280 -0
- package/src/state/types.test.ts +33 -0
- package/src/state/types.ts +30 -0
- package/src/systems/index.ts +2 -0
- package/src/systems/system.test.ts +217 -0
- package/src/systems/system.ts +150 -0
- package/src/systems/types.ts +35 -0
- package/src/testing/harness.ts +271 -0
- package/src/testing/mock-renderer.test.ts +93 -0
- package/src/testing/mock-renderer.ts +178 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/primitives.test.ts +105 -0
- package/src/ui/primitives.ts +260 -0
- package/src/ui/types.ts +57 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentConfig,
|
|
3
|
+
AgentProtocol,
|
|
4
|
+
ActionInfo,
|
|
5
|
+
ActionResult,
|
|
6
|
+
SimulateResult,
|
|
7
|
+
SnapshotData,
|
|
8
|
+
DescribeOptions,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
import { defaultDescribe } from "./describe.ts";
|
|
11
|
+
import { get } from "../state/query.ts";
|
|
12
|
+
|
|
13
|
+
declare const globalThis: { __arcaneAgent?: AgentProtocol<unknown> };
|
|
14
|
+
|
|
15
|
+
/** Deep clone a value via JSON round-trip */
|
|
16
|
+
function deepClone<T>(value: T): T {
|
|
17
|
+
return JSON.parse(JSON.stringify(value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Register an agent protocol, installing it on globalThis.__arcaneAgent */
|
|
21
|
+
export function registerAgent<S>(config: AgentConfig<S>): AgentProtocol<S> {
|
|
22
|
+
const getState = "store" in config
|
|
23
|
+
? () => config.store.getState() as S
|
|
24
|
+
: config.getState;
|
|
25
|
+
|
|
26
|
+
const setState = "store" in config
|
|
27
|
+
? (s: S) => config.store.replaceState(s)
|
|
28
|
+
: config.setState;
|
|
29
|
+
|
|
30
|
+
// Capture initial state for rewind
|
|
31
|
+
const initialSnapshot: SnapshotData<S> = {
|
|
32
|
+
state: deepClone(getState()),
|
|
33
|
+
timestamp: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const actions = config.actions ?? {};
|
|
37
|
+
const describeFn = config.describe ?? defaultDescribe;
|
|
38
|
+
|
|
39
|
+
const protocol: AgentProtocol<S> = {
|
|
40
|
+
name: config.name,
|
|
41
|
+
|
|
42
|
+
getState(): S {
|
|
43
|
+
return getState();
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
inspect(path: string): unknown {
|
|
47
|
+
return get(getState(), path);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
describe(options?: DescribeOptions): string {
|
|
51
|
+
return describeFn(getState(), options ?? {});
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
listActions(): readonly ActionInfo[] {
|
|
55
|
+
return Object.entries(actions).map(([name, action]) => ({
|
|
56
|
+
name,
|
|
57
|
+
description: action.description,
|
|
58
|
+
...(action.args ? { args: action.args } : {}),
|
|
59
|
+
}));
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
executeAction(name: string, argsJson?: string): ActionResult<S> {
|
|
63
|
+
const action = actions[name];
|
|
64
|
+
if (!action) {
|
|
65
|
+
return { ok: false, state: getState(), error: `Unknown action: ${name}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let args: Record<string, unknown> = {};
|
|
69
|
+
if (argsJson) {
|
|
70
|
+
try {
|
|
71
|
+
args = JSON.parse(argsJson);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { ok: false, state: getState(), error: `Invalid JSON args: ${(e as Error).message}` };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const newState = action.handler(getState(), args);
|
|
79
|
+
setState(newState);
|
|
80
|
+
return { ok: true, state: newState };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { ok: false, state: getState(), error: `Action failed: ${(e as Error).message}` };
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
simulate(name: string, argsJson?: string): SimulateResult<S> {
|
|
87
|
+
const action = actions[name];
|
|
88
|
+
if (!action) {
|
|
89
|
+
return { ok: false, state: getState(), error: `Unknown action: ${name}` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let args: Record<string, unknown> = {};
|
|
93
|
+
if (argsJson) {
|
|
94
|
+
try {
|
|
95
|
+
args = JSON.parse(argsJson);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, state: getState(), error: `Invalid JSON args: ${(e as Error).message}` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const cloned = deepClone(getState());
|
|
103
|
+
const newState = action.handler(cloned, args);
|
|
104
|
+
return { ok: true, state: newState };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ok: false, state: getState(), error: `Simulation failed: ${(e as Error).message}` };
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
rewind(): S {
|
|
111
|
+
setState(deepClone(initialSnapshot.state));
|
|
112
|
+
return deepClone(initialSnapshot.state);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
captureSnapshot(): SnapshotData<S> {
|
|
116
|
+
return {
|
|
117
|
+
state: deepClone(getState()),
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
(globalThis as any).__arcaneAgent = protocol;
|
|
124
|
+
return protocol;
|
|
125
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { GameStore } from "../state/store.ts";
|
|
2
|
+
|
|
3
|
+
/** Verbosity levels for describe() output */
|
|
4
|
+
export type Verbosity = "minimal" | "normal" | "detailed";
|
|
5
|
+
|
|
6
|
+
/** Options passed to describe() */
|
|
7
|
+
export type DescribeOptions = Readonly<{
|
|
8
|
+
verbosity?: Verbosity;
|
|
9
|
+
path?: string;
|
|
10
|
+
}>;
|
|
11
|
+
|
|
12
|
+
/** Information about a registered action */
|
|
13
|
+
export type ActionInfo = Readonly<{
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
args?: readonly ArgInfo[];
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
/** Describes a single argument to an action */
|
|
20
|
+
export type ArgInfo = Readonly<{
|
|
21
|
+
name: string;
|
|
22
|
+
type: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
/** Result of executing an action */
|
|
27
|
+
export type ActionResult<S> = Readonly<{
|
|
28
|
+
ok: boolean;
|
|
29
|
+
state: S;
|
|
30
|
+
error?: string;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
/** Result of simulating an action (state not committed) */
|
|
34
|
+
export type SimulateResult<S> = Readonly<{
|
|
35
|
+
ok: boolean;
|
|
36
|
+
state: S;
|
|
37
|
+
error?: string;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
/** A captured state snapshot for rewind */
|
|
41
|
+
export type SnapshotData<S> = Readonly<{
|
|
42
|
+
state: S;
|
|
43
|
+
timestamp: number;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
/** An action handler: takes current state and parsed args, returns new state */
|
|
47
|
+
export type ActionHandler<S> = (state: S, args: Record<string, unknown>) => S;
|
|
48
|
+
|
|
49
|
+
/** Custom describe function */
|
|
50
|
+
export type DescribeFn<S> = (state: S, options: DescribeOptions) => string;
|
|
51
|
+
|
|
52
|
+
/** Agent configuration — either store-backed or get/setState-backed */
|
|
53
|
+
export type AgentConfig<S> = {
|
|
54
|
+
name: string;
|
|
55
|
+
actions?: Record<string, { handler: ActionHandler<S>; description: string; args?: ArgInfo[] }>;
|
|
56
|
+
describe?: DescribeFn<S>;
|
|
57
|
+
} & (
|
|
58
|
+
| { store: GameStore<S> }
|
|
59
|
+
| { getState: () => S; setState: (s: S) => void }
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/** The protocol object installed on globalThis */
|
|
63
|
+
export type AgentProtocol<S> = Readonly<{
|
|
64
|
+
name: string;
|
|
65
|
+
getState: () => S;
|
|
66
|
+
inspect: (path: string) => unknown;
|
|
67
|
+
describe: (options?: DescribeOptions) => string;
|
|
68
|
+
listActions: () => readonly ActionInfo[];
|
|
69
|
+
executeAction: (name: string, argsJson?: string) => ActionResult<S>;
|
|
70
|
+
simulate: (name: string, argsJson?: string) => SimulateResult<S>;
|
|
71
|
+
rewind: () => S;
|
|
72
|
+
captureSnapshot: () => SnapshotData<S>;
|
|
73
|
+
}>;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, assert } from "../testing/harness.ts";
|
|
2
|
+
import { findPath } from "./astar.ts";
|
|
3
|
+
import type { PathGrid } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Create a grid from string rows: '#' = wall, '.' = walkable */
|
|
6
|
+
function gridFromStrings(rows: string[]): PathGrid {
|
|
7
|
+
const height = rows.length;
|
|
8
|
+
const width = rows[0].length;
|
|
9
|
+
return {
|
|
10
|
+
width,
|
|
11
|
+
height,
|
|
12
|
+
isWalkable: (x, y) => {
|
|
13
|
+
if (x < 0 || x >= width || y < 0 || y >= height) return false;
|
|
14
|
+
return rows[y][x] !== "#";
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("pathfinding", () => {
|
|
20
|
+
it("start equals goal returns trivial path", () => {
|
|
21
|
+
const grid = gridFromStrings(["..."]);
|
|
22
|
+
const result = findPath(grid, { x: 1, y: 0 }, { x: 1, y: 0 });
|
|
23
|
+
assert.equal(result.found, true);
|
|
24
|
+
assert.equal(result.path.length, 1);
|
|
25
|
+
assert.deepEqual(result.path[0], { x: 1, y: 0 });
|
|
26
|
+
assert.equal(result.cost, 0);
|
|
27
|
+
assert.equal(result.explored, 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("straight line path (no obstacles)", () => {
|
|
31
|
+
const grid = gridFromStrings([".....", ".....", "....."]);
|
|
32
|
+
const result = findPath(grid, { x: 0, y: 1 }, { x: 4, y: 1 });
|
|
33
|
+
assert.equal(result.found, true);
|
|
34
|
+
assert.equal(result.path.length, 5);
|
|
35
|
+
assert.deepEqual(result.path[0], { x: 0, y: 1 });
|
|
36
|
+
assert.deepEqual(result.path[4], { x: 4, y: 1 });
|
|
37
|
+
assert.equal(result.cost, 4);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("path around single obstacle", () => {
|
|
41
|
+
const grid = gridFromStrings([
|
|
42
|
+
".....",
|
|
43
|
+
"..#..",
|
|
44
|
+
".....",
|
|
45
|
+
]);
|
|
46
|
+
const result = findPath(grid, { x: 1, y: 1 }, { x: 3, y: 1 });
|
|
47
|
+
assert.equal(result.found, true);
|
|
48
|
+
// Must go around the wall — path length > 3
|
|
49
|
+
assert.ok(result.path.length > 3);
|
|
50
|
+
assert.deepEqual(result.path[0], { x: 1, y: 1 });
|
|
51
|
+
assert.deepEqual(result.path[result.path.length - 1], { x: 3, y: 1 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("no path found (completely blocked)", () => {
|
|
55
|
+
const grid = gridFromStrings([
|
|
56
|
+
"..#..",
|
|
57
|
+
"..#..",
|
|
58
|
+
"..#..",
|
|
59
|
+
]);
|
|
60
|
+
const result = findPath(grid, { x: 0, y: 1 }, { x: 4, y: 1 });
|
|
61
|
+
assert.equal(result.found, false);
|
|
62
|
+
assert.equal(result.path.length, 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("diagonal movement finds shorter path", () => {
|
|
66
|
+
const grid = gridFromStrings([
|
|
67
|
+
"...",
|
|
68
|
+
"...",
|
|
69
|
+
"...",
|
|
70
|
+
]);
|
|
71
|
+
const cardinal = findPath(grid, { x: 0, y: 0 }, { x: 2, y: 2 });
|
|
72
|
+
const diag = findPath(grid, { x: 0, y: 0 }, { x: 2, y: 2 }, { diagonal: true });
|
|
73
|
+
assert.equal(cardinal.found, true);
|
|
74
|
+
assert.equal(diag.found, true);
|
|
75
|
+
assert.ok(diag.cost < cardinal.cost);
|
|
76
|
+
assert.ok(diag.path.length <= cardinal.path.length);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("diagonal disabled doesn't use diagonals", () => {
|
|
80
|
+
const grid = gridFromStrings([
|
|
81
|
+
"...",
|
|
82
|
+
"...",
|
|
83
|
+
"...",
|
|
84
|
+
]);
|
|
85
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 2, y: 2 }, { diagonal: false });
|
|
86
|
+
assert.equal(result.found, true);
|
|
87
|
+
// Without diagonals, each step moves exactly 1 in x or y
|
|
88
|
+
for (let i = 1; i < result.path.length; i++) {
|
|
89
|
+
const dx = Math.abs(result.path[i].x - result.path[i - 1].x);
|
|
90
|
+
const dy = Math.abs(result.path[i].y - result.path[i - 1].y);
|
|
91
|
+
assert.equal(dx + dy, 1);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("custom cost function (weighted cells)", () => {
|
|
96
|
+
const grid: PathGrid = {
|
|
97
|
+
width: 5,
|
|
98
|
+
height: 1,
|
|
99
|
+
isWalkable: () => true,
|
|
100
|
+
cost: (x, _y) => (x === 2 ? 10 : 1),
|
|
101
|
+
};
|
|
102
|
+
// With high cost at x=2, the 1D path must still go through it
|
|
103
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 4, y: 0 });
|
|
104
|
+
assert.equal(result.found, true);
|
|
105
|
+
assert.equal(result.cost, 4 + 9); // cells 1,2,3,4 — cost(2)=10, others=1 → 1+10+1+1=13
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("max iterations limit reached", () => {
|
|
109
|
+
const grid = gridFromStrings([
|
|
110
|
+
"...........",
|
|
111
|
+
"...........",
|
|
112
|
+
"...........",
|
|
113
|
+
"...........",
|
|
114
|
+
"...........",
|
|
115
|
+
]);
|
|
116
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 10, y: 4 }, { maxIterations: 5 });
|
|
117
|
+
assert.equal(result.found, false);
|
|
118
|
+
assert.equal(result.explored, 5);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("manhattan heuristic gives correct result", () => {
|
|
122
|
+
const grid = gridFromStrings([".....", ".....", "....."]);
|
|
123
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 4, y: 2 }, { heuristic: "manhattan" });
|
|
124
|
+
assert.equal(result.found, true);
|
|
125
|
+
assert.equal(result.cost, 6);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("euclidean heuristic gives correct result", () => {
|
|
129
|
+
const grid = gridFromStrings([".....", ".....", "....."]);
|
|
130
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 4, y: 2 }, {
|
|
131
|
+
heuristic: "euclidean",
|
|
132
|
+
diagonal: true,
|
|
133
|
+
});
|
|
134
|
+
assert.equal(result.found, true);
|
|
135
|
+
// Diagonal path should be shorter than 6
|
|
136
|
+
assert.ok(result.cost < 6);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("chebyshev heuristic gives correct result", () => {
|
|
140
|
+
const grid = gridFromStrings([".....", ".....", "....."]);
|
|
141
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 4, y: 2 }, {
|
|
142
|
+
heuristic: "chebyshev",
|
|
143
|
+
diagonal: true,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(result.found, true);
|
|
146
|
+
assert.ok(result.cost < 6);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("large grid (50x50) with obstacles", () => {
|
|
150
|
+
const row = ".".repeat(50);
|
|
151
|
+
const rows: string[] = [];
|
|
152
|
+
for (let y = 0; y < 50; y++) {
|
|
153
|
+
rows.push(row);
|
|
154
|
+
}
|
|
155
|
+
// Add a wall across the middle with a gap
|
|
156
|
+
const wallRow = "#".repeat(25) + "." + "#".repeat(24);
|
|
157
|
+
rows[25] = wallRow;
|
|
158
|
+
|
|
159
|
+
const grid = gridFromStrings(rows);
|
|
160
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 49, y: 49 });
|
|
161
|
+
assert.equal(result.found, true);
|
|
162
|
+
assert.ok(result.path.length > 0);
|
|
163
|
+
assert.deepEqual(result.path[0], { x: 0, y: 0 });
|
|
164
|
+
assert.deepEqual(result.path[result.path.length - 1], { x: 49, y: 49 });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("path along corridor", () => {
|
|
168
|
+
const grid = gridFromStrings([
|
|
169
|
+
"#####",
|
|
170
|
+
"#...#",
|
|
171
|
+
"###.#",
|
|
172
|
+
"#...#",
|
|
173
|
+
"#####",
|
|
174
|
+
]);
|
|
175
|
+
const result = findPath(grid, { x: 1, y: 1 }, { x: 1, y: 3 });
|
|
176
|
+
assert.equal(result.found, true);
|
|
177
|
+
// Must go right, down through gap, then left
|
|
178
|
+
assert.deepEqual(result.path[0], { x: 1, y: 1 });
|
|
179
|
+
assert.deepEqual(result.path[result.path.length - 1], { x: 1, y: 3 });
|
|
180
|
+
assert.ok(result.path.length >= 5);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("multiple valid paths (finds one)", () => {
|
|
184
|
+
const grid = gridFromStrings([
|
|
185
|
+
"...",
|
|
186
|
+
".#.",
|
|
187
|
+
"...",
|
|
188
|
+
]);
|
|
189
|
+
const result = findPath(grid, { x: 0, y: 0 }, { x: 2, y: 2 });
|
|
190
|
+
assert.equal(result.found, true);
|
|
191
|
+
assert.deepEqual(result.path[0], { x: 0, y: 0 });
|
|
192
|
+
assert.deepEqual(result.path[result.path.length - 1], { x: 2, y: 2 });
|
|
193
|
+
// Cardinal-only path around obstacle is length 5
|
|
194
|
+
assert.equal(result.path.length, 5);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("out of bounds handled gracefully", () => {
|
|
198
|
+
const grid = gridFromStrings(["..."]);
|
|
199
|
+
const r1 = findPath(grid, { x: -1, y: 0 }, { x: 2, y: 0 });
|
|
200
|
+
assert.equal(r1.found, false);
|
|
201
|
+
|
|
202
|
+
const r2 = findPath(grid, { x: 0, y: 0 }, { x: 5, y: 0 });
|
|
203
|
+
assert.equal(r2.found, false);
|
|
204
|
+
|
|
205
|
+
const r3 = findPath(grid, { x: 0, y: -1 }, { x: 2, y: 0 });
|
|
206
|
+
assert.equal(r3.found, false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { Vec2 } from "../state/types.ts";
|
|
2
|
+
import type { PathGrid, PathOptions, PathResult } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
// Cardinal directions: right, down, left, up
|
|
5
|
+
const CARDINAL_DX = [1, 0, -1, 0];
|
|
6
|
+
const CARDINAL_DY = [0, 1, 0, -1];
|
|
7
|
+
|
|
8
|
+
// Diagonal directions (appended after cardinal)
|
|
9
|
+
const DIAGONAL_DX = [1, 1, -1, -1];
|
|
10
|
+
const DIAGONAL_DY = [1, -1, 1, -1];
|
|
11
|
+
|
|
12
|
+
const SQRT2 = Math.SQRT2;
|
|
13
|
+
|
|
14
|
+
// --- Binary min-heap ---
|
|
15
|
+
|
|
16
|
+
type HeapEntry = { index: number; f: number };
|
|
17
|
+
|
|
18
|
+
function heapPush(heap: HeapEntry[], entry: HeapEntry): void {
|
|
19
|
+
heap.push(entry);
|
|
20
|
+
let i = heap.length - 1;
|
|
21
|
+
while (i > 0) {
|
|
22
|
+
const parent = (i - 1) >> 1;
|
|
23
|
+
if (heap[parent].f <= heap[i].f) break;
|
|
24
|
+
const tmp = heap[parent];
|
|
25
|
+
heap[parent] = heap[i];
|
|
26
|
+
heap[i] = tmp;
|
|
27
|
+
i = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function heapPop(heap: HeapEntry[]): HeapEntry | undefined {
|
|
32
|
+
const len = heap.length;
|
|
33
|
+
if (len === 0) return undefined;
|
|
34
|
+
const top = heap[0];
|
|
35
|
+
const last = heap[len - 1];
|
|
36
|
+
heap.length = len - 1;
|
|
37
|
+
if (len > 1) {
|
|
38
|
+
heap[0] = last;
|
|
39
|
+
let i = 0;
|
|
40
|
+
const half = heap.length >> 1;
|
|
41
|
+
while (i < half) {
|
|
42
|
+
let child = (i << 1) + 1;
|
|
43
|
+
const right = child + 1;
|
|
44
|
+
if (right < heap.length && heap[right].f < heap[child].f) {
|
|
45
|
+
child = right;
|
|
46
|
+
}
|
|
47
|
+
if (heap[child].f >= heap[i].f) break;
|
|
48
|
+
const tmp = heap[child];
|
|
49
|
+
heap[child] = heap[i];
|
|
50
|
+
heap[i] = tmp;
|
|
51
|
+
i = child;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return top;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Heuristics ---
|
|
58
|
+
|
|
59
|
+
function manhattan(dx: number, dy: number): number {
|
|
60
|
+
return Math.abs(dx) + Math.abs(dy);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function euclidean(dx: number, dy: number): number {
|
|
64
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function chebyshev(dx: number, dy: number): number {
|
|
68
|
+
return Math.max(Math.abs(dx), Math.abs(dy));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function selectHeuristic(name: string): (dx: number, dy: number) => number {
|
|
72
|
+
if (name === "euclidean") return euclidean;
|
|
73
|
+
if (name === "chebyshev") return chebyshev;
|
|
74
|
+
return manhattan;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- A* ---
|
|
78
|
+
|
|
79
|
+
export function findPath(
|
|
80
|
+
grid: PathGrid,
|
|
81
|
+
start: Vec2,
|
|
82
|
+
goal: Vec2,
|
|
83
|
+
options?: PathOptions,
|
|
84
|
+
): PathResult {
|
|
85
|
+
const diagonal = options?.diagonal ?? false;
|
|
86
|
+
const maxIterations = options?.maxIterations ?? 10000;
|
|
87
|
+
const heuristic = selectHeuristic(options?.heuristic ?? "manhattan");
|
|
88
|
+
|
|
89
|
+
const { width, height, isWalkable } = grid;
|
|
90
|
+
const getCost = grid.cost;
|
|
91
|
+
|
|
92
|
+
const sx = start.x | 0;
|
|
93
|
+
const sy = start.y | 0;
|
|
94
|
+
const gx = goal.x | 0;
|
|
95
|
+
const gy = goal.y | 0;
|
|
96
|
+
|
|
97
|
+
// Trivial case
|
|
98
|
+
if (sx === gx && sy === gy) {
|
|
99
|
+
return { found: true, path: [{ x: sx, y: sy }], cost: 0, explored: 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Out of bounds or unwalkable endpoints
|
|
103
|
+
if (
|
|
104
|
+
sx < 0 || sx >= width || sy < 0 || sy >= height ||
|
|
105
|
+
gx < 0 || gx >= width || gy < 0 || gy >= height ||
|
|
106
|
+
!isWalkable(sx, sy) || !isWalkable(gx, gy)
|
|
107
|
+
) {
|
|
108
|
+
return { found: false, path: [], cost: 0, explored: 0 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const size = width * height;
|
|
112
|
+
const gCost = new Float64Array(size);
|
|
113
|
+
const parentIdx = new Int32Array(size);
|
|
114
|
+
const closed = new Uint8Array(size);
|
|
115
|
+
|
|
116
|
+
// Initialize g-costs to infinity
|
|
117
|
+
gCost.fill(Infinity);
|
|
118
|
+
|
|
119
|
+
const startIdx = sy * width + sx;
|
|
120
|
+
const goalIdx = gy * width + gx;
|
|
121
|
+
gCost[startIdx] = 0;
|
|
122
|
+
parentIdx[startIdx] = -1;
|
|
123
|
+
|
|
124
|
+
const heap: HeapEntry[] = [];
|
|
125
|
+
heapPush(heap, { index: startIdx, f: heuristic(gx - sx, gy - sy) });
|
|
126
|
+
|
|
127
|
+
const numCardinal = 4;
|
|
128
|
+
const numDirs = diagonal ? 8 : 4;
|
|
129
|
+
|
|
130
|
+
let iterations = 0;
|
|
131
|
+
|
|
132
|
+
while (heap.length > 0) {
|
|
133
|
+
if (iterations >= maxIterations) {
|
|
134
|
+
return { found: false, path: [], cost: 0, explored: iterations };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const current = heapPop(heap)!;
|
|
138
|
+
const ci = current.index;
|
|
139
|
+
|
|
140
|
+
if (closed[ci]) continue;
|
|
141
|
+
closed[ci] = 1;
|
|
142
|
+
iterations++;
|
|
143
|
+
|
|
144
|
+
if (ci === goalIdx) {
|
|
145
|
+
// Reconstruct path
|
|
146
|
+
const path: Vec2[] = [];
|
|
147
|
+
let idx = goalIdx;
|
|
148
|
+
while (idx !== -1) {
|
|
149
|
+
path.push({ x: idx % width, y: (idx / width) | 0 });
|
|
150
|
+
idx = parentIdx[idx];
|
|
151
|
+
}
|
|
152
|
+
path.reverse();
|
|
153
|
+
return { found: true, path, cost: gCost[goalIdx], explored: iterations };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cx = ci % width;
|
|
157
|
+
const cy = (ci / width) | 0;
|
|
158
|
+
|
|
159
|
+
for (let d = 0; d < numDirs; d++) {
|
|
160
|
+
let nx: number, ny: number;
|
|
161
|
+
let isDiag: boolean;
|
|
162
|
+
if (d < numCardinal) {
|
|
163
|
+
nx = cx + CARDINAL_DX[d];
|
|
164
|
+
ny = cy + CARDINAL_DY[d];
|
|
165
|
+
isDiag = false;
|
|
166
|
+
} else {
|
|
167
|
+
nx = cx + DIAGONAL_DX[d - numCardinal];
|
|
168
|
+
ny = cy + DIAGONAL_DY[d - numCardinal];
|
|
169
|
+
isDiag = true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
|
173
|
+
if (!isWalkable(nx, ny)) continue;
|
|
174
|
+
|
|
175
|
+
const ni = ny * width + nx;
|
|
176
|
+
if (closed[ni]) continue;
|
|
177
|
+
|
|
178
|
+
const moveCost = getCost
|
|
179
|
+
? getCost(nx, ny)
|
|
180
|
+
: (isDiag ? SQRT2 : 1);
|
|
181
|
+
|
|
182
|
+
const newG = gCost[ci] + moveCost;
|
|
183
|
+
if (newG < gCost[ni]) {
|
|
184
|
+
gCost[ni] = newG;
|
|
185
|
+
parentIdx[ni] = ci;
|
|
186
|
+
const h = heuristic(gx - nx, gy - ny);
|
|
187
|
+
heapPush(heap, { index: ni, f: newG + h });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { found: false, path: [], cost: 0, explored: iterations };
|
|
193
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Vec2 } from "../state/types.ts";
|
|
2
|
+
|
|
3
|
+
export type PathGrid = {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
isWalkable: (x: number, y: number) => boolean;
|
|
7
|
+
cost?: (x: number, y: number) => number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PathOptions = {
|
|
11
|
+
diagonal?: boolean;
|
|
12
|
+
maxIterations?: number;
|
|
13
|
+
heuristic?: "manhattan" | "euclidean" | "chebyshev";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PathResult = {
|
|
17
|
+
found: boolean;
|
|
18
|
+
path: Vec2[];
|
|
19
|
+
cost: number;
|
|
20
|
+
explored: number;
|
|
21
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Axis-Aligned Bounding Box */
|
|
2
|
+
export type AABB = {
|
|
3
|
+
x: number; // left edge
|
|
4
|
+
y: number; // top edge
|
|
5
|
+
w: number; // width
|
|
6
|
+
h: number; // height
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Check if two AABBs overlap */
|
|
10
|
+
export function aabbOverlap(a: AABB, b: AABB): boolean {
|
|
11
|
+
return a.x < b.x + b.w && a.x + a.w > b.x &&
|
|
12
|
+
a.y < b.y + b.h && a.y + a.h > b.y;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Check if a circle overlaps an AABB */
|
|
16
|
+
export function circleAABBOverlap(
|
|
17
|
+
cx: number, cy: number, radius: number,
|
|
18
|
+
box: AABB
|
|
19
|
+
): boolean {
|
|
20
|
+
const closestX = Math.max(box.x, Math.min(cx, box.x + box.w));
|
|
21
|
+
const closestY = Math.max(box.y, Math.min(cy, box.y + box.h));
|
|
22
|
+
const dx = cx - closestX;
|
|
23
|
+
const dy = cy - closestY;
|
|
24
|
+
return (dx * dx + dy * dy) <= (radius * radius);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get collision normal for circle vs AABB. Returns null if no collision. */
|
|
28
|
+
export function circleAABBResolve(
|
|
29
|
+
cx: number, cy: number, radius: number,
|
|
30
|
+
box: AABB
|
|
31
|
+
): { nx: number; ny: number } | null {
|
|
32
|
+
const closestX = Math.max(box.x, Math.min(cx, box.x + box.w));
|
|
33
|
+
const closestY = Math.max(box.y, Math.min(cy, box.y + box.h));
|
|
34
|
+
const dx = cx - closestX;
|
|
35
|
+
const dy = cy - closestY;
|
|
36
|
+
const distSq = dx * dx + dy * dy;
|
|
37
|
+
|
|
38
|
+
if (distSq > radius * radius) return null;
|
|
39
|
+
if (distSq === 0) {
|
|
40
|
+
// Circle center is inside the box — push out along shortest axis
|
|
41
|
+
const midX = box.x + box.w / 2;
|
|
42
|
+
const midY = box.y + box.h / 2;
|
|
43
|
+
const fromCenterX = cx - midX;
|
|
44
|
+
const fromCenterY = cy - midY;
|
|
45
|
+
if (Math.abs(fromCenterX / box.w) > Math.abs(fromCenterY / box.h)) {
|
|
46
|
+
return { nx: fromCenterX > 0 ? 1 : -1, ny: 0 };
|
|
47
|
+
} else {
|
|
48
|
+
return { nx: 0, ny: fromCenterY > 0 ? 1 : -1 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const dist = Math.sqrt(distSq);
|
|
53
|
+
return { nx: dx / dist, ny: dy / dist };
|
|
54
|
+
}
|