7
\$\begingroup\$

This is the first of (hopefully) several posts as I reimplement an ancient (and unfortunately long lost) Excel VBA Tetris clone. The main workhorse class in the game engine is the display driver, which uses a rectangular area of a Worksheet to emulate a monitor. The display is sprite based, so the class holds a Dictionary loaded with IDrawables, which are basically bitmaps. The interface is as follows:

'IDrawable.cls
Option Explicit
Public Property Get Top() As Long
End Property
Public Property Let Top(inValue As Long)
End Property
Public Property Get Left() As Long
End Property
Public Property Let Left(inValue As Long)
End Property
Public Property Get Width() As Long
End Property
Public Property Get Height() As Long
End Property
Public Property Let Bitmap(colors() As Long)
End Property
Public Property Get Bitmap() As Long()
End Property
Public Function GetPixel(pixelRow As Long, pixelColumn As Long) As Long
End Function

Properties

AnchorCell - This is a one cell Range that determines where the display rectangle will be placed. It will raise an error if more than one cell is passed.

Top and Left are the row and column number of the AnchorCell in the parent Worksheet's coordinates. These are read-only and are derived from the AnchorCell.

Width and Height set the dimensions of the display.

DotPitch is the size of each "pixel", and the rows and columns in the display range are set to this width and height (in pixels). Currently I have it limited to CGA resolution (mainly for convenience when I test it), but the limits can be set by altering the appropriate constants.

BackColor sets the background color for the display. Any pixel in an IDrawable that matches the BackColor will be "transparent".


Methods

AddDrawable, RemoveDrawable and ClearDrawables are the main methods for interacting with the display. Any item added to the container will be rendered.

Refresh forces the display to repaint itself. If changes were made to any of the properties that alter how it is rendered on the Worksheet, it will apply those changes. As a side note, this should eventually include Z-ordering, but I'm still up in the air as to the implementation details (and Tetris doesn't need the functionality - maybe for some other game...). There's also room for improvement in the resize - I currently have the conversion between character width and pixels fixed, but it should really calculate it based on the display settings.

Create is simply a factory method. I'm open to ideas as to how to make the "default constructor" private. :-P


The Code

Note, VB_PredeclaredId is set to True. For convenience, the raw file is available on pastebin.

Yes, yes, I know - I'll get it up on github eventually.

'CellDisplayDriver.cls
Option Explicit
Public Enum DriverErrors
 NullRangeError = vbObjectError + 1
 ArgumentError = vbObjectError + 2
 InvalidStateError = vbObjectError + 3
End Enum
Private Const MIN_WIDTH As Long = 10
Private Const MAX_WIDTH As Long = 160
Private Const MIN_HEIGHT As Long = 10
Private Const MAX_HEIGHT As Long = 100
Private Const MIN_PITCH As Long = 1
Private Const MAX_PITCH As Long = 10
Private Type DriverProperties
 AnchorCell As Range
 Top As Long
 Left As Long
 Width As Long
 Height As Long
 DotPitch As Long
 BackColor As Long
 DisplayArea As Range
 Drawables As Scripting.Dictionary
 PendingResize As Boolean
End Type
Private this As DriverProperties
Private Sub Class_Initialize()
 With this
 Set .Drawables = New Scripting.Dictionary
 .PendingResize = True
 .Width = MIN_WIDTH
 .Height = MIN_HEIGHT
 .DotPitch = MAX_PITCH
 End With
End Sub
Public Property Get AnchorCell() As Range
 Set AnchorCell = this.AnchorCell
End Property
Public Property Set AnchorCell(inValue As Range)
 If inValue Is Nothing Then
 Err.Raise DriverErrors.NullRangeError, "CellDisplayDriver.AnchorCell", "AnchorCell cannot be set to Nothing."
 End If
 With inValue
 If .Rows.Count > 1 Or .Columns.Count > 1 Then
 RaiseInvalidArgument "AnchorCell", "AnchorCell must be a Range containing a single cell."
 End If
 End With
 If Not this.AnchorCell Is Nothing Then
 this.PendingResize = inValue.Address <> this.AnchorCell.Address
 End If
 Set this.AnchorCell = inValue
 With this.AnchorCell
 this.Top = .Rows(1).Row
 this.Left = .Columns(1).Column
 End With
 With this.AnchorCell.Parent
 Set this.DisplayArea = .Range(.Cells(this.Top, this.Left), _
 .Cells(this.Top + this.Height - 1, this.Left + this.Width - 1))
 End With
