Compare commits

..

6 commits

Author SHA1 Message Date
1c74aabd53
Change from blue to red 2026-03-10 11:04:28 -04:00
a187ccded2
Fix zooming 2026-03-10 11:04:13 -04:00
8ad5130466
Persist config changes in local storage 2026-03-10 10:53:52 -04:00
14208e17fb
Move panels to the left 2026-03-10 10:53:52 -04:00
8dfc6f54bc
Disable culling 2026-03-10 10:53:44 -04:00
eddef83e5b
Add neocities deploy 2026-03-10 10:53:44 -04:00
6 changed files with 231 additions and 37 deletions

View file

@ -8,29 +8,115 @@
body {
margin: 0;
overflow: hidden;
display: flex;
}
canvas {
width: 100%;
height: 100%;
#sidebar {
width: 220px;
min-width: 220px;
height: 100vh;
background: #0a0a0a;
overflow-y: auto;
display: flex;
flex-direction: column;
}
#info {
position: absolute;
top: 0;
left: 0;
font-family: monospace;
color: #777;
padding: 12px;
font-size: 12px;
line-height: 2;
border-bottom: 1px solid #1a1a1a;
}
#info kbd {
display: inline-block;
background: #1a1a1a;
color: #c43c3c;
padding: 1px 5px;
border-radius: 3px;
font-family: monospace;
font-size: 11px;
}
#info .label {
display: inline-block;
width: 65px;
}
.reset-btn {
width: calc(100% - 16px);
margin: 12px 8px;
padding: 6px;
background: #141414;
color: #666;
border: 1px solid #2a2a2a;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
cursor: pointer;
}
.reset-btn:hover {
background: #1a1a1a;
color: #c43c3c;
}
#gui-container {
flex: 1;
}
/* lil-gui noir + red theme */
#gui-container .lil-gui {
--background-color: #0a0a0a;
--widget-color: #1a1a1a;
--hover-color: #222;
--focus-color: #2a2a2a;
--number-color: #c43c3c;
--string-color: #c43c3c;
--text-color: #888;
--title-background-color: #0f0f0f;
--title-text-color: #999;
--folder-indent: 8px;
}
/* lil-gui overrides: fill sidebar, labels on top */
#gui-container .lil-gui.root {
width: 100% !important;
position: static;
}
#gui-container .lil-gui .controller {
flex-wrap: wrap;
}
#gui-container .lil-gui .controller .name {
width: 100%;
text-align: left;
font-family: monospace;
color: white;
padding: 16px;
text-shadow: 0 0 3px black;
pointer-events: none;
min-width: 100%;
padding-bottom: 2px;
}
#gui-container .lil-gui .controller .widget {
min-width: 100%;
}
canvas {
flex: 1;
min-width: 0;
height: 100vh;
}
</style>
</head>
<body>
<div id="info">Controls:<br/>Q - draw home cells<br/>W - draw food cells<br/>E - draw obstacle<br/>R - erase<br/>Drag and scroll to move the camera</div>
<div id="sidebar">
<div id="info">
<kbd>Q</kbd> <span class="label">home</span><kbd>W</kbd> food<br/>
<kbd>E</kbd> <span class="label">obstacle</span><kbd>R</kbd> erase<br/>
drag + scroll to move camera
</div>
<div id="gui-container"></div>
</div>
<canvas id="canvas"></canvas>
<script type="module" src="/src/App.ts"></script>
</body>

View file

