30
\$\begingroup\$

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 will 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)

MVC diagram of classes

Rubbberduck folder structure

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 = "$D3ドル"
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

  1. Everyone's favorite VBE addin, Rubberduck!
  2. This SO answer which got me thinking about all this VBA OOP viability in the first place.
  3. This excel version of Battleship from which I have mimicked the Adapter-events-passing pattern.
  4. Pacman Dossier has very detailed analysis of the inner workings of pacman.
Sᴀᴍ Onᴇᴌᴀ
29.5k16 gold badges45 silver badges201 bronze badges
asked Sep 1, 2020 at 21:14
\$\endgroup\$
7
  • 7
    \$\begingroup\$ "[...] and I, perhaps, have a masochistic fondness for the framework. VBA comes with a host of challenges [...] that I seek to overcome." - this, right there. So much this! #NotAlone ;-) ...would love to see this on GitHub! \$\endgroup\$ Commented Sep 1, 2020 at 23:13
  • 5
    \$\begingroup\$ This is awesome. Not only is it Pac-Man, you included an architecture diagram. Chef’s Kiss \$\endgroup\$ Commented Sep 2, 2020 at 0:01
  • 2
    \$\begingroup\$ @RubberDuck thank you for the love! I'm glad to not be the only one enthusiastic about something as silly as this! <3 \$\endgroup\$ Commented Sep 2, 2020 at 0:31
  • 2
    \$\begingroup\$ You're off to a great start. Have you read Understanding Pac-Man Ghost Behavior? \$\endgroup\$ Commented Sep 2, 2020 at 4:04
  • 1
    \$\begingroup\$ @MathieuGuindon repo added! I'm curious to see how well it will work on a machine other than my own! \$\endgroup\$ Commented Sep 2, 2020 at 12:58

0

Know someone who can answer? Share a link to this question via email, Twitter, or Facebook.

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.