search
LoginSignup
4

More than 1 year has passed since last update.

posted at

updated at

ReScript で作ったモアイまわし風ブラウザゲームの実装紹介

はじめに

この記事は Opt Technologies Advent Calendar 2020 の 6 日目の記事です。
この記事を書いたタイミングでふと弊社アドカレを見たら 取られているのに記事が書かれていない枠がいっぱいあった 枠が空いていたので、折角なのでアドカレ記事ということにしました。

皆さんは名作ブラウザゲーム1モアイまわし」をご存知でしょうか?
あの複雑なモアイの挙動をどう実現しているのか朝も起きずに悩んだ末、これならそれっぽくなるんじゃないかという方針を思いついたので、最近(僕の中で)話題の ReScript を使って実装してみました。
そこで今回は、その実装(リポジトリはこちら)と ReScript の紹介をしたいと思います。
もしおかしなところがあったらコメント欄などで 優しく 指摘していただけると助かります。

完成品

百聞は一見にしかず、まずは実際に動くものをご覧ください。

ロジック

今回思いついた方針は「モアイごとに直線を引き、その直線とマウスポインタの距離からモアイの角度を決定する」というものです2
ランダムに引いた直線との距離を使うことで、それぞれのモアイがマウスポインタの動きに対して違った動きをしてくれるはずです。

手順としては、まず直線の交点 p をランダムに決め、モアイごとの直線の傾き an と回転の周期3 cn もランダムに決めます。
交点と傾きから切片は求められるので、これでモアイごとに直線が引けることになります。
ゲームが開始したらマウスポインタの位置と直線の距離 d を求め、x = 360 * (d / cn) としたとき、transform: rotate(${x}deg); をモアイに適用すれば、モアイが回転してくれます。
マウスポインタが直線上にあるときの距離は 0 なので、交点上にマウスポインタがあるときは全てのモアイの回転が 0 (全てのモアイが立っている状態)になります。つまり、交点が正解の位置になる訳です。
最初に周期を決めましたが、「マウスポインタが動ける範囲内で引ける最長の線分の長さ」よりも大きい周期が 1 つでもあった場合、そのモアイは交点以外では立たないことになるので、必ず正解の位置は交点だけになります。

使用技術

ReScript

The JavaScript-like language you have been waiting for.

ReScript とは、OCaml ライクな AltJS です。
BuckleScript と Reason をご存知の方であれば、それらを統合したものだ4と言えば通じると思います。
Pattern Matching や Variant などの便利な機能が揃っています。
また、Interop が充実している点も非常に使い勝手が良いです。
文法は OCaml と JavaScript を足して 2 で割ったようなものになっています。

ReasonReact

ReasonReact helps you use Reason to build React components with deeply integrated, strong, static type safety.

ReasonReact は、Reason(もとい ReScript )で React を使うためのライブラリです。
ちゃんと(?) React hooks とかも使えます。
なお、本記事では React の細かい説明はしません。ご了承ください。

Parcel

Parcel is a web application bundler, differentiated by its developer experience. It offers blazing fast performance utilizing multicore processing, and requires zero configuration.

今回はバンドラに Parcel を使います。
ReScript ファイルそれぞれが JavaScript ファイルに変換されるので、それらをバンドルするのに使います。
設定ファイルを書かなくて良いので便利ですね。

Glitch

Glitch is the friendly community where everyone codes together!!

Glitch はウェブアプリをホスティングできるサービスです。
HTML + JavaScript + CSS から成る静的なサイトから、Express と SQLite を使ったバックエンドを持つものまで様々なアプリを実現できます。

セットアップ

まず、ReScript のコンパイラである bs-platform をグローバルにインストールします。

$ npm install -g bs-platform

次に、 bsb -init を実行して雛形を生成します5

$ bsb -init moai-mawashi

reason-react やら parcel-bundler やらを入れて、

$ npm install --save-dev reason-react parcel-bundler

公式ドキュメント bsb のテンプレートを参考にしつつ、bsconfig.json も書きます。

