import {
Application,
Container,
Graphics,
Sprite,
Text,
TextStyle,
Texture,
} from "pixi.js";
import UPNG from "upng-js";
const stockMaps: Record<string, string> = import.meta.glob("./maps/map*.png", {
eager: true,
import: "default",
query: "?url",
}) as Record<string, string>;
const TILE_SIZE = 32;
const MAP_OFFSET_X = 16;
const MAP_OFFSET_Y = 16;
const PATH_COLOR = 0x00ffff;
const PATH_LINE_W = 2;
const PATH_HEAD_SIZE = 8;
const SMOOTH_COLOR = 0xff8800;
const THETA_COLOR = 0x2255dd;
const FUNNEL_COLOR = 0x00ff88;
const THETA_FUNNEL_COLOR = 0xaa44ff;
const START_COLOR = 0xffff00;
const GOAL_COLOR = 0xff8800;
const MARKER_R = 8;
const UNIT_RADIUS = TILE_SIZE * 0.3;
const enum Tile {
Ground = 0,
Wall = 1,
}
interface World {
cols: number;
rows: number;
tiles: Uint8Array;
}
// Bidirectional tile↔color mapping for PNG I/O.
// Export: tileToRgb → RGBA buffer → UPNG quantises to indexed PNG.
// Import: UPNG → RGBA buffer → rgbToTile (nearest RGB distance).
const TILE_COLORS: { tile: Tile; r: number; g: number; b: number }[] = [
{ tile: Tile.Ground, r: 0, g: 0, b: 0 },
{ tile: Tile.Wall, r: 255, g: 255, b: 255 },
];
function tileToRgb(tile: Tile): { r: number; g: number; b: number } {
for (const entry of TILE_COLORS) {
if (entry.tile === tile) return entry;
}
return TILE_COLORS[0];
}
function rgbToTile(r: number, g: number, b: number): Tile {
let best = TILE_COLORS[0];
let bestDist = Infinity;
for (const entry of TILE_COLORS) {
const d = (r - entry.r) ** 2 + (g - entry.g) ** 2 + (b - entry.b) ** 2;
if (d < bestDist) {
bestDist = d;
best = entry;
}
}
return best.tile;
}
type Coord = { col: number; row: number };
type Path = Coord[];
function makeWorld(cols: number, rows: number): World {
return { cols, rows, tiles: new Uint8Array(cols * rows) };
}
function getTile(world: World, col: number, row: number): Tile {
return world.tiles[row * world.cols + col] as Tile;
}
function tileCenter(col: number, row: number): { x: number; y: number } {
return {
x: MAP_OFFSET_X + col * TILE_SIZE + TILE_SIZE / 2,
y: MAP_OFFSET_Y + row * TILE_SIZE + TILE_SIZE / 2,
};
}
function coordToIndex(world: World, col: number, row: number): number {
return row * world.cols + col;
}
function inBounds(world: World, col: number, row: number): boolean {
return col >= 0 && col < world.cols && row >= 0 && row < world.rows;
}
interface HeapNode {
index: number;
score: number;
}
class MinHeap {
private data: HeapNode[] = [];
push(node: HeapNode): void {
this.data.push(node);
this.bubbleUp(this.data.length - 1);
}
pop(): HeapNode | undefined {
if (this.data.length === 0) return undefined;
const top = this.data[0];
const last = this.data.pop()!;
if (this.data.length > 0) {
this.data[0] = last;
this.sinkDown(0);
}
return top;
}
get size(): number {
return this.data.length;
}
private bubbleUp(i: number): void {
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.data[parent].score <= this.data[i].score) break;
[this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
i = parent;
}
}
private sinkDown(i: number): void {
const n = this.data.length;
while (true) {
let smallest = i;
const l = 2 * i + 1;
const r = 2 * i + 2;
if (l < n && this.data[l].score < this.data[smallest].score) smallest = l;
if (r < n && this.data[r].score < this.data[smallest].score) smallest = r;
if (smallest === i) break;
[this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
i = smallest;
}
}
}
const NEIGHBORS = [
{ dc: 0, dr: -1 },
{ dc: 0, dr: 1 },
{ dc: -1, dr: 0 },
{ dc: 1, dr: 0 },
];
function findPath(world: World, start: Coord, goal: Coord): Path | null {
if (getTile(world, start.col, start.row) === Tile.Wall) return null;
if (getTile(world, goal.col, goal.row) === Tile.Wall) return null;
const startIdx = coordToIndex(world, start.col, start.row);
const goalIdx = coordToIndex(world, goal.col, goal.row);
const gScore = new Float32Array(world.cols * world.rows).fill(Infinity);
const cameFrom = new Int32Array(world.cols * world.rows).fill(-1);
gScore[startIdx] = 0;
const open = new MinHeap();
const heuristic = (col: number, row: number) =>
Math.abs(col - goal.col) + Math.abs(row - goal.row);
open.push({ index: startIdx, score: heuristic(start.col, start.row) });
const inOpen = new Uint8Array(world.cols * world.rows);
inOpen[startIdx] = 1;
while (open.size > 0) {
const { index: current } = open.pop()!;
inOpen[current] = 0;
if (current === goalIdx) {
const path: Path = [];
let idx = goalIdx;
while (idx !== -1) {
path.push({ col: idx % world.cols, row: Math.floor(idx / world.cols) });
idx = cameFrom[idx];
}
return path.reverse();
}
const col = current % world.cols;
const row = Math.floor(current / world.cols);
for (const { dc, dr } of NEIGHBORS) {
const nc = col + dc;
const nr = row + dr;
if (!inBounds(world, nc, nr)) continue;
if (getTile(world, nc, nr) === Tile.Wall) continue;
const neighborIdx = coordToIndex(world, nc, nr);
const tentativeG = gScore[current] + 1;
if (tentativeG < gScore[neighborIdx]) {
cameFrom[neighborIdx] = current;
gScore[neighborIdx] = tentativeG;
if (!inOpen[neighborIdx]) {
open.push({
index: neighborIdx,
score: tentativeG + heuristic(nc, nr),
});
inOpen[neighborIdx] = 1;
}
}
}
}
return null;
}
function findPathTheta(world: World, start: Coord, goal: Coord): Path | null {
if (getTile(world, start.col, start.row) === Tile.Wall) return null;
if (getTile(world, goal.col, goal.row) === Tile.Wall) return null;
const startIdx = coordToIndex(world, start.col, start.row);
const goalIdx = coordToIndex(world, goal.col, goal.row);
const gScore = new Float32Array(world.cols * world.rows).fill(Infinity);
const cameFrom = new Int32Array(world.cols * world.rows).fill(-1);
gScore[startIdx] = 0;
const open = new MinHeap();
const heuristic = (col: number, row: number) => {
const dc = col - goal.col;
const dr = row - goal.row;
return Math.sqrt(dc * dc + dr * dr);
};
open.push({ index: startIdx, score: heuristic(start.col, start.row) });
// Use closed set + lazy deletion: allow duplicate heap entries, skip already-closed nodes.
const closed = new Uint8Array(world.cols * world.rows);
while (open.size > 0) {
const { index: current } = open.pop()!;
if (closed[current]) continue;
closed[current] = 1;
if (current === goalIdx) {
const path: Path = [];
let idx = goalIdx;
while (idx !== -1) {
path.push({ col: idx % world.cols, row: Math.floor(idx / world.cols) });
idx = cameFrom[idx];
}
return path.reverse();
}
const col = current % world.cols;
const row = Math.floor(current / world.cols);
const parentIdx = cameFrom[current];
for (const { dc, dr } of NEIGHBORS) {
const nc = col + dc;
const nr = row + dr;
if (!inBounds(world, nc, nr)) continue;
if (getTile(world, nc, nr) === Tile.Wall) continue;
const neighborIdx = coordToIndex(world, nc, nr);
if (closed[neighborIdx]) continue;
// Theta*: if LOS exists from parent to neighbor, shortcut through parent.
let tentativeG: number;
let newParent: number;
if (parentIdx !== -1) {
const pCol = parentIdx % world.cols;
const pRow = Math.floor(parentIdx / world.cols);
if (
lineOfSight(world, { col: pCol, row: pRow }, { col: nc, row: nr })
) {
const dpc = nc - pCol;
const dpr = nr - pRow;
tentativeG = gScore[parentIdx] + Math.sqrt(dpc * dpc + dpr * dpr);
newParent = parentIdx;
} else {
tentativeG = gScore[current] + Math.sqrt(dc * dc + dr * dr);
newParent = current;
}
} else {
tentativeG = gScore[current] + Math.sqrt(dc * dc + dr * dr);
newParent = current;
}
if (tentativeG < gScore[neighborIdx]) {
cameFrom[neighborIdx] = newParent;
gScore[neighborIdx] = tentativeG;
open.push({
index: neighborIdx,
score: tentativeG + heuristic(nc, nr),
});
}
}
}
return null;
}
function lineOfSight(world: World, a: Coord, b: Coord): boolean {
const x0 = a.col + 0.5;
const y0 = a.row + 0.5;
const x1 = b.col + 0.5;
const y1 = b.row + 0.5;
const dx = x1 - x0;
const dy = y1 - y0;
const stepX = dx > 0 ? 1 : -1;
const stepY = dy > 0 ? 1 : -1;
const tDeltaX = dx !== 0 ? Math.abs(1 / dx) : Infinity;
const tDeltaY = dy !== 0 ? Math.abs(1 / dy) : Infinity;
let tMaxX =
dx !== 0
? Math.abs((Math.floor(x0) + (dx > 0 ? 1 : 0) - x0) / dx)
: Infinity;
let tMaxY =
dy !== 0
? Math.abs((Math.floor(y0) + (dy > 0 ? 1 : 0) - y0) / dy)
: Infinity;
let col = Math.floor(x0);
let row = Math.floor(y0);
const endCol = Math.floor(x1);
const endRow = Math.floor(y1);
while (true) {
if (getTile(world, col, row) === Tile.Wall) return false;
if (col === endCol && row === endRow) break;
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
col += stepX;
} else if (tMaxY < tMaxX) {
tMaxY += tDeltaY;
row += stepY;
} else {
// Ray hits a grid corner exactly — step both axes and check the diagonal tile.
tMaxX += tDeltaX;
tMaxY += tDeltaY;
if (
getTile(world, col + stepX, row) === Tile.Wall ||
getTile(world, col, row + stepY) === Tile.Wall
)
return false;
col += stepX;
row += stepY;
}
}
return true;
}
function smoothPath(world: World, path: Path): Path {
if (path.length <= 2) return path;
const result: Path = [path[0]];
let current = 0;
while (current < path.length - 1) {
let farthest = current + 1;
for (let i = path.length - 1; i > current + 1; i--) {
if (lineOfSight(world, path[current], path[i])) {
farthest = i;
break;
}
}
result.push(path[farthest]);
current = farthest;
}
return result;
}
function pathLength(path: Path): number {
let len = 0;
for (let i = 1; i < path.length; i++) {
const dc = path[i].col - path[i - 1].col;
const dr = path[i].row - path[i - 1].row;
len += Math.sqrt(dc * dc + dr * dr);
}
return len;
}
function pathToTileSequence(path: Path): Coord[] {
if (path.length === 0) return [];
const tiles: Coord[] = [];
const seen = new Set<number>();
const add = (col: number, row: number) => {
const key = row * 10000 + col;
if (!seen.has(key)) {
seen.add(key);
tiles.push({ col, row });
}
};
for (let s = 0; s < path.length - 1; s++) {
const a = path[s];
const b = path[s + 1];
const x0 = a.col + 0.5;
const y0 = a.row + 0.5;
const x1 = b.col + 0.5;
const y1 = b.row + 0.5;
const dx = x1 - x0;
const dy = y1 - y0;
const stepX = dx > 0 ? 1 : -1;
const stepY = dy > 0 ? 1 : -1;
const tDeltaX = dx !== 0 ? Math.abs(1 / dx) : Infinity;
const tDeltaY = dy !== 0 ? Math.abs(1 / dy) : Infinity;
let tMaxX =
dx !== 0
? Math.abs((Math.floor(x0) + (dx > 0 ? 1 : 0) - x0) / dx)
: Infinity;
let tMaxY =
dy !== 0
? Math.abs((Math.floor(y0) + (dy > 0 ? 1 : 0) - y0) / dy)
: Infinity;
let col = Math.floor(x0);
let row = Math.floor(y0);
const endCol = Math.floor(x1);
const endRow = Math.floor(y1);
while (true) {
add(col, row);
if (col === endCol && row === endRow) break;
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
col += stepX;
} else if (tMaxY < tMaxX) {
tMaxY += tDeltaY;
row += stepY;
} else {
tMaxX += tDeltaX;
tMaxY += tDeltaY;
col += stepX;
row += stepY;
}
}
}
return tiles;
}
function drawPath(g: Graphics, path: Path, color: number): void {
if (path.length < 2) return;
const first = tileCenter(path[0].col, path[0].row);
g.moveTo(first.x, first.y);
for (let i = 1; i < path.length; i++) {
const { x, y } = tileCenter(path[i].col, path[i].row);
g.lineTo(x, y);
}
g.stroke({ color, width: PATH_LINE_W });
const last = tileCenter(path[path.length - 1].col, path[path.length - 1].row);
const prev = tileCenter(path[path.length - 2].col, path[path.length - 2].row);
const dx = last.x - prev.x;
const dy = last.y - prev.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const ux = dx / dist;
const uy = dy / dist;
const px = -uy * (PATH_HEAD_SIZE * 0.5);
const py = ux * (PATH_HEAD_SIZE * 0.5);
const base = {
x: last.x - ux * PATH_HEAD_SIZE,
y: last.y - uy * PATH_HEAD_SIZE,
};
g.moveTo(last.x, last.y);
g.lineTo(base.x + px, base.y + py);
g.lineTo(base.x - px, base.y - py);
g.fill(color);
}
type Vec2 = { x: number; y: number };
function drawPixelPath(g: Graphics, pts: Vec2[], color: number): void {
if (pts.length < 2) return;
g.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y);
g.stroke({ color, width: PATH_LINE_W });
const last = pts[pts.length - 1];
const prev = pts[pts.length - 2];
const dx = last.x - prev.x;
const dy = last.y - prev.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const ux = dx / dist;
const uy = dy / dist;
const px = -uy * (PATH_HEAD_SIZE * 0.5);
const py = ux * (PATH_HEAD_SIZE * 0.5);
const bx = last.x - ux * PATH_HEAD_SIZE;
const by = last.y - uy * PATH_HEAD_SIZE;
g.moveTo(last.x, last.y);
g.lineTo(bx + px, by + py);
g.lineTo(bx - px, by - py);
g.fill(color);
}
interface Portal {
left: Vec2;
right: Vec2;
}
function extractPortals(path: Path, r: number): Portal[] {
// Each adjacent tile pair shares a grid edge — the portal the unit crosses.
// "left" and "right" are defined looking from path[i] toward path[i+1].
const portals: Portal[] = [];
const ox = MAP_OFFSET_X;
const oy = MAP_OFFSET_Y;
const ts = TILE_SIZE;
for (let i = 0; i < path.length - 1; i++) {
const dc = path[i + 1].col - path[i].col;
const dr = path[i + 1].row - path[i].row;
// Diagonal step (corner-touching) has no meaningful portal — skip.
if (Math.abs(dc) + Math.abs(dr) !== 1) continue;
const c = path[i].col;
const row = path[i].row;
let left: Vec2, right: Vec2;
if (dc === 1) {
// moving right: portal is left edge of next tile (vertical)
const x = ox + (c + 1) * ts;
left = { x, y: oy + row * ts + r };
right = { x, y: oy + (row + 1) * ts - r };
} else if (dc === -1) {
// moving left: portal is right edge of next tile (vertical)
const x = ox + c * ts;
left = { x, y: oy + (row + 1) * ts - r };
right = { x, y: oy + row * ts + r };
} else if (dr === 1) {
// moving down: portal is top edge of next tile (horizontal)
const y = oy + (row + 1) * ts;
left = { x: ox + (c + 1) * ts - r, y };
right = { x: ox + c * ts + r, y };
} else {
// moving up: portal is bottom edge of next tile (horizontal)
const y = oy + row * ts;
left = { x: ox + c * ts + r, y };
right = { x: ox + (c + 1) * ts - r, y };
}
portals.push({ left, right });
}
return portals;
}
function cross2(o: Vec2, a: Vec2, b: Vec2): number {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
function funnelPath(start: Vec2, goal: Vec2, portals: Portal[]): Vec2[] {
// Simple Stupid Funnel Algorithm (Mikko Mononen)
const result: Vec2[] = [start];
if (portals.length === 0) {
result.push(goal);
return result;
}
let apexIdx = -1; // index into portals where current apex was set (-1 = start)
let apex = start;
let leftV = portals[0].left;
let rightV = portals[0].right;
let leftIdx = 0;
let rightIdx = 0;
for (let i = 1; i <= portals.length; i++) {
const newLeft = i < portals.length ? portals[i].left : goal;
const newRight = i < portals.length ? portals[i].right : goal;
// Tighten right
if (cross2(apex, rightV, newRight) <= 0) {
if (apex === rightV || cross2(apex, leftV, newRight) > 0) {
rightV = newRight;
rightIdx = i;
} else {
// Right crossed left — emit left as waypoint, restart from there
result.push(leftV);
apex = leftV;
apexIdx = leftIdx;
leftV = apex;
rightV = apex;
leftIdx = apexIdx;
rightIdx = apexIdx;
// Restart scan from apexIdx + 1
i = apexIdx;
continue;
}
}
// Tighten left
if (cross2(apex, leftV, newLeft) >= 0) {
if (apex === leftV || cross2(apex, rightV, newLeft) < 0) {
leftV = newLeft;
leftIdx = i;
} else {
result.push(rightV);
apex = rightV;
apexIdx = rightIdx;
leftV = apex;
rightV = apex;
leftIdx = apexIdx;
rightIdx = apexIdx;
i = apexIdx;
continue;
}
}
}
result.push(goal);
return result;
}
function worldToIndexedPng(world: World): ArrayBuffer {
const rgba = new Uint8Array(world.cols * world.rows * 4);
for (let i = 0; i < world.tiles.length; i++) {
const { r, g, b } = tileToRgb(world.tiles[i] as Tile);
rgba[i * 4 + 0] = r;
rgba[i * 4 + 1] = g;
rgba[i * 4 + 2] = b;
rgba[i * 4 + 3] = 255;
}
return UPNG.encode([rgba.buffer], world.cols, world.rows, 0, []);
}
function worldFromIndexedPng(buffer: ArrayBuffer): World | null {
try {
const img = UPNG.decode(buffer);
const rgba = new Uint8Array(UPNG.toRGBA8(img)[0]);
const world = makeWorld(img.width, img.height);
const area = img.width * img.height;
for (let i = 0; i < area; i++) {
world.tiles[i] = rgbToTile(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]);
}
return world;
} catch {
return null;
}
}
function mountIoButtons(
getWorld: () => World,
timeLabel: Text,
onLoad: (loaded: World) => void,
): void {
const wrap = document.createElement("div");
wrap.style.cssText =
"position:fixed;top:16px;right:16px;display:flex;flex-direction:column;gap:8px;";
const btnStyle =
"padding:6px 12px;background:#333;color:#ccc;border:1px solid #666;cursor:pointer;font-family:monospace;font-size:13px;";
const dlBtn = document.createElement("button");
dlBtn.textContent = "Download PNG";
dlBtn.style.cssText = btnStyle;
dlBtn.addEventListener("click", () => {
const buf = worldToIndexedPng(getWorld());
const blob = new Blob([buf], { type: "image/png" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "world.png";
a.click();
URL.revokeObjectURL(url);
});
const applyWorld = (buf: ArrayBuffer, label: string) => {
const loaded = worldFromIndexedPng(buf);
if (!loaded) {
timeLabel.text = `${label} failed: not a valid indexed PNG`;
return;
}
onLoad(loaded);
};
const rtBtn = document.createElement("button");
rtBtn.textContent = "Round-trip";
rtBtn.style.cssText = btnStyle;
rtBtn.addEventListener("click", () =>
applyWorld(worldToIndexedPng(getWorld()), "Round-trip"),
);
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".png,image/png";
fileInput.style.display = "none";
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (!file) return;
file.arrayBuffer().then((buf) => applyWorld(buf, "Upload"));
fileInput.value = "";
});
const ulBtn = document.createElement("button");
ulBtn.textContent = "Upload PNG";
ulBtn.style.cssText = btnStyle;
ulBtn.addEventListener("click", () => fileInput.click());
wrap.appendChild(dlBtn);
wrap.appendChild(rtBtn);
wrap.appendChild(ulBtn);
wrap.appendChild(fileInput);
const mapEntries = Object.entries(stockMaps)
.map(([path, url]) => {
const name = path.replace(/^.*\//, "").replace(/\.png$/, "");
return { name, url };
})
.sort((a, b) => a.name.localeCompare(b.name));
if (mapEntries.length > 0) {
const sep = document.createElement("hr");
sep.style.cssText = "border:none;border-top:1px solid #666;margin:4px 0;";
wrap.appendChild(sep);
const loadMap = (url: string, label: string) =>
fetch(url)
.then((r) => r.arrayBuffer())
.then((buf) => applyWorld(buf, label));
for (const { name, url } of mapEntries) {
const btn = document.createElement("button");
btn.textContent = name;
btn.style.cssText = btnStyle;
btn.addEventListener("click", () => loadMap(url, name));
wrap.appendChild(btn);
}
const hash = location.hash.replace(/^#/, "");
const match = mapEntries.find((e) => e.name === hash);
if (match) loadMap(match.url, match.name);
}
document.body.appendChild(wrap);
}
async function setup() {
const app = new Application();
await app.init({ background: "#111111", resizeTo: window, antialias: true });
document.getElementById("pixi-container")!.appendChild(app.canvas);
return app;
}
function makeTileTextures(app: Application): Record<Tile, Texture> {
const wallGfx = new Graphics()
.rect(0, 0, TILE_SIZE, TILE_SIZE)
.fill(0x444444);
const groundGfx = new Graphics()
.rect(0, 0, TILE_SIZE, TILE_SIZE)
.fill(0x222222)
.rect(0, 0, TILE_SIZE, TILE_SIZE)
.stroke({ color: 0x1a1a1a, width: 1, alignment: 1 });
return {
[Tile.Wall]: app.renderer.generateTexture(wallGfx),
[Tile.Ground]: app.renderer.generateTexture(groundGfx),
};
}
function pixelToTile(world: World, px: number, py: number): Coord | null {
const col = Math.floor((px - MAP_OFFSET_X) / TILE_SIZE);
const row = Math.floor((py - MAP_OFFSET_Y) / TILE_SIZE);
if (!inBounds(world, col, row)) return null;
return { col, row };
}
(async () => {
const app = await setup();
const textures = makeTileTextures(app);
const map0Buf = await fetch(stockMaps["./maps/map0.png"]).then((r) =>
r.arrayBuffer(),
);
let world = worldFromIndexedPng(map0Buf) ?? makeWorld(1, 1);
// --- Containers ---
const mapContainer = new Container();
const overlayContainer = new Container();
const belowMapContainer = new Container();
belowMapContainer.x = MAP_OFFSET_X;
app.stage.addChild(mapContainer);
app.stage.addChild(overlayContainer);
app.stage.addChild(belowMapContainer);
// --- Map container contents ---
let startCoord: Coord | null = null;
let goalCoord: Coord | null = null;
type Mode = "route" | "edit";
let mode: Mode = "route";
let tileSprites: Sprite[][] = [];
function onPointerDown(event: import("pixi.js").FederatedPointerEvent): void {
const pos = event.getLocalPosition(app.stage);
const coord = pixelToTile(world, pos.x, pos.y);
if (!coord) return;
if (mode === "edit") {
applyEditAt(
coord.col,
coord.row,
event.button === 0 ? Tile.Wall : Tile.Ground,
);
return;
}
if (getTile(world, coord.col, coord.row) === Tile.Wall) return;
if (event.button === 0) {
startCoord = coord;
} else if (event.button === 2) {
goalCoord = coord;
}
redrawOverlay();
}
function onPointerMove(event: import("pixi.js").FederatedPointerEvent): void {
if (mode !== "edit" || event.buttons === 0) return;
const pos = event.getLocalPosition(app.stage);
const coord = pixelToTile(world, pos.x, pos.y);
if (!coord) return;
applyEditAt(
coord.col,
coord.row,
event.buttons & 1 ? Tile.Wall : Tile.Ground,
);
}
function applyEditAt(col: number, row: number, tile: Tile): void {
if (world.tiles[row * world.cols + col] === tile) return;
world.tiles[row * world.cols + col] = tile;
tileSprites[row][col].texture = textures[tile];
redrawOverlay();
}
function buildMap(): void {
for (const child of mapContainer.removeChildren()) child.destroy();
tileSprites = Array.from(
{ length: world.rows },
() => new Array<Sprite>(world.cols),
);
for (let row = 0; row < world.rows; row++) {
for (let col = 0; col < world.cols; col++) {
const sprite = new Sprite(textures[getTile(world, col, row)]);
sprite.x = MAP_OFFSET_X + col * TILE_SIZE;
sprite.y = MAP_OFFSET_Y + row * TILE_SIZE;
tileSprites[row][col] = sprite;
mapContainer.addChild(sprite);
}
}
const mapW = world.cols * TILE_SIZE;
const mapH = world.rows * TILE_SIZE;
const border = new Graphics()
.rect(MAP_OFFSET_X, MAP_OFFSET_Y, mapW, mapH)
.stroke({ width: 1, color: 0x888888 });
mapContainer.addChild(border);
const hit = new Graphics()
.rect(MAP_OFFSET_X, MAP_OFFSET_Y, mapW, mapH)
.fill({ color: 0x000000, alpha: 0 });
hit.eventMode = "static";
hit.cursor = "pointer";
hit.on("pointerdown", onPointerDown);
hit.on("pointermove", onPointerMove);
mapContainer.addChild(hit);
belowMapContainer.y = Math.round(MAP_OFFSET_Y + mapH + 8);
}
buildMap();
// --- Below-map container: stats + legend ---
const LEGEND_LOCAL_X = 340;
const LEGEND_ROW = 20;
const LEGEND_LINE_LEN = 20;
const LEGEND_LINE_MID_Y = 7;
const legendLines = [
{ color: PATH_COLOR, label: "a*" },
{ color: THETA_COLOR, label: "θ*" },
{ color: SMOOTH_COLOR, label: "string-pull" },
{ color: FUNNEL_COLOR, label: "a* + funnel" },
{ color: THETA_FUNNEL_COLOR, label: "θ* + funnel" },
];
const markerItems = [
{ color: START_COLOR, label: "start" },
{ color: GOAL_COLOR, label: "goal" },
];
const timeLabel = new Text({
text: "",
style: new TextStyle({
fill: 0x888888,
fontSize: 14,
fontFamily: "monospace",
lineHeight: 20,
}),
});
belowMapContainer.addChild(timeLabel);
const legendContainer = new Container();
legendContainer.x = LEGEND_LOCAL_X;
belowMapContainer.addChild(legendContainer);
const legendGfx = new Graphics();
for (let i = 0; i < legendLines.length; i++) {
const y = i * LEGEND_ROW + LEGEND_LINE_MID_Y;
legendGfx
.moveTo(0, y)
.lineTo(LEGEND_LINE_LEN, y)
.stroke({ color: legendLines[i].color, width: PATH_LINE_W });
}
for (let i = 0; i < markerItems.length; i++) {
const cy = (legendLines.length + i) * LEGEND_ROW + LEGEND_LINE_MID_Y;
legendGfx.circle(LEGEND_LINE_LEN / 2, cy, 5).fill(markerItems[i].color);
}
legendContainer.addChild(legendGfx);
const legendStyle = new TextStyle({
fill: 0x888888,
fontSize: 14,
fontFamily: "monospace",
});
// --- Overlay container: highlights + path/marker graphics ---
const highlightGraphics = new Graphics();
overlayContainer.addChild(highlightGraphics);
let currentPaths: ({ path: Path; color: number } | null)[] = [
null,
null,
null,
null,
null,
];
const clearHighlight = () => {
highlightGraphics.clear();
};
const showHighlight = (idx: number) => {
highlightGraphics.clear();
const entry = currentPaths[idx];
if (!entry) return;
const seq = pathToTileSequence(entry.path);
for (const { col, row } of seq) {
highlightGraphics
.rect(
MAP_OFFSET_X + col * TILE_SIZE,
MAP_OFFSET_Y + row * TILE_SIZE,
TILE_SIZE,
TILE_SIZE,
)
.fill({ color: entry.color, alpha: 0.18 });
}
};
for (let i = 0; i < legendLines.length; i++) {
const lbl = new Text({ text: legendLines[i].label, style: legendStyle });
lbl.x = Math.round(LEGEND_LINE_LEN + 8);
lbl.y = Math.round(i * LEGEND_ROW);
lbl.eventMode = "static";
lbl.cursor = "pointer";
const idx = i;
lbl.on("pointerover", () => showHighlight(idx));
lbl.on("pointerout", clearHighlight);
legendContainer.addChild(lbl);
}
for (let i = 0; i < markerItems.length; i++) {
const lbl = new Text({ text: markerItems[i].label, style: legendStyle });
lbl.x = Math.round(LEGEND_LINE_LEN + 8);
lbl.y = Math.round((legendLines.length + i) * LEGEND_ROW);
legendContainer.addChild(lbl);
}
// --- Mode buttons (Route / Edit) ---
const modeContainer = new Container();
modeContainer.x = LEGEND_LOCAL_X + 160;
belowMapContainer.addChild(modeContainer);
const modeEntries: { m: Mode; label: string }[] = [
{ m: "route", label: "Route" },
{ m: "edit", label: "Edit" },
];
const modeActiveStyle = new TextStyle({
fill: 0xdddddd,
fontSize: 14,
fontFamily: "monospace",
});
const modeInactiveStyle = new TextStyle({
fill: 0x555555,
fontSize: 14,
fontFamily: "monospace",
});
const modeBtns = modeEntries.map(({ m, label }, i) => {
const btn = new Text({
text: label,
style: m === mode ? modeActiveStyle : modeInactiveStyle,
});
btn.y = i * LEGEND_ROW;
btn.eventMode = "static";
btn.cursor = "pointer";
modeContainer.addChild(btn);
return btn;
});
const setMode = (m: Mode) => {
mode = m;
for (let j = 0; j < modeBtns.length; j++) {
modeBtns[j].style =
modeEntries[j].m === mode ? modeActiveStyle : modeInactiveStyle;
}
};
modeBtns.forEach((btn, i) =>
btn.on("pointerdown", () => setMode(modeEntries[i].m)),
);
// Overlay layer for markers and path (redrawn on each click)
let overlayGraphics: Graphics | null = null;
function redrawOverlay(): void {
if (overlayGraphics) {
overlayContainer.removeChild(overlayGraphics);
overlayGraphics.destroy();
overlayGraphics = null;
}
const g = new Graphics();
overlayGraphics = g;
if (startCoord) {
const { x, y } = tileCenter(startCoord.col, startCoord.row);
g.circle(x, y, MARKER_R).fill(START_COLOR);
}
if (goalCoord) {
const { x, y } = tileCenter(goalCoord.col, goalCoord.row);
g.circle(x, y, MARKER_R).fill(GOAL_COLOR);
}
clearHighlight();
overlayContainer.addChild(g);
if (startCoord && goalCoord) {
const t0 = performance.now();
const path = findPath(world, startCoord, goalCoord);
const t1 = performance.now();
const thetaPath = findPathTheta(world, startCoord, goalCoord);
const t2 = performance.now();
if (path) {
const smoothed = smoothPath(world, path);
const t3 = performance.now();
const startPx = tileCenter(startCoord.col, startCoord.row);
const goalPx = tileCenter(goalCoord.col, goalCoord.row);
const t4 = performance.now();
const astarPortals = extractPortals(path, UNIT_RADIUS);
const funnelPts = funnelPath(startPx, goalPx, astarPortals);
const t5 = performance.now();
const thetaSeq = thetaPath ? pathToTileSequence(thetaPath) : null;
const thetaPortals = thetaSeq
? extractPortals(thetaSeq, UNIT_RADIUS)
: null;
const thetaFunnelPts = thetaPortals
? funnelPath(startPx, goalPx, thetaPortals)
: null;
const t6 = performance.now();
currentPaths = [
{ path, color: PATH_COLOR },
thetaPath ? { path: thetaPath, color: THETA_COLOR } : null,
{ path: smoothed, color: SMOOTH_COLOR },
{ path, color: FUNNEL_COLOR },
thetaPath ? { path: thetaPath, color: THETA_FUNNEL_COLOR } : null,
];
const pixelPathLen = (pts: Vec2[]) =>
pts.reduce((sum, pt, i) => {
if (i === 0) return 0;
const dx = pt.x - pts[i - 1].x;
const dy = pt.y - pts[i - 1].y;
return sum + Math.sqrt(dx * dx + dy * dy) / TILE_SIZE;
}, 0);
const gridLen = pathLength(path);
const smoothLen = pathLength(smoothed);
const thetaLen = thetaPath ? pathLength(thetaPath) : null;
const funnelLen = pixelPathLen(funnelPts);
const thetaFunnelLen = thetaFunnelPts
? pixelPathLen(thetaFunnelPts)
: null;
const dc = goalCoord.col - startCoord.col;
const dr = goalCoord.row - startCoord.row;
const straight = Math.sqrt(dc * dc + dr * dr);
timeLabel.text = [
`a*: ${(t1 - t0).toFixed(3)} ms ${gridLen.toFixed(2)} tiles`,
`θ*: ${(t2 - t1).toFixed(3)} ms ${thetaLen !== null ? thetaLen.toFixed(2) : "—"} tiles`,
`string-pull: ${(t3 - t2).toFixed(3)} ms ${smoothLen.toFixed(2)} tiles`,
`a* + funnel: ${(t5 - t4).toFixed(3)} ms ${funnelLen.toFixed(2)} tiles`,
`θ* + funnel: ${(t6 - t5).toFixed(3)} ms ${thetaFunnelLen !== null ? thetaFunnelLen.toFixed(2) : "—"} tiles`,
`straight: ${straight.toFixed(2)} tiles`,
].join("\n");
drawPath(g, path, PATH_COLOR);
if (thetaPath) drawPath(g, thetaPath, THETA_COLOR);
drawPath(g, smoothed, SMOOTH_COLOR);
drawPixelPath(g, funnelPts, FUNNEL_COLOR);
if (thetaFunnelPts)
drawPixelPath(g, thetaFunnelPts, THETA_FUNNEL_COLOR);
} else {
currentPaths = [null, null, null];
timeLabel.text = `a*: ${(t1 - t0).toFixed(3)} ms no path found`;
}
}
}
// Prevent context menu on right click
app.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
mountIoButtons(
() => world,
timeLabel,
(loaded) => {
world = loaded;
startCoord = null;
goalCoord = null;
buildMap();
redrawOverlay();
},
);
})();