📖

ノベルゲームのシナリオをJSXで書きたい

commits3 min read

Zenn初投稿です。よろしくお願いします。

最近遊んだアドベンチャーゲームがとても面白くて自分でも作ってみたくなったので、こんなものを作ってみました。

https://github.com/mkizka/advx

RPGツクールっぽい画面↓

使い方

function App() {
  const choice = useChoice();
  return (
    <Game>
      <SenarioRenderer>
        <Text>テキスト</Text>
        <Choice choices={["選択肢1", "選択肢2"]} />
        <Branch if={choice.answer == "選択肢1"}>
          <Text>選択肢1が選ばれました</Text>
        </Branch>
        <Branch if={choice.answer == "選択肢2"}>
          <Text>選択肢2が選ばれました</Text>
        </Branch>
      </SenarioRenderer>
      <ScreenRenderer>
        <MessageWindow />
      </ScreenRenderer>
    </Game>
  );
}

基本的なコンポーネント

  • Game
    • 一番上を囲うコンポーネント
    • 中身はContext.Providerを並べてるだけ
  • SenarioRenderer
    • シナリオを書くためのコンポーネント
    • カスタムレンダラが渡されたchildrenをJSONに変換する
  • ScreenRenderer
    • 画面に要素を配置するためのコンポーネント
    • 中身はほぼ@inlet/react-pixiStageを置いてるだけ

その他や詳細はテスト用ディレクトリのApp.tsxや本体の実装を見てください。

https://github.com/mkizka/advx/blob/main/playground/src/App.tsx

なぜ作ったか

RPGツクールやティラノスクリプトなど、ある程度形式が決まったゲームをなるべく簡単に作るためのツールはすでに多くあると思います。

それらのいくつかは、JavaScriptなどを用いてツールを拡張することが出来るものがあります。
でも凝ったことをしようとすればするほどそうした拡張の比率がどんどん増えてきて、拡張と元々の構文とで実装の見通しが悪くなると思うんですよね。

ということで全部JavaScriptで書けるようなやつがあっても良いんじゃないかと思ったので、作ってみました。

やったこと

多くの部分はライブラリに頼っており特に難しいことはしていませんが、いろいろ調べたり考えたことを書いておきます。

Reactのカスタムレンダラの実装(react-reconciler)

シナリオをJSXで書くにあたって専用のレンダラを作りました。

Reactでカスタムレンダラを作るにはreact-reconcilerに、各種メソッドを実装したHostConfigと呼ばれるオブジェクトを渡す必要があります。
HostConfigに実装したメソッドはReactのレンダリング時に呼び出され、ツリー構造の生成や、差分検出、差分の適用などが行われるようです。

最新の情報ではありませんが以下の記事などが参考になりました。

https://blog.atulr.com/react-custom-renderer-1/

このあたりのリポジトリでHostConfigの実際の実装が確認できます。

https://github.com/facebook/react/blob/main/packages/react-art/src/ReactARTHostConfig.js
https://github.com/inlet/react-pixi/blob/master/src/reconciler/hostconfig.js
https://github.com/jiayihu/react-tiny-dom/blob/master/renderer/tiny-dom.js

シナリオ記述のためのコンポーネント設計

カスタムレンダラの実装が複雑にならないように、レンダリング用のコンポーネント(react-domで言うところのDOM要素)は以下の2つのみにしました。

  • Text
    • <Text>テキスト</Text>のように書くと画面に描画するためのテキストになる
  • Action
    • 渡された関数を実行して次の要素に移る

つまり、レンダリング結果は結局のところ以下のようになります。

[
  { type: "Text", message: "テキスト" },
  { type: "Action", action: ... },
  { type: "Text", message: "テキスト" },
  { type: "Text", message: "テキスト" }
]

これらがメッセージウィンドウのクリックなどに応じて一つずつ表示または実行されていきます。

従って、Text以外のシナリオ用コンポーネントはActionをラップして実装しています。
例えば、選択肢を表示するChoiceはこんな感じです。

function Choice({ choices }) {
  const choice = useChoice();
  return <Action action={() => choice.setChoices(choices)} />;
}

問題:異なるレンダラ間でコンテキストを共有できない

現状、実行すると以下のような警告がコンソールに表示されます。

Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported.

これは自作のレンダラと画面描画用のレンダラ(@inlet/react-pixi)とでコンテキストを共有しようとしていることに起因しています。

本来、異なるレンダラ間でコンテキストを共有する場合は、HostConfigのisPrimaryRendererfalseにすることで許容されるようです。

https://github.com/facebook/react/issues/17275

しかし今回はreact-dom、自作レンダラ、画面描画レンダラの3つがあり、react-domとそれぞれで共有する分には警告はでませんが、後ろ2つで同じコンテキストを使おうとしているため警告が出ています。

- react-dom
  - @inlet/react-pixiのレンダラ
  - 自作レンダラ

今のところどうしようもないようなので、大きな問題が出るまでは放置しています。

おわり

最初に想定していたものはおおむね実現出来たので満足です。Reconcilerに触れてみて、Reactの理解も深まった気がします。

かなり大雑把に実装してしまったので、もっと上手く出来るという方はぜひ作ってください。そして使わせてください。

GitHubで編集を提案

Discussion

ログインするとコメントできます