From 070bfd8d627e87de93e2ff8582c3231dc729f7bc Mon Sep 17 00:00:00 2001 From: log101 Date: Fri, 3 May 2024 22:27:18 +0300 Subject: [PATCH] feat: draw gol on canvas --- src/layouts/Layout.astro | 29 +- src/pages/index.astro | 28 +- src/scripts/conway-simulator.js | 485 ++++++++++++++++++++++++++++++++ src/scripts/gol.js | 97 ++++--- src/scripts/utils.js | 6 + src/styles/gol.css | 24 +- 6 files changed, 583 insertions(+), 86 deletions(-) create mode 100644 src/scripts/conway-simulator.js create mode 100644 src/scripts/utils.js diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 7b552be..01c09e1 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -22,30 +22,11 @@ const { title } = Astro.props; diff --git a/src/pages/index.astro b/src/pages/index.astro index a02e317..ad68e4d 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,12 +1,26 @@ --- -import { Title } from '../components/header'; +import Layout from '../layouts/Layout.astro'; import '../styles/gol.css'; --- -
- - <div id="board" class="board"></div> -</div> +<Layout title="log101"> + <div class="container"> + <div class="header"> + <h1 class="main-title">log101</h1> + <div id="board" class="board"></div> + </div> -<button id="startButton">Start Game</button> -<script src="../scripts/gol.js"></script> + <button id="startButton">Start Game</button> + <button id="changeWidthButton">Change Width</button> + </div> +</Layout> + +<script> + import ConwaySimulator from '../scripts/conway-simulator'; + + let gol = new ConwaySimulator(10, 46, 10, 250); + document.getElementById('board')?.append(gol.canvas); + gol.start(); + const startButton = document.getElementById('startButton'); + startButton!.onclick = () => gol.stop(); +</script> diff --git a/src/scripts/conway-simulator.js b/src/scripts/conway-simulator.js new file mode 100644 index 0000000..2aebaad --- /dev/null +++ b/src/scripts/conway-simulator.js @@ -0,0 +1,485 @@ +import { heavyWeightSpaceshipCell } from './utils' + +/* + A wrapper for an HTML <canvas> based visualizatiuon of Conway's Game of Life. +*/ +export default class ConwaySimulator { + + /* + Create a new simulation. A simulation is comprised of a + 2D data grid (rows-by-cols) of ConwayPixels, a canvas element, + and a canvas context. + */ + constructor(rows, cols, pixelSize, interRoundDelay) { + this.rows = rows; + this.cols = cols; + this.pixelSize = pixelSize; + this.interRoundDelay = interRoundDelay; + this.mouseIsDown = false; + this.paused = false; + this.intervalId = null; + + // Make the grid + this.grid = []; + for (let i = 0; i < rows; i++) { + this.grid.push([]); + for (let j = 0; j < cols; j++) { + let alive = heavyWeightSpaceshipCell(j, i) + this.grid[i].push(new ConwayPixel(alive)); + } + } + + // Inform each pixel who it's neighbors are (performance optimization) + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + this.grid[i][j].neighbors = this.getNeighbors(i, j); + } + } + + // Setup the canvas + let width = this.pixelSize * this.cols + let height = this.pixelSize * this.rows + this.canvas = document.createElement('canvas'); + this.canvas.width = width; + this.canvas.height = height; + this.canvasCtx = this.canvas.getContext('2d', { alpha: true }); + } + + /* + Starts the simulation via setInterval if it's not running + */ + start() { + if (this.intervalId) { + return; + } + + this.intervalId = setInterval(() => { + this.advanceRound(); + this.repaint(); + }, this.interRoundDelay); + } + + /* + If the simulation is running, stop it using clearInterval + */ + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + /* + Return the neighbors of a particular grid location + */ + getNeighbors(row, col) { + let neighbors = []; + for (let i = row - 1; i <= row + 1; i++) { + for (let j = col - 1; j <= col + 1; j++) { + if (i === row && j === col) continue; + if (this.grid[i] && this.grid[i][j]) { + neighbors.push(this.grid[i][j]); + } + } + } + + return neighbors; + } + + /* + Update the grid according to the rules for each SimEntity + */ + advanceRound() { + if (this.mouseIsDown) return; + + // First prepare each pixel (give it a next state) + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + this.grid[i][j].prepareUpdate(); + } + } + + // Then actually advance them, once all the new states are computed + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + this.grid[i][j].update(); + } + } + } + + /* + Optimized repaint that only updates pixels that have changed, and paints + in batches by color. Using force will repaint all pixels regardless of their + state/previousState/nextState, which is slower. + */ + repaint(force = false) { + if (this.mouseIsDown && !force) return; + + // Canvas optimization -- it's faster to paint by color than placement. + let byColor = {}; + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + let pixel = this.grid[i][j]; + + if (!force && !pixel.forceRepaint && pixel.alive === pixel.previousState) { + continue; // No need to repaint if the pixel didn't change + } + + let color = pixel.alive ? pixel.lifeStyle : pixel.deathStyle; + if (byColor[color] === undefined) { + byColor[color] = [] + } + + byColor[color].push([i, j]); + pixel.forceRepaint = false; // Once a pixel is painted, reset it's forced state + } + } + + for (let color in byColor) { + this.canvasCtx.fillStyle = color; + for (let [row, col] of byColor[color]) { + this.canvasCtx.fillRect( + col * this.pixelSize, + row * this.pixelSize, + this.pixelSize, + this.pixelSize + ); + } + } + } + + /* + Paint an individual pixel. This is not used by repaint because of a batch + optimziation. painting an individual pixel does take place when click events + happen. + */ + paintPixel(row, col) { + this.grid[row][col].setPaintStyles(this.canvasCtx); + this.canvasCtx.fillRect( + col * this.pixelSize, + row * this.pixelSize, + this.pixelSize, + this.pixelSize + ); + } + + /* ============= + Visualizatiuon Modifiers + ================ */ + + /* + Give each entity in the grid an alive style such that when all pixels are alive + the grid would be a rainbow gradient. + */ + setRainbowScheme() { + let rows = this.grid.length; + let cols = this.grid[0].length; + let diagonalLength = Math.sqrt((this.rows * this.rows) + (this.cols * this.cols)); + let hueIncrement = 360 / diagonalLength; + + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols; j++) { + let h = Math.floor(Math.sqrt((i * i) + (j * j)) * hueIncrement); + let px = this.grid[i][j]; + px.lifeStyle = `hsl(${h}, 100%, 60%)`; + px.deathStyle = `#000000`; + px.forceRepaint = true; + } + } + } + + /* + Give each entity in the specified area of the grid an alive style + such that when all pixels are alive the area would be a rainbow gradient. + */ + setRainbowSchemeWithin(startRow, stopRow, startCol, stopCol) { + let rows = stopRow - startRow; + let cols = stopCol - startCol; + let diagonalLength = Math.sqrt((rows * rows) + (cols * cols)); + let hueIncrement = 360 / diagonalLength; + + for (let i = startRow; i < stopRow; i++) { + for (let j = startCol; j < stopCol; j++) { + let h = Math.floor(Math.sqrt((i * i) + (j * j)) * hueIncrement); + let px = this.grid[i][j]; + px.lifeStyle = `hsl(${h}, 100%, 60%)`; + px.deathStyle = `#000000`; + px.forceRepaint = true; + } + } + } + + /* + set colors to the provided parameters + */ + setPixelColors(lifeStyle, deathStyle) { + this.grid.forEach((row) => { + row.forEach((entity) => { + entity.lifeStyle = lifeStyle; + entity.deathStyle = deathStyle; + entity.forceRepaint = true; + }); + }); + } + + /* + Give the board random semi-complementary colors. + */ + setRandomPixelColors() { + let baseHue = randomInteger(1, 360); + let complementaryHue = (baseHue + randomInteger(90, 270) % 360); + this.setPixelColors(`hsl(${baseHue}, 100%, 60%)`, `hsl(${complementaryHue}, 100%, 60%)`) + } + + /* + Given a bounding box, apply the currently selected rules to ONLY the + pixels within the provided box. + */ + applyColorsWithin(rowStart, rowStop, colStart, colStop, lifeStyle, deathStyle) { + for (let i = rowStart; i < rowStop; i++) { + for (let j = colStart; j < colStop; j++) { + let pixel = this.grid[i][j]; + pixel.lifeStyle = lifeStyle; + pixel.deathStyle = deathStyle; + pixel.forceRepaint = true; + } + } + } + + /* + Give a sopecific area of the board random semi-complementary colors. + */ + applyRandomColorsWithin(rowStart, rowStop, colStart, colStop) { + let baseHue = randomInteger(1, 360); + let complementaryHue = (baseHue + randomInteger(90, 270) % 360); + this.applyColorsWithin(rowStart, rowStop, colStart, `hsl(${baseHue}, 100%, 60%)`, `hsl(${complementaryHue}, 100%, 60%)`) + } + + /* + Set all the pixels to alive=false + */ + resetLife(chanceOfLife) { + this.grid.forEach((row) => { + row.forEach((pixel) => { + pixel.previousState = pixel.alive; + pixel.alive = Math.random() < chanceOfLife; + }); + }); + + this.repaint(); + } + + + /* + Given a bounding box, apply the currently selected rules to ONLY the + pixels within the provided box. + */ + resetLifeWithin(rowStart, rowStop, colStart, colStop, chanceOfLife = .1) { + for (let i = rowStart; i < rowStop; i++) { + for (let j = colStart; j < colStop; j++) { + let pixel = this.grid[i][j]; + if (pixel) { + pixel.previousState = pixel.alive; + pixel.alive = Math.random() < chanceOfLife; + } + } + } + + this.repaint(); + } + + /* + Update the rules for all the pixels + */ + setRules(underpopulation, overpopulation, reproductionMin, reproductionMax) { + this.grid.forEach((row) => { + row.forEach((pixel) => { + pixel.underpopulation = underpopulation; + pixel.overpopulation = overpopulation; + pixel.reproductionMin = reproductionMin; + pixel.reproductionMax = reproductionMax; + }); + }); + } + + /* + Swap life and death styles across the center of the grid. + */ + setYinYangMode() { + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.cols / 2; j++) { + let t = this.grid[i][j].lifeStyle; + this.grid[i][j].lifeStyle = this.grid[i][j].deathStyle; + this.grid[i][j].deathStyle = t; + } + } + + this.repaint(true); + } + + /* + Given a bounding box, apply the currently selected rules to ONLY the + pixels within the provided box. + */ + setRulesWithin(rowStart, rowStop, colStart, colStop, underpopulation, overpopulation, reproductionMin, reproductionMax) { + for (let i = rowStart; i < rowStop; i++) { + for (let j = colStart; j < colStop; j++) { + let pixel = this.grid[i][j]; + pixel.underpopulation = underpopulation; + pixel.overpopulation = overpopulation; + pixel.reproductionMin = reproductionMin; + pixel.reproductionMax = reproductionMax; + pixel.forceRepaint = true; + } + } + } + + /* + The grid has click, and click-and-drag functionality. Entities define their + own behavior when clicked, and this function ensures the proper entity is + updated when it is clicked (or dragged-over) + */ + registerMouseListeners() { + bindMultipleEventListener(this.canvas, ['mousemove', 'touchmove'], (e) => { + e.preventDefault(); + if (this.mouseIsDown) { + let x, y; + if (e.touches) { + let rect = e.target.getBoundingClientRect(); + x = Math.floor((e.touches[0].pageX - rect.left) / this.pixelSize); + y = Math.floor((e.touches[0].pageY - rect.top) / this.pixelSize); + } + else { + x = Math.floor(e.offsetX / this.pixelSize); + y = Math.floor(e.offsetY / this.pixelSize); + } + + this.grid[y][x].handleClick(); + this.paintPixel(y, x); + } + }); + + // Capture mouse state for click and drag features + bindMultipleEventListener(this.canvas, ['mousedown', 'touchstart'], (e) => { + e.preventDefault(); + let rect = e.target.getBoundingClientRect(); + let x, y; + if (e.touches) { + let rect = e.target.getBoundingClientRect(); + x = Math.floor((e.touches[0].pageX - rect.left) / this.pixelSize); + y = Math.floor((e.touches[0].pageY - rect.top) / this.pixelSize); + } + else { + x = Math.floor(e.offsetX / this.pixelSize); + y = Math.floor(e.offsetY / this.pixelSize); + } + + this.grid[y][x].handleClick(); + this.paintPixel(y, x); + this.mouseIsDown = true; + }); + + bindMultipleEventListener(this.canvas, ['mouseup', 'touchend'], (e) => { + e.preventDefault(); + this.mouseIsDown = false; + }); + } +} + + +/* + A single pixel within a greater ConwaySimulator. Each ConwayPixel has it's own rules for evolution, + and for performance reason's maintains a list of it's neighbors inside of it's simulator. + + This class is intended as an internal class, and is not exported. Manipulation of individual + ConwayPixels outside of the ConwaySimulator class is not advised. +*/ +class ConwayPixel { + + /* + Constuct a default ConwayPixel, which follows the original Game of Life rules. + */ + constructor(alive) { + this.alive = alive; + this.lifeStyle = '#000000'; + this.deathStyle = '#FFFFFF'; + this.underpopulation = 2; + this.overpopulation = 3; + this.reproductionMin = 3; + this.reproductionMax = 3; + + // Experimental improvement... + this.neighbors = []; + this.nextState = null; + this.previousState = null; + this.forceRepaint = true; + + // Reproduction min cannot be more than reproduction max + if (this.reproductionMax < this.reproductionMin) { + this.reproductionMin = this.reproductionMax + } + } + + /* + In order to process whole rounds at a time, update returns + a replacement entity, it does not edit the entity in place. + + Following the rules for conway's game of life: + Any live cell with fewer than two live neighbors dies, + as if caused by underpopulation. + + Any live cell with two or three live neighbors lives on + to the next generation. + + Any live cell with more than three live neighbors dies, + as if by overpopulation. + + Any dead cell with exactly three live neighbors becomes + a live cell, as if by reproduction. + + */ + prepareUpdate() { + let sum = 0; + let nextState = this.alive; + + for (let n of this.neighbors) { + if (n.alive && n !== this) sum++; + } + + if (nextState && sum < this.underpopulation) { + nextState = false; + } + else if (nextState && sum > this.overpopulation) { + nextState = false; + } + else if (!nextState && sum >= this.reproductionMin && sum <= this.reproductionMax) { + nextState = true; + } + + this.nextState = nextState; + } + + /* + Advance this pixel to it's nextState. + */ + update() { + this.previousState = this.alive; + this.alive = this.nextState; + this.nextState = null; + } + + /* + The calling context infers that a click HAS occured, this is not a mouse; + this is not an event listener. + */ + handleClick() { + this.alive = true; + } + + /* + Provided with a canvas context, paint ourselves! + */ + setPaintStyles(canvasCtx) { + canvasCtx.fillStyle = this.alive ? this.lifeStyle : this.deathStyle; + } +} diff --git a/src/scripts/gol.js b/src/scripts/gol.js index 33c7cf4..a3f4d44 100644 --- a/src/scripts/gol.js +++ b/src/scripts/gol.js @@ -1,7 +1,9 @@ const boardElement = document.getElementById('board'); -const boardSize = 10; -const grid = []; -let intervalId; +let boardWidth = 100; // Width of the board +const boardHeight = 7; // Height of the board +const currentGrid = []; // Current state of the grid +const nextGrid = []; // Next state of the grid +let intervalId; // ID for the setInterval const cells = ['30', '20', '51', '01', '62', '63', '03', '64', '54', '44', '34', '24', '14'] @@ -11,49 +13,44 @@ const heavyWeightSpaceshipCell = (x, y) => { } function initializeBoard() { - for (let y = 0; y < boardSize; y++) { - let row = []; - for (let x = 0; x < boardSize; x++) { + for (let y = 0; y < boardHeight; y++) { + currentGrid[y] = []; + nextGrid[y] = []; + for (let x = 0; x < boardWidth; x++) { let cell = document.createElement('div'); cell.classList.add('cell'); + boardElement.appendChild(cell); if (heavyWeightSpaceshipCell(x, y)) { - cell.classList.add('alive'); - boardElement.appendChild(cell); - row.push(1) + cell.classList.add('alive') + currentGrid[y][x] = 1; + nextGrid[y][x] = 0; } else { - boardElement.appendChild(cell); - row.push(0); + currentGrid[y][x] = 0; + nextGrid[y][x] = 0; } - } - grid.push(row); } } function computeNextGeneration() { - let changes = []; + for (let y = 0; y < boardHeight; y++) { + for (let x = 0; x < boardWidth; x++) { + const aliveNeighbors = countAliveNeighbors(y, x); + const cell = currentGrid[y][x]; + nextGrid[y][x] = (cell === 1 && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (cell === 0 && aliveNeighbors === 3) ? 1 : 0; + } + } - grid.forEach((row, y) => { - row.forEach((cell, x) => { - let aliveNeighbors = countAliveNeighbors(y, x); - if (cell === 1 && (aliveNeighbors < 2 || aliveNeighbors > 3)) { - changes.push({ y, x, state: 0 }); - } else if (cell === 0 && aliveNeighbors === 3) { - changes.push({ y, x, state: 1 }); + // Apply changes and minimize DOM updates + for (let y = 0; y < boardHeight; y++) { + for (let x = 0; x < boardWidth; x++) { + if (currentGrid[y][x] !== nextGrid[y][x]) { + const cellElement = boardElement.children[y * boardWidth + x]; + cellElement.classList.toggle('alive', nextGrid[y][x] === 1); } - }); - }); - - changes.forEach(change => { - grid[change.y][change.x] = change.state; - const cellElement = boardElement.children[change.y * boardSize + change.x]; - cellElement.animate([ - { backgroundColor: change.state ? 'black' : 'white' } - ], { - duration: 50, - fill: 'forwards' - }); - }); + currentGrid[y][x] = nextGrid[y][x]; + } + } } function countAliveNeighbors(y, x) { @@ -61,23 +58,43 @@ function countAliveNeighbors(y, x) { for (let yOffset = -1; yOffset <= 1; yOffset++) { for (let xOffset = -1; xOffset <= 1; xOffset++) { if (yOffset === 0 && xOffset === 0) continue; - // Wrap around the edges - let newY = (y + yOffset + boardSize) % boardSize; - let newX = (x + xOffset + boardSize) % boardSize; - count += grid[newY][newX]; + const newY = (y + yOffset + boardHeight) % boardHeight; + const newX = (x + xOffset + boardWidth) % boardWidth; + count += currentGrid[newY][newX]; } } return count; } function startGame() { - console.log('starting') if (intervalId) clearInterval(intervalId); - intervalId = setInterval(computeNextGeneration, 500); + intervalId = setInterval(computeNextGeneration, 100); +} + +function changeWidth() { + if (intervalId) clearInterval(intervalId); // stop the game + + document.querySelectorAll(".cell").forEach(el => el.remove()); // remove the cells + + var r = document.querySelector(':root') + boardWidth += 50; + boardWidth %= 200; + + + r.style.setProperty('--board-width', boardWidth) + initializeBoard() } const startButton = document.getElementById('startButton') startButton.onclick = startGame +const changeWidthButton = document.getElementById('changeWidthButton') + +changeWidthButton.onclick = changeWidth + +window.onresize = () => { + console.log(document.getElementById('board').clientWidth) +} + initializeBoard(); diff --git a/src/scripts/utils.js b/src/scripts/utils.js new file mode 100644 index 0000000..67980d4 --- /dev/null +++ b/src/scripts/utils.js @@ -0,0 +1,6 @@ +const cells = ['30', '20', '51', '01', '62', '63', '03', '64', '54', '44', '34', '24', '14'] + +export const heavyWeightSpaceshipCell = (x, y) => { + const coor = `${x}${y}` + return cells.includes(coor) +} diff --git a/src/styles/gol.css b/src/styles/gol.css index 43153fe..af34d9f 100644 --- a/src/styles/gol.css +++ b/src/styles/gol.css @@ -1,20 +1,14 @@ -.board { - display: grid; - grid-template-columns: repeat(10, 5px); - row-gap: 1px; - column-gap: 1px; -} -.cell { - width: 5px; - height: 5px; - background-color: white; -} -.alive { - background-color: black; +.container { + width: 710; } -#title-and-gol { +.main-title { + font-family: 'Courier New', Courier, monospace; + font-size: var(--h1-desktop); + max-width: 250; +} + +.header { display: flex; - flex-direction: row; align-items: center; }