🥰

React Tutorial の Tic Tac Toe を Scala.js & Laminar & Vite でやる.

2023/02/18に公開

Laminar とは...? 🤔となった人はこちらの記事も読んでみるといいかもしれません.

https://zenn.dev/110416/articles/997e1932fa0010

Goal


https://i10416.github.io/tictactoe/

レポジトリはこちら
https://github.com/i10416/tictactoe

Hands-on

使うもの

git clone https://github.com/i10416/tictactoe
cd tictactoe

ライブリロードでインタラクティブに開発するためにシェルを二つ開いて一つで npm run dev を、もう一つで sbt ~fastLinkJS を実行します.

npm run dev で vite の開発サーバーが立ち上がり、http://localhost:3000 でアプリケーションがサーブされます.

sbt ~fastLinkJS を実行すると src/main/scala 以下の 変更を検知して 変更があるたびにビルドが走ります.

display UI

まずは src/main/scala/tictactoe/Index.scala をみてみましょう.
次のようなコードがあります.

src/main/scala/tictactoe/Index.scala
package tictactoe
import com.raquo.laminar.api.L.{*, given}
import org.scalajs.dom

@main def program =
  renderOnDomContentLoaded(
    dom.document.getElementById("app"),
    Game.layout
  )

Game.layout は目指すべき完成品の参考のための実装なのでこれを p("Hello, Laminar") に置き換えます.

src/main/scala/tictactoe/Index.scala
package tictactoe
import com.raquo.laminar.api.L.{*, given}
import org.scalajs.dom

@main def program =
  renderOnDomContentLoaded(
    dom.document.getElementById("app"),
-   Game.layout
+   p("Hello, Laminar")
  )

ブラウザを見て、画面がリロードされて次のような画面が表示されることを確認しましょう.

次に、三目並べ(Tic Tac Toe) ゲームのマス目を表現する Square を作成します.

object Square:
  def apply() = button("Btn")

この button("Btn") は HTML の <button>Btn</button> に対応する要素です. Laminar はそのほか標準的な HTML (div,hr,p,h1~ h6 など)もサポートしています.

button(...)(...)button.apply(...) の省略形です. Scala ではこのように apply という名前のメソッドの場合 apply を省略してかけます.

apply メソッドは Modifier を受け取ります. Modifier は HTML の子要素(child, children)、 style などのアトリビュート、onClickonBlur などのイベントハンドラーです.

このままでは味気ないので Square に装飾を追加して program で描画してみましょう.

object Square:
  def apply() = button(
    "Btn",
    fontSize.larger,
    width("56px"),
    height("56px"),
    display.block,
    color.black,
    fontWeight.bold
  )
@main def program =
  renderOnDomContentLoaded(
    dom.document.getElementById("app"),
    Square()
  )

下のが上のような UI が描画されるはずです.

三目並べでは 3 x 3 のマス目が必要なのでコレクションのコンビネーターを使ってこれを用意します.

<div>
  <div>
    <Square/>
    <Square/>
    <Square/>
  </div>
  <div>
    <Square/>
    <Square/>
    <Square/>
  </div>
  <div>
    <Square/>
    <Square/>
    <Square/>
  </div>
</div>

上のHTML に対応する階層構造を作ります. Scala のコレクションに生えている便利なメソッドを活用すればシュッと作れるはずです.

@main def program =
  renderOnDomContentLoaded(
    dom.document.getElementById("app"),
    div(
      List
        .tabulate(9)(identity)
        .grouped(3)
        .map(
          _.map(_ => Square())
        )
        .map(div(_).amend(display.flex))
        .toList
    )
  )

div や HTML 要素には amendamendThis というメソッドが生えていて、case classcopy のように元のデータを修正した新しいインスタンスを作ることができます.

State Management

これで UI はざっくりと準備できました. ここからはこの UI に状態を反映する処理を書いていきます.

三目並べのマス目の状態は 空・X・O の三つの状態を取ります. また、ゲームにはターンがあり、毎ターン、三目並べの状態が変更されます. これらをモデリングします.

enum Cell:
  case Empty
  case X
  case O
