Building the Memory Game graphics directly with the Sparta canvas
Caveat: This tutorial is currently incomplete and may hang at times.
In this chapter, we will build the visual appearance of the cards step by step, using a SpartaCanvas
API. Sparta is an almost stateless vector graphics API for Pharo that provides bindings to the Moz2D rendering backend. Moz2D is the extracted graphical engine from Mozilla Firefox compiled as a standalone shared library together with the external C bindings required to call the engine from Pharo.
The Bloc graphical engine uses Sparta to draw BlElement
graphical widgets. All Bloc visual objects subclass BlElement
. Bloc widgets are drawn calling Sparta in BlElement>>#drawOnSpartaCanvas:
, whic h is the method we will override in this section.
The Card Element
Our graphic element representing a card, called RawCardElement
, will be a subclass of BlElement
which has a reference to a card model:
BlElement subclass: #RawCardElement instanceVariableNames: 'card' classVariableNames: '' poolDictionaries: '' category: 'Tutorial-MemoryGame-SpartaUI'. RawCardElement class instanceVariableNames: ''
We then define accessor and initialize methods, defining a default size and card model:
"protocol: #accessing" RawCardElement >> card ^ card "protocol: #accessing" RawCardElement >> card: anObject card := anObject "protocol: #initialization" RawCardElement >> initialize super initialize. self size: self cardSize. self card: Card new. "protocol: #'visual properties'" RawCardElement >> cardSize ^ 80 @ 80
The RawCardElement>>#initialize
method defines the element size and a default card model.
You can observe the result of the current implementation executing the following snippet:
RawCardElement new
As you can see there is nothing visible yet. You can observe the effect of setting the element size at the Measurement tab in the inspector above.
Drawing a Card
Our card has two sides, back and face, that need to be implemented. We implement them by overriding the BlElement>>#drawOnSpartaCanvas:
method.
Back Side
Let's start with the back side, which will be represented by two diagonal lines, across the RawCardElement
card. First, we need to define a basic card shape, represented by a rectangle:
"protocol: #drawing" RawCardElement >> drawOnSpartaCanvas: aCanvas super drawOnSpartaCanvas: aCanvas. aCanvas fill paint: self backgroundColor; path: self boundsInLocal; draw "protocol: #'visual properties'" RawCardElement >> backgroundColor ^ Color lightBlue
You can observe the result of the current implementation executing the following snippet:
RawCardElement new
As you can see, that the RawCardElement>>#drawOnSpartaCanvas:
method uses SpartaCanvas
to draw a rectangle.
We would like to represent the card with a rounded rectangle. To do so, we will use SpartaShapeFactory>>#roundedRectangle:radii:
:
"protocol: #drawing" RawCardElement >> drawOnSpartaCanvas: aCanvas super drawOnSpartaCanvas: aCanvas. aCanvas fill paint: self backgroundColor; path: (aCanvas shape roundedRectangle: (self boundsInLocal) radii: (BlCornerRadii radius: 12)); draw
Let's look closer at the RawCardElement>>#drawOnSpartaCanvas:
method.
We use SpartaFillPainter
to fill a path with a color (paint). We use SpartaShapeFactory
to define a rounded-rectangle shape.
By creating an element instance, we should be able to observe a rounded-rectangle back side card. To do so, execute the following snippet:
RawCardElement new.
Back Side Crosses
In the following step, we add one cross line to the back side:
"protocol: #drawing" RawCardElement >> drawOnSpartaCanvas: aCanvas super drawOnSpartaCanvas: aCanvas. aCanvas fill paint: self backgroundColor; path: (aCanvas shape roundedRectangle: (self boundsInLocal) radii: (BlCornerRadii radius: 12)); draw. aCanvas stroke paint: self foregroundColor; path: (aCanvas shape line: 0 @ 0 to: self extent); draw "protocol: #'visual properties'" RawCardElement >> foregroundColor ^ Color gray
We use SpartaShapeFactory
to define the cross line, see the RawCardElement>>#drawOnSpartaCanvas:
method.
By executing the following script, you will notice that the cross line is not ideal as it is not clipped to the rounded-rectangle corners:
RawCardElement new.
To make the line clipping working, we need to consider the rounded-rectangle radius:
"protocol: #drawing" RawCardElement >> drawOnSpartaCanvas: aCanvas | aRadiusOffset | super drawOnSpartaCanvas: aCanvas. aRadiusOffset := 12 / Float pi. aCanvas fill paint: self backgroundColor; path: (aCanvas shape roundedRectangle: (self boundsInLocal) radii: (BlCornerRadii radius: 12)); draw. aCanvas stroke paint: self foregroundColor; path: (aCanvas shape line: aRadiusOffset @ aRadiusOffset to: self extent - aRadiusOffset); draw
By executing the following script, we should see a clipped line:
RawCardElement new.
Let's draw the other cross-line the same way:
"protocol: #drawing" RawCardElement >> drawOnSpartaCanvas: aCanvas | aRadiusOffset | super drawOnSpartaCanvas: aCanvas. aRadiusOffset := 12 / Float pi. aCanvas fill paint: self backgroundColor; path: (aCanvas shape roundedRectangle: (self boundsInLocal) radii: (BlCornerRadii radius: 12)); draw. aCanvas stroke paint: self foregroundColor; path: (aCanvas shape line: aRadiusOffset @ aRadiusOffset to: self extent - aRadiusOffset); draw. aCanvas stroke paint: self foregroundColor; path: (aCanvas shape line: (self width - aRadiusOffset) @ aRadiusOffset to: aRadiusOffset @ (self height - aRadiusOffset)); draw
By executing the following script, we should see two clipped lines:
RawCardElement new.
Refactoring The Drawing Method
Before we start to implement the face side, we refactor the RawCardElement>>#drawOnSpartaCanvas:
. The rounded rectangle is same for both sides and we need to add a logic to decide which side (back or face) to draw:
"protocol: #drawing" RawCardElement >> drawOnSpartaCanvas: aCanvas super drawOnSpartaCanvas: aCanvas. self drawCommonOnSpartaCanvas: aCanvas. self card isFlipped ifTrue: [ self drawFaceSideOnSpartaCanvas: aCanvas ] ifFalse: [ self drawBackSideOnSpartaCanvas: aCanvas ] "protocol: #drawing" RawCardElement >> drawCommonOnSpartaCanvas: aCanvas aCanvas fill paint: self backgroundColor; path: (aCanvas shape roundedRectangle: self boundsInLocal radii: (BlCornerRadii radius: 12)); draw "protocol: #drawing" RawCardElement >> drawBackSideOnSpartaCanvas: aCanvas | aRadiusOffset | aRadiusOffset := 12 / Float pi. aCanvas stroke paint: self foregroundColor; path: (aCanvas shape line: aRadiusOffset @ aRadiusOffset to: self extent - aRadiusOffset); draw. aCanvas stroke paint: self foregroundColor; path: (aCanvas shape line: (self width - aRadiusOffset) @ aRadiusOffset to: aRadiusOffset @ (self height - aRadiusOffset)); draw "protocol: #drawing" RawCardElement >> drawFaceSideOnSpartaCanvas: aCanvas "nothing to do for now"
Drawing the back side should work as before:
RawCardElement new.
By drawing the face side, you should see an empty rounded rectangle:
aCard := Card new flip. RawCardElement new card: aCard.
Face Side
Let's implement the face side which displays a character (a symbol), implementing the RawCardElement>>#drawFaceSideOnSpartaCanvas:
method:
"protocol: #drawing" RawCardElement >> drawFaceSideOnSpartaCanvas: aCanvas | aFont anOrigin | aFont := aCanvas font named: 'Source Sans Pro'; size: 50; build. anOrigin := self extent / 2. aCanvas text baseline: anOrigin; font: aFont; paint: self foregroundColor; string: self card symbol asString; draw
The RawCardElement>>#drawFaceSideOnSpartaCanvas:
method uses a SpartaFontBuilder
to define a font and SpartaTextPainter
to draw a text with a given color and font.
The following code snippet show us how a card face side is currently displayed:
aCard := Card new symbol: $A; flip. RawCardElement new card: aCard.
The symbol is not centered. To center well the text, we have to use exact font metrics. The font metrics should be measured and manipulated via the same back-end abstraction. For this purpose, the expression aCanvas text
returns a text painter SpartaTextPainter
and such a text painter provide access to the font measures SpMetrics
. Using such measure we can then get access to the text metrics and compute propertly the symbol center:
"protocol: #drawing" RawCardElement >> drawFaceSideOnSpartaCanvas: aCanvas | aFont anOrigin aTextPainter aMetrics | aFont := aCanvas font named: 'Source Sans Pro'; size: 50; build. aTextPainter := aCanvas text font: aFont; paint: self foregroundColor; string: self card symbol asString. aMetrics := aTextPainter measure. anOrigin := (self extent - aMetrics textMetrics bounds extent) / 2. aTextPainter baseline: anOrigin; draw
The following code snippet show us that the card symbol is properly centered horizontally, not vertically:
aCard := Card new symbol: $A; flip. RawCardElement new card: aCard.
To be able to align the symbol also vertically, we need to take into account symbol's font size:
"protocol: #drawing" RawCardElement >> drawFaceSideOnSpartaCanvas: aCanvas | aFont anOrigin aTextPainter aMetrics | aFont := aCanvas font named: 'Source Sans Pro'; size: 50; build. aTextPainter := aCanvas text font: aFont; paint: self foregroundColor; string: self card symbol asString. aMetrics := aTextPainter measure. anOrigin := (self extent - aMetrics textMetrics bounds extent) / 2. anOrigin := anOrigin - aMetrics textMetrics bounds origin. aTextPainter baseline: anOrigin; draw
Executing again the code snippet, we will see properly aligned symbol:
aCard := Card new symbol: $A; flip. RawCardElement new card: aCard.
Updating Element on Card Changes
Tu summarize current progress, we have the game model, including Card
, Game
, and announcements CardFlipped
and CardDisappeared
. The UI element RawCardElement
can display back and face card sides. The UI element does not reflect the model changes as we can check by executing the following snippet:
aCardOne := Card new symbol: $A. RawCardElement new card: aCardOne.
and by flipping the card:
aCardOne flip.
It should display the card's face side. The reason is that the graphical widget does not register to the card model changes in the RawCardElement>>#card:
method.
The following code registers to the card CardFlipped
changes and updates the UI widget accordingly:
"protocol: #accessing" RawCardElement >> card: aDMGCard card ifNotNil: [ :anOldCard | anOldCard announcer unsubscribe: self ]. card := aDMGCard. card announcer when: CardFlipped send: #onUpdated to: self. self onUpdated. "protocol: #hooks" RawCardElement >> onUpdated self invalidate
The RawCardElement
now registers to Card
changes in the RawCardElement>>#card:
method:
It also triggers an RawCardElement>>#onUpdated
that invalidates the element, requiring its redrawing :
The RawCardElement>>#onUpdated
is also called when CardFlipped
is triggered as you can see in the RawCardElement>>#card:
method:
To test the existing code, you can execute the following snippet that initialize a card and its element:
aCardTwo := Card new symbol: $A. RawCardElement new card: aCardTwo.
To switch the card side, execute several times the following snippet and observe the changes in the result above:
aCardTwo flip.
Disappearing Card Element
To make a card disappear from a game board, we will register to the CardDisappeared
announcement and set the card element's BlVisibility
:
"protocol: #accessing" RawCardElement >> card: aDMGCard card ifNotNil: [ :anOldCard | anOldCard announcer unsubscribe: self ]. card := aDMGCard. card announcer when: CardFlipped send: #onUpdated to: self. card announcer when: CardDisappeared send: #onDisappear to: self. self onUpdated. "protocol: #hooks" RawCardElement >> onDisappear self visibility: BlVisibility hidden.
RawCardElement
now registers to the CardDisappeared
announcement at RawCardElement>>#card:
:
The RawCardElement>>#onDisappear
is implemented by changing the element visibility:
By displaying the card:
aCardThree := Card new symbol: $B. RawCardElement new card: aCardThree.
we can make it disappear by executing the following snippet:
aCardThree disappear.
Notice, that we do not have an appear
action as the game does not require it.
Links to Pages containing missing references - allowed failures to allow references to missing classes and methods in the page.