191
199

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.

JavaScriptでゲームシナリオを快適に実装する(デモ有)

Posted at

以下のような人向けの、実践的なサンプルやデモを含む記事です。

  • ゲームのシナリオデータをJavaScriptできれいに書き下したい
  • ゲームの敵・NPC等の動きをJavaScriptできれいに書き下したい
  • Flash や AfterEffects のタイムライン編集でやるようなアニメーションを JavaScript で実装したい
  • Web UIのチュートリアルとして「実際に操作している様子」を画面上で見せる機能を実装したい
  • async-await の同期版みたいなものが欲しい
  • ジェネレータの有用性を理解したい

「普段はJavaScriptをフロントエンド開発に使用している」という読者を想定して書いていますが、例えば async-await についてよく知らないという方は、それに関する記述を読み飛ばしていただいて構いません。

事の発端

今年の新年会で同僚から「エイプリルフールにひっそりと公開するつもりでゲームを作っている」ということを聞き、俺漏れも!とばかり絡みに行って話を膨らませていったのですが、広報に拾っていただいてけっこう大きく扱ってもらえることに…

infrawars-tw.png

(ゲーム本編は こちら で 2018/6/30 までダウンロードできます)

同僚はゲーム制作初挑戦。僕自身は小中学生の頃のBASIC・Z80アセンブラから始まり、Java2ME・C++での実務経験もあったものの、現在はバックエンド開発に携わっており、ブラウザJavaScriptでゲームを作るのは初めてでした。作るものの規模が小さいので不安はなかったのですが、「凝り始めるとどうかな?」という心配が当初少々あり、それが解消した経緯に関する記事になります。

JavaScriptでのゲーム制作で何が問題となるか

「無限ループがご法度な言語環境」で少し凝ったゲームを作ろうとしたことがある人には言うまでもないことですが、ゲーム制作ではしばしば「switch地獄」や「コールバック地獄」が発生します。

これは、「シナリオっぽいもの」や「様々な敵やNPCの動き」「凝ったアニメーション」を実装する過程で発生します。例えば、

例1(動きません)
function inn () {
  message('たびびとの やどやへ ようこそ')
  message('ひとばん 6ゴールドですが おとまりに なりますか? (Y/N)')
  const key = waitKey(['y', 'n'])

  if (key === 'y') {
    message('では おやすみなさいませ')
    waitFrame(30)
    waitFadeOut()
    waitFrame(30)
    // player.money -= 6
    // player.hp = player.maxHP
    // player.mp = player.maxMP
    waitFadeIn()
    message('おはよう ございます')
    /* if (player.withPrincess) */ message('ゆうべは おたのしみでしたね')
    waitKey()
  }

  message('では また どうぞ')
  waitKey()
}

シナリオはこんなふうに書けるのが理想ですが、一般的なJavaScriptでは直接このようには書けません。Webでは requestAnimationFrame が1フレームごとに呼ばれる仕組みが基本であり、無限ループでユーザイベントを待機するのはご法度。上記の例の waitKey のようなところで「ユーザの入力を待つ」ためには、処理の続きを記述したコールバックを渡しつつ、一旦システムに処理を戻してやる必要があります。Promise, async, await に感謝したことのある人なら、上のコードをコールバックで直接書いたらどれだけ複雑になるかが容易に想像できるでしょう。

そもそも、シナリオはゲームシステム本体と同じ言語で直接書こうとはせず、最初から別の方法を取る場合も多いと思います。例えば、

  • あくまでも「データ」として(JSONなどで)シナリオを記述し、それを読み取りながら再生する仕組みを実装する
  • ゲームシステム本体を記述する言語とは別に、その上で動くスクリプトエンジン用の言語(Luaなど)を用意する

といった方法です。が、それはそれで別の問題が発生することがあります。

  • シナリオをデータとして記述する場合、凝り始めて記述の自由度を高めていくと、ほぼ一つのDSLを自分で実装するのに等しい労力がかかる(そしてswitch地獄になる)
  • パフォーマンスの良くない言語の上にスクリプトエンジンを導入すると、さらにパフォーマンスが落ちる

ですから、例1のようにコールバックなしで書き下せる仕組みがあるのなら、まずそれを利用したくなるのは当然と言えます。

async-awaitじゃダメなのか

「なるほど、じゃあ async await を使ってみよう」と言いたいところですが、若干問題があります。requestAnimationFrame のコールバックが呼ばれたら、そのフレームでやるべき処理はすべてそのコールパス内で完結する必要があるからです。

async-await を requestAnimationFrame 内で適用してみた例を、こちらで実際に見てみましょう。
https://jsfiddle.net/townewgokgok/1t8mdcmh/

async-awaitの例
let frame = 0;
const parent = document.getElementById('parent');
const child = document.getElementById('child');


async function setVisibility(target, visible) {
    target.style.display = visible ? 'block' : 'none'
}

async function main() {
    await setVisibility(parent, frame % 2 === 0)
    await setVisibility(child, frame % 2 === 0)
}

function update() {
    main();
    frame++;
    requestAnimationFrame(update);
}

requestAnimationFrame(update);

この例で本来期待されている動作は「偶数フレームで parentchild が同時に表示される」ですが、実際にはそうならず、child が表示されません。それぞれに対して setVisibility が行われるフレームがずれてしまっているわけです。

async await の中身は、その名のとおり「非同期な」コールバックであり、中断時に一旦システムに処理が戻されます。ということは、requestAnimationFrame のコールパス内でこれを行うと、await 以降に処理が戻るのは画面が再描画された後ということになってしまうのです。

もっとも、上記の例では setVisibility を普通の関数にしてしまえばこんなことにはなりませんが、requestAnimationFrame コールバック内での async-await の使用は危険であるという点だけご理解ください。

ジェネレータを使おう

一方、ジェネレータ ではそのようなことが起こりません。ジェネレータは async-await よりも以前から存在する機能で、型としては () => Iterator のことであり、「書き下したとおりに振る舞うイテレータを生成してくれる関数」のことです。処理の途中で yield に出会うとイテレータは中断され、呼び出し側で next() してやると再開されます。これらは async await と違って同期的に行われます。

先述の例1にジェネレータを適用すると、以下のとおり実際に動作するコードが書けます。リンク先のJSFiddleの再生画面(右下の白い枠)を一度クリックしてから、キー入力してみてください。

例2(単一ジェネレータ版)シナリオ記述部
function* inn () {
  message('たびびとの やどやへ ようこそ')
  message('ひとばん 6ゴールドですが おとまりに なりますか? (Y/N)')
  limitKeys = ['y', 'n']
  yield 'waitKey'

  if (lastKey === 'y') {
    message('では おやすみなさいませ')
    waitingFrames = 30
    yield 'waitFrame'
    yield 'waitFadeOut'
    waitingFrames = 30
    yield 'waitFrame'
    // player.money -= 6
    // player.hp = player.maxHP
    // player.mp = player.maxMP
    yield 'waitFadeIn'
    message('おはよう ございます')
    /* if (player.withPrincess) */ message('ゆうべは おたのしみでしたね')
    yield 'waitKey'
  }

  message('では また どうぞ')
  yield 'waitKey'
}

ゲームシステム側の実装は以下のようになります。requestAnimationFrame のコールバック内では、イテレータの next() を呼んでいます。ここで yield された値の内容によって mode =「何をwaitするのか」が決まり、然るべき処理が行われます。

例2(単一ジェネレータ版)ゲームシステム側の実装
mode = 'normal'

// ...

let iter = inn()
let frame = 0

function update () {
  request = null

  let r
  const e = document.getElementById('inn')
  e.classList.remove('showCursor')
  switch (mode) {
    case 'waitKey':
      if (frame % 60 < 30) {
        e.classList.add('showCursor')
      }
      break

    case 'waitFrame':
      waitingFrames--
      if (waitingFrames <= 0) {
        mode = 'normal'
      }
      break

    // ...

    default:                      // mode == 'normal' のとき
      r = iter.next()             // ここでイテレータを回す
      if (r.value) mode = r.value // yield された値が次の mode
  }
  frame++
  if (!(r && r.done)) {
    request = requestAnimationFrame(update)
  }
}

request = requestAnimationFrame(update)

ジェネレータからジェネレータを呼び出す

さて、上記の例2で気になるのは

  • waitingFrames = 30; yield 'waitFrame'; とか書くの面倒くさい
  • waitFrame(30) という関数にまとめたらいいじゃない

という点です。では、まとめてみましょう。

例3(動きません)
function* waitFrame(f) {
  waitingFrames = f;
  yield 'waitFrame';
}

