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)
        }
    )

}