object Game:
  val turn = Var(0)
  val board = Var(Seq.fill(9)(Cell.Empty))

Var, Signal, EventBus and EventStream

ここであらわれた Var は Laminar においてアプリケーションの状態を管理するために使う API です. Laminar の状態管理に関係する API は Var,Signal,EventBus,EventStream があります. Var は直近の値を保持して、その値に変更があれば変更を listeners に通知する概念です. 開発者は Varsignal メソッドからSignalを取得してこの Signal をリアクティブな要素に <-- メソッドを使って bind することができます.
(と言っても、何を言っているかわからないかもしれないので下の例やLaminar のドキュメントの Examples を見てください.)
Signal は直近の値を保持していて listener が subscribe するとその値を通知しますが、signal.changes メソッドや EventBus から得られる EventStream は subscribe 以降の変更があってはじめてデータを listeners に通知します.

さて、上の turnboard に対応する UI を作っていきます.
Square はそれぞれのマス目をあらわすので自身の場所と状態がわかるように loc:Intcell: Cell を渡します. これは React の Props を意識するとわかりやすいでしょう.

@main def program =
  renderOnDomContentLoaded(
    dom.document.getElementById("app"),
    div(children <-- Game.cells)
  )

object Game:
  val turn = Var(0)
  val board = Var(Seq.fill(9)(Cell.Empty))
  object Square:
    def apply(loc: Int, cell: Cell) =
       div(
         button(
           cell.toString(),
         ).amend(
           fontSize.larger,
           width("56px"),
           height("56px"),
           display.block,
           color.black,
           fontWeight.bold
         ),
         padding("2px")
       )

上の実装ではマス目を List.tabulate(9)(identity) で生成していましたが、ここでは board の変更を反映できるように board.signal から生成します.

  val cells = board.signal
    .map(state => // state: Seq[Cell]
      state.zipWithIndex
        .map((cell, loc) => Square(loc, cell))
        .grouped(3)
        .map(
          div(_).amend(
            display.flex,
            justifyContent.center,
            flexWrap.nowrap
          )
        )
        .toSeq
    )

これで次のような UI が得られるはずです.

Mutate State

この時点ではマス目を押してもまだ状態を変更できません. なぜなら状態を変更するイベントハンドラーが実装されていないからです.

状態管理を整理するために Redux や Elm Architecture のように状態を変更するコマンドと状態を変更する処理を分けて実装する方針を取ります.

まず盤面の状態を変更するためのコマンド Choose を定義します.

enum Cmd:
  case Choose(loc:Int)

次にこれを処理する Observer[Cmd] を用意します. Observer には Cmd を受け取って Unit を返す処理を書きます.

  val obs: Observer[Cmd] = Observer[Cmd] {
    case Cmd.Choose(loc) =>
      val t = turn.now()
      board.now()(loc) match
        case Cell.Empty =>
          // replace element at loc
          board.update(state =>
            val (leading, _ +: trailing) = state.splitAt(loc): @unchecked
            (leading :+ (if turn % 2 == 0 then Cell.O
                                      else Cell.X)) ++ trailing
          )
          turn.update(_ + 1)
        case _ => ()
    case _ => ()
  }

最後に、空のマス目をクリックした時に状態を変更するコマンドを発火する処理を Square に追加します.

  object Square:
    def apply(loc: Int, cell: Cell) =
       div(
         button(
           cell.toString(),
           onClick
             .filter(_ => cell == Cell.Empty)
             .mapTo(Cmd.Choose(loc)) --> obs
         ).amend(
           fontSize.larger,
           width("56px"),
           height("56px"),
           display.block,
           color.black,
           fontWeight.bold
         ),
         padding("2px")
       )

これで空のマス目をクリックするとそのマス目の位置を持った Choose コマンドが Observer[Cmd] に dispatch され、Observerboardturn を変更することで cells 変数のサブスクリプションが発火して UI が再描画されます.

ここまでのコードを下にまとめました.

@main def program =
  renderOnDomContentLoaded(
    dom.document.getElementById("app"),
    div(children <-- Game.cells)
  )

object Game:
  val turn = Var(0)
  val board = Var(Seq.fill(9)(Cell.Empty))
  object Square:
    def apply(loc: Int, cell: Cell) =
       div(
         button(
           cell.toString(),
           onClick
             // prevent onClick event when cell is not empty.
             .filter(_ => cell == Cell.Empty)
             .mapTo(Cmd.Choose(loc)) --> obs
         ).amend(
           fontSize.larger,
           width("56px"),
           height("56px"),
           display.block,
           color.black,
           fontWeight.bold
         ),
         padding("2px")
       )

  val cells = board.signal
    .map(state =>
      state.zipWithIndex
        .map((cell, loc) => Square(loc, cell))
        .grouped(3)
        .map(
          div(_).amend(
            display.flex,
            justifyContent.center,
            flexWrap.nowrap
          )
        )
        .toSeq
    )
  val obs: Observer[Cmd] = Observer[Cmd] {
    case Cmd.Choose(loc) =>
      val t = turn.now()
      board.now()(loc) match
        case Cell.Empty =>
          // replace element at loc
          board.update(state =>
            val (leading, _ +: trailing) = state.splitAt(loc): @unchecked
            (leading :+ (if t % 2 == 0 then Cell.O
                                      else Cell.X)) ++ trailing
          )
          turn.update(_ + 1)
        case _ => ()
    case _ => ()
  }

マス目をクリックすると下の画像のように UI が切り替わります.

"Empty" の見栄えが悪いので綺麗にするためのヘルパーメソッドを用意します.

object Cell:
  extension (cell: Cell)
    def show: String = cell match
      case Empty => ""
      case X     => "X"
      case O     => "O"

