functions/New-MiniGame.ps1

function New-MiniGame {
    <#
    .SYNOPSIS
    This return a utility class for running a game loop.
 
    .DESCRIPTION
    This return a utility class for running a game loop.
 
    It combines several other utility classes:
    - MiniGameObjectUtility
    - MiniGameDrawUtility
    - MiniGameSoundUtility
 
    Itself can handle:
    - reading keyboard input
    - easy aliases for keypresses (LeftArrow => A)
    - starting a gameloop with vars, functions and all utility classes
 
 
    TODO (Ideas):
    - Menue
    - Multiple scenes
    - Save/Load game state (json or psd1)
    - Save/Load game settings (json or psd1)
    - Save leaderboard (json or psd1)
 
 
    .OUTPUTS
 
     
    .EXAMPLE
 
    PS>
     
        !!!!!!!!!!!!! This is an outdated example !!!!!!!!!!!!!
        !!!!!!!!!!!!! TODO Update Exmaple !!!!!!!!!!!!!
        $gameUtility = New-MiniGame -InvertY -Mute
 
        # Variables for the gameloop can be defined here.
        # These are availabe as the '$var'-Parameter.
        $gameUtility.set(
            @{
                LifePoints = 5
                Damage = 2
            }
        )
         
        # Custom sound from files can be registered here.
        # These are availabe in the 'once' and 'loop'-function via sound.once("damage") or sound.loop("music").
        $gameUtility.sound.add("music", "./path/to/music.wav")
        $gameUtility.sound.add("damage", "./path/to/hit.wav")
 
 
        # Define a player as an object.
        $player = $gameUtility.object.Add("player 1")
        $player.setPos(40,1)
        $player.AddCanvas('Right',
            @(
                " O_",
                " Ä "
            )
        )
        $player.AddCanvas('Left',
            @(
                "_O ",
                " Ä "
            )
        )
 
        # For Key Pressed any number of aliases can be registered
        $gameUtility.alias([System.ConsoleKey]::A, [System.ConsoleKey]::LeftArrow)
        $gameUtility.alias([System.ConsoleKey]::D, [System.ConsoleKey]::RightArrow)
        # Move the player on key presses
        $gameUtility.onKey([System.ConsoleKey]::A,
            {
                param($key, $var, $func, $canvas, $sound, $object, $stop)
                $object.Get("Player 1").SetCanvas('Left')
                $object.Get("Player 1").Move(-1, 0)
                $object.Get("Player 1").Redraw()
            }
        )
        $gameUtility.onKey([System.ConsoleKey]::D,
            {
                param($key, $var, $func, $canvas, $sound, $object, $stop)
                $object.Get("Player 1").SetCanvas('Right')
                $object.Get("Player 1").Move(1, 0)
                $object.Get("Player 1").Redraw()
            }
        )
 
        $gameUtility.once(
            {
                # This is similar to the gameloop but only executed once.
                # This is useful for setting up variables, a looping music and other setups that only need to be executed once.
                param($var, $func, $canvas, $sound, $object, $stop)
 
                $sound.loop("music")
                $object.draw()
            }
        )
        $gameUtility.loop(
            {
                # The first script-block gets executed in a gameloop
                param($var, $func, $canvas, $sound, $object, $stop)
 
                # Access and modify the defined Variables
                if($wasHit) {
                    $var.LifePoints -= 1
                }
 
                # Look at New-MiniGameDrawUtility
                # $canvas can be used to draw text in certain colors on the screen
                $canvas.ForeGround.onceColor("Red")
                $canvas.draw(0, 10, "Hello World")
 
                # Look at New-MiniGameSoundUtility
                # $sound can be used to play custom sounds from files
                $sound.once("My Sound")
 
                # Stops the gameloop when a condition is met
                if($var.LifePoints -LE 0){
                    $stop.Invoke()
                }
            }
        )
 
        $game.End(
            {
                param($var, $func, $canvas, $sound, $object, $static)
            }
        )
 
    .LINK
     
    #>



    [CmdletBinding()]
    param (
        [Parameter(
            Position = 0,
            Mandatory = $false 
        )]
        [System.Int32]
        [ValidateRange(1, 1000)] 
        $TickSpanMilliseconds = 100,


        <#
         
            Explicitly mutes the sound, even when the sound.play() function is called.
             
            Sound is by default only played when called via $sound.play() in the game.loop or game.once function.
         
        #>

        [Parameter()]
        [switch]
        $Mute,

        <#
         
            Load game data from a directory.
        #>

        [Parameter()]
        [System.IO.DirectoryInfo]
        $Load,

        # Initial variables for the game.
        [Parameter()]
        [System.Collections.Hashtable]
        $Vars = @{},

        <#
         
            Setting custom boundaries for drawing objects.
 
            Defaults to full console window size.
 
        #>


        [Parameter()]
        [System.Int32]
        $MaximumX = -1,
        [Parameter()]
        [System.Int32]
        $MaximumY = -1,
        [Parameter()]
        [System.Int32]
        $MinimumX = 0,
        [Parameter()]
        [System.Int32]
        $MinimumY = 0,
    
    
        # Inverts drawing on the Y-Axis. Normally Y=0 points to the top of the screen.
        # This is usefull for having elements fly from the bottom upwards.
        [Parameter()]
        [switch]
        $InvertY,

        # Inverts drawing on the X-Axis. Normally X=0 points to the left side of the screen.
        # Might not be useful but complement to InvertY
        [Parameter()]
        [switch]
        $InvertX,
    
        # This automatically trims empty spaces from inputet strings.
        [Parameter(
            Mandatory = $false
        )]
        [System.Boolean]
        $TrimSpaces = $true,

        <#
            The method of calculating positions.
            - Radius: Draws a radius around each object and calculates its distance (more performant with many objects)
            - Position: Compares all occupied positions of each object
 
            Use radius if Position causes performance issues.
        #>

        [Parameter()]
        [ValidateSet('Position', 'Radius')]
        [System.String]
        $CollisionMethod = 'Position'
    )

    $InvokedCommand = Get-PSCallStack 
    | Select-Object -Skip 1 -First 1
    | Select-Object -ExpandProperty Command

    $objectUtility = $null
    $soundUtility = $null
    $drawSettings = @{
        MinimumX        = $MinimumX
        MinimumY        = $MinimumY
        MaximumX        = $MaximumX
        MaximumY        = $MaximumY
        TrimSpaces      = $TrimSpaces
        InvertY         = $InvertY.IsPresent
        InvertX         = $InvertX.IsPresent
        CollisionMethod = $CollisionMethod
    }

    try {
        $objectUtility = New-MiniGameObjectUtility @drawSettings
        $soundUtility = New-MiniGameSoundUtility -Mute:$Mute
    }
    catch {
        Write-Error "$_ `n`nTry using the -Mute parameter to disable sound. if sound is causing issues."
    }


    class MiniGameRunUtility {

        [System.Collections.Hashtable] $Functions = @{}
        [System.Collections.Hashtable] $Variables = $Vars
        [System.Collections.Hashtable] $Settings = @{
            TickSpanMilliseconds = $TickSpanMilliseconds
            GameRunning          = $true
        }

        [System.Collections.Hashtable] $Assets = @{
            # Blueprint-Scripts are only intialized when the game starts,
            # so that vars and functions are available.
            UninitializedBluePrints = @()
            LoadedDirectory         = $null
            Credits                 = @()

            InvokedCommand          = $InvokedCommand

            GameEndedReason         = $null

            Scene                   = @{
                'Main' = @{
                    OnceScript = $null
                    LoopScript = $null
                    EndScript  = $null
                }
            }
        }
        [System.Collections.Hashtable] $EventHandler = @{
            keyEvent = @{}
        }

        $Sound = $soundUtility
        $Object = $objectUtility
        $Canvas = $objectUtility.canvas
        MiniGameRunUtility() {}


        [Void] Ticks([System.Int32] $TickSpanMilliseconds) {
            $this.Settings.TickSpanMilliseconds = $TickSpanMilliseconds
        }
        [Void] Stop([System.String] $reason) {
            $this.Settings.GameRunning = $false
            $this.Settings.GameEndedReason = $reason
        }
        [Void] Stop() {
            $this.Stop('Game was stopped!')
        }
        [Void] Clear() {
            $this.Canvas.Clear()
        }



        [Void] Func([System.String] $name, [scriptblock] $action) {
            $this.Functions.Remove($name)
            $this.Functions.Add($name, $action)
        }
        [void] Once ([scriptblock] $action) {
            $this.Assets.Scene.Main.OnceScript = $action
        }
        [void] Loop([scriptblock] $action) {
            $this.Assets.Scene.Main.LoopScript = $action
        }
        [void] End([scriptblock] $action) {
            $this.Assets.Scene.Main.EndScript = $action
        }


        <#
            Set and Get variables.
            - Variables are accessible in the game loop via $var.
            - Variables can be modified during the game.
        #>

        [System.Object] Get([System.String] $name) {
            return $this.Variables[$name]
        }
        [Void] Set([System.String] $name, [System.Object] $value) {
            $this.Variables.Remove($name)
            $this.Variables.Add($name, $value)
        }
        [Void] Set([System.Collections.Hashtable] $variables) {
            $variables.GetEnumerator() 
            | ForEach-Object {
                $this.set($_.Key, $_.Value)
            }
        }


        <#
         
            This is the main function that starts the game loop.
        #>

        [void] Start() {

            if ([System.String]::IsNullOrEmpty($this.Assets.Scene.Main.LoopScript)) {
                throw [System.InvalidOperationException]::new(@"
            No game loop was provided!`n
            Either provide a gameloop via:
            - New-MiniGame -Load <directory>
            - `$game.Loop(<directory>)
            - `$game.Loop(<scriptblock>)
"@
)
            }



            <#
         
            Call the once script block if it is not null.
            Start the game loop
        #>


            $initalValue = [System.Console]::TreatControlCAsInput
            [System.Console]::TreatControlCAsInput = $true

            # Initialize the blueprints
            $this.InitBluePrints()

            $this.Clear()
            $this.Settings.GameRunning = $true
            $Scene = $this.Assets.Scene.Main

            if ($null -NE $Scene.OnceScript) {
                $Scene.OnceScript.Invoke(
                    $this.Variables,
                    $this.Functions,
                    $this.Canvas,
                    $this.Sound,
                    $this.Object,
                    $this.Stop
                )
            }

            try {
                :GameLoop
                while ($this.Settings.GameRunning) {
                    [System.Console]::CursorVisible = $this.CursorVisibility

                    $this.processKeys()
                    $Scene.LoopScript.Invoke(
                        $this.Variables,
                        $this.Functions,
                        $this.Canvas,
                        $this.Sound,
                        $this.Object,
                        $this.Stop
                    )
                    $this.Variables.ticks += 1
                    
                    Start-Sleep -Milliseconds $this.Settings.TickSpanMilliseconds
                }
            }
            catch {
                if ($_.Exception -IS [System.OperationCanceledException]) {
                    [System.Console]::SetCursorPosition(0, [System.Console]::BufferHeight - 5)

                    $Style = [System.Management.Automation.PSStyle]::Instance
                    Write-Host -ForegroundColor Red "$($style.Bold)-------------------------------"
                    Write-Host -ForegroundColor Red "$($style.Bold)[OperationCanceledException] User pressed Ctrl+C"
                }
                else {
                    [System.Console]::SetCursorPosition(0, [System.Console]::BufferHeight - 10)

                    $Style = [System.Management.Automation.PSStyle]::Instance
                    $type = $_.Exception.GetType().Name
                    $num = $_.InvocationInfo.ScriptLineNumber
                    $line = $_.InvocationInfo.Line.Trim()

                    $errorOutput = $Style.ForeGround.BrightRed + $Style.Bold
                    $errorOutput += "-------------------------------"
                    $errorOutput += "`n" + "[$type]: " 

                    $errorOutput += $Style.Foreground.BrightCyan
                    $errorOutput += "`n" + "Line".PadLeft(5) + " | "
                    $errorOutput += "`n" + "$num".PadLeft(5) + " | "
                    $errorOutput += " " + $line

                    $errorOutput += "`n" + " ".PadLeft(5) + " | "
                    $errorOutput += $Style.Foreground.BrightRed
                    $errorOutput += " " + ("~" * $line.Length)

                    $errorOutput += $Style.Foreground.BrightCyan
                    $errorOutput += "`n" + " ".PadLeft(5) + " | "
                    $errorOutput += $Style.Foreground.BrightRed
                    $errorOutput += $_.Exception.Message + "`n"

                    Write-Host -ForegroundColor Red $errorOutput
                }
            }
            finally {
                [System.Console]::TreatControlCAsInput = $initalValue
                [System.Console]::CursorVisible = $true

                $this.Sound.Stop()
                if ($null -NE $Scene.EndScript) {
                    $Scene.EndScript.Invoke(
                        $this.Variables,
                        $this.Functions,
                        $this.Canvas,
                        $this.Sound,
                        $this.Object
                    )
                }

                $Style = [System.Management.Automation.PSStyle]::Instance
                if (-NOT [System.String]::IsNullOrEmpty($this.Settings.GameEndedReason)) {
                    [System.Console]::SetCursorPosition(0, [System.Console]::BufferHeight - 5)

                    Write-Host -ForegroundColor Red "$($Style.Bold)-------------------------------"
                    Write-Host -ForegroundColor Red "$($Style.Bold)Game ended: $($this.Settings.GameEndedReason)"
                }

                if ($this.Assets.Credits.Count -GT 0) {

                    [System.Console]::SetCursorPosition(0, [System.Console]::BufferHeight - 3)
                    Write-Host -ForegroundColor Cyan "$($Style.Bold)-------------------------------"
                    Write-Host -ForegroundColor Cyan "$($Style.Bold)Press any key to see credits..."
                    Start-Sleep -Milliseconds 800
                    [System.Console]::ReadKey($true) | Out-Null
                    [System.Console]::Clear()
            
                    $InvokedCommand = $this.Assets.InvokedCommand
                    Write-Host -ForegroundColor Cyan "$InvokedCommand Credits:`n"
                    foreach ($entry in $this.Assets.Credits) {
                        Write-Host -ForegroundColor Cyan   " File: $($entry.Name)"
                        Write-Host -ForegroundColor Yellow " Credit: $($entry.Credit)"
                        Write-Host -ForegroundColor DarkGray "-------------------------"
                    }
                }
            }
        }



        <#
 
            This processes key inputs and execute the action associated with the key.
        #>

        [void] ProcessKeys() {
            $this.processKeys($false, 1)
        }
        [Void] ProcessKeys([System.Boolean] $blocking) {
            $this.processKeys($blocking, 1)
        }
        [Void] ProcessKeys([System.Boolean] $blocking, [System.Byte] $expectedKeys) {
            while ([System.Console]::KeyAvailable -OR $blocking ) {
                $keyEvent = [System.Console]::ReadKey($true)

                # Throw an OperationCanceledException when Ctrl+C is pressed.
                if (
                    $keyEvent.Key -EQ [System.ConsoleKey]::C -AND
                    $keyEvent.Modifiers -EQ [System.ConsoleModifiers]::Control
                ) {
                    throw [System.OperationCanceledException]::new("User pressed Ctrl+C")
                }

                # If the user pressed Escape, stop the game.
                if ($keyEvent.Key -EQ [System.ConsoleKey]::Escape) {
                    $this.Stop('User pressed Escape')
                    return
                }


                <#
                 
                    Proceed with normal key event processing.
                #>

                $handlers = $this.EventHandler.keyEvent
                $script = $handlers[$keyEvent.Key]

                # If the script is a key alias, get the actual script
                while ($script -IS [System.ConsoleKey]) {
                    $script = $handlers[$script]
                }
    
                if ($null -NE $script) {

                    $script.Invoke(
                        $keyEvent,
                        $this.Variables,
                        $this.Functions,
                        $this.Canvas,
                        $this.Sound,
                        $this.Object,
                        $this.Stop
                    )
                }
                
                # When blocking execute only for expected key amounts
                if ($blocking -AND --$expectedKeys -LE 0) {
                    return
                }
            }
        }

        <#
 
            This defines actions in key pressens:
            key: The name of the pressed key
            action: A script block
        #>

        [Void] OnKey([System.ConsoleKey] $key, [ScriptBlock] $action) {
            $this.EventHandler.keyEvent.Remove($key)
            $this.EventHandler.keyEvent.Add($key, $action)
        }

        <#
 
            This processes key inputs and execute the action associated with the key.
            Source: The name of the pressed key
            Alias: The Alias to set for the key
        #>

        [void] Alias([System.ConsoleKey] $Source, [System.ConsoleKey] $Alias) {
            $this.EventHandler.keyEvent.Remove($Alias)
            $this.EventHandler.keyEvent.Add($Alias, $Source)
        }

        [void] RemoveKey([System.ConsoleKey] $key) {
            $this.EventHandler.keyEvent.Remove($key)
        }
        [void] RemoveAlias([System.ConsoleKey] $key) {
            $this.EventHandler.keyEvent.Remove($key)
        }


        <#
         
            This loads a directory with all files in it.
       
            - functions: All script blocks in the directory are loaded as functions.
            - sounds: All files in the directory are loaded as sounds.
            - psdata: psd1 files are loaded as variables.
        #>

        [void] _ValidateDirectory([System.IO.DirectoryInfo] $Directory) {
            $Directory = Get-Item -Path $Directory -ErrorAction Stop

            if (-NOT [System.IO.FileInfo]::New("$Directory\states\once.ps1").Exists) {
                throw [System.InvalidOperationException]::new("File: '$Directory\states\once.ps1' was not found!`nThis should be a script block that is executed once before the game loop starts.`nIs provided as argument to`gameUtility.Once(once)")
            }
            if (-NOT [System.IO.FileInfo]::New("$Directory\states\loop.ps1").Exists) {
                throw [System.InvalidOperationException]::new("File: '$Directory\states\loop.ps1' was not found!`nThis should be the main game loop.`nIs provided as first argument to`gameUtility.Loop(loop, end)")
            }
            if (-NOT [System.IO.FileInfo]::New("$Directory\states\end.ps1").Exists) {
                throw [System.InvalidOperationException]::new("File: '$Directory\states\end.ps1' was not found!`nThis should be a script block at game end.`nIs provided as second argument to`gameUtility.Loop(loop, end)")
            }
        }
        [MiniGameRunUtility] Load(
            [System.IO.DirectoryInfo] $Directory
        ) {
            $this.Assets.LoadedDirectory = $Directory.FullName

            # Load functions
            $funcFiles = Get-ChildItem -Path "$Directory\functions\*.ps1" -Recurse -ErrorAction SilentlyContinue
            foreach ($func in ($funcFiles ?? $())) {
                $content = Get-Content -Path $func.FullName -Raw
                $this.Func($func.BaseName, 
                    [scriptblock]::Create($content)
                )
            }
            # Load data
            $dataFiles = Get-ChildItem -Path "$Directory\psdata\*.psd1" -Recurse -ErrorAction SilentlyContinue
            foreach ($file in ($dataFiles ?? $())) {
                $content = Import-PowerShellDataFile -Path $file.FullName
                $this.Set($file.BaseName, $content)
            }

            # Load sounds
            $soundFiles = Get-ChildItem -Path "$Directory\sounds\*" -Recurse -ErrorAction SilentlyContinue
            foreach ($file in ($soundFiles ?? $())) {
                $this.Sound.Add($file.BaseName, $file.FullName)
            }

            $creditsJson = Get-Content -Path "$Directory\credits.json" -ErrorAction SilentlyContinue 
            | ConvertFrom-Json -AsHashtable -ErrorAction SilentlyContinue
            foreach ($item in ($creditsJson ?? @{}).GetEnumerator()) {
                $this.Assets.Credits += [PSCustomObject]@{
                    Name   = $item.Name
                    File   = Get-Item -Path "$Directory\$($item.Name)*"
                    Credit = $item.Value
                }
            }

            # Load Blueprints
            $bluePrintFiles = Get-ChildItem -Path "$Directory\blueprints\*" -Recurse -ErrorAction SilentlyContinue
            foreach ($file in ($bluePrintFiles ?? $())) {
                $content = Get-Content -Raw -Path $file.FullName
                $this.Assets.UninitializedBluePrints += @{
                    Blueprint  = $this.Object.AddBlueprint($file.BaseName)
                    InitScript = [scriptblock]::Create($content)
                }
            }

            # Load once, loop and end scripts
            $onceFile = Get-Content -Raw -Path "$($this.Assets.LoadedDirectory)\scene\once.ps1" -ErrorAction SilentlyContinue
            if (-NOT [System.String]::IsNullOrEmpty($onceFile)) {
                $this.Assets.Scene.Main.OnceScript = [scriptblock]::Create($onceFile)
            }

            $loopFile = Get-Content -Raw -Path "$($this.Assets.LoadedDirectory)\scene\loop.ps1" -ErrorAction SilentlyContinue
            if (-NOT [System.String]::IsNullOrEmpty($loopFile)) {
                $this.Assets.Scene.Main.LoopScript = [scriptblock]::Create($loopFile)
            }

            $endFile = Get-Content -Raw -Path "$($this.Assets.LoadedDirectory)\scene\end.ps1" -ErrorAction SilentlyContinue
            if (-NOT [System.String]::IsNullOrEmpty($endFile)) {
                $this.Assets.Scene.Main.EndScript = [scriptblock]::Create($endFile)
            }

            return $this
        }


        <#
            Blueprints are only initialized when the game starts,
            to make sure that all variables and functions are available.
            - The script in the folder is run when the blueprint is created.
            - This is NOT the initsciript of the blueprint itself, which is run on each instance of the blueprint.
        #>

        [Void] InitBluePrints() {
            foreach ($item in $this.Assets.UninitializedBluePrints) {
                $null = $item.InitScript.Invoke(
                    $item.Blueprint,
                    $this.Variables,
                    $this.Settings
                )
            }
            $this.Assets.UninitializedBluePrints = $()
        }
    }

    $gameUtility = [MiniGameRunUtility]@{}

    if ($PSBoundParameters.ContainsKey("Load")) {
        return $gameUtility.Load($Load)
    } 
    else {
        return $gameUtility
    }
}