End Property
Public Property Get Top() As Long
 Top = this.Top
End Property
Public Property Get Left() As Long
 Left = this.Left
End Property
Public Property Get Width() As Long
 Width = this.Width
End Property
Public Property Let Width(inValue As Long)
 If inValue < MIN_WIDTH Or inValue > MAX_WIDTH Then
 RaiseInvalidArgument "Width", "Width must be between " & MIN_WIDTH & " and " & MAX_WIDTH & "."
 End If
 this.PendingResize = inValue <> this.Width
 this.Width = inValue
End Property
Public Property Get Height() As Long
 Height = this.Height
End Property
Public Property Let Height(inValue As Long)
 If inValue < MIN_HEIGHT Or inValue > MAX_HEIGHT Then
 RaiseInvalidArgument "Height", "Height must be between " & MIN_HEIGHT & " and " & MAX_HEIGHT & "."
 End If
 this.PendingResize = inValue <> this.Height
 this.Height = inValue
End Property
Public Property Get DotPitch() As Long
 DotPitch = this.DotPitch
End Property
Public Property Let DotPitch(inValue As Long)
 If inValue < MIN_PITCH Or inValue > MAX_PITCH Then
 RaiseInvalidArgument "Create", "Dot pitch must be between " & MIN_PITCH & " and " & MAX_PITCH & "."
 End If
 this.PendingResize = inValue <> this.DotPitch
 this.DotPitch = inValue
End Property
Public Property Get BackColor() As Long
 BackColor = this.BackColor
End Property
Public Property Let BackColor(rgbValue As Long)
 this.BackColor = rgbValue
End Property
Public Sub AddDrawable(addition As IDrawable)
 With this.Drawables
 If Not .Exists(addition) Then .Add addition, vbNull
 End With
End Sub
Public Sub RemoveDrawable(deletion As IDrawable)
 With this.Drawables
 If .Exists(deletion) Then .Remove deletion
 End With
End Sub
Public Sub ClearDrawables()
 this.Drawables.RemoveAll
End Sub
Public Sub Refresh()
 If this.DisplayArea Is Nothing Then
 Err.Raise DriverErrors.InvalidStateError, "CellDisplayDriver.Refresh", "AnchorCell must be set prior to Refresh."
 End If
 Application.ScreenUpdating = False
 If this.PendingResize Then Resize
 With this.DisplayArea
 .Interior.Color = this.BackColor
 Dim drawable As Variant
 For Each drawable In this.Drawables.Keys
 Dim r As Long
 Dim c As Long
 Dim rowTarget As Long
 Dim colTarget As Long
 For r = 1 To drawable.Height
 rowTarget = r + drawable.Top
 If rowTarget >= 1 And rowTarget < this.Height Then
 For c = 1 To drawable.Width
 colTarget = c + drawable.Left
 If colTarget >= 1 And colTarget < this.Width Then
 .Cells(rowTarget, colTarget).Interior.Color = drawable.GetPixel(r, c)
 End If
 Next
 End If
 Next
 Next
 End With
 Application.ScreenUpdating = True
 DoEvents
End Sub
Public Function Create(displayWidth As Long, displayHeight As Long, pitch As Long, anchor As Range) As CellDisplayDriver
 Dim display As New CellDisplayDriver
 With display
 .Width = displayWidth
 .Height = displayHeight
 .DotPitch = pitch
 Set .AnchorCell = anchor
 End With
 Set Create = display
End Function
Private Sub Resize()
 With this.DisplayArea
 Dim rng As Range
 For Each rng In .Cells.Rows
 rng.RowHeight = this.DotPitch
 Next
 For Each rng In .Cells.Columns
 rng.ColumnWidth = this.DotPitch * 0.085
 Next
 .Interior.Color = this.BackColor
 End With
 this.PendingResize = False
End Sub
Private Sub RaiseInvalidArgument(proc As String, description As String)
 Err.Raise DriverErrors.ArgumentError, "CellDisplayDriver." & proc, description
End Sub

Test code

This isn't nearly as much fun without some test code, so I knocked together a super basic IDrawable...

'Sprite.cls
Option Explicit
Implements IDrawable
Private Type SpriteMembers
 Top As Long
 Left As Long
 Bitmap() As Long
End Type
Private this As SpriteMembers
Public Property Get Top() As Long
 Top = this.Top
End Property
Public Property Let Top(inValue As Long)
 this.Top = inValue
End Property
Public Property Get Left() As Long
 Left = this.Left
End Property
Public Property Let Left(inValue As Long)
 this.Left = inValue
End Property
Public Property Get Width() As Long
 Width = UBound(this.Bitmap, 2) + 1
End Property
Public Property Get Height() As Long
 Height = UBound(this.Bitmap, 1) + 1
End Property
Public Property Let Bitmap(colors() As Long)
 this.Bitmap = colors
End Property
Public Property Get Bitmap() As Long()
 Bitmap = this.Bitmap
End Property
Public Function GetPixel(pixelRow As Long, pixelColumn As Long) As Long
 GetPixel = this.Bitmap(pixelRow - 1, pixelColumn - 1)
End Function
Public Property Get IDrawable_Top() As Long
 IDrawable_Top = Top
End Property
Public Property Let IDrawable_Top(inValue As Long)
 Top = inValue
End Property
Public Property Get IDrawable_Left() As Long
 IDrawable_Left = Left
End Property
Public Property Let IDrawable_Left(inValue As Long)
 Left = inValue
End Property
Public Property Get IDrawable_Width() As Long
 IDrawable_Width = Width
End Property
Public Property Get IDrawable_Height() As Long
 IDrawable_Height = Height
End Property
Public Property Let IDrawable_BitMap(colors() As Long)
 Bitmap = colors
End Property
Public Property Get IDrawable_BitMap() As Long()
 IDrawable_BitMap = Bitmap
End Property
Public Function IDrawable_GetPixel(pixelRow As Long, pixelColumn As Long) As Long
 IDrawable_GetPixel = GetPixel(pixelRow, pixelColumn)
End Function

...and a test Sub that bounces a red square around:

Sub Test()
 Dim sheet As Worksheet
 Set sheet = ActiveWorkbook.Worksheets.Add
 sheet.Name = "Demo"
 Dim screen As CellDisplayDriver
 Set screen = CellDisplayDriver.Create(160, 100, 2, ActiveSheet.Cells(2, 2))
 Dim foo As Sprite
 Set foo = New Sprite
 Dim colors() As Long
 ReDim colors(9, 9)
 Dim r As Long
 Dim c As Long
 For r = 0 To 9
 For c = 0 To 9
 colors(r, c) = RGB(255, 0, 0)
 Next
 Next
 With foo
 .Top = 45
 .Left = 0
 .Bitmap = colors
 End With
 Dim down As Boolean
 Dim right As Boolean
 Dim frames As Long
 screen.AddDrawable foo
 Dim ending As Single
 ending = Timer + 30
 With foo
 Do
 If .Left = 0 Then right = True
 If .Left = 150 Then right = False
 If .Top = 0 Then down = True
 If .Top = 90 Then down = False
 If right Then
 .Left = .Left + 1
 Else
 .Left = .Left - 1
 End If
 If down Then
 .Top = .Top + 1
 Else
 .Top = .Top - 1
 End If
 screen.Refresh
 frames = frames + 1
 Loop While Timer < ending
 End With
 Application.DisplayAlerts = False
 sheet.Delete
 Application.DisplayAlerts = True
 Debug.Print "~" & Format$(frames / 30, "#.00") & " frames per second."
End Sub

Edit:

Sample "output" by popular request:

ScreenToGif sample

