29
\$\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 = "$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

  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.
\$\endgroup\$
8
  • 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\$
    – RubberDuck
    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\$
    – ArcherBird
    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\$
    – TinMan
    Commented Sep 2, 2020 at 4:04
  • 2
    \$\begingroup\$ +1 Simply awesome. Rubberduck to the help again! \$\endgroup\$ Commented Sep 2, 2020 at 19:46

0

Browse other questions tagged or ask your own question.