Compare commits

..

No commits in common. "gh-pages" and "master" have entirely different histories.

26 changed files with 14106 additions and 2 deletions

3
.babelrc Normal file
View file

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

34
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,34 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
push:
branches: [ master ]
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
# Runs a single command using the runners shell
- name: Install Deps
run: npm install
- name: Test
run: npm run test
- name: Build
run: npm run build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist

View file

11
README.md Normal file
View file

@ -0,0 +1,11 @@
# Game of Life
*It's not really about the Game of Life*
A weekend project with the following goals:
- [x] implement webpack build with babel
- [x] develop a simple Conway's Game of Life app without framework reliance
- [x] seed Game of Life with GitHub contribution calendars just for fun
- [x] implement a GitHub Actions workflow
![Screenshot of Game of Life being seeded with creator's contribution calendar](./SeedScreenshot.png)
![Screenshot of same Game of Life running a few generations later](./RunningScreenshot.png)

BIN
RunningScreenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
SeedScreenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

View file

@ -1 +1,20 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Game of Life</title></head><body><aside id="controls"><h1>Game of Life</h1></aside><main><p>Enter valid GitHub user handle above to seed with contribution calendar</p><p>Touch below to toggle seed cells alive or dead</p><canvas id="game-field" width="500" height="300"></canvas></main><script src="app.bundle.js"></script></body></html> <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game of Life</title>
</head>
<body>
<aside id="controls">
<h1>Game of Life</h1>
<!-- insert form for fetch seed data -->
<!-- insert controls for animation -->
</aside>
<main>
<p>Enter valid GitHub user handle above to seed with contribution calendar</p>
<p>Touch below to toggle seed cells alive or dead</p>
<canvas id="game-field" width="500" height="300"></canvas>
</main>
</body>
</html>

12659
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "game-of-life",
"version": "1.0.0",
"homepage": "https://sorrelbri.github.io/game-of-life",
"description": "",
"private": true,
"scripts": {
"build": "webpack --mode=production --config webpack.prod.js",
"test": "jest",
"start": "webpack-dev-server --open --config webpack.dev.js",
"cleanup": "rm -rf node_modules/gh-pages/.cache",
"deploy": "npm run cleanup && gh-pages -d dist"
},
"author": "",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"babel-jest": "^26.0.1",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.5.3",
"gh-pages": "^2.2.0",
"html-loader": "^1.1.0",
"html-webpack-plugin": "^4.3.0",
"jest": "^26.0.1",
"style-loader": "^1.2.1",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2"
},
"jest": {
"transform": {
".*": "<rootDir>/node_modules/babel-jest"
},
"moduleFileExtensions": [
"js"
]
}
}

66
src/components/Cell.js Normal file
View file

@ -0,0 +1,66 @@
const { Stream } = require("../utils");
class Cell {
constructor(living = false, liveNeighbors = 0) {
this.living = living;
this.liveNeighbors = liveNeighbors;
}
toggleLiving() {
this.living = !this.living;
}
addLiveNeighbor() {
this.liveNeighbors++;
}
setLiving() {
if (this.living && this.liveNeighbors !== 2 && this.liveNeighbors !== 3) {
this.liveNeighbors = 0;
return (this.living = false);
}
if (this.liveNeighbors === 3) {
this.liveNeighbors = 0;
return (this.living = true);
}
this.liveNeighbors = 0;
}
}
class CellStream extends Stream {
constructor(head, next) {
super(head, next);
}
get living() {
return this.head.living;
}
get liveNeighbors() {
return this.head.liveNeighbors;
}
set liveNeighbors(liveNeighbors) {
this.head.liveNeighbors = liveNeighbors;
}
addLiveNeighbor() {
this.head.addLiveNeighbor();
}
setLiving() {
this.head.setLiving();
}
toggleLiving() {
this.head.toggleLiving();
}
}
const cellStream = (living = false, liveNeighbors = 0) => {
return new CellStream(new Cell(living, liveNeighbors), function () {
this.head.setLiving();
return this;
});
};
// as a stream -> cellStream = Stream(Cell, () => Cell(cellStream.isLiving()))
// in this case GameField = { [x-y]: cellStream }
// communicating with neighbors = filter for (Boolean(Cell.living)) -> Cell neighbors.addLivingNeighbor
// controlling whether to call or not: filter for (Boolean(Cell.living) || Boolean(cell.liveNeighbors)) -> cellStream.next
module.exports = {
Cell,
cellStream,
};

104
src/components/Controls.js Normal file
View file

@ -0,0 +1,104 @@
const html = require("./controls.html");
const css = require("../styles/controls.css");
const { getCalendar } = require("../utils/index");
const init = (gameField) => {
const controlsEl = document.getElementById("controls");
controlsEl.innerHTML += html;
const rateEl = document.getElementById("rate");
const forwardEl = document.getElementById("forward");
const playEl = document.getElementById("play");
const resetEl = document.getElementById("reset");
const clearEl = document.getElementById("clear");
const canvasEl = document.getElementById("game-field");
const calendarFormEl = document.getElementById("calendar-form");
const controls = {
interval: null,
play() {
const gameLoop = () => gameField.advance();
this.interval = setInterval(gameLoop, 1000 / this.rate);
},
pause() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
},
clear() {
gameField = gameField.clear();
},
reset() {
this.pause();
gameField = gameField.reset();
},
seed(weeks) {
if (weeks.length) {
gameField = gameField.seed(weeks);
}
},
forward() {
gameField.advance();
},
rate: 10,
updateField(x, y) {
gameField = gameField.toggleCell(x, y);
},
updateRate(rate) {
controls.rate = rate;
if (this.interval) {
clearInterval(this.interval);
this.play();
}
},
};
rateEl.addEventListener("change", (e) => {
e.preventDefault();
controls.updateRate(e.target.value);
});
forwardEl.addEventListener("click", (e) => {
e.preventDefault();
controls.forward();
});
playEl.addEventListener("click", (e) => {
e.preventDefault();
if (controls.interval) {
controls.pause();
return (forwardEl.disabled = false);
}
controls.play();
forwardEl.disabled = true;
});
resetEl.addEventListener("click", (e) => {
e.preventDefault();
controls.reset();
});
clearEl.addEventListener("click", (e) => {
e.preventDefault();
controls.clear();
});
canvasEl.addEventListener("click", (e) => {
e.preventDefault();
if (controls.interval) return;
const { offsetX, offsetY } = e;
controls.updateField(offsetX, offsetY);
});
calendarFormEl.addEventListener("submit", (e) => {
e.preventDefault();
const user = e.target[0].value;
getCalendar(user)
.then((data) => {
return data.map((week) => {
return week.contributionDays.map((day) => day.contributionCount);
});
})
.then((weeks) => controls.seed(weeks))
.catch((e) => {
calendarFormEl.elements[0].value = "enter valid handle";
});
});
return controls;
};
module.exports = {
init,
};

View file

