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 Object subclass: #SpartaCanvas uses: TSpartaInspectorPreview + TSpartaSurface instanceVariableNames: 'composeOperators' classVariableNames: '' package: 'Sparta-Core' 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 Object subclass: #BlElement uses: TBlTransformable + TBlEventTarget + TBlDebug instanceVariableNames: 'spaceReference parent children bounds measuredBounds boundsCache eventDispatcher constraints layout transformation taskQueue errorHandler userData visuals flags' classVariableNames: '' package: 'Bloc-Basic' graphical widgets. All Bloc visual objects subclass BlElement Object subclass: #BlElement uses: TBlTransformable + TBlEventTarget + TBlDebug instanceVariableNames: 'spaceReference parent children bounds measuredBounds boundsCache eventDispatcher constraints layout transformation taskQueue errorHandler userData visuals flags' classVariableNames: '' package: 'Bloc-Basic' . Bloc widgets are drawn calling Sparta in BlElement>>#drawOnSpartaCanvas: drawOnSpartaCanvas: aCanvas self shouldDrawBackgroundOrBorder ifFalse: [ ^ self ]. aCanvas figure path: (self geometry pathOnSpartaCanvas: aCanvas of: self); background: self background; backgroundAlpha: self background opacity; border: self border paint; borderAlpha: self border opacity; width: self border width; in: [ :aPainter | self border style applyOn: aPainter ]; in: [ :aPainter | self outskirts = BlOutskirts outside ifTrue: [ aPainter borderOutside ]. self outskirts = BlOutskirts centered ifTrue: [ aPainter borderCentered ]. self outskirts = BlOutskirts inside ifTrue: [ aPainter borderInside ] ]; draw , 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 Object subclass: #BlElement uses: TBlTransformable + TBlEventTarget + TBlDebug instanceVariableNames: 'spaceReference parent children bounds measuredBounds boundsCache eventDispatcher constraints layout transformation taskQueue errorHandler userData visuals flags' classVariableNames: '' package: 'Bloc-Basic' 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: drawOnSpartaCanvas: aCanvas self shouldDrawBackgroundOrBorder ifFalse: [ ^ self ]. aCanvas figure path: (self geometry pathOnSpartaCanvas: aCanvas of: self); background: self background; backgroundAlpha: self background opacity; border: self border paint; borderAlpha: self border opacity; width: self border width; in: [ :aPainter | self border style applyOn: aPainter ]; in: [ :aPainter | self outskirts = BlOutskirts outside ifTrue: [ aPainter borderOutside ]. self outskirts = BlOutskirts centered ifTrue: [ aPainter borderCentered ]. self outskirts = BlOutskirts inside ifTrue: [ aPainter borderInside ] ]; draw 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 Object subclass: #SpartaCanvas uses: TSpartaInspectorPreview + TSpartaSurface instanceVariableNames: 'composeOperators' classVariableNames: '' package: 'Sparta-Core' to draw a rectangle.

We would like to represent the card with a rounded rectangle. To do so, we will use SpartaShapeFactory>>#roundedRectangle:radii: roundedRectangle: aRectangle radii: anArrayOfCornerRadii "anArrayOfCornerRadii must contain 4 numbers representing corner radii: { topLeft, topRight, bottomRight, bottomLeft }" | min tlr trr brr blr x y right bottom | min := (aRectangle width min: aRectangle height) / 2.0. tlr := anArrayOfCornerRadii first min: min. trr := anArrayOfCornerRadii second min: min. brr := anArrayOfCornerRadii third min: min. blr := anArrayOfCornerRadii fourth min: min. x := aRectangle left. y := aRectangle top. right := aRectangle right. bottom := aRectangle bottom. ^ canvas path absolute; moveTo: x @ (y + tlr); cwArcTo: (x + tlr) @ y; lineTo: (right - trr) @ y; cwArcTo: right @ (y + trr); lineTo: right @ (bottom - brr); cwArcTo: (right - brr) @ bottom; lineTo: (x + blr) @ bottom; cwArcTo: x @ (bottom - blr); close; finish :

"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 SpartaPathPainter subclass: #SpartaFillPainter instanceVariableNames: '' classVariableNames: '' package: 'Sparta-Core-Builders' to fill a path with a color (paint). We use SpartaShapeFactory SpartaAbstractBuilder subclass: #SpartaShapeFactory instanceVariableNames: '' classVariableNames: '' package: 'Sparta-Core-Builders' 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 SpartaAbstractBuilder subclass: #SpartaShapeFactory instanceVariableNames: '' classVariableNames: '' package: 'Sparta-Core-Builders' 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 SpartaAbstractBuilder subclass: #SpartaFontBuilder uses: TSpFontDescriptor instanceVariableNames: 'size language' classVariableNames: '' package: 'Sparta-Core-Builders' to define a font and SpartaTextPainter SpartaAbstractPainter subclass: #SpartaTextPainter uses: TSpartaStrokeOptions instanceVariableNames: 'drawOptions strokeOptions font paint strokePaint baseline spacing text' classVariableNames: '' package: 'Sparta-Core-Builders' 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 SpartaAbstractPainter subclass: #SpartaTextPainter uses: TSpartaStrokeOptions instanceVariableNames: 'drawOptions strokeOptions font paint strokePaint baseline spacing text' classVariableNames: '' package: 'Sparta-Core-Builders' 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 Object subclass: #BlVisibility uses: TBlDebug instanceVariableNames: '' classVariableNames: '' package: 'Bloc-Basic-Properties' :

"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.