GWの進捗としてRPG作った / redux-saga でメインループ処理、JSONSchemaからのコード生成

作った。GWの間、コンビニと近所のカフェ以外に外出してないし、ゲームもしてない。

https://mizchi-sandbox.github.io/rpg-prototype/ で触れる。デザインはしょぼい。Chrome以外で動いてる気がしない。

コードはここ https://github.com/mizchi-sandbox/rpg-prototype

仮素材はウディタに付いてくるサンプル素材をお借りした。

WOLF RPGエディター公式サイト 【RPG作成フリーソフト】

仕様

  • Spaceでポーズ&リスタート
  • クリックでスキルの使用
  • 一度スキルを使ったらクールダウンがある
  • Player1 だけ操作できる

あとはなんか察してほしい。

何故作ったか

前々から、ゲーム、とくにRPGを作りたいと思ってたのだけど、メインループがすんなり綺麗にかけたためしがない。趣味プロジェクトは技術的に辛いとやる気が無くなる。

ゲーム作りたい人は、そこを気合で乗り越えるんだろうけど、そこに関しては自分はエンジニア気質なのが勝つので、自分が納得できるまで綺麗なアーキテクチャが実現できるまで、なんども試作していて、今回やっと納得できそうなコードが書けた。

あと http://anond.hatelabo.jp/20170507200847 みたいなこと言われて、いや、揶揄じゃなくて、別にそう見えるのは仕方ないよなと思いつつ、いずれ金にしたいという思いはありつつも成果はある程度まで公開しとこうと思った。

技術

主な構成要素は flow/react/react-redux/redux-saga。 まず一番難しいであろう、戦闘画面だけ作った。

Immutable

コード、汚くはないつもりだが、かなり手癖が強い。頭のなかにある将来的な仕様も同時に作ってるので、デッドコードもたくさんある。テストもコメントも足りない。プロトタイピングなので、こんなもんで許して。コミットログは最悪。

特徴があるとすると、一部を除いて、オブジェクトへの副作用絶対ほぼ禁止、というルールで書いた。

たとえば入力されたコマンドを実行しながら、Resultの配列を作る処理はこうなってる。

export function processCommandPhase(
  state: BattleState,
  commandQueue: Command[]
): CommandApplicationProgress {
  return commandQueue.reduce(
    (next: CommandApplicationProgress, nextCmd: Command) => {
      const { state: nextState, results } = nextCmd(next.state)
      return {
        state: nextState,
        results: next.results.concat(results)
      }
    },
    { state, results: [] }
  )
}

また、 https://github.com/peterkhayes/eslint-plugin-mutation というルールを導入した。

やってみると、ほぼすべてImmutableにしたことで、コードの予測のしづらさがなくなり、心理的な安心が得られた。ただ、コードを書く量はどうしても増える。四則演算ごとに計算関数生やさないといけないから当然っちゃ当然だが。

このスタイル、おそらく盛大にメモリを使うと予想していたが、メモリプロファイラを見てもそこまで漏れてる様子がない。

GC負荷はそれなりにかかってそうなので、あとで調べる。

redux-saga でメインループ制御

1フレームの処理はこんな感じになる。

  • 入力処理
  • コマンド生成
  • コマンド処理
  • コマンド結果をビューに送信

で、二種類実装してみた。一つは某F○っぽく、常に時間が経過するアクティブなやつで、もう一つは入力可能なコマンドがあると一旦処理を止めて、入力があると再開する。

これを表現するために、 battleSaga.js というエモい名前で次のような処理を書いている。

let waitMode = false
function* start(_action: any) {
  // Use wait mode
  waitMode = location.search.indexOf('wait') > -1

  let state: BattleState = createBattleMock()

  // Sync first
  yield put(sync(state))

  // Start loop
  while (true) {
    // InputQueue buffer
    let takenInputQueue: Input[] = []

    // WaitMode: check executableSkill
    if (waitMode) {
      // Wait input on wait mode
      const executableSkill = findActiveSkill(state.battlers)
      if (executableSkill) {
        yield put(sync(state))
        yield put(battleActions.paused())
        const takenInputAction: { payload: Input } = yield take(
          battleActions.ADD_INPUT_TO_QUEUE
        )
        takenInputQueue = [takenInputAction.payload]
        yield put(battleActions.restarted())
        yield call(delay, 100)
      }
    }

    // ActiveMode: wait interval or intercept by pause request
    if (!waitMode) {
      const { paused } = yield race({
        waited: call(delay, 300),
        paused: take(battleActions.REQUEST_PAUSE)
      })
      // if user request pausing, wait for restart
      if (paused) {
        yield put(battleActions.paused())
        yield take(battleActions.REQUEST_RESTART)
        yield put(battleActions.restarted())
      }
      // Get input
      takenInputQueue = hydrateInputQueue()
      yield put(battleActions.updateInputQueue([]))
    }

    // Update state
    const processed = processTurn(state, takenInputQueue)
    state = processed.state
    for (const result of processed.results) {
      switch (result.type) {
        case ResultActions.LOG:
          yield put(battleActions.log(result.message))
          if (waitMode) {
            yield put(sync(state))
            yield call(delay, 100)
          }
          break
      }
    }

    // Check finished flag
    const finshed = isFinished(state)
    if (finshed) {
      yield put(sync(state))
      // yield put(battleActions.log(`${finshed.winner} win.`))
      yield put(battleActions.openResult(`${finshed.winner} win.`))
      break
    }

    // Sync state by each frame on active
    if (!waitMode) {
      yield put(sync(state))
    }

    // Clear inputQueue
    takenInputQueue = []
  }
}

