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