Conway's Game of Life

A implementation of Conway's Game of Life cellular automaton (and variants).

This example demonstrates providing live 2D data efficiently from Python to QML.

Files to load into this simulator can be found online, e.g. going to https://conwaylife.com/wiki/Gosper_glider_gun and downloading the "RLE" file under "Pattern files" in the right sidebar.

 // SPDX-FileCopyrightText: Copyright (c) 2024 Refeyn Ltd and other QuickGraphLib contributors
 // SPDX-License-Identifier: MIT

 import QtQuick
 import QtQuick.Layouts as QQL
 import QtQuick.Dialogs as QQD
 import QtQuick.Controls as QQC
 import QuickGraphLib as QuickGraphLib
 import examples.Conway as ConwayProvider

 QQL.ColumnLayout {
     QQD.FileDialog {
         id: loadDialog

         fileMode: QQD.FileDialog.OpenFile
         nameFilters: ["RLE files (*.rle)", "All files (*)"]
         title: "Load RLE pattern"

         onAccepted: conwayProvider.loadFile(selectedFile)
     }
     Timer {
         interval: 1000 / 60
         repeat: true
         running: true

         onTriggered: gc()// Prevent JS hoarding too many frames, since it doesn't know how big they are
     }
     Timer {
         interval: intervalSpin.value
         repeat: true
         running: runButton.checked

         onTriggered: conwayProvider.tick()
     }
     ConwayProvider.ConwayProvider {
         id: conwayProvider

     }
     QQL.RowLayout {
         QQC.Button {
             text: "Load pattern"

             onClicked: loadDialog.open()
         }
         QQC.Button {
             text: "Reset"

             onClicked: conwayProvider.reset()
         }
         QQC.Button {
             text: "Step"

             onClicked: conwayProvider.tick()
         }
         QQC.Button {
             id: runButton

             checkable: true
             text: checked ? "Pause" : "Run"
         }
         QQC.Button {
             text: "Rotate 90°"

             onClicked: conwayProvider.rot90()
         }
         Item {
             QQL.Layout.fillWidth: true
         }
         QQC.Label {
             text: "Display mode"
         }
         QQC.ComboBox {
             id: displayModeCombo

             model: [
                 {
                     "value": "historyCells",
                     "text": "Cells with history"
                 },
                 {
                     "value": "cells",
                     "text": "Cells"
                 },
                 {
                     "value": "neighbourCounts",
                     "text": "Neighbour counts"
                 }
             ]
             textRole: "text"
             valueRole: "value"
         }
     }
     Item {
         QQL.Layout.fillHeight: true
         QQL.Layout.fillWidth: true
         clip: true

         QuickGraphLib.ZoomPanHandler {
             id: pinchArea

             anchors.fill: parent
         }
         QuickGraphLib.ImageView {
             anchors.fill: parent
             colormap: QuickGraphLib.ColorMaps.Magma
             fillMode: Qt.KeepAspectRatio
             source: conwayProvider[displayModeCombo.currentValue || "cells"]
             sourceSize: conwayProvider.size

             transform: Matrix4x4 {
                 matrix: pinchArea.viewTransform
             }
         }
     }
     QQL.RowLayout {
         QQC.Label {
             text: "ms per tick"
         }
         QQC.SpinBox {
             id: intervalSpin

             editable: true
             from: 1
             to: 2000
             value: 100
         }
         QQC.Label {
             text: "Size"
         }
         QQC.SpinBox {
             id: widthSpin

             editable: true
             from: 1
             to: 2000
             value: conwayProvider.size.width

             onValueModified: conwayProvider.size = Qt.size(widthSpin.value, heightSpin.value)
         }
         QQC.Label {
             text: "x"
         }
         QQC.SpinBox {
             id: heightSpin

             editable: true
             from: 1
             to: 2000
             value: conwayProvider.size.height

             onValueModified: conwayProvider.size = Qt.size(widthSpin.value, heightSpin.value)
         }
         QQC.Label {
             text: "Rule B"
         }
         QQC.TextField {
             implicitWidth: 75
             maximumLength: 9
             text: conwayProvider.ruleB

             validator: RegularExpressionValidator {
                 regularExpression: /[0-8]*/
             }

             onEditingFinished: conwayProvider.ruleB = text
         }
         QQC.Label {
             text: "/S"
         }
         QQC.TextField {
             implicitWidth: 75
             maximumLength: 9
             text: conwayProvider.ruleS

             validator: RegularExpressionValidator {
                 regularExpression: /[0-8]*/
             }

             onEditingFinished: conwayProvider.ruleS = text
         }
     }
 }
 # SPDX-FileCopyrightText: Copyright (c) 2024 Refeyn Ltd and other QuickGraphLib contributors
 # SPDX-License-Identifier: MIT

 import enum
 import itertools
 import pathlib
 import re
 from typing import List, Tuple

 import numpy as np
 from PySide6 import QtCore, QtQml

 from QuickGraphLib import QGLDoubleList

 QML_IMPORT_NAME = "examples.Conway"
 QML_IMPORT_MAJOR_VERSION = 1
 QML_IMPORT_MINOR_VERSION = 0

 @QtQml.QmlElement
 class ConwayProvider(QtCore.QObject):
     class CellTransition(enum.IntFlag):
         SURVIVE = 1
         BORN = 2
         DIE = 0
         BORN_AND_SURVIVE = SURVIVE | BORN

     RULE_LENGTH = 9
     FILE_LOAD_PADDING = 2

     def __init__(self):
         super().__init__()

         self._buffer = np.zeros((100, 100), dtype=bool)
         self._history_buffer = np.zeros_like(self._buffer, dtype=int)
         self._neighbour_counts = np.zeros_like(self._buffer, dtype=int)
         # Standard game is B3/S23
         self._rule = self._rule_from_strs("3", "23")

     sizeChanged = QtCore.Signal(name="sizeChanged")

     @QtCore.Property(QtCore.QSize, notify=sizeChanged)
     def size(self) -> QtCore.QSize:
         return QtCore.QSize(*self._buffer.shape[::-1])

     @size.setter  # type: ignore[no-redef]
     def size(self, size: QtCore.QSize) -> None:
         left_y = max(0, round((size.height() - self._buffer.shape[0]) / 2))
         right_y = max(0, round((self._buffer.shape[0] - size.height()) / 2))
         min_height = min(self._buffer.shape[0], size.height())

         left_x = max(0, round((size.width() - self._buffer.shape[1]) / 2))
         right_x = max(0, round((self._buffer.shape[1] - size.width()) / 2))
         min_width = min(self._buffer.shape[1], size.width())

         new_buffer = np.zeros((size.height(), size.width()), dtype=bool)
         new_buffer[left_y : left_y + min_height, left_x : left_x + min_width] = (
             self._buffer[right_y : right_y + min_height, right_x : right_x + min_width]
         )
         self._buffer = new_buffer

         new_history_buffer = np.zeros((size.height(), size.width()), dtype=int)
         new_history_buffer[
             left_y : left_y + min_height, left_x : left_x + min_width
         ] = self._history_buffer[
             right_y : right_y + min_height, right_x : right_x + min_width
         ]
         self._history_buffer = new_history_buffer

         new_neighbour_counts = np.zeros((size.height(), size.width()), dtype=int)
         new_neighbour_counts[
             left_y : left_y + min_height, left_x : left_x + min_width
         ] = self._neighbour_counts[
             right_y : right_y + min_height, right_x : right_x + min_width
         ]
         self._neighbour_counts = new_neighbour_counts

         self.sizeChanged.emit()
         self.dataChanged.emit()

     ruleChanged = QtCore.Signal(name="ruleChanged")

     @QtCore.Property(str, notify=ruleChanged)
     def ruleB(self) -> str:
         return self._strs_from_rule()[0]

     @ruleB.setter  # type: ignore[no-redef]
     def ruleB(self, rule_b: str) -> None:
         self._rule = self._rule_from_strs(rule_b, self._strs_from_rule()[1])
         self.ruleChanged.emit()

     @QtCore.Property(str, notify=ruleChanged)
     def ruleS(self) -> str:
         return self._strs_from_rule()[1]

     @ruleS.setter  # type: ignore[no-redef]
     def ruleS(self, rule_s: str) -> None:
         self._rule = self._rule_from_strs(self._strs_from_rule()[0], rule_s)
         self.ruleChanged.emit()

     dataChanged = QtCore.Signal(name="dataChanged")

     @QtCore.Property(QGLDoubleList, notify=dataChanged)  # type: ignore[operator, arg-type]
     def cells(self) -> QGLDoubleList:
         return QGLDoubleList.fromNDArray(self._buffer.ravel().astype(int))

     @QtCore.Property(QGLDoubleList, notify=dataChanged)  # type: ignore[operator, arg-type]
     def historyCells(self) -> QGLDoubleList:
         return QGLDoubleList.fromNDArray(self._history_buffer.ravel())

     @QtCore.Property(QGLDoubleList, notify=dataChanged)  # type: ignore[operator, arg-type]
     def neighbourCounts(self) -> QGLDoubleList:
         return QGLDoubleList.fromNDArray(self._neighbour_counts.ravel())

     @QtCore.Slot()
     def tick(self) -> None:
         self._neighbour_counts = np.zeros(self._buffer.shape, dtype=int)
         for y_shift, x_shift in itertools.product([-1, 0, 1], repeat=2):
             if y_shift or x_shift:
                 self._neighbour_counts += np.roll(
                     self._buffer, (y_shift, x_shift), (0, 1)
                 )
         for i, rule in enumerate(self._rule):
             if rule == self.CellTransition.BORN_AND_SURVIVE:
                 self._buffer[self._neighbour_counts == i] = True
             elif rule == self.CellTransition.BORN:
                 self._buffer[self._neighbour_counts == i] = ~self._buffer[
                     self._neighbour_counts == i
                 ]
             elif rule == self.CellTransition.SURVIVE:
                 pass
             elif rule == self.CellTransition.DIE:
                 self._buffer[self._neighbour_counts == i] = False
         self._history_buffer *= 95
         self._history_buffer //= 100
         self._history_buffer[self._buffer] = 1000
         self.dataChanged.emit()

     @QtCore.Slot()
     def reset(self) -> None:
         self._buffer = np.zeros((100, 100), dtype=bool)
         self._history_buffer = np.zeros_like(self._buffer, dtype=int)
         self._neighbour_counts = np.zeros_like(self._buffer, dtype=int)
         self.dataChanged.emit()
         self.sizeChanged.emit()

     @QtCore.Slot()
     def rot90(self) -> None:
         self._buffer = np.rot90(self._buffer)
         self._history_buffer = np.rot90(self._history_buffer)
         self._neighbour_counts = np.rot90(self._neighbour_counts)
         self.dataChanged.emit()
         self.sizeChanged.emit()

     def _rule_from_strs(self, rule_b: str, rule_s: str) -> List[CellTransition]:
         rule = [self.CellTransition.DIE] * self.RULE_LENGTH
         for num in rule_s:
             rule[int(num)] |= self.CellTransition.SURVIVE
         for num in rule_b:
             rule[int(num)] |= self.CellTransition.BORN
         return rule

     def _strs_from_rule(self) -> Tuple[str, str]:
         rule_s = "".join(
             [
                 str(i)
                 for i in range(self.RULE_LENGTH)
                 if self._rule[i] & self.CellTransition.SURVIVE
             ]
         )
         rule_b = "".join(
             [
                 str(i)
                 for i in range(self.RULE_LENGTH)
                 if self._rule[i] & self.CellTransition.BORN
             ]
         )
         return rule_b, rule_s

     @QtCore.Slot(QtCore.QUrl)
     def loadFile(self, url: QtCore.QUrl) -> None:
         path = pathlib.Path(url.toLocalFile())
         if path.suffix != ".rle":
             print("Unrecognized file type")
             return

         data = path.read_text(encoding="ascii")
         seen_header = False
         initial_x = current_x = current_y = 0
         buffer = np.zeros((0, 0), dtype=bool)
         rule_b = rule_s = None
         for line in data.split("\n"):
             if line.startswith("#") or not line.strip():
                 pass
             elif not seen_header:
                 line_match = re.match(
                     r"\s*x\s*=\s*(\d+),\s*y\s*=\s*(\d+)(?:,\s*rule\s*=\s*B(\d*)/S(\d*))?",
                     line,
                 )
                 if not line_match:
                     print("Could not interpret file header")
                     return
                 x, y, rule_b, rule_s = line_match.groups()

                 x, y = int(x), int(y)
                 buffer = np.zeros(
                     (
                         max(self._buffer.shape[0], y + self.FILE_LOAD_PADDING * 2),
                         max(self._buffer.shape[1], x + self.FILE_LOAD_PADDING * 2),
                     ),
                     dtype=bool,
                 )
                 initial_x = current_x = (buffer.shape[1] - x) // 2
                 current_y = (buffer.shape[0] - y) // 2
                 seen_header = True
             else:
                 line = line.strip()
                 while line and line != "!":
                     tag = re.match(r"(\d*)([a-zA-Z$])", line)
                     if not tag:
                         print("Could not interpret RLE sequence item")
                         return
                     times = int(tag.group(1) or "1")
                     op = tag.group(2) if tag.group(2) in "ob$" else "o"
                     for _ in range(times):
                         if op == "$":
                             current_y += 1
                             current_x = initial_x
                         else:
                             if op == "o":
                                 buffer[(current_y, current_x)] = True
                             current_x += 1
                     line = line[tag.end() :].strip()

         self._buffer = buffer
         self._history_buffer = self._buffer.astype(int)
         self._neighbour_counts = np.zeros_like(self._buffer, dtype=int)
         self._rule = self._rule_from_strs(
             "3" if rule_b is None else rule_b, "23" if rule_s is None else rule_s
         )
         self.sizeChanged.emit()
         self.dataChanged.emit()
         self.ruleChanged.emit()