@ -0,0 +1,85 @@
const { cellStream } = require("./Cell");
const { Stream, getNeighbors } = require("../utils");
class GameField {
constructor({ fieldArray = [], fieldMap = {} }) {
// seed = [ [] ]
this.map = {};
fieldArray.forEach((subArray, majorIndex) =>
subArray.forEach((value, minorIndex) => {
if (value > 0) {
this.map[`${majorIndex},${minorIndex}`] = cellStream(true, 0);
}
})
);
Object.entries(fieldMap).forEach(
([key, [live, neighbors]]) =>
(this.map[key] = cellStream(live, neighbors))
);
}
}
class FieldStream extends Stream {
constructor(head, next) {
super(head, next);
}
get map() {
return this.head.map;
}
addLiveNeighbor(key) {
if (this.map[key] === undefined) {
this.map[key] = cellStream(false);
}
this.map[key].addLiveNeighbor();
}
}
const seedMap = (map, [key, seed]) => {
map[key] = seed;
return map;
};
const isLiving = ([key, cell]) => cell.living === true;
const incrementLiveNeighbors = (field) => ([key]) =>
getNeighbors(key).forEach((neighbor) => field.addLiveNeighbor(neighbor));
const makeSeed = ([key, cell]) => [key, [cell.living, cell.liveNeighbors]];
const makeSeedNextGen = ([key, cell]) => {
cell.setLiving();
return [key, [cell.living, 0]];
};
const fieldStream = ({ fieldArray, fieldMap }) => {
return new FieldStream(new GameField({ fieldArray, fieldMap }), function () {
// calculate liveNeighbors for all cells on first next call
Object.entries(this.map)
.filter(isLiving)
.forEach(incrementLiveNeighbors(this));
// generate seed for next Stream with liveNeighbors
const mapWithLiveNeighbors = Object.entries(this.map)
.map(makeSeed)
.reduce(seedMap, {});
// return next stream
return new FieldStream(
new GameField({ fieldMap: mapWithLiveNeighbors }),
function () {
// determine living cells for next generation
const nextGeneration = Object.entries(this.map)
.map(makeSeedNextGen)
.reduce(seedMap, {});
// seed next Stream
return fieldStream({ fieldMap: nextGeneration });
}
);
});
};
// wrapper for fieldStream
// -- .reset => instantiates new fieldStream
// -- .toggle(cell) => manually toggles cell state
// instantiate table (orientation of major and minor axis dependent on viewport)
// const gameFields = new Array(1).fill(new GameField({}));
module.exports = {
GameField,
fieldStream,
};

View file

@ -0,0 +1,71 @@
const css = require("../styles/gameField.css");
const { fieldStream } = require("./GameField");
const canvasEl = document.getElementById("game-field");
const canvas2D = canvasEl.getContext("2d");
canvas2D.fillStyle = "white";
const parseSeed = (seed) => {
if (seed && Array.isArray(seed)) {
return {
fieldArray: seed,
};
}
return seed;
};
const fieldView = (seed) => {
seed = parseSeed(seed);
const field = fieldStream(seed);
const view = {
draw(x, y) {
const { scale, offset } = this.dimension;
canvas2D.fillRect(x * scale + offset, y * scale + offset, scale, scale);
},
dimension: { x0: 0, y0: 0, x1: 500, y1: 300, scale: 6, offset: 100 },
field,
updateView() {
canvas2D.clearRect(0, 0, this.dimension.x1, this.dimension.y1);
Object.entries(this.field.map)
.filter(([key, cell]) => cell.living)
.map(([key]) => key.split(","))
.forEach(([x, y]) => this.draw(x, y));
},
clear() {
return fieldView({});
},
reset() {
const newField = fieldView(seed);
newField.updateView();
return newField;
},
seed(seed) {
return fieldView(seed);
},
advance() {
this.field = this.field.next.next;
this.updateView();
},
toggleCell(x, y) {
const { scale, offset } = this.dimension;
x = Math.floor((x - offset) / scale);
y = Math.floor((y - offset) / scale);
const fieldMap = Object.entries(this.field.map)
.map(([key, cell]) => [key, cell.living])
.reduce((map, [key, living]) => {
map[key] = [living];
return map;
}, {});
fieldMap[`${x},${y}`] = !!fieldMap[`${x},${y}`]
? [!fieldMap[`${x},${y}`][0]]
: [true];
return new fieldView({ fieldMap });
},
};
view.updateView();
return view;
};
module.exports = {
fieldView,
};

View file

@ -0,0 +1,17 @@
<div class="playControls">
<button id="clear">⏹️</button>
<button id="reset">🔄️</button>
<button id="play">⏯️</button>
<button id="forward">⏩️</button>
<input
type="range"
id="rate"
min="1"
max="20"
value="10"
/>
</div>
<form action="/" id="calendar-form">
<input type="text" value="GitHub handle"/>
<input type="submit" value="seed" />
</form>

7
src/index.js Normal file
View file

@ -0,0 +1,7 @@
import reset from "./styles/reset.css";
import css from "./styles/style.css";
const { fieldView } = require("./components/GameFieldTable");
const { init } = require("./components/Controls");
(() => console.log("hello world!"))();
window.game = fieldView([]);
window.controls = init(game);

51
src/styles/controls.css Normal file
View file

@ -0,0 +1,51 @@
aside {
background: #944;
justify-content: space-evenly;
display: flex;
flex-flow: column nowrap;
position: float;
top: 0;
border-bottom: solid 1em #522;
}
aside * {
background: inherit;
color: #dff;
}
h1 {
font-size: 130%;
padding: 1em;
}
div.playControls {
min-width: 30vw;
display: grid;
border-radius: 1vh;
border: solid .5vh;
grid-template-rows: 2fr 1fr;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 1vh;
grid-template-areas: "buttons buttons buttons buttons" "slider slider slider slider";
padding: 1vh;
}
button {
width: 100%;
background: #ccc;
border-radius: .5vh;
}
button:disabled {
background: #999;
}
input[type="range"] {
grid-area: slider;
}
form {
margin: 0.5em;
padding: 0.5em
}

7
src/styles/gameField.css Normal file
View file

@ -0,0 +1,7 @@
canvas {
background: #222;
color: white;
border: 0.5vmin solid #aaa;
fill: white;
margin: 0 3em 3em 3em;
}

361
src/styles/reset.css Normal file
View file

