Now that we have defined our model, we need a view. In MVC terms, the view is the component that’s making the game state visible to the player; it is responsible for the two-way communication with the controller. Since we’re in Microsoft Excel, we can use a worksheet to do this. So we craft a lovely-looking Battleship game screen:
I used a stock image for the background, spent more time than I probably should have looking for images of the game ships, and used a number of rounded rectangle shapes to make various boxes and buttons – the clickable ones being attached to sheet-local macros. The two game grids use a customized 5-icon conditional format that not-so-coincidentally map to the GridState enum values:
If you recall from the previous post, the
GridState enum was defined as follows:
Public Enum GridState '@Description("Content at this coordinate is unknown.") Unknown = -1 '@Description("Unconfirmed friendly ship position.") PreviewShipPosition = 0 '@Description("Confirmed friendly ship position.") ShipPosition = 1 '@Description("Unconfirmed invalid/overlapping ship position.") InvalidPosition = 2 '@Description("No ship at this coordinate.") PreviousMiss = 3 '@Description("An enemy ship occupies this coordinate.") PreviousHit = 4 End Enum
PlayerGrid class has a
StateArray read-only property that returns a 2D variant array with
Unknown values being
Empty, and the rest of the state values being returned as-is: this means in order to “refresh” the view, all we need to do is dump this 2D variant array onto the appropriate game grid, and we’re done!
Private Property Get PlayerGrid(ByVal gridId As Byte) As Range Set PlayerGrid = Me.Names("PlayerGrid" & gridId).RefersToRange End Property Public Sub RefreshGrid(ByVal grid As PlayerGrid) Application.ScreenUpdating = False Me.Unprotect PlayerGrid(grid.gridId).Value = Application.WorksheetFunction.Transpose(grid.StateArray) Me.Protect Me.EnableSelection = xlUnlockedCells Application.ScreenUpdating = True End Sub
Listing all the code here like I did for the model post would be rather boring, so I’m not going to do that. If the model was just a handful of classes with factory methods and explicit interfaces, the view is much more interesting as a concept.
The worksheet handles 3 worksheet events:
Private Sub Worksheet_BeforeDoubleClick(ByVal target As Range, ByRef Cancel As Boolean) Cancel = True Dim gridId As Byte Dim position As IGridCoord Set position = RangeToGridCoord(target, gridId) If Mode = FleetPosition Or Mode = player1 And gridId = 2 Or Mode = player2 And gridId = 1 Then RaiseEvent DoubleClick(gridId, position, Mode) End If End Sub Private Sub Worksheet_BeforeRightClick(ByVal target As Range, Cancel As Boolean) Cancel = True If Mode = FleetPosition Then Dim gridId As Byte Dim position As IGridCoord Set position = RangeToGridCoord(target, gridId) RaiseEvent RightClick(gridId, position, Mode) End If End Sub Private Sub Worksheet_SelectionChange(ByVal target As Range) Dim gridId As Byte Dim position As IGridCoord Set position = RangeToGridCoord(target, gridId) If Not position Is Nothing Then Me.Unprotect CurrentSelectionGrid(gridId).Value = position.ToA1String CurrentSelectionGrid(IIf(gridId = 1, 2, 1)).Value = Empty Me.Protect Me.EnableSelection = xlUnlockedCells RaiseEvent SelectionChange(gridId, position, Mode) End If End Sub
Notice these aren’t doing anything really – they merely work out a way to talk to another component – see, making a worksheet (or any document module class) implement an interface is a very bad idea: don’t do it (unless you like to crash the host and lose everything). So instead, we make another class implement the “view” interfaces, and make that class talk to the worksheet – a bit like we did in There is no worksheet.
The view needs two interfaces: one for the controller to send messages to the view, and the other for the view to send messages to the controller. If we call controller-to-view messages “commands”, and view-to-controller messages “events”, then the names
IGridViewCommands make complete sense!
WorksheetView class (not the
GameSheet worksheet) implements the
IGridViewCommands interface, like this:
Private Sub IGridViewCommands_OnBeginAttack(ByVal currentPlayerGridId As Byte) sheetUI.ShowInfoBeginAttackPhase currentPlayerGridId End Sub Private Sub IGridViewCommands_OnBeginShipPosition(ByVal currentShip As IShip, ByVal player As IPlayer) sheetUI.ShowInfoBeginDeployShip currentShip.Name End Sub Private Sub IGridViewCommands_OnBeginWaitForComputerPlayer() Application.Cursor = xlWait Application.StatusBar = "Please wait..." End Sub
WorksheetView class also handles the custom events sent from the worksheet, like this:
Private Sub sheetUI_DoubleClick(ByVal gridId As Byte, ByVal position As IGridCoord, ByVal Mode As ViewMode) Select Case Mode Case ViewMode.FleetPosition ViewEvents.ConfirmShipPosition gridId, position Case ViewMode.player1, ViewMode.player2 ViewEvents.AttackPosition gridId, position End Select End Sub Private Sub sheetUI_PlayerReady() ViewEvents.HumanPlayerReady End Sub Private Sub sheetUI_RightClick(ByVal gridId As Byte, ByVal position As IGridCoord, ByVal Mode As ViewMode) If Mode = FleetPosition Then ViewEvents.PreviewRotateShip gridId, position End Sub Private Sub sheetUI_SelectionChange(ByVal gridId As Byte, ByVal position As IGridCoord, ByVal Mode As ViewMode) If Mode = FleetPosition Then ViewEvents.PreviewShipPosition gridId, position End Sub
So what is this
ViewEvents? If VBA allowed an interface to expose events, we wouldn’t need it: we would simply raise an event to relay the message directly to the controller, who would then handle the view events and respond with a command back to the view. But VBA does not let us expose events on an interface, so this is where the adapter pattern kicks in.
We have a
GridViewAdapter class that implements both
IGridViewCommands interfaces; the
WorksheetView holds a (weak) reference to the adapter, through its
IGridViewEvents interface – so
ViewEvents.AttackPosition is a method on the adapter.
GridViewAdapter class receives these messages from the view, and relays them back to the controller, via events:
Private Sub IGridViewEvents_AttackPosition(ByVal gridId As Byte, ByVal position As IGridCoord) RaiseEvent OnAttackPosition(gridId, position) End Sub Private Sub IGridViewEvents_ConfirmShipPosition(ByVal gridId As Byte, ByVal position As IGridCoord) RaiseEvent OnConfirmCurrentShipPosition(gridId, position) End Sub Private Sub IGridViewEvents_CreatePlayer(ByVal gridId As Byte, ByVal pt As PlayerType, ByVal difficulty As AIDifficulty) RaiseEvent OnCreatePlayer(gridId, pt, difficulty) End Sub Private Sub IGridViewEvents_HumanPlayerReady() RaiseEvent OnPlayerReady End Sub Private Sub IGridViewEvents_PreviewRotateShip(ByVal gridId As Byte, ByVal position As IGridCoord) RaiseEvent OnRotateCurrentShipPosition(gridId, position) End Sub Private Sub IGridViewEvents_PreviewShipPosition(ByVal gridId As Byte, ByVal position As IGridCoord) RaiseEvent OnPreviewCurrentShipPosition(gridId, position) End Sub
GameController has a
Private WithEvents viewAdapter As GridViewAdapter private field, and with that it’s able to respond to the adapter’s events and, say, create a
HumanPlayer in grid 1, or a
MercilessAI AI player in grid2 – and then instruct the view to begin positioning the ships, one by one, until the game is ready to begin.
Apart from events, the worksheet exposes methods that display, hide, or flash such or such shape, depending on what the controller says needs to happen next: the worksheet doesn’t control anything whatsoever about the game mechanics – that’s the controller’s job. The view raises events, the adapter handles them and relays them to the controller; controller alters game state, and then sends a command to the view to reflect the current state.
This makes the controller blissfully unaware about any worksheet, or event about any
WorksheetView class: it knows about the
GridViewAdapter, but then looking at how the game is started…
Public Sub PlayWorksheetInterface() Dim view As WorksheetView Set view = New WorksheetView Dim randomizer As IRandomizer Set randomizer = New GameRandomizer Set controller = New GameController controller.NewGame GridViewAdapter.Create(view), randomizer End Sub
…we can easily infer that the adapter would work with any class that implements the
IGridViewCommands interface and that’s able to “adapt” its event model to the
IGridViewEvents methods: the components are said to be decoupled; we can easily swap one implementation for another – be it for unit-testing the individual components… or we could implement a view that has nothing to do with any Excel worksheet.
We could easily add another button to the start screen, attach it to some
PlayUserFormInterface macro, and do something like this:
Public Sub PlayUserFormInterface() Dim view As UserFormView Set view = New UserFormView Dim randomizer As IRandomizer Set randomizer = New GameRandomizer Set controller = New GameController controller.NewGame GridViewAdapter.Create(view), randomizer End Sub
And then play the exact same game with a UI that’s entirely different.