Public/Start-Snake.ps1
Function Start-Snake { #Requires -Version 5.1 Using Assembly PresentationCore Using Assembly PresentationFramework Using Namespace System.Collections.Generic Using Namespace System.ComponentModel Using Namespace System.Linq Using Namespace System.Reflection Using Namespace System.Text Using Namespace System.Windows Using Namespace System.Windows.Input Using Namespace System.Windows.Markup Using Namespace System.Windows.Media Using Namespace System.Windows.Threading Set-StrictMode -Version Latest [Int32] $boardWidth = 20 [Int32] $boardHeight = 15 [Int32] $fieldSizePixels = 30 [Int32] $stepsMilliseconds = 75 Class ViewModel : INotifyPropertyChanged { Hidden [PropertyChangedEventHandler] $PropertyChanged [Int32] $BoardWidthPixels [Int32] $BoardHeightPixels [Int32] $FieldDisplaySizePixels [Int32] $HalfFieldDisplaySizePixels [Int32] $Score [Object] $SnakeGeometry [Object] $FoodCenter [Boolean] $GameOverVisible [Boolean] $WonVisible [Void] add_PropertyChanged([PropertyChangedEventHandler] $propertyChanged) { $this.PropertyChanged = [Delegate]::Combine($this.PropertyChanged, $propertyChanged) } [Void] remove_PropertyChanged([PropertyChangedEventHandler] $propertyChanged) { $this.PropertyChanged = [Delegate]::Remove($this.PropertyChanged, $propertyChanged) } Hidden [Void] NotifyPropertyChanged([String] $propertyName) { If ($this.PropertyChanged -cne $null) { $this.PropertyChanged.Invoke($this, (New-Object PropertyChangedEventArgs $propertyName)) } } [Void] SetScore([Int32] $score) { If ($this.Score -cne $score) { $this.Score = $score $this.NotifyPropertyChanged('Score'); } } [Void] SetSnakeGeometry([Object] $snakeGeometry) { If ($this.SnakeGeometry -cne $snakeGeometry) { $this.SnakeGeometry = $snakeGeometry $this.NotifyPropertyChanged('SnakeGeometry') } } [Void] SetFoodCenter([Object] $foodCenter) { If ($this.FoodCenter -cne $foodCenter) { $this.FoodCenter = $foodCenter $this.NotifyPropertyChanged('FoodCenter') } } [Void] SetGameOverVisible([Boolean] $gameOverVisible) { If ($this.GameOverVisible -cne $gameOverVisible) { $this.GameOverVisible = $gameOverVisible $this.NotifyPropertyChanged('GameOverVisible') } } [Void] SetWonVisible([Boolean] $wonVisible) { If ($this.WonVisible -cne $wonVisible) { $this.WonVisible = $wonVisible $this.NotifyPropertyChanged('WonVisible') } } } Enum SnakeDirection { Left Right Up Down } Enum SnakeAction { Nothing Collision FoodEaten } Class SnakeSegment { [Int32] $Length [SnakeDirection] $Direction SnakeSegment([Int32] $length, [SnakeDirection] $direction) { $this.Length = $length $this.Direction = $direction } [String] GetGeometryOperation([Int32] $fieldSizePixels) { [String] $directionChar = @('h', 'v')[$this.Direction -gt [SnakeDirection]::Right] [Int32] $directionFactor = $this.Direction % 2 * 2 - 1 Return "$directionChar $($this.Length * $fieldSizePixels * $directionFactor)" } } Class Snake { Hidden [Int32] $BoardWidth Hidden [Int32] $BoardHeight Hidden [Int32] $FieldSizePixels [Int32] $HeadX [Int32] $HeadY [Int32] $TailX [Int32] $TailY [List[SnakeSegment]] $Segments [SnakeDirection] $Direction Snake([Int32] $boardWidth, [Int32] $boardHeight, [Int32] $fieldSizePixels) { $this.BoardWidth = $boardWidth $this.BoardHeight = $boardHeight $this.FieldSizePixels = $fieldSizePixels $this.Reset() } [Void] Reset() { $this.TailX = $this.BoardWidth / 2 - 2 $this.TailY = $this.BoardHeight / 2 $this.HeadX = $this.TailX + 4 $this.HeadY = $this.TailY $this.Segments = New-Object List[SnakeSegment] $this.Segments.Add((New-Object SnakeSegment 4, 'Right')) $this.Direction = 'Right' } [String] GetGeometryString() { [StringBuilder] $geometry = New-Object StringBuilder $geometry.Append("m $($this.TailX * $this.FieldSizePixels + $this.FieldSizePixels / 2) $($this.TailY * $this.FieldSizePixels + $this.FieldSizePixels / 2)") ForEach ($segment In $this.Segments) { $geometry.Append($segment.GetGeometryOperation($this.FieldSizePixels)) } Return $geometry.ToString() } [HashSet[Tuple[Int32, Int32]]] GetPoints() { [HashSet[Tuple[Int32, Int32]]] $points = New-Object 'HashSet[Tuple[Int32, Int32]]' [Int32] $x = $this.TailX [Int32] $y = $this.TailY $points.Add((New-Object 'Tuple[Int32, Int32]' $x, $y)) ForEach ($segment In $this.Segments) { 1 .. $segment.Length ` | ForEach-Object { Switch ($segment.Direction) { 'Left' { $x-- } 'Right' { $x++ } 'Up' { $y-- } 'Down' { $y++ } } $points.Add((New-Object 'Tuple[Int32, Int32]' $x, $y)) } } return $points } [SnakeAction] Move([Food] $food) { [Int32] $currentHeadX = $this.HeadX [Int32] $currentHeadY = $this.HeadY # Move the head. Switch ($this.Direction) { 'Left' { $this.HeadX-- } 'Right' { $this.HeadX++ } 'Up' { $this.HeadY-- } 'Down' { $this.HeadY++ } } # Check OOB. If ($this.HeadX -lt 0 -or $this.HeadX -ge $this.BoardWidth -or $this.HeadY -lt 0 -or $this.HeadY -ge $this.BoardHeight) { $this.HeadX = $currentHeadX $this.HeadY = $currentHeadY return [SnakeAction]::Collision } # Check collision. [HashSet[Tuple[Int32, Int32]]] $points = $this.GetPoints() If ($points.Contains((New-Object 'Tuple[Int32, Int32]' $this.HeadX, $this.HeadY))) { $this.HeadX = $currentHeadX $this.HeadY = $currentHeadY return [SnakeAction]::Collision } # Check food. [SnakeAction] $result = @([SnakeAction]::Nothing, [SnakeAction]::FoodEaten)[ $this.HeadX -ceq $food.FoodX -and $this.HeadY -ceq $food.FoodY ] # Handle head segment. [SnakeSegment] $headSegment = $this.Segments[-1] If ($headSegment.Direction -ceq $this.Direction) { $headSegment.Length++ } Else { $this.Segments.Add((New-Object SnakeSegment 1, $this.Direction)) } # Handle tail segment. If ($result -cne 'FoodEaten') { [SnakeSegment] $tailSegment = $this.Segments[0] $tailSegment.Length-- Switch ($tailSegment.Direction) { 'Left' { $this.TailX-- } 'Right' { $this.TailX++ } 'Up' { $this.TailY-- } 'Down' { $this.TailY++ } } If ($tailSegment.Length -ceq 0) { $this.Segments.RemoveAt(0) } } Return $result } } Class Food { Hidden [Int32] $FieldSizePixels Hidden [Random] $Random Hidden [HashSet[Tuple[Int32, Int32]]] $AllValidPoints [Int32] $FoodX [Int32] $FoodY Food([Int32] $boardWidth, [Int32] $boardHeight, [Int32] $fieldSizePixels) { $this.FieldSizePixels = $fieldSizePixels $this.Random = New-Object Random $this.AllValidPoints = New-Object 'HashSet[Tuple[Int32, Int32]]' For ([Int32] $x = 0; $x -lt $boardWidth; $x++) { For ([Int32] $y = 0; $y -lt $boardHeight; $y++) { $this.AllValidPoints.Add((New-Object 'Tuple[Int32, Int32]' $x, $y)) } } } [Tuple[Int32, Int32]] GetGeometryLocation() { Return New-Object 'Tuple[Int32, Int32]' ` ($this.FoodX * $this.FieldSizePixels + $this.FieldSizePixels / 2), ($this.FoodY * $this.FieldSizePixels + $this.FieldSizePixels / 2) } [Boolean] Move([Snake] $snake) { [HashSet[Tuple[Int32, Int32]]] $availablePoints = New-Object 'HashSet[Tuple[Int32, Int32]]' $this.AllValidPoints $availablePoints.ExceptWith($snake.GetPoints()) If ($availablePoints.Count -ceq 0) { Return $true } [Tuple[Int32, Int32]] $foodPoint = [Enumerable]::ElementAt($availablePoints, $this.Random.Next($availablePoints.Count)) $this.FoodX = $foodPoint.Item1 $this.FoodY = $foodPoint.Item2 Return $false } } [Window] $mainWindow = [XamlReader]::Parse(@' <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="{Binding Score, StringFormat={}Snake - {0}}" SizeToContent="WidthAndHeight" ResizeMode="NoResize" > <Window.Resources> <BooleanToVisibilityConverter x:Key="VisibilityConverter" /> </Window.Resources> <Grid Margin="5"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> <DockPanel Grid.Row="0" LastChildFill="True" Margin="0 0 0 5"> <TextBlock DockPanel.Dock="Left" Text="{Binding Score, StringFormat={}Score: {0}}" Margin="0 0 5 0" /> <TextBlock DockPanel.Dock="Left" Text="GAME OVER" FontWeight="Bold" Visibility="{Binding GameOverVisible, Converter={StaticResource VisibilityConverter}}" /> <TextBlock DockPanel.Dock="Left" Text="YOU WON" FontWeight="Bold" Foreground="DarkGreen" Visibility="{Binding WonVisible, Converter={StaticResource VisibilityConverter}}" /> <TextBlock Text="Use arrow keys to move, Enter to reset." TextAlignment="Right" /> </DockPanel> <Border Grid.Row="1" BorderBrush="Black" BorderThickness="1"> <Canvas Width="{Binding BoardWidthPixels}" Height="{Binding BoardHeightPixels}"> <Path Stroke="DarkGreen" StrokeThickness="{Binding FieldDisplaySizePixels}" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round" Data="{Binding SnakeGeometry}" /> <Path Fill="DarkRed"> <Path.Data> <EllipseGeometry Center="{Binding FoodCenter}" RadiusX="{Binding HalfFieldDisplaySizePixels}" RadiusY="{Binding HalfFieldDisplaySizePixels}" /> </Path.Data> </Path> </Canvas> </Border> </Grid> </Window> '@) [ViewModel] $viewModel = New-Object ViewModel -Property @{ BoardWidthPixels = $boardWidth * $fieldSizePixels BoardHeightPixels = $boardHeight * $fieldSizePixels FieldDisplaySizePixels = $fieldSizePixels - 2 HalfFieldDisplaySizePixels = ($fieldSizePixels - 2) / 2 } $mainWindow.DataContext = $viewModel [DispatcherTimer] $timer = New-Object DispatcherTimer -Property @{ Interval = New-Object TimeSpan 0, 0, 0, 0, $stepsMilliseconds } [Snake] $snake = New-Object Snake $boardWidth, $boardHeight, $fieldSizePixels [Food] $food = New-Object Food $boardWidth, $boardHeight, $fieldSizePixels $food.Move($snake) | Out-Null Function Update-View() { $viewModel.SetSnakeGeometry([Geometry]::Parse($snake.GetGeometryString())) [Tuple[Int32, Int32]] $foodLocation = $food.GetGeometryLocation() $viewModel.SetFoodCenter((New-Object Point $foodLocation.Item1, $foodLocation.Item2)) } $mainWindow.add_Loaded( { Update-View $timer.Start() }) $timer.Tag = [SnakeAction]::Nothing $timer.add_Tick( { [SnakeAction] $action = $snake.Move($food) Switch ($action) { 'Collision' { if ($timer.Tag -ceq 'Collision') { $viewModel.SetGameOverVisible($true) $timer.Stop() } Break } 'FoodEaten' { $viewModel.SetScore($viewModel.Score + 1) If ($food.Move($snake)) { $viewModel.SetWonVisible($true) $timer.Stop() } Break } } Update-View $timer.Tag = $action }) [EventManager]::RegisterClassHandler([Window], [Keyboard]::KeyDownEvent, [KeyEventHandler] { Param ([Object] $sender, [KeyEventArgs] $eventArgs) Switch ($eventArgs.Key) { 'Left' { If ($snake.Segments[-1].Direction -cne 'Right') { $snake.Direction = 'Left' } Break } 'Right' { If ($snake.Segments[-1].Direction -cne 'Left') { $snake.Direction = 'Right' } Break } 'Up' { If ($snake.Segments[-1].Direction -cne 'Down') { $snake.Direction = 'Up' } Break } 'Down' { If ($snake.Segments[-1].Direction -cne 'Up') { $snake.Direction = 'Down' } Break } 'Return' { $snake.Reset() $food.Move($snake) $viewModel.SetScore(0) $viewModel.SetGameOverVisible($false) $viewModel.SetWonVisible($false) Update-View $timer.Start() Break } 'Q' { If (-not $timer.IsEnabled) { 'Cheater ;)' | Out-Host $viewModel.SetGameOverVisible($false) $timer.Start() } Break } } }) [Application] $application = New-Object Application $application.Run($mainWindow) | Out-Null $timer.Stop() } |