functions/Start-MiniGameSnake.ps1
|
function Start-MiniGameSnake { <# .SYNOPSIS Draws a snake game. Use UP,DOWN,LEFT,RIGHT Arrows or WASD for movement. .DESCRIPTION Draws a snake game. Use UP,DOWN,LEFT,RIGHT Arrows or WASD for movement. .OUTPUTS Moderate Snacks eaten: 5 Snake: 8 +------------------------------+ | | | O | | O | | O | | OOOOO@ * | | | | | | | | | | | +------------------------------+ NOTE: Requires Powershell 7 or higher .EXAMPLE .LINK #> [CmdletBinding( DefaultParameterSetName = "difficultyLevels" )] param ( [Parameter( Position = 0, Mandatory = $false, ParameterSetName = "difficultyLevels" )] [System.String] [ArgumentCompleter( { return @('Beginner', 'Moderate', 'Difficult', 'Challenge', 'Hardcore', 'Unbeatable') } )] [ValidateScript( { $_ -in @('Beginner', 'Moderate', 'Difficult', 'Challenge', 'Hardcore', 'Unbeatable') }, ErrorMessage = "Valid diffculites: 'Beginner', 'Moderate', 'Difficult', 'Challenge', 'Hardcore', 'Unbeatable" )] $Difficulty = "Moderate", [Parameter( Position = 1, Mandatory = $false )] [switch] $Mute, [Parameter( Position = 2, Mandatory = $false )] [ValidateScript( { $_ -match '\d+x\d+' }, ErrorMessage = "Must match format '<Widht>x<Height>' e.g. 30x10." )] [System.String] $Dimension = "30x10", [Parameter( Position = 3, Mandatory = $false )] [switch] $MaxDimension, # The initial snake length. [Parameter( Position = 4, Mandatory = $false )] [System.Int32] $SnakeLength = 3, [Parameter( Position = 5, ParameterSetName = 'customTicks' )] [System.Int32] [ValidateRange(1, 1000)] $CustomTicks ) $Characters = @{ Wall = @{ Corner = '+' Vertical = '|' Horizontal = '-' } Snake = @{ Snack = '*' Body = 'O' Head = '@' } Empty = ' ' } $difficultyMapping = [ordered]@{ "Custom" = @{ ticks = $CustomTicks name = "Custom ???" color = @{ text = @(144, 238, 144) # LightGreen snakeSnack = [System.ConsoleColor]::White } chars = $Characters } "Beginner" = @{ ticks = 350 name = "Beginner" color = @{ text = @(144, 238, 144) # LightGreen snakeSnack = [System.ConsoleColor]::White } chars = $Characters } "Moderate" = @{ ticks = 175 name = "Moderate" color = @{ text = @(255, 255, 0) # Yellow snakeSnack = [System.ConsoleColor]::White } chars = $Characters } "Difficult" = @{ ticks = 125 name = "Difficult" color = @{ text = @(255, 165, 0) # Orange snakeSnack = [System.ConsoleColor]::White } chars = $Characters } "Challenge" = @{ ticks = 100 name = "Challenge" color = @{ text = @(128, 0, 128) # Purple snakeSnack = [System.ConsoleColor]::White } chars = $Characters } "Hardcore" = @{ ticks = 65 name = "<3 Heartcore ♥" # "♥" Not supported by all consoles color = @{ text = @(255, 0, 0) # Bright Red snakeSnack = [System.ConsoleColor]::Red } chars = @{ Wall = $Characters.Wall Empty = $Characters.Empty Snake = @{ Snack = $Characters.Snake.Snack # "♥" Not supported by all consoles Body = $Characters.Snake.Body Head = $Characters.Snake.Head } } } "Unbeatable" = @{ ticks = 35 name = "Unbeatable x.x" color = @{ text = @(0, 0, 0) snakeSnack = [System.ConsoleColor]::Red } chars = @{ Wall = $Characters.Wall Empty = $Characters.Empty Snake = @{ Snack = $Characters.Snake.Snack # "♥" Not supported by all consoles Body = $Characters.Snake.Body Head = $Characters.Snake.Head } } } } $GameHeight = [System.Int32]::Parse($Dimension.split('x')[1]) $GameWidth = [System.Int32]::Parse($Dimension.split('x')[0]) $WindowHeight = $host.UI.RawUI.WindowSize.Height $WindowWidth = $host.UI.RawUI.WindowSize.Width if ($GameWidth -LT 2) { throw [System.Exception]::new('The GameWidth must be at least 2') } if ($GameHeight -LT 2) { throw [System.Exception]::new('The GameHeight must be at least 2') } # Game Height # + 1 empty line above # + 2 lines of text above # + 2 lines (Upper/Lower Wall) # + 2 lines of text below $RequiredHeight = $GameHeight + 1 + 2 + 2 + 2 # Game Width # + 2 Walls $RequiredWidth = $GameWidth + 2 if ($MaxDimension.IsPresent) { $RequiredHeight -= $GameHeight $RequiredWidth -= $GameWidth $GameHeight = $WindowHeight - $RequiredHeight - 2 $GameWidth = $WindowWidth - $RequiredWidth - 2 $RequiredHeight += $GameHeight $RequiredWidth += $GameWidth } if ($WindowWidth -LT $RequiredWidth) { throw [System.Exception]::new("Terminal Width must be at least: $RequiredWidth (Currently: $WindowWidth)") } if ($WindowHeight -LT $RequiredHeight) { throw [System.Exception]::new("Terminal Height must be at least: $RequiredHeight (Currently: $WindowHeight)") } ######################################################## ###### Setting up the game loop $selectedDifficulty = $PSBoundParameters.ContainsKey("CustomTicks") ? $difficultyMapping.Custom : $difficultyMapping[$Difficulty] $GameOffsetLength = [System.Math]::floor($WindowWidth / 2 - $GameWidth / 2) $gameSettings = @{ TickSpanMilliseconds = $selectedDifficulty.ticks MinimumX = $GameOffsetLength MinimumY = 0 MaximumX = $RequiredWidth + $GameOffsetLength MaximumY = $RequiredHeight Mute = $Mute.IsPresent } $gameUtility = New-MiniGameRunUtility @gameSettings $gameUtility.sound.Add("snack", "$PSScriptRoot\assets\sounds\snake_snack.wav") $gameUtility.sound.Add("music", "$PSScriptRoot\assets\sounds\snake_music.wav") $gameUtility.set( @{ isWindows = $IsWindows # 1 Empty top row # + 1 row for difficulty # + 1 row for game stats # + 1 row for upper wall upperWallHeight = 4 lowerWallHeight = 4 + $GameHeight - 1 leftWallWidth = 1 rightWallWidth = $GameWidth # Initial snake position is the center of the screen. snake = $null snakeLength = [System.Int32]$SnakeLength snakeSnack = $null snackedSnakeSnacks = 0 gameEndingMessage = "Game exited unexpectedly" # Velocity is overwritte to next velocity on every loop. # This is to avoid invalid velocities when the user presses the arrows to fast, # before the call of the loop function. velocity = [System.Numerics.Vector2]::new(0, 1) nextVelocity = [System.Numerics.Vector2]::new(0, 1) collisionMap = [System.Collections.Hashtable]::new() difficulty = $selectedDifficulty } ) $gameUtility.alias([System.ConsoleKey]::A, [System.ConsoleKey]::LeftArrow) $gameUtility.alias([System.ConsoleKey]::D, [System.ConsoleKey]::RightArrow) $gameUtility.alias([System.ConsoleKey]::W, [System.ConsoleKey]::UpArrow) $gameUtility.alias([System.ConsoleKey]::S, [System.ConsoleKey]::DownArrow) $gameUtility.OnKey([System.ConsoleKey]::Escape, { param($var, $func, $canvas, $sound, $object, $stop) $var.gameEndingMessage = "Game was exited" $stop.Invoke() }) $gameUtility.OnKey([System.ConsoleKey]::A, { param($key, $var, $func, $canvas, $sound, $object, $stop) if ($var.velocity.X -EQ 0) { # Only accept if the snake is not moving to the right $var.nextVelocity = [System.Numerics.Vector2]::new(-1, 0) } }) $gameUtility.OnKey([System.ConsoleKey]::D, { param($key, $var, $func, $canvas, $sound, $object, $stop) if ($var.velocity.X -EQ 0) { # Only accept if the snake is not moving to the left $var.nextVelocity = [System.Numerics.Vector2]::new(1, 0) } }) $gameUtility.OnKey([System.ConsoleKey]::W, { param($key, $var, $func, $canvas, $sound, $object, $stop) if ($var.velocity.Y -EQ 0) { # Only accept if the snake is not moving down $var.nextVelocity = [System.Numerics.Vector2]::new(0, -1) } }) $gameUtility.OnKey([System.ConsoleKey]::S, { param($key, $var, $func, $canvas, $sound, $object, $stop) if ($var.velocity.Y -EQ 0) { # Only accept if the snake is not moving up $var.nextVelocity = [System.Numerics.Vector2]::new(0, 1) } }) $gameUtility.Once( { param($var, $func, $canvas, $sound, $object, $stop) $sound.Loop("music") # These only need to be drawn once. (Unless the snake hits a wall, which is game over) $chars = $var.difficulty.chars $wallHorizontal = $chars.Wall.Corner + ($chars.wall.Horizontal * $GameWidth) + $chars.wall.Corner $wallVertical = $chars.Wall.Vertical + ($chars.Empty * $GameWidth) + $chars.Wall.Vertical $canvas.Clear() $canvas.Draw(0, 3, $wallHorizontal) $canvas.Draw(0, 3 + $GameHeight + 1, $wallHorizontal) 1..$GameHeight | ForEach-Object { $canvas.Draw(0, $_ + 3, $wallVertical) } if (-NOT $var.isWindows) { $canvas.Draw(0, 3 + $GameHeight + 2, 'Sound supported only on Windows') } # Define a boundary for the snake for easy Out-Of-Bounds-Check. $canvas.Bounds.Add('cage', $var.leftWallWidth, $var.upperWallHeight, $var.rightWallWidth, $var.lowerWallHeight ) $var.snake = @( [System.Numerics.Vector2]@{ X = [System.Math]::Floor($var.leftWallWidth + $GameWidth / 2) Y = [System.Math]::Floor($var.upperWallHeight) } ) } ) $gameUtility.Loop( { param($var, $func, $canvas, $sound, $object, $stop) $var.velocity = $var.nextVelocity $characters = $var.difficulty.chars $snakeTail = $var.snake | Select-Object -Last 1 $snakeHead = [System.Numerics.Vector2]@{ X = $var.snake[0].X + $var.velocity.X Y = $var.snake[0].Y + $var.velocity.Y } ### Every element of the snake is moved to the position of the element in front of it for ($index = $var.snake.Count - 1; $index -GE 1; $index--) { $var.snake[$index] = [System.Numerics.Vector2]@{ X = $var.snake[$index - 1].X Y = $var.snake[$index - 1].Y } } ### when growing keep the last element in place as a new tail. if ($var.snake.Count -LT $var.snakeLength) { $snakeTail = [System.Numerics.Vector2]@{ X = $snakeTail.X Y = $snakeTail.Y } $var.snake += $snakeTail $canvas.Draw($snakeTail, $characters.Snake.Body) } ### Remove last element if the snake is not growing. else { $var.collisionMap.Remove($snakeTail) $canvas.Undraw($snakeTail, $characters.Snake.Body) } # Redraw the previous head as a body # This reduces console draws, since only the previous head and new head need be drawn. $canvas.Draw($var.snake[0], $characters.Snake.Body) $canvas.Draw($snakeHead, $characters.Snake.Head) if ($canvas.Bounds.IsOutside('cage', $snakeHead)) { $var.gameEndingMessage = 'Snake hit a Wall' $stop.Invoke() } elseif ($var.collisionMap.ContainsKey($snakeHead)) { $var.gameEndingMessage = 'Snake hit itself' $stop.Invoke() } else { $var.snake[0] = $snakeHead $var.collisionMap.Add($snakeHead, $null) } ### Check if the snake has eaten a snack and update the game stats. if ($null -NE $var.snakeSnack -AND $snakeHead.Equals($var.snakeSnack)) { $sound.Once("snack") $var.snakeSnack = $null $var.snakeLength += 1 $var.snackedSnakeSnacks += 1 } $canvas.Draw(0, 2, "Snacks eaten: $($var.snackedSnakeSnacks)") $canvas.DrawRight(0, 2, "Snake: $($var.snakeLength)") $canvas.ForeGround.onceRGB($var.difficulty.color.text) $canvas.Draw(0, 1, $var.difficulty.name) ### Spawn a new snack for the snake. while ($null -EQ $var.snakeSnack) { $proposedPosition = [System.Numerics.Vector2]@{ X = Get-Random -Minimum ($var.leftWallWidth + 0) -Maximum ($var.rightWallWidth + 1) Y = Get-Random -Minimum ($var.upperWallHeight + 0) -Maximum ($var.lowerWallHeight + 1) } # Snacks can only spawn on fields which are not occupied by the snake if (-NOT $var.collisionMap.ContainsKey($proposedPosition)) { $var.snakeSnack = $proposedPosition $canvas.ForeGround.onceColor($var.difficulty.color.snakeSnack) $canvas.Draw($var.snakeSnack, $characters.Snake.Snack) } } }, { param($var, $func, $canvas, $sound, $object, $stop) $canvas.Draw(0, $GameHeight + 5, "Ended: $($var.gameEndingMessage)") $canvas.Draw(0, $GameHeight + 6, "Press any key to continue...") $null = [System.Console]::ReadKey($true) } ) } |