@ -0,0 +1,361 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0-modified | 20110126
License: none (public domain)
*/
* {
box-sizing: border-box;
}
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
input[type=search]::-webkit-search-cancel-button,
input[type=search]::-webkit-search-decoration,
input[type=search]::-webkit-search-results-button,
input[type=search]::-webkit-search-results-decoration {
-webkit-appearance: none;
-moz-appearance: none;
}
input[type=search] {
-webkit-appearance: none;
-moz-appearance: none;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
}
textarea {
overflow: auto;
vertical-align: top;
resize: vertical;
}
/**
* Correct `inline-block` display not defined in IE 6/7/8/9 and Firefox 3.
*/
audio,
canvas,
video {
display: inline-block;
*display: inline;
*zoom: 1;
max-width: 100%;
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address styling not present in IE 7/8/9, Firefox 3, and Safari 4.
* Known issue: no IE 6 support.
*/
[hidden] {
display: none;
}
/**
* 1. Correct text resizing oddly in IE 6/7 when body `font-size` is set using
* `em` units.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-size: 100%; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-ms-text-size-adjust: 100%; /* 2 */
}
/**
* Address `outline` inconsistency between Chrome and other browsers.
*/
a:focus {
outline: thin dotted;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/**
* 1. Remove border when inside `a` element in IE 6/7/8/9 and Firefox 3.
* 2. Improve image quality when scaled in IE 7.
*/
img {
border: 0; /* 1 */
-ms-interpolation-mode: bicubic; /* 2 */
}
/**
* Address margin not present in IE 6/7/8/9, Safari 5, and Opera 11.
*/
figure {
margin: 0;
}
/**
* Correct margin displayed oddly in IE 6/7.
*/
/**
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct color not being inherited in IE 6/7/8/9.
* 2. Correct text not wrapping in Firefox 3.
* 3. Correct alignment displayed oddly in IE 6/7.
*/
legend {
border: 0; /* 1 */
padding: 0;
white-space: normal; /* 2 */
*margin-left: -7px; /* 3 */
}
/**
* 1. Correct font size not being inherited in all browsers.
* 2. Address margins set differently in IE 6/7, Firefox 3+, Safari 5,
* and Chrome.
* 3. Improve appearance and consistency in all browsers.
*/
button,
input,
select,
textarea {
font-size: 100%; /* 1 */
margin: 0; /* 2 */
vertical-align: baseline; /* 3 */
*vertical-align: middle; /* 3 */
}
/**
* Address Firefox 3+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
button,
input {
line-height: normal;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Chrome, Safari 5+, and IE 6+.
* Correct `select` style inheritance in Firefox 4+ and Opera.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
* 4. Remove inner spacing in IE 7 without affecting normal text inputs.
* Known issue: inner spacing remains in IE 6.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
*overflow: visible; /* 4 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* 1. Address box sizing set to content-box in IE 8/9.
* 2. Remove excess padding in IE 8/9.
* 3. Remove excess padding in IE 7.
* Known issue: excess padding remains in IE 6.
*/
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
*height: 13px; /* 3 */
*width: 13px; /* 3 */
}
/**
* 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome
* (include `-moz` to future-proof).
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari 5 and Chrome
* on OS X.
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Remove inner padding and border in Firefox 3+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/**
* 1. Remove default vertical scrollbar in IE 6/7/8/9.
* 2. Improve readability and alignment in all browsers.
*/
textarea {
overflow: auto; /* 1 */
vertical-align: top; /* 2 */
}
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
html,
button,
input,
select,
textarea {
color: #222;
}
::-moz-selection {
background: #b3d4fc;
text-shadow: none;
}
::selection {
background: #b3d4fc;
text-shadow: none;
}
img {
vertical-align: middle;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
textarea {
resize: vertical;
}
.chromeframe {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}

27
src/styles/style.css Normal file
View file

@ -0,0 +1,27 @@
* {
background: #333;
color: #eee;
}
body {
font-family: Verdana, Geneva, Tahoma, sans-serif;
display: flex;
flex-flow: column nowrap;
align-items: center;
}
main, aside {
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
main {
overflow: scroll;
}
main p {
padding: 1em;
}

68
src/test/Cell.test.js Normal file
View file

@ -0,0 +1,68 @@
import { Cell, cellStream } from "../components/Cell";
describe("Cell functionality", () => {
test("dispatch toggleLiving state should mark living cell dead", () => {
const cell = new Cell(true);
cell.toggleLiving();
expect(cell.living).toEqual(false);
});
test("dispatch toggleLiving state should mark dead cell living", () => {
const cell = new Cell();
cell.toggleLiving();
expect(cell.living).toEqual(true);
});
test("dispatch add Live Neighbor should increment live neighbors property", () => {
const cell = new Cell();
cell.addLiveNeighbor();
expect(cell.liveNeighbors).toEqual(1);
});
const livingFromLivingStates = new Array(8)
.fill()
.map((_, i) => (i === 2 || i === 3 ? true : false));
livingFromLivingStates.forEach((state, liveNeighbors) => {
test(`dispatch setLiving on live cell with ${liveNeighbors} neighbors should result in living = ${state}`, () => {
const cell = new Cell(true);
new Array(liveNeighbors).fill().forEach((_) => cell.addLiveNeighbor());
cell.setLiving();
expect(cell.living).toEqual(state);
});
});
const livingFromDeadStates = new Array(8)
.fill()
.map((_, i) => (i === 3 ? true : false));
livingFromDeadStates.forEach((state, liveNeighbors) => {
test(`dispatch setLiving on dead cell with ${liveNeighbors} neighbors should result in living = ${state}`, () => {
const cell = new Cell();
new Array(liveNeighbors).fill().forEach((_) => cell.addLiveNeighbor());
cell.setLiving();
expect(cell.living).toEqual(state);
});
});
const cellStreamNextCalls = [
[2, false],
[3, true],
[0, false],
[5, false],
];
cellStreamNextCalls.forEach(([liveNeighbors, livingResult]) => {
test(`cellStream advances cell state for ${liveNeighbors} live Neighbors (from dead cell)`, () => {
const cell = cellStream(false, liveNeighbors).next;
expect(cell.living).toEqual(livingResult);
});
});
[[2, true], ...cellStreamNextCalls.slice(1)].forEach(
([liveNeighbors, livingResult]) => {
test(`cellStream advances cell state for ${liveNeighbors} live Neighbors (from living cell)`, () => {
const cell = cellStream(true, liveNeighbors).next;
expect(cell.living).toEqual(livingResult);
});
}
);
});

368
src/test/GameField.test.js Normal file
View file

@ -0,0 +1,368 @@
import { GameField, fieldStream } from "../components/GameField";
describe("Game Field seeds living Cells with array", () => {
const fieldArray = [
[0, 1, 0],
[1, 0, 1],
];
const fieldMap = {
"0,0": [true, 0],
"0,2": [true, 0],
"1,1": [true, 0],
};
const gameArraySeed = new GameField({ fieldArray });
const gameMapSeed = new GameField({ fieldMap });
const streamArraySeed = fieldStream({ fieldArray });
const streamMapSeed = fieldStream({ fieldMap });
["0,1", "1,0", "1,2"].forEach((key) => {
test(`Array seed: ${key} should equal living Cell`, () => {
expect(gameArraySeed.map[key].living).toEqual(true);
});
test(`Map seed: ${key} should equal undefined`, () => {
expect(gameMapSeed.map[key]).toEqual(undefined);
});
test(`Stream array seed: ${key} should equal living Cell`, () => {
expect(streamArraySeed.map[key].living).toEqual(true);
});
test(`Stream map seed: ${key} should equal undefined`, () => {
expect(streamMapSeed.map[key]).toEqual(undefined);
});
});
["0,0", "0,2", "1,1"].forEach((key) => {
test(`Array seed: ${key} should equal undefined`, () => {
expect(gameArraySeed.map[key]).toEqual(undefined);
});
test(`Map seed: ${key} should equal living Cell`, () => {
expect(gameMapSeed.map[key].living).toEqual(true);
});
test(`Stream array seed: ${key} should equal undefined`, () => {
expect(streamArraySeed.map[key]).toEqual(undefined);
});
test(`Stream map seed: ${key} should equal living Cell`, () => {
expect(streamMapSeed.map[key].living).toEqual(true);
});
});
});
describe("fieldStream.next calculates liveNeighbors", () => {
const fieldArray = [
[0, 1, 0],
[1, 0, 1],
];
const testStream = fieldStream({ fieldArray });
[
["-1,0", 1],
["-1,1", 1],
["-1,2", 1],
["0,-1", 1],
["0,0", 2],
["0,1", 2],
["0,2", 2],
["0,3", 1],
["1,-1", 1],
["1,0", 1],
["1,1", 3],
["1,2", 1],
["1,3", 1],
["2,-1", 1],
["2,0", 1],
["2,1", 2],
["2,2", 1],
["2,3", 1],
].forEach(([key, liveNeighbors]) => {
test(`after .next ${key} in 1st should have ${liveNeighbors} liveNeighbors`, () => {
expect(testStream.next.map[key].liveNeighbors).toEqual(liveNeighbors);
});
});
const fieldMap = {
"0,1": [true, 0],
"0,2": [true, 0],
"1,0": [true, 0],
"1,1": [true, 0],
"1,3": [true, 0],
"2,1": [true, 0],
"2,2": [true, 0],
};
const testStream2 = fieldStream({ fieldMap });
[
["-1,0", 1],
["-1,1", 2],
["-1,2", 2],
["-1,3", 1],
["0,-1", 1],
["0,0", 3],
["0,1", 3],
["0,2", 3],
["0,3", 2],
["0,4", 1],
["1,-1", 1],
["1,0", 3],
["1,1", 5],
["1,2", 6],
["1,3", 2],
["1,4", 1],
["2,-1", 1],
["2,0", 3],
["2,1", 3],
["2,2", 3],
["2,3", 2],
["2,4", 1],
["3,0", 1],
["3,1", 2],
["3,2", 2],
["3,3", 1],
].forEach(([key, liveNeighbors]) => {
test(`after .next ${key} in 2nd should have ${liveNeighbors} liveNeighbors`, () => {
expect(testStream2.next.map[key].liveNeighbors).toEqual(liveNeighbors);
});
});
});
describe("fieldStream.next tests still lifes", () => {
const blockArray = [
[1, 1],
[1, 1],
];
const streamBlock = fieldStream({ fieldArray: blockArray });
[
["-1,-1", false],
["-1,0", false],
["-1,1", false],
["-1,2", false],
["0,-1", false],
["0,0", true],
["0,1", true],
["0,2", false],
["1,-1", false],
["1,0", true],
["1,1", true],
["1,2", false],
["2,-1", false],
["2,0", false],
["2,1", false],
["2,2", false],
].forEach(([key, live]) => {
test(`after one generation of Block, ${key} alive: ${live}`, () => {
expect(streamBlock.next.next.map[key].living).toEqual(live);
});
});
const beehiveMap = {
"0,1": [true, 0],
"0,2": [true, 0],
"1,0": [true, 0],
"1,3": [true, 0],
"2,1": [true, 0],
"2,2": [true, 0],
};
const streamBeehive = fieldStream({ fieldMap: beehiveMap });
[
["-1,0", false],
["-1,1", false],
["-1,2", false],
["-1,3", false],
["0,-1", false],
["0,0", false],
["0,1", true],
["0,2", true],
["0,3", false],
["0,4", false],
["1,-1", false],
["1,0", true],
["1,1", false],
["1,2", false],
["1,3", true],
["1,4", false],
["2,-1", false],
["2,0", false],
["2,1", true],
["2,2", true],
["2,3", false],
["2,4", false],
["3,0", false],
["3,1", false],
["3,2", false],
["3,3", false],
].forEach(([key, live]) => {
test(`after one generation of Beehive, ${key} alive: ${live}`, () => {
expect(streamBeehive.next.next.map[key].living).toEqual(live);
});
});
const boatArray = [
[1, 1, 0],
[1, 0, 1],
[0, 1, 0],
];
const streamBoat = fieldStream({ fieldArray: boatArray });
[
["-1,-1", false],
["-1,0", false],
["-1,1", false],
["-1,2", false],
["0,-1", false],
["0,0", true],
["0,1", true],
["0,2", false],
["1,-1", false],
["1,0", true],
["1,1", false],
["1,2", true],
["1,3", false],
["2,-1", false],
["2,0", false],
["2,1", true],
["2,2", false],
["2,3", false],
["3,0", false],
["3,1", false],
["3,2", false],
].forEach(([key, live]) => {
test(`after one generation of Beehive, ${key} alive: ${live}`, () => {
expect(streamBoat.next.next.map[key].living).toEqual(live);
});
});
});
describe("fieldStream.next tests oscillators, spaceships", () => {
const blinkerArray = [
[0, 1, 0],
[0, 1, 0],
[0, 1, 0],
];
const streamBlinker = fieldStream({ fieldArray: blinkerArray });
[
["0,0", false],
["0,1", false],
["0,2", false],
["1,0", true],
["1,1", true],
["1,2", true],
["2,0", false],
["2,1", false],
["2,2", false],
].forEach(([key, live]) => {
test(`after one generation of blinker, ${key} alive: ${live}`, () => {
expect(streamBlinker.next.next.map[key].living).toEqual(live);
});
});
[
["0,0", false],
["0,1", true],
["0,2", false],
["1,0", false],
["1,1", true],
["1,2", false],
["2,0", false],
["2,1", true],
["2,2", false],
].forEach(([key, live]) => {
test(`after two generations of blinker, ${key} alive: ${live}`, () => {
expect(streamBlinker.next.next.next.next.map[key].living).toEqual(live);
});
});
const gliderArray = [
[0, 1, 0],
[0, 0, 1],
[1, 1, 1],
];
const streamGlider = fieldStream({ fieldArray: gliderArray });
[
["0,0", false],
["0,1", false],
["0,2", false],
["1,0", true],
["1,1", false],
["1,2", true],
["2,0", false],
["2,1", true],
["2,2", true],
["3,0", false],
["3,1", true],
["3,2", false],
].forEach(([key, live]) => {
test(`after one generation of glider, ${key} alive: ${live}`, () => {
expect(streamGlider.next.next.map[key].living).toEqual(live);
});
});
[
["0,0", false],
["0,1", false],
["0,2", false],
["1,0", false],
["1,1", false],
["1,2", true],
["2,0", true],
["2,1", false],
["2,2", true],
["3,0", false],
["3,1", true],
["3,2", true],
].forEach(([key, live]) => {
test(`after two generations of glider, ${key} alive: ${live}`, () => {
expect(streamGlider.next.next.next.next.map[key].living).toEqual(live);
});
});
[
["0,0", false],
["0,1", false],
["0,2", false],
["0,3", false],
["1,0", false],
["1,1", true],
["1,2", false],
["1,3", false],
["2,0", false],
["2,1", false],
["2,2", true],
["2,3", true],
["3,0", false],
["3,1", true],
["3,2", true],
["3,3", false],
].forEach(([key, live]) => {
test(`after three generations of glider, ${key} alive: ${live}`, () => {
expect(
streamGlider.next.next.next.next.next.next.map[key].living
).toEqual(live);
});
});
[
["0,0", false],
["0,1", false],
["0,2", false],
["0,3", false],
["1,0", false],
["1,1", false],
["1,2", true],
["1,3", false],
["2,0", false],
["2,1", false],
["2,2", false],
["2,3", true],
["3,0", false],
["3,1", true],
["3,2", true],
["3,3", true],
].forEach(([key, live]) => {
test(`after four generations of glider, ${key} alive: ${live}`, () => {
expect(
streamGlider.next.next.next.next.next.next.next.next.map[key].living
).toEqual(live);
});
});
});

47
src/utils/index.js Normal file
View file

@ -0,0 +1,47 @@
const options = {
method: "get",
};
const getCalendar = (user) => {
return fetch(
`https://appkeychain.herokuapp.com/git/calendar/${user}`,
options
)
.then((res) => res.json())
.catch((e) => e);
};
class Stream {
constructor(head, next) {
this.head = head;
this.tail = next;
this.memo = false;
}
get next() {
if (!this.memo) {
this.tail = this.tail();
this.memo = true;
}
return this.tail;
}
}
const getNeighbors = (key) => {
const [x, y] = key.split(",").map((str) => parseInt(str));
return [
[x - 1, y - 1],
[x - 1, y],
[x - 1, y + 1],
[x, y - 1],
[x, y + 1],
[x + 1, y - 1],
[x + 1, y],
[x + 1, y + 1],
].map((arr) => arr.join(","));
};
module.exports = {
Stream,
getNeighbors,
getCalendar,
};

43
webpack.common.js Normal file
View file

@ -0,0 +1,43 @@
const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: {
app: "./src/index.js",
},
plugins: [
// new CleanWebpackPlugin(['dist/*']) for < v2 versions of CleanWebpackPlugin
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: "index.html",
}),
],
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
chunkFilename: "[name].bundle.js",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.html$/i,
loader: "html-loader",
options: {
attributes: true,
},
},
],
},
};

10
webpack.dev.js Normal file
View file

@ -0,0 +1,10 @@
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "development",
devtool: "inline-source-map",
devServer: {
contentBase: "./dist",
},
});

6
webpack.prod.js Normal file
View file

@ -0,0 +1,6 @@
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "production",
});