import {
Application,
Assets,
BufferImageSource,
Container,
Graphics,
Rectangle,
Sprite,
Texture,
} from "pixi.js";
import { settings as tilemapSettings, Tilemap } from "@pixi/tilemap";
import seedrandom from "seedrandom";
tilemapSettings.use32bitIndex = true;
import {
parseGpl,
decodeWorldMap,
type GplEntry,
type TileGrid,
} from "../srcUtils/worldMaps.ts";
import { buildTileAtlas, TileAtlas } from "../srcUtils/tileAtlas.ts";
import {
generateTileset,
NoiseAlgo,
type TextureSpec,
} from "./scratchProceduralTexture.ts";
import paletteText from "./maps/palette2.gpl?raw";
const islandMaps = import.meta.glob("./maps/islands*.png", {
eager: true,
import: "default",
query: "?url",
}) as Record<string, string>;
const TILE_SIZE = 32;
const TILE_VARIANTS = 16;
const FOREST_SPEC: TextureSpec = {
rgb: [25, 110, 35],
lum: 0.3,
range: 0.16,
fill: 0.2,
algo: NoiseAlgo.corner,
};
const WATER_SPEC: TextureSpec = {
rgb: [20, 35, 140],
lum: 0.2,
range: 0.12,
fill: 0.15,
algo: NoiseAlgo.domino,
};
const GROUND_SPEC: TextureSpec = {
rgb: [90, 55, 0],
lum: 0.3,
range: 0.08,
fill: 0.1,
algo: NoiseAlgo.normal,
};
const MOUNTAIN_SPEC: TextureSpec = {
rgb: [210, 210, 210],
lum: 0.5,
range: 0.12,
fill: 0.1,
algo: NoiseAlgo.corner,
};
const VIEWPORT_MARGIN = 16;
const BORDER_COLOR = 0x888888;
const BORDER_WIDTH = 2;
const PAN_STEP = TILE_SIZE;
const STORAGE_KEY = "scratchWorldMap";
const MINIMAP_FRACTION = 0.22;
enum TileType {
Blank = 0,
Floor = 1,
Wall = 2,
Forest = 3,
Water = 4,
Ground = 5,
Mountain = 6,
}
const BEVEL = 2;
// Display colors — separate from palette save colors
const DISPLAY_COLORS: Record<TileType, [number, number, number]> = {
[TileType.Blank]: [0, 0, 0],
[TileType.Floor]: [34, 34, 34], // handled by makeFloorTexture
[TileType.Wall]: [204, 204, 204], // handled by makeWallTexture
[TileType.Forest]: [0, 100, 5], // crushed neon green
[TileType.Water]: [0, 40, 100], // deep saturated navy
[TileType.Ground]: [90, 55, 0], // rich dark amber
[TileType.Mountain]: [50, 50, 70], // dark slate-blue
};
function makeFloorTexture(tileSize: number, app: Application): Texture {
const gfx = new Graphics()
.rect(0, 0, tileSize, tileSize)
.fill(0x222222)
.rect(0, 0, tileSize, tileSize)
.stroke({ color: 0x1a1a1a, width: 1, alignment: 1 });
const tex = app.renderer.generateTexture(gfx);
gfx.destroy();
return tex;
}
function makeWallTexture(tileSize: number, app: Application): Texture {
const gfx = new Graphics()
.rect(0, 0, tileSize, tileSize)
.fill(0xcccccc)
.rect(0, 0, tileSize, BEVEL)
.fill(0xffffff)
.rect(0, BEVEL, BEVEL, tileSize - BEVEL)
.fill(0xffffff)
.rect(0, tileSize - BEVEL, tileSize, BEVEL)
.fill(0x666666)
.rect(tileSize - BEVEL, BEVEL, BEVEL, tileSize - 2 * BEVEL)
.fill(0x666666);
const tex = app.renderer.generateTexture(gfx);
gfx.destroy();
return tex;
}
function pixelBufToTexture(buf: Uint8Array, size: number): Texture {
return new Texture({
source: new BufferImageSource({
resource: buf,
width: size,
height: size,
format: "rgba8unorm",
}),
});
}
function makeTileTextures(
palette: GplEntry[],
tileEnum: Record<string, number>,
app: Application,
tileSize: number,
): Record<string, Texture> {
const textures: Record<string, Texture> = {};
for (const entry of palette) {
const index = tileEnum[entry.name];
if (index === undefined) continue;
if (entry.name === "Floor") {
textures[entry.name] = makeFloorTexture(tileSize, app);
continue;
}
if (entry.name === "Wall") {
textures[entry.name] = makeWallTexture(tileSize, app);
continue;
}
if (entry.name === "Forest") {
const bufs = generateTileset(FOREST_SPEC, tileSize, TILE_VARIANTS);
bufs.forEach((buf, v) => {
textures[`Forest_${v}`] = pixelBufToTexture(buf, tileSize);
});
continue;
}
if (entry.name === "Water") {
const bufs = generateTileset(WATER_SPEC, tileSize, TILE_VARIANTS);
bufs.forEach((buf, v) => {
textures[`Water_${v}`] = pixelBufToTexture(buf, tileSize);
});
continue;
}
if (entry.name === "Ground") {
const bufs = generateTileset(GROUND_SPEC, tileSize, TILE_VARIANTS);
bufs.forEach((buf, v) => {
textures[`Ground_${v}`] = pixelBufToTexture(buf, tileSize);
});
continue;
}
if (entry.name === "Mountain") {
const bufs = generateTileset(MOUNTAIN_SPEC, tileSize, TILE_VARIANTS);
bufs.forEach((buf, v) => {
textures[`Mountain_${v}`] = pixelBufToTexture(buf, tileSize);
});
continue;
}
const [r, g, b] = DISPLAY_COLORS[index as TileType] ?? [
entry.r,
entry.g,
entry.b,
];
const color = (r << 16) | (g << 8) | b;
const gfx = new Graphics().rect(0, 0, tileSize, tileSize).fill(color);
textures[entry.name] = app.renderer.generateTexture(gfx);
gfx.destroy();
}
return textures;
}
function drawTilemap(
tilemap: Tilemap,
grid: TileGrid,
getTextureName: (tile: number, row: number, col: number) => string,
atlas: TileAtlas,
) {
for (let row = 0; row < grid.rows; row++) {
for (let col = 0; col < grid.cols; col++) {
const tile = grid.tiles[row * grid.cols + col];
tilemap.tile(
atlas.get(getTextureName(tile, row, col)),
col * TILE_SIZE,
row * TILE_SIZE,
);
}
}
}
async function setup(app: Application) {
await app.init({ background: "#111111", resizeTo: window, antialias: true });
document.getElementById("pixi-container")!.appendChild(app.canvas);
const palette = parseGpl(paletteText);
const mapEntries = Object.entries(islandMaps)
.map(([path, url]) => ({
name: path.replace(/^.*\//, "").replace(/\.png$/, ""),
url,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const firstBuf = await fetch(mapEntries[0].url).then((r) => r.arrayBuffer());
let grid = decodeWorldMap(palette, firstBuf, TileType);
// Scene: [M][Viewport][M][Panel column][M]
const viewport = new Container();
app.stage.addChild(viewport);
const world = new Container();
viewport.addChild(world);
const maskShape = new Graphics();
viewport.addChild(maskShape);
world.mask = maskShape;
const border = new Graphics();
viewport.addChild(border);
// Minimap sits at the bottom of the panel column
const minimap = new Container();
app.stage.addChild(minimap);
const minimapSprite = new Sprite();
minimap.addChild(minimapSprite);
const minimapBorder = new Graphics();
minimap.addChild(minimapBorder);
const minimapViewRect = new Graphics();
minimap.addChild(minimapViewRect);
let panelContentW = 0;
let wrap: HTMLDivElement | null = null;
let vW = 0;
let vH = 0;
let mmW = 0;
let mmH = 0;
function updateMinimapRect() {
const worldW = grid.cols * TILE_SIZE;
const worldH = grid.rows * TILE_SIZE;
const scaleX = mmW / worldW;
const scaleY = mmH / worldH;
minimapViewRect
.clear()
.rect(-world.x * scaleX, -world.y * scaleY, vW * scaleX, vH * scaleY)
.stroke({ color: 0xffffff, width: 1 });
}
function updateViewport() {
const worldW = grid.cols * TILE_SIZE;
const worldH = grid.rows * TILE_SIZE;
const isLandscape = window.innerWidth > window.innerHeight;
if (isLandscape) {
// Panel column on the right: [M][Viewport][M][Panel][M]
mmW = Math.floor(window.innerWidth * MINIMAP_FRACTION);
mmH = Math.round((mmW * worldH) / worldW);
const maxMmH = window.innerHeight - VIEWPORT_MARGIN * 2;
if (mmH > maxMmH) {
mmH = maxMmH;
mmW = Math.round((mmH * worldW) / worldH);
}
const panelW = Math.max(panelContentW, mmW);
const panelLeft = window.innerWidth - VIEWPORT_MARGIN - panelW;
vW = Math.min(worldW, panelLeft - VIEWPORT_MARGIN * 2);
vH = Math.min(worldH, window.innerHeight - VIEWPORT_MARGIN * 2);
minimap.x = window.innerWidth - VIEWPORT_MARGIN - mmW;
minimap.y = window.innerHeight - VIEWPORT_MARGIN - mmH;
if (wrap)
wrap.style.cssText = `position:fixed;top:${VIEWPORT_MARGIN}px;right:${VIEWPORT_MARGIN}px;display:flex;flex-direction:column;gap:8px;`;
} else {
// Panel row on the bottom: [M][Viewport][M][Panel][M]
mmH = Math.floor(window.innerHeight * MINIMAP_FRACTION);
mmW = Math.round((mmH * worldW) / worldH);
const panelTop = window.innerHeight - VIEWPORT_MARGIN - mmH;
vW = Math.min(worldW, window.innerWidth - VIEWPORT_MARGIN * 2);
vH = Math.min(worldH, panelTop - VIEWPORT_MARGIN * 2);
minimap.x = window.innerWidth - VIEWPORT_MARGIN - mmW;
minimap.y = panelTop;
if (wrap)
wrap.style.cssText = `position:fixed;bottom:${VIEWPORT_MARGIN}px;left:${VIEWPORT_MARGIN}px;display:flex;flex-direction:column;gap:8px;`;
}
viewport.x = VIEWPORT_MARGIN;
viewport.y = VIEWPORT_MARGIN;
maskShape.clear().rect(0, 0, vW, vH).fill(0xffffff);
border
.clear()
.rect(0, 0, vW, vH)
.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH });
minimap.hitArea = new Rectangle(0, 0, mmW, mmH);
minimapSprite.width = mmW;
minimapSprite.height = mmH;
minimapBorder
.clear()
.rect(0, 0, mmW, mmH)
.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH });
clampWorld();
updateMinimapRect();
}
function clampWorld() {
world.x = Math.min(0, Math.max(-(grid.cols * TILE_SIZE - vW), world.x));
world.y = Math.min(0, Math.max(-(grid.rows * TILE_SIZE - vH), world.y));
}
function saveState() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
mapUrl: currentMapUrl,
worldX: world.x,
worldY: world.y,
}),
);
}
function pan(dx: number, dy: number) {
world.x += dx;
world.y += dy;
clampWorld();
updateMinimapRect();
saveState();
}
app.renderer.on("resize", updateViewport);
window.addEventListener("keydown", (e) => {
switch (e.key) {
case "ArrowLeft":
pan(PAN_STEP, 0);
break;
case "ArrowRight":
pan(-PAN_STEP, 0);
break;
case "ArrowUp":
pan(0, PAN_STEP);
break;
case "ArrowDown":
pan(0, -PAN_STEP);
break;
}
});
minimap.eventMode = "static";
minimap.cursor = "grab";
let dragActive = false;
let dragStartMm = { x: 0, y: 0 };
let dragStartWorld = { x: 0, y: 0 };
minimap.on("pointerdown", (e) => {
dragActive = true;
const local = minimap.toLocal(e.global);
dragStartMm = { x: local.x, y: local.y };
dragStartWorld = { x: world.x, y: world.y };
minimap.cursor = "grabbing";
});
minimap.on("pointermove", (e) => {
if (!dragActive) return;
const local = minimap.toLocal(e.global);
const dx = local.x - dragStartMm.x;
const dy = local.y - dragStartMm.y;
world.x = dragStartWorld.x - dx * ((grid.cols * TILE_SIZE) / mmW);
world.y = dragStartWorld.y - dy * ((grid.rows * TILE_SIZE) / mmH);
clampWorld();
updateMinimapRect();
});
minimap.on("pointerup", () => {
dragActive = false;
minimap.cursor = "grab";
saveState();
});
minimap.on("pointerupoutside", () => {
dragActive = false;
minimap.cursor = "grab";
saveState();
});
let currentMapUrl = mapEntries[0].url;
let currentTilemap: Tilemap | null = null;
let currentTileTextures: Record<string, Texture> | null = null;
let currentAtlas: TileAtlas | null = null;
function rebuild() {
if (currentTilemap) {
world.removeChild(currentTilemap);
currentTilemap.destroy();
}
if (currentTileTextures) {
for (const tex of Object.values(currentTileTextures)) tex.destroy(true);
}
if (currentAtlas) currentAtlas.source.destroy();
const tileTextures = makeTileTextures(palette, TileType, app, TILE_SIZE);
const atlas = buildTileAtlas(app.renderer, tileTextures, TILE_SIZE);
currentTileTextures = tileTextures;
currentAtlas = atlas;
const tilemap = new Tilemap(atlas.source);
const rng = seedrandom("worldmap-tiles");
drawTilemap(
tilemap,
grid,
(tile) => {
const name = TileType[tile];
if (name === undefined)
throw new Error(`No TileType entry for value ${tile}`);
if (name === "Wall" || name === "Floor" || name === "Blank")
return name;
return `${name}_${Math.floor(rng() * TILE_VARIANTS)}`;
},
atlas,
);
world.addChild(tilemap);
currentTilemap = tilemap;
}
async function loadMap(url: string) {
const [buf, tex] = await Promise.all([
fetch(url).then((r) => r.arrayBuffer()),
Assets.load<Texture>(url),
]);
currentMapUrl = url;
world.x = 0;
world.y = 0;
grid = decodeWorldMap(palette, buf, TileType);
minimapSprite.texture = tex;
updateViewport();
rebuild();
saveState();
}
const btnStyle =
"padding:6px 12px;background:#333;color:#ccc;border:1px solid #666;cursor:pointer;font-family:monospace;font-size:13px;";
wrap = document.createElement("div");
for (const { name, url } of mapEntries) {
const btn = document.createElement("button");
btn.textContent = name;
btn.style.cssText = btnStyle;
btn.addEventListener("click", () => loadMap(url));
wrap.appendChild(btn);
}
wrap.style.cssText = "display:inline-flex;flex-direction:column;gap:8px;";
document.body.appendChild(wrap);
panelContentW = wrap.offsetWidth;
const saved = (() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
})();
const defaultUrl =
mapEntries.find((e) => e.name === "islands2")?.url ?? mapEntries[0].url;
const initialUrl =
saved?.mapUrl && mapEntries.some((e) => e.url === saved.mapUrl)
? saved.mapUrl
: defaultUrl;
currentMapUrl = initialUrl;
if (initialUrl !== mapEntries[0].url) {
const [savedBuf, savedTex] = await Promise.all([
fetch(initialUrl).then((r) => r.arrayBuffer()),
Assets.load<Texture>(initialUrl),
]);
grid = decodeWorldMap(palette, savedBuf, TileType);
minimapSprite.texture = savedTex;
} else {
minimapSprite.texture = await Assets.load<Texture>(initialUrl);
}
updateViewport();
if (saved?.mapUrl === initialUrl) {
world.x = saved.worldX ?? 0;
world.y = saved.worldY ?? 0;
clampWorld();
updateMinimapRect();
}
rebuild();
}
export async function main() {
const app = new Application();
await setup(app);
}