├── .prettierrc ├── .gitignore ├── .prettierignore ├── src ├── config.ts ├── game │ ├── utils │ │ └── Math.ts │ ├── graphics │ │ ├── canvas │ │ │ ├── plugins │ │ │ │ ├── CanvasPlugin.ts │ │ │ │ ├── SelectedCells.ts │ │ │ │ ├── KeyControl.ts │ │ │ │ └── Draggable.ts │ │ │ ├── config.ts │ │ │ ├── CanvasPainter.ts │ │ │ └── CanvasController.ts │ │ ├── GraphicsController.ts │ │ ├── EventTypes.ts │ │ └── GraphicsEvents.ts │ ├── core │ │ ├── GameBoard.ts │ │ └── GameEngine.ts │ ├── structures │ │ └── CartesianPlane.ts │ └── GameOfLife.ts ├── index.ts └── index.test.ts ├── tsconfig.json ├── .github └── workflows │ └── npm-publish.yml ├── webpack.config.js ├── LICENSE ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | /umd 4 | /.vscode -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "./game/structures/CartesianPlane.js"; 2 | 3 | export interface GameConfigParams { 4 | onNextGeneration?: (board: Point[]) => {}; 5 | delay?: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/game/utils/Math.ts: -------------------------------------------------------------------------------- 1 | export const round = (number: number, decimals: number) => { 2 | const factorDecimal = Math.pow(10, decimals); 3 | return Math.round(number * factorDecimal) / factorDecimal; 4 | }; 5 | -------------------------------------------------------------------------------- /src/game/graphics/canvas/plugins/CanvasPlugin.ts: -------------------------------------------------------------------------------- 1 | import { CanvasConfig, CanvasConfigParams } from "../config.js"; 2 | 3 | export class CanvasPlugin { 4 | protected readonly canvas: HTMLCanvasElement; 5 | protected readonly getConfig: () => CanvasConfig; 6 | protected readonly setConfig: (config: CanvasConfigParams) => any; 7 | 8 | constructor( 9 | canvas: HTMLCanvasElement, 10 | getConfig: () => CanvasConfig, 11 | setConfig: (config: CanvasConfigParams) => any, 12 | ) { 13 | this.canvas = canvas; 14 | this.getConfig = getConfig; 15 | this.setConfig = setConfig; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/game/graphics/GraphicsController.ts: -------------------------------------------------------------------------------- 1 | import { GameBoard } from "../core/GameBoard.js"; 2 | import { Point } from "../structures/CartesianPlane.js"; 3 | import { GraphicsEvents } from "./GraphicsEvents.js"; 4 | 5 | export class GraphicsController { 6 | protected aliveCells: Point[]; 7 | public events: GraphicsEvents; 8 | 9 | constructor() { 10 | this.events = new GraphicsEvents(); 11 | this.aliveCells = []; 12 | } 13 | 14 | public setCells(cells: Point[]): void { 15 | this.aliveCells = cells; 16 | this.render(); 17 | } 18 | 19 | protected render(): void { 20 | console.log("render"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/game/core/GameBoard.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "../structures/CartesianPlane.js"; 2 | 3 | export class GameBoard { 4 | private board: Map; 5 | 6 | constructor() { 7 | this.board = new Map(); 8 | } 9 | 10 | public setCell(x: number, y: number, alive = true) { 11 | if (alive) this.board.set(`${x}:${y}`, { x, y }); 12 | else this.board.delete(`${x}:${y}`); 13 | } 14 | 15 | public getCell(x: number, y: number) { 16 | return this.board.has(`${x}:${y}`); 17 | } 18 | 19 | public getCells() { 20 | return Array.from(this.board.values()); 21 | } 22 | 23 | resetCells() { 24 | this.board.clear(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "lib": [ 5 | "DOM" 6 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 7 | "module": "ES6" /* Specify what module code is generated. */, 8 | "outDir": "./lib/esm" /* Specify an output folder for all emitted files. */, 9 | "declaration": true, 10 | "moduleResolution": "Node", 11 | "strict": true /* Enable all strict type-checking options. */, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true 14 | }, 15 | "include": ["src/**/*", "tests/**/*"], 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Npm package deploy 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm ci 20 | - run: npm run build 21 | - run: npm run build:umd 22 | - run: npm publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ResolveTypescriptPlugin = require('resolve-typescript-plugin'); 3 | 4 | module.exports = { 5 | entry: './src/index.ts', 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.ts$/, 10 | use: 'ts-loader', 11 | exclude: /node_modules/, 12 | }, 13 | ], 14 | }, 15 | resolve: { 16 | plugins: [new ResolveTypescriptPlugin()] 17 | }, 18 | output: { 19 | filename: 'gamelife.min.js', 20 | path: path.resolve(__dirname, 'umd'), 21 | library: { 22 | type: 'umd', 23 | name: 'GameLife', 24 | auxiliaryComment: 'Create new Canvas Game of life', 25 | umdNamedDefine: true, 26 | export: 'default' 27 | }, 28 | }, 29 | mode: 'production', 30 | }; -------------------------------------------------------------------------------- /src/game/graphics/EventTypes.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "../structures/CartesianPlane.js"; 2 | 3 | export type EventTypes = 4 | | "onCellBorn" 5 | | "onCellKill" 6 | | "onCellToggle" 7 | | "onGameStartStop" 8 | | "onSpeedUp" 9 | | "onSpeedDown"; 10 | 11 | export type onCellBorn = { 12 | type: "onCellBorn"; 13 | callback: (point: Point) => any; 14 | }; 15 | export type onCellKill = { 16 | type: "onCellKill"; 17 | callback: (point: Point) => any; 18 | }; 19 | export type onCellToggle = { 20 | type: "onCellToggle"; 21 | callback: (point: Point) => any; 22 | }; 23 | 24 | export type onGameStartStop = { 25 | type: "onGameStartStop"; 26 | callback: Function; 27 | }; 28 | 29 | export type onSpeedUp = { 30 | type: "onSpeedUp"; 31 | callback: (factor: number) => any; 32 | }; 33 | 34 | export type onSpeedDown = { 35 | type: "onSpeedDown"; 36 | callback: (factor: number) => any; 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jose Antonio Felix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/game/graphics/canvas/config.ts: -------------------------------------------------------------------------------- 1 | export interface CanvasConfig { 2 | cells: { 3 | size: number; 4 | }; 5 | grid: { 6 | lineWidth: number; 7 | gap: number; 8 | }; 9 | board: { 10 | height: number; 11 | width: number; 12 | offset_x: number; 13 | offset_y: number; 14 | zoom: number; 15 | }; 16 | colors: { 17 | background: string; 18 | cell: string; 19 | selected_cell: string; 20 | grid: string; 21 | }; 22 | } 23 | 24 | export interface CanvasConfigParams { 25 | cells?: { 26 | size?: number; 27 | }; 28 | grid?: { 29 | lineWidth?: number; 30 | gap?: number; 31 | }; 32 | board?: { 33 | height?: number; 34 | width?: number; 35 | offset_x?: number; 36 | offset_y?: number; 37 | zoom?: number; 38 | }; 39 | colors?: { 40 | background?: string; 41 | cell?: string; 42 | selected_cell?: string; 43 | grid?: string; 44 | }; 45 | } 46 | 47 | export const defaultCanvasConfig: CanvasConfig = { 48 | cells: { 49 | size: 20, 50 | }, 51 | grid: { 52 | lineWidth: 1, 53 | gap: 0.5, 54 | }, 55 | board: { 56 | height: 900, 57 | width: 1900, 58 | offset_x: 0, 59 | offset_y: 0, 60 | zoom: 100, 61 | }, 62 | colors: { 63 | background: "#222222", 64 | cell: "#ffffff", 65 | selected_cell: "rgba(255,255,255,0.2)", 66 | grid: "#626567", 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { GameConfigParams } from "./config.js"; 2 | import { GameOfLife } from "./game/GameOfLife.js"; 3 | import { CanvasController } from "./game/graphics/canvas/CanvasController.js"; 4 | import { CanvasConfigParams } from "./game/graphics/canvas/config.js"; 5 | 6 | const createGameOfLife = ( 7 | canvas: HTMLCanvasElement, 8 | defaultConfig: { 9 | game?: GameConfigParams; 10 | graphics?: CanvasConfigParams; 11 | } = {}, 12 | ): GameOfLife => { 13 | // Create game 14 | const graphics = new CanvasController(canvas); 15 | const game = new GameOfLife(graphics); 16 | 17 | // Default config 18 | const { game: gameConfig, graphics: graphicsConfig } = defaultConfig; 19 | if (gameConfig) game.setConfig(gameConfig); 20 | if (graphicsConfig) graphics.setConfig(graphicsConfig); 21 | 22 | return game; 23 | }; 24 | 25 | export default createGameOfLife; 26 | export * from "./config.js"; 27 | export * from "./game/GameOfLife.js"; 28 | export * from "./game/core/GameBoard.js"; 29 | export * from "./game/core/GameEngine.js"; 30 | export * from "./game/graphics/EventTypes.js"; 31 | export * from "./game/graphics/GraphicsController.js"; 32 | export * from "./game/graphics/GraphicsEvents.js"; 33 | export * from "./game/graphics/canvas/CanvasController.js"; 34 | export * from "./game/graphics/canvas/CanvasPainter.js"; 35 | export * from "./game/graphics/canvas/config.js"; 36 | export * from "./game/graphics/canvas/plugins/CanvasPlugin.js"; 37 | export * from "./game/graphics/canvas/plugins/Draggable.js"; 38 | export * from "./game/graphics/canvas/plugins/KeyControl.js"; 39 | export * from "./game/graphics/canvas/plugins/SelectedCells.js"; 40 | export * from "./game/structures/CartesianPlane.js"; 41 | export * from "./game/utils/Math.js"; 42 | -------------------------------------------------------------------------------- /src/game/graphics/GraphicsEvents.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "../structures/CartesianPlane.js"; 2 | import { 3 | EventTypes, 4 | onCellBorn, 5 | onCellKill, 6 | onCellToggle, 7 | onGameStartStop, 8 | onSpeedDown, 9 | onSpeedUp, 10 | } from "./EventTypes.js"; 11 | 12 | export class GraphicsEvents { 13 | private listeners: { 14 | onCellBorn: Function[]; 15 | onCellKill: Function[]; 16 | onCellToggle: Function[]; 17 | onGameStartStop: Function[]; 18 | onSpeedDown: Function[]; 19 | onSpeedUp: Function[]; 20 | }; 21 | 22 | constructor() { 23 | this.listeners = { 24 | onCellBorn: [], 25 | onCellKill: [], 26 | onCellToggle: [], 27 | onGameStartStop: [], 28 | onSpeedDown: [], 29 | onSpeedUp: [], 30 | }; 31 | } 32 | public on( 33 | event: 34 | | onCellBorn 35 | | onCellKill 36 | | onCellToggle 37 | | onGameStartStop 38 | | onSpeedUp 39 | | onSpeedDown, 40 | ) { 41 | this.listeners[event.type].push(event.callback); 42 | } 43 | 44 | private emit(event: EventTypes, payload: any) { 45 | this.listeners[event].forEach((callback) => callback(payload)); 46 | } 47 | 48 | public emitCellBorn(point: Point) { 49 | this.emit("onCellBorn", point); 50 | } 51 | 52 | public emitCellKill(point: Point) { 53 | this.emit("onCellKill", point); 54 | } 55 | 56 | public emitCellToggle(point: Point) { 57 | this.emit("onCellToggle", point); 58 | } 59 | 60 | public emitGameStartStop() { 61 | this.emit("onGameStartStop", null); 62 | } 63 | 64 | public emitSpeedUp(factor: number) { 65 | this.emit("onSpeedUp", factor); 66 | } 67 | 68 | public emitSpeedDown(factor: number) { 69 | this.emit("onSpeedDown", factor); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game-life", 3 | "version": "1.2.4", 4 | "description": "Customizable Conway's \"Game of life\" cellular automat game", 5 | "main": "lib/cjs/index.js", 6 | "module": "lib/esm/index.js", 7 | "types": "lib/esm/index.d.ts", 8 | "modules": { 9 | ".": { 10 | "import": "./lib/esm/index.js", 11 | "require": "./lib/cjs/index.js" 12 | }, 13 | "./cjs": "lib/cjs/index.js" 14 | }, 15 | "typesVersions": { 16 | "*": { 17 | "./cjs": [ 18 | "lib/cjs/index.d.ts" 19 | ] 20 | } 21 | }, 22 | "files": [ 23 | "lib/**/*", 24 | "umd/**/*", 25 | "README.md" 26 | ], 27 | "scripts": { 28 | "build": "npm run build:esm && npm run build:cjs && npm run build:umd", 29 | "build:esm": "tsc --target ES6", 30 | "build:cjs": "tsc --target ES6 --module commonjs --outDir lib/cjs", 31 | "build:umd": "webpack", 32 | "build:watch": "tsc --watch", 33 | "build:umd:watch": "webpack --watch", 34 | "test": "jest" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/JAFB321/game-of-life.git" 39 | }, 40 | "author": "JAFB321", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/JAFB321/game-of-life/issues" 44 | }, 45 | "homepage": "https://github.com/JAFB321/game-of-life#readme", 46 | "devDependencies": { 47 | "@types/jest": "^27.5.1", 48 | "canvas": "^3.0.1", 49 | "jest": "^28.1.0", 50 | "jest-environment-jsdom": "^28.1.0", 51 | "prettier": "3.4.2", 52 | "resolve-typescript-plugin": "^1.2.0", 53 | "ts-jest": "^28.0.2", 54 | "ts-loader": "^9.2.6", 55 | "typescript": "^4.5.5", 56 | "webpack": "^5.73.0", 57 | "webpack-cli": "^4.10.0" 58 | }, 59 | "jest": { 60 | "preset": "ts-jest", 61 | "testEnvironment": "jsdom", 62 | "testRegex": "\\.test\\.ts$" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/game/graphics/canvas/plugins/SelectedCells.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "../../../structures/CartesianPlane.js"; 2 | import { CanvasConfig, CanvasConfigParams } from "../config.js"; 3 | import { CanvasPlugin } from "./CanvasPlugin.js"; 4 | 5 | export class SelectedCells extends CanvasPlugin { 6 | private readonly getSelectedCells: () => Point[]; 7 | private readonly setSelectCells: (points: Point[]) => any; 8 | private readonly listeners: { 9 | onCellClicked: ((point: Point) => any)[]; 10 | }; 11 | 12 | constructor( 13 | canvas: HTMLCanvasElement, 14 | getConfig: () => CanvasConfig, 15 | setConfig: (config: CanvasConfigParams) => any, 16 | getSelectedCells: () => Point[], 17 | setSelectCells: (points: Point[]) => any, 18 | ) { 19 | super(canvas, getConfig, setConfig); 20 | this.getSelectedCells = getSelectedCells; 21 | this.setSelectCells = setSelectCells; 22 | this.listeners = { 23 | onCellClicked: [], 24 | }; 25 | this.init(); 26 | } 27 | 28 | public onCellClicked(callback: (point: Point) => any) { 29 | this.listeners.onCellClicked.push(callback); 30 | } 31 | 32 | private emitCellSelected(point: Point) { 33 | this.listeners.onCellClicked.forEach((callback) => callback(point)); 34 | } 35 | 36 | public init() { 37 | const canvas = this.canvas; 38 | 39 | canvas.addEventListener("mousemove", (ev) => { 40 | const { offsetX: x, offsetY: y } = ev; 41 | const { board, cells, grid } = this.getConfig(); 42 | const { width, height, offset_x, offset_y, zoom } = board; 43 | const { lineWidth, gap } = grid; 44 | 45 | let { size } = cells; 46 | size = (size * zoom) / 100; 47 | size = Math.ceil(size); 48 | 49 | let cell_size = size + gap * 4; 50 | 51 | const pos_x = Math.floor((x - offset_x) / cell_size); 52 | const pos_y = Math.floor((y - offset_y) / cell_size); 53 | 54 | this.setSelectCells([{ x: pos_x, y: pos_y }]); 55 | }); 56 | 57 | canvas.addEventListener("dblclick", (ev) => { 58 | const cell = this.getSelectedCells()[0]; 59 | if (cell) this.emitCellSelected(cell); 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/game/core/GameEngine.ts: -------------------------------------------------------------------------------- 1 | import { GameBoard } from "./GameBoard.js"; 2 | 3 | export class GameEngine { 4 | public nextGeneration(board: GameBoard) { 5 | const newGameboard = new GameBoard(); 6 | 7 | // performance.mark("start-script") 8 | const cells = board.getCells(); 9 | 10 | for (const { x, y } of cells) { 11 | this.getCellLife(board, x, y) && newGameboard.setCell(x, y); 12 | this.getCellLife(board, x + 1, y) && newGameboard.setCell(x + 1, y); 13 | this.getCellLife(board, x + 1, y + 1) && 14 | newGameboard.setCell(x + 1, y + 1); 15 | this.getCellLife(board, x + 1, y - 1) && 16 | newGameboard.setCell(x + 1, y - 1); 17 | this.getCellLife(board, x - 1, y + 1) && 18 | newGameboard.setCell(x - 1, y + 1); 19 | this.getCellLife(board, x - 1, y - 1) && 20 | newGameboard.setCell(x - 1, y - 1); 21 | this.getCellLife(board, x - 1, y) && newGameboard.setCell(x - 1, y); 22 | this.getCellLife(board, x, y + 1) && newGameboard.setCell(x, y + 1); 23 | this.getCellLife(board, x, y - 1) && newGameboard.setCell(x, y - 1); 24 | } 25 | 26 | // performance.mark("end-script") 27 | // console.log(performance.measure("total-script-execution-time", "start-script", "end-script").duration / 1000); 28 | 29 | return newGameboard; 30 | } 31 | 32 | private getCellLife(board: GameBoard, x: number, y: number) { 33 | let aliveSilbings = 0; 34 | aliveSilbings += board.getCell(x, y + 1) ? 1 : 0; 35 | aliveSilbings += board.getCell(x + 1, y + 1) ? 1 : 0; 36 | aliveSilbings += board.getCell(x + 1, y) ? 1 : 0; 37 | aliveSilbings += board.getCell(x + 1, y - 1) ? 1 : 0; 38 | aliveSilbings += board.getCell(x, y - 1) ? 1 : 0; 39 | aliveSilbings += board.getCell(x - 1, y - 1) ? 1 : 0; 40 | aliveSilbings += board.getCell(x - 1, y) ? 1 : 0; 41 | aliveSilbings += board.getCell(x - 1, y + 1) ? 1 : 0; 42 | 43 | const isAlive = board.getCell(x, y); 44 | 45 | // Cell live rules 46 | if (isAlive) { 47 | if (aliveSilbings >= 2 && aliveSilbings <= 3) return true; 48 | } else if (aliveSilbings === 3) return true; 49 | 50 | return false; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/game/graphics/canvas/plugins/KeyControl.ts: -------------------------------------------------------------------------------- 1 | import { CanvasConfig, CanvasConfigParams } from "../config.js"; 2 | import { CanvasPlugin } from "./CanvasPlugin.js"; 3 | 4 | export class KeyControl extends CanvasPlugin { 5 | private readonly listeners: { 6 | onGameStartStop: (() => any)[]; 7 | onSpeedUp: ((factor: number) => any)[]; 8 | onSpeedDown: ((factor: number) => any)[]; 9 | }; 10 | 11 | constructor( 12 | canvas: HTMLCanvasElement, 13 | getConfig: () => CanvasConfig, 14 | setConfig: (config: CanvasConfigParams) => any, 15 | ) { 16 | super(canvas, getConfig, setConfig); 17 | 18 | this.listeners = { 19 | onGameStartStop: [], 20 | onSpeedDown: [], 21 | onSpeedUp: [], 22 | }; 23 | this.init(); 24 | } 25 | 26 | public onGameStartStop(callback: () => any) { 27 | this.listeners.onGameStartStop.push(callback); 28 | } 29 | 30 | public onSpeedUp(callback: (factor: number) => any) { 31 | this.listeners.onSpeedUp.push(callback); 32 | } 33 | 34 | public onSpeedDowm(callback: (factor: number) => any) { 35 | this.listeners.onSpeedDown.push(callback); 36 | } 37 | 38 | private emitGameStartStop() { 39 | this.listeners.onGameStartStop.forEach((callback) => callback()); 40 | } 41 | 42 | private emitSpeedUp(factor: number) { 43 | this.listeners.onSpeedUp.forEach((callback) => callback(factor)); 44 | } 45 | 46 | private emitSpeedDown(factor: number) { 47 | this.listeners.onSpeedDown.forEach((callback) => callback(factor)); 48 | } 49 | 50 | private init() { 51 | if (!!window) { 52 | window.addEventListener("keydown", (evt) => this.onKey(evt)); 53 | } else { 54 | this.canvas.ownerDocument.addEventListener("keydown", (evt) => 55 | this.onKey(evt), 56 | ); 57 | } 58 | } 59 | 60 | private onKey(evt: KeyboardEvent) { 61 | const { key } = evt; 62 | 63 | switch (key) { 64 | case "Enter": 65 | this.emitGameStartStop(); 66 | break; 67 | 68 | case "+": 69 | this.emitSpeedUp(1.2); 70 | break; 71 | 72 | case "-": 73 | this.emitSpeedDown(0.8); 74 | break; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { GameOfLife } from "./game/GameOfLife"; 2 | import { CanvasController } from "./game/graphics/canvas/CanvasController"; 3 | import createGameOfLife from "./index"; 4 | const canvas = document.querySelector("#gameboard-main"); 5 | const info = document.querySelector(".floating-info"); 6 | 7 | describe("Game of life", () => { 8 | test("Instance a new game", () => { 9 | const canvas = document.createElement("canvas"); 10 | 11 | const game = createGameOfLife(canvas); 12 | 13 | expect(game).toBeInstanceOf(GameOfLife); 14 | expect(game.graphics).toBeInstanceOf(CanvasController); 15 | expect(game.getCells().length).toBe(0); 16 | }); 17 | 18 | test("Add a cell", () => { 19 | const canvas = document.createElement("canvas"); 20 | 21 | const game = createGameOfLife(canvas); 22 | game.bornCell({ x: 1, y: 1 }); 23 | expect(game.getCells().length).toBe(1); 24 | expect(game.getCells()).toContainEqual({ x: 1, y: 1 }); 25 | }); 26 | }); 27 | 28 | function init() { 29 | if (!info) return; 30 | if (!canvas) return; 31 | 32 | const game = createGameOfLife(canvas, { 33 | game: { 34 | delay: 200, 35 | }, 36 | }); 37 | 38 | game.bornCell({ x: 10, y: 10 }); 39 | game.bornCell({ x: 10, y: 11 }); 40 | game.bornCell({ x: 10, y: 12 }); 41 | game.bornCell({ x: 10, y: 14 }); 42 | game.bornCell({ x: 9, y: 14 }); 43 | game.bornCell({ x: 11, y: 14 }); 44 | 45 | game.startEvolution(); 46 | 47 | game.graphics.setConfig({ 48 | board: { 49 | // width: 300, 50 | // height: 100 51 | }, 52 | grid: { 53 | gap: 0.5, 54 | }, 55 | cells: { 56 | size: 20, 57 | }, 58 | colors: { 59 | background: "#222222", 60 | }, 61 | }); 62 | 63 | game.graphics.onConfigChange((newConfig) => { 64 | info.innerHTML = JSON.stringify(newConfig, null, 4); 65 | }); 66 | 67 | window.gameStart = () => game.startEvolution(); 68 | window.gameStop = () => game.stopEvolution(); 69 | 70 | // let n = 100; 71 | // setInterval(() => { 72 | // n+= 0.8; 73 | // game.graphics.setConfig({ 74 | // board: { 75 | // offset_x: n, 76 | // offset_y: n 77 | // } 78 | // }); 79 | // }, 1000); 80 | } 81 | 82 | init(); 83 | 84 | declare global { 85 | interface Window { 86 | gameStart: Function; 87 | gameStop: Function; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/game/structures/CartesianPlane.ts: -------------------------------------------------------------------------------- 1 | export interface Point { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | // export class Rectangle{ 7 | // point1; 8 | // point2; 9 | 10 | // constructor(x1, y1, x2, y2){ 11 | // this.point1 = new Point(x1, y1); 12 | // this.point2 = new Point(x2, y2); 13 | // } 14 | // } 15 | 16 | /** 17 | * Deprecated 18 | */ 19 | export class CartesianPlane { 20 | // Cartesian Plane quadrants 21 | private quadrant1: (ValueType | null)[][]; // x+ y+ 22 | private quadrant2: (ValueType | null)[][]; // x- y+ 23 | private quadrant3: (ValueType | null)[][]; // x- y- 24 | private quadrant4: (ValueType | null)[][]; // x+ y- 25 | 26 | // Default point value 27 | readonly defaultValue; 28 | 29 | constructor(defaultValue: ValueType | null) { 30 | this.defaultValue = defaultValue; 31 | this.quadrant1 = [[]]; 32 | this.quadrant2 = [[]]; 33 | this.quadrant3 = [[]]; 34 | this.quadrant4 = [[]]; 35 | } 36 | 37 | public setPoint({ x, y }: Point, value: ValueType) { 38 | if (x >= 0 && y >= 0) { 39 | this.verifyRow(x, this.quadrant1); 40 | this.quadrant1[x][y] = value; 41 | } else if (x < 0 && y >= 0) { 42 | this.verifyRow(x * -1, this.quadrant2); 43 | this.quadrant2[x * -1][y] = value; 44 | } else if (x < 0 && y < 0) { 45 | this.verifyRow(x * -1, this.quadrant3); 46 | this.quadrant3[x * -1][y * -1] = value; 47 | } else { 48 | this.verifyRow(x, this.quadrant4); 49 | this.quadrant4[x][y * -1] = value; 50 | } 51 | } 52 | 53 | public getPoint({ x, y }: Point) { 54 | if (x >= 0 && y >= 0) { 55 | if (!this.quadrant1[x]) return this.defaultValue; 56 | return this.quadrant1[x][y] || this.defaultValue; 57 | } else if (x < 0 && y >= 0) { 58 | if (!this.quadrant2[x * -1]) return this.defaultValue; 59 | return this.quadrant2[x * -1][y] || this.defaultValue; 60 | } else if (x < 0 && y < 0) { 61 | if (!this.quadrant3[x * -1]) return this.defaultValue; 62 | return this.quadrant3[x * -1][y * -1] || this.defaultValue; 63 | } else { 64 | if (!this.quadrant4[x]) return this.defaultValue; 65 | return this.quadrant4[x][y * -1] || this.defaultValue; 66 | } 67 | } 68 | 69 | public resetPlane() { 70 | this.quadrant1 = []; 71 | this.quadrant2 = []; 72 | this.quadrant3 = []; 73 | this.quadrant4 = []; 74 | } 75 | 76 | private verifyRow(x: number, quadrant: (ValueType | null)[][]) { 77 | if (!quadrant[x]) quadrant[x] = []; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/game/graphics/canvas/plugins/Draggable.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "../../../structures/CartesianPlane.js"; 2 | import { CanvasPainter } from "../CanvasPainter.js"; 3 | import { CanvasConfig, CanvasConfigParams } from "../config.js"; 4 | import { CanvasPlugin } from "./CanvasPlugin.js"; 5 | 6 | interface DragState { 7 | isDragging: boolean; 8 | lastX: number; 9 | lastY: number; 10 | } 11 | 12 | export class Draggable extends CanvasPlugin { 13 | private state: DragState; 14 | 15 | constructor( 16 | canvas: HTMLCanvasElement, 17 | getConfig: () => CanvasConfig, 18 | setConfig: (config: CanvasConfigParams) => any, 19 | ) { 20 | super(canvas, getConfig, setConfig); 21 | 22 | this.state = { 23 | isDragging: false, 24 | lastX: 0, 25 | lastY: 0, 26 | }; 27 | this.init(); 28 | } 29 | 30 | public init() { 31 | const canvas = this.canvas; 32 | 33 | canvas.onmousedown = (ev) => { 34 | const { state } = this; 35 | if (state.isDragging) return; 36 | 37 | this.state = { 38 | ...state, 39 | isDragging: true, 40 | lastX: ev.x, 41 | lastY: ev.y, 42 | }; 43 | }; 44 | 45 | canvas.onmouseup = (ev) => { 46 | const { state } = this; 47 | if (!state.isDragging) return; 48 | 49 | this.state = { 50 | ...state, 51 | isDragging: false, 52 | lastX: 0, 53 | lastY: 0, 54 | }; 55 | }; 56 | 57 | canvas.onmousemove = (ev) => { 58 | const { state } = this; 59 | if (!state.isDragging) return; 60 | 61 | const { lastX, lastY } = state; 62 | const { board } = this.getConfig(); 63 | const { offset_x, offset_y, zoom } = board; 64 | 65 | const x = ev.x - lastX; 66 | const y = ev.y - lastY; 67 | 68 | // Deprecated (used for canvas 2d scale) 69 | // const newOffset_x = Math.floor(offset_x+(x/(zoom/100))); 70 | // const newOffset_y = Math.floor(offset_y+(y/(zoom/100))); 71 | 72 | // Deprecated (used for canvas 2d scale) 73 | // const newOffset_x = offset_x+(x/(zoom/100)); 74 | // const newOffset_y = offset_y+(y/(zoom/100)); 75 | 76 | const newOffset_x = offset_x + x; 77 | const newOffset_y = offset_y + y; 78 | 79 | this.setConfig({ 80 | board: { 81 | offset_x: newOffset_x, 82 | offset_y: newOffset_y, 83 | }, 84 | }); 85 | 86 | this.state = { 87 | ...state, 88 | lastX: ev.x, 89 | lastY: ev.y, 90 | }; 91 | }; 92 | 93 | canvas.onwheel = (ev) => { 94 | ev.preventDefault(); 95 | const zoom = Math.sign(ev.deltaY); 96 | const { board } = this.getConfig(); 97 | 98 | let newZoom = board.zoom - (zoom * board.zoom) / 20; 99 | newZoom = Math.min(newZoom, 200); 100 | newZoom = Math.max(newZoom, 50); 101 | newZoom = Math.round(newZoom); 102 | 103 | this.setConfig({ 104 | board: { 105 | zoom: newZoom, 106 | }, 107 | }); 108 | }; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 |

5 |

6 | Customizable Conway's "Game of life" cellular automat generator 7 |

8 |

9 | 10 | [![NPM version](https://img.shields.io/npm/v/game-life?style=flat-square)](https://www.npmjs.com/package/game-life) 11 | [![Package size](https://img.shields.io/bundlephobia/min/game-life?style=flat-square)](https://www.npmjs.com/package/game-life) 12 | ![npm](https://img.shields.io/npm/dt/game-life?style=flat-square) 13 | ![GitHub](https://img.shields.io/github/license/jafb321/game-life?style=flat-square) 14 | ![GitHub Repo stars](https://img.shields.io/github/stars/jafb321/game-life?style=social) 15 | [![Twitter](https://img.shields.io/twitter/follow/jafb321.svg?label=Follow&style=social)](https://twitter.com/jafb321) 16 | 17 |

18 |
19 | 20 | --- 21 | 22 | #### Content 23 | 24 | - [Features](#features-) 25 | - [Install](#install-) 26 | - [Usage](#usage-) 27 | - [API](#api-) 28 | 29 | ## Features ✨ 30 | 31 | - Easy to use 32 | - Work with canvas element (cooming soon with DOM) 33 | - No dependencies 34 | - Scalable performance 35 | - Made with love <3 36 | 37 | ## Install 🐱‍💻 38 | 39 | There are 2 ways to install it in your project: 40 | 41 | #### 1. Install npm package (ES6 Import) 42 | 43 | ```bash 44 | npm install game-life 45 | ``` 46 | 47 | #### 2. Or add Script CDN 48 | 49 | ```html 50 | 54 | ``` 55 | 56 | ## Usage 💡 57 | 58 | Depending on how you installed, there are two ways to use it: 59 | 60 | #### 1. ES6 Import 61 | 62 | ```javascript 63 | import GameLife from "game-life"; 64 | const canvas = document.querySelector("canvas"); 65 | 66 | const game = GameLife(canvas); 67 | ``` 68 | 69 | #### 2. or with script CDN 70 | 71 | ```javascript 72 | const canvas = document.querySelector("canvas"); 73 | 74 | const game = GameLife(canvas); 75 | ``` 76 | 77 | ##### Result: 78 | 79 | ![Game life dark demo](https://raw.githubusercontent.com/JAFB321/JAFB321/main/game-life-dark.gif) 80 | 81 | #### How to use 82 | 83 | - Drag to explore the board 84 | - Double click to spawn/kill cells 85 | - Mouse wheel to zoom in/out 86 | - Enter to start/pause evolution 87 | - +/- keys to speed up/down 88 | 89 | ###### You can also pass a **default config** to the game: 90 | 91 | ```javascript 92 | const game = GameLife(canvas, { 93 | graphics: { 94 | board: { width: 1900, height: 800 }, 95 | colors: { background: "#FFFFFF", grid: "#E0E0E0" }, 96 | cells: { size: 20 }, 97 | }, 98 | game: { delay: 1000 }, 99 | }); 100 | ``` 101 | 102 | ##### Result: 103 | 104 | ![Game life white demo](https://raw.githubusercontent.com/JAFB321/JAFB321/main/game-life-white.gif) 105 | 106 | #### Manual actions 107 | 108 | ```javascript 109 | const game = GameLife(canvas); 110 | 111 | game.bornCell({ x: 10, t: 10 }); // Spawn cell 112 | game.killCell({ x: 10, y: 10 }); // Kill cell 113 | game.startEvolution(); // Start 114 | game.stopEvolution(); // Stop 115 | game.speedUp(1.5); // Speed up 1.5x 116 | game.speedDown(0.8); // Speed down 0.8x 117 | game.graphics.setConfig({ 118 | // Change graphics config 119 | colors: { background: "#F0F0F0", cell: "#000000" }, 120 | }); 121 | // and more 122 | ``` 123 | 124 | ## API 👩‍💻 125 | 126 | Cooming soon... 🚧 127 | 128 | --- 129 | -------------------------------------------------------------------------------- /src/game/graphics/canvas/CanvasPainter.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "../../structures/CartesianPlane.js"; 2 | import { CanvasConfig } from "./config.js"; 3 | 4 | export class CanvasPainter { 5 | public canvas: HTMLCanvasElement; 6 | public canvasContext: CanvasRenderingContext2D; 7 | 8 | constructor( 9 | canvas: HTMLCanvasElement, 10 | canvasContext: CanvasRenderingContext2D, 11 | ) { 12 | this.canvas = canvas; 13 | this.canvasContext = canvasContext; 14 | } 15 | 16 | paint( 17 | config: CanvasConfig, 18 | aliveCells: Point[], 19 | selectedCells: Point[], 20 | ): void { 21 | this.applyTransforms(config); 22 | // this.renderBackground(config); 23 | this.renderCells(config, aliveCells, selectedCells); 24 | this.renderGrid(config); 25 | } 26 | 27 | private renderGrid(config: CanvasConfig) { 28 | const ctx = this.canvasContext; 29 | const { board, grid, colors, cells } = config; 30 | const { width, height, offset_x, offset_y, zoom } = board; 31 | const { grid: gridColor } = colors; 32 | 33 | // Scale grid to zoom 34 | let { size } = cells; 35 | let { lineWidth, gap } = grid; 36 | 37 | size = (size * zoom) / 100; 38 | size = Math.ceil(size); 39 | 40 | // Paint Grid 41 | ctx.lineWidth = lineWidth; 42 | ctx.strokeStyle = gridColor; 43 | 44 | const cell_size = size + gap * 4; 45 | 46 | for (let x = 0; x < width * 2 + Math.abs(offset_x); x += cell_size) { 47 | ctx.moveTo(x, gap - height - offset_y); 48 | ctx.lineTo(x, height * 2 - offset_y); 49 | 50 | ctx.moveTo(-x + gap * 2, gap - height - offset_y); 51 | ctx.lineTo(-x + gap * 2, height * 2 - offset_y); 52 | } 53 | 54 | for (let y = 0; y < height * 2 + Math.abs(offset_y); y += cell_size) { 55 | ctx.moveTo(gap - width - offset_x, y); 56 | ctx.lineTo(width * 2 - offset_x, y); 57 | 58 | ctx.moveTo(gap - width - offset_x, -y + gap * 2); 59 | ctx.lineTo(width * 2 - offset_x, -y + gap * 2); 60 | } 61 | ctx.stroke(); 62 | } 63 | 64 | private renderCells( 65 | config: CanvasConfig, 66 | aliveCells: Point[], 67 | selectedCells: Point[], 68 | ) { 69 | const ctx = this.canvasContext; 70 | const { cells, colors, grid, board } = config; 71 | const { zoom } = board; 72 | const { cell: cell_color, selected_cell: selected_cell_color } = colors; 73 | 74 | // Scale cell size to zoom 75 | let { gap } = grid; 76 | let { size } = cells; 77 | 78 | size = (size * zoom) / 100; 79 | size = Math.ceil(size); 80 | 81 | const cell_size = size + gap * 4; 82 | 83 | ctx.fillStyle = cell_color; 84 | for (const point of aliveCells) { 85 | const { x, y } = point; 86 | 87 | const cell_x = x * cell_size + gap * 2; 88 | const cell_y = y * cell_size + gap * 2; 89 | 90 | ctx.fillRect(cell_x, cell_y, size, size); 91 | } 92 | 93 | ctx.fillStyle = selected_cell_color; 94 | for (const point of selectedCells) { 95 | const { x, y } = point; 96 | 97 | const cell_x = x * (size + gap * 4) + gap * 3; 98 | const cell_y = y * (size + gap * 4) + gap * 3; 99 | 100 | ctx.fillRect(cell_x, cell_y, size, size); 101 | } 102 | } 103 | 104 | private renderBackground(config: CanvasConfig) { 105 | const ctx = this.canvasContext; 106 | const { board, colors } = config; 107 | const { width, height, offset_x, offset_y } = board; 108 | const { background } = colors; 109 | 110 | ctx.fillStyle = background; 111 | ctx.fillRect(0 - offset_x, 0 - offset_y, width, height); 112 | } 113 | 114 | private applyTransforms(config: CanvasConfig) { 115 | const ctx = this.canvasContext; 116 | const { board } = config; 117 | const { offset_x, offset_y, zoom } = board; 118 | 119 | const newZoom = zoom / 100; 120 | 121 | const currentTransform = ctx.getTransform(); 122 | const zoomChanged = currentTransform.a !== newZoom; 123 | const offsetChanged = 124 | currentTransform.e !== offset_x || currentTransform.f !== offset_y; 125 | 126 | // Only transform if config changed 127 | if (zoomChanged || offsetChanged) { 128 | ctx.reset(); 129 | // ctx.scale(newZoom, newZoom); Deprecated 130 | ctx.translate(offset_x, offset_y); 131 | } 132 | 133 | let startOffset = 0.5; 134 | ctx.translate(startOffset, startOffset); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/game/graphics/canvas/CanvasController.ts: -------------------------------------------------------------------------------- 1 | import { GameBoard } from "../../core/GameBoard.js"; 2 | import { Point } from "../../structures/CartesianPlane.js"; 3 | import { GraphicsController } from "../GraphicsController.js"; 4 | import { CanvasPainter } from "./CanvasPainter.js"; 5 | import { 6 | CanvasConfig, 7 | CanvasConfigParams, 8 | defaultCanvasConfig, 9 | } from "./config.js"; 10 | import { CanvasPlugin } from "./plugins/CanvasPlugin.js"; 11 | import { SelectedCells } from "./plugins/SelectedCells.js"; 12 | import { Draggable } from "./plugins/Draggable.js"; 13 | import { KeyControl } from "./plugins/KeyControl.js"; 14 | 15 | export class CanvasController extends GraphicsController { 16 | private painter: CanvasPainter; 17 | private plugins: CanvasPlugin[]; 18 | 19 | protected listeners: { 20 | onConfigChange: ((config: CanvasConfig) => any)[]; 21 | }; 22 | 23 | protected canvas: HTMLCanvasElement; 24 | protected canvasContext: CanvasRenderingContext2D; 25 | protected config: CanvasConfig; 26 | 27 | protected selectedCells: Point[] = []; 28 | 29 | constructor(canvas: HTMLCanvasElement) { 30 | super(); 31 | this.canvas = canvas; 32 | this.canvasContext = 33 | canvas.getContext("2d") || new CanvasRenderingContext2D(); 34 | 35 | if (!this.canvas || !this.canvas.getContext("2d")) 36 | throw new Error("Canvas cannot be null"); 37 | 38 | this.config = defaultCanvasConfig; 39 | this.listeners = { 40 | onConfigChange: [], 41 | }; 42 | 43 | this.painter = new CanvasPainter(canvas, this.canvasContext); 44 | this.plugins = []; 45 | 46 | this.initPlugins(); 47 | this.configDOMCanvas(); 48 | } 49 | 50 | protected render() { 51 | this.painter.paint(this.config, this.aliveCells, this.selectedCells); 52 | } 53 | 54 | private initPlugins() { 55 | const draggable = new Draggable( 56 | this.canvas, 57 | () => this.getConfig(), 58 | (config) => this.setConfig(config), 59 | ); 60 | 61 | const selectedCells = new SelectedCells( 62 | this.canvas, 63 | () => this.getConfig(), 64 | (config) => this.setConfig(config), 65 | () => this.selectedCells, 66 | (selectedCells) => this.setSelectedCells(selectedCells), 67 | ); 68 | 69 | selectedCells.onCellClicked((point) => { 70 | this.events.emitCellToggle(point); 71 | }); 72 | 73 | const keyControls = new KeyControl( 74 | this.canvas, 75 | () => this.getConfig(), 76 | (config) => this.setConfig(config), 77 | ); 78 | 79 | keyControls.onGameStartStop(() => { 80 | this.events.emitGameStartStop(); 81 | }); 82 | 83 | keyControls.onSpeedUp((factor) => { 84 | this.events.emitSpeedUp(factor); 85 | }); 86 | 87 | keyControls.onSpeedDowm((factor) => { 88 | this.events.emitSpeedDown(factor); 89 | }); 90 | 91 | this.plugins = [draggable, selectedCells, keyControls]; 92 | } 93 | 94 | private configDOMCanvas() { 95 | const { height, width } = this.config.board; 96 | const { width: canvasWidth, height: canvasHeight } = this.canvas; 97 | 98 | this.canvas.style.width = `${this.config.board.width}px`; 99 | this.canvas.style.height = `${this.config.board.height}px`; 100 | if (width !== canvasWidth) this.canvas.width = this.config.board.width; 101 | if (height !== canvasHeight) this.canvas.height = this.config.board.height; 102 | this.canvas.style.overflow = "hidden"; 103 | this.canvas.style.backgroundColor = this.config.colors.background; 104 | } 105 | 106 | protected setSelectedCells(selectedCells: Point[]) { 107 | this.selectedCells = selectedCells; 108 | this.render(); 109 | } 110 | 111 | public getConfig(): CanvasConfig { 112 | const { board, cells, colors, grid } = this.config; 113 | return { 114 | board: { 115 | ...board, 116 | }, 117 | cells: { 118 | ...cells, 119 | }, 120 | colors: { 121 | ...colors, 122 | }, 123 | grid: { 124 | ...grid, 125 | }, 126 | }; 127 | } 128 | 129 | public setConfig({ board, cells, colors, grid }: CanvasConfigParams) { 130 | this.config = { 131 | board: { 132 | ...this.config.board, 133 | ...board, 134 | }, 135 | cells: { 136 | ...this.config.cells, 137 | ...cells, 138 | }, 139 | colors: { 140 | ...this.config.colors, 141 | ...colors, 142 | }, 143 | grid: { 144 | ...this.config.grid, 145 | ...grid, 146 | }, 147 | }; 148 | window.requestAnimationFrame(() => { 149 | this.render(); 150 | this.configDOMCanvas(); 151 | setTimeout(() => { 152 | this.listeners.onConfigChange.forEach((listener) => 153 | listener(this.getConfig()), 154 | ); 155 | }, 1); 156 | }); 157 | } 158 | 159 | public onConfigChange(listener: (config: CanvasConfig) => any) { 160 | this.listeners.onConfigChange.push(listener); 161 | } 162 | } 163 | 164 | declare global { 165 | interface CanvasRenderingContext2D { 166 | reset(): void; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/game/GameOfLife.ts: -------------------------------------------------------------------------------- 1 | import { GameBoard } from "./core/GameBoard.js"; 2 | import { GameEngine } from "./core/GameEngine.js"; 3 | import { GraphicsController } from "./graphics/GraphicsController.js"; 4 | import { Point } from "./structures/CartesianPlane.js"; 5 | 6 | interface EvolutionState { 7 | isEvolving: boolean; 8 | intervalID: number; 9 | config: { 10 | onNextGeneration: (board: Point[]) => any; 11 | delay: number; 12 | }; 13 | } 14 | 15 | export class GameOfLife { 16 | private gameBoard: GameBoard; 17 | private engine: GameEngine; 18 | public readonly graphics: GraphicsType; 19 | 20 | private evolution: EvolutionState = { 21 | isEvolving: false, 22 | intervalID: -1, 23 | config: { 24 | onNextGeneration: (board: Point[]) => {}, 25 | delay: 500, 26 | }, 27 | }; 28 | 29 | constructor(graphics: GraphicsType) { 30 | this.gameBoard = new GameBoard(); 31 | this.engine = new GameEngine(); 32 | this.graphics = graphics; 33 | this.initEvents(); 34 | } 35 | 36 | public bornCell({ x, y }: Point) { 37 | this.stopEvolution(); 38 | this.gameBoard.setCell(x, y, true); 39 | this.updateGraphics(); 40 | } 41 | 42 | public bornCells(points: Point[]) { 43 | this.stopEvolution(); 44 | points.forEach((point) => this.gameBoard.setCell(point.x, point.y, true)); 45 | this.updateGraphics(); 46 | } 47 | 48 | public killCell({ x, y }: Point) { 49 | this.stopEvolution(); 50 | this.gameBoard.setCell(x, y, false); 51 | this.updateGraphics(); 52 | } 53 | 54 | public toggleCell({ x, y }: Point) { 55 | this.stopEvolution(); 56 | this.gameBoard.setCell(x, y, !this.gameBoard.getCell(x, y)); 57 | this.updateGraphics(); 58 | } 59 | 60 | public exterminateCells() { 61 | this.stopEvolution(); 62 | this.gameBoard.resetCells(); 63 | this.updateGraphics(); 64 | } 65 | 66 | public getCells() { 67 | return this.gameBoard.getCells(); 68 | } 69 | 70 | public speedUp(factor: number) { 71 | const { delay } = this.evolution.config; 72 | this.changeDelay(Math.floor(delay / (factor || 1))); 73 | } 74 | 75 | public speedDown(factor: number) { 76 | const { delay } = this.evolution.config; 77 | this.changeDelay(Math.floor(delay / (factor || 1))); 78 | } 79 | 80 | public changeDelay(ms: number) { 81 | this.setConfig({ 82 | delay: ms >= 20 ? ms : 20, 83 | }); 84 | } 85 | 86 | public setConfig(options: { 87 | onNextGeneration?: (board: Point[]) => void; 88 | delay?: number; 89 | }) { 90 | const { onNextGeneration, delay } = options; 91 | const { config } = this.evolution; 92 | 93 | this.stopEvolution(); 94 | config.onNextGeneration = onNextGeneration || config.onNextGeneration; 95 | config.delay = delay || config.delay; 96 | this.startEvolution(); 97 | } 98 | 99 | public startEvolution() { 100 | const { isEvolving, config } = this.evolution; 101 | const { onNextGeneration, delay } = config; 102 | 103 | if (isEvolving) return; 104 | 105 | onNextGeneration(this.gameBoard.getCells()); 106 | this.updateGraphics(); 107 | 108 | const intervalID = window.setInterval(() => { 109 | this.evolveGeneration(); 110 | onNextGeneration(this.gameBoard.getCells()); 111 | }, delay); 112 | 113 | this.evolution.isEvolving = true; 114 | this.evolution.intervalID = intervalID; 115 | } 116 | 117 | public stopEvolution() { 118 | const { isEvolving, intervalID } = this.evolution; 119 | 120 | if (isEvolving && intervalID !== -1) { 121 | clearInterval(intervalID); 122 | this.evolution.intervalID = -1; 123 | this.evolution.isEvolving = false; 124 | } 125 | } 126 | 127 | private evolveGeneration() { 128 | const newGeneration = this.engine.nextGeneration(this.gameBoard); 129 | this.gameBoard = newGeneration; 130 | this.updateGraphics(); 131 | } 132 | 133 | private updateGraphics() { 134 | this.graphics.setCells(this.gameBoard.getCells()); 135 | } 136 | 137 | private initEvents() { 138 | const { events } = this.graphics; 139 | 140 | events.on({ 141 | type: "onCellBorn", 142 | callback: (point: Point) => { 143 | this.bornCell(point); 144 | }, 145 | }); 146 | 147 | events.on({ 148 | type: "onCellKill", 149 | callback: (point: Point) => { 150 | this.killCell(point); 151 | }, 152 | }); 153 | 154 | events.on({ 155 | type: "onCellToggle", 156 | callback: (point: Point) => { 157 | this.toggleCell(point); 158 | }, 159 | }); 160 | 161 | events.on({ 162 | type: "onGameStartStop", 163 | callback: () => { 164 | if (this.evolution.isEvolving) this.stopEvolution(); 165 | else this.startEvolution(); 166 | }, 167 | }); 168 | 169 | events.on({ 170 | type: "onSpeedUp", 171 | callback: (factor) => { 172 | this.speedUp(factor); 173 | }, 174 | }); 175 | 176 | events.on({ 177 | type: "onSpeedDown", 178 | callback: (factor) => { 179 | this.speedDown(factor); 180 | }, 181 | }); 182 | } 183 | } 184 | --------------------------------------------------------------------------------