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

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

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

いきなりUIを作るのではなく、まずはモデルから作りましょうという話。Model-View のフレームを使うので、そのためのモデルを定義する。実際には M-VC みたいな感じになるのかな。
Model は View には直接アクセスせずに、announcement(通知って訳すのでいいのか?)を用いて状態を知らせ、View は Model に直接アクセスできるようにするということ。

2.1 Reviewing the card model

まずはカードのモデルから作る。カードは表示する文字と、表か裏かの状態、あと、状態の変化を通知するための announcer を保持する。Model のサブクラスにすれば、最初から announcer を持ってるけど、あえて Object のサブクラスにするらしい。

Object subclass: #MgdCardModel
    instanceVariableNames: 'symbol flipped announcer'
    classVariableNames: ''
    package: 'Bloc-MemoryGame-Demo-Model'

上記のようなクラスを追加したら、次は initialize メソッドを使って裏(flipped = false)に初期化する。

MgdCardModel >> initialize
    super initialize.
    flipped := false.

加えてインスタンス変数のアクセッサを作っておく。

MgdCardModel >> symbol: aCharacter
    symbol := aCharacter.
MgdCardModel >> symbol
    ^ symbol
MgdCardModel >> isFlipped
    ^ flipped

isFlipped は、カードが表の状態の時に true を返す。

MgdCardModel >> announcer
    ^ announcer ifNil: [ announcer := Announcer new ].

announcer メソッドでは Lazy Initialization パターンにしている。

2.2 Card simple operations

カードを裏返すメソッドと、不要なカードを取り除くメソッドを作る。

MgdCardModel >> flip
    flipped := flipped not.
    self notifyFlipped
MgdCardModel >> disappear
    self notifyDisappear

取り除くとはいえ、取り除かれるカードそのものとしては、誰かにそのことを伝える(announceする)くらいしかできない。

2.3 Adding notification

直前の2つのメソッド定義で用いたnotifyで始まる通知メソッドを定義する。内容としては、 MgdCardFlippedAnnouncement と MgdCardDisappearAnnouncement という型のイベントを通知するだけ。後でUI 側がこのイベントの通知を受け取れるように登録する。

MgdCardModel >> notifyFlipped
    self announcer announce: MgdCardFlippedAnnouncement new.
MgdCardModel >> notifyDisappear
    self announcer announce: MgdCardDisappearAnnouncement new.

当然のことながら、上のメソッドを追加する前に、以下の2つのクラス定義をしておく必要があるよね。

Announcement subclass: #MgdCardFlippedAnnouncement
    instanceVariableNames: ''
    classVariableNames: ''
    package: 'Block-MemoryGame-Demo-Model'
Announcement subclass: #MgdCardDisappearAnnouncement
    instanceVariableNames: ''
    classVariableNames: ''
    package: 'Block-MemoryGame-Demo-Model'

カードのモデルの締めくくりに、モデルを表示するメソッドを作っておく。

MgdCardModel >> printOn: aStream
    aStream
      nextPutAll: 'Card';
      nextPut: Character space;
      nextPut: $(;
      nextPut: self symbol;
      nextPut: $).

カードのモデルについては、とりあえず終わりらしい。

2.4 Reviewing the game model

次はゲームのモデルにとりかかる。ゲームのモデルは、利用可能なカードの保持と、プレイヤーによって選ばれているカードの情報を保持する。

Object subclass: #MgdGameModel
    instanceVariableNames: 'availableCards chosenCards'
    classVariableNames: ''
    package: 'Bloc-MemoryGame-Demo-Model'

initialize メソッドでは各インスタンス変数を OrderedCollection のオブジェクトで初期化する。

MgdGameModel >> initialize
    super initialize.
    availableCards := OrderedCollection new.
    chosenCards := OrderedCollection new.

それぞれのアクセッサも作っておく。

MgdGameModel >> availableCards
    ^ availableCards
MgdGameModel >> chosenCards
    ^ chosenCards

2.5 Grid size and card number

あれれ?と思うけど、ゲーム盤の次元(縦横のマス数)は固定してしまうらしい。for now とか言っているから、後で変えられるようにするのかも。

MgdGameModel >> girdSize
    ^ 4

カードを揃える枚数もメソッドにしておく。

MgdGameModel >> matchesCount
    ^ 2

次元に応じて必要となるカード枚数を求めるメソッドも作る。

MgdGameModel >> cardsCount
    ^ self gridSize * self gridSize

2.6 Initialization

