1
\$\begingroup\$

I'm using React Native to implement Conway's Game of Life as a mobile phone app. So far I've implemented the game logic and a main screen with an embedded GLView component where the game board is rendered, as well as buttons for moving the camera. The touch screen system to allow users to set the initial board state hasn't been implemented yet. As it is the app starts from a fixed pseudorandom board state that I'm using for performance testing. This is the first substantial JavaScript program I've written and I'd appreciate it if someone could provide feedback on the readability of the code.

Feedback about design choices is also fine but I'm deliberately avoiding saying more about them at this point, as I'm interested in how well the design can be understood from the code and comments. I've embedded the three core modules below; any additional code they import can be found here.

App.js

import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { StyleSheet, Text, View, TouchableOpacity, Image, useWindowDimensions } from 'react-native';
import {GLView} from 'expo-gl';
import {onContextCreation, control, initialiseControls} from './GameBoardRenderer.js';

const styles = StyleSheet.create({
  mainScreenContainer: {
    flex: 1,
    backgroundColor: "rgb(255, 255, 255)",
    flexDirection: "column-reverse",
    alignItems: "center"
  },
  mainButtonContainer: {
    flex: 0.1,
    flexDirection: "row",
    backgroundColor: "rgb(0, 0, 160)"
  },
  gameBoardContainer: {
    flex: 0.8,
    backgroundColor: "rgb(255, 255, 255)"
  },
  menuBarContainer: {
    flex: 0.1,
    backgroundColor: "rgb(0, 0, 0)",
    flexDirection: "row",
    justifyContent: "flex-end"
  },
  mainScreenButton: {
    flex: 1,
    flexDirection: "row",
    backgroundColor: "rgb(255, 255, 255)"
  },
  mainScreenButtonImage: {
    flex: 1
  }
});

