什么?为什么?
我受VBA中OOP实现主题的其他几篇文章启发,尝试创建Pacman克隆。我认为在大多数语言中,这项任务并不困难。但是,我首先学习了如何通过VBA进行编码,也许我对框架有一种自虐的爱好。 VBA带来了我要克服的许多挑战(缺乏继承,单线程环境等)。
我对OOP实现的主要目标之一是使游戏逻辑与UI分离,从而使可以将用户界面实现为Excel工作表或用户表单,也可以将其想象为在VBA中可用的任何形式。另一个目标是尽可能地接近真实的游戏规则。
还有很多事情要做,所以我希望您不要介意我将其分解为多个代码审查文章,每个文章具有较小的关注范围。对于本篇文章,我希望获得有关总体体系结构计划的反馈,并让您对游戏逻辑类以及如何使它与UI成为界面有一个大致的了解。 (第一个UI实现将是Excel工作表。)
体系结构
该设计将类似于MVP模式,其思想是可以以多种方式实现体系结构的View部分。同样,在这种情况下,我将为游戏实现一个基于Excel工作表的视图。我提供了一个(可能不完整的)图表来帮助说明以及我的Rubberduck文件夹结构(同样不完整)


模型
模型将包含各种游戏元素。在真正分离的设计中,这些将是简单的POVOs(?),但我计划将一些游戏逻辑封装到模型中,因为这些模型在吃豆子游戏的上下文之外不会有太多用途,它将使游戏成为现实。控制器要简单一些。例如,吃豆人和幽灵等角色会知道如何在迷宫中四处走动。这样,控制器可以简单地在每个控制器中调用一个Move()成员。我将模型的代码保存在另一篇代码审查文章中。
查看
我不太在乎拥有超级灵活,独立的视图;因此,在我的设计中,为吃豆子游戏实现的任何视图都将了解模型。这将使传递数据到视图变得更加简单,因为我们可以传递整个模型。游戏控制器将通过界面层与视图对话。这个想法是,View将实现IGameEventsHandler接口,以便控制器可以在游戏事件发生时调用View中的方法。该视图还将引用一个IViewEventsHandler类,以便它可以调用事件方法以将用户生成的事件通知控制器。
控制器
控制器将保留很多游戏逻辑,并有助于持续进行游戏。游戏进度的滴答声。由于事件是在VBA中完成的,因此我有一个额外的ViewAdapter类,该类将有助于促进从View侦听事件。当发生用户生成的事情时,View可以在具体的IViewEventsHandler类中调用ViewAdapter方法,该类又将向控制器引发一个事件。这样,由于每次滴答都会发生DoEvents调用,因此来自View的事件可以“打断”控制器中的滴答声。 (这是克服单线程限制的第1步。)
代码示例
接口
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
这是一个外观类,它将包装Excel.Worksheet用作我们的UI。这段代码可以直接进入工作表类,但可惜,您无法在隐藏的代码中创建工作表。
'@Folder "ViewImplementations.ExcelWorksheet"
'//UI implemented as an Excel Worksheet
Option Explicit
Implements IGameEventsHandler

Private Const MAP_START_ADDRESS As String = "$D"

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

控制器
此类显然是WIP,但我在此处包括了该类,以显示Controller如何使用ViewAdapter向/从View发送消息/从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

客户端-将它们放在一起:
这里有一些示例代码,说明了一些客户端代码如何将所有部分拼凑在一起,以使游戏正常运行。
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

我欢迎对我的体系结构计划,命名约定甚至挑剔的任何批评。 !我的具体问题之一是:在某个时候,我需要通过设置游戏控制器的播放器,viewAdapter,ghost,map等属性来配置游戏控制器。在我看来,ViewAdapter应该从外部注入。是否还应该注入其他成分?还是我应该让控制器在内部配置所有这些?
我已经将整个项目发布到github仓库中,以便您可以构建和运行到目前为止的工作。这个项目有很多部分,因此请原谅我在我的文章中尝试平衡完整性和泛滥程度。在即将发布的帖子中,我计划要求对以下主题进行代码审查:移动游戏模型并以不同的速度移动它们,地图/迷宫构建和交互,在视图中对游戏动作进行动画处理,以及可能在我进一步开发时进行一些其他操作。感谢您阅读本书!
致谢

每个人最喜欢的VBE插件,Rubberduck!

这个答案让我开始思考所有VBA OOP首先是生存力。

我模仿的Adapter-events-passing模式是这艘战舰的excel版本。

Pacman Dossier对内部工作原理进行了非常详细的分析pacman的作品。


评论

“也许我对这个框架有一种自虐的爱好。VBA伴随着我要克服的许多挑战。” -这个,就在那里。这么多! #NotAlone ;-) ...很想在GitHub上看到它!

这太棒了。它不仅是Pac-Man,还包括体系结构图。厨师之吻

@RubberDuck谢谢你的爱!我很高兴自己不是唯一一个喜欢这种愚蠢事物的人! <3

您的开端很好。您是否已阅读《了解吃豆人的幽灵行为》?

+1真棒。再次向橡皮鸭求助!