asked Sep 15, 2016 at 4:21
\$\endgroup\$
1
  • \$\begingroup\$ BTW, my VM with 2 i7 cores and 4 GB allocated averages about 30fps. I'm curious what other people's results are like. \$\endgroup\$ Commented Sep 15, 2016 at 4:27

2 Answers 2

5
\$\begingroup\$

First let me say that this is pure awesome, (削除) and I only have superficial improvements to suggest. (削除ここまで) nevermind, this grew bigger than I originally thought.... again.

There is no reason for any of the parameters (well, except the array one) you're passing any of the IDrawable interface members to be passed ByRef - and they're all passed by reference, implicitly.

Public Property Let Top(inValue As Long)
End Property
Public Property Let Left(inValue As Long)
End Property
Public Function GetPixel(pixelRow As Long, pixelColumn As Long) As Long
End Function

Could be:

Public Property Let Top(ByVal inValue As Long)
End Property
Public Property Let Left(ByVal inValue As Long)
End Property
Public Function GetPixel(ByVal pixelRow As Long, ByVal pixelColumn As Long) As Long
End Function

While we're at it, might as well make the colors() array explicitly passed ByRef:

Public Property Let Bitmap(ByRef colors() As Long)
End Property

In the Sprite class (I know, it's test/demo code), there's not really a need to expose any public members - in fact the only reason you do need them is because you've declared foo As Sprite:

Dim foo As Sprite
Set foo = New Sprite

But this would work just as well:

Dim foo As IDrawable
Set foo = New Sprite

Actually, I think the IDrawable interface should not expose Property Let members to mutate Top and Left values (but leave them on the concrete implementations... or don't - and give it a PredeclaredId and a Create function instead). It could expose some Move procedure instead of mutators:

Public Sub Move(ByVal x As Long, ByVal y As Long)
End Sub

That would change this client code:

If right Then
 .Left = .Left + 1
Else
 .Left = .Left - 1
End If
If down Then
 .Top = .Top + 1
Else
 .Top = .Top - 1
End If

Into that one-liner:

.Move IIf(right, 1, -1), IIf(down, 1, -1)

The Refresh method feels a bit cluttered, because it's responsible for drawing every pixel of every drawable object. You could have some DrawableObject with a PredeclaredId, and a "static" method where you could draw one single IDrawable object:

Public Sub Draw(ByRef drawable As IDrawable, ByRef screen As Range)
 Dim r As Long
 Dim c As Long
 Dim rowTarget As Long
 Dim colTarget As Long
 For r = 1 To drawable.Height
 rowTarget = r + drawable.Top
 If rowTarget >= 1 And rowTarget < this.Height Then
 For c = 1 To drawable.Width
 colTarget = c + drawable.Left
 If colTarget >= 1 And colTarget < this.Width Then
 screen.Cells(rowTarget, colTarget).Interior.Color = drawable.GetPixel(r, c)
 End If
 Next
 End If
 Next
End Sub

Then this:

With this.DisplayArea
 .Interior.Color = this.BackColor
 Dim drawable As Variant
 For Each drawable In this.Drawables.Keys
 Dim r As Long
 Dim c As Long
 Dim rowTarget As Long
 Dim colTarget As Long
 For r = 1 To drawable.Height
 rowTarget = r + drawable.Top
 If rowTarget >= 1 And rowTarget < this.Height Then
 For c = 1 To drawable.Width
 colTarget = c + drawable.Left
 If colTarget >= 1 And colTarget < this.Width Then
 .Cells(rowTarget, colTarget).Interior.Color = drawable.GetPixel(r, c)
 End If
 Next
 End If
 Next
 Next
End With

Becomes this:

With this.DisplayArea
 .Interior.Color = this.BackColor
 Dim drawable As Variant 'shame this can't be IDrawable.. right?
 For Each drawable In this.Drawables.Keys
 DrawableObject.Draw drawable, .Range 'might need to cast to IDrawable
 Next
End With
answered Sep 15, 2016 at 21:33
\$\endgroup\$
2
  • \$\begingroup\$ I like the DrawableObject idea - Refresh is bound to become even more cluttered if I ever implement Z-ordering, because it would need to sort them. I also like the idea of making Left and Top read-only in IDrawable - in c# I'd add an IMoveable interface that inherits from IDrawable, but even if IMovable_IDrawable_Top were possible in VBA, it would be a ugly sight to see... :sigh: \$\endgroup\$ Commented Sep 15, 2016 at 22:42
  • \$\begingroup\$ My first idea was to add a Draw method to IDrawable, but then all implementations would have repeated themselves, so extracting a whole type made more sense. \$\endgroup\$ Commented Sep 15, 2016 at 23:27
3
\$\begingroup\$

Have you considered using Conditional Formats as your rendering cathode ray?

I've just thrown this together, so it's by no means complete or optimized, but by assigning sprite arrays of values to specific ranges, I avoid the need for any calls to the interior of a Range. This approach might also solve your Z-Order dilemma, as you can just write over the top, or you could even to blends!

It's fast. So fast I can't tell if it's even rendering on every frame, but I do see lots of movement. It runs about twice as fast if I omit the removal of the prior sprite location.

Theoretically, it's 1500 frames/sec with sprite erase on each frame, or 4000 frames/sec if I omit the sprite erase on each frame.

Set up the screen Apply 3 different conditional formats for values 1, 2 and 3

Sub InitScreen()
 Dim screen As Range
 Set screen = Range(Cells(1, 1), Cells(ScreenHeight, ScreenWidth))
 screen.EntireColumn.ColumnWidth = 0.15
 screen.EntireRow.RowHeight = 2
 screen.FormatConditions.Delete
 With screen.FormatConditions.Add(Type:=xlCellValue, Operator:=xlEqual, Formula1:="=1")
 .SetFirstPriority
 .Interior.Color = RGB(192, 32, 32)
 End With
 With screen.FormatConditions.Add(Type:=xlCellValue, Operator:=xlEqual, Formula1:="=2")
 .SetFirstPriority
 .Interior.Color = RGB(32, 192, 32)
 End With
 With screen.FormatConditions.Add(Type:=xlCellValue, Operator:=xlEqual, Formula1:="=3")
 .SetFirstPriority
 .Interior.Color = RGB(32, 32, 192)
 End With
End Sub

Render the Frames Define a sprite of random numbers between 1 and 3, and a sprite the same size of all zeros.

Sub RenderFrames()
 Const FrameCount As Long = 1500
 Const SpriteHeight As Long = 3
 Const SpriteWidth As Long = 3
 Dim screen As Range
 Dim i As Long, j As Long, x As Long
 Dim sprite(1 To 3, 1 To 3)
 Dim spriteLocation As Range
 Dim start As Double
 Set screen = Range(Cells(1, 1), Cells(ScreenHeight, ScreenWidth))
 'Set up an sprite
 Dim eraserSprite(1 To 3, 1 To 3) As Long
 'Set up a random sprite
 For i = 1 To SpriteHeight
 For j = 1 To SpriteWidth
 sprite(i, j) = 1 + (Rnd * 17 Mod 3)
 Next j
 Next i
 start = Timer
 For x = 1 To FrameCount
 If Not spriteLocation Is Nothing Then
 spriteLocation.Value2 = eraserSprite
 End If
 Set spriteLocation = screen.Range(Cells(1 + x Mod 150, 1), Cells(4 + x Mod 150, 3))
 spriteLocation.Value2 = sprite
 Next x
 Debug.Print Timer - start, FrameCount / (Timer - start)
End Sub
answered Sep 21, 2016 at 8:56
\$\endgroup\$
2
  • \$\begingroup\$ Interesting. It would require a palette of possible colors to set the conditional formatting to though (if done without palette switching). Is there a limit to the number of conditional formats a cell can hold? \$\endgroup\$ Commented Sep 21, 2016 at 13:04
  • \$\begingroup\$ I'm unsure of the maximum number of conditional formats, but if you're striving for the classic beauty of Tetris Duotone, you could perhaps use a conditional format that applies colors on a scale, based on the values, and set the color scale to gamma. ;-P \$\endgroup\$ Commented Sep 21, 2016 at 13:54

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.