Building a memory game with Blocの手習い(Chapter 3)

これはSmalltalk Advent Calendar 2017の記事です。

前回に引き続いて、Bloc のチュートリアルである Building a memory game with Bloc のChapter 3(Building card graphical elements) の内容をやっていく。
元ネタは
https://github.com/pharo-graphics/Bloc
の中にある、
http://files.pharo.org/books-pdfs/booklet-Bloc/2017-11-09-memorygame.pdf
である。

そろそろUIらしい話になると思われる3章にたどり着いた。冒頭ではカードの表示について少しずつ学んでいけるようなことが書かれている。
Blocでは視覚的なオブジェクトをエレメントと呼んで、BlElement を継承したクラスを使うらしい。

3.1 First: the card element

まずはカードのビューとなるクラスを定義する。先ほどのとおり BlElement のサブクラスとするので、カードのエレメントと呼ぶことにする。このエレメントには、カードのモデルを参照するためのインスタンス変数 card を作る。

BlElement subclass: #MgdRawCardElement
    instanceVariableNames: 'card'
    classVariableNames: ''
    package: 'Bloc-MemoryGame-Demo-Elements'

backgroundPaint でカードのエレメントの背景色を決める。

MgdRawCardElement >> backgroundPaint
    ^ Color lightGray

モデルとビューとでやり取りするためのアクセッサを追加する。

MgdRawCardElement >> card
    ^ card
MgdRawCardElement >> card: aMgCard
    card := aMgCard.

initialize メソッドを追加する。

MgdRawCardElement >> initialize
    super initialize.
    self size: 80 @ 80.
    self card: (MgdCardModel new symbol: $a).

3.2 Starting to draw a card

エレメントの表示属性を定義するために drawOnSpartaCanvas: というメソッドをオーバーライドするらしい。sparta canvas ってなんだ?厳密なキャンバス?
なるほど、Sparta というのはベクタグラフィクス用のAPIなのね。といってもよくわからないけど、今までの Canvas とは違うらしい。

MgdRawCardElement >> drawOnSpartaCanvas: aCanvas
    aCanvas fill
      paint: self backgroundPaint;
      path: self boundsInLocal;
      draw.

普通の drawOn: メソッドの場合、キャンバスに描画メッセージを送るだけで良かったけど、drawOnSpartaCanvas: では、最後にdraw メッセージを送らないと表示されないらしい。
今までの内容で表示させるには、 Playground を開いて以下を入力して右上の Do it all and go をクリックするか、 Ctrl+g(Mac なら Command + g)を入力すればよい。灰色の80px四方のエレメントが表示される。

MgdRawCardElement new.

BlElement は Morph ではなく Object のサブクラスなので、 MgdRawCardElement new openInWorld とかやっても表示されない。今までのモーフのようにはいかない。

3.3 Improving the card visual

灰色の真四角でカードを表すのはダサいので、Sparta のシェイプファクトリを使って見栄え良くしよう。シェイプファクトリはいろいろなパス(直線、矩形、楕円、円など)を返すので、 path: メッセージでキャンバスに渡せばいいらしい。

path: (aCanvas shape ellipse: self boundsInLocal)

上のような式で、バウンディングエリアに収まる楕円をパスとして渡すことができるそうだ。先ほどの drawOnSpartaCanvas: を書き換える。

MgdRawCardElement >> drawOnSpartaCanvas: aCanvas
  aCanvas fill
    paint: self backgroundPaint;
    path: (aCanvas shape ellipse: self boundsInLocal);
    draw.

上のコードだと矩形の代わりに円が表示される。

カードとしては円だと不適切なので角丸四角形にしたい。角丸四角形を描くためには、 roundedRectangle:radii: というメソッドを使うようだ。こんな感じにすればいいのかな。

MgdRawCardElement >> drawOnSpartaCanvas: aCanvas
  aCanvas fill
    paint: self backgroundPaint;
    path: (aCanvas shape roundedRectangle: self boundsInLocal radii: 12);
    draw.

試してみたらエラーになってしまった。radii: には曲率半径の数値ではなく BlConerRadii のインスタンスを指定しなければいけない。12の代わりに(BlCornerRadii radius: 12)とする。

MgdRawCardElement >> drawOnSpartaCanvas: aCanvas
  aCanvas fill
    paint: self backgroundPaint;
    path: (aCanvas shape roundedRectangle: self boundsInLocal radii: (BlCornerRadii radius: 12));
    draw.

ちゃんと角丸四角形になったぞ。

チュートリアルに戻ってみると、上のコードとはずいぶん違うコードになっていた。

MgdRawCardElement >> drawOnSpartaCanvas: aCanvas
  | roundedRectangle |
  roundedRectangle := aCanvas shape
     roundedRectangle: self boundsInLocal
     radii: (BlCornerRadii radius: self cornerRadius).
  aCanvas clip
    by: roundedRectangle
    during: [ aCanvas fill
          paint: self backgroundPaint;
          path: self boundsInLocal;
          draw ].

角丸四角形をそのまま描くのではなく、角丸四角形をクリッピングに使うということらしい。 おそらく、カードの表面・裏面で角丸四角形を描かず、最終的に角丸四角形に仕上げるのが理由だろう。次節を先読みしたらまさしくそうだった。
あと1点。12のようなマジックナンバーではなく、四隅の曲率半径を返すヘルパーメソッドを作らないと。

MgdRawCardElement >> cornerRadius
  ^ 12

3.4 Preparing flipping

前節に引き続いてカードの描画を行う。次節で表面と裏面を描くので、この節では表裏を描き分けるための仕組みを準備する。
まず2つのメソッド(中身はとりあえず空)を作る。

MgdRawCardElement >> drawBacksideOn: aCanvas
  "nothing for now"
MgdRawCardElement >> drawFlippedSideOn: aCanvas
  "nothing for now"

この2つのメソッドを切り替えて表示できるように drawOnSpartaCanvas: を修正する。

MgdRawCardElement >> drawOnSpartaCanvas: aCanvas
  | roundedRectangle |
  roundedRectangle := aCanvas shape
     roundedRectangle: self boundsInLocal
     radii: (BlCornerRadii radius: self cornerRadius).
  aCanvas clip
    by: roundedRectangle
    during: [ aCanvas fill
          paint: self backgroundPaint;
          path: self boundsInLocal;
          draw.
      self card isFlipped
        ifTrue: [ self drawFlippedSideOn: aCanvas ]
        ifFalse: [ self drawBacksideOn: aCanvas ] ].

カードのモデルの状態(isFlipped)によって表示するメッセージを切り替えているのがわかる。
矩形を描く部分も別メソッドにして、全体をシンプルにする。

MgdRawCardElement >> drawCommonOn: aCanvas
  aCanvas fill
    paint: self backgroundPaint;
    path: self boundsInLocal;
    draw.

結果として drawOnSpartaCanvas: は以下のようにすっきりした。

MgdRawCardElement >> drawOnSpartaCanvas: aCanvas
  | roundedRectangle |
  roundedRectangle := aCanvas shape
     roundedRectangle: self boundsInLocal
     radii: (BlCornerRadii radius: self cornerRadius).
  aCanvas clip
    by: roundedRectangle
    during: [
      self drawCommonOn: aCanvas.
      self card isFlipped
        ifTrue: [ self drawFlippedSideOn: aCanvas ]
        ifFalse: [ self drawBacksideOn: aCanvas ] ].

3.5 Adding a cross

準備が整ったところでカードの裏面の表示にとりかかる。

上の画像のようにカードの裏面はシンプルな✕印を描く予定だから、✕印となる線を引く必要がある。
線を描くには、 Path オブジェクトを作って渡すか、シェイプファクトリを用いてキャンバスに描画させるかの2種類の方法があるらしい。

(aCanvas shape line: 0 @ 0 to: self extent)

上のコードは、shape メッセージでシェイプファクトリを作り、それに左上端から右下端までの線を描くよう指示している。

MgdRawCardElements >> drawBacksideOn: aCanvas
  aCanvas stroke
    paint: Color paleBlue;
    path: (aCanvas shape line: 0 @ 0 to: self extent);
    draw.

結局、線を描くシェイプを path: で渡しているんだよ。パスとシェイプの違いはなんだろう。cairo をちゃんと勉強するとわかるのかな。

3.6 Full cross

先ほどのコードでは一方の斜め線しか描いていないので、反対側の斜め線も描くようにする。

MgdRawCardElements >> drawBacksideOn: aCanvas
  aCanvas stroke
    paint: Color paleBlue;
    path: (aCanvas shape line: 0 @ 0 to: self extent);
    draw.
  aCanvas stroke
    paint: Color paleBlue;
    path: (aCanvas shape line: self width @ 0 to: 0 @ self height);

これでカード裏面の描画の実装が完了した。

3.7 Flipped side

最後にカードの表面、つまり文字の書かれた面の描画にとりかかる。カードの表面を表示させるには、以下のようにしてカードのモデルに flip メッセージを送る。

| cardElement |
cardElement := MgdRawCardElement new.
cardElement card flip.
cardElement.

上記のコードを Playground に入力して Do it all and go ボタンを押せば表面が表示される。今のところ、drawFlippedSideOn: メソッドの中身は空っぽなので角丸四角形が表示されるだけだが。
以下の方針でカード表面の描画にとりかかる。

  • キャンバスに対して大きさ50のフォントを生成させる。Blocではビットマップフォントは使えないので FreeType フォントを用いる。
  • そのフォントを使って文字を描く。
MgdRawCardElement >> drawFlippedSideOn: aCanvas
  | font |
  font := aCanvas font
    named: 'Source Sans Pro';
    size: 50;
    build.
  aCanvas text
    font: font;
    paint: Color white;
    string: self card symbol asString;
    draw.

上記のまま描画しようとしても何も表示されない。というのも文字を表示する位置が悪いためで、ベースラインを指定してこれを修正する。

MgdRawCardElement >> drawFlippedSideOn: aCanvas
  | font origin |
  font := aCanvas font
    named: 'Source Sans Pro';
    size: 50;
    build.
  origin := self extent / 2.0.
  aCanvas text
    baseline: origin;
    font: font;
    paint: Color white;
    string: self card symbol asString;
    draw.


とはいえ右上に偏って表示されているのを何とかしなければならない。中央に表示するにはフォントメトリックを取得する必要がある。aCanvas text からフォントメトリックを得ることもできるので、その情報を元に表示位置を求めるようにする。

MgdRawCardElement >> drawFlippedSideOn: aCanvas
  | font origin textPainter metrics |
  font := aCanvas font
    named: 'Source Sans Pro';
    size: 50;
    build.
  textPainter := aCanvas text
    font: font;
    paint: Color white;
    string: self card symbol asString.
  metrics := textPainter measure.
  origin := (self extent - metrics textMetrics bounds extent) / 2.0.
  textPainter
    baseline: origin;
    draw.


水平方向では中央になったが、フォントの大きさを考慮していないため上に偏っている。最終的なコードは以下のようになる。

MgdRawCardElement >> drawFlippedSideOn: aCanvas
  | font origin textPainter metrics |
  font := aCanvas font
    named: 'Source Sans Pro';
    size: 50;
    build.
  textPainter := aCanvas text
    font: font;
    paint: Color white;
    string: self card symbol asString.
  metrics := textPainter measure.
  origin := (self extent - metrics textMetrics bounds extent) / 2.0.
  origin := origin - metrics textMetrics bounds origin.
  textPainter
    baseline: origin;
    draw.


以上でカードの描画に関する実装は終わった。Blocでは、キャンバスから図形や文字を描く様々なファクトリを作って描画するということがわかった。