generator関数の無限ループでyieldして一個ずつイベントを処理する。

put がビューへの dispatch, take が イベント待ちだと思えばよい。call は非同期関数呼び出し。

たぶん制御フロー的に面白いのはここ。

      const { paused } = yield race({
        waited: call(delay, 300),
        paused: take(battleActions.REQUEST_PAUSE)
      })
      // if user request pausing, wait for restart
      if (paused) {
        yield put(battleActions.paused())
        yield take(battleActions.REQUEST_RESTART)
        yield put(battleActions.restarted())
      }

saga の race コマンドで、「300ms 待つ」もしくは「ポーズ命令を受け取る」の速い方で処理を進めて、もしポーズ命令だったなら、リスタート命令が来るまで待機する。

saga、やってる処理が非常にRx的だが、このストップ&リスタートをRxで自然に書ける気がしない。いや、単に自分がRx詳しくないだけかもだが。

また、これをReducerからsaga側にゲームロジックの更新を全部追い出したことで、実際に副作用を起こす場所を、サーバー側に持っていけそう。オーバーエンジニアリングの成果だ。

ゲームの内部状態をビューに伝える箇所がここ。

    // Update state
    const processed = processTurn(state, takenInputQueue)
    state = processed.state
    for (const result of processed.results) {
      switch (result.type) {
        case ResultActions.LOG:
          yield put(battleActions.log(result.message))
          if (waitMode) {
            yield put(sync(state))
            yield call(delay, 100)
          }
          break
      }
    }

今はちょっと時間がなくて、LOG イベントしか実装してないんだけど、ここでアニメーションの再生命令などを書くと、ステップ処理をシュッと綺麗に書けそう。

JSONSchemaでコード生成

昔ポシャったやつから借りてきた。ので、ほとんど使われてないが…

masterdata/
├── data
│   ├── consume-item-data.yml
│   ├── dungeon-data.yml
│   ├── equip-data.yml
│   ├── job-data.yml
│   ├── material-data.yml
│   ├── monster-data.yml
│   ├── race-data.yml
│   ├── skill-data.yml
│   └── troop-data.yml
└── schema
    ├── consume-item-schema.yml
    ├── dungeon-schema.yml
    ├── job-schema.yml
    ├── material-schema.yml
    ├── monster-schema.yml
    ├── race-schema.yml
    ├── skill-schema.yml
    └── troop-schema.yml

2 directories, 17 files

script/gen-code.js が、こんな感じのコードを生成する。

/* @flow */
/* eslint-disable */
import data from './data'

// === ConsumeItem ===
export type ConsumeItemId = '$life-herb'
export type ConsumeItemData = {
  id: string;
  displayName: string;
  stackable?: boolean;
};
export function loadConsumeItemData(id: ConsumeItemId): ConsumeItemData  { return data['consume-item'].find(i => i.id === id) }

// === Job ===
export type JobId = '$warrior' | '$mage' | '$rogue' | '$novice'
export type JobData = {
  id: string;
  displayName: string;
  life: string;
  mana: string;
};
export function loadJobData(id: JobId): JobData  { return data['job'].find(i => i.id === id) }
// ... 略

データ自体は仮なので、とくに意味はない。生成できてるというのが大事。

Storybook

試してみたら結構良かった。 なんとなくアトミックデザインっぽい分割をしてみている。

反省

仕事で使うReact/Reduxの知見を深める目的もあった。redux-sagaは元々半信半疑で、今回はユースケースに合うので使ってみたんだけど、大抵のケースではオーバーキルな気はする。ただ、今回はこれが生命線っていえるぐらいMVP。

前回の試作(1年半ぐらい前だったと思う。公開してない)と比較して、RPGツクールMVのエンジン部分のコードと、Game Programming Patterns を読んだ経験が生きた。

このGame Programming Patterns って本すごくよくて、ゲームの本というよりは、ゲームの視点でGoFデザパタを再考する、という感じで読むと、すごくいい。この手の本は筆者の視点がC++に偏ってることが多いが、関数型も動的型付の言語で実装する際のことも言及されていて、とてもバランスがよく、読みながら納得しかない。SPAのアーキテクチャGUIにとても近いので、実際に参考にできる技術も多い。

今後

  • マスターデータ増やす
  • 戦闘以外の画面を増やす
  • セーブデータの永続化層を作る
  • react-native に突っ込む

現状、まだ動くことがわかっただけで、ゲーム性と呼べるものが発生してない。

自分的には設計に納得したので、もう少し作り続けられそう。頑張る。