20
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ReasonML + React でフロントエンド開発

Last updated at Posted at 2018-05-28

関数型 AltJS には様々ありますが、今 ReasonML が熱いです。多分。

Reason

Simple, fast & type safe code that leverages the JavaScript & OCaml ecosystems

とある通り、 OCaml の影響を強く受けた言語となっております。

そして開発元は Facebook です。

Facebook といえば React ですよね?

開発元が同じなら多分相性もいいでしょう、という事で、その二つを組み合わせて使ってみましょう。

なお、 Facebook という事でライセンスが気になるかと思いますが、 Reason のライセンスは MIT でした。

ReasonReact

「開発元が同じなら相性もいい」というのはいかにも思考の浅い印象の意見ですが、強ち間違いとも言えません。

ReasonReactという公式のバインディングがあるからです。

今回はこのライブラリを使って、小さな Web アプリケーションを作ってみる事にします。

「プログラミングの卒業試験としてブラックジャックを実装してみるのが良い」という話が出ていたので、今回はそれを作ってみましょう。

完成したソースはこちらです。

ブラックジャック

私はよく知らなかったのですが、ブラックジャックって結構ルールが複雑なんですね。

賭け事の為のゲームなので、払い出しの倍率とかに関わってくるものも多くて、全部取り込むのは大変そうです。

なので、沢山あるルールを削ぎ落として、以下のような感じのルールで実装する事とします。

  • ディーラとプレイヤーの1対1で対戦
  • 交互にカードを引く( Hit )か引かない( Stand )かを決める
    • 一度カードを引かない選択をすると、以降ゲーム終了まで新しいカードは引けない
  • 今まで引いたカードの数字を合計する
    • J, Q, K は 10 として数える
    • A は 1 と 11 の都合の良い数字で数える
    • 合計が 21 を超えていたら負け(バースト)
    • そうでない場合は相手にターンを回す
  • 双方がカードを引かない場合、そこでゲームを終了する
    • 同様の計算を行い、合計が 21 に近い方が勝ち
  • ディーラーは合計が 17 になるまでカードを引き、 17 を超えたら引かない
  • 最初のターン、ディーラーの1枚目のカードは裏向き
    • プレイヤーの最初の行動の後、そのカードを表側にする
  • カードは無制限に利用する(同じカードが何枚でも出現し得る)
    • 全てのカードの出現率は同一

掛け金などは一切出てこない、健全なゲームとなっております。

実装する

早速実装していきましょう。

なお、この記事では ReasonML の文法の解説などは行わないので、そちらが知りたい方は公式サイトを御覧ください。

ReasonML + React で簡単に環境を整えるには、 ReasonScript を使うと早いのでこれを使いました。

書かれている方法でプロジェクトを作ると、 ReasonML + React の雛形が生成されます。

これを元に、アプリを作っていきましょう。

ゲームのロジック

View とロジックはある程度分離しておく方が、テストしやすいですしコードも追いやすいです。

そこで、ゲームのロジック部分を Game モジュールとして単独ファイルに切り出しましょう。

game.re ファイルを作り、そこにソースを書いていきます。

game.re
type mark =
  | Spade
  | Club
  | Heart
  | Diam;

type cards = list((int, mark));

type action =
  | NoAction
  | Hit
  | Stand;

type state = {
  player: cards,
  dealer: cards,
  last_action: action,
};

type status =
  | Playing(state)
  | Win(state)
  | Lose(state);

let get_player =
  fun
  | Playing({player}) => player
  | Win({player}) => player
  | Lose({player}) => player;

let get_dealer =
  fun
  | Playing({dealer}) => dealer
  | Win({dealer}) => dealer
  | Lose({dealer}) => dealer;

let get_last_action =
  fun
  | Playing({last_action}) => last_action
  | Win({last_action}) => last_action
  | Lose({last_action}) => last_action;

exception UnexpectedRandomNumber(int);

let draw_card = () => {
  let number =
    switch (Random.int(13)) {
    | 0 => 13
    | n => n
    };
  let mark =
    switch (Random.int(4)) {
    | 0 => Spade
    | 1 => Club
    | 2 => Heart
    | 3 => Diam
    | n => raise(UnexpectedRandomNumber(n))
    };
  (number, mark);
};

let sum_cards = (cards: cards) => {
  let cards = List.map(((n, _)) => n, cards);

  let (sum, ac) =
    List.fold_left(
      (acc, n) =>
        switch (acc) {
        | (s, a) when n == 1 => (s + 1, a + 1)
        | (s, a) when n == 11 || n == 12 || n == 13 => (s + 10, a)
        | (s, a) => (s + n, a)
        },
      (0, 0),
      cards,
    );

  let rec ret = (sum, ac) =>
    switch (ac) {
    | 0 => sum
    | _ when sum + 10 > 21 => sum
    | _ => ret(sum + 10, ac - 1)
    };

  ret(sum, ac);
};

let is_bust = cards => sum_cards(cards) > 21;

let will_dealer_draw = cards => sum_cards(cards) < 17;

let init = () => {
  Random.self_init();
  {
    player: [draw_card(), draw_card()],
    dealer: [draw_card(), draw_card()],
    last_action: NoAction,
  };
};

let rec next = (action, {player, dealer, last_action}) => {
  let (player, last_action) =
    switch (last_action, action) {
    | (_, Stand | NoAction)
    | (Stand, Hit) => (player, Stand)
    | (_, Hit) => ([draw_card(), ...player], Hit)
    };

  if (is_bust(player)) {
    let state = {player, dealer, last_action};
    Lose(state);
  } else {
    let dealer =
      will_dealer_draw(dealer) ? [draw_card(), ...dealer] : dealer;
    let state = {player, dealer, last_action};

    switch (is_bust(dealer), will_dealer_draw(dealer), last_action) {
    | (true, _, _) => Win(state)
    | (_, _, Hit) => Playing(state)
    | (_, true, Stand) => next(Stand, state)
    | (_, false, Stand) when sum_cards(player) > sum_cards(dealer) =>
      Win(state)
    | (_, false, Stand) => Lose(state)
    | _ => Playing(state)
    };
  };
};

これがブラックジャックのゲームのロジックとなります。

まず、ゲーム内で使う型を宣言している部分です。

( trivial な型については説明を省きます。)

type mark =
  | Spade
  | Club
  | Heart
  | Diam;

mark 型は、トランプのマーク(スート)ですね。代数的データ型として、4種類をそれぞれ型にします。

今回のルールだとトランプの柄はゲームに影響しないですが、見た目は重要ですので、情報として含めてあります。

type action =
  | NoAction
  | Hit
  | Stand;

action 型はプレイヤーの行動で、こちらも代数的データ型にしてあります。

NoAction は初手の場合にのみ発生する特殊な型ですね。

type state = {
  player: cards,
  dealer: cards,
  last_action: action,
};

state 型はゲームの状態を保持するレコードとなっています。

プレイヤーとディーラーのそれぞれの手札と、プレイヤーの最後の行動を保持しています。

関数

次に、それぞれの関数を見ていきます。

(こちらも trivial な関数については説明を省きます。)

exception UnexpectedRandomNumber(int);

let draw_card = () => {
  let number =
    switch (Random.int(13)) {
    | 0 => 13
    | n => n
    };
  let mark =
    switch (Random.int(4)) {
    | 0 => Spade
    | 1 => Club
    | 2 => Heart
    | 3 => Diam
    | n => raise(UnexpectedRandomNumber(n))
    };
  (number, mark);
};

新しいカードを生成して返す関数です。

Random.int 関数を使い、ランダムな数値を取得してそれを使ってカードの数字と絵柄を決めています。

Random.int(n) は $[0..n)$ の範囲でランダムな整数を返すので、例えば Random.int(13) は 0 から 12 の間の何か数を返します。

同様に Random.int(4) も 0 から 3 までの範囲なので、それ以外の数字が返ってくる可能性はありません。

が、 switch 式の網羅性チェックを宥める為に、例外を定義して万が一想定外の数が返ってくると例外を投げるようにしています。

let sum_cards = (cards: cards) => {
  let cards = List.map(((n, _)) => n, cards);

  let (sum, ac) =
    List.fold_left(
      (acc, n) =>
        switch (acc) {
        | (s, a) when n == 1 => (s + 1, a + 1)
        | (s, a) when n == 11 || n == 12 || n == 13 => (s + 10, a)
        | (s, a) => (s + n, a)
        },
      (0, 0),
      cards,
    );

  let rec ret = (sum, ac) =>
    switch (ac) {
    | 0 => sum
    | _ when sum + 10 > 21 => sum
    | _ => ret(sum + 10, ac - 1)
    };

  ret(sum, ac);
};

カードの数値の合計を算出する関数です。

カードの数値に関しては以下の規則がありました。

  • J, Q, K は 10 として数える
  • A は 1 と 11 の都合の良い方で数える

この規則の中で、できるだけ 21 に近い数字を出すように計算しています。

let init = () => {
  Random.self_init();
  {
    player: [draw_card(), draw_card()],
    dealer: [draw_card(), draw_card()],
    last_action: NoAction,
  };
};

ゲームの状態を初期化する関数です。

Random.self_init() の呼び出しで、乱数のシードを初期化しています。
これを忘れると、常に同じ結果になります。
ちなみに self_init は、外からシード値を与えるのではなく、何かいい感じのシード値を勝手に使ってくれる処理系依存の便利な関数です。
(ただ、今回はこういう実装ですが、テストのしやすさを考えると、乱数シードの設定はモジュールの外に出すのが正解でしょうね。)

そして、ゲームの初期状態を作ります。
ゲームの最初には、プレイヤーとディーラーにそれぞれ 2 枚ずつカードが配られるので、そのようにしています。

last_action には NoAction を設定しています。
これは、盤面がプレイヤーの最初の行動前か後かを判断するのに利用します。

let rec next = (action, {player, dealer, last_action}) => {
  let (player, last_action) =
    switch (last_action, action) {
    | (_, Stand | NoAction)
    | (Stand, Hit) => (player, Stand)
    | (_, Hit) => ([draw_card(), ...player], Hit)
    };

  if (is_bust(player)) {
    let state = {player, dealer, last_action};
    Lose(state);
  } else {
    let dealer =
      will_dealer_draw(dealer) ? [draw_card(), ...dealer] : dealer;
    let state = {player, dealer, last_action};

    switch (is_bust(dealer), will_dealer_draw(dealer), last_action) {
    | (true, _, _) => Win(state)
    | (_, _, Hit) => Playing(state)
    | (_, true, Stand) => next(Stand, state)
    | (_, false, Stand) when sum_cards(player) > sum_cards(dealer) =>
      Win(state)
    | (_, false, Stand) => Lose(state)
    | _ => Playing(state)
    };
  };
};

ゲームのターンを進める関数です。

プレイヤーの行動、バーストの判定が終わってから、ディーラーの行動決定やバースト判定を行います。

その後、どちらもバーストしていなかった場合、膠着状態(プレイヤーは Stand しており、ディーラーは 17 を超えている場合)であれば両者の数値を比較して勝敗を決め、プレイヤーが Hit であれば次のプレイヤーの手を待ち、プレイヤーが Stand であればプレイヤーの判断を待たずに再度この関数を呼び出します。

これで、簡易ブラックジャックのゲーム部分の実装は完成です。

init 関数で初期状態を作った後は、プレイヤーからの入力を next 関数に渡して行けば、ゲームを進める事ができるはずです。

ゲームの View

ロジックを乗せる View を作ります。

React が活躍するのはこの部分ですね。

[%bs.raw {|require('./app.css')|}];

module RR = ReasonReact;
let s = RR.string;

let mark_to_str = mark =>
  switch (mark) {
  | Game.Spade => {js||js}
  | Game.Club => {js||js}
  | Game.Heart => {js||js}
  | Game.Diam => {js||js}
  };

let is_red = mark =>
  switch (mark) {
  | Game.Heart
  | Game.Diam => true
  | _ => false
  };

let render_card = (number, mark) => {
  let str = mark_to_str(mark);
  let colour = is_red(mark) ? "red" : "";
  <div className="card">
    <div className="front">
      <span className={j|top-left $colour|j}> (s({j|$number$str|j})) </span>
      <span className={j|middle $colour|j}> (s(str)) </span>
      <span className={j|bottom-right $colour|j}>
        (s({j|$number$str|j}))
      </span>
    </div>
  </div>;
};

let show_cards = cards =>
  cards
  |> List.rev
  |> List.map(
       fun
       | (1, mark) => render_card("A", mark)
       | (11, mark) => render_card("J", mark)
       | (12, mark) => render_card("Q", mark)
       | (13, mark) => render_card("K", mark)
       | (n, mark) => render_card(string_of_int(n), mark),
     )
  |> Array.of_list;

let render_cards_table = cards =>
  RR.createDomElement("div", ~props={"className": "play-table"}, cards);

type state = Game.status;

let initialState = () => Game.Playing(Game.init());

type action =
  | Hit
  | Stand
  | Retry;

let reducer = (action, state) =>
  switch (action, state) {
  | (Hit, Game.Playing(game_state)) =>
    RR.Update(Game.next(Game.Hit, game_state))
  | (Stand, Game.Playing(game_state)) =>
    RR.Update(Game.next(Game.Stand, game_state))
  | (Retry, _) => RR.Update(Game.Playing(Game.init()))
  | _ => RR.Update(state)
  };

let component = RR.reducerComponent("App");

let make = _children => {
  ...component,
  initialState,
  reducer,
  render: self => {
    let player = Game.get_player(self.state);
    let dealer = Game.get_dealer(self.state);
    let last_action = Game.get_last_action(self.state);
    let player_cards = show_cards(player);
    let dealer_cards =
      switch (last_action) {
      | Game.NoAction =>
        let cards = show_cards(dealer);
        let hided_cards =
          <div className="card"> <div className="back" /> </div>;
        cards[0] = hided_cards;
        cards;
      | _ => show_cards(dealer)
      };
    let panel =
      switch (self.state) {
      | Game.Playing(_) when Game.get_last_action(self.state) == Game.Stand =>
        <div className="button-table">
          <button className="disabled" disabled=true> (s("Hit")) </button>
          <button onClick=(_ => self.send(Stand))> (s("Stand")) </button>
        </div>
      | Game.Playing(_) =>
        <div className="button-table">
          <button onClick=(_ => self.send(Hit))> (s("Hit")) </button>
          <button onClick=(_ => self.send(Stand))> (s("Stand")) </button>
        </div>
      | Game.Win(_) =>
        <div className="result-table">
          <div> <span className="result win"> (s("You win!")) </span> </div>
          <div>
            <button onClick=(_ => self.send(Retry))> (s("Retry")) </button>
          </div>
        </div>
      | Game.Lose(_) =>
        <div className="result-table">
          <div> <span className="result lose"> (s("You lose!")) </span> </div>
          <div>
            <button onClick=(_ => self.send(Retry))> (s("Retry")) </button>
          </div>
        </div>
      };
    <div>
      <div className="table-wrapper">
        (render_cards_table(dealer_cards))
        <span className="cast-label"> (s("DEALER")) </span>
      </div>
      <div className="table-wrapper">
        <span className="cast-label"> (s("PLAYER")) </span>
        (render_cards_table(player_cards))
      </div>
      panel
    </div>;
  },
};

ReasonReact で React のコンポーネントを作る場合は、そのコンポーネント名のモジュールの中で、 ReasonReact モジュールに用意された関数を使ってコンポーネントの雛形を作った後、 make 関数を定義して、そのモジュールを拡張した(所定の関数を置き換えた)レコードを返すようにします。

ステートを持たないコンポーネントの例:

let component = ReasonReact.statelessComponent("App");

let make = _children => {
  ...component,
  render: _self =>
    <div>
      {ReasonReact.string("Hello!")}
    </div>
};

ReasonReact.reducerComponent は、状態や reducer を持つコンポーネントの雛形を作る関数です。この関数で作り出したオブジェクトに、 reducerrender といった名前の関数を引き渡せば、独自のコンポーネントを作る事ができます。

JSX 内でコンポーネントを利用すると make 関数が呼び出され、プロパティを名前付き引数で、子要素を配列として受け渡されます。この App コンポーネントの場合、何のプロパティも取らず子要素も利用しないので、どちらの引数も使っていません。

