Multithreaded Excel + VBScript Timer
After reviewing these post: Snake in Excel in… VBA? and Drawing a snake using arrow keys in Excel using VBA, I decided to work on creating a VBA Timer.
The typical Timers patterns used in the VBA have serious drawbacks.
- API Sleep - Stops the code execution until the sleep period is over
- API Callback - Will abruptly quit Excel if you are editing a cell, in break mode or if you are not holding your mouth right
- Do While True Loops - Uses a lot of resources. Generally, one of your CPUs usage will go dramatically increase. Considering that the VBA isn't multithreaded this is a significant drawback
Download Multithreaded Excel VBScript Timer - Snake Game.xlsm
This Gif was recorded at 15 fps per second. The Game was set to Updates every 100ms. The actual game is smoother that the GIF.
This Timer seems to be functioning quite well without any of the drawbacks listed above. I don't like the fact that it requires requires a callback function in a public module, but don't think that there is any way around it. I might end getting rid of the Timer class altogether and just use a single public module.
I'm looking for ways to improve the Timer and would apreciate any feedback
Public Module: Callback and setSheetView Function
Public Function Timer_Tick(Optional KeyCode As Integer) As Boolean
If Not Game Is Nothing Then
Game.Timer1.Update
Timer_Tick = True
End If
End Function
Public Sub setSheetView(Optional Maximize As Boolean)
Application.DisplayFullScreen = Maximize
ActiveWindow.DisplayHeadings = Not Maximize
ActiveWindow.DisplayGridlines = Not Maximize
Application.DisplayFormulaBar = Not Maximize
If Maximize Then
Application.Cursor = xlIBeam
wsMap.Protect UserInterfaceOnly:=True, DrawingObjects:=True, Contents:=True, Scenarios:=True
wsMap.EnableSelection = xlNoSelection
Else
Application.Cursor = xlDefault
wsMap.Unprotect
End If
End Sub
Class: TimerClass
Option Explicit
'MULTITHREADING VBA USING VBSCRIPT: http://analystcave.com/excel-multithreading-in-vba-using-vbscript/
Private Declare Function GetAsyncKeyState Lib "user32" (ByVal vKey As Long) As Integer
Private mKeysPressed(1 To 127) As Boolean
Public Event Update(KeysPressed() As Boolean)
Public isRunning As Boolean
Public Speed As Long
Public TimerScript As String
Public Sub Stop_Timer()
isRunning = False
DeleteScript
End Sub
Public Sub Start_Timer()
isRunning = True
RunScript
End Sub
Private Sub RunScript()
Dim fso As Object, wsh As Object
Set fso = CreateObject("Scripting.FileSystemObject")
DeleteScript
TimerScript = fso.BuildPath(Environ("Temp"), Replace(fso.GetTempName, "tmp", "vbs"))
With fso.CreateTextFile(TimerScript, True)
.WriteLine "On Error Resume Next"
.WriteLine "Const ScriptFullName = " & Chr(34) & TimerScript & Chr(34)
.WriteLine "Const wbFullName = " & Chr(34) & ThisWorkbook.FullName & Chr(34)
.WriteLine "Dim wb"
.WriteLine "Set wb = WScript.GetObject(wbFullName)"
.WriteLine "If Err.Number <> 0 Then DeleteMe"
.WriteLine "Do While wb.Application.Run(""Timer_Tick"")"
.WriteLine " WScript.Sleep(" & Speed & ")"
.WriteLine " If Err.Number <> 0 Then DeleteMe"
.WriteLine "Loop"
.WriteLine "DeleteMe"
.WriteLine "Sub DeleteMe"
.WriteLine " On Error Resume Next"
.WriteLine " Dim fso"
.WriteLine " Set fso = CreateObject(""Scripting.FileSystemObject"")"
.WriteLine " fso.DeleteFile ScriptFullName,True"
.WriteLine " WScript.Quit"
.WriteLine "End Sub"
.Close
' Debug.Print TimerScript
' Debug.Print fso.OpenTextFile(TimerScript).ReadAll
End With
Set wsh = VBA.CreateObject("WScript.Shell")
On Error Resume Next
wsh.Run """" & TimerScript & """"
On Error GoTo 0
Set wsh = Nothing
End Sub
Private Sub DeleteScript()
On Error Resume Next
If Len(Dir(TimerScript)) > 0 And Len(TimerScript) > 0 Then CreateObject("Scripting.FileSystemObject").DeleteFile TimerScript, True
On Error GoTo 0
End Sub
Public Sub Update()
Dim x As Long
For x = LBound(mKeysPressed) To UBound(mKeysPressed)
mKeysPressed(x) = GetAsyncKeyState(x)
Next
RaiseEvent Update(mKeysPressed)
End Sub
wsMap Sheet Module: Start Button
Private Sub btnStart_Click()
If Game Is Nothing Then
Set Game = New GameClass
Set Game.Button = btnStart
Game.Start
Else
Set Game = Nothing
End If
End Sub
Class: GameClass
Option Explicit
Private colOffset As Long, rowOffset As Long
Private Direction As Integer
Private Food As Variant
Private FoodCount As Long
Private Foods As Object
Private GrassMap As Range
Private Grass_Color As Long
Private Head As Range
Private Segments As Object
Private SnakeHead As String
Private SnakeSegment As String
Private SpawnLocations As Object
Private Water_Color As Long
Public WithEvents Timer1 As TimerClass
Public Button As MSForms.CommandButton
'OBJECT ORIENTED VBA: DESIGN PATTERNS: THE SINGLETON (https://hammondmason.wordpress.com/2015/07/15/object-oriented-vba-design-patterns-the-singleton/)
Private Sub Class_Initialize()
Application.ScreenUpdating = False
If Not Game Is Nothing Then
Err.Raise 1 + vbObjectError, "Factory.Instantiate", "Instantiation failed. There already exists an instance of Game Loop"
Exit Sub
End If
Set Game = Me
'Initiate Objects
Set Foods = CreateObject("System.Collections.ArrayList")
Set Segments = CreateObject("System.Collections.ArrayList")
Set SpawnLocations = CreateObject("System.Collections.ArrayList")
Set Timer1 = New TimerClass
'Initiate Settings
Food = Range("Food").Value
FoodCount = Range("FoodCount").Value
Grass_Color = Range("Grass").Interior.Color
Water_Color = Range("Water").Interior.Color 'Unsupported
SnakeSegment = Chr(173)
SnakeHead = Chr(233)
Set Head = wsMap.Range(Range("StartAddress").Value)
CenterOnCell Head
'Defines Playable Area
Init_Lists
'Apply Settings
Timer1.Speed = Range("Speed").Value * 100
Range("Length").Value = 1
Range("Updates").Value = 0
'Spawn Food
Spawn_Food
Head.Value = SnakeHead
Application.ScreenUpdating = True
End Sub
Private Sub Class_Terminate()
setSheetView False
Timer1.Stop_Timer
MsgBox "Game Over", vbInformation, ""
Button.Caption = "Start"
End Sub
'Based on Chip Pearson: http://www.cpearson.com/excel/zoom.htm
Private Sub CenterOnCell(cell As Range)
Dim ScrollColumn As Long, ScrollRow As Long
On Error Resume Next
With ActiveWindow.VisibleRange
ActiveWindow.ScrollColumn = cell.Column - .Columns.Count / 2
ActiveWindow.ScrollRow = cell.Row - .Rows.Count / 2
End With
On Error GoTo 0
End Sub
Function getRange(IList As Object) As Range
Dim cell As Variant, Target As Range
If TypeName(cell) = "String" Then Set cell = wsMap.Range(cell)
For Each cell In IList
If Target Is Nothing Then
Set Target = cell
Else
Set Target = Union(Target, cell)
End If
Next
Set getRange = Target
End Function
Private Sub Init_Lists()
Dim cell As Range
For Each cell In wsMap.UsedRange
If cell.Interior.Color = Grass_Color Then
If GrassMap Is Nothing Then
Set GrassMap = cell
Else
Set GrassMap = Union(GrassMap, cell)
End If
SpawnLocations.Add cell
cell.ClearContents
End If
Next
End Sub
Public Sub Spawn_Food()
Dim n As Long
Dim list As Object, cell As Range
Set list = SpawnLocations.Clone
Do Until list.Count = 0 Or Foods.Count = FoodCount
n = (list.Count * Rnd)
Set cell = list(n)
If Len(cell.Value) = 0 Then
cell.Value = Food
Foods.Add cell.Address
End If
Loop
End Sub
Public Sub Start()
Button.Caption = "Stop"
Timer1.Start_Timer
setSheetView True
End Sub
Private Sub Timer1_Update(KeysPressed() As Boolean)
With Timer1
If KeysPressed(vbKeyEscape) Then
Set Game = Nothing
ElseIf KeysPressed(vbKeyRight) And KeysPressed(vbKeyUp) Then
colOffset = 1: rowOffset = -1: SnakeHead = Chr(236)
ElseIf KeysPressed(vbKeyRight) And KeysPressed(vbKeyDown) Then
colOffset = 1: rowOffset = 1: SnakeHead = Chr(238)
ElseIf KeysPressed(vbKeyLeft) And KeysPressed(vbKeyUp) Then
colOffset = -1: rowOffset = -1: SnakeHead = Chr(235)
ElseIf KeysPressed(vbKeyLeft) And KeysPressed(vbKeyDown) Then
colOffset = -1: rowOffset = 1: SnakeHead = Chr(237)
ElseIf KeysPressed(vbKeyLeft) Then
colOffset = -1: rowOffset = 0: SnakeHead = Chr(231)
ElseIf KeysPressed(vbKeyRight) Then
colOffset = 1: rowOffset = 0: SnakeHead = Chr(232)
ElseIf KeysPressed(vbKeyDown) Then
colOffset = 0: rowOffset = 1: SnakeHead = Chr(234)
ElseIf KeysPressed(vbKeyUp) Then
colOffset = 0: rowOffset = -1: SnakeHead = Chr(233)
End If
End With
If Head.Row + rowOffset < 1 Or Head.Column + colOffset < 1 _
Or Head.Row + rowOffset > wsMap.Rows.Count Or Head.Column + colOffset > wsMap.Columns.Count Then
Set Game = Nothing
ElseIf Intersect(Head.Offset(rowOffset, colOffset), GrassMap) Is Nothing Then
Set Game = Nothing
Else
If Segments.Count > 0 Then If Not Intersect(Head.Offset(rowOffset, colOffset), getRange(Segments)) Is Nothing Then Set Game = Nothing
Segments.Add Head
getRange(Segments).ClearContents
With Head.Offset(rowOffset, colOffset)
If Not .Value = Food Then
Segments.RemoveAt 0
Spawn_Food
End If
Set Head = .Cells
End With
If Segments.Count > 0 Then getRange(Segments).Value = SnakeSegment
Range("Length").Value = Segments.Count + 1
Head.Value = SnakeHead
CenterOnCell Head
End If
Range("Updates").Value = Range("Updates").Value + 1
End Sub