function* inn() {
  // ...
  waitFrame(30);
  // ...
  waitFrame(30);
  // ...
}

ところが、これは動作しません。waitFrame(30) はあくまでも「イテレータを生成する」だけであり、それを回す処理が別途必要だからです。waitFrame 関数内の yield はあくまでも waitFrame 関数自身を一時停止するためのものであり、呼び出し側の関数まで一時停止されるわけではありません1

したがって、正しく動作させるには、呼び出し側を すべての呼び出し部で 以下のように書く必要があります。

例4(動くけど、まぁ、うん…)
function* waitFrame(f) {
  waitingFrames = f;
  yield 'waitFrame';
}

function* inn() {
  // ...
  for (let v of waitFrame(30)) yield v; // ← 呼び出し部でイテレータを回す
  // ...
  for (let v of waitFrame(30)) yield v; // ← 呼び出し部でイテレータを回す
  // ...
}

おそらく、作り込んで行くと inn() を呼び出す側や waitHoge 的な他のヘルパー群などが増えて行き、内部で中断される可能性のあるこれらの関数群はすべてジェネレータで書くことになるので、その中に登場するすべての呼び出し部で上記のループ処理をやらなければならなくなります。

絶望的な気分になったところで、yield* という演算子を導入します。yield* は「イテレータを回しつつ、受け取った値を順次そのまま yield する」という演算子で、これは上記のループ処理そのものです。これを用いると、以下のように簡潔に書けるようになります。

例4'
function* waitFrame(f) {
  waitingFrames = f;
  yield 'waitFrame';
}

function* inn() {
  // ...
  yield* waitFrame(30);
  // ...
  yield* waitFrame(30);
  // ...
}

完成形

最終的に、例4までの宿屋のコードは以下のように書けるようになりました。

例5(完成形)シナリオ記述部
function* inn () {
  message('たびびとの やどやへ ようこそ')
  message('ひとばん 6ゴールドですが おとまりに なりますか? (Y/N)')
  const key = yield* waitKey(['y', 'n'])

  if (key === 'y') {
    message('では おやすみなさいませ')
    yield* waitFrame(30)
    yield* waitFadeOut()
    yield* waitFrame(30)
    // player.money -= 6
    // player.hp = player.maxHP
    // player.mp = player.maxMP
    yield* waitFadeIn()
    message('おはよう ございます')
    /* if (player.withPrincess) */ message('ゆうべは おたのしみでしたね')
    yield* waitKey()
  }

  message('では また どうぞ')
  yield* waitKey()
}

waitHoge 的な名前の関数はすべてジェネレータとして実装されており、ゲームシステムに返す値を yield することでコールパスの全イテレータを一時停止する役割を負っています。システムに処理を戻す箇所はすべてそれらを通じて行っているので、シナリオ記述本体には生の値を直接 yield している箇所はありません。このようにすると yieldyield* が混在しなくなり、「あれ? ここはどっちになるんだ?」という事態を防げるでしょう。

例5(完成形)ヘルパー関数
function* waitKey (keys) {
  limitKeys = keys
  yield 'waitKey'
  return lastKey
}

function* waitFrame (f) {
  waitingFrames = f
  yield 'waitFrame'
}

function* waitFadeOut () {
  yield 'waitFadeOut'
}

function* waitFadeIn () {
  yield 'waitFadeIn'
}

async-await とジェネレータの比較

async-await と Promise に馴染みのある方は、以下のように置き換えて考えると分かりやすいかも知れません。

async-await generator
async function hoge(arg) {...} function* hoge(arg) {...}
await hoge(arg) yield* hoge(arg)
resolve(value) yield value

ただし、yield value は関数(イテレータ)を一時停止する効果があるため、resolve(value) と違って1コール内で複数回行えるという点が異なります(この辺はそもそも使用目的が異なるということです)。

番外編:ラッパを介してイテレータを制御する

ここまで書いておいてなんですが、この記事をまとめるためにジェネレータについて久々に調べ直してみるまで、僕は yield* 演算子の存在を知りませんでした :innocent:。 ではどうしていたのかというと、yield* と同じことができるイテレータのラッパクラスを作って利用していたのですが、これはこれで「ジェネレータの呼び出し部や yield, return の前後に処理を差し込む」ような(例えば Go の defer みたいな)ことができて有意義でした。

このラッパを切り出し、改良して luacoro-js というライブラリとしてまとめました ので、興味のある方はお試しください。冒頭に記した各種デモの luacoro-js版デモ もあります(経緯としてはこちらが先に作られたものです)。basic, concurrent に関しては独自機能を用いて書かれているので、README と併せてお読みください。

ラッパの詳しい仕組みについては、極限まで簡略化した こちらのサンプル をお読みいただくと理解しやすいと思います。基本的には、

  • ユーザは常にラッパを通じてイテレータを回す(ラッパは回す対象となるイテレータを保持する)
  • イテレータが別のイテレータをyieldすると、回す対象がそちらに切り替わる
  • このとき、それまで回していたイテレータはスタックにpushされ、新しいイテレータの終了後にpop・再開される

というアイデアに基いており、yield* を一切使わずに同じことを実現しています。イテレータを回すところでラッパを介す必要がある点以外は、見た目的には yield*yield に置き換わっただけのように読めると思います。

luacoro-js版の宿屋
export function start () {
  // ...
  let coro = luacoro.create(inn())
  // ...
  function update () {
    // ...
    r = coro.resume()
    // ...
    if (coro.isAlive) {
      request = requestAnimationFrame(update)
    }
  }
  request = requestAnimationFrame(update)
}

function* inn (): luacoro.Iterator<string> {
  message('Welcome to the traveler\'s Inn.')
  message('Room and board is 6 GOLD per night.')
  message('Dost thou want a room? (Y/N)')
  const key = yield waitKey(['y', 'n'])

  if (key === 'y') {
    message('Good night.')
    yield waitFrame(30)
    yield waitFadeOut()
    yield waitFrame(30)
    // player.money -= 6
    // player.hp = player.maxHP
    // player.mp = player.maxMP
    yield waitFadeIn()
    message('Good morning.')
    message('Thou hast had a good night\'s sleep I hope.')
    yield waitKey()
  }

  message('I shall see thee again.')
  yield waitKey()
}

今回作ったゲームでの活用例

「さくらのINFRA WARS」では、以下のような場面でジェネレータを活用しました。

  • 敵の出現パターン(面データ)
  • キャラクタのアニメーション
  • シーン管理

などなど、あらゆるところで気軽に使用しています(requestAnimationFrame コールバックからのコールパスのほとんどがジェネレータです)。特に威力を発揮したのは2〜3面おきに休憩として表示されるコーヒーブレイクのシーンで、冒頭にも「Flash や AfterEffects のタイムライン編集でやるようなアニメーションを JavaScript で実装したい」例として挙げましたが、以下のデモをご覧ください。

こういったシーンが爆速で書けるわけです。リンク先でソースを見ていただくと分かりますが、「キャラクタを動かして待機」みたいな処理を必要なだけループして、終わったら次のループへ… と直感的に記述できていると思います(古いBASICを触ったことのある方なら こういうのでいいんだよ こういうので と思うのではないでしょうか)。特にデモ2は「イテレータ中でさらに複数の子イテレータを回す」ような構造になっており、スレッド的な使い方も実現されています。

このデモに関しては一定の動作を再生しているだけなので、今どきの開発環境なら Unity あたりでグラフィカルに作れそうではありますが、このように特定のフレームワークに縛られなくともアニメーションを流暢に記述できるというのは強力です。

ということで、JavaScriptでゲームやUI・Webプレゼンテーション等を作っていて「凝ったことをやるのがめんどうくさい」と感じたら、ジェネレータを活用して快適なシステムを整えてみてください。フラストレーションが取り除かれ、イマジネーションを形にするペースがアガって行ったら良いなと思います :fire: :laughing: :fire:


  1. Luaのコルーチン では単に waitFrame(30) と書けば普通に関数として呼び出され、呼び出し先の関数内で coroutine.yield すればコルーチン全体が中断されます。Firefoxが以前独自実装していた yield はこれと同じ仕様で、ほぼコルーチンと言える機能でした(関連記事)。呼び出し部で yield* といちいち書く必要もなく、任意の場所で中断できて便利そうでしたが、残念ながら 廃止されました 。また、組み込み向けのJavaScript実行環境である duktape などには コルーチン機能が備わっている ものもあります。ゲームシステム自体をC/C++で、シナリオ部分をJavaScriptで書きたい場合には、こういったものを組み合わせるのが最適解でしょう。

191
199
3

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
191
199

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?