Compare commits

...

3 Commits

Author SHA1 Message Date
Ali Gator a3102c895c renderer module 2022-12-23 16:41:03 +01:00
Ali Gator faee8f61d5 board module 2022-12-23 12:32:59 +01:00
Ali Gator f351f37c46 unit test dependancies 2022-12-23 12:27:58 +01:00
13 changed files with 2503 additions and 142 deletions

2053
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,9 @@
"build": "vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src",
"format": "prettier . --write"
"format": "prettier . --write",
"coverage": "vitest run --coverage",
"test": "vitest"
},
"dependencies": {
"@vitejs/plugin-vue": "^3.2.0",
@ -17,10 +19,13 @@
"vue": "3.2"
},
"devDependencies": {
"@testing-library/vue": "^6.6.1",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-vue": "^9.8.0",
"prettier": "2.8.0"
"happy-dom": "^8.1.1",
"prettier": "2.8.0",
"vitest": "^0.26.2"
},
"eslintConfig": {
"root": true,

View File

@ -16,8 +16,8 @@
const onResize = () => {
nextTick(() => {
windowWidth.value = window.innerWidth;
store.canvasWidth = window.innerWidth;
store.canvasHeight = window.innerHeight;
// TODO: changing the width will clear the canvas. Find something else
store.renderer.resize(window.innerWidth, window.innerHeight);
store.setBoardWidth();
store.setBoardHeight();
});

View File

@ -2,9 +2,6 @@
import { onMounted, watch } from "vue";
import { globalStore } from "../stores/index.js";
import {
create1dState,
create1dStateOneCell,
create2dState,
createBoard,
conwayRules,
overpopulationRules,
@ -13,18 +10,14 @@
highLifeRules,
servietteRules,
evolve2d,
} from "../modules/automata.js";
import { getRandomInt } from "../modules/common.js";
import { boardToPic, picToBoard } from "../modules/picture.js";
} from "../modules/core.js";
import {
create1dInitialState,
create2dRandomGrid,
} from "../modules/board.js";
const store = globalStore();
// TODO: Do we really need to declare a work canvas in this scope?
// Do we really need to declare a canvas here at all?
let canvas = null;
let workCanvas = null;
let workCtx = null;
let ctx = null;
const available2dRules = {
conway: conwayRules,
overpopulation: overpopulationRules,
@ -35,8 +28,9 @@
};
// used to determine the dimensions of the board
// TODO: should be a Board method
const max = () => {
return Math.max(store.boardWidth, store.boardHeight);
return Math.max(store.board.width, store.board.height);
};
const selectedRules = () => {
@ -79,66 +73,46 @@
);
onMounted(() => {
canvas = Object.freeze(document.getElementById("board-canvas"));
workCanvas = Object.freeze(document.getElementById("work-canvas"));
ctx = canvas.getContext("2d", { willReadFrequently: true });
workCtx = workCanvas.getContext("2d", {
// TODO : butt ugly.
const canvas = document.getElementById("board-canvas");
store.renderer.width = canvas.parentElement.clientWidth;
store.renderer.height = canvas.parentElement.clientHeight;
store.renderer.canvas = canvas;
store.renderer.ctx = store.renderer.canvas.getContext("2d", {
willReadFrequently: true,
});
store.renderer.workCanvas = new OffscreenCanvas(
canvas.parentElement.clientWidth,
canvas.parentElement.clientHeight
);
store.renderer.workCtx = store.renderer.workCanvas.getContext("2d", {
willReadFrequently: true,
});
store.canvasWidth = canvas.parentElement.clientWidth;
store.canvasHeight = canvas.parentElement.clientHeight;
store.setBoardWidth();
store.setBoardHeight();
});
// draws the board on the canvas
const drawCanvas = async (board, width, height) => {
const d = store.cellProperties.size;
// bool to RGBA colors
const img = await boardToPic(board, width, height, store.cellProperties);
// rescale and draw
ctx.save();
ctx.clearRect(0, 0, store.canvasWidth, store.canvasHeight);
workCtx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.scale(d, d);
ctx.drawImage(workCanvas, 0, 0, store.canvasWidth, store.canvasHeight);
ctx.restore();
};
// create a first state, either a single living cell
// at the center or random ones
const compute1dInitialState = () => {
if (store.initial1dState === "onecell")
return create1dStateOneCell(store.boardWidth);
return create1dState(store.boardWidth, getRandomInt, [0, 2]);
};
// initialize board with random cells
const randomInitialState = () => {
return create2dState(
store.boardWidth,
store.boardHeight,
getRandomInt,
[0, 2]
);
const drawCanvas = async (board) => {
store.renderer.render(board);
};
// draw elementary automaton on the canvas based on selected ruleset
const draw1d = () => {
const initialState = compute1dInitialState();
const initialState = create1dInitialState(
store.board,
store.initial1dState
);
const board = createBoard(initialState, store.ruleset1d.rules, max());
store.lastBoard = Object.freeze(board);
// TODO: the board clearly could be an object
drawCanvas(store.lastBoard, store.boardWidth, store.boardHeight);
store.board.grid = Object.freeze(board);
drawCanvas(store.board);
store.toggleStop();
};
// draw 2D automaton on the canvas in a loop
const draw2d = (board) => {
const newBoard = evolve2d(board, selectedRules());
drawCanvas(newBoard, store.boardWidth, store.boardHeight);
store.lastBoard = Object.freeze(newBoard);
store.board.grid = Object.freeze(evolve2d(board.grid, selectedRules()));
drawCanvas(store.board);
};
// draw 2d automaton in a loop, starting from passed state
@ -147,7 +121,7 @@
requestAnimationFrame(() => {
if (!store.canDraw) return;
draw2d(board);
return draw2dNext(store.lastBoard);
return draw2dNext(store.board);
});
}, store.refreshRate);
};
@ -155,59 +129,39 @@
// draw 2d automaton from a new state
const draw2dNew = async () => {
if (!store.canDraw) return;
const initialState = randomInitialState();
const board = evolve2d(initialState, selectedRules());
if (store.loop) return draw2dNext(board);
else draw2d(board);
const initialGrid = create2dRandomGrid(
store.board.width,
store.board.height
);
store.board.grid = Object.freeze(evolve2d(initialGrid, selectedRules()));
if (store.loop) return draw2dNext(store.board);
else draw2d(store.board);
store.toggleStop();
};
// draw 2d automaton from the last known generated board
const draw2dLast = async () => {
if (!store.canDraw) return;
if (store.loop) return draw2dNext(store.lastBoard);
else draw2d(store.lastBoard);
if (store.loop) return draw2dNext(store.board);
else draw2d(store.board);
store.toggleStop();
};
// draw 2d automaton from an uploaded picture.
// use the picture representation as an initial state
const draw2dPicture = () => {
// draw image on canvas
ctx.fillStyle = "black";
ctx.fillRect(0, 0, store.canvasWidth, store.canvasHeight);
ctx.drawImage(
store.renderer.renderImage(store.picture);
const newBoard = store.renderer.getResizedImageData(
store.picture,
Math.floor((store.canvasWidth - store.picture.width) / 2),
Math.floor((store.canvasHeight - store.picture.height) / 2),
store.picture.width,
store.picture.height
store.board
);
// draw the image back on the work canvas with the dimensions of the board
workCtx.drawImage(store.picture, 0, 0, store.boardWidth, store.boardHeight);
// get the resized image data from work canvas
const resized = workCtx.getImageData(
0,
0,
store.boardWidth,
store.boardHeight
);
// convert the image into a 2D board of boolean based on pixel value
store.lastBoard = Object.freeze(
picToBoard(resized.data, store.boardWidth, store.boardHeight)
);
store.toggleStop();
store.board.grid = Object.freeze(newBoard);
};
// stop drawing routines and clear the canvas
const reset = () => {
store.toggleStop();
store.lastBoard = null;
ctx.clearRect(0, 0, store.canvasWidth, store.canvasHeight);
store.board.grid = null;
store.renderer.reset();
store.reset = false;
};
</script>
@ -215,14 +169,8 @@
<canvas
id="board-canvas"
ref="board-canvas"
:width="store.canvasWidth"
:height="store.canvasHeight"
/>
<canvas
id="work-canvas"
ref="work-canvas"
:width="store.canvasWidth"
:height="store.canvasHeight"
:width="store.renderer.width"
:height="store.renderer.height"
/>
</template>
<style>

View File

@ -5,17 +5,14 @@
const store = globalStore();
const updateCellProperties = (event) => {
const elem = event.target;
store.cellProperties[elem.name] = elem.value;
const { name, value } = event.target;
store.setCellProperties(name, value);
store.setBoardWidth();
store.setBoardHeight();
};
const switchColor = () => {
[store.cellProperties["liveColor"], store.cellProperties["deadColor"]] = [
store.cellProperties["deadColor"],
store.cellProperties["liveColor"],
];
store.switchColor();
};
</script>
@ -27,7 +24,7 @@
<input
name="liveColor"
type="color"
:value="store.cellProperties.liveColor"
:value="store.board.cellProperties.liveColor"
@input="updateCellProperties"
/>
</div>
@ -36,7 +33,7 @@
<input
name="deadColor"
type="color"
:value="store.cellProperties.deadColor"
:value="store.board.cellProperties.deadColor"
@input="updateCellProperties"
/>
</div>
@ -49,7 +46,7 @@
name="size"
type="number"
min="1"
:value="store.cellProperties.size"
:value="store.board.cellProperties.size"
@click="updateCellProperties"
/>
</div>

View File

@ -6,17 +6,17 @@
const updateCanvasHeight = (event) => {
const elem = event.target;
store.canvasHeight = elem.value;
store.renderer.resize(elem.value, store.renderer.width);
};
const updateCanvasWidth = (event) => {
const elem = event.target;
store.canvasWidth = elem.value;
store.renderer.resize(elem.value, store.renderer.height);
};
const updateRefreshRate = (event) => {
const elem = event.target;
store.refreshRate = elem.value;
store.renderer.refreshRate = elem.value;
};
const updateDrawingDirection = (event) => {
@ -39,7 +39,7 @@
name="canvasWidth"
type="number"
step="10"
:value="store.canvasWidth"
:value="store.renderer.width"
@input="updateCanvasWidth"
/>
</div>
@ -50,7 +50,7 @@
name="canvasHeight"
type="number"
step="10"
:value="store.canvasHeight"
:value="store.renderer.height"
@input="updateCanvasHeight"
/>
</div>
@ -64,7 +64,7 @@
type="number"
min="100"
step="100"
:value="store.refreshRate"
:value="store.renderer.refreshRate"
@input="updateRefreshRate"
/>
</div>

28
src/modules/board.js Normal file
View File

@ -0,0 +1,28 @@
import { create2dState, create1dStateOneCell, create1dState } from "./core.js";
import { getRandomInt } from "./common.js";
function Board(width, height, grid = []) {
this.width = width;
this.height = height;
(this.cellProperties = {
size: 3,
liveColor: "#000000",
deadColor: "#F5F5F5",
}),
(this.grid = grid);
}
// create a first state, either a single living cell
// at the center or random ones
const create1dInitialState = (board, type = "onecell") => {
if (type === "onecell") return create1dStateOneCell(board.width);
return create1dState(board.width, getRandomInt, [0, 2]);
};
// initialize 2d board with random cells
const create2dRandomGrid = (width, height) => {
return create2dState(width, height, getRandomInt, [0, 2]);
};
export { Board, create1dInitialState, create2dRandomGrid };

216
src/modules/core.js Normal file
View File

@ -0,0 +1,216 @@
// core functions to generate initial states and evolve them
// handles negative index and index bigger than its array length
function guard(index, arrayLength) {
if (index > arrayLength - 1) return 0;
if (index < 0) return arrayLength - 1;
return index;
}
// get the next evolution of a 1D CA initial state
function evolve1d(state, rules) {
function getCell(index) {
const safeIndex = guard(index, state.length);
return state[safeIndex];
}
const newState = state.map((_, x) => {
const cells = [getCell(x - 1), getCell(x), getCell(x + 1)];
return rules[cells.join("")];
});
return newState.map(Number);
}
// create a 2D board from a 1D CA initial state
// function createBoard(state, rules, height) {
// function createBoardAcc(s, h, acc) {
// if (h === 0) return acc;
// const newState = evolve1d(s, rules);
// const newAcc = acc.concat([s]);
// return createBoardAcc(newState, h - 1, newAcc);
// }
// return createBoardAcc(state, height, []);
// }
// performance "choke point" in full imperative
function createBoard(state, rules, max) {
var board = [];
let prevState = [];
for (let i = 0; i < max; i++) {
let nextState = [];
// use the passed initial step during first iteration
if (i == 0) {
nextState = evolve1d(state, rules);
} else {
nextState = evolve1d(prevState, rules);
}
board = board.concat([nextState]);
prevState = nextState;
}
return board;
}
// Find the neighbor of a given cell in a 2D CA board
function getCellNeighbors(board, cellCoordinates) {
const [x, y] = cellCoordinates;
const rowLength = board[0].length; // caca?
// handles board edges where the cell is missing neighbors
function getCell(xx, yy) {
const safeX = guard(xx, board.length);
const safeY = guard(yy, rowLength.length);
return board[safeX][safeY];
}
// the current cell is not included in the result
return [
getCell(x - 1, y - 1),
getCell(x, y - 1),
getCell(x + 1, y - 1),
getCell(x - 1, y),
getCell(x + 1, y),
getCell(x - 1, y + 1),
getCell(x, y + 1),
getCell(x + 1, y - 1),
];
}
// Sums the value of a cell's neighbors
function getNeighborsSum(cells) {
return cells.reduce((cell, acc) => cell + acc, 0);
}
// Get the next evolution of a cell according to
// Conway's game of life rules
function conwayRules(cell, neighbors) {
// loneliness rule
if (cell === 1 && neighbors < 2) return 0;
// overpopulation rule
if (cell === 1 && neighbors > 3) return 0;
// born when three live neighbors rule
if (cell === 0 && neighbors === 3) return 1;
// the cell remains the same if none apply
return cell;
}
// Get the next evolution of a cell according to
// Conway's game of life rules
function servietteRules(cell, neighbors) {
// loneliness rule
if (cell === 0 && [2, 3, 4].find((x) => x == neighbors)) return 1;
// the cell remains the same if none apply
return 0;
}
// variation of the game of life where a
// cell comes to live if 6 neigbor cells are alive
function highLifeRules(cell, neighbors) {
// loneliness rule
if (cell === 1 && neighbors < 2) return 0;
// overpopulation rule
if (cell === 1 && neighbors > 3) return 0;
// born when three live neighbors rule
if (cell === 0 && neighbors === 2) return 1;
// highlife rules
if ((cell === 0 && neighbors === 3) || neighbors === 6) return 1;
// the cell remains the same if none apply
return cell;
}
// variation on the game of life's rules,
// where the "three live neighbors" rule is ignored
function threebornRules(cell, neighbors) {
// loneliness rule
if (cell === 1 && neighbors < 2) return 0;
// overpopulation rule
if (cell === 1 && neighbors > 3) return 0;
// born when three live neighbors rule
return cell;
}
// variation on the game of life's rules,
// where the loneliness rule is ignored
function lonelinessRules(cell, neighbors) {
// overpopulation rule
if (cell === 1 && neighbors > 3) return 0;
// born when three live neighbors rule
if (cell === 0 && neighbors === 3) return 1;
// the cell remains the same if none apply
return cell;
}
// variation on the game of life's rules,
// where the overpopulation rule is ignored
function overpopulationRules(cell, neighbors) {
// loneliness rule
if (cell === 1 && neighbors < 2) return 0;
// born when three live neighbors rule
if (cell === 0 && neighbors === 3) return 1;
// the cell remains the same if none apply
return cell;
}
// get the next evolution of a 2D CA initial state
// Rules : Moore neighborhood
function evolve2d(board, rulesFn) {
return board.map((row, x) =>
row.map((cell, y) => {
const neighbors = getCellNeighbors(board, [x, y]);
const sum = getNeighborsSum(neighbors);
return rulesFn(cell, sum);
})
);
}
function getDrawingValues(state, acc, cell) {
const d = cell.dimension;
return Object.keys(state).map((key) => {
const fillStyle = (() => {
if (state[key] === "1") return cell.liveColor;
return cell.deadColor;
})();
return {
move: [key * d, acc * d],
fill: [key * d, acc * d, d, d],
fillStyle,
};
});
}
// Populates the first state with a single living cell in the center
function create1dStateOneCell(width) {
return [...Array(width)].map((cell, index) => {
if (index === Math.floor(width / 2)) return 1;
return 0;
});
}
// Populates the first state of a 1D CA with cells returned
// by initFn
function create1dState(width, initFn, args) {
return [...Array(width)].map(() => initFn(...args));
}
// Populates the first state of a 2D CA with cells returned
// by initFn
function create2dState(width, height, initFn, args) {
return [...Array(height)].map(() =>
[...Array(width)].map(() => initFn(...args))
);
}
export {
getDrawingValues,
create1dState,
create2dState,
createBoard,
create1dStateOneCell,
conwayRules,
overpopulationRules,
lonelinessRules,
threebornRules,
highLifeRules,
servietteRules,
evolve1d,
evolve2d,
};

View File

@ -36,7 +36,7 @@ export function picToBlackAndWhite(pixels, width, height) {
}
// convert an ImageData into a 2D array of boolean (0, 1) values
export function picToBoard(pixels, width, height) {
export function picToBoard(pixels, board) {
const flat = pixels.reduce((acc, pixel, index) => {
const i = index * 4;
const count = pixels[i] + pixels[i + 1] + pixels[i + 2];
@ -44,19 +44,21 @@ export function picToBoard(pixels, width, height) {
acc[index] = value;
return acc;
}, []);
return toMatrix(flat, width, height);
// TODO: The representation has to be 2D, not the data structure
// (change to flat)
return toMatrix(flat, board.width, board.height);
}
// convert board to ImageData
// TODO : different cell to color functions
// (binary, intermediate states, camaieux, etc)
export function boardToPic(board, width, height, cellProperties) {
const live = cellProperties.liveColor;
const dead = cellProperties.deadColor;
const img = new ImageData(width, height);
export function boardToPic(board) {
const live = board.cellProperties.liveColor;
const dead = board.cellProperties.deadColor;
const img = new ImageData(board.width, board.height);
const colors = [hexToRGB(live), hexToRGB(dead)];
board.flat().reduce((acc, cell, index) => {
board.grid.flat().reduce((acc, cell, index) => {
// TODO : bitshift operation instead of ternary
const color = cell === 1 ? colors[0] : colors[1];
const i = index * 4;
acc[i] = color[0];

72
src/modules/renderer.js Normal file
View File

@ -0,0 +1,72 @@
import { boardToPic, picToBoard } from "./picture.js";
// draws the board representation on the canvas
async function render(board) {
const d = board.cellProperties.size;
// bool to RGBA colors
const img = await boardToPic(board);
this.ctx.save();
// rescale
this.ctx.clearRect(0, 0, this.width, this.height);
this.workCtx.putImageData(img, 0, 0);
this.ctx.imageSmoothingEnabled = false;
this.ctx.scale(d, d);
// draw from work canvas
this.ctx.drawImage(this.workCanvas, 0, 0, this.width, this.height);
this.ctx.restore();
}
// draw image on canvas
function renderImage(image) {
this.ctx.fillStyle = "black";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(
image,
Math.floor((this.canvas.width - image.width) / 2),
Math.floor((this.canvas.height - image.height) / 2),
image.width,
image.height
);
}
// resize image to board dimensions
function getResizedImageData(image, board) {
// draw the image on the work canvas with the dimensions of the board
this.workCtx.drawImage(image, 0, 0, board.width, board.height);
// get the resized image data from work canvas
const resized = this.workCtx.getImageData(0, 0, board.width, board.height);
// convert the image into a 2D board of boolean based on pixel value
return picToBoard(resized.data, board);
}
function resize(width, height) {
this.width = width;
this.height = height;
this.canvas.height = height;
this.canvas.width = width;
this.workCanvas.height = height;
this.workCanvas.width = width;
}
function reset() {
this.ctx.clearRect(0, 0, this.width, this.height);
}
function Renderer() {
this.canvas = null;
this.workCanvas = null;
this.workCtx = null;
this.width = null;
this.height = null;
this.ctx = null;
this.refreshRate = 300;
this.render = render;
this.getResizedImageData = getResizedImageData;
this.renderImage = renderImage;
this.reset = reset;
this.resize = resize;
}
export { Renderer };

View File

@ -1,4 +1,6 @@
import { defineStore } from "pinia";
import { Board } from "../modules/board.js";
import { Renderer } from "../modules/renderer.js";
export const globalStore = defineStore("globalStore", {
state: () => {
@ -21,19 +23,9 @@ export const globalStore = defineStore("globalStore", {
name: "Conway's Game of Life",
description: "The most popular 2d automata",
},
cellProperties: {
size: 3,
liveColor: "#000000",
deadColor: "#F5F5F5",
},
canvasWidth: 0,
canvasHeight: 0,
boardWidth: 0,
boardHeight: 0,
refreshRate: 300,
initial1dState: "onecell",
drawingDirection: "y",
lastBoard: null,
board: new Board(),
draw1d: false,
draw2d: false,
draw2dLast: false,
@ -45,17 +37,32 @@ export const globalStore = defineStore("globalStore", {
activeSubMenu: "",
loop: false,
lastAction: "drawfromlast",
renderer: new Renderer(),
};
},
actions: {
setBoardWidth() {
this.boardWidth = Math.floor(this.canvasWidth / this.cellProperties.size);
this.board.width = Math.floor(
this.renderer.width / this.board.cellProperties.size
);
},
setBoardHeight() {
this.boardHeight = Math.floor(
this.canvasHeight / this.cellProperties.size
this.board.height = Math.floor(
this.renderer.height / this.board.cellProperties.size
);
},
setCellProperties(name, value) {
this.board.cellProperties[name] = value;
},
switchColor() {
[
this.board.cellProperties["liveColor"],
this.board.cellProperties["deadColor"],
] = [
this.board.cellProperties["deadColor"],
this.board.cellProperties["liveColor"],
];
},
toggleDraw1d() {
this.setActiveSubMenu("");
this.setMainMenu(false);

28
tests/board.test.js Normal file
View File

@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import {
Board,
create1dInitialState,
create2dRandomGrid,
} from "src/modules/board.js";
describe("Board", () => {
const board = new Board(503, 301);
test("create1dInitialState onecell", () => {
const stateOne = create1dInitialState(board);
expect(stateOne.length).toBe(board.width);
expect(stateOne).toContain(1);
});
test("create1dInitialState random", () => {
const stateRandom = create1dInitialState(board, "random");
expect(stateRandom.length).toBe(board.width);
expect(stateRandom).toContain(1);
});
test("create2dRandomGrid", () => {
const board = new Board(503, 301);
const got = create2dRandomGrid(board.width, board.height);
expect(got.length).toBe(board.height);
expect(got[0].length).toBe(board.width);
});
});

View File

@ -13,4 +13,11 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
test: {
// enable jest-like global test APIs
globals: true,
// simulate DOM with happy-dom
// (requires installing happy-dom as a peer dependency)
environment: "happy-dom",
},
});