Created
April 2, 2023 14:19
-
-
Save bennadel/f5f27b9f7b6ee374b5d8eb32738c8f58 to your computer and use it in GitHub Desktop.
Conway's Game Of Life In Hotwire And ColdFusion
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfoutput> | |
<!--- | |
In our Game of Life board, each living cell is being represented by a CHECKBOX | |
whose value denotes the "x,y" coordinates of the cell within the infinite canvas. | |
On each subsequent page load, the checkbox will be CHECKED if its composite "x,y" | |
key still exists in the index of living cells. | |
-- | |
NOTE: We are including an ID on the board so that we can replace its rendering via | |
a Turbo Stream action. | |
---> | |
<div id="board" class="board"> | |
<cfloop index="y" from="#state.minY#" to="#state.maxY#"> | |
<div> | |
<cfloop index="x" from="#state.minX#" to="#state.maxX#"> | |
<label> | |
<input | |
type="checkbox" | |
name="cells[]" | |
value="#x#,#y#" | |
<cfif state.cellIndex.keyExists( "#x#,#y#" )>checked</cfif> | |
/> | |
</label> | |
</cfloop> | |
</div> | |
</cfloop> | |
</div> | |
</cfoutput> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
content | |
type = "text/vnd.turbo-stream.html; charset=utf-8" | |
; | |
</cfscript> | |
<turbo-stream action="replace" target="board"> | |
<template> | |
<cfinclude template="_board.cfm" /> | |
</template> | |
</turbo-stream> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
component | |
output = false | |
hint = "I provide game play methods for Conway's Game of Life." | |
{ | |
/** | |
* I initialize the Game of Life with the given cell keys. Each key is a composite of | |
* the "x,y" coordinate of a living cell within the infinite canvas of biodiversity. | |
*/ | |
public void function init( | |
numeric minX = 0, | |
numeric maxX = 15, | |
numeric minY = 0, | |
numeric maxY = 15, | |
array cellKeys = [] | |
) { | |
// As the board grows and shrinks, we never want the visual rendering to shrink | |
// below a certain boundary. | |
variables.boardMinX = arguments.minX; | |
variables.boardMaxX = arguments.maxX; | |
variables.boardMinY = arguments.minY; | |
variables.boardMaxY = arguments.maxY; | |
variables.currentState = stateNew( cellKeys ); | |
// The rules of the game are based on the number of living / dead cells around a | |
// given cell. To make those calculations easier, this collection represents the | |
// {x,y} delta for each neighboring cell. | |
variables.neighborOffsets = [ | |
{ x: -1 , y: -1 }, | |
{ x: 0 , y: -1 }, | |
{ x: 1 , y: -1 }, | |
{ x: -1 , y: 0 }, | |
{ x: 1 , y: 0 }, | |
{ x: -1 , y: 1 }, | |
{ x: 0 , y: 1 }, | |
{ x: 1 , y: 1 } | |
]; | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
/** | |
* I get the current state of the game board. | |
*/ | |
public struct function getState() { | |
return({ | |
minX: currentState.minX, | |
maxX: currentState.maxX, | |
minY: currentState.minY, | |
maxY: currentState.maxY, | |
cellIndex: currentState.cellIndex.copy() | |
}); | |
} | |
/** | |
* I move the state of the board ahead by one life-tick. | |
*/ | |
public void function tick() { | |
// We don't want to change the state of the current board while we are examining | |
// it as doing so may corrupt further checks within the algorithm. As such, we're | |
// going to build-up a new state and then swap it in for the current state. | |
var nextState = stateNew(); | |
// The game requires us to look at both the living cells and the dead cells (that | |
// are around the living cells). Since dead cells aren't explicitly tracked in the | |
// state of the board itself - there are an infinite number of dead cells - we | |
// need to track the dead cells that are around the living cells that we inspect. | |
var deadCellIndex = [:]; | |
for ( var key in currentState.cellIndex ) { | |
var cell = stateGetCell( currentState, key ); | |
// Living cells remain alive if they have 2 or 3 living neighbors. | |
if ( | |
( cell.livingNeighbors.len() == 2 ) || | |
( cell.livingNeighbors.len() == 3 ) | |
) { | |
stateActivateCell( nextState, key ); | |
} | |
// Track the dead neighbors around the living cell so that we can explore them | |
// once we are done processing the living cells. | |
for ( var deadKey in cell.deadNeighbors ) { | |
deadCellIndex[ deadKey ] = true; | |
} | |
} | |
// Now that we've located any relevant dead cells (neighboring living cells), we | |
// can see if any of them need to spring to life. | |
for ( var key in deadCellIndex ) { | |
var cell = stateGetCell( currentState, key ); | |
// If the cell is dead, then it becomes alive if it has 3 living neighbors. | |
if ( cell.livingNeighbors.len() == 3 ) { | |
stateActivateCell( nextState, key ); | |
} | |
} | |
// Swap in the new game state. | |
currentState = nextState; | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
/** | |
* I activate the cell for the given key (marking it as alive). | |
*/ | |
private void function stateActivateCell( | |
required struct state, | |
required string key | |
) { | |
var coordinates = key.listToArray(); | |
var x = fix( coordinates[ 1 ] ); | |
var y = fix( coordinates[ 2 ] ); | |
state.cellIndex[ key ] = true; | |
state.minX = min( state.minX, x ); | |
state.maxX = max( state.maxX, x ); | |
state.minY = min( state.minY, y ); | |
state.maxY = max( state.maxY, y ); | |
} | |
/** | |
* I get the cell (and some contextual info) for the given key. | |
*/ | |
private struct function stateGetCell( | |
required struct state, | |
required string key | |
) { | |
var coordinates = key.listToArray(); | |
var x = fix( coordinates[ 1 ] ); | |
var y = fix( coordinates[ 2 ] ); | |
var cell = { | |
x: x, | |
y: y, | |
livingNeighbors: [], | |
deadNeighbors: [] | |
}; | |
for ( var offset in neighborOffsets ) { | |
var neighborX = ( cell.x + offset.x ); | |
var neighborY = ( cell.y + offset.y ); | |
var neighborKey = "#neighborX#,#neighborY#"; | |
if ( state.cellIndex.keyExists( neighborKey ) ) { | |
cell.livingNeighbors.append( neighborKey ); | |
} else { | |
cell.deadNeighbors.append( neighborKey ); | |
} | |
} | |
return( cell ); | |
} | |
/** | |
* I create a new state data model. If initial keys are provided, they will be used to | |
* activate cells in the new state. | |
*/ | |
private struct function stateNew( array cellKeys = [] ) { | |
// Instead of keeping a two-dimensional array of the entire board, we're just | |
// going to keep an index of the living cells based on a composite key of the | |
//"x,y" magnitudes. The VALUE of the entry doesn't matter. | |
var state = { | |
cellIndex: [:], | |
minX: boardMinX, | |
maxX: boardMaxX, | |
minY: boardMinY, | |
maxY: boardMaxY | |
}; | |
for ( var key in cellKeys ) { | |
stateActivateCell( state, key ); | |
} | |
return( state ); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<cfscript> | |
param name="form.cells" type="array" default=[]; | |
param name="form.autoUpdate" type="boolean" default=false; | |
param name="form.submitted" type="boolean" default=false; | |
// On every page request, we're going to initialize the Game of Life with the | |
// currently submitted cells. | |
game = new lib.GameOfLife( | |
minX = -10, | |
maxX = 10, | |
minY = -10, | |
maxY = 10, | |
cellKeys = form.cells | |
); | |
// If this is a form-submission, move the evolution of the game forward 1-tick. | |
if ( form.submitted ) { | |
game.tick(); | |
} | |
state = game.getState(); | |
// If the form is set to auto-update, but there are no living cells left on the board, | |
// disable the auto-update. Subsequent requests won't change the board in any way. | |
if ( form.autoUpdate && state.cellIndex.isEmpty() ) { | |
form.autoUpdate = false; | |
} | |
// If this request is a form submission via Turbo Drive , then we cannot simply re- | |
// render the form - Turbo Drive requires either a redirect, an error, or a Turbo | |
// Stream. As such, we're going to have to REPLACE the board with a stream action | |
// rather than update the whole page. | |
if ( request.isPost && request.turbo.isStream ) { | |
include "./_board.stream.cfm"; | |
exit; | |
} | |
</cfscript> | |
<cfmodule template="./tags/page.cfm"> | |
<cfoutput> | |
<form | |
method="post" | |
action="index.htm" | |
data-controller="game" | |
data-action="turbo:submit-end->game##checkForAutoUpdate" | |
data-game-interval-param="500" | |
data-game-target="form"> | |
<input type="hidden" name="submitted" value="true" /> | |
<div class="controls"> | |
<button type="submit"> | |
Increment Game Play | |
</button> | |
<label for="auto-update"> | |
<input | |
id="auto-update" | |
type="checkbox" | |
name="autoUpdate" | |
value="true" | |
data-game-target="autoUpdate" | |
<cfif form.autoUpdate>checked</cfif> | |
/> | |
Auto update | |
</label> | |
<a href="index.htm"> | |
Reset | |
</a> | |
</div> | |
<cfinclude template="./_board.cfm" /> | |
</form> | |
<!--- | |
Graceful degradation: If Hotwire Turbo Drive is not managing the game play, | |
then we want to fall-back to using vanilla JavaScript to automatically submit | |
the board. If on any subsequent page load, the Turbo Drive script kicks-in, | |
it will hook into the `requestSubmit()` life-cycle, take over, and this script | |
will no longer be relevant. | |
---> | |
<cfif form.autoUpdate> | |
<script type="text/javascript"> | |
var form = document.querySelector( "form" ); | |
setTimeout( | |
() => { | |
form.requestSubmit(); | |
}, | |
form.dataset.gameIntervalParam | |
); | |
</script> | |
</cfif> | |
</cfoutput> | |
</cfmodule> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import core modules. | |
import { Application } from "@hotwired/stimulus"; | |
import { Controller } from "@hotwired/stimulus"; | |
import * as Turbo from "@hotwired/turbo"; | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
export class GameController extends Controller { | |
static targets = [ "form", "autoUpdate" ]; | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
/** | |
* When the board tick has been processed, I check to see if the user wants the next | |
* board tick to be fired automatically. | |
*/ | |
checkForAutoUpdate( event ) { | |
if ( | |
! this.autoUpdateTarget.checked || | |
! event.params.interval | |
) { | |
return; | |
} | |
setTimeout( | |
() => { | |
// Before we attempt to tick forward the game play, let's make sure that | |
// we actually have any living cells left on the board. If the board is | |
// empty, there's no chance that anything will suddenly burst into life. | |
if ( this.hasLivingCells() ) { | |
this.formTarget.requestSubmit(); | |
} else { | |
this.autoUpdateTarget.checked = false; | |
} | |
}, | |
event.params.interval | |
); | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
/** | |
* I determine if there are any living cells currently on the board. | |
*/ | |
hasLivingCells() { | |
return( !! this.formTarget.querySelector( ".board input[checked]" ) ); | |
} | |
} | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
window.Stimulus = Application.start(); | |
// When not using the Ruby On Rails asset pipeline / build system, Stimulus doesn't know | |
// how to map controller classes to data-controller attributes. As such, we have to | |
// explicitly register the Controllers on Stimulus startup. | |
Stimulus.register( "game", GameController ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment