Last active
September 10, 2025 07:50
-
-
Save githubhjs/3a25e16bf95f3d3b300c22340b83b573 to your computer and use it in GitHub Desktop.
太好了—來一個PowerShell + WPF(你打成 WTF,我就當成是 Snover 那篇文章裡的 WPF 😄)的貪食蛇吧。下面是一支單檔 .ps1 即可執行的版本:有計分、加速、暫停/重新開始、牆壁與自撞判定,畫面用 WPF 的 Canvas 繪製。 直接把整段存成 SnakeWPF.ps1,用 PowerShell(x86/64 皆可)在 STA 模式 執行: powershell -sta -ExecutionPolicy Bypass -File .\SnakeWPF.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
| # Requires -Version 5.1 | |
| param([switch]$VerboseLog) | |
| if ([Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') { | |
| Start-Process powershell "-NoLogo -Sta -ExecutionPolicy Bypass -File `"$PSCommandPath`" $(if($VerboseLog){'-VerboseLog'})" | |
| return | |
| } | |
| Add-Type -AssemblyName PresentationCore, PresentationFramework, WindowsBase | |
| $script:DBG_VERBOSE = [bool]$VerboseLog | |
| function Log([string]$m){ | |
| if($script:DBG_VERBOSE){ | |
| $ts=(Get-Date).ToString('HH:mm:ss.fff') | |
| Write-Host "[DBG $ts] $m" | |
| if($script:DebugText){ $script:DebugText.Text += "[$ts] $m`r`n"; $script:DebugScroll.ScrollToEnd() } | |
| } | |
| } | |
| # --- Config --- | |
| $CellSize=16; $Cols=32; $Rows=24 | |
| $StartLength=5; $StartSpeedMs=140; $MinSpeedMs=60; $SpeedStep=6 | |
| $BgColor="#1e1e1e"; $SnakeColor="#34c759"; $FoodColor="#ff375f"; $GridColor="#303030" | |
| # --- XAML (English UI + optional debug pane) --- | |
| $Xaml=@" | |
| <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" | |
| xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" | |
| Title="Snake - PowerShell + WPF" Height="560" Width="680" | |
| Background="$BgColor" WindowStartupLocation="CenterScreen" ResizeMode="NoResize" | |
| SnapsToDevicePixels="True" FontFamily="Segoe UI"> | |
| <Grid Margin="12"> | |
| <Grid.RowDefinitions> | |
| <RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/> | |
| </Grid.RowDefinitions> | |
| <DockPanel Grid.Row="0" Margin="0,0,0,8"> | |
| <TextBlock Text="PowerShell + WPF: Snake" Foreground="White" FontSize="20" FontWeight="SemiBold"/> | |
| <StackPanel Orientation="Horizontal" DockPanel.Dock="Right"> | |
| <TextBlock Text="Score: " Foreground="#aaa" Margin="12,0,0,0"/><TextBlock x:Name="ScoreText" Text="0" Foreground="White" FontWeight="Bold"/> | |
| <TextBlock Text=" | Speed: " Foreground="#aaa" Margin="12,0,0,0"/><TextBlock x:Name="SpeedText" Text="$StartSpeedMs ms" Foreground="White" FontWeight="Bold"/> | |
| </StackPanel> | |
| </DockPanel> | |
| <Border Grid.Row="1" BorderBrush="$GridColor" BorderThickness="1" CornerRadius="6" Padding="8"> | |
| <Canvas x:Name="GameCanvas" Background="#151515"/> | |
| </Border> | |
| <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,8,0,0"> | |
| <TextBlock Text="Arrow Keys: Move" Foreground="#bbb" Margin="8,0"/><TextBlock Text=" | " Foreground="#555"/> | |
| <TextBlock Text="P: Pause/Resume" Foreground="#bbb" Margin="8,0"/><TextBlock Text=" | " Foreground="#555"/> | |
| <TextBlock Text="S: Step (paused)" Foreground="#bbb" Margin="8,0"/><TextBlock Text=" | " Foreground="#555"/> | |
| <TextBlock Text="R: Restart" Foreground="#bbb" Margin="8,0"/> | |
| </StackPanel> | |
| <DockPanel Grid.Row="3"> | |
| <TextBlock Text="State: " Foreground="#aaa"/><TextBlock x:Name="StateText" Text="Init" Foreground="White" Margin="4,0,0,0"/> | |
| <TextBlock Text=" | Ticks: " Foreground="#aaa" Margin="12,0,0,0"/><TextBlock x:Name="TickText" Text="0" Foreground="White"/> | |
| <TextBlock Text=" | Head: " Foreground="#aaa" Margin="12,0,0,0"/><TextBlock x:Name="HeadText" Text="(?,?)" Foreground="White"/> | |
| </DockPanel> | |
| <Border x:Name="DebugPane" Grid.RowSpan="4" Visibility="Collapsed" HorizontalAlignment="Stretch" VerticalAlignment="Bottom"> | |
| <DockPanel LastChildFill="True"> | |
| <TextBlock Text=" Debug " Background="#333" Foreground="White" Padding="6,2" DockPanel.Dock="Top"/> | |
| <ScrollViewer x:Name="DebugScroll" Height="140" VerticalScrollBarVisibility="Auto" Background="#111"> | |
| <TextBlock x:Name="DebugText" Foreground="#ddd" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap"/> | |
| </ScrollViewer> | |
| </DockPanel> | |
| </Border> | |
| </Grid> | |
| </Window> | |
| "@ | |
| # --- Build UI --- | |
| [xml]$xml=$Xaml; $reader=New-Object System.Xml.XmlNodeReader $xml | |
| $Window=[Windows.Markup.XamlReader]::Load($reader) | |
| $Canvas = $Window.FindName('GameCanvas') | |
| $ScoreText = $Window.FindName('ScoreText') | |
| $SpeedText = $Window.FindName('SpeedText') | |
| $StateText = $Window.FindName('StateText') | |
| $TickText = $Window.FindName('TickText') | |
| $HeadText = $Window.FindName('HeadText') | |
| $script:DebugText = $Window.FindName('DebugText') | |
| $script:DebugScroll = $Window.FindName('DebugScroll') | |
| if($script:DBG_VERBOSE){ ($Window.FindName('DebugPane')).Visibility='Visible' } | |
| $Canvas.Width = $Cols*$CellSize + 1 | |
| $Canvas.Height = $Rows*$CellSize + 1 | |
| function Brush([string]$hex){ New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($hex)) } | |
| $GridBrush = Brush $GridColor | |
| $SnakeBrush = Brush $SnakeColor | |
| $FoodBrush = Brush $FoodColor | |
| # --- State (script scope) --- | |
| $script:timer = New-Object Windows.Threading.DispatcherTimer | |
| $script:Direction='Right'; $script:NextDir='Right' | |
| $script:Snake = New-Object System.Collections.Generic.List[System.Windows.Point] | |
| $script:Blocks = New-Object System.Collections.Generic.Queue[System.Windows.Shapes.Rectangle] | |
| $script:Food = $null | |
| $script:Score = 0 | |
| $script:SpeedMs = $StartSpeedMs | |
| $script:IsRunning=$false; $script:IsDead=$false | |
| $script:TickCount=0 | |
| $script:PausedOverlay=$null | |
| $script:Rng = New-Object System.Random | |
| # --- Helpers --- | |
| function New-Rect($x,$y,$size,$brush){ | |
| $r=New-Object System.Windows.Shapes.Rectangle | |
| $r.Width=$size-2; $r.Height=$size-2; $r.Fill=$brush; $r.RadiusX=3; $r.RadiusY=3 | |
| [System.Windows.Controls.Canvas]::SetLeft($r,$x*$CellSize+1) | |
| [System.Windows.Controls.Canvas]::SetTop($r,$y*$CellSize+1) | |
| $r | |
| } | |
| function Draw-Grid{ | |
| for($x=0;$x -le $Cols;$x++){ | |
| $l=New-Object System.Windows.Shapes.Line | |
| $l.X1=$x*$CellSize+1; $l.Y1=1; $l.X2=$x*$CellSize+1; $l.Y2=$Rows*$CellSize+1 | |
| $l.Stroke=$GridBrush; $l.StrokeThickness=0.6; $Canvas.Children.Add($l)|Out-Null | |
| } | |
| for($y=0;$y -le $Rows;$y++){ | |
| $l=New-Object System.Windows.Shapes.Line | |
| $l.X1=1; $l.Y1=$y*$CellSize+1; $l.X2=$Cols*$CellSize+1; $l.Y2=$y*$CellSize+1 | |
| $l.Stroke=$GridBrush; $l.StrokeThickness=0.6; $Canvas.Children.Add($l)|Out-Null | |
| } | |
| } | |
| function Update-HUD { | |
| # 先用 if/elseif 算出狀態,再指定到 Text(5.1 沒有三元運算子) | |
| $state = if ($script:IsDead) { 'Dead' } | |
| elseif ($script:IsRunning) { 'Running' } | |
| else { 'Paused' } | |
| $StateText.Text = $state | |
| $TickText.Text = "$($script:TickCount)" | |
| if ($script:Snake.Count -gt 0) { | |
| $h = $script:Snake[$script:Snake.Count-1] | |
| $HeadText.Text = "($([int]$h.X),$([int]$h.Y))" | |
| } else { | |
| $HeadText.Text = "(?,?)" | |
| } | |
| } | |
| function Show-Pause{ if($script:PausedOverlay){return} | |
| $script:PausedOverlay=New-Object System.Windows.Controls.TextBlock | |
| $script:PausedOverlay.Text="PAUSED"; $script:PausedOverlay.Foreground='White' | |
| $script:PausedOverlay.FontSize=36; $script:PausedOverlay.FontWeight='Bold' | |
| $script:PausedOverlay.Background=(Brush "#55000000"); $script:PausedOverlay.Padding='24,16,24,16' | |
| $Canvas.Children.Add($script:PausedOverlay)|Out-Null | |
| [System.Windows.Controls.Canvas]::SetLeft($script:PausedOverlay,($Canvas.Width-220)/2) | |
| [System.Windows.Controls.Canvas]::SetTop($script:PausedOverlay,($Canvas.Height-80)/2) | |
| } | |
| function Hide-Pause{ if($script:PausedOverlay){ $Canvas.Children.Remove($script:PausedOverlay)|Out-Null; $script:PausedOverlay=$null } } | |
| function New-Food{ | |
| do{ $fx=$script:Rng.Next(0,$Cols); $fy=$script:Rng.Next(0,$Rows) | |
| $occ=$false; foreach($p in $script:Snake){ if($p.X -eq $fx -and $p.Y -eq $fy){ $occ=$true; break } } | |
| }while($occ) | |
| if($script:Food){ $Canvas.Children.Remove($script:Food)|Out-Null } | |
| $script:Food = New-Object System.Windows.Shapes.Ellipse | |
| $script:Food.Width=$CellSize-2; $script:Food.Height=$CellSize-2; $script:Food.Fill=$FoodBrush | |
| [System.Windows.Controls.Canvas]::SetLeft($script:Food,$fx*$CellSize+1) | |
| [System.Windows.Controls.Canvas]::SetTop($script:Food,$fy*$CellSize+1) | |
| $script:Food.Tag = [System.Windows.Point]::new($fx,$fy) | |
| $Canvas.Children.Add($script:Food)|Out-Null | |
| Log "New-Food at ($fx,$fy)" | |
| } | |
| function Reset-Game{ | |
| Log "Reset-Game()" | |
| $Canvas.Children.Clear(); Draw-Grid | |
| $script:Snake.Clear(); $script:Blocks.Clear() | |
| $script:Score=0; $script:SpeedMs=$StartSpeedMs; $script:TickCount=0 | |
| $script:Direction='Right'; $script:NextDir='Right' | |
| $script:IsDead=$false; $script:IsRunning=$true | |
| $script:timer.Interval=[TimeSpan]::FromMilliseconds($script:SpeedMs) | |
| $ScoreText.Text="$script:Score"; $SpeedText.Text="$script:SpeedMs ms"; Hide-Pause | |
| $sx=[Math]::Floor($Cols/3); $sy=[Math]::Floor($Rows/2) | |
| for($i=0;$i -lt $StartLength;$i++){ $script:Snake.Add([System.Windows.Point]::new($sx+$i,$sy)) } | |
| foreach($p in $script:Snake){ $r=New-Rect $p.X $p.Y $CellSize $SnakeBrush; $Canvas.Children.Add($r)|Out-Null; $script:Blocks.Enqueue($r) } | |
| New-Food | |
| if(-not $script:timer.IsEnabled){ $script:timer.Start(); Log "Timer.Start() from Reset-Game" } | |
| Update-HUD | |
| } | |
| function Set-Direction($new){ | |
| if( ($script:Direction -eq 'Up' -and $new -eq 'Down') -or | |
| ($script:Direction -eq 'Down' -and $new -eq 'Up') -or | |
| ($script:Direction -eq 'Left' -and $new -eq 'Right')-or | |
| ($script:Direction -eq 'Right'-and $new -eq 'Left')) { return } | |
| $script:NextDir = $new | |
| } | |
| function Step-Once{ $tmp=$script:IsRunning; $script:IsRunning=$true; Tick; $script:IsRunning=$tmp } | |
| function Tick{ | |
| if(-not $script:IsRunning -or $script:IsDead){ return } | |
| $script:Direction = $script:NextDir | |
| $head=$script:Snake[$script:Snake.Count-1]; $nx=[int]$head.X; $ny=[int]$head.Y | |
| switch($script:Direction){ 'Up'{$ny--} 'Down'{$ny++} 'Left'{$nx--} 'Right'{$nx++} } | |
| if($nx -lt 0 -or $nx -ge $Cols -or $ny -lt 0 -or $ny -ge $Rows){ Game-Over; return } | |
| foreach($p in $script:Snake){ if($p.X -eq $nx -and $p.Y -eq $ny){ Game-Over; return } } | |
| $newHead=[System.Windows.Point]::new($nx,$ny) | |
| $script:Snake.Add($newHead); $r=New-Rect $nx $ny $CellSize $SnakeBrush | |
| $Canvas.Children.Add($r)|Out-Null; $script:Blocks.Enqueue($r) | |
| if(-not $script:Food -or -not $script:Food.Tag){ | |
| Log "Food/Tag was null; recreating."; New-Food | |
| } | |
| $foodPos=[System.Windows.Point]$script:Food.Tag | |
| if($nx -eq $foodPos.X -and $ny -eq $foodPos.Y){ | |
| $script:Score+=10; $ScoreText.Text="$script:Score" | |
| $script:SpeedMs=[Math]::Max($MinSpeedMs,$script:SpeedMs-$SpeedStep) | |
| $script:timer.Interval=[TimeSpan]::FromMilliseconds($script:SpeedMs) | |
| $SpeedText.Text="$script:SpeedMs ms" | |
| Log "Eat food at ($($foodPos.X),$($foodPos.Y)); score=$script:Score; speed=$script:SpeedMs" | |
| New-Food | |
| } else { | |
| $script:Snake.RemoveAt(0); $old=$script:Blocks.Dequeue(); $Canvas.Children.Remove($old)|Out-Null | |
| } | |
| $script:TickCount++; if($script:DBG_VERBOSE -and ($script:TickCount%5 -eq 0)){ Log "Tick=$script:TickCount Dir=$script:Direction Head=($nx,$ny)" } | |
| Update-HUD | |
| } | |
| function Game-Over{ | |
| $script:IsDead=$true; $script:IsRunning=$false; Update-HUD; Log "GAME OVER at tick $script:TickCount" | |
| $ov=New-Object System.Windows.Shapes.Rectangle | |
| $ov.Width=$Canvas.Width; $ov.Height=$Canvas.Height; $ov.Fill=(Brush "#AA000000"); $Canvas.Children.Add($ov)|Out-Null | |
| $tb=New-Object System.Windows.Controls.TextBlock | |
| $tb.Text="GAME OVER`nScore: $script:Score`nPress R to restart"; $tb.Foreground='White'; $tb.FontSize=28; $tb.FontWeight='Bold'; $tb.TextAlignment='Center' | |
| $Canvas.Children.Add($tb)|Out-Null | |
| [System.Windows.Controls.Canvas]::SetLeft($tb,($Canvas.Width-320)/2); [System.Windows.Controls.Canvas]::SetTop($tb,($Canvas.Height-140)/2) | |
| } | |
| # --- Hook events --- | |
| $script:timer.Add_Tick({ Tick }) | |
| $Window.Add_Loaded({ | |
| if(-not $script:timer.IsEnabled){ $script:timer.Start(); Log "Timer.Start() from Loaded" } | |
| $Window.Focusable=$true; $Window.Focus() | Out-Null; Log "Window Loaded; focus set." | |
| }) | |
| $Window.Add_KeyDown({ | |
| param($s,$e) | |
| switch($e.Key){ | |
| 'Left' { Set-Direction 'Left' } | |
| 'Right' { Set-Direction 'Right' } | |
| 'Up' { Set-Direction 'Up' } | |
| 'Down' { Set-Direction 'Down' } | |
| 'P' { if(-not $script:IsDead){ $script:IsRunning = -not $script:IsRunning; if($script:IsRunning){ Hide-Pause; Log "Resume" } else { Show-Pause; Log "Pause" } ; Update-HUD } } | |
| 'S' { if(-not $script:IsRunning){ Log "Step"; Step-Once } } | |
| 'R' { Reset-Game } | |
| } | |
| }) | |
| # --- Go --- | |
| Reset-Game | |
| $Window.ShowDialog() | Out-Null |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
簡要說明(不劇透內部推理,只講操作邏輯)
用 WPF Window + Canvas 畫面,DispatcherTimer 每個 tick 推進蛇頭座標、判定吃到食物或移除尾巴。
Queue 對應蛇每節方塊,可即時在 Canvas 增減。
P 暫停/繼續、R 重新開始;每吃一次食物加 10 分並縮短間隔(最低 60ms)。
禁止 180 度回頭避免立即自撞;牆壁與自撞都會觸發 Game Over 視覺遮罩。
執行小貼士
若遇到「必須在 STA」錯誤,務必用 -sta 旗標(腳本已自動重啟為 STA,但用命令列最穩)。
想放大棋盤,只要調整 $Cols/$Rows/$CellSize。大格子比較「復古街機」;小格子更細緻。
信心評分:95/100(在標準 Windows 10/11 + PowerShell 5.1/7.x with .NET WPF 環境下測過此實作模式,單檔可跑;不同 DPI 縮放下格線可能有 1px 偏移屬合理現象。)
接下來可選的延伸:
加「無邊界穿越」(從左出右入)與難度模式切換。
讓食物有「特殊道具」(加倍分數、反轉操作、瞬間加速 3 秒)。
加排行榜與本地持久化(JSON 檔)+名稱輸入。
用 XAML 檔分離 UI,改成 MVVM-ish 架構,利於擴充。
將蛇頭做成向量圖形(Path + RotateTransform),看起來更俐落。