diff --git a/src/App.ts b/src/App.ts index f6facfb..6ef22f1 100644 --- a/src/App.ts +++ b/src/App.ts @@ -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() { diff --git a/src/Config.ts b/src/Config.ts index bfbc4ee..7d4d87d 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -13,6 +13,7 @@ const defaults = { antSpeed: 1, antRotationAngle: Math.PI / 30, brushRadius: 20, + cameraZoom: 0, // per-channel pheromone params repellentFadeOutFactor: 0.0005, repellentBlurRadius: 0.05, diff --git a/src/GUI.ts b/src/GUI.ts index 90e296b..f0806d7 100644 --- a/src/GUI.ts +++ b/src/GUI.ts @@ -44,6 +44,12 @@ class GUIController { .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(); diff --git a/src/scenes/ScreenScene.ts b/src/scenes/ScreenScene.ts index 0b78209..a4ea054 100644 --- a/src/scenes/ScreenScene.ts +++ b/src/scenes/ScreenScene.ts @@ -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(); + } }