Skip to content

Instantly share code, notes, and snippets.

@nikonthethird
Last active April 18, 2024 00:19
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nikonthethird/2ab6bfad9a81d5fe127fd0d1c2844b7c to your computer and use it in GitHub Desktop.
Save nikonthethird/2ab6bfad9a81d5fe127fd0d1c2844b7c to your computer and use it in GitHub Desktop.
PowerShell script for playing 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()
@bluikko
Copy link

bluikko commented Aug 17, 2020

Excellent (only?) example of INotifyPropertyChanged in PowerShell! Helped me get binding working in PowerShell.

Still trying to get ValidationRule working, seems like your minesweeper will help for that.

@AmbassadorLaZer
Copy link

AmbassadorLaZer commented Feb 10, 2021

@nikonthethird New-Object : Exception calling ".ctor" with "0" argument(s): "Cannot create more than one
System.Windows.Application instance in the same AppDomain."
At C:\Users******PC\Documents\Snake.ps1:417 char:30

  • [Application] $application = New-Object Application
  •                          ~~~~~~~~~~~~~~~~~~~~~~
    
    • CategoryInfo : InvalidOperation: (:) [New-Object], MethodInvocationException
    • FullyQualifiedErrorId : ConstructorInvokedThrowException,Microsoft.PowerShell.Commands.NewObjec
      tCommand

The variable '$application' cannot be retrieved because it has not been set.
At C:\Users******PC\Documents\Snake.ps1:418 char:1

  • $application.Run($mainWindow) | Out-Null
  •   + CategoryInfo          : InvalidOperation: (application:String) [], RuntimeException
      + FullyQualifiedErrorId : VariableIsUndefined
    

@Redsloth
Copy link

@nikonthethird New-Object : Exception calling ".ctor" with "0" argument(s): "Cannot create more than one
System.Windows.Application instance in the same AppDomain."
At C:\Users******PC\Documents\Snake.ps1:417 char:30

  • [Application] $application = New-Object Application

  •                          ~~~~~~~~~~~~~~~~~~~~~~
    
    • CategoryInfo : InvalidOperation: (:) [New-Object], MethodInvocationException
    • FullyQualifiedErrorId : ConstructorInvokedThrowException,Microsoft.PowerShell.Commands.NewObjec
      tCommand

The variable '$application' cannot be retrieved because it has not been set.
At C:\Users******PC\Documents\Snake.ps1:418 char:1

  • $application.Run($mainWindow) | Out-Null
  •   + CategoryInfo          : InvalidOperation: (application:String) [], RuntimeException
      + FullyQualifiedErrorId : VariableIsUndefined
    

I got these as well when using Powershell IDE but no errors when running direct.

@mi4c
Copy link

mi4c commented Dec 10, 2021

@nikonthethird
Replace the lines
417 and 418
With this this
$mainWindow.ShowDialog() | Out-Null
[GC]::Collect()

And you can reopen the snake multiple times...
Anyway nice example script 👍

@MattClarke-ESi
Copy link

@nikonthethird Replace the lines 417 and 418 With this this $mainWindow.ShowDialog() | Out-Null [GC]::Collect()

And you can reopen the snake multiple times... Anyway nice example script 👍

Perfect, thank you.

@mi4c
Copy link

mi4c commented Apr 18, 2024

@MattClarke-ESi if you want to see some more powershell fun... I have made few public.
https://github.com/mi4c/Posh3d_cube_ball

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment