feat: draw gol on canvas

This commit is contained in:
log101 2024-05-03 22:27:18 +03:00
parent 3b3e5b9173
commit 070bfd8d62
6 changed files with 583 additions and 86 deletions

View File

@ -22,30 +22,11 @@ const { title } = Astro.props;
</html>
<style is:global>
:root {
--accent: 136, 58, 234;
--accent-light: 224, 204, 250;
--accent-dark: 49, 10, 101;
--accent-gradient: linear-gradient(
45deg,
rgb(var(--accent)),
rgb(var(--accent-light)) 30%,
white 60%
);
--h1-desktop: 3.815rem;
font-size: 18px;
}
html {
font-family: system-ui, sans-serif;
background: #13151a;
background-size: 224px;
}
code {
font-family:
Menlo,
Monaco,
Lucida Console,
Liberation Mono,
DejaVu Sans Mono,
Bitstream Vera Sans Mono,
Courier New,
monospace;
body {
display: flex;
justify-content: center;
}
</style>

View File

@ -1,12 +1,26 @@
---
import { Title } from '../components/header';
import Layout from '../layouts/Layout.astro';
import '../styles/gol.css';
---
<div id="title-and-gol">
<Title />
<Layout title="log101">
<div class="container">
<div class="header">
<h1 class="main-title">log101</h1>
<div id="board" class="board"></div>
</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>

View File

@ -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;
}
}

View File

@ -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 = [];
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 });
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;
}
}
});
});
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'
});
});
// 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);
}
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();

6
src/scripts/utils.js Normal file
View File

@ -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)
}

View File

@ -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;
}