What?, Why?
I have been inspired by several other posts on the topic of OOP implementations in VBA to try and create a Pacman clone. I think this task is not all that hard in most languages; but, I first learned how to code via VBA and I, perhaps, have a masochistic fondness for the framework. VBA comes with a host of challenges (lack of inheritance, single thread environment, etc) that I seek to overcome.
One of my main goals with an OOP implementation is to have the game logic decoupled from the UI so that one could implement a UI as an Excel Worksheet, or a Userform, or whatever else you might imagine available to you in VBA. The other goal is to get as close to the real game rules as I can.
There is going to be a lot to go over, so I hope you don't mind me breaking this into multiple code review posts, each with some smaller scope of focus. For this post, I'd like to get feedback on my overall architecture plan, and give you a very general look at the game logic class and how I intend to make it interface-able to a UI. (The first UI implementation will be an Excel Worksheet).
Architecture
The design will resemble an MVP pattern with the idea that the View part of the architecture can be implemented in any number of ways. Again, in this case, I will implement an Excel Worksheet-based view for the game. I've included a (likely incomplete) diagram to help illustrate as well as my Rubberduck folder structure (also incomplete)
Models
Models will consist of the various game elements. In a truly decoupled design, these would be simple POVOs(?), but I plan to encapsulate some game logic into the models because these models won't ever have much use outside of the context of the Pacman game and it will make the game controller a little simpler. For example, characters like pacman and the ghosts will know how to move around in the maze. That way, the controller can simply call a Move()
member in each of them. I will save the models' code for another code review post.
View
I don't really care too much about having a super flexible, independent view; so in my design, any view implemented for the Pacman game will know about the models. This will make passing data to the view much simpler because we can just pass the entire model(s). The game controller will talk to the view through an interface layer. The idea is that the View will implement the IGameEventsHandler
interface so that the Controller can call methods in the View as game events happen. The View will also have a reference to an IViewEventsHandler
class so that it can call event methods to notify the Controller of user generated events.
Controller
The controller will hold much of the game logic and will facilitate the continuous ticking of the game progression. Because of how events are done in VBA, I have an extra ViewAdapter
class that will help facilitate listening to events from the View. When user generated things happen, the View can call IViewEventsHandler
methods in the concrete ViewAdapter
class, which will, in turn, raise an event to the controller. This way, events from the View can "interrupt" the game ticking in the controller thanks to DoEvents
calls that will happen with every tick. (This is step 1 in overcoming our single-thread limitation).
Code Samples
Interfaces
IGameEventsHandler:
'@Folder "PacmanGame.View"
'@Interface
'@ModuleDescription("Methods that the Controller will need to be able to call in the UI. These are things the Controller will need to tell the UI to do.")
Option Explicit
Private Const mModuleName As String = "IGameEventsHandler"
'@Description("Provides a way for the ViewAdapter to hook itself into an IGameEventsHandler implementer")
Public Property Get Events() As IViewEventsHandler
End Property
Public Property Set Events(ByVal value As IViewEventsHandler)
End Property
Public Sub CreateMap(map() As Tile)
End Sub
Public Sub CreatePacman(character As PacmanModel)
End Sub
Public Sub CreateGhost(character As GhostModel)
End Sub
Public Sub UpdateComponents(gamePieces As Collection)
End Sub
Private Sub Class_Initialize()
Err.Raise 5, mModuleName, "Interface class must not be instantiated."
End Sub
IViewEventsHandler:
'@Folder "PacmanGame.View"
'@Interface
'@ModuleDescription("Methods that the UI can call to notify the controller of user interaction. These are events from the UI that the Controller wants to hear about")
Option Explicit
Private Const mModuleName As String = "IViewEventsHandler"
Public Enum KeyCode
LeftArrow = 37
RightArrow = 39
UpArrow = 38
DownArrow = 40
End Enum
Public Sub OnDirectionalKeyPress(vbKey As KeyCode)
End Sub
Public Sub OnGameStarted()
End Sub
Public Sub OnGamePaused()
End Sub
Public Sub OnQuit()
End Sub
Private Sub Class_Initialize()
Err.Raise 5, mModuleName, "Interface class must not be instantiated."
End Sub
WorksheetViewWrapper
This is a facade class that will wrap an Excel.Worksheet
to use as our UI. This code could go directly into a worksheet class, but alas, you cannot make a worksheet Implement
anything in the code-behind.
'@Folder "ViewImplementations.ExcelWorksheet"
'//UI implemented as an Excel Worksheet
Option Explicit
Implements IGameEventsHandler
Private Const MAP_START_ADDRESS As String = "$D$3"
Private Type TWorksheetViewWrapper
MapRange As Range
dPad As Range
Adapter As IViewEventsHandler
ShapeWrappers As Dictionary
YIndexOffset As Long
XIndexOffset As Long
End Type
Private WithEvents innerWs As Worksheet
Private this As TWorksheetViewWrapper
Public Sub Init(xlWs As Worksheet)
Dim s As Shape
For Each s In xlWs.Shapes
s.Delete
Next
xlWs.Activate
xlWs.Range("AE65").Select
Set innerWs = xlWs
Set this.dPad = xlWs.Range("AE65")
End Sub
Private Sub Class_Initialize()
Set this.ShapeWrappers = New Dictionary
End Sub
Private Sub Class_Terminate()
Set this.Adapter = Nothing
Set innerWs = Nothing
Set this.dPad = Nothing
Debug.Print TypeName(Me) & " terminating..."
End Sub
'// Support for IGameEventsHandler
Private Sub IGameEventsHandler_CreateGhost(character As GhostModel)
'// Create a corrosponding ViewModel Ghost
Dim newGhostShape As New GhostStyler
newGhostShape.Init innerWs, character.Color
'// Add him to the drawing collection
this.ShapeWrappers.Add character.Name, newGhostShape
End Sub
Private Sub IGameEventsHandler_CreatePacman(character As PacmanModel)
'// Create a corrosponding ViewModel Pacman
Dim newPacmanShape As New PacmanStyler
newPacmanShape.Init innerWs
'// Add him to the drawing collection
this.ShapeWrappers.Add character.Name, newPacmanShape
End Sub
Private Sub IGameEventsHandler_CreateMap(map() As Tile)
this.YIndexOffset = 1 - LBound(map, 1)
this.XIndexOffset = 1 - LBound(map, 2)
Set this.MapRange = innerWs.Range(MAP_START_ADDRESS).Resize(UBound(map, 1) + this.YIndexOffset, UBound(map, 2) + this.XIndexOffset)
End Sub
Private Sub IGameEventsHandler_UpdateComponents(characters As Collection)
Dim character As IGamePiece
Dim characterShape As IDrawable
Dim i As Integer
For Each character In characters
'// use the id from each character to get the corresponding ShapeWrapper
Set characterShape = this.ShapeWrappers.Item(character.Id)
characterShape.Redraw character.CurrentHeading, TileToRange(character.CurrentTile)
Next
End Sub
Private Property Set IGameEventsHandler_Events(ByVal RHS As IViewEventsHandler)
Set this.Adapter = RHS
End Property
Private Property Get IGameEventsHandler_Events() As IViewEventsHandler
Set IGameEventsHandler_Events = this.Adapter
End Property
'// Events from the worksheet that we will translate into view events
Private Sub innerWs_Activate()
'// maybe pause the game?
End Sub
Private Sub innerWs_Deactivate()
'// maybe we need a resume game event?
End Sub
Private Sub innerWs_SelectionChange(ByVal Target As Range)
If this.dPad.Offset(-1, 0).Address = Target.Address Then
this.Adapter.OnDirectionalKeyPress UpArrow
ElseIf this.dPad.Offset(1, 0).Address = Target.Address Then
this.Adapter.OnDirectionalKeyPress (DownArrow)
ElseIf this.dPad.Offset(0, -1).Address = Target.Address Then
this.Adapter.OnDirectionalKeyPress (LeftArrow)
ElseIf this.dPad.Offset(0, 1).Address = Target.Address Then
this.Adapter.OnDirectionalKeyPress (RightArrow)
End If
Application.EnableEvents = False
this.dPad.Select
Application.EnableEvents = True
End Sub
'// Private helpers
Private Function TileToRange(mapTile As Tile) As Range
Set TileToRange = this.MapRange.Cells(mapTile.y + this.YIndexOffset, mapTile.x + this.XIndexOffset)
End Function
Adapter
'@Folder "PacmanGame.View"
Option Explicit
Implements IViewEventsHandler
Implements IGameEventsHandler
Private Const mModuleName As String = "ViewAdapter"
Private viewUI As IGameEventsHandler
Public Event DirectionalKeyPressed(vbKeyCode As KeyCode)
Public Event GameStarted()
Public Event GamePaused()
Public Event Quit()
Public Sub Init(inViewUI As IGameEventsHandler)
Set viewUI = inViewUI
Set viewUI.Events = Me
End Sub
Public Sub Deconstruct()
'// unhooks itself from the GameEventsHandler to prevent memory leakage
Set viewUI.Events = Nothing
End Sub
Public Function AsCommandSender() As IGameEventsHandler
'// allows access to the IGameEventsHandler methods
Set AsCommandSender = Me
End Function
Private Sub Class_Terminate()
Set viewUI = Nothing
Debug.Print TypeName(Me) & " terminating..."
End Sub
'//IGameEventsHandler Support
Private Property Set IGameEventsHandler_Events(ByVal RHS As IViewEventsHandler)
'//this isn't meant to be set from the outside for this class
End Property
Private Property Get IGameEventsHandler_Events() As IViewEventsHandler
Set IGameEventsHandler_Events = Me
End Property
Private Sub IGameEventsHandler_CreateGhost(character As GhostModel)
viewUI.CreateGhost character
End Sub
Private Sub IGameEventsHandler_CreatePacman(character As PacmanModel)
viewUI.CreatePacman character
End Sub
Private Sub IGameEventsHandler_CreateMap(map() As Tile)
viewUI.CreateMap map
End Sub
Private Sub IGameEventsHandler_UpdateComponents(characters As Collection)
viewUI.UpdateComponents characters
End Sub
'//IViewEventsHandler Support
Private Sub IViewEventsHandler_OnDirectionalKeyPress(vbKey As KeyCode)
RaiseEvent DirectionalKeyPressed(vbKey)
End Sub
Private Sub IViewEventsHandler_OnGamePaused()
RaiseEvent GamePaused
End Sub
Private Sub IViewEventsHandler_OnGameStarted()
RaiseEvent GameStarted
End Sub
Private Sub IViewEventsHandler_OnQuit()
RaiseEvent Quit
End Sub
Controller
This class is obviously a WIP, but I've included it here to show how the Controller uses a ViewAdapter to send/receive messages to/from the View
'@Folder "PacmanGame.Controller"
'@Exposed
Option Explicit
Private Const mModuleName As String = "GameController"
Private Const SECONDS_PER_TICK As Double = 0.06 '// sets a minimum amount of time (in seconds) that will pass between game ticks
Private Const TICK_CYCLE_RESOLUTION As Double = 10 '// helps faciliate game pieces moving at different speeds
Public WithEvents UIAdapter As ViewAdapter
Public Enum Direction
dNone = 0
dUp = -1
dDown = 1
dLeft = -2
dRight = 2
End Enum
'//Encasulated Fields
Private Type TGameController
IsGameOver As Boolean
Maze() As Tile
TickCounter As Long
Ghosts As Collection
GamePieces As Collection
Player As PacmanModel
End Type
Private this As TGameController
Public Sub StartGame()
'// this is here to temporarily provide a way for me to kick off the game from code
UIAdapter_GameStarted
End Sub
Private Sub Class_Initialize()
Set this.GamePieces = New Collection
End Sub
Private Sub Class_Terminate()
Debug.Print TypeName(Me) & " terminating..."
Set this.GamePieces = Nothing
UIAdapter.Deconstruct
Erase this.Maze
Erase MapManager.Maze
Set UIAdapter = Nothing
End Sub
'// This is the main engine of the game that is called repeatedly until the game is over
Private Sub Tick()
Dim t As Double
t = Timer
Dim character As IGamePiece
For Each character In this.GamePieces
If character.CycleRemainder >= TICK_CYCLE_RESOLUTION Then
character.CycleRemainder = character.CycleRemainder Mod TICK_CYCLE_RESOLUTION
character.Move
Else
If this.TickCounter Mod Round(TICK_CYCLE_RESOLUTION / (TICK_CYCLE_RESOLUTION * (1 - character.Speed)), 0) <> 0 Then
character.CycleRemainder = character.CycleRemainder + TICK_CYCLE_RESOLUTION Mod (TICK_CYCLE_RESOLUTION * (1 - character.Speed))
character.Move
End If
If Round(TICK_CYCLE_RESOLUTION / (TICK_CYCLE_RESOLUTION * (1 - character.Speed)), 0) = 1 Then
character.CycleRemainder = character.CycleRemainder + TICK_CYCLE_RESOLUTION Mod (TICK_CYCLE_RESOLUTION * (1 - character.Speed))
End If
End If
Next
'// TODO: check if player died and/or there is a game over... account for player Lives > 1
'If this.Player.IsDead Then IsGameOver = True
'// update the view
UIAdapter.AsCommandSender.UpdateComponents this.GamePieces
'// ensure a minimum amount of time has passed
Do
DoEvents
Loop Until Timer > t + SECONDS_PER_TICK
End Sub
'//ViewEvents Handling
Private Sub UIAdapter_DirectionalKeyPressed(vbKeyCode As KeyCode)
Select Case vbKeyCode
Case KeyCode.UpArrow
this.Player.Heading = dUp
Case KeyCode.DownArrow
this.Player.Heading = dDown
Case KeyCode.LeftArrow
this.Player.Heading = dLeft
Case KeyCode.RightArrow
this.Player.Heading = dRight
End Select
End Sub
Private Sub UIAdapter_GameStarted()
'// TODO: unbloat this a bit!
'// initialize vars
'//scoreboard
'//
'// initialize game peices
Dim blinky As GhostModel
Dim inky As GhostModel
Dim pinky As GhostModel
Dim clyde As GhostModel
'// set up maze
this.Maze = MapManager.LoadMapFromFile
MapManager.Maze = this.Maze
UIAdapter.AsCommandSender.CreateMap this.Maze
'// set up pacman
Set this.Player = New PacmanModel
Set this.Player.CurrentTile = MapManager.GetMazeTile(46, 30)
this.GamePieces.Add this.Player
UIAdapter.AsCommandSender.CreatePacman this.Player
'// set up ghosts
Set blinky = BuildGhost("Blinky", vbRed, MapManager.GetMazeTile(22, 30), ShadowBehavior.Create(this.Player))
this.GamePieces.Add blinky
UIAdapter.AsCommandSender.CreateGhost blinky
Set pinky = BuildGhost("Pinky", rgbLightPink, MapManager.GetMazeTile(22, 20), SpeedyBehavior.Create(this.Player))
this.GamePieces.Add pinky
UIAdapter.AsCommandSender.CreateGhost pinky
Set inky = BuildGhost("Inky", vbCyan, MapManager.GetMazeTile(22, 34), BashfulBehavior.Create(this.Player, blinky))
this.GamePieces.Add inky
UIAdapter.AsCommandSender.CreateGhost inky
Set clyde = BuildGhost("Clyde", rgbOrange, MapManager.GetMazeTile(22, 37), RandomBehavior.Create())
this.GamePieces.Add clyde
UIAdapter.AsCommandSender.CreateGhost clyde
'//play intro
this.TickCounter = 0
Do While Not this.IsGameOver
'DoEvents
'If TickCounter = MaxCycles Then TickCounter = 0
this.TickCounter = this.TickCounter + 1
Tick
'DoEvents
Loop
End Sub
'//Private Helpers
Private Function BuildGhost(Name As String, _
Color As Long, _
startTile As Tile, behavior As IGhostBehavior) As GhostModel
Dim newGhost As GhostModel
Set newGhost = New GhostModel
With newGhost
.Name = Name
.Color = Color
Set .CurrentTile = startTile
Set .ActiveBehavior = behavior
End With
Set BuildGhost = newGhost
End Function
Private Sub BuildGameBoard()
UIAdapter.AsCommandSender.CreateMap Me.Maze
End Sub
Client - putting it all together:
Here is some sample code that illustrates how some client code might snap all the pieces together to have a functioning game.
Public Sub Main()
'//get our concrete sheet
Dim xlWs As Worksheet
Set xlWs = Sheet1
'//wrap it up
Dim sheetWrapper As WorksheetViewWrapper
Set sheetWrapper = New WorksheetViewWrapper
sheetWrapper.Init xlWs
'//give it to a game adapter
Dim viewUIAdapter As ViewAdapter
Set viewUIAdapter = New ViewAdapter
viewUIAdapter.Init sheetWrapper
'//hand that to a new controller
Set mController = New GameController
Set mController.UIAdapter = viewUIAdapter
'//start the game!
mController.StartGame
End Sub
I welcome any critiques on my architecture plan, naming conventions, and even nit picks! One of my specific questions is this: at some point I need to configure the game controller by setting its player, viewAdapter, ghosts, map, etc properties. It seems to me that the ViewAdapter should be injected from the outside. Should the other components also be injected? or should I just let the controller configure all of these internally?
I have published my entire project to a github repo so that you can build and run what I have working so far. There are so many parts in this project, so please forgive me as I attempt to balance completeness with overbroadness in my posts. In forthcoming posts, I plan to ask for code review on these topics: Moving game piece models and moving them at different speeds, map/maze building and interaction, animating game action in the view, and probably some others as I further development. Thank you for reading this whole thing!!!
Acknowledgements
- Everyone's favorite VBE addin, Rubberduck!
- This SO answer which got me thinking about all this VBA OOP viability in the first place.
- This excel version of Battleship from which I have mimicked the Adapter-events-passing pattern.
- Pacman Dossier has very detailed analysis of the inner workings of pacman.