Last active
March 10, 2026 16:31
-
-
Save dfinke/c222b5995a3136c4fdeb4df400f0ba0c to your computer and use it in GitHub Desktop.
Using AI - built a standalone PowerShell arcade game at space-invaders.ps1
This file contains hidden or 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
| param( | |
| [int]$DemoFrames = 0, | |
| [int]$FrameDelayMs = 55, | |
| [switch]$NoColor, | |
| [switch]$Sound | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Stop' | |
| $script:Rng = [System.Random]::new() | |
| $script:Escape = [char]27 | |
| $script:ResetStyle = if ($NoColor) { '' } else { "$($script:Escape)[0m" } | |
| function Get-FgStyle { | |
| param( | |
| [int]$Red, | |
| [int]$Green, | |
| [int]$Blue | |
| ) | |
| if ($NoColor) { | |
| return '' | |
| } | |
| return "$($script:Escape)[38;2;${Red};${Green};${Blue}m" | |
| } | |
| function New-Buffer { | |
| param( | |
| [int]$Width, | |
| [int]$Height | |
| ) | |
| $chars = New-Object 'object[]' $Height | |
| $styles = New-Object 'object[]' $Height | |
| for ($y = 0; $y -lt $Height; $y++) { | |
| $charRow = New-Object 'char[]' $Width | |
| [System.Array]::Fill($charRow, [char]' ') | |
| $chars[$y] = $charRow | |
| $styleRow = New-Object 'string[]' $Width | |
| [System.Array]::Fill($styleRow, '') | |
| $styles[$y] = $styleRow | |
| } | |
| return [pscustomobject]@{ | |
| Width = $Width | |
| Height = $Height | |
| Chars = $chars | |
| Styles = $styles | |
| } | |
| } | |
| function Set-Cell { | |
| param( | |
| $Buffer, | |
| [int]$X, | |
| [int]$Y, | |
| [string]$Glyph, | |
| [string]$Style = '' | |
| ) | |
| if ($X -lt 0 -or $X -ge $Buffer.Width -or $Y -lt 0 -or $Y -ge $Buffer.Height) { | |
| return | |
| } | |
| $Buffer.Chars[$Y][$X] = [char]$Glyph[0] | |
| $Buffer.Styles[$Y][$X] = $Style | |
| } | |
| function Draw-Text { | |
| param( | |
| $Buffer, | |
| [int]$X, | |
| [int]$Y, | |
| [string]$Text, | |
| [string]$Style = '' | |
| ) | |
| for ($index = 0; $index -lt $Text.Length; $index++) { | |
| Set-Cell -Buffer $Buffer -X ($X + $index) -Y $Y -Glyph $Text[$index] -Style $Style | |
| } | |
| } | |
| function Draw-NeonText { | |
| param( | |
| $Buffer, | |
| [int]$X, | |
| [int]$Y, | |
| [string]$Text, | |
| [object[]]$Palette, | |
| [int]$Offset = 0 | |
| ) | |
| for ($index = 0; $index -lt $Text.Length; $index++) { | |
| $style = $Palette[($index + $Offset) % $Palette.Count] | |
| Set-Cell -Buffer $Buffer -X ($X + $index) -Y $Y -Glyph $Text[$index] -Style $style | |
| } | |
| } | |
| function Draw-Box { | |
| param( | |
| $Buffer, | |
| [int]$X, | |
| [int]$Y, | |
| [int]$Width, | |
| [int]$Height, | |
| [string]$Style | |
| ) | |
| for ($column = 0; $column -lt $Width; $column++) { | |
| $edgeGlyph = if ($column -eq 0 -or $column -eq ($Width - 1)) { '+' } else { '-' } | |
| Set-Cell -Buffer $Buffer -X ($X + $column) -Y $Y -Glyph $edgeGlyph -Style $Style | |
| Set-Cell -Buffer $Buffer -X ($X + $column) -Y ($Y + $Height - 1) -Glyph $edgeGlyph -Style $Style | |
| } | |
| for ($row = 1; $row -lt ($Height - 1); $row++) { | |
| Set-Cell -Buffer $Buffer -X $X -Y ($Y + $row) -Glyph '|' -Style $Style | |
| Set-Cell -Buffer $Buffer -X ($X + $Width - 1) -Y ($Y + $row) -Glyph '|' -Style $Style | |
| } | |
| } | |
| function Render-Buffer { | |
| param($Buffer) | |
| $builder = [System.Text.StringBuilder]::new() | |
| for ($y = 0; $y -lt $Buffer.Height; $y++) { | |
| $currentStyle = '' | |
| for ($x = 0; $x -lt $Buffer.Width; $x++) { | |
| $style = $Buffer.Styles[$y][$x] | |
| if ($style -ne $currentStyle) { | |
| [void]$builder.Append($style) | |
| $currentStyle = $style | |
| } | |
| [void]$builder.Append($Buffer.Chars[$y][$x]) | |
| } | |
| if ($currentStyle -ne '') { | |
| [void]$builder.Append($script:ResetStyle) | |
| } | |
| if ($y -lt ($Buffer.Height - 1)) { | |
| [void]$builder.Append("`n") | |
| } | |
| } | |
| return $builder.ToString() | |
| } | |
| function New-Particle { | |
| param( | |
| [double]$X, | |
| [double]$Y, | |
| [string]$Style | |
| ) | |
| $chars = @('.', '*', '+', '.', ':') | |
| return [pscustomobject]@{ | |
| X = $X | |
| Y = $Y | |
| VX = (($script:Rng.NextDouble() * 2.0) - 1.0) * 18.0 | |
| VY = (($script:Rng.NextDouble() * 2.0) - 1.0) * 11.0 | |
| Life = 0.65 + ($script:Rng.NextDouble() * 0.55) | |
| MaxLife = 1.20 | |
| Style = $Style | |
| Glyph = $chars[$script:Rng.Next(0, $chars.Count)] | |
| } | |
| } | |
| function Add-Explosion { | |
| param( | |
| $State, | |
| [double]$X, | |
| [double]$Y, | |
| [string]$Style, | |
| [int]$Count = 12 | |
| ) | |
| for ($index = 0; $index -lt $Count; $index++) { | |
| [void]$State.Particles.Add((New-Particle -X $X -Y $Y -Style $Style)) | |
| } | |
| if ($Sound) { | |
| try { | |
| [console]::Beep(560 + ($script:Rng.Next(0, 100)), 35) | |
| } | |
| catch { | |
| } | |
| } | |
| } | |
| function New-Star { | |
| param($State) | |
| $depth = $script:Rng.Next(1, 4) | |
| $glyph = switch ($depth) { | |
| 1 { '.' } | |
| 2 { '*' } | |
| default { '+' } | |
| } | |
| $style = switch ($depth) { | |
| 1 { $State.Styles.StarDim } | |
| 2 { $State.Styles.StarBright } | |
| default { $State.Styles.StarHot } | |
| } | |
| return [pscustomobject]@{ | |
| X = [double]$script:Rng.Next(($State.PlayLeft + 1), $State.PlayRight) | |
| Y = [double]$script:Rng.Next(($State.PlayTop + 1), $State.PlayBottom) | |
| Speed = 1.2 + ($depth * 2.2) | |
| Style = $style | |
| Glyph = $glyph | |
| } | |
| } | |
| function Initialize-Stars { | |
| param($State) | |
| $State.Stars = [System.Collections.ArrayList]::new() | |
| $count = [Math]::Max(28, [Math]::Floor($State.Width / 2)) | |
| for ($index = 0; $index -lt $count; $index++) { | |
| [void]$State.Stars.Add((New-Star -State $State)) | |
| } | |
| } | |
| function Initialize-Shields { | |
| param($State) | |
| $State.Shields = [System.Collections.ArrayList]::new() | |
| $patterns = @( | |
| ' ##### ', | |
| '#######', | |
| '### ###', | |
| '## ##' | |
| ) | |
| $shieldCount = 4 | |
| $shieldSpacing = [Math]::Floor(($State.PlayWidth - ($shieldCount * 7)) / ($shieldCount + 1)) | |
| $shieldY = $State.PlayerY - 5 | |
| for ($shieldIndex = 0; $shieldIndex -lt $shieldCount; $shieldIndex++) { | |
| $baseX = $State.PlayLeft + $shieldSpacing + ($shieldIndex * (7 + $shieldSpacing)) | |
| for ($row = 0; $row -lt $patterns.Count; $row++) { | |
| for ($column = 0; $column -lt $patterns[$row].Length; $column++) { | |
| if ($patterns[$row][$column] -eq '#') { | |
| [void]$State.Shields.Add([pscustomobject]@{ | |
| X = $baseX + $column | |
| Y = $shieldY + $row | |
| HP = 3 | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function Initialize-Wave { | |
| param($State) | |
| $State.Enemies = [System.Collections.ArrayList]::new() | |
| $rows = 4 | |
| $columns = 8 | |
| $enemyWidth = 5 | |
| $gap = 3 | |
| $totalWidth = ($columns * $enemyWidth) + (($columns - 1) * $gap) | |
| $startX = $State.PlayLeft + [Math]::Floor(($State.PlayWidth - $totalWidth) / 2) | |
| $startY = $State.PlayTop + 3 | |
| for ($row = 0; $row -lt $rows; $row++) { | |
| for ($column = 0; $column -lt $columns; $column++) { | |
| [void]$State.Enemies.Add([pscustomobject]@{ | |
| X = $startX + ($column * ($enemyWidth + $gap)) | |
| Y = $startY + ($row * 2) | |
| Width = $enemyWidth | |
| Row = $row | |
| Score = (4 - $row) * 10 | |
| }) | |
| } | |
| } | |
| $State.EnemyDirection = 1 | |
| $State.EnemyMoveTimer = 0.32 | |
| $State.EnemyFireTimer = 0.75 | |
| $State.BannerText = "WAVE $($State.Level)" | |
| $State.BannerSubText = if ($State.Level -eq 1) { 'Defend Earth.' } else { 'They are getting meaner.' } | |
| $State.BannerTime = 2.2 | |
| } | |
| function Reset-GameState { | |
| param($State) | |
| $State.Score = 0 | |
| $State.Level = 1 | |
| $State.Lives = 3 | |
| $State.Ticks = 0.0 | |
| $State.ExitRequested = $false | |
| $State.GameOver = $false | |
| $State.Paused = $false | |
| $State.PlayerCooldown = 0.0 | |
| $State.PlayerInvulnerable = 0.0 | |
| $State.NextSaucerAt = 12.0 + ($script:Rng.NextDouble() * 10.0) | |
| $State.Saucer = $null | |
| $State.PlayerBullets = [System.Collections.ArrayList]::new() | |
| $State.EnemyBullets = [System.Collections.ArrayList]::new() | |
| $State.Particles = [System.Collections.ArrayList]::new() | |
| $State.PlayerX = [Math]::Floor(($State.PlayLeft + $State.PlayRight - 4) / 2) | |
| Initialize-Stars -State $State | |
| Initialize-Shields -State $State | |
| Initialize-Wave -State $State | |
| } | |
| function New-GameState { | |
| param( | |
| [int]$Width, | |
| [int]$Height | |
| ) | |
| $styles = [pscustomobject]@{ | |
| Border = Get-FgStyle -Red 55 -Green 155 -Blue 255 | |
| TitleA = Get-FgStyle -Red 0 -Green 255 -Blue 214 | |
| TitleB = Get-FgStyle -Red 68 -Green 164 -Blue 255 | |
| TitleC = Get-FgStyle -Red 255 -Green 80 -Blue 170 | |
| Player = Get-FgStyle -Red 84 -Green 255 -Blue 198 | |
| PlayerWarn = Get-FgStyle -Red 255 -Green 240 -Blue 100 | |
| Bullet = Get-FgStyle -Red 255 -Green 255 -Blue 255 | |
| EnemyA = Get-FgStyle -Red 255 -Green 120 -Blue 120 | |
| EnemyB = Get-FgStyle -Red 255 -Green 190 -Blue 85 | |
| EnemyC = Get-FgStyle -Red 255 -Green 90 -Blue 230 | |
| EnemyD = Get-FgStyle -Red 255 -Green 70 -Blue 90 | |
| EnemyShot = Get-FgStyle -Red 255 -Green 180 -Blue 50 | |
| ShieldHigh = Get-FgStyle -Red 98 -Green 255 -Blue 109 | |
| ShieldMid = Get-FgStyle -Red 255 -Green 216 -Blue 76 | |
| ShieldLow = Get-FgStyle -Red 255 -Green 120 -Blue 120 | |
| StarDim = Get-FgStyle -Red 70 -Green 110 -Blue 165 | |
| StarBright = Get-FgStyle -Red 120 -Green 195 -Blue 255 | |
| StarHot = Get-FgStyle -Red 255 -Green 255 -Blue 255 | |
| Saucer = Get-FgStyle -Red 255 -Green 70 -Blue 190 | |
| Alert = Get-FgStyle -Red 255 -Green 90 -Blue 90 | |
| Overlay = Get-FgStyle -Red 160 -Green 220 -Blue 255 | |
| Accent = Get-FgStyle -Red 120 -Green 255 -Blue 255 | |
| } | |
| $state = [pscustomobject]@{ | |
| Width = $Width | |
| Height = $Height | |
| PlayTop = 3 | |
| PlayBottom = $Height - 2 | |
| PlayLeft = 1 | |
| PlayRight = $Width - 2 | |
| PlayWidth = $Width - 2 | |
| PlayHeight = $Height - 5 | |
| PlayerY = $Height - 4 | |
| Styles = $styles | |
| Score = 0 | |
| Level = 1 | |
| Lives = 3 | |
| Ticks = 0.0 | |
| ExitRequested = $false | |
| GameOver = $false | |
| Paused = $false | |
| PlayerCooldown = 0.0 | |
| PlayerInvulnerable = 0.0 | |
| EnemyDirection = 1 | |
| EnemyMoveTimer = 0.32 | |
| EnemyFireTimer = 0.75 | |
| NextSaucerAt = 12.0 | |
| Saucer = $null | |
| BannerText = '' | |
| BannerSubText = '' | |
| BannerTime = 0.0 | |
| Stars = [System.Collections.ArrayList]::new() | |
| Shields = [System.Collections.ArrayList]::new() | |
| Enemies = [System.Collections.ArrayList]::new() | |
| PlayerBullets = [System.Collections.ArrayList]::new() | |
| EnemyBullets = [System.Collections.ArrayList]::new() | |
| Particles = [System.Collections.ArrayList]::new() | |
| PlayerX = 0 | |
| } | |
| Reset-GameState -State $state | |
| return $state | |
| } | |
| function Get-ShieldStyle { | |
| param( | |
| $State, | |
| [int]$HP | |
| ) | |
| switch ($HP) { | |
| 3 { return $State.Styles.ShieldHigh } | |
| 2 { return $State.Styles.ShieldMid } | |
| default { return $State.Styles.ShieldLow } | |
| } | |
| } | |
| function Get-EnemyStyle { | |
| param( | |
| $State, | |
| [int]$Row | |
| ) | |
| switch ($Row) { | |
| 0 { return $State.Styles.EnemyA } | |
| 1 { return $State.Styles.EnemyB } | |
| 2 { return $State.Styles.EnemyC } | |
| default { return $State.Styles.EnemyD } | |
| } | |
| } | |
| function Get-EnemySprite { | |
| param( | |
| [int]$Row, | |
| [int]$Frame | |
| ) | |
| switch ($Row) { | |
| 0 { | |
| if ($Frame -eq 0) { return '[-^-]' } | |
| return '[^_^]' | |
| } | |
| 1 { | |
| if ($Frame -eq 0) { return '<o_o>' } | |
| return '<O_O>' | |
| } | |
| 2 { | |
| if ($Frame -eq 0) { return '{x_x}' } | |
| return '{X_X}' | |
| } | |
| default { | |
| if ($Frame -eq 0) { return '/MMM\' } | |
| return '\MMM/' | |
| } | |
| } | |
| } | |
| function Try-SpawnSaucer { | |
| param($State) | |
| if ($State.Saucer -or $State.Ticks -lt $State.NextSaucerAt) { | |
| return | |
| } | |
| $direction = if ($script:Rng.Next(0, 2) -eq 0) { 1 } else { -1 } | |
| $x = if ($direction -eq 1) { $State.PlayLeft - 6 } else { $State.PlayRight + 1 } | |
| $State.Saucer = [pscustomobject]@{ | |
| X = [double]$x | |
| Y = [double]($State.PlayTop + 1) | |
| Width = 5 | |
| Height = 1 | |
| VX = 10.0 * $direction | |
| Score = 125 | |
| } | |
| $State.NextSaucerAt = $State.Ticks + 13.0 + ($script:Rng.NextDouble() * 12.0) | |
| } | |
| function Fire-PlayerShot { | |
| param($State) | |
| if ($State.PlayerCooldown -gt 0.0 -or $State.GameOver) { | |
| return | |
| } | |
| $spread = if ($State.Level -ge 4) { @(-1, 1) } else { @(0) } | |
| foreach ($offset in $spread) { | |
| [void]$State.PlayerBullets.Add([pscustomobject]@{ | |
| X = [double]($State.PlayerX + 2 + $offset) | |
| Y = [double]($State.PlayerY - 1) | |
| Speed = 26.0 | |
| }) | |
| } | |
| $State.PlayerCooldown = if ($State.Level -ge 5) { 0.19 } else { 0.28 } | |
| if ($Sound) { | |
| try { | |
| [console]::Beep(900, 20) | |
| } | |
| catch { | |
| } | |
| } | |
| } | |
| function Get-LowestEnemies { | |
| param($State) | |
| $lowestByColumn = @{} | |
| foreach ($enemy in $State.Enemies) { | |
| $key = [string]$enemy.X | |
| if (-not $lowestByColumn.ContainsKey($key) -or $enemy.Y -gt $lowestByColumn[$key].Y) { | |
| $lowestByColumn[$key] = $enemy | |
| } | |
| } | |
| return @($lowestByColumn.Values) | |
| } | |
| function Fire-EnemyShot { | |
| param($State) | |
| if ($State.Enemies.Count -eq 0) { | |
| return | |
| } | |
| $shooters = Get-LowestEnemies -State $State | |
| if ($shooters.Count -eq 0) { | |
| return | |
| } | |
| $shooter = $shooters[$script:Rng.Next(0, $shooters.Count)] | |
| [void]$State.EnemyBullets.Add([pscustomobject]@{ | |
| X = [double]($shooter.X + 2) | |
| Y = [double]($shooter.Y + 1) | |
| Speed = 13.0 + ([Math]::Min($State.Level, 6) * 0.8) | |
| }) | |
| } | |
| function Test-ShieldHit { | |
| param( | |
| $State, | |
| [int]$X, | |
| [int]$Y | |
| ) | |
| for ($index = $State.Shields.Count - 1; $index -ge 0; $index--) { | |
| $cell = $State.Shields[$index] | |
| if ($cell.X -eq $X -and $cell.Y -eq $Y) { | |
| $cell.HP-- | |
| if ($cell.HP -le 0) { | |
| $State.Shields.RemoveAt($index) | |
| } | |
| return $true | |
| } | |
| } | |
| return $false | |
| } | |
| function Test-PointInRect { | |
| param( | |
| [int]$X, | |
| [int]$Y, | |
| [double]$Left, | |
| [double]$Top, | |
| [int]$Width, | |
| [int]$Height | |
| ) | |
| return ($X -ge [Math]::Floor($Left) -and $X -lt ([Math]::Floor($Left) + $Width) -and $Y -ge [Math]::Floor($Top) -and $Y -lt ([Math]::Floor($Top) + $Height)) | |
| } | |
| function Advance-Wave { | |
| param($State) | |
| $State.Level++ | |
| Initialize-Shields -State $State | |
| Initialize-Wave -State $State | |
| } | |
| function Update-Stars { | |
| param( | |
| $State, | |
| [double]$Delta | |
| ) | |
| foreach ($star in $State.Stars) { | |
| $star.Y += ($star.Speed * $Delta) | |
| if ($star.Y -gt ($State.PlayBottom - 1)) { | |
| $star.Y = $State.PlayTop + 1 | |
| $star.X = [double]$script:Rng.Next(($State.PlayLeft + 1), $State.PlayRight) | |
| } | |
| } | |
| } | |
| function Update-Particles { | |
| param( | |
| $State, | |
| [double]$Delta | |
| ) | |
| for ($index = $State.Particles.Count - 1; $index -ge 0; $index--) { | |
| $particle = $State.Particles[$index] | |
| $particle.Life -= $Delta | |
| if ($particle.Life -le 0.0) { | |
| $State.Particles.RemoveAt($index) | |
| continue | |
| } | |
| $particle.X += ($particle.VX * $Delta) | |
| $particle.Y += ($particle.VY * $Delta) | |
| $particle.VY += (8.0 * $Delta) | |
| } | |
| } | |
| function Update-EnemyFormation { | |
| param( | |
| $State, | |
| [double]$Delta | |
| ) | |
| $State.EnemyMoveTimer -= $Delta | |
| if ($State.EnemyMoveTimer -gt 0.0) { | |
| return | |
| } | |
| $moveStep = 1 | |
| $needsDrop = $false | |
| foreach ($enemy in $State.Enemies) { | |
| $nextX = $enemy.X + ($State.EnemyDirection * $moveStep) | |
| if ($nextX -le ($State.PlayLeft + 1) -or ($nextX + $enemy.Width - 1) -ge ($State.PlayRight - 1)) { | |
| $needsDrop = $true | |
| break | |
| } | |
| } | |
| if ($needsDrop) { | |
| $State.EnemyDirection *= -1 | |
| foreach ($enemy in $State.Enemies) { | |
| $enemy.Y += 1 | |
| } | |
| } | |
| else { | |
| foreach ($enemy in $State.Enemies) { | |
| $enemy.X += ($State.EnemyDirection * $moveStep) | |
| } | |
| } | |
| foreach ($enemy in $State.Enemies) { | |
| if ($enemy.Y -ge ($State.PlayerY - 1)) { | |
| $State.GameOver = $true | |
| $State.BannerText = 'EARTH OVERRUN' | |
| $State.BannerSubText = 'Press R to restart or Q to quit.' | |
| $State.BannerTime = 999.0 | |
| return | |
| } | |
| } | |
| $speedBoost = [Math]::Min($State.Level * 0.02, 0.12) | |
| $densityBoost = [Math]::Min($State.Enemies.Count * 0.0035, 0.14) | |
| $State.EnemyMoveTimer = [Math]::Max(0.08, 0.34 - $speedBoost - $densityBoost) | |
| } | |
| function Update-Saucer { | |
| param( | |
| $State, | |
| [double]$Delta | |
| ) | |
| Try-SpawnSaucer -State $State | |
| if (-not $State.Saucer) { | |
| return | |
| } | |
| $State.Saucer.X += ($State.Saucer.VX * $Delta) | |
| if (($State.Saucer.VX -gt 0 -and $State.Saucer.X -gt $State.PlayRight + 5) -or ($State.Saucer.VX -lt 0 -and $State.Saucer.X -lt $State.PlayLeft - 7)) { | |
| $State.Saucer = $null | |
| } | |
| } | |
| function Update-Bullets { | |
| param( | |
| $State, | |
| [double]$Delta | |
| ) | |
| for ($index = $State.PlayerBullets.Count - 1; $index -ge 0; $index--) { | |
| $bullet = $State.PlayerBullets[$index] | |
| $bullet.Y -= ($bullet.Speed * $Delta) | |
| $hitX = [int][Math]::Round($bullet.X) | |
| $hitY = [int][Math]::Round($bullet.Y) | |
| if ($bullet.Y -lt ($State.PlayTop + 1)) { | |
| $State.PlayerBullets.RemoveAt($index) | |
| continue | |
| } | |
| if (Test-ShieldHit -State $State -X $hitX -Y $hitY) { | |
| $State.PlayerBullets.RemoveAt($index) | |
| continue | |
| } | |
| if ($State.Saucer -and (Test-PointInRect -X $hitX -Y $hitY -Left $State.Saucer.X -Top $State.Saucer.Y -Width $State.Saucer.Width -Height $State.Saucer.Height)) { | |
| $State.Score += $State.Saucer.Score | |
| Add-Explosion -State $State -X $bullet.X -Y $bullet.Y -Style $State.Styles.Saucer -Count 18 | |
| $State.Saucer = $null | |
| $State.PlayerBullets.RemoveAt($index) | |
| continue | |
| } | |
| $enemyHit = $false | |
| for ($enemyIndex = $State.Enemies.Count - 1; $enemyIndex -ge 0; $enemyIndex--) { | |
| $enemy = $State.Enemies[$enemyIndex] | |
| if (Test-PointInRect -X $hitX -Y $hitY -Left $enemy.X -Top $enemy.Y -Width $enemy.Width -Height 1) { | |
| $State.Score += $enemy.Score | |
| Add-Explosion -State $State -X $bullet.X -Y $bullet.Y -Style (Get-EnemyStyle -State $State -Row $enemy.Row) | |
| $State.Enemies.RemoveAt($enemyIndex) | |
| $State.PlayerBullets.RemoveAt($index) | |
| $enemyHit = $true | |
| break | |
| } | |
| } | |
| if ($enemyHit) { | |
| continue | |
| } | |
| } | |
| for ($index = $State.EnemyBullets.Count - 1; $index -ge 0; $index--) { | |
| $bullet = $State.EnemyBullets[$index] | |
| $bullet.Y += ($bullet.Speed * $Delta) | |
| $hitX = [int][Math]::Round($bullet.X) | |
| $hitY = [int][Math]::Round($bullet.Y) | |
| if ($bullet.Y -gt ($State.PlayBottom - 1)) { | |
| $State.EnemyBullets.RemoveAt($index) | |
| continue | |
| } | |
| if (Test-ShieldHit -State $State -X $hitX -Y $hitY) { | |
| $State.EnemyBullets.RemoveAt($index) | |
| continue | |
| } | |
| if ((Test-PointInRect -X $hitX -Y $hitY -Left $State.PlayerX -Top $State.PlayerY -Width 5 -Height 1) -and $State.PlayerInvulnerable -le 0.0) { | |
| $State.Lives-- | |
| $State.PlayerInvulnerable = 2.0 | |
| Add-Explosion -State $State -X $bullet.X -Y $bullet.Y -Style $State.Styles.PlayerWarn -Count 16 | |
| $State.EnemyBullets.RemoveAt($index) | |
| if ($State.Lives -le 0) { | |
| $State.GameOver = $true | |
| $State.BannerText = 'MISSION FAILED' | |
| $State.BannerSubText = 'Press R to restart or Q to quit.' | |
| $State.BannerTime = 999.0 | |
| } | |
| continue | |
| } | |
| } | |
| } | |
| function Update-Game { | |
| param( | |
| $State, | |
| [double]$Delta | |
| ) | |
| if ($State.Paused -or $State.GameOver) { | |
| Update-Stars -State $State -Delta ($Delta * 0.15) | |
| Update-Particles -State $State -Delta ($Delta * 0.35) | |
| return | |
| } | |
| $State.Ticks += $Delta | |
| $State.PlayerCooldown = [Math]::Max(0.0, $State.PlayerCooldown - $Delta) | |
| $State.PlayerInvulnerable = [Math]::Max(0.0, $State.PlayerInvulnerable - $Delta) | |
| $State.BannerTime = [Math]::Max(0.0, $State.BannerTime - $Delta) | |
| if ($script:Rng.NextDouble() -lt 0.45) { | |
| [void]$State.Particles.Add([pscustomobject]@{ | |
| X = [double]($State.PlayerX + 2) | |
| Y = [double]($State.PlayerY + 1) | |
| VX = (($script:Rng.NextDouble() * 2.0) - 1.0) * 2.0 | |
| VY = 4.0 + ($script:Rng.NextDouble() * 2.0) | |
| Life = 0.25 + ($script:Rng.NextDouble() * 0.15) | |
| MaxLife = 0.40 | |
| Style = $State.Styles.TitleA | |
| Glyph = '.' | |
| }) | |
| } | |
| Update-Stars -State $State -Delta $Delta | |
| Update-Particles -State $State -Delta $Delta | |
| Update-EnemyFormation -State $State -Delta $Delta | |
| Update-Saucer -State $State -Delta $Delta | |
| $State.EnemyFireTimer -= $Delta | |
| if ($State.EnemyFireTimer -le 0.0) { | |
| Fire-EnemyShot -State $State | |
| $State.EnemyFireTimer = [Math]::Max(0.18, 0.85 - ([Math]::Min($State.Level, 7) * 0.06) - ($script:Rng.NextDouble() * 0.2)) | |
| } | |
| Update-Bullets -State $State -Delta $Delta | |
| if ($State.Enemies.Count -eq 0 -and -not $State.GameOver) { | |
| Advance-Wave -State $State | |
| } | |
| } | |
| function Process-Input { | |
| param($State) | |
| if ($DemoFrames -gt 0) { | |
| return | |
| } | |
| while ([Console]::KeyAvailable) { | |
| $key = [Console]::ReadKey($true) | |
| switch ($key.Key) { | |
| 'LeftArrow' { | |
| $State.PlayerX = [Math]::Max($State.PlayLeft + 1, $State.PlayerX - 2) | |
| } | |
| 'RightArrow' { | |
| $State.PlayerX = [Math]::Min($State.PlayRight - 5, $State.PlayerX + 2) | |
| } | |
| 'A' { | |
| $State.PlayerX = [Math]::Max($State.PlayLeft + 1, $State.PlayerX - 2) | |
| } | |
| 'D' { | |
| $State.PlayerX = [Math]::Min($State.PlayRight - 5, $State.PlayerX + 2) | |
| } | |
| 'Spacebar' { | |
| Fire-PlayerShot -State $State | |
| } | |
| 'P' { | |
| $State.Paused = -not $State.Paused | |
| } | |
| 'R' { | |
| if ($State.GameOver) { | |
| Reset-GameState -State $State | |
| } | |
| } | |
| 'Escape' { | |
| $State.ExitRequested = $true | |
| } | |
| 'Q' { | |
| $State.ExitRequested = $true | |
| } | |
| } | |
| } | |
| } | |
| function Draw-Hud { | |
| param($State, $Buffer) | |
| $titlePalette = @($State.Styles.TitleA, $State.Styles.TitleB, $State.Styles.TitleC) | |
| Draw-NeonText -Buffer $Buffer -X 2 -Y 0 -Text 'SPACE INVADERS // POWERSHELL EDITION' -Palette $titlePalette -Offset ([int]($State.Ticks * 8)) | |
| $scoreText = "SCORE {0:D5} LIVES {1} WAVE {2}" -f $State.Score, $State.Lives, $State.Level | |
| Draw-Text -Buffer $Buffer -X 2 -Y 1 -Text $scoreText -Style $State.Styles.Accent | |
| $controls = 'A/D or arrows move SPACE fires P pause R restart after defeat Q quit' | |
| Draw-Text -Buffer $Buffer -X 2 -Y 2 -Text $controls.Substring(0, [Math]::Min($controls.Length, $State.Width - 4)) -Style $State.Styles.Border | |
| } | |
| function Draw-Playfield { | |
| param($State, $Buffer) | |
| for ($x = $State.PlayLeft; $x -le $State.PlayRight; $x++) { | |
| Set-Cell -Buffer $Buffer -X $x -Y $State.PlayTop -Glyph '-' -Style $State.Styles.Border | |
| Set-Cell -Buffer $Buffer -X $x -Y $State.PlayBottom -Glyph '-' -Style $State.Styles.Border | |
| } | |
| for ($y = $State.PlayTop; $y -le $State.PlayBottom; $y++) { | |
| Set-Cell -Buffer $Buffer -X $State.PlayLeft -Y $y -Glyph '|' -Style $State.Styles.Border | |
| Set-Cell -Buffer $Buffer -X $State.PlayRight -Y $y -Glyph '|' -Style $State.Styles.Border | |
| } | |
| Set-Cell -Buffer $Buffer -X $State.PlayLeft -Y $State.PlayTop -Glyph '+' -Style $State.Styles.Border | |
| Set-Cell -Buffer $Buffer -X $State.PlayRight -Y $State.PlayTop -Glyph '+' -Style $State.Styles.Border | |
| Set-Cell -Buffer $Buffer -X $State.PlayLeft -Y $State.PlayBottom -Glyph '+' -Style $State.Styles.Border | |
| Set-Cell -Buffer $Buffer -X $State.PlayRight -Y $State.PlayBottom -Glyph '+' -Style $State.Styles.Border | |
| foreach ($star in $State.Stars) { | |
| Set-Cell -Buffer $Buffer -X ([int][Math]::Round($star.X)) -Y ([int][Math]::Round($star.Y)) -Glyph $star.Glyph -Style $star.Style | |
| } | |
| foreach ($shieldCell in $State.Shields) { | |
| $glyph = switch ($shieldCell.HP) { | |
| 3 { '#' } | |
| 2 { '=' } | |
| default { '.' } | |
| } | |
| Set-Cell -Buffer $Buffer -X $shieldCell.X -Y $shieldCell.Y -Glyph $glyph -Style (Get-ShieldStyle -State $State -HP $shieldCell.HP) | |
| } | |
| $enemyFrame = if ((([int]($State.Ticks * 5)) % 2) -eq 0) { 0 } else { 1 } | |
| foreach ($enemy in $State.Enemies) { | |
| Draw-Text -Buffer $Buffer -X $enemy.X -Y $enemy.Y -Text (Get-EnemySprite -Row $enemy.Row -Frame $enemyFrame) -Style (Get-EnemyStyle -State $State -Row $enemy.Row) | |
| } | |
| if ($State.Saucer) { | |
| Draw-Text -Buffer $Buffer -X ([int][Math]::Round($State.Saucer.X)) -Y ([int][Math]::Round($State.Saucer.Y)) -Text '<==>' -Style $State.Styles.Saucer | |
| } | |
| foreach ($bullet in $State.PlayerBullets) { | |
| Set-Cell -Buffer $Buffer -X ([int][Math]::Round($bullet.X)) -Y ([int][Math]::Round($bullet.Y)) -Glyph '|' -Style $State.Styles.Bullet | |
| } | |
| foreach ($bullet in $State.EnemyBullets) { | |
| Set-Cell -Buffer $Buffer -X ([int][Math]::Round($bullet.X)) -Y ([int][Math]::Round($bullet.Y)) -Glyph '!' -Style $State.Styles.EnemyShot | |
| } | |
| foreach ($particle in $State.Particles) { | |
| $x = [int][Math]::Round($particle.X) | |
| $y = [int][Math]::Round($particle.Y) | |
| if ($x -gt $State.PlayLeft -and $x -lt $State.PlayRight -and $y -gt $State.PlayTop -and $y -lt $State.PlayBottom) { | |
| Set-Cell -Buffer $Buffer -X $x -Y $y -Glyph $particle.Glyph -Style $particle.Style | |
| } | |
| } | |
| if (($State.PlayerInvulnerable -le 0.0) -or ((([int]($State.Ticks * 18)) % 2) -eq 0)) { | |
| $playerSprite = if ((([int]($State.Ticks * 10)) % 2) -eq 0) { '/_A_\' } else { '\_^_/' } | |
| $playerStyle = if ($State.PlayerInvulnerable -gt 0.0) { $State.Styles.PlayerWarn } else { $State.Styles.Player } | |
| Draw-Text -Buffer $Buffer -X $State.PlayerX -Y $State.PlayerY -Text $playerSprite -Style $playerStyle | |
| } | |
| } | |
| function Draw-Overlay { | |
| param($State, $Buffer) | |
| if ($State.BannerTime -le 0.0 -and -not $State.Paused -and -not $State.GameOver) { | |
| return | |
| } | |
| $boxWidth = 38 | |
| $boxHeight = 6 | |
| $boxX = [Math]::Floor(($State.Width - $boxWidth) / 2) | |
| $boxY = [Math]::Floor(($State.Height - $boxHeight) / 2) | |
| Draw-Box -Buffer $Buffer -X $boxX -Y $boxY -Width $boxWidth -Height $boxHeight -Style $State.Styles.Overlay | |
| $headline = if ($State.Paused) { 'PAUSED' } else { $State.BannerText } | |
| $subline = if ($State.Paused) { 'Press P to continue.' } else { $State.BannerSubText } | |
| $headlineX = $boxX + [Math]::Floor(($boxWidth - $headline.Length) / 2) | |
| $sublineX = $boxX + [Math]::Floor(($boxWidth - $subline.Length) / 2) | |
| Draw-NeonText -Buffer $Buffer -X $headlineX -Y ($boxY + 2) -Text $headline -Palette @($State.Styles.TitleA, $State.Styles.TitleB, $State.Styles.TitleC) -Offset ([int]($State.Ticks * 7)) | |
| Draw-Text -Buffer $Buffer -X $sublineX -Y ($boxY + 3) -Text $subline -Style $State.Styles.Accent | |
| } | |
| function Draw-Frame { | |
| param($State) | |
| $buffer = New-Buffer -Width $State.Width -Height $State.Height | |
| Draw-Hud -State $State -Buffer $buffer | |
| Draw-Playfield -State $State -Buffer $buffer | |
| Draw-Overlay -State $State -Buffer $buffer | |
| return (Render-Buffer -Buffer $buffer) | |
| } | |
| function Start-SpaceInvaders { | |
| $windowWidth = 0 | |
| $windowHeight = 0 | |
| try { | |
| $windowWidth = [Console]::WindowWidth | |
| $windowHeight = [Console]::WindowHeight | |
| } | |
| catch { | |
| if ($DemoFrames -gt 0) { | |
| $windowWidth = 92 | |
| $windowHeight = 32 | |
| } | |
| else { | |
| Write-Host "Unable to access the interactive console. Run the game in a regular PowerShell terminal window." | |
| return | |
| } | |
| } | |
| if ($windowWidth -lt 78 -or $windowHeight -lt 30) { | |
| Write-Host "Please enlarge the terminal to at least 78x30, then run the game again." | |
| return | |
| } | |
| $width = [Math]::Min($windowWidth, 100) | |
| $height = [Math]::Min($windowHeight, 36) | |
| $state = New-GameState -Width $width -Height $height | |
| $frameCounter = 0 | |
| $clock = [System.Diagnostics.Stopwatch]::StartNew() | |
| $last = $clock.Elapsed | |
| try { | |
| try { | |
| [Console]::CursorVisible = $false | |
| } | |
| catch { | |
| } | |
| Write-Host -NoNewline "$($script:Escape)[2J$($script:Escape)[H" | |
| while (-not $state.ExitRequested) { | |
| $now = $clock.Elapsed | |
| $delta = ($now - $last).TotalSeconds | |
| if ($delta -gt 0.12) { | |
| $delta = 0.12 | |
| } | |
| $last = $now | |
| Process-Input -State $state | |
| Update-Game -State $state -Delta $delta | |
| $frame = Draw-Frame -State $state | |
| Write-Host -NoNewline "$($script:Escape)[H$frame" | |
| if ($DemoFrames -gt 0) { | |
| $frameCounter++ | |
| if ($frameCounter -ge $DemoFrames) { | |
| break | |
| } | |
| } | |
| Start-Sleep -Milliseconds $FrameDelayMs | |
| } | |
| } | |
| finally { | |
| Write-Host -NoNewline "$($script:ResetStyle)$($script:Escape)[?25h`n" | |
| try { | |
| [Console]::CursorVisible = $true | |
| } | |
| catch { | |
| } | |
| } | |
| } | |
| Start-SpaceInvaders |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment