React Tutorial の Tic Tac Toe を Scala.js & Laminar & Vite でやる.
Laminar とは...? 🤔となった人はこちらの記事も読んでみるといいかもしれません.
Goal
レポジトリはこちら
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
をみてみましょう.
次のようなコードがあります.
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")
に置き換えます.
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
などのアトリビュート、onClick
や onBlur
などのイベントハンドラーです.
このままでは味気ないので 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 要素には amend
や amendThis
というメソッドが生えていて、case class
の copy
のように元のデータを修正した新しいインスタンスを作ることができます.
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 に通知する概念です. 開発者は Var
の signal
メソッドからSignal
を取得してこの Signal
をリアクティブな要素に <--
メソッドを使って bind することができます.
(と言っても、何を言っているかわからないかもしれないので下の例やLaminar のドキュメントの Examples を見てください.)
Signal
は直近の値を保持していて listener が subscribe するとその値を通知しますが、signal.changes
メソッドや EventBus
から得られる EventStream
は subscribe 以降の変更があってはじめてデータを listeners に通知します.
さて、上の turn
と board
に対応する UI を作っていきます.
Square
はそれぞれのマス目をあらわすので自身の場所と状態がわかるように loc:Int
と cell: 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 され、Observer
が board
や turn
を変更することで 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"
Square
の toString()
を show
に置き換えます.
object Square:
def apply(loc: Int, cell: Cell) =
div(
button(
cell.show,
Enhancement
さて、ここまでで以下のような機能の実装がまだ残っています.
- 勝者の判定
- 盤面の履歴機能
勝者の判定は盤面に同じマークが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 _ => ()
}
盤面のロックは、Square
の onClick
を無効化することで表現します.
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
も少し修正します. withCurrentValueOf
で wonBy
の値も追跡します.
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)
Cmd
に JumpTo
を追加すると、 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