Created
October 23, 2018 14:33
-
-
Save mklement0/b797cf35efda8acceed6857ec7e79cb5 to your computer and use it in GitHub Desktop.
PowerShell script that implements a terminal-based version of Conway's Game of Life (see https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)
This file contains 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
<# | |
.SYNOPSIS | |
A terminal-based implementation of Conway's Game of Life. | |
.DESCRIPTION | |
Conway's Game of Life is a zero-player game that simulates a cellular automaton. | |
See https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life for background. | |
The initial state can be seeded explicitly or, by default, randomly. | |
The game ends automatically once all cells have died or when a "still life" | |
state is reached, i.e., when the grid no longer changes. | |
No attempt is made to detect periods (cyclic states). | |
.PARAMETER GridSize | |
Specifies how many cells make up the square grid along each axis, 20 by default, | |
with a maxium of 255. | |
In other words: The grid will be made up of GridSize x GridSize cells. | |
Choose a value small enough so that the grid fits into your terminal window | |
and note that the computation effort increases quadratically; in short: | |
large grids are computation-intensive and slow to update. | |
.PARAMETER RandomSeedCount | |
Specifies how many cells to seed randomly, i.e., how many cells should | |
initially be "alive". | |
The default is a quarter (0.25) of all cells. | |
.PARAMETER SeedCoordinates | |
As an alternative to random seeding, this parameter allows you to specify | |
the exact cells to initially mark as alive, which allows deterministic replays | |
of games. | |
You may specify the coordinates either: | |
* as a string of coordinate pairs (pairs can also be separated by commas): | |
'x1, y1 x2, y2 [...]' | |
* or as a flat array of coordinates (unsinged integers): | |
x1, y1, x2, y2 [...] | |
.PARAMETER GenerationCount | |
Specifies the count of generations after which to stop the game automatically. | |
The default value, -1, keeps the game going indefinitely, or until a | |
"still life" state is reached, where generations no longer change. | |
.PARAMETER List | |
Specifies that successive generations of the grid should be printed one | |
after the other, whereas by default the grid is updated in-place. | |
.PARAMETER PauseAtLeastMilliseconds | |
Specifies how long, in milliseconds, to pause at a minimum between displaying | |
successive generations, so that play progress can be followed by a human | |
observer. Note that, due to computational effort, the actual interval be | |
longer. | |
The default is 500 msecs. | |
.EXAMPLE | |
Invoke-Life | |
Starts a game with the default grid size and random seeding. | |
.EXAMPLE | |
Invoke-Life -GridSize 40 -RandomSeedCount (40 * 40 / 3) | |
Starts a game in a 40 x 40 grid with a third of randomly chosen cells | |
initially alive. | |
.EXAMPLE | |
Invoke-Life -GridSize 6 -SeedCoordinates '0,5 1,2 3,5 4,1 4,3 4,5 5,2 5,3 5,5' | |
Starts a game with a 6 x 6 grid and explicit seed coordinates. | |
This particular eventually game exhibits cyclic patterns with a period of 4. | |
#> | |
#Requires -version 3 | |
[cmdletbinding(PositionalBinding=$false, DefaultParameterSetName='Random')] | |
param( | |
[parameter(Position=0)] | |
[ValidateRange(1, 80)] | |
[int] $GridSize = 20, # The number of cells along each axis of the grid. | |
[parameter(ParameterSetName='Random', Position=1)] | |
[int] $RandomSeedCount = $GridSize * $GridSize * 0.25, # how many live cells, randomly selected, to seed the grid with | |
[parameter(Mandatory, ParameterSetName='ExplicitSeed', Position=1)] | |
[ValidateNotNull()] | |
[object] $SeedCoordinates, # Note: we accept both an [int[]] array or a single string representing one | |
[ValidateRange(-1, [int]::MaxValue)] | |
[int] $GenerationCount = -1, # How many generations to show (0 means: just the seed (initial state)); omit to keep going indefinitely. | |
[switch] $List, # By default, generations are updated in-place; whether to print subsequent generations sequentially. | |
[ValidateRange(0, [int]::MaxValue)] | |
[Alias('ms')] | |
[int] $PauseAtLeastMilliseconds = 500 # For slowing down updates: the minimum time to pause between ticks (generations). | |
) | |
$ErrorActionPreference = 'Stop' # Abort on all unhandled errors. | |
Set-StrictMode -version 1 # Prevent use of uninitialized variables. | |
if ($RandomSeedCount -lt 0 -or $RandomSeedCount -gt $GridSize * $GridSize) { Throw "Invalid seed count: $_`nYou must specify a number between 0 and $($GridSize * $GridSize)" } | |
# Given a grid as $grid_ref, calculates the next generation and assigns it | |
# back to $grid_ref. | |
function update-generation { | |
param( | |
[ref] [byte[,]]$grid_ref # the by-reference grid variable | |
) | |
# Create a new, all-zero clone of the current grid to | |
# receive the new generation. | |
$grid_new = New-Object 'byte[,]' ($grid_ref.Value.GetLength(0), $grid_ref.Value.GetLength(1)) | |
$aliveCount = 0 | |
For($x=0; $x -le $grid_new.GetUpperBound(0); $x++ ){ | |
For($y=0; $y -le $grid_new.GetUpperBound(1); $y++){ | |
# Get the count of live neighbors. | |
# Note that the *original* matrix must be used to: | |
# - determine the live neighbors | |
# - inspect the current state | |
# because the game rules must be applied *simultaneously*. | |
$neighborCount = get-LiveNeighborCount $grid_ref.Value $x $y | |
if ($grid_ref.Value[$x,$y]) { # currently LIVE cell | |
# A live cell with 2 or 3 neighbors lives, all others die. | |
$grid_new[$x,$y] = [int] ($neighborCount -eq 2 -or $neighborCount -eq 3) | |
} else { # curently DEAD cell | |
# A currently dead cell is resurrected if it has 3 live neighbors. | |
$grid_new[$x,$y] = [int] ($neighborCount -eq 3) | |
} | |
if ($grid_new[$x,$y]) { ++$aliveCount } | |
} | |
} | |
# Assign the new generation to the by-reference grid variable. | |
$grid_ref.Value = $grid_new | |
# Output the number of alive cells | |
$aliveCount | |
} | |
# Get the count of live neighbors for grid position $x, $y. | |
function get-LiveNeighborCount{ | |
param( | |
[byte[,]]$grid, # the grid | |
[Int]$x, | |
[Int]$y | |
) | |
$xLength = $grid.GetLength(0) | |
$yLength = $grid.GetLength(1) | |
$count = 0 | |
for($xOffset = -1; $xOffset -le 1; $xOffset++) { | |
for($yOffset = -1; $yOffset -le 1; $yOffset++) { | |
if (-not ($xOffset -eq 0 -and $yOffset -eq 0)) { # skip the position at hand itself | |
if($grid[(get-wrappedIndex $xLength ($x + $xOffset)),(get-wrappedIndex $yLength ($y + $yOffset))]) { | |
$count++ | |
} | |
} | |
} | |
} | |
# Output the count. | |
$count | |
} | |
# Given a potentially out-of-bounds index along a dimension of a given length, | |
# return the wrapped-around-the-edges value. | |
function get-wrappedIndex{ | |
param( | |
[Int]$length, | |
[Int]$index | |
) | |
If($index -lt 0){ | |
$index += $length | |
} | |
ElseIf($index -ge $length){ | |
$index -= $length | |
} | |
# Output the potentially wrapped index. | |
$index | |
} | |
# Initialize a grid with a specified number of randomly selected live cells. | |
function initialize-WithRandomSeed { | |
param( | |
[byte[,]] $grid, | |
[int] $RandomSeedCount # how many cells to make live randomly | |
) | |
$i = 0 | |
while ($i -lt $RandomSeedCount){ | |
$randomX, $randomY = (Get-Random $GridSize), (Get-Random $GridSize) | |
# Avoid seeding the same cell twice. | |
if ($grid[$randomX, $randomY] -eq 0) { | |
$grid[$randomX, $randomY] = 1 | |
++$i | |
} | |
} | |
# Output the number of live cells that were seeded. | |
$RandomSeedCount | |
} | |
# Initialize a grid from a *string* containing a list of coordinates indicating live cells | |
function initialize-FromLiveCoordinatesString { | |
param( | |
[byte[,]] $grid, | |
[uint32[]] $coordinates | |
) | |
$gridSize = $grid.GetLength(0) | |
Write-Verbose "Coordinate indices: $coordinates" | |
for ([int] $i = 0; $i -lt $coordinates.Length - 1 ; $i += 2) { | |
if ($coordinates[$i] -ge $gridSize -or $coordinates[$i+1] -ge $gridSize) { Throw "Invalid coordinate pair specified. Coordinate values must be between 0 and $gridSize`: $i, $($i+1)" } | |
$grid[$coordinates[$i], $coordinates[$i+1]] = 1 | |
} | |
# Output the number of live cells that were seeded. | |
$coordinates.Length / 2 | |
} | |
# Initialize a grid from a *[byte[]]* array containing a sequence of coordinates indicating live cells | |
function initialize-FromLiveCoordinates { | |
param( | |
[byte[,]] $grid, | |
[byte[]] $liveCoordinates | |
) | |
# Make sure that the array contains an even number of numbers. | |
if ($liveCoordinates % 2) { Throw "Coordinates array is invalid, because it has an uneven count of elements: $($liveCoordinates.Count)" } | |
$gridSize = $grid.GetLength(0) | |
for ([int] $i = 0; $i -lt $liveCoordinates.Length - 1 ; $i += 2) { | |
$x, $y = $liveCoordinates[$i], $liveCoordinates[$i+1] | |
if ($x -ge $gridSize -or $y -ge $gridSize) { Throw "Invalid coordinate pair specified. Coordinate values must be between 0 and $gridSize`: $x, $y" } | |
$grid[$x, $y] = 1 | |
} | |
# Output the number of live cells that were seeded. | |
$liveCoordinates.Length / 2 | |
} | |
# Get the live cells as a flat list of consecutive coordinate pairs as a [byte[]] array | |
function convertTo-LiveCoordinates { | |
param( | |
[int] $aliveCellCount, | |
[byte[,]] $grid | |
) | |
$barr = New-Object 'byte[]' (2 * $aliveCellCount) | |
$i = 0 | |
for($row=0; $row -lt $grid.GetLength(0); ++$row) { | |
for ($col=0; $col -lt $grid.GetLength(1); ++$col) { | |
if ($grid[$row, $col]) { | |
$barr[$i++] = $row | |
$barr[$i++] = $col | |
} | |
} | |
} | |
$barr | |
} | |
# Get the live cells as a list of coordinates in human-friendly string format. | |
function convertTo-LiveCoordinatesString { | |
param( | |
[byte[,]] $grid | |
) | |
$coords = $sep = '' | |
for($row=0; $row -lt $grid.GetLength(0); ++$row) { | |
for ($col=0; $col -lt $grid.GetLength(1); ++$col) { | |
if ($grid[$row, $col]) { | |
# $coords += $sep + "[$row,$col]" | |
$coords += $sep + "$row,$col" | |
$sep = ' ' | |
} | |
} | |
} | |
$coords | |
} | |
# Test if any cells are alive in a grid. | |
function test-anyGridCellAlive { | |
param( | |
[byte[,]] $grid | |
) | |
$anyAlive=$false | |
for($row=0; $row -lt $grid.GetLength(0); ++$row) { | |
for ($col=0; $col -lt $grid.GetLength(1); ++$col) { | |
if ($grid[$row, $col]) { $anyAlive = $true; break } | |
} | |
} | |
$anyAlive | |
} | |
# Test if two grids - assumed to be the same size - are equal. | |
function test-gridsEqual { | |
param( | |
[byte[,]] $grid1, | |
[byte[,]] $grid2 | |
) | |
$equal = $true | |
for($row=0; $row -lt $grid1.GetLength(0); ++$row) { | |
for ($col=0; $col -lt $grid1.GetLength(1); ++$col) { | |
if ($grid1[$row, $col] -ne $grid2[$row, $col]) { $equal = $false; break } | |
} | |
} | |
$equal | |
} | |
# Print a (single generation's) grid. | |
function show-grid { | |
param( | |
[byte[,]] $grid, | |
[string] $legend | |
) | |
for($row=0; $row -lt $grid.GetLength(0); ++$row) { | |
$line = $sep = '' | |
for ($col=0; $col -lt $grid.GetLength(1); ++$col) { | |
$line += $sep + (' ', '■')[$grid[$row, $col]] | |
$sep = ' ' | |
} | |
Write-Host $line | |
} | |
if ($legend) { Write-Host -ForegroundColor Gray "`n$legend" } | |
} | |
# Returns information about a pending keypress, if any, and consumes it. | |
# - If one has been pressed, a [System.ConsoleKeyInfo] instance representing that key is returned | |
# - Otherwise, nothing is returned. | |
function waitingKeyPress { | |
if ($host.Name -eq 'ConsoleHost') { | |
# If a key is waiting, read and return it. | |
# Note: The $True argument is meant to suppress echoing of the key pressed to the terminal, | |
# but that only works with *synchronous* use of [Console]::ReadKey() | |
if ([Console]::KeyAvailable) { [Console]::ReadKey($True) } | |
} | |
} | |
# Write a full line to the console, right-padding with spaces to the full | |
# window width to ensure that any previous content is overwritten. | |
function write-FullHostLine { | |
param([string] $txt) | |
Write-Host $txt.PadRight($host.UI.RawUI.WindowSize.Width - $txt.Length) | |
} | |
# Turn console output (echoing) on or off. | |
function show-output { | |
param([bool]$Show = $true) | |
if ($Host.Name -eq 'ConsoleHost') { | |
$thisFunc = $MyInvocation.MyCommand; $customPropName = 'net_same2_orgStdout' | |
# try { | |
if ($Show) { | |
# Restore the cached [Console]::Out stream. | |
# !! This assumes that show-output $False was previously called at least once. | |
$outStream = $thisFunc.$customPropName | |
# !! If there's no cached stream, do nothing. | |
if (-not $outStream) { return } | |
} else { | |
# !! Restoring [Console]::Out to New-Object System.IO.StreamWriter ([Console]::OpenStandardOutput()) | |
# !! as follows works on Windows, but not macOS. | |
# $outStream = New-Object System.IO.StreamWriter ([Console]::OpenStandardOutput()) | |
# $outStream.AutoFlush = true | |
# !! By contrast, saving and restoring the original [Console]::Out works on all platforms. | |
# !! We use a custom property on this function object to | |
if (-not $thisFunc.$customPropName) { | |
$thisFunc | Add-Member -MemberType NoteProperty -Name $customPropName -Value ([Console]::Out) | |
} | |
$outStream = New-Object System.IO.StreamWriter ([System.IO.Stream]::Null) | |
} | |
[Console]::SetOut($outStream) | |
# } catch {} | |
} | |
} | |
# Turn the cursor in the console on or off. | |
function show-cursor { | |
param([bool]$Show = $true) | |
# !! The console on macOS doesn't support toggling cursor visibility, so | |
# !! must trap exceptions (and quietly ignore them). | |
if ($host.Name -eq 'ConsoleHost') { | |
try { | |
[Console]::CursorVisible = $Show | |
} catch {} | |
} | |
} | |
# Show successive generations. | |
function show-generations { | |
$instructions = "`nVisit https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life to learn more." | |
# Print the specified number of generations or until all cells have died, | |
# which may never happen; press Ctrl+C to interrupt anytime. | |
$gridSize = $grid.GetLength(0) | |
$cellCount = $gridSize * $gridSize | |
$fillRatio = $seedCount / $cellCount | |
$gridDescr = "$seedCount-seed $gridSize x $gridSize grid " + ('({0:0}% seeded)' -f ($fillRatio * 100)) | |
if ($host.Name -eq 'ConsoleHost') { | |
show-cursor $False # Hide the cursor, if possible - !! Trying to hide the cursor fails on macOS as of v6-alpha18 - simply ignore the failure. | |
} else { # Not a console, default to -List display (no in-place updating) | |
$List = $true | |
Write-Warning "Forcing list mode, because the host is not a console." | |
Write-Warning "Can only check for keypresses in a console host. Use Ctrl+C to terminate." | |
} | |
if (-not $List) { | |
Clear-Host # !! On macOS, this is currently necessary for in-place updating to work. | |
$startCursorPos = $host.UI.RawUI.CursorPosition | |
} | |
$sw = New-Object System.Diagnostics.Stopwatch | |
$paused = $false | |
$belowGridCursorPos = $null | |
# Initialize the list of states, each element of which stores a flat [byte[]] | |
# array containing consecutive pairs of the live cells in each generation. | |
$stateHistory = [System.Collections.Generic.List[byte[]]]::New(100) | |
try { | |
for ([int] $i = 0; $i -le $GenerationCount -or $GenerationCount -eq -1; ++$i) { # generation loop | |
$sw.Start() | |
# See if the user pressed a key. | |
$keyPressed = waitingKeyPress | |
if (-not $keyPressed) { | |
# -- Calculate this generation | |
if ($i -eq 0) { # initial state (seed) | |
$aliveCellCount = $seedCount | |
} else { # calculate the next generation. | |
$prevGrid = $grid.Clone() | |
$aliveCellCount = update-generation ([ref] $grid) | |
} | |
# Add this generation to the state history | |
$stateHistory.Add((convertTo-LiveCoordinates $aliveCellCount $grid)) | |
# If a minimum delay is to be enforced, sleep accordingly. | |
do { | |
# Break, if a key was pressed. | |
if ($keyPressed = waitingKeyPress) { break } | |
} | |
while ($sw.Elapsed.TotalMilliseconds -lt $PauseAtLeastMilliseconds -and $i -gt 0 -and $(Start-Sleep -Milliseconds 10; $true)) | |
} | |
$sw.Stop(); $sw.Reset() | |
if ($i -gt 0 -and ($paused -or $keyPressed)) { | |
:interact do { | |
if (-not $keyPressed) { # Show the menu prompt and wait for a keypress | |
$host.ui.RawUI.CursorPosition = $belowGridCursorPos | |
show-output | |
write-FullHostLine "(Q)uit, (N)ew game, (<-/->) step back/forward, (<space>) resume?" | |
show-output $False | |
show-cursor | |
$keyPressed = [Console]::ReadKey($true) | |
show-cursor $False | |
} | |
$valid = $false | |
if ($keyPressed.Modifiers -eq 0 -or $keyPressed.Modifiers -eq [System.ConsoleModifiers]::Shift) { # ignore keypresses that involve modifiers other than Shift | |
$valid = $true | |
switch ($keyPressed.Key) { # !! be sure to (...)-enclose all non-script-block branch conditions - otherwise they'll be treated as *strings* | |
# Note: On macOS, attempts to use Write-Host here - whose output should render *below* the menu hint/prompt - do not work. | |
([System.ConsoleKey]::Q) { exit } # Write-Host -ForegroundColor Yellow 'Terminated by user request.'; exit } | |
{ $_ -in [System.ConsoleKey]::Escape, [System.ConsoleKey]::Spacebar } { if ($paused) { $paused = $false; break interact } else { $paused = $true; $keyPressed = $null; continue interact } } | |
([System.ConsoleKey]::LeftArrow) { } # Write-Host -ForegroundColor Yellow 'Generation back' } | |
([System.ConsoleKey]::RightArrow) { } # Write-Host -ForegroundColor Yellow 'Generation forward' } | |
default { $valid = $false } | |
} | |
} | |
# if (-not $valid) { Write-Warning "Unrecognized key: $($keyPressed.KeyChar)" } | |
$keyPressed = $null | |
} while (-not $valid) | |
} | |
# -- Display this generation. | |
if ($List) { | |
Write-Host '' # Output empty line before the next grid is printed. | |
} else { | |
# Resume the original cursor position to "paint over" the original grid. | |
# !! [As of v6-alpha18] On macOS, this doesn't work reliably; After having run | |
# !! Clear-Host, it seems to work predictably. | |
$host.UI.RawUI.CursorPosition = $startCursorPos | |
} | |
$descr = $gridDescr + ' - ' | |
if ($i -eq 0 ) { | |
$descr += "initial state" | |
} else { | |
$descr += "generation #$i " + $(if ($GenerationCount -gt 0) { "of max. $GenerationCount "}) + ('({0:0}% filled)' -f ($aliveCellCount / $cellCount * 100)) | |
} | |
$descr += $instructions | |
# Print this generation. | |
show-output | |
show-grid $grid $descr | |
if (-not $belowGridCursorPos) { | |
$belowGridCursorPos = $host.UI.RawUI.CursorPosition | |
} | |
if (-not $paused) { | |
write-FullHostLine "Press Q to quit, <space> to pause and show menu." | |
} | |
show-output $False | |
# Stop: | |
# * if all cells died. | |
$sbEndReasonMessage = $null | |
if ($aliveCellCount -eq 0) { | |
$sbEndReasonMessage = { Write-Host -ForegroundColor Red "`nLife ended in generation $i." } | |
break | |
# * if the grid became a "still life", i.e., if the previous generation | |
# was exactly the same and all future generations would therefore be | |
# as well. | |
} elseif ($i -gt 0 -and (test-gridsEqual $prevGrid $grid)) { | |
# Write-Host -ForegroundColor Yellow "`nLife froze in generation $($i-1)." | |
$sbEndReasonMessage = { Write-Host -ForegroundColor Yellow "`nLife became a still life in generation $($i-1)." } | |
break | |
} | |
# Note: We make no attempt to detect *periods* (cycles of repeating) | |
# generations. | |
} # for | |
} finally { | |
# Turn echoing and the cursor back on. | |
show-output | |
show-cursor | |
if ($sbEndReasonMessage) { | |
& $sbEndReasonMessage | |
} else { | |
Write-Host -ForegroundColor Yellow "Life halted by request." | |
} | |
# Write the command that allows replaying the very same game on demand. | |
Write-Host -ForegroundColor Yellow "To replay this exact game on demand:`n $replayCmd" | |
} | |
} | |
# The grid is always a square | |
$grid = New-Object 'byte[,]' $GridSize, $GridSize | |
# Seed the grid | |
if ($SeedCoordinates) { # Explicit seed coordinates given. | |
try { | |
if ($SeedCoordinates -is [string]) { | |
[uint32[]] $coordinates = $SeedCoordinates -split '[, ]' -ne '' | |
} else { | |
[uint32[]] $coordinates = $SeedCoordinates | |
} | |
} catch { | |
Throw "Invalid seed coordinates given: $_" | |
} | |
$seedCount = initialize-FromLiveCoordinatesString $grid $coordinates | |
} else { # Random initialization | |
$seedCount = initialize-WithRandomSeed $grid $RandomSeedCount | |
} | |
# Construct a command line for replaying this very invocation, with incidental parameters omitted. | |
$replayCmd = "$($MyInvocation.MyCommand.Name) -GridSize $GridSize" | |
# if ($null -ne $GenerationCount) { | |
# $replayCmd += " -GenerationCount $GenerationCount" | |
# } | |
$replayCmd += " -SeedCoordinates '$(convertTo-LiveCoordinatesString $grid)'" | |
# Start showing the generations. | |
show-generations |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment