我受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真棒。再次向橡皮鸭求助!