const mainScreen = () => {
  const window = useWindowDimensions();
  const buttonHeight = window.height * 0.1;
  const buttonWidth = window.width * 0.125;
  initialiseControls();

  return (
    <View style={[styles.mainScreenContainer, {paddingTop: window.height * 0.04}]}>
      <View style={[styles.mainButtonContainer, {width: window.width}]}>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}>
          <Image source={require("./assets/playButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}>
          <Image source={require("./assets/resetButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}
                          onPress={control.moveCameraUp}>
          <Image source={require("./assets/upButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}
                          onPress={control.moveCameraDown}>
          <Image source={require("./assets/downButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}
                          onPress={control.moveCameraLeft}>
          <Image source={require("./assets/leftButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}
                          onPress={control.moveCameraRight}>
          <Image source={require("./assets/rightButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}
                          onPress={control.moveCameraForward}>
          <Image source={require("./assets/plusButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.mainScreenButton, {height: buttonHeight}]}
                          onPress={control.moveCameraBack}>
          <Image source={require("./assets/minusButton.png")} resizeMode="cover"
                 style={[styles.mainScreenButtonImage, {height: buttonHeight}]}/>
        </TouchableOpacity>
      </View>
      <GLView style={[styles.gameBoardContainer, {width: window.width}]} onContextCreate={onContextCreation} />
      <View style={[styles.menuBarContainer, {width: window.width}]}>
        <Image source={require("./assets/menuButton.png")}
               style={{height: buttonHeight, width: buttonWidth}} />
      </View>
    </View>
  );
};

export default mainScreen;

GameBoardRenderer.js

// This module contains functions that implement the rendering of the game board by determining
// the contents of the corresponding GLView component for each frame.

import vertexShader from './VertexShader.js';
import fragmentShader from './FragmentShader.js';
import {cellModel, verticalLineModel, horizontalLineModel, modelElements}
from './GameBoardModels.js';
import {gameBoard, handleUpdateEvent} from './GameLogic.js';

// These global variables are assigned values related to the OpenGL context that will be needed to
// render the game board each frame.
var gl;
var glP;

var tickCount = 0;
var reportSwitch = true;
var modelCount = 0;

// This function is used to encapsulate values related to the OpenGL context in an object, a
// reference to which is assigned to global variable glP when the renderer is initialised.
function glParameters(uniform_modToClip, uniform_colour, attribute_modPosition,
                      vertexBuffer_cellModel, vertexBuffer_verticalLineModel,
                      vertexBuffer_horizontalLineModel, elementBuffer) {
  let frustumScale = 1;
  let zNear = 0.5;
  let zFar = 100;
  return {
    uniform_modToClip: function() {
      return uniform_modToClip;
    },
    uniform_colour: function() {
      return uniform_colour;
    },
    attribute_modPosition: function() {
      return attribute_modPosition;
    },
    vertexBuffer_cellModel: function() {
      return vertexBuffer_cellModel;
    },
    vertexBuffer_verticalLineModel: function() {
      return vertexBuffer_verticalLineModel;
    },
    vertexBuffer_horizontalLineModel: function() {
      return vertexBuffer_horizontalLineModel;
    },
    elementBuffer: function() {
      return elementBuffer;
    },
    frustumScale: function() {
      return frustumScale;
    },
    zNear: function() {
      return zNear;
    },
    zFar: function() {
      return zFar;
    }
  }
}

// The top level application state that can be modified through the UI is encapsulated in the object
// returned by getControlObject.  This includes everything except the game board state.
// A reference to this object is assigned to gloabal variable control when the renderer
// is initialised.
var control;

function getControlObject() {
  let mode = "creative";
  const camera = {
    x: 0, y: 0, z: -48
  };
  let showGrid = true;
  let scale = Math.abs(camera.z) / 8;

  return {
    setMode: function(newMode) {
      mode = newMode;
    },
    moveCameraLeft: function() {
      camera.x += scale;
    },
    moveCameraRight: function() {
      camera.x -= scale;
    },
    moveCameraUp: function() {
      camera.y -= scale;
    },
    moveCameraDown: function() {
      camera.y += scale;
    },
    moveCameraBack: function() {
      camera.z -= 1;
    },
    moveCameraForward: function() {
      camera.z += 1;
    },
    setShowGrid: function(newMode) {
      showGrid = newMode;
    },
    getMode: function() {
      return mode;
    },
    getCamera: function() {
      return {
        x: camera.x, y: camera.y, z: camera.z
      };
    },
    getShowGrid: function() {
      return showGrid;
    }
  }
}

// This function is called from App before GL context creation so that the controls are ready to
// use in time.
function initialiseControls() {
  control = getControlObject();
}

// This function returns a function that is used to generate the transformation matrix that is
// passed to the vertex shader for each model rendered.
function genModelTransformFunction(x, y, z, frustumTerm1, zNear, zFar) {
  const window = {width: gl.drawingBufferWidth, height: gl.drawingBufferHeight};
  const frustumTerm0 = frustumTerm1 / (window.width / window.height);
  const frustumTerm2 = (zFar + zNear) / (zNear - zFar);
  const frustumTerm3 = (2 * zFar * zNear) / (zNear - zFar);

  function modelTransformFunction(i, j) {
    const modelToClip = [frustumTerm0, 0, 0, frustumTerm0 * (x + i),
                         0, frustumTerm1, 0, frustumTerm1 * (y + j),
                         0, 0, frustumTerm2, frustumTerm2 * z + frustumTerm3,
                         0, 0, -1, -z];
    return modelToClip;
  }

  return modelTransformFunction;
}

// This function is called when the corresponding GLView component is first rendered in App.
// It creates the single GL shader program used for rendering the game board and assigns values
// related to the GL context to the global variables gl and glP.
function onContextCreation(_gl) {
  _gl.viewport(0, 0, _gl.drawingBufferWidth, _gl.drawingBufferHeight);
  _gl.clearColor(1, 1, 1, 1);
  _gl.clear(_gl.COLOR_BUFFER_BIT);

  const vert = _gl.createShader(_gl.VERTEX_SHADER);
  _gl.shaderSource(vert, vertexShader);
  _gl.compileShader(vert);

  const frag = _gl.createShader(_gl.FRAGMENT_SHADER);
  _gl.shaderSource(frag, fragmentShader);
  _gl.compileShader(frag);

  const shaderProgram = _gl.createProgram();
  _gl.attachShader(shaderProgram, vert);
  _gl.attachShader(shaderProgram, frag);
  _gl.linkProgram(shaderProgram);
  _gl.validateProgram(shaderProgram);
  const vertStatus = _gl.getShaderParameter(vert, _gl.COMPILE_STATUS);
  const fragStatus = _gl.getShaderParameter(frag, _gl.COMPILE_STATUS);
  const linkStatus = _gl.getProgramParameter(shaderProgram, _gl.LINK_STATUS);
  const programValid = _gl.getProgramParameter(shaderProgram, _gl.VALIDATE_STATUS);
  console.log("Vertex shader status: " + vertStatus);
  console.log("Fragment shader status: " + fragStatus);
  console.log("Program link status: " + linkStatus);
  console.log("Program validate status: " + programValid);

  const uniform_modToClip = _gl.getUniformLocation(shaderProgram, "modToClip");
  const uniform_colour = _gl.getUniformLocation(shaderProgram, "colour");
  const attribute_modPosition = _gl.getAttribLocation(shaderProgram, "modPosition");
  const vertexBuffer_cellModel = loadBuffer(cellModel, null, _gl);
  const vertexBuffer_verticalLineModel = loadBuffer(verticalLineModel, null, _gl);
  const vertexBuffer_horizontalLineModel = loadBuffer(horizontalLineModel, null, _gl);
  const elementBuffer = loadBuffer(null, modelElements, _gl);
  glP = glParameters(uniform_modToClip, uniform_colour, attribute_modPosition,
                     vertexBuffer_cellModel, vertexBuffer_verticalLineModel,
                     vertexBuffer_horizontalLineModel, elementBuffer);
  _gl.useProgram(shaderProgram);

  gl = _gl;
  setInterval(handleRenderEvent, 200);
}

// This function loads vertex and element drawing data into GL buffers, which will be used to
// perform the rendering for each frame.
function loadBuffer(vertexArray, elementArray, _gl) {
  let buffer;
  if (elementArray === null) {
    buffer = _gl.createBuffer();
    _gl.bindBuffer(_gl.ARRAY_BUFFER, buffer);
    _gl.bufferData(_gl.ARRAY_BUFFER, new Float32Array(vertexArray), _gl.STREAM_DRAW);
  }
  else {
    buffer = _gl.createBuffer();
    _gl.bindBuffer(_gl.ELEMENT_ARRAY_BUFFER, buffer);
    _gl.bufferData(_gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(elementArray), _gl.STREAM_DRAW);
  }

  return buffer;
}

// This function is used to apply the model to clip space transform function for each vertical or
// horizontal grid line on the game board, thereby allowing these grid lines to optionally be
// rendered.
function genGridTransforms(transformFunction, transformArray, i, j, diffI, diffJ, cMax) {
  for (let c = 0; c <= cMax; c++) {
    transformArray.push(transformFunction(i, j));
    i += diffI;
    j += diffJ;
  }
}

// This function applies the model to clip space transform function for each
// live cell on the game board, thereby allowing a cell model to be rendered in each corresponding
// position.
function genCellTransforms(gameBoard, transformFunction, transformArray, i, j, maxI, maxJ) {
  if (i > maxI) {return;}

  if (gameBoard[i][j].quadrant1) {transformArray.push(transformFunction(i, j));}

  if (gameBoard[i][j].quadrant2) {transformArray.push(transformFunction(-i, j));}

  if (gameBoard[i][j].quadrant3) {transformArray.push(transformFunction(-i, -j));}

  if (gameBoard[i][j].quadrant4) {transformArray.push(transformFunction(i, -j));}

  if (j === maxJ) {
    genCellTransforms(gameBoard, transformFunction, transformArray, i + 1, 0, maxI, maxJ);
  }
  else {genCellTransforms(gameBoard, transformFunction, transformArray, i, j + 1, maxI, maxJ);}
}

// This function is the central branching point of this module and is called through an interval
// timer.
function handleRenderEvent() {
  handleUpdateEvent();
  const {x, y, z} = control.getCamera();
  transformFunction = genModelTransformFunction(x, y, z, glP.frustumScale(), glP.zNear(),
                                                glP.zFar());
  gl.clear(gl.COLOR_BUFFER_BIT);
  let transformArray = [];
  genCellTransforms(gameBoard, transformFunction, transformArray, 0, 0, 25, 25);
  renderModels(1, transformArray, glP.uniform_modToClip(), glP.uniform_colour(),
               glP.attribute_modPosition(), glP.vertexBuffer_cellModel(), glP.elementBuffer());
  // genGridTransforms(transformFunction, transformArray, -64, 0, 1, 0, 127);
  // renderModels(1, transformArray, glP.uniform_modToClip(), glP.uniform_colour(),
  //             glP.attribute_modPosition(), glP.vertexBuffer_horizontalLineModel(),
  //             glP.elementBuffer());
  // genGridTransforms(transformFunction, transformArray, 0, -64, 0, 1, 127);
  // renderModels(1, transformArray, glP.uniform_modToClip(), glP.uniform_colour(),
  //             glP.attribute_modPosition(), glP.vertexBuffer_verticalLineModel(),
  //             glP.elementBuffer());
  gl.flush();
  gl.endFrameEXP();

  if (tickCount % 5 === 0) {console.log("tickCount: " + tickCount);}

  tickCount++;
}

// This function is used to render all the cell, vertical or horizontal graph line models
// for the current frame.
function renderModels(mode, transformArray, uniform_modToClip, uniform_colour,
                      attribute_modPosition, vertexBuffer, elementBuffer) {
  if (mode === 0) {
    if (transformArray.length === 0) {
      if (reportSwitch) {
        console.log("modelCount: " + modelCount);
        reportSwitch = false;
      }

      return;
    }
    let transform = transformArray.pop();
    gl.uniformMatrix4fv(uniform_modToClip, true, transform);
    gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
    modelCount++;
    renderModels(0, transformArray, uniform_modToClip, uniform_colour, attribute_modPosition,
                 vertexBuffer, elementBuffer);
  }
  else {
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
    gl.vertexAttribPointer(attribute_modPosition, 4, gl.FLOAT, false, 0, 0);
    gl.enableVertexAttribArray(attribute_modPosition);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, elementBuffer);
    gl.uniform4fv(uniform_colour, [0, 0, 1, 1]);
    renderModels(0, transformArray, uniform_modToClip, uniform_colour, attribute_modPosition,
                 vertexBuffer, elementBuffer);
  }
}

export {onContextCreation, control, initialiseControls};

GameLogic.js

// The gameBoard and newGameBoard arrays hold the state of the game board itself.
// The boardUpdateTable array holds meta data that allows an optimisation to be applied, such that
// certain dead cells that have no chance of becoming live at the next game time tick don't
// have the game logic applied to them.

import {testBoardState1} from './TestBoardStates.js';

var gameBoard = Array(26).fill(0).map(() => new Array(26).fill(0));
var newGameBoard = Array(26).fill(0).map(() => new Array(26).fill(0));
var boardUpdateTable = Array(26).fill(0).map(() => new Array(26).fill(0));
var gameTime = 0;
createGameBoard(gameBoard);
createGameBoard(newGameBoard);
createUpdateTable(boardUpdateTable);

initTestBoard(-25, -25, -25, 25);

function initTestBoard(i, j, min, max) {
  if (i > max) {return;}

  if (testBoardState1.pop() === 1) {setCellState(i, j, true, gameBoard);}

  if (j === max) {initTestBoard(i + 1, min, min, max);}
  else {initTestBoard(i, j + 1, min, max);}
}

// This function is used to initialise the gameBoard and newGameBoard arrays.
function createGameBoard(board) {
  for (let i = 0; i <= 25; i++) {
    for (let j = 0; j <= 25; j++) {
      board[i][j] = {quadrant1: false, quadrant2: false, quadrant3: false, quadrant4: false};
    }
  }
}

// This function is used to initialise the boardUpdateTable array.
function createUpdateTable(table) {
  for (let i = 0; i <= 25; i++) {
    for (let j = 0; j <= 25; j++) {
      table[i][j] = {quadrant1: 0, quadrant2: 0, quadrant3: 0, quadrant4: 0};
    }
  }
}

// This is an accessor function (get) for the gameBoard and boardUpdateTable arrays.
function getCellState(i, j, board) {
  let absoluteI = Math.abs(i);
  let absoluteJ = Math.abs(j);
  if (absoluteI >= board.length || absoluteJ >= board.length) {
    return {exists: false, cellState: false};
  }
    
  if (i >= 0 && j >= 0) {
    return {exists: true, cellState: board[absoluteI][absoluteJ].quadrant1};
  }
  else if (i < 0 && j >= 0) {
    return {exists: true, cellState: board[absoluteI][absoluteJ].quadrant2};
  }
  else if (i < 0 && j < 0) {
    return {exists: true, cellState: board[absoluteI][absoluteJ].quadrant3};
  }
  else {return {exists: true, cellState: board[absoluteI][absoluteJ].quadrant4};}
}

// This is an accessor function (set) for the newGameBoard and boardUpdateTable arrays.
function setCellState(i, j, state, board) {
  let absoluteI = Math.abs(i);
  let absoluteJ = Math.abs(j);
  if (absoluteI >= board.length || absoluteJ >= board.length) {
    return false;
  }

  if (i >= 0 && j >= 0) {
    board[absoluteI][absoluteJ].quadrant1 = state;
  }
  else if (i < 0 && j >= 0) {
    board[absoluteI][absoluteJ].quadrant2 = state;
  }
  else if (i < 0 && j < 0) {
    board[absoluteI][absoluteJ].quadrant3 = state;
  }
  else {board[absoluteI][absoluteJ].quadrant4 = state;}
  return true;
}

// This function is used by updateGameBoard to set the current cell and each of its neighbours to
// true in boardUpdateTable.
function setUpdateTable(i, j, gameT, updateTable) {
  setCellState(i, j, gameT, updateTable);
  setCellState(i + 1, j, gameT, updateTable);
  setCellState(i, j + 1, gameT, updateTable);
  setCellState(i - 1, j, gameT, updateTable);
  setCellState(i, j - 1, gameT, updateTable);
  setCellState(i + 1, j + 1, gameT, updateTable);
  setCellState(i - 1, j - 1, gameT, updateTable);
  setCellState(i + 1, j - 1, gameT, updateTable);
  setCellState(i - 1, j + 1, gameT, updateTable);
}

// This function applies the game logic to each cell on the board where the corresponding cell
// in boardUpdateTable >= gameT.
function updateGameBoard(updateTable, board, newBoard, survivalRules, birthRules,
                         gameT, i, j, min, max) {
  if (i > max) {return true;}

  if (getCellState(i, j, updateTable).cellState >= gameT) {
    let localSurvey = [], boundaryTest;
    localSurvey.push(getCellState(i + 1, j, board), getCellState(i - 1, j, board),
                     getCellState(i, j + 1, board), getCellState(i, j - 1, board),
                     getCellState(i + 1, j + 1, board), getCellState(i - 1, j + 1, board),
                     getCellState(i - 1, j - 1, board), getCellState(i + 1, j - 1, board));
    //boundaryTest = localSurvey.find(obj => obj.exists === false);
    //if (boundaryTest === {exists: false, cellState: false}) {return false;}
  
    let localPopulation = localSurvey.reduce((total, obj) => {if (obj.cellState) {return total + 1}
                                                              else {return total;}}, 0);
    if (getCellState(i, j, board).cellState) {
      survivalRules.forEach((x, index) =>
        {if (x && index === localPopulation) {setCellState(i, j, true, newBoard);
                                              setUpdateTable(i, j, gameT + 1, updateTable);}});
    }
    else {
      birthRules.forEach((x, index) =>
        {if (x && index === localPopulation) {setCellState(i, j, true, newBoard);
                                              setUpdateTable(i, j, gameT + 1, updateTable);}});
    }
  }

  if (j === max) {updateGameBoard(updateTable, board, newBoard, survivalRules, birthRules,
                                   gameT, i + 1, min, min, max);}
  else {updateGameBoard(updateTable, board, newBoard, survivalRules, birthRules,
                        gameT, i, j + 1, min, max);}  
}

function handleSetEvent(i, j) {
  setCellState(i, j, true, gameBoard);
}

function handleUpdateEvent() {
  updateGameBoard(boardUpdateTable, gameBoard, newGameBoard,
                 [false, false, true, true, false, false, false, false, false],
                 [false, false, false, true, false, false, false, false, false], gameTime, -25, -25,
                 -25, 25);
  gameBoard = newGameBoard;
  newGameBoard = Array(26).fill(0).map(() => new Array(26).fill(0));
  createGameBoard(newGameBoard);
  gameTime++;
}

// export {createGameBoard, createUpdateTable, getCellState, setCellState, updateGameBoard,
//         testGameBoard};

export {gameBoard, handleUpdateEvent};
\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.