@ -39,6 +39,10 @@ export default new (class App {
this.gui.on("reset", () => {
this.resetRenderer();
});
this.gui.on("zoomChange", () => {
this.scenes.screen.applyCameraZoom();
});
}
private resetRenderer() {
@ -57,8 +61,9 @@ export default new (class App {
}
private resize() {
const width = window.innerWidth * window.devicePixelRatio;
const height = window.innerHeight * window.devicePixelRatio;
const canvas = this.renderer.canvas;
const width = canvas.clientWidth * window.devicePixelRatio;
const height = canvas.clientHeight * window.devicePixelRatio;
this.renderer.resizeCanvas(width, height);

View file

@ -1,4 +1,6 @@
export default {
const STORAGE_KEY = "ants-simulation-config";
const defaults = {
worldSize: 1024,
antsCount: 12,
simulationStepsPerSecond: 60,
@ -11,9 +13,48 @@ export default {
antSpeed: 1,
antRotationAngle: Math.PI / 30,
brushRadius: 20,
cameraZoom: 0,
// per-channel pheromone params
repellentFadeOutFactor: 0.0005,
repellentBlurRadius: 0.05,
repellentMaxPerCell: 10,
repellentThreshold: 0.01,
};
type ConfigType = typeof defaults;
function loadSaved(): Partial<ConfigType> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {
// corrupted data, ignore
}
return {};
}
const saved = loadSaved();
const Config: ConfigType = { ...defaults, ...saved };
export function saveConfig(): void {
const toSave: Partial<ConfigType> = {};
for (const key of Object.keys(defaults) as (keyof ConfigType)[]) {
if (Config[key] !== defaults[key]) {
// biome-ignore lint/suspicious/noExplicitAny: generic key/value copy
(toSave as any)[key] = Config[key];
}
}
if (Object.keys(toSave).length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} else {
localStorage.removeItem(STORAGE_KEY);
}
}
export function resetConfig(): void {
Object.assign(Config, defaults);
localStorage.clear();
}
export { defaults };
export default Config;

View file

@ -1,11 +1,11 @@
import GUI from "lil-gui";
import Config from "./Config";
import Config, { resetConfig, saveConfig } from "./Config";
type EventHandler = () => void;
class GUIController {
private gui: GUI = new GUI({
width: 400,
container: document.getElementById("gui-container") as HTMLElement,
});
private listeners: Map<string, EventHandler[]> = new Map();
@ -16,33 +16,56 @@ class GUIController {
.add(Config, "worldSize", 256, 4096)
.name("World size")
.step(1)
.onChange(() => this.emit("reset"));
.onChange(() => this.saveAndEmit("reset"));
simFolder
.add(Config, "antsCount", 0, 22)
.name("Ants count 2^")
.step(1)
.onChange(() => this.emit("reset"));
.onChange(() => this.saveAndEmit("reset"));
simFolder
.add(Config, "scentFadeOutFactor", 0, 0.01)
.name("Pheromone evaporation factor")
.step(0.0001)
.onChange(() => this.emit("reset"));
.onChange(() => this.saveAndEmit("reset"));
simFolder
.add(Config, "scentBlurRadius", 0, 0.5)
.name("Pheromone diffusion factor")
.step(0.01)
.onChange(() => this.emit("reset"));
.onChange(() => this.saveAndEmit("reset"));
simFolder
.add(Config, "simulationStepsPerSecond", 1, 500)
.name("Simulation steps per second")
.step(1);
.step(1)
.onChange(() => saveConfig());
const controlsFolder = this.gui.addFolder("Controls");
controlsFolder.add(Config, "brushRadius", 1, 100).name("Brush radius");
controlsFolder
.add(Config, "brushRadius", 1, 100)
.name("Brush radius")
.onChange(() => saveConfig());
controlsFolder
.add(Config, "cameraZoom")
.name("Zoom")
.step(0.1)
.listen()
.onChange(() => this.emit("zoomChange"));
simFolder.open();
controlsFolder.open();
const resetBtn = document.createElement("button");
resetBtn.textContent = "Reset to defaults";
resetBtn.className = "reset-btn";
resetBtn.addEventListener("click", () => {
resetConfig();
for (const c of this.gui.controllersRecursive()) {
c.updateDisplay();
}
this.emit("reset");
});
// biome-ignore lint/style/noNonNullAssertion: gui-container exists in index.html
document.getElementById("gui-container")!.appendChild(resetBtn);
}
on(event: string, handler: EventHandler): void {
@ -53,6 +76,11 @@ class GUIController {
this.listeners.get(event)!.push(handler);
}
private saveAndEmit(event: string): void {
saveConfig();
this.emit(event);
}
private emit(event: string): void {
const handlers = this.listeners.get(event);
if (handlers) {

View file

@ -196,8 +196,7 @@ export default class Renderer {
}
public resizeCanvas(width: number, height: number) {
this.canvas.width = width;
this.canvas.height = height;
this.renderer.setSize(width, height, false);
}
public getCommonMaterialDefines(): Record<string, string> {

View file

@ -22,7 +22,7 @@ export default class ScreenScene extends AbstractScene {
public readonly groundMaterial: THREE.ShaderMaterial;
public readonly pointerPosition: THREE.Vector2 = new THREE.Vector2();
public drawMode: PointerState = PointerState.None;
private cameraZoomLinear: number = 0;
// zoom stored in Config.cameraZoom
private isPointerDown: boolean = false;
public renderWidth: number = 1;
public renderHeight: number = 1;
@ -90,8 +90,9 @@ export default class ScreenScene extends AbstractScene {
this.renderer.canvas.addEventListener("pointerdown", (e) => {
this.isPointerDown = true;
raycastVector.x = (e.clientX / window.innerWidth) * 2 - 1;
raycastVector.y = -(e.clientY / window.innerHeight) * 2 + 1;
const rect = this.renderer.canvas.getBoundingClientRect();
raycastVector.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
raycastVector.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(raycastVector, this.camera);
@ -105,17 +106,18 @@ export default class ScreenScene extends AbstractScene {
this.renderer.canvas.addEventListener("pointermove", (e) => {
if (this.isPointerDown) {
const rect = this.renderer.canvas.getBoundingClientRect();
const dx = e.movementX;
const dy = e.movementY;
this.camera.position.x -=
dx / window.innerHeight / this.camera.zoom;
this.camera.position.y +=
dy / window.innerHeight / this.camera.zoom;
this.camera.position.x -= dx / rect.height / this.camera.zoom;
this.camera.position.y += dy / rect.height / this.camera.zoom;
this.clampCamera();
}
raycastVector.x = (e.clientX / window.innerWidth) * 2 - 1;
raycastVector.y = -(e.clientY / window.innerHeight) * 2 + 1;
const rect = this.renderer.canvas.getBoundingClientRect();
raycastVector.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
raycastVector.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(raycastVector, this.camera);
@ -136,9 +138,11 @@ export default class ScreenScene extends AbstractScene {
});
this.renderer.canvas.addEventListener("wheel", (e) => {
this.cameraZoomLinear -= e.deltaY * 0.001;
Config.cameraZoom -= e.deltaY * 0.001;
Config.cameraZoom = Math.max(-1, Config.cameraZoom);
this.updateCameraZoom();
this.clampCamera();
});
window.addEventListener("keydown", (e) => {
@ -168,10 +172,36 @@ export default class ScreenScene extends AbstractScene {
}
private updateCameraZoom() {
this.camera.zoom = 2 ** this.cameraZoomLinear;
this.camera.zoom = 2 ** Config.cameraZoom;
this.camera.updateProjectionMatrix();
}
private clampCamera() {
// world plane spans from -0.5 to 0.5 in both axes
// visible half-extents from camera center
const halfW =
(this.camera.right - this.camera.left) / 2 / this.camera.zoom;
const halfH =
(this.camera.top - this.camera.bottom) / 2 / this.camera.zoom;
// camera center can go at most to world edge + half viewport,
// but never so far that the world leaves the screen entirely.
// when zoomed out (halfW > 0.5), the world fits in view — lock to center.
const minX = halfW >= 0.5 ? 0 : -0.5 + halfW;
const maxX = halfW >= 0.5 ? 0 : 0.5 - halfW;
const minY = halfH >= 0.5 ? 0 : -0.5 + halfH;
const maxY = halfH >= 0.5 ? 0 : 0.5 - halfH;
this.camera.position.x = Math.max(
minX,
Math.min(maxX, this.camera.position.x),
);
this.camera.position.y = Math.max(
minY,
Math.min(maxY, this.camera.position.y),
);
}
private createInstancedAntsMesh() {
if (this.ants) {
this.remove(this.ants);
@ -216,4 +246,9 @@ export default class ScreenScene extends AbstractScene {
}
public update() {}
public applyCameraZoom() {
this.updateCameraZoom();
this.clampCamera();
}
}