Compare commits
6 commits
c428099b9e
...
1c74aabd53
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c74aabd53 | |||
| a187ccded2 | |||
| 8ad5130466 | |||
| 14208e17fb | |||
| 8dfc6f54bc | |||
| eddef83e5b |
6 changed files with 231 additions and 37 deletions
112
index.html
112
index.html
|
|
@ -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;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: monospace;
|
||||
color: white;
|
||||
padding: 16px;
|
||||
text-shadow: 0 0 3px black;
|
||||
pointer-events: none;
|
||||
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%;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
44
src/GUI.ts
44
src/GUI.ts
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue