import {
Application,
Container,
Graphics,
Rectangle,
Sprite,
Text,
TextStyle,
Texture,
} from "pixi.js";
import { Tilemap } from "@pixi/tilemap";
type RGB = [number, number, number];
const TILE_SIZE = 36;
const GRID = 20;
const STRIP_TILE = 28;
const NUM_LEVELS = 18;
const NOISE_DENSITY = 0.1;
const COLOR_OFFSET = 0.15;
const MARKER_RADIUS = 6;
const GROUND_LOW: RGB = [30, 22, 12];
const GROUND_HIGH: RGB = [150, 140, 120];
const WATER_SHALLOW: RGB = [80, 150, 210];
const WATER_DEEP: RGB = [10, 25, 90];
const FLOW_RATE = 0.15;
const SOURCE_RATE = 0.05;
const SOURCE_ROW = 10;
const SOURCE_COL = 5;
const WATER_VISIBLE_THRESHOLD = 0.01;
const MAX_TERRAIN = 7;
const WATER_VISIBILITY_DEPTH = 3;
const SENSOR_THRESHOLD = 0.05;
function lerpChannel(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * t);
}
function lerpColor(a: RGB, b: RGB, t: number): RGB {
return [
lerpChannel(a[0], b[0], t),
lerpChannel(a[1], b[1], t),
lerpChannel(a[2], b[2], t),
];
}
function levelIndex(value: number, max: number): number {
return Math.min(
NUM_LEVELS - 1,
Math.max(0, Math.round((value / max) * (NUM_LEVELS - 1))),
);
}
/**
* Packs named groups of same-sized textures into a single atlas.
* Returns the atlas texture and a record of texture arrays keyed by group name,
* where each texture is a sub-rectangle of the atlas.
*/
function buildAtlas(
app: Application,
groups: Record<string, Texture[]>,
tileSize: number,
): { atlas: Texture; textures: Record<string, Texture[]> } {
const entries = Object.entries(groups);
const totalTiles = entries.reduce((sum, [, texs]) => sum + texs.length, 0);
const cols = Math.ceil(Math.sqrt(totalTiles));
const rows = Math.ceil(totalTiles / cols);
const container = new Container();
const layout: { key: string; index: number; col: number; row: number }[] = [];
let tileIndex = 0;
for (const [key, texs] of entries) {
for (let i = 0; i < texs.length; i++) {
const col = tileIndex % cols;
const row = Math.floor(tileIndex / cols);
const sprite = new Sprite(texs[i]);
sprite.x = col * tileSize;
sprite.y = row * tileSize;
container.addChild(sprite);
layout.push({ key, index: i, col, row });
tileIndex++;
}
}
const atlas = app.renderer.generateTexture({
target: container,
resolution: 1,
frame: new Rectangle(0, 0, cols * tileSize, rows * tileSize),
});
const result: Record<string, Texture[]> = {};
for (const { key, index, col, row } of layout) {
if (!result[key]) result[key] = [];
result[key][index] = new Texture({
source: atlas.source,
frame: new Rectangle(col * tileSize, row * tileSize, tileSize, tileSize),
});
}
return { atlas, textures: result };
}
function makeNoiseTileTexture(bg: RGB, fg: RGB, size: number): Texture {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
const imageData = ctx.createImageData(size, size);
const d = imageData.data;
for (let i = 0; i < size * size; i++) {
const c = Math.random() < NOISE_DENSITY ? fg : bg;
d[i * 4] = c[0];
d[i * 4 + 1] = c[1];
d[i * 4 + 2] = c[2];
d[i * 4 + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
return Texture.from(canvas);
}
function makeGroundNoise(size: number): Texture[] {
return Array.from({ length: NUM_LEVELS }, (_, i) => {
const t = i / (NUM_LEVELS - 1);
const bg = lerpColor(GROUND_LOW, GROUND_HIGH, t);
const fg = lerpColor(
GROUND_LOW,
GROUND_HIGH,
Math.min(1, t + COLOR_OFFSET),
);
return makeNoiseTileTexture(bg, fg, size);
});
}
function makeWaterNoise(size: number): Texture[] {
return Array.from({ length: NUM_LEVELS }, (_, i) => {
const t = i / (NUM_LEVELS - 1);
const bg = lerpColor(WATER_SHALLOW, WATER_DEEP, t);
const fg = lerpColor(
WATER_SHALLOW,
WATER_DEEP,
Math.max(0, t - COLOR_OFFSET),
);
return makeNoiseTileTexture(bg, fg, size);
});
}
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;
}
const TEXT_STYLE = new TextStyle({
fill: 0xffffff,
fontSize: 11,
fontFamily: "monospace",
lineHeight: 16,
});
function drawStrips(app: Application) {
const LEFT = 20;
const LABEL_W = 52;
const ROW_H = STRIP_TILE + 4;
const rows = [
{ label: "ground", tiles: makeGroundNoise(STRIP_TILE) },
{ label: "water", tiles: makeWaterNoise(STRIP_TILE) },
];
rows.forEach(({ label, tiles }, row) => {
const y = 8 + row * ROW_H;
const lbl = new Text({ text: label, style: TEXT_STYLE });
lbl.x = LEFT;
lbl.y = Math.round(y + (STRIP_TILE - 12) / 2);
app.stage.addChild(lbl);
tiles.forEach((tex, i) => {
const s = new Sprite(tex);
s.x = LEFT + LABEL_W + i * STRIP_TILE;
s.y = y;
app.stage.addChild(s);
});
});
const topBottom = 8 + rows.length * ROW_H + 10;
return { LEFT, topBottom };
}
function generateTerrain(): number[][] {
const lc = { r: GRID / 2, c: GRID * 0.25 };
const rc = { r: GRID / 2, c: GRID * 0.75 };
const sigma = 3.5;
const grid = Array.from({ length: GRID }, (_, r) =>
Array.from({ length: GRID }, (_, c) => {
const dL = Math.sqrt((r - lc.r) ** 2 + (c - lc.c) ** 2);
const dR = Math.sqrt((r - rc.r) ** 2 + (c - rc.c) ** 2);
return (
1 +
6 * Math.tanh(Math.min(dL, dR) / sigma) +
(Math.random() - 0.5) * 0.15
);
}),
);
const channelRow = 10;
const channelDepth = 1.2;
for (let c = Math.floor(GRID * 0.25); c <= Math.ceil(GRID * 0.75); c++) {
grid[channelRow][c] = Math.max(0, grid[channelRow][c] - channelDepth);
}
return grid;
}
function makeWaterGrid(): number[][] {
return Array.from({ length: GRID }, () => Array(GRID).fill(0));
}
function simStep(terrain: number[][], water: number[][]) {
water[SOURCE_ROW][SOURCE_COL] += SOURCE_RATE;
const delta = makeWaterGrid();
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
if (water[r][c] <= 0) continue;
const surface = terrain[r][c] + water[r][c];
const neighbors: [number, number][] = [];
if (r > 0) neighbors.push([r - 1, c]);
if (r < GRID - 1) neighbors.push([r + 1, c]);
if (c > 0) neighbors.push([r, c - 1]);
if (c < GRID - 1) neighbors.push([r, c + 1]);
let totalDiff = 0;
const diffs: number[] = [];
for (const [nr, nc] of neighbors) {
const nSurface = terrain[nr][nc] + water[nr][nc];
const diff = Math.max(0, surface - nSurface);
diffs.push(diff);
totalDiff += diff;
}
if (totalDiff <= 0) continue;
const maxFlow = Math.min(water[r][c], totalDiff * FLOW_RATE);
for (let i = 0; i < neighbors.length; i++) {
const flow = maxFlow * (diffs[i] / totalDiff);
const [nr, nc] = neighbors[i];
delta[r][c] -= flow;
delta[nr][nc] += flow;
}
}
}
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
water[r][c] = Math.max(0, water[r][c] + delta[r][c]);
}
}
}
function renderTilemap(
tilemap: Tilemap,
groundTex: Texture[],
waterTex: Texture[],
terrain: number[][],
water: number[][],
) {
tilemap.clear();
for (let row = 0; row < GRID; row++) {
for (let col = 0; col < GRID; col++) {
const px = col * TILE_SIZE;
const py = row * TILE_SIZE;
const w = water[row][col];
if (w > WATER_VISIBLE_THRESHOLD) {
tilemap.tile(waterTex[levelIndex(w, WATER_VISIBILITY_DEPTH)], px, py);
} else {
tilemap.tile(
groundTex[levelIndex(terrain[row][col], MAX_TERRAIN)],
px,
py,
);
}
}
}
}
(async () => {
const app = await setup();
const { LEFT, topBottom } = drawStrips(app);
const { atlas, textures: atlasTextures } = buildAtlas(
app,
{
ground: makeGroundNoise(TILE_SIZE),
water: makeWaterNoise(TILE_SIZE),
},
TILE_SIZE,
);
const { ground: groundTex, water: waterTex } = atlasTextures;
const terrain = generateTerrain();
const water = makeWaterGrid();
const mapX = LEFT;
const mapY = topBottom;
const tilemap = new Tilemap(atlas.source);
tilemap.x = mapX;
tilemap.y = mapY;
app.stage.addChild(tilemap);
const sourceMarker = new Graphics();
sourceMarker
.circle(0, 0, MARKER_RADIUS)
.fill(0x00aaff)
.stroke({ width: 1, color: 0xffffff });
sourceMarker.x = mapX + SOURCE_COL * TILE_SIZE + TILE_SIZE / 2;
sourceMarker.y = mapY + SOURCE_ROW * TILE_SIZE + TILE_SIZE / 2;
app.stage.addChild(sourceMarker);
const sensorCells: [number, number][] = [
[0, 0],
[0, GRID - 1],
[GRID - 1, 0],
[GRID - 1, GRID - 1],
];
const sensorMarkers = sensorCells.map(([r, c]) => {
const marker = new Graphics();
marker
.circle(0, 0, MARKER_RADIUS)
.fill(0xff4444)
.stroke({ width: 1, color: 0xffffff });
marker.x = mapX + c * TILE_SIZE + TILE_SIZE / 2;
marker.y = mapY + r * TILE_SIZE + TILE_SIZE / 2;
app.stage.addChild(marker);
return marker;
});
renderTilemap(tilemap, groundTex, waterTex, terrain, water);
const totalTiles = NUM_LEVELS * 2;
const atlasCols = Math.ceil(Math.sqrt(totalTiles));
const atlasRows = Math.ceil(totalTiles / atlasCols);
const atlasW = atlasCols * TILE_SIZE;
const atlasH = atlasRows * TILE_SIZE;
const atlasKB = ((atlasW * atlasH * 4) / 1024).toFixed(1);
const TARGET_FPS = 60;
const TARGET_MS = 1000 / TARGET_FPS;
const BAR_WIDTH = GRID * TILE_SIZE;
const BAR_HEIGHT = 14;
const BAR_X = mapX;
const BAR_Y = mapY + GRID * TILE_SIZE + 12;
const SCALE_Y = BAR_Y + BAR_HEIGHT + 4;
const MS_PER_PX = BAR_WIDTH / TARGET_MS;
const EMA_WINDOW = 60;
const EMA_ALPHA = 2 / (EMA_WINDOW + 1);
const statsText = new Text({ text: "", style: TEXT_STYLE });
statsText.x = BAR_X + BAR_WIDTH + 16;
statsText.y = 8;
app.stage.addChild(statsText);
const timingBar = new Graphics();
timingBar.x = BAR_X;
timingBar.y = BAR_Y;
app.stage.addChild(timingBar);
const scaleBar = new Graphics();
scaleBar
.rect(BAR_X, SCALE_Y, BAR_WIDTH, 2)
.fill(0x888888)
.rect(BAR_X, SCALE_Y - 2, 1, 6)
.fill(0x888888)
.rect(BAR_X + BAR_WIDTH, SCALE_Y - 2, 1, 6)
.fill(0x888888);
app.stage.addChild(scaleBar);
const scaleLabel = new Text({
text: `${TARGET_MS.toFixed(1)}ms (${TARGET_FPS}fps)`,
style: TEXT_STYLE,
});
scaleLabel.x = BAR_X + BAR_WIDTH + 4;
scaleLabel.y = SCALE_Y - 5;
app.stage.addChild(scaleLabel);
const atlasDisplayY = SCALE_Y + 20;
const atlasSprite = new Sprite(atlas);
atlasSprite.x = BAR_X;
atlasSprite.y = atlasDisplayY;
app.stage.addChild(atlasSprite);
const atlasInfoText = new Text({
text: [
`Tile size: ${TILE_SIZE}×${TILE_SIZE}px`,
`Atlas grid: ${atlasCols}×${atlasRows} (${totalTiles} tiles)`,
`Atlas pixels: ${atlasW}×${atlasH}`,
`Atlas size: ${atlasKB} KB`,
].join("\n"),
style: TEXT_STYLE,
});
atlasInfoText.x = BAR_X + atlasW + 12;
atlasInfoText.y = atlasDisplayY;
app.stage.addChild(atlasInfoText);
let emaSim = 0;
let emaRender = 0;
let emaTotal = TARGET_MS;
let lastFrameEnd = performance.now();
let frame = 0;
app.ticker.add(() => {
const frameStart = performance.now();
const totalMs = frameStart - lastFrameEnd;
frame++;
const simStart = performance.now();
simStep(terrain, water);
const simMs = performance.now() - simStart;
const renderStart = performance.now();
renderTilemap(tilemap, groundTex, waterTex, terrain, water);
const renderMs = performance.now() - renderStart;
// Reset after render so the pre-reset state is visible for one frame
const triggered = sensorCells.some(
([r, c]) => water[r][c] > SENSOR_THRESHOLD,
);
sensorCells.forEach(([r, c], i) => {
sensorMarkers[i].tint =
water[r][c] > SENSOR_THRESHOLD ? 0x00ff00 : 0xffffff;
});
if (triggered) water.forEach((row) => row.fill(0));
emaSim += EMA_ALPHA * (simMs - emaSim);
emaRender += EMA_ALPHA * (renderMs - emaRender);
emaTotal += EMA_ALPHA * (totalMs - emaTotal);
const fps = 1000 / emaTotal;
const margin = TARGET_MS - emaSim - emaRender;
statsText.text = [
`sim: ${emaSim.toFixed(2)} ms`,
`render: ${emaRender.toFixed(2)} ms`,
`fps: ${fps.toFixed(1)}`,
`margin: ${margin.toFixed(2)} ms`,
`tick: ${frame}`,
].join("\n");
timingBar.clear();
timingBar.rect(0, 0, BAR_WIDTH, BAR_HEIGHT).fill(0x222222);
let x = 0;
timingBar.rect(x, 0, emaSim * MS_PER_PX, BAR_HEIGHT).fill(0x44aa44);
x += emaSim * MS_PER_PX;
timingBar.rect(x, 0, emaRender * MS_PER_PX, BAR_HEIGHT).fill(0x4488cc);
lastFrameEnd = performance.now();
});
})();