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).
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 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.
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.
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).
'@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
'@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
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
'@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
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!!!
- 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.