ゲームの初期化のための特別なメソッド initializeForSymbols: を作る。このメソッドは、与えられた文字のリストに基づいてカードを生成して、シャッフルするものである。

MgdGameModel >> initializeForSymbols: characters
    self
      assert: [ characters size = (self cardsCount / self matchesCount ]
      description: [ 'Amount of characters must be equal to possible all combinations' ].
    availableCards := (characters asArray collect: [ :aSymbol |
      (1 to: self matchesCount) collect: [ :i |
        MgdCardModel new symbol: aSymbol ]])
          flattened shuffled asOrderedCollection.

メソッドの最初の3行は、与えられた文字のリストの長さと、ゲーム盤が必要とする文字のサイズとが合致するかをチェックしている。
次に、与えられた文字のリストを配列に変換した後、1文字ずつ取り出しつつ、揃えるべきカード枚数分のカードモデルのオブジェクトを作っている。そのままだと、カードのモデルの配列を要素とする配列となってしまうため flattened で1次元の配列に変換する。要素の順序を shuffled でシャッフルしたあと、 OrderedCollection に変換して availableCards に格納する。
慣れればなんてことはないけど、これだけ文字が密集すると「うわっ」てなるかも。

2.7 Game logic

ゲームのモデルで最も重要なロジックに関わるメソッドを作る。その主要なものが chooseCard: メソッドで、プレイヤーが選んだカードに対する処理を実現する。処理はいくつかに分かれている。

  1. 与えられたカードが、既に chosenCards にあったら何もしない。
  2. カードを chosenCards に加える。
  3. そのカードを表にする。
  4. ゲーム盤の状況に応じて、カードを取り除いたり、裏返したりする。
MgdGameModel >> chooseCard: aCard
    (self chosenCards includes: aCard)
      ifTrue: [ ^ self ].
    self chosenCards add: aCard.
    aCard flip.
    self shouldCompleteStep
      ifTrue: [ ^ self completeStep ].
    self shouldResetStep
      ifTrue: [ ^ self resetStep ].

ステップ4について、「プレイヤーが必要な枚数のカードを選び、かつ、すべて同じ文字ならば、各カードに対して disappear メッセージを送り、それらのカードをリストから取り除く」ということを、 shuoldCompleteStep, chosenCardMatch と、 completeStep に分けて記述する。前2者は条件を満たしているかチェックし、後者は満たしている場合の処理を行う。

MgdGameModel >> shouldCompleteStep
    ^ self chosenCards size = self matchesCount
      and: [ self chosenCardMatch ]

shouldCompleteStep メソッドは、 chosenCards の大きさが揃えるカードの枚数に一致するかチェックし、更に chosenCardMatch で文字が一致しているかチェックする。

MgdGameModel >> chosenCardMatch
    | firstCard |
    firstCard := self chosenCards first.
    ^ self chosenCards allSatisfy: [ :aCard |
      aCard isFlipped and: [ firstCard symbol = aCard symbol ] ]

chosenCardMatch メソッドは、 chosenCards の最初の要素を取り出した後、 chosenCards 内のカードが全て表になっていて、最初の要素と文字が同じかどうかチェックする。

MgdGameModel >> completeStep
    self chosenCards
      do: [ :aCard | aCard disappear ];
      removeAll.

completeStep メソッドは、 chosenCards の全てのカードに disappear を送った後、リストを空にしている。
ステップ4の後半については、プレイヤーが3枚目のカードを表にしたとき、3枚目以外のカードを裏返しにする。

MgdGameModel >> shouldResetStep
    ^ self chosenCards size > self matchesCount

3枚目と明示しているのではなく、揃えるカード枚数を超えたかをチェックしている。

MgdGameModel >> resetStep
    | lastCard |
    lastCard := self chosenCards last.
    self chosenCards
      allButLastDo: [ :aCard | aCard flip ];
      removeAll;
      add: lastCard.

chosenCards の最後に加えられたカードを取っておいて、そのカード以外に flip メッセージを送り、chosenCards を取っておいた最後のカードだけにしている。

2.8 Ready

モデルが完成したので、ようやくゲームのViewにとりかかることができる。その後に気になる文章が…

Bloc は開発中で、正常に表示できないような場合には例外が発生することがある。そういう場合には以下のように全体を初期化する必要がある。

BlUniverse reset

世界を元に戻すおまじないとして覚えておかないと。
さて、Chapter 2の内容はゲームのモデル作成だったので、 Bloc らしさというものは特に感じられなかった。きっと次のChapter 3 から Bloc らしいことが学べるのだろう。