import { Application, Container, Graphics, Sprite, 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 ENTITY_RADIUS = 4;
const MAX_SPEED = 2;
const ARRIVAL_RADIUS = TILE_SIZE * 1.5;
const ORBIT_STRENGTH = 0.8;
const SEPARATION_WEIGHT = 1.5;
const WALL_SIGHT_RADIUS = TILE_SIZE * 1.5;
const WALL_AVOIDANCE_WEIGHT = 2.0;
const SEPARATION_RADIUS_LINEAR = ENTITY_RADIUS * 5;
const SEPARATION_SIGMA = ENTITY_RADIUS * 3;
const SEPARATION_CUTOFF = SEPARATION_SIGMA * 4;
type SeparationKernel = (dist: number) => number;
const kernelLinear: SeparationKernel = (dist) =>
dist < SEPARATION_RADIUS_LINEAR
? ((SEPARATION_RADIUS_LINEAR - dist) / SEPARATION_RADIUS_LINEAR) *
SEPARATION_WEIGHT
: 0;
const kernelExponential: SeparationKernel = (dist) =>
dist < SEPARATION_CUTOFF
? SEPARATION_WEIGHT * Math.exp(-dist / SEPARATION_SIGMA)
: 0;
const enum Tile {
Ground = 0,
Wall = 1,
}
interface World {
cols: number;
rows: number;
tiles: Uint8Array;
}
interface Entity {
x: number;
y: number;
targetX: number;
targetY: number;
}
type Coord = { col: number; row: number };
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 inBounds(world: World, col: number, row: number): boolean {
return col >= 0 && col < world.cols && row >= 0 && row < world.rows;
}
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 };
}
function isPassable(
world: World,
px: number,
py: number,
radius: number,
): boolean {
const probes: [number, number][] = [
[0, 0],
[radius, 0],
[-radius, 0],
[0, radius],
[0, -radius],
];
for (const [ox, oy] of probes) {
const coord = pixelToTile(world, px + ox, py + oy);
if (!coord || getTile(world, coord.col, coord.row) === Tile.Wall)
return false;
}
return true;
}
// Tile↔color mapping for PNG I/O: Ground=black, Wall=white.
function tileToRgb(tile: Tile): [number, number, number] {
return tile === Tile.Wall ? [255, 255, 255] : [0, 0, 0];
}
function rgbToTile(r: number, g: number, b: number): Tile {
return r + g + b > 382 ? Tile.Wall : Tile.Ground;
}
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] = 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);
for (let i = 0; i < world.tiles.length; i++) {
world.tiles[i] = rgbToTile(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]);
}
return world;
} catch {
return null;
}
}
const BTN_STYLE =
"padding:6px 12px;background:#333;color:#ccc;border:1px solid #666;cursor:pointer;font-family:monospace;font-size:13px;";
function makeBtn(label: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement("button");
btn.textContent = label;
btn.style.cssText = BTN_STYLE;
btn.addEventListener("click", onClick);
return btn;
}
function mountIoButtons(
getWorld: () => World,
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 applyWorld = (buf: ArrayBuffer) => {
const loaded = worldFromIndexedPng(buf);
if (loaded) onLoad(loaded);
};
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));
fileInput.value = "";
});
wrap.appendChild(
makeBtn("Download PNG", () => {
const buf = worldToIndexedPng(getWorld());
const url = URL.createObjectURL(new Blob([buf], { type: "image/png" }));
const a = document.createElement("a");
a.href = url;
a.download = "world.png";
a.click();
URL.revokeObjectURL(url);
}),
);
wrap.appendChild(
makeBtn("Round-trip", () => applyWorld(worldToIndexedPng(getWorld()))),
);
wrap.appendChild(makeBtn("Upload PNG", () => fileInput.click()));
wrap.appendChild(fileInput);
const mapEntries = Object.entries(stockMaps)
.map(([path, url]) => ({
name: path.replace(/^.*\//, "").replace(/\.png$/, ""),
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) =>
fetch(url)
.then((r) => r.arrayBuffer())
.then((buf) => applyWorld(buf));
for (const { name, url } of mapEntries) {
wrap.appendChild(makeBtn(name, () => loadMap(url)));
}
const hash = location.hash.replace(/^#/, "");
const match = mapEntries.find((e) => e.name === hash);
if (match) loadMap(match.url);
}
document.body.appendChild(wrap);
}
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),
};
}
(async () => {
const app = new Application();
await app.init({ background: "#111111", resizeTo: window, antialias: true });
document.getElementById("pixi-container")!.appendChild(app.canvas);
const textures = makeTileTextures(app);
const map0Buf = await fetch(stockMaps["./maps/map2.png"]).then((r) =>
r.arrayBuffer(),
);
let world = worldFromIndexedPng(map0Buf) ?? makeWorld(1, 1);
const mapContainer = new Container();
const entityGfx = new Graphics();
app.stage.addChild(mapContainer);
app.stage.addChild(entityGfx);
let editMode = false;
let activeKernel: SeparationKernel = kernelExponential;
let tileSprites: Sprite[][] = [];
const entities: Entity[] = [];
function onPointerDown(event: import("pixi.js").FederatedPointerEvent): void {
const pos = event.getLocalPosition(app.stage);
if (!editMode) {
if (event.button === 0) {
for (const e of entities) {
e.targetX = pos.x;
e.targetY = pos.y;
}
} else if (event.button === 2) {
const spawnRadius = 3 * TILE_SIZE;
for (let i = 0; i < 20; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * spawnRadius;
const sx = pos.x + Math.cos(angle) * dist;
const sy = pos.y + Math.sin(angle) * dist;
if (!isPassable(world, sx, sy, ENTITY_RADIUS)) continue;
entities.push({ x: sx, y: sy, targetX: sx, targetY: sy });
}
}
return;
}
const coord = pixelToTile(world, pos.x, pos.y);
if (!coord) return;
applyEditAt(
coord.col,
coord.row,
event.button === 0 ? Tile.Wall : Tile.Ground,
);
}
function onPointerMove(event: import("pixi.js").FederatedPointerEvent): void {
if (!editMode || 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];
}
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;
mapContainer.addChild(
new Graphics()
.rect(MAP_OFFSET_X, MAP_OFFSET_Y, mapW, mapH)
.stroke({ width: 1, color: 0x888888 }),
);
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);
}
buildMap();
// --- Steering: outputs a desire vector (direction + magnitude 0–1) ---
function steer(
e: Entity,
allEntities: Entity[],
w: World,
): { dx: number; dy: number } {
// Arrival with orbital blend near target
const tdx = e.targetX - e.x;
const tdy = e.targetY - e.y;
const tDist = Math.hypot(tdx, tdy);
const proximity = tDist > 0 ? Math.min(tDist / ARRIVAL_RADIUS, 1) : 0;
let ax = 0;
let ay = 0;
if (tDist > 0) {
const radX = tdx / tDist;
const radY = tdy / tDist;
const tanX = -radY;
const tanY = radX;
const orbitBlend = (1 - proximity) * ORBIT_STRENGTH;
const dirX = radX * (1 - orbitBlend) + tanX * orbitBlend;
const dirY = radY * (1 - orbitBlend) + tanY * orbitBlend;
const dMag = Math.hypot(dirX, dirY);
ax = (dirX / dMag) * proximity;
ay = (dirY / dMag) * proximity;
}
// Separation + wall avoidance (unbounded magnitude)
let lx = 0;
let ly = 0;
for (const other of allEntities) {
if (other === e) continue;
const odx = e.x - other.x;
const ody = e.y - other.y;
const oDist = Math.hypot(odx, ody);
if (oDist > 0) {
const strength = activeKernel(oDist);
if (strength > 0) {
lx += (odx / oDist) * strength;
ly += (ody / oDist) * strength;
}
}
}
const tileRange = Math.ceil(WALL_SIGHT_RADIUS / TILE_SIZE);
const eCoord = pixelToTile(w, e.x, e.y);
if (eCoord) {
for (let dr = -tileRange; dr <= tileRange; dr++) {
for (let dc = -tileRange; dc <= tileRange; dc++) {
const col = eCoord.col + dc;
const row = eCoord.row + dr;
if (!inBounds(w, col, row)) continue;
if (getTile(w, col, row) !== Tile.Wall) continue;
const { x: wx, y: wy } = tileCenter(col, row);
const wdx = e.x - wx;
const wdy = e.y - wy;
const wDist = Math.hypot(wdx, wdy);
if (wDist > 0 && wDist < WALL_SIGHT_RADIUS) {
const strength =
((WALL_SIGHT_RADIUS - wDist) / WALL_SIGHT_RADIUS) *
WALL_AVOIDANCE_WEIGHT;
lx += (wdx / wDist) * strength;
ly += (wdy / wDist) * strength;
}
}
}
}
// Clamp local forces to unit magnitude
const lMag = Math.hypot(lx, ly);
const localScale = lMag > 0 ? Math.min(lMag, 1) / lMag : 0;
return { dx: ax + lx * localScale, dy: ay + ly * localScale };
}
// --- Movement: converts steering desire into displacement ---
function move(e: Entity, desire: { dx: number; dy: number }): void {
const mag = Math.hypot(desire.dx, desire.dy);
if (mag <= 0) return;
const speed = Math.min(mag, 1) * MAX_SPEED;
const nx = e.x + (desire.dx / mag) * speed;
const ny = e.y + (desire.dy / mag) * speed;
if (isPassable(world, nx, ny, ENTITY_RADIUS)) {
e.x = nx;
e.y = ny;
}
}
app.ticker.add(() => {
for (const e of entities) {
move(e, steer(e, entities, world));
}
entityGfx.clear();
for (const e of entities)
entityGfx.circle(e.x, e.y, ENTITY_RADIUS).fill(0x44aaff);
});
app.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
const editBtn = makeBtn("Edit: OFF", () => {
editMode = !editMode;
editBtn.textContent = `Edit: ${editMode ? "ON" : "OFF"}`;
});
editBtn.style.cssText = BTN_STYLE + "position:fixed;bottom:16px;left:16px;";
document.body.appendChild(editBtn);
const ACTIVE_BTN_STYLE = BTN_STYLE + "background:#555;color:#fff;";
const setKernel = (kernel: SeparationKernel) => {
activeKernel = kernel;
expBtn.style.cssText =
(kernel === kernelExponential ? ACTIVE_BTN_STYLE : BTN_STYLE) +
"position:fixed;bottom:16px;left:140px;";
linBtn.style.cssText =
(kernel === kernelLinear ? ACTIVE_BTN_STYLE : BTN_STYLE) +
"position:fixed;bottom:16px;left:260px;";
};
const expBtn = makeBtn("Exponential", () => setKernel(kernelExponential));
const linBtn = makeBtn("Linear", () => setKernel(kernelLinear));
document.body.appendChild(expBtn);
document.body.appendChild(linBtn);
setKernel(kernelExponential);
mountIoButtons(
() => world,
(loaded) => {
world = loaded;
entities.length = 0;
buildMap();
},
);
})();