bsconfig.json
{
  "name": "moai-mawashi",
  "reason": { "react-jsx": 3 },
  "bsc-flags": ["-bs-super-errors"],
  "sources": [
    {
      "dir": "src",
      "subdirs": true
    }
  ],
  "package-specs": [
    {
      "module": "commonjs",
      "in-source": true
    }
  ],
  "suffix": ".bs.js",
  "namespace": true,
  "bs-dependencies": ["reason-react"],
  "refmt": 3
}

ビルドコマンドとかをどうするかは結構悩んだんですが、最終的にこうなりました。
開発時は npm run re:watch しながら npm run start することで良い感じに変更がブラウザに反映されます。
Parcel の設定は書いていないので、ビルド結果は dist/ 以下に吐かれます。

package.json
  "scripts": {
    "start": "parcel public/index.html --open",
    "build": "bsb -make-world -clean-world && parcel build public/index.html",
    "re:watch": "bsb -make-world -clean-world -w"
  }

実装

ディレクトリ構造はこんな感じ6
.bs.js ファイルは bsb が吐いた JavaScript ファイルです。

moai-mawashi/
├── README.md
├── bsconfig.json
├── dist/
├── node_modules/
├── package-lock.json
├── package.json
├── public
│   ├── index.html
│   └── style.css
└── src
    ├── Helper.bs.js
    ├── Helper.res
    ├── Index.bs.js
    ├── Index.res
    ├── Type.bs.js
    ├── Type.res
    └── components
        ├── App.bs.js
        ├── App.res
        ├── Moai.bs.js
        ├── Moai.res
        ├── Row.bs.js
        └── Row.res

src/components/App.res を見ながら ReScript の紹介をしていきます。
(Qiita が ReScript のハイライトに対応していないのが悲しい...)

App.res
@bs.send
external getElementById: (Dom.document, string) => Dom.element = "getElementById"
@bs.val external document: Dom.document = "document"
@bs.val external alert: string => unit = "alert"
@bs.get external offsetTop: Dom.element => int = "offsetTop"
@bs.get external offsetLeft: Dom.element => int = "offsetLeft"

最上部にあるこいつらは JavaScript の Interop です。
例えば @bs.send で JavaScript のグローバル空間にあるオブジェクトに対するメソッド呼び出しが ReScript 内で行えるようになります。
また、 @bs.val で JavaScript のグローバル空間にあるオブジェクトを ReScript 内で呼び出せるようになります。
もうなんとなく分かると思いますが、最後の @bs.get によって JavaScript のグローバル空間にあるメソッドを ReScript 内で呼び出せるようになります。

このように、JavaScript の世界にしか存在しないオブジェクトやメソッドを、型をつけた上で自由に呼び出すことができるのです。
また、JavaScript コードそのものを %%raw("const a = 1") といった形でベタ書きすることもできます。
ここでは紹介し切れませんが、Interop には他にも色々あります

App.res
open Belt
open Type
open Helper

まず Belt とは、ReScript 用の標準ライブラリです。
色々と便利な関数が揃っているので、基本的に ReScript を書くときは必要になるでしょう。
今回はファイルの先頭で open していますが、bsconfig.json"bsc-flags": ["-open Belt"] とすることで全ファイルに対して自動で読み込まれるので、そうする方がベターかも。

ところで、ReScript では Module やファイルの import/export が必要ありません
つまりコード中でおもむろに Belt.Array.length(["test"]) などと書けるのですが、いちいち Belt. を書くのをサボるために open しています。
Type と Helper は別ファイルなのですが、同様の理由で open しています。

App.res
@react.component
let make = () => {
  ...
};

@react.component スニペットと make() 関数を用意する7ことで component が作成できます。
props を受け取らせたい場合は make() に名前付き引数を持たせることで実現できます。
それでは make() の中身を見ていきましょう。

App.res
  let (params, setParams) = React.useState(_ => initParams())
  let (mousePoint, setMousePoint) = React.useState((_): point => (0, 0))
  let (origin, setOrigin) = React.useState((_): point => (0, 0))
  let (intersection, setIntersection) = React.useState(_ => randomPoint())
  let (startTime, setStartTime) = React.useState(_ => 0.)

ご存知 useState() が使えます。
hooks などの React 関連の関数は React Module 配下に生えています。

App.res
  let id = "field"

  React.useEffect1(() => {
    let field = getElementById(document, id)
    setOrigin(_ => (offsetLeft(field), offsetTop(field)))
    setStartTime(_ => Js.Date.now())
    None
  }, [])

useEffect1() も使えます。
(ここで最初に型をつけた getElementById() などを使っていますね。)
React Module 配下には useEffect() , useEffect1() , useEffect2() , ... , useEffect7() が生えており、それぞれ型が微妙に違います。
よく分かりませんが、用途に合ったものを使いましょう(?)

ちなみにここで使っている Js Module ですが、これは標準的な JavaScript API をラップしたライブラリです。
つまり、 Js.Date.now() は JavaScript の Date.now() と同一です。

ところで React.useEffect1() の第一引数の関数の最後に None とありますが、これは None を返しているという意味です。
まず関数の話をしますが、ReScript の関数に JavaScript でいうところの return にあたるものはありません。最後に評価された値が戻り値になります。

次に None とは何かですが、これは Option の一種です。
じゃあ Option はなんなんだという話ですが、これは「存在しない可能性のある値」を表します
JavaScript でいう null とか undefined がない代わりに、ReScript ではこの Option を使うことでフワフワした値を型安全に扱うことができます。
Option には Some('a)None があり、後者は「値が存在しない」ことを表しています。
React.useEffect1() の第一引数の型は unit => option<unit => unit> なのですが、今回は副作用を起こすだけなので特段何かを返す必要は(多分)ありません。故に None を返している、という訳です。

App.res
  let rowIds = Array.range(0, horizontalCells - 1)
  let children = Array.map(rowIds, rowId => {
    let key = "row-" ++ Int.toString(rowId)
    let p = Array.getExn(params, rowId)
    let rotations = calculateRotations(intersection, mousePoint, p)

    <Row key rowId rotations />
  })

  <div id onClick onMouseMove> {React.array(children)} </div>

ReasonReact では我らが JSX が使えます8
イベントリスナーの登録もできます(イベントリスナーの実装は割愛)。

折角(?)ReScript を使うんだから Array じゃなくて List を使った方が良いかとも思いましたが、 Array の方が取り回しやすかったのでこうなりました。
ReScript は ListArrayTuple なんかもあるので、コードの規模や性質から好きなものを選べるのが良いですね。
JSX に直接 Array を埋め込むことはできないので、 React.array() を忘れないようにしましょう。

その他 ReScript の特徴

今回のコードでは紹介できませんでしたが、ReScript には他にも色々と便利な機能があります。

昨今のフロントエンドは大量の状態を管理しないといけないですが、ReScript の強力な型システムはそれと相性が良いような気がします。
シンタックスが JavaScript 風で React の知識も生かせるので、趣味の範囲であれば割と導入もしやすい(と思っている)ので、是非試してみてください。

最後に

様々な事情で最近記事を書いていなかったのですが、久々にアウトプットできて満足しました。
僕が書きたいことをただ書き散らかしただけの記事になってしまったような気もしますが、どこかの誰かに面白がってもらえれば幸いです。


  1. かつてはフラッシュゲームでしたが、今は素のブラウザで動くみたいです。スマホアプリ版もあるみたい。 

  2. 作った後に比較してみたら、本家モアイまわしはもっと複雑な動きをしている気がしました。 

  3. モアイは自発的に回転する訳ではないので「周期」という表現が妥当かは微妙ですが、他に良い単語が思いつきませんでした。 

  4. Reason を書いたことのある方向けに更に補足すると、ReScript はなんとセミコロンが不要です! 

  5. ここで -theme オプションをつければ reason-react を使った雛形も作れます。 

  6. 実はこの記事を書く直前までファイルが .re だった(つまり私が書いていたのは ReScript じゃなくて Reason だった)のは内緒です。 

  7. ここは ~name という名前付き引数を持たせた方が良かったかも? 

  8. ReScript 単体でも JSX は使えるみたいです。 

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
What you can do with signing up
4