SquaretoString()show に置き換えます.

 object Square:
    def apply(loc: Int, cell: Cell) =
       div(
         button(
           cell.show,

Enhancement

さて、ここまでで以下のような機能の実装がまだ残っています.

  1. 勝者の判定
  2. 盤面の履歴機能

勝者の判定は盤面に同じマークが3つ並んだところがあるか判定すればいいので以下のような Seq[Cell] => Boolean の関数を実装すればいいです.

  def check(board: Seq[Cell]) =
    val lines = Seq(
      (0, 1, 2),
      (3, 4, 5),
      (6, 7, 8),
      (0, 3, 6),
      (1, 4, 7),
      (2, 5, 8),
      (0, 4, 8),
      (2, 4, 6)
    )
    lines.find { (a, b, c) =>
      (board(a), board(b), board(c)) match
        case (Cell.O, Cell.O, Cell.O) | (Cell.X, Cell.X, Cell.X) => true
        case _                                                   => false
    }.isDefined

あとは、勝者がいるかどうかを保持する変数 wonBy: Var[Option[Boolean]] を用意して、各手番で条件をチェック、これが true ならば盤面をロックする処理を入れればいいです. ゲームのプレイヤーは二人なので O が勝利したら Some(true), X が勝利したら Some(false) をセットすることにします.

 val wonBy: Var[Option[Boolean]] = Var(None)
 // ... 省略 ...
 val obs: Observer[Cmd] = Observer[Cmd] {
    case Cmd.Choose(loc) =>
      val turn = turnVar.now()
      val last = histories.now()(turn)
      last(loc) match
        case Cell.Empty =>
          val (leading, _ +: trailing) = last.splitAt(loc): @unchecked
          val present = (leading :+ (if turn % 2 == 0 then Cell.O
                                     else Cell.X)) ++ trailing
          histories.update(_.take(turn + 1) :+ present)
          if check(present) then wonBy.set(Some(turn % 2 == 0))
          turnVar.update(_ + 1)
        case _ => ()
 }

盤面のロックは、SquareonClick を無効化することで表現します.

object Square:
  def apply(loc: Int, cell: Cell, winner: Option[Boolean]) =
    div(
      button(
        cell.show,
        onClick
          .filter(_ => cell == Cell.Empty && winner.isEmpty)
          .mapTo(Cmd.Choose(loc)) --> Game.obs,
        disabled := cell != Cell.Empty
      ).amend(
        fontSize.larger,
        width("56px"),
        height("56px"),
        display.block,
        color.black,
        fontWeight.bold
      ),
      padding("2px")
    )

Square のシグネチャが変わって勝者の状態の変更も subscribe するようになったので cells も少し修正します. withCurrentValueOfwonBy の値も追跡します.

  val cells = turnVar.signal
    .withCurrentValueOf(histories.signal)
    .withCurrentValueOf(wonBy)
    .map((turn, boards, winner) =>
      boards(turn).zipWithIndex
        .map((cell, loc) => Square(loc, cell, winner))
        .grouped(3) // group 3 items into row
        .map(
          div(_).amend(
            display.flex,
            justifyContent.center,
            flexWrap.nowrap
          )
        )
        .toSeq
    )

履歴機能の実装はやや複雑です. まず状態を Seq[Cell] から Seq[Seq[Cell]] にする必要があります.

val histories = Var(Seq(Seq.fill(9)(Cell.Empty)))

また、特定の盤面に戻るための UI を追加する必要があります.

特定の盤面に戻るには turn を変更したいので、変更を発生させるためのコマンドを追加します.

enum Cmd:
  case Choose(loc: Int)
  case JumpTo(t: Int)

CmdJumpTo を追加すると、 Observer[Cmd]case で網羅性チェックの警告が出ているはずです.

盤面の状態の変更方法と一緒に修正します.

    case Cmd.Choose(loc) =>
      val turn = turnVar.now()
      val last = histories.now()(turn)
      last(loc) match
        case Cell.Empty =>
          val (leading, _ +: trailing) = last.splitAt(loc): @unchecked
          val present = (leading :+ (if turn % 2 == 0 then Cell.O
                                     else Cell.X)) ++ trailing
          histories.update(_.take(turn + 1) :+ present)
          if check(present) then wonBy.set(Some(turn % 2 == 0))
          turnVar.update(_ + 1)
        case _ => ()
    case Cmd.JumpTo(t) => turnVar.set(t)

盤面の更新が histories.update(_.take(turn + 1) :+ present)に変わっていることに注意しましょう.

(※ 元々は下のように一つの盤面しか管理していなかった)

          board.update(state =>
            val (leading, _ +: trailing) = state.splitAt(loc): @unchecked
            (leading :+ (if turn % 2 == 0 then Cell.O
                                      else Cell.X)) ++ trailing
          )

cells も指定された手番の盤面を描画できるように修正する必要があります.

  val cells = turnVar.signal
    .withCurrentValueOf(histories.signal)
    .withCurrentValueOf(wonBy)
    .map((turn, boards, winner) =>
      boards(turn).zipWithIndex
        .map((cell, loc) => Square(loc, cell, winner))
        .grouped(3) // group 3 items into row
        .map(
          div(_).amend(
            display.flex,
            justifyContent.center,
            flexWrap.nowrap
          )
        )
        .toSeq
    )

あとは、特定の盤面に戻るためのボタンを用意して UI を整えれば完成です.

完成したサンプルは https://github.com/i10416/tictactoe/blob/main/src/main/scala/tictactoe/TicTacToe.scala にあるのでこれを参考にしてください.

最終的に下のような UI で、特定の手番の盤面に戻ることができるようになります.

まとめ

以上のように React などの JavaScript 向けUIフレームワークに依存しないフロントエンドアプリケーションをシュッと書くことができます. Scala ですから!

余談

Laminar (とその状態管理ライブラリ Airstream) は柔軟な設計になっているので下の例のように React ライクにUIコンポーネントを表現することもできます.(どちらかといえば非推奨だが...)

case class Props(foo:Int,bar:Boolean)
case class State(buz: String)
object MyComponent:
  def initialStateFromProps(init:Props): State = ???
  def apply($props: Signal[Props]): Mod[HtmlElement] =
    onMountInsert { ctx =>
      val init = $props.observe(ctx.owner).now()
      val state = Var(initialStateFromProps(init))
      div(
        foo <--$props.combineWith(state.signal).map((p,s) => ???),
	button(onClick --> { _ => state.update(_.copy(???)) })
      )
    }

とはいえ柔軟すぎて指針が立ちにくいのでなんらかのパターンを決めた方がいいかもしれないですね🤔

Discussion