initialState は、初期状態( state )を返す関数です。
コンポーネントにはこれらの他にも、 React のコンポーネントのライフサイクル系メソッドに対応する関数を渡す事ができます。

reducer は、 action と state とを受け取って、新しい state を返す関数です。

render は、 そのコンポーネントがレンダリングする要素を返す関数です。引数である self は、そのコンポーネントの state を取り出したり、 self.send(action) を使う事でその action と現在の state とで自身の reducer を呼び出すといった使い方をします。

このファイルのコードがやっている事は、ゲームのロジック部分と表示とのつなぎ込みです。現在のゲーム状態(終了時なら勝敗、プレイ中なら両者のカード)を画面に反映させ、プレイヤーからの入力( Hit か Stand か、またゲームが終了しているなら再挑戦するか)を受け付けます。

その他

この2つのソースファイルの他、適切な css ファイル( app.re のソース中で読み込んでいる通り、 app.css という名前にしましょう)を準備すれば、もう動かせます。エントリーポイントである index.re は雛形のものをちょっと変えてほぼそのまま使います。

動かしてみる

$ yarn start

で動かすと、勝手にブラウザが立ち上がってブラックジャックのプレイ画面が表示されます。

試しに遊んでみましょう。

blackjack.mov.gif

(しかし掛け金の無いブラックジャックはあまり楽しくありませんね……。)

ReasonML と ReasonReact との感想

ReasonML をつかって小さな Web アプリケーションを作ってみました。
以下のような部分で「良いなぁ」と感じました。

  • 型安全な言語でコンパイル時にミスがわかる
  • 優秀な型推論
  • 代数的データ型とパターンマッチの相性が素晴らしい

これらは ReasonML というか、関数型言語全般に対して言える事ではありますが……。
型安全性の高さや、型推論の優秀さ、多相関数の便利さなどは、元にした OCaml 由来でとても使いやすいです。
代数的データ型とパターンマッチの組み合わせも、言うまでもありません。

特に React + Redux はこの辺りと本当に相性がよく、 action や state を代数的データ型やレコードとして定義して、 reducer 内でそれをパターンマッチして処理を振り分ける事で、厄介なコンポーネントのステート更新処理を見やすく、分かりやすく書き下す事ができます。
(この辺りは、 Redux に強く影響を与えたのが Elm である事を考えると当然でしょうか。)

これらの利点を享受しつつ、純粋を志向する関数型言語だと難しい破壊的変更やグローバルな状態変更を、比較的簡単に行えるのも利点と言えるかもしれません。
例えば今回は、ランダムな値を取得したり、配列の要素を破壊的に変更するといった事を行っています。
小規模で制御されている限り、クイック・アンド・ダーティな実装ができた方が何事に於いても楽なのです。

そして、以下の点で「ちょっと微妙だなぁ……」と感じました。

  • 文法

文法の好みは人それぞれなので、どういう文法が良いとか、悪いとかは言えないと思います。
ただ ReasonML の文法は、 OCaml と JavaScript を足して二で割ったような雰囲気をしています。
(というか、 OCaml を JavaScript っぽく書けるようにしたと言いますか……。)

OCaml とも JavaScript とも微妙に違うものなので、「新しい文法を覚える」という手間が発生してしまいます。

その上、現状の ReasonML はまず OCaml にトランスパイルされて、そこから BuckleScript として JavaScript に変換されるという手順を踏んでいるので、使いこなそうとすると、 BuckleScript ( OCaml )についても知る必要が出てきます。

そういう意味で、学習コストは小さくないように見えました。

まとめ

ReasonML を使って小さなフロントエンドを作ってみました。

関数型言語の力強さを活かして楽しくコーディングする事ができましたが、反面、慣れない文法に苦戦したりもしました。

ReasonML でできる事のほとんどはおそらく BuckleScript でも可能なので( 不可能な事の一番大きなものは、 JSX の利用でしょうか? )、好みの問題ではありますが、 BuckleScript を使っても良いかと思います。

参考にしたサイトなど

20
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?