From a6376723f5c1cf20f9d2955e38761fd7b954df9b Mon Sep 17 00:00:00 2001 From: michael-lesirge <100492377+michael-lesirge@users.noreply.github.com> Date: Sun, 22 Dec 2024 01:24:47 -0800 Subject: [PATCH] add boids with controls to conway project --- .vscode/settings.json | 2 + conway/index.html | 123 ++++++++--- conway/script.js | 463 +++++++++++++++++++++++++++++++++++++++--- conway/style.css | 45 +++- 4 files changed, 576 insertions(+), 57 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index d103c9c..5ee5377 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,8 @@ { "cSpell.words": [ "Accum", + "boid", + "Boids", "customisable", "Royale", "setpoint", diff --git a/conway/index.html b/conway/index.html index 1eb4e86..8fc20c4 100644 --- a/conway/index.html +++ b/conway/index.html @@ -1,5 +1,6 @@ + @@ -8,11 +9,16 @@ Conway's Game Of Life + Others + + + +
- +
@@ -30,38 +36,93 @@
-
- - - -
-

- Drag mouse to draw cells -

-
-
+
+
-
- - +
+

+ Edit grid to change environment +

+
+ + +
+

+ Drag mouse to draw enabled cells +

+
+ + + + +
+
+

+ Settings to control the behavior of boids +

+
+
+ + + - +
+
+ + + - +
+
+ + + - +
+
+

+ Drag mouse to add boids +

+
+
+ + + - +
+
+ + + - +
+
+ +
+
+

+ Left click to add particle +

+
+
- +
+ + +
- -
+
@@ -70,9 +131,13 @@
+
+ FPS + +
-
+ \ No newline at end of file diff --git a/conway/script.js b/conway/script.js index cb9ca6a..58b23d7 100644 --- a/conway/script.js +++ b/conway/script.js @@ -11,19 +11,30 @@ updateCanvasSizes(canvas, dpr); // -- FPS selection -- const fpsInput = document.getElementById('fps'); +const pauseBtn = document.getElementById("pause-btn"); + let fps = 0; +let lastFPS = fpsInput.value; + +const pauseText = "⏸️ Pause"; +const resumeText = "▶️ Resume"; function updateFPS() { fps = fpsInput.value; document.getElementById('fps-value').textContent = fps; + pauseBtn.textContent = fps == 0 ? resumeText : pauseText; } fpsInput.addEventListener('input', updateFPS); -document.getElementById("pause-btn").addEventListener("click", () => { - fpsInput.value = 0; +pauseBtn.addEventListener("click", (event) => { + if (event.target.textContent == pauseText) { + lastFPS = fpsInput.value; + } + fpsInput.value = event.target.textContent == pauseText ? 0 : lastFPS; updateFPS(); + }); -document.getElementById("fps-btn").addEventListener("click", () => { +document.getElementById("fps-btn").addEventListener("click", (event) => { fpsInput.value = 60; updateFPS(); }); @@ -40,7 +51,8 @@ document.getElementsByName("mode-select-button").forEach((button) => { }); }); -// Conway's Game of Life + +// --- Color Util --- function randRGB() { return [randomFloat(0, 255), randomFloat(0, 255), randomFloat(0, 255)] @@ -67,7 +79,6 @@ function cmyk2rgb(c, m, y, k) { return [r, g, b]; } - function mixCmyks(...cmyks) { let c = cmyks.map(cmyk => cmyk[0]).reduce((a, b) => a + b, 0) / cmyks.length; let m = cmyks.map(cmyk => cmyk[1]).reduce((a, b) => a + b, 0) / cmyks.length; @@ -76,41 +87,81 @@ function mixCmyks(...cmyks) { return [c, m, y, k]; } -function mixRgb(...colors) { +function mixRgb(...colors) { let cmyks = colors.map(color => rgb2cmyk(...color)); let mixed_cmyk = mixCmyks(...cmyks); return cmyk2rgb(...mixed_cmyk); } +function simpleBlendRGB(...colors) { + + const colorCount = colors.length; + let sumR = 0, sumG = 0, sumB = 0; + + for (const color of colors) { + sumR += color[0] ** 2; + sumG += color[1] ** 2; + sumB += color[2] ** 2; + } + + const blendedR = Math.sqrt(sumR / colorCount); + const blendedG = Math.sqrt(sumG / colorCount); + const blendedB = Math.sqrt(sumB / colorCount); + + return [blendedR, blendedG, blendedB]; +} + function rgbToCss(color) { + if (color.length == 4) { + return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3]})` + } return `rgb(${color[0]}, ${color[1]}, ${color[2]})` } function hexToRgb(hex) { - const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, function(m, r, g, b) { - return r + r + g + g + b + b; - }); + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function (m, r, g, b) { + return r + r + g + g + b + b; + }); - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null; + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null; } function rgbToHex(r, g, b) { - return "#" + (1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1); + return "#" + (1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1); +} + +function blendColorValue(a, b, t) { + return Math.sqrt((1 - t) * a ** 2 + t * b ** 2); +} +function smartBlendRGB(c1, c2, t) { + return [blendColorValue(c1[0], c2[0], t), blendColorValue(c1[1], c2[1], t), blendColorValue(c1[2], c2[2], t)]; } +// --- Conway --- + const DEAD = 0; class Conway { - constructor(idealCellSize) { - this.cellSize = idealCellSize; + constructor(gridSize = 10, percentage = 0.5) { this.grid = []; this.nextGrid = []; this.cols = 0; this.rows = 0; this.showGridInput = document.getElementById('show-grid'); + this.gridSizeInput = document.getElementById('grid-size'); + + this.percentage = percentage; + + + this.startGridSize = gridSize; + this.gridSizeInput.value = gridSize; + this.gridSizeInput.addEventListener('input', () => { + this.init(); + }); + this.drawColorInput = document.getElementById('draw-color'); this.randomDrawColorCheckbox = document.getElementById('random-draw-color'); @@ -120,11 +171,15 @@ class Conway { }); } - init(percentage = 0.5) { + init(percentage) { + this.percentage = percentage ?? this.percentage ?? 0.5; + + this.cellSize = parseInt(this.gridSizeInput.value || this.startGridSize); + this.cols = Math.floor(canvas.width / this.cellSize); this.rows = Math.floor(canvas.height / this.cellSize); - this.grid = Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => Math.random() < percentage ? randRGB() : DEAD)); + this.grid = Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => Math.random() < this.percentage ? randRGB() : DEAD)); this.nextGrid = Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => DEAD)); } @@ -180,7 +235,7 @@ class Conway { for (let row = 0; row < this.rows; row++) { for (let col = 0; col < this.cols; col++) { ctx.clearRect(col * this.cellSize, row * this.cellSize, this.cellSize, this.cellSize); - if (this.grid[row][col] !== DEAD) { + if (this.grid[row][col] !== DEAD) { ctx.fillStyle = rgbToCss(this.grid[row][col]); ctx.fillRect(col * this.cellSize, row * this.cellSize, this.cellSize, this.cellSize); } @@ -211,7 +266,7 @@ class Conway { const col = Math.floor(x / this.cellSize); if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) return; - + if (this.randomDrawColorCheckbox.checked) { this.drawColorInput.value = rgbToHex(...randRGB()); } @@ -231,7 +286,7 @@ class Conway { mouseMove(x, y) { if (!this.isMouseDown) return; - + const [lastX, lastY] = this.lastPos || [x, y]; this.drawLine(lastX, lastY, x, y); @@ -266,37 +321,372 @@ class Conway { } } } - + +} + +// --- Boids --- + +const turnFactor = 0.2; +const visualRange = 40; +const protectedRange = 8; +const centeringFactor = 0.0005; +const avoidFactor = 0.05; +const matchingFactor = 0.05; +const maxSpeed = 6; +const minSpeed = 3; +const maxBias = 0.01; +const biasIncrement = 0.00004; +const defaultBiasVal = 0.001; + +const boidsCount = Math.floor(canvas.width * canvas.height / 10000); + +function makeSettings(prefix, values, func = (value) => value, textFunc = (value) => value) { + const settings = {}; + + for (const [element, value] of Object.entries(values)) { + const input = document.getElementById(`${prefix}-${element}`); + const display = document.getElementById(`${prefix}-${element}-value`); + + function set(value) { + input.value = value; + display.textContent = textFunc(value); + } + + settings[element] = { + set: (value) => set(value), + get: () => func(input.value ?? value) + }; + + set(value); + + input.addEventListener("input", (event) => { + set(event.target.value); + }); + } + + return settings; } -const conway = new Conway(10); +const boidMovementSettings = makeSettings("boids", { + "coherence": 100, + "alignment": 100, + "separation": 100, +}, (value) => parseFloat(value) / 100, (value) => `${value}%`); + +const boidSettings = makeSettings("boids", { + "count": boidsCount, + "trail": 0, +}, (value) => Math.floor(value), (value) => Math.floor(value)); + +class Boid { + constructor(group, x, y, vx, vy, rgb, scoutGroup, biasValue = defaultBiasVal) { + this.group = group; + + this.x = x; + this.y = y; + + this.vx = vx; + this.vy = vy; + + this.rgb = rgb; + + this.biasValue = biasValue; + this.scoutGroup = scoutGroup; + + this.trail = []; + + this.trailI = 0; + } + + update() { + let xPosAvg = 0, yPosAvg = 0, xVelAvg = 0, yVelAvg = 0; + let neighboringBoids = 0; + let closeDx = 0, closeDy = 0; + + let closestBoid = null; + let closestDist = Infinity; + + for (const other of this.group.boids) { + if (other === this) continue; + + const dx = this.x - other.x; + const dy = this.y - other.y; + + const dist = Math.hypot(dy, dx); + + if (dist < closestDist) { + closestDist = dist; + closestBoid = other; + } + + if (dist < protectedRange) { + closeDx += dx; + closeDy += dy; + } else if (dist < visualRange) { + xPosAvg += other.x; + yPosAvg += other.y; + xVelAvg += other.vx; + yVelAvg += other.vy; + neighboringBoids++; + } + } + + if (closestBoid && closestDist < visualRange) { + this.rgb = smartBlendRGB(this.rgb, closestBoid.rgb, 0.01); + } + + // Alignment and Cohesion + if (neighboringBoids > 0) { + xPosAvg /= neighboringBoids; + yPosAvg /= neighboringBoids; + xVelAvg /= neighboringBoids; + yVelAvg /= neighboringBoids; + + this.vx += (xPosAvg - this.x) * (centeringFactor * boidMovementSettings["coherence"].get()) + (xVelAvg - this.vx) * (matchingFactor * boidMovementSettings["alignment"].get()); + this.vy += (yPosAvg - this.y) * (centeringFactor * boidMovementSettings["coherence"].get()) + (yVelAvg - this.vy) * (matchingFactor * boidMovementSettings["alignment"].get()); + } + + // Separation + this.vx += closeDx * (avoidFactor * boidMovementSettings["separation"].get()); + this.vy += closeDy * (avoidFactor * boidMovementSettings["separation"].get()); + + // Boundary conditions (stay within the visible boundary) + if (this.x < this.group.boundaryX) this.vx += turnFactor; + if (this.x > this.group.boundaryX + this.group.boundaryWidth) this.vx -= turnFactor; + if (this.y < this.group.boundaryY) this.vy += turnFactor; + if (this.y > this.group.boundaryY + this.group.boundaryHeight) this.vy -= turnFactor; + + // Bias update + if (this.scoutGroup === 1) { + this.biasValue = this.vx > 0 ? Math.min(maxBias, this.biasValue + biasIncrement) : Math.max(biasIncrement, this.biasValue - biasIncrement); + } else if (this.scoutGroup === 2) { + this.biasValue = this.vx < 0 ? Math.min(maxBias, this.biasValue + biasIncrement) : Math.max(biasIncrement, this.biasValue - biasIncrement); + } + + // Apply bias + if (this.scoutGroup === 1) { + this.vx = (1 - this.biasValue) * this.vx + this.biasValue * 1; + } else if (this.scoutGroup === 2) { + this.vx = (1 - this.biasValue) * this.vx - this.biasValue * 1; + } + + // Speed adjustment + const speed = Math.sqrt(this.vx ** 2 + this.vy ** 2); + if (speed < minSpeed) { + this.vx = (this.vx / speed) * minSpeed; + this.vy = (this.vy / speed) * minSpeed; + } + if (speed > maxSpeed) { + this.vx = (this.vx / speed) * maxSpeed; + this.vy = (this.vy / speed) * maxSpeed; + } + + // Update position + this.x += this.vx; + this.y += this.vy; + + // Clamp position + this.x = clamp(this.x, 0, canvas.width); + this.y = clamp(this.y, 0, canvas.height); + + // Trail + this.trailI++; + this.trail.push({x: this.x, y: this.y, rgb: this.rgb}); + } + + draw() { + const angle = Math.atan2(this.vy, this.vx); + + ctx.save(); + + ctx.translate(this.x, this.y); + ctx.rotate(angle); + + const scale = 1; + + const width = 3 * scale; + const height = 7 * scale; + + ctx.beginPath(); + ctx.moveTo(height, 0); + ctx.lineTo(-height, -width); + ctx.lineTo(-height, width); + ctx.closePath(); + + ctx.fillStyle = rgbToCss(this.rgb); + ctx.fill(); + + ctx.restore(); + + while (this.trail.length > boidSettings["trail"].get()) { + this.trail.shift(); + } + for (let i = 1; i < this.trail.length; i++) { + const element = this.trail[i]; + const lastElement = this.trail[i - 1]; + + ctx.save() + ctx.fillStyle = rgbToCss(lastElement.rgb); + + ctx.globalAlpha = i / this.trail.length; + ctx.beginPath(); + ctx.moveTo(element.x, element.y); + ctx.lineTo(lastElement.x, lastElement.y); + ctx.stroke(); + ctx.restore(); + } + } +} + +class Boids { + constructor(percentagePadding = 0.1) { + this.boids = []; + this.percentagePadding = percentagePadding; + + } + + init(boidsCount) { + boidSettings["count"].set(boidsCount); + + this.boundaryWidth = canvas.width * this.percentagePadding; + this.boundaryHeight = canvas.height * this.percentagePadding; + this.boundaryX = (canvas.width - this.boundaryWidth) / 2; + this.boundaryY = (canvas.height - this.boundaryHeight) / 2; + + this.boids = []; + } + + update() { + while (this.boids.length < boidSettings["count"].get()) { + this.boids.push(new Boid( + this, + randomFloat(this.boundaryX, this.boundaryX + this.boundaryWidth), + randomFloat(this.boundaryY, this.boundaryY + this.boundaryHeight), + randomFloat(-1, 1), + randomFloat(-1, 1), + randRGB(), + randomChoice([1, 2]), + )); + } + + while (this.boids.length > boidSettings["count"].get()) { + this.boids.shift(); + } + + while (this.boids.length > 1000) { + this.boids.shift(); + } + + boidSettings["count"].set(this.boids.length); + + for (const boid of this.boids) { + boid.update(); + } + } + + draw() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + this.boids.forEach(boid => boid.draw()); + + ctx.strokeStyle = "rgba(255, 255, 255, 0.2)"; + ctx.lineWidth = 3; + ctx.strokeRect(this.boundaryX, this.boundaryY, this.boundaryWidth, this.boundaryHeight); + } + + click(event) { + this.boids.push( + new Boid( + this, + event.clientX * dpr, + event.clientY * dpr, + randomFloat(-1, 1), + randomFloat(-1, 1), + randRGB(), + randomChoice([1, 2]), + )); + boidSettings["count"].set(this.boids.length); + } + + mouseDown(event) { + this.isMouseDown = true; + } + + mouseUp(event) { + this.isMouseDown = false; + } + + mouseMove(event) { + if (this.isMouseDown) { + this.boids.push(new Boid( + this, + event.clientX * dpr, + event.clientY * dpr, + event.movementX, + event.movementY, + randRGB(), + randomChoice([1, 2]), + )); + boidSettings["count"].set(this.boids.length); + } + } +} + +const conway = new Conway(15); +const boids = new Boids(0.7); // -- Funcs loop -- const inits = { "conway": () => { conway.init(0.1); - canvas.addEventListener("mousemove", (event) => conway.mouseMove(event.clientX * dpr, event.clientY * dpr)); + canvas.addEventListener("mousemove", (event) => conway.mouseMove(event.clientX * dpr, event.clientY * dpr)); canvas.addEventListener("mousedown", (event) => conway.mouseDown(event.clientX * dpr, event.clientY * dpr)); canvas.addEventListener("mouseup", (event) => conway.mouseUp()); - } + canvas.addEventListener("mouseleave", (event) => conway.mouseUp()); + document.getElementById("conway-settings").classList.add("active"); + }, + "boids": () => { + boids.init(boidsCount); + document.getElementById("boids-settings").classList.add("active"); + canvas.addEventListener("click", (event) => boids.click(event)); + canvas.addEventListener("mousemove", (event) => boids.mouseMove(event)); + canvas.addEventListener("mousedown", (event) => boids.mouseDown(event)); + canvas.addEventListener("mouseup", (event) => boids.mouseUp(event)); + canvas.addEventListener("mouseleave", (event) => boids.mouseUp(event)); + }, } const updates = { "conway": () => { conway.update(); + }, + "boids": () => { + boids.update(); } } const clearCanvas = { "conway": () => { conway.init(0); + }, + "boids": () => { + boids.init(0); } } const draws = { "conway": () => { conway.draw(); + }, + "boids": () => { + boids.draw(); + }, + "particle": () => { + ctx.font = "30px Arial"; + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.fillText("TODO: Finish this mode", canvas.width / 2, canvas.height / 2); } } @@ -305,7 +695,14 @@ const clear = { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "black"; ctx.fillRect(0, 0, canvas.width, canvas.height); - } + document.getElementById("conway-settings").classList.remove("active"); + }, + "boids": () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + document.getElementById("boids-settings").classList.remove("active"); + }, } @@ -327,6 +724,8 @@ randomButton.addEventListener('click', () => { let lastTime = 0; let lastMode; +const realFpsOutput = document.getElementById("real-fps"); + function loop() { const currentTime = performance.now(); @@ -339,6 +738,8 @@ function loop() { if (inits[mode]) inits[mode](); } + realFpsOutput.value = Math.round(1000 / deltaTime); + if (deltaTime >= interval) { lastTime = currentTime; if (updates[mode]) updates[mode](); @@ -357,7 +758,7 @@ window.addEventListener("resize", () => { loop(); -// -- Canvas resizing -- +// --- Canvas resizing --- function fitToContainer(canvas) { function updateToContainerOnce() { @@ -382,8 +783,16 @@ function updateCanvasSizes(canvas, dpr = 1) { updateCanvasSizesOnce() } -// -- Utility functions -- +// --- Utility functions --- function randomFloat(min, max) { return Math.random() * (max - min) + min; } + +function randomChoice(values) { + return values[Math.floor(Math.random() * values.length)]; +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} \ No newline at end of file diff --git a/conway/style.css b/conway/style.css index d18ff87..2f2550d 100644 --- a/conway/style.css +++ b/conway/style.css @@ -111,6 +111,39 @@ html, body, main { gap: 0.5rem; } +.mode-settings { + display: none; + opacity: 0; + +} + +.mode-settings.active { + animation: fadeIn 0.5s forwards; + display: flex; + opacity: 1; +} + +.mode-settings-container { + /* height: max(50%, max-content); */ + /* width: max(75%, max-content); */ + width: 75%; + height: 50%; + display: flex; + flex-direction: column; + justify-content: center; +} + +@keyframes fadeIn { + from { + opacity: 0; + display: none; + } + to { + opacity: 1; + display: flex; + } +} + .setting.full { width: 100%; } @@ -147,4 +180,14 @@ html, body, main { .button:active { filter: brightness(1.1); -} \ No newline at end of file +} + +@media screen and (max-width: 768px) { + .main { + grid-template-columns: 1fr; + } + + .settings { + height: 120%; + } +}