スキップ時間を短縮する

# スキップ

Akashic のマルチプレイでは、ゲームの途中で画面を開くと、最初にビデオ映像を早送りするような画面が表示されます。 マルチプレイの基礎 でも触れたとおり、これはゲームの最新状態に追いつくために、 それまでの操作 (イベント) を高速で消化することによるものです。 この早送り動作を スキップ と呼びます。

このスキップ処理によって、「他プレイヤーとの実行状態の共有」を意識することなくゲームを作成することができます。 他方この仕組みには プレイ時間に比例してスキップの時間が伸びていく という弱点があります。 大規模なゲームや、一度の起動中に繰り返し遊べるように作られたゲームでは、スキップ時間が非常に長くなってしまう場合があります。

この対策として、Akashic Engine は「スナップショット」という機能を提供しています。

厳密には、スキップ処理はマルチプレイとは独立な動作です。 例えばニコニコ生放送で、ニコ生ゲームを遊んでいる最中の配信を見始めた時は、 そのゲームがシングルプレイであっても (最新フレームに追いつくために) スキップが発生します。 また過去のプレイのリプレイの再生中に、シークバーを操作して大きく時間をジャンプするような場合にも、 スキップが発生することがあります。

# スナップショット

Akashic Engine において、ゲームの実行状態をシリアライズしたオブジェクトを スナップショット と呼びます。 ゲーム開発者は、ゲームのスナップショットを作成し、エンジンに保存を要求することができます。

エンジンは、ゲームプレイのある時点 (最新フレームなど) に追いつく必要がある場合、 まずその時点より前のもっとも近いスナップショットを探します。 スナップショットが見つからなければ、エンジンは単にゲーム開始からのすべての操作を早送りで消化 (スキップ) します。 もし見つかれば、スナップショットをゲームに渡して状態を復元させ、そこから目標の時点までスキップ処理を行います。 これによりスキップの時間を短縮することができます。

このようにスナップショットは、ゲームのいわゆる「セーブデータ」とは異なることに注意してください。 たとえばプレイヤーがスナップショットを選んでロードするようなことはできません。 あくまでスキップ処理の時間を短縮するために、エンジンが自動的に検索して利用するために存在します。

スナップショットを利用するには、次の二つが必要です:

  • 適時スナップショットを作成・保存する処理
  • ゲームがスナップショットつきで起動された時、スナップショットから状態を復元する処理

# スナップショットの保存

スナップショットの保存は、 g.game.requestSaveSnapshot() によって要求することができます。

詳細な仕様は後述しますが、このメソッドは主に以下の形で利用します。

g.game.requestSaveSnapshot(() => {
  const snapshot = {
    // ゲームの実行状態を表すデータ
  };
  return { snapshot: snapshot };
});

すなわち requestSaveSnapshot() は引数として関数を取ります。 この関数は、 requestSaveSnapshot() を呼び出したフレームの終了時に引数なしで呼び出されます。

関数の戻り値として、 snapshot プロパティを持つオブジェクトを返してください。 このプロパティの値がスナップショットとして保存されます。 このプロパティの値は JSON として妥当な値でなければなりません。

# スナップショットからの復元

一度でもスナップショットを保存する場合、 スナップショットからの復元 処理を実装する必要があります。

スナップショットが利用される場合、まず現在のゲームの実行状態が破棄されます。 その後通常のゲーム開始時と同じように、 main スクリプト (のエクスポートした関数) が呼び出されます。 ただしこの時、第一引数の snapshot プロパティにスナップショットが渡されます。 その値は、同じゲームが過去に g.game.requestSaveSnapshot() で保存させた値のいずれかです。

スナップショットからの復元処理とは、文字通り渡されたスナップショットから、保存時点のゲーム状態を再現することです。

以下の例は、画面中央付近の数字をインクリメントしていくだけの非常に単純なゲームです。

// script/main.js (mainスクリプト)

function main() {
  const scene = new g.Scene({ game: g.game });
  scene.onLoad.add(() => {
    g.game.vars.value = 0;

    const font = new g.DynamicFont({
      game: g.game,
      fontFamily: "sans-serif",
      size: 15
    });

    const label = new g.Label({
      scene: scene,
      font: font,
      fontSize: 15,
      text: "" + g.game.vars.value,
      x: g.game.width / 2,
      y: g.game.height / 2
    });
    label.onUpdate.add(() => {
      label.text = "" + g.game.vars.value++;
      label.invalidate();
    });
    scene.append(label);
  });
  g.game.pushScene(scene);
}

module.exports = main;

このゲームは、次のように変更することでスナップショットに対応させることができます。

// script/main.js (mainスクリプト)

function main(param) {
  // 引数 param に snapshot プロパティがあればそこから復元。
  // なければ通常のゲーム開始だが、ここでは「空オブジェクト ({}) からの復元」として一本化。
  const snapshot = param.snapshot || {};

  const scene = new g.Scene({ game: g.game });
  scene.onLoad.add(() => {
    // スナップショットからの復元なら保存されていた値、そうでなければ 0 で初期化。
    g.game.vars.value = snapshot.val || 0;

    const font = new g.DynamicFont({
      game: g.game,
      fontFamily: "sans-serif",
      size: 15
    });

    const label = new g.Label({
      scene: scene,
      id: snapshot.labelId || undefined, // エンティティIDを復元していることに注意(後述)。
      font: font,
      fontSize: 15,
      text: "" + g.game.vars.value,
      x: g.game.width / 2,
      y: g.game.height / 2
    });
    label.onUpdate.add(() => {
      label.text = "" + g.game.vars.value++;
      label.invalidate();
    });
    scene.append(label);

    // 1 分 (60 * 1000 ms) おきにスナップショットを保存。
    scene.setInterval(() => {
      g.game.requestSaveSnapshot(() => {
        const snapshot = {
          val: g.game.vars.value,
          labelId: label.id
        };
        return { snapshot: snapshot };
      });
    }, 60 * 1000);
  });
  g.game.pushScene(scene);
}

module.exports = main;

scene.setInterval() で 1 分おきにスナップショットを作成、保存するようになっています。 このゲームの実行状態は画面中央の数字 (g.game.vars.value の値) だけなので、その値をスナップショットに含めています。 また main() の引数 param を受け取り、 param.snapshot を参照してゲームの実行状態を復元しています。 スナップショットが指定されない場合、従来と動作が変わっていないことに注意してください。

ただしここで、ラベルエンティティ labelid プロパティもスナップショットに含めている点に注意する必要があります。

すなわちゲームの実行状態には、通常暗黙に設定され、意識する必要もない「エンティティの ID」(後述) さえ含まれます。 このようにスナップショットは、シーンの状態からエンティティの ID ・ カメラの状態やプレイヤーの参加状態・スワイプ中ならその状態など、すべて何もかも保存・復元しなければなりません。 これは技術的には可能ですが、動作確認を含めて簡単に実装できるものではありません。

そこで一般には、 できるだけ内部状態を持たないタイミングを作り、そこでのみスナップショットを保存する ことを推奨します。

# 保存のタイミング

スナップショットの保存は、「内部状態がほとんどない」フレームで行うことを推奨します。

「内部状態がほとんどない」とは、次の全てを満たすような状況です。

  • (非ローカルの) エンティティがない
  • シーンが一つだけである
  • タイマー待ち (setInterval() などで渡した関数の呼び出し待ち) がない
  • ゲームがひと段落した "合間" である

例えば対戦ゲームであれば「対戦の決着がついて、メニュー画面に戻る直前」などが "合間" に該当するでしょう。 対戦中は「キャラクターの体力」「キャラクターの位置」「残り時間」「スコア」「アニメーションの再生状態」など、 ゲームによってさまざまな状態があり得ますが、 "合間" にはそれらがありません。 また画面に何も表示しなくても違和感を覚えられにくいはずです。 このようなタイミングでは、スナップショットの保存・復元は非常に単純になります。

極論、ゲームがその一種類の "合間" でしかスナップショットを保存しないのであれば、 保存する値は空オブジェクト ({}) だけにすることすらできます。 スナップショットからの復元処理は、単に「メニュー画面」を表示するだけの処理になるためです。 もちろん「ハイスコア」「戦績」など、対戦を通して持ち越す情報がある場合は、その値をスナップショットに含めてください。

# 注意点: スナップショットを事前に生成しない

スナップショットは、 requestSaveSnapshot() に与えた関数 (コールバック) の中で生成してください

以下はこれに反するコード例です。

// NG: スナップショットを事前に生成している
const snapshot = {
  // ...
};
g.game.requestSaveSnapshot(() => {
  return { snapshot: snapshot };
});

次のように、 requestSaveSnapshot() に与えた関数の中で生成してください。

// OK: スナップショットを保存直前に生成している
g.game.requestSaveSnapshot(() => {
  const snapshot = {
    // ...
  };
  return { snapshot: snapshot };
});

これは Akashic Engine のゲームがフレーム単位で実行されることに起因しています。 スナップショットから復元した場合、処理は「スナップショットを保存したフレーム」の次のフレームから始まります。 そのためフレームの "途中" でスナップショットを保存することはできません。 スナップショットは「それを保存したフレーム」の終了時の状態を表す必要があります。

そもそも requestSaveSnapshot() が、直接スナップショットを引数に取らないのも、 コールバックを (呼び出された瞬間ではなく) フレームの終了時に呼び出すのもこのためです。

requestSaveSnapshot() を呼び出す時、スナップショットを事前に生成してしまうと、「フレームの終了時」の状態と異なる可能性があります。 例えば次のようなコードが考えられます。

let score = 0;
scene.onPointDownCapture.add(() => {
  score++;
});

scne.onMessage.add(ev => {
  if (ev.data && ev.data.type === "SNAPSHOT") {
    // NG: スナップショットを事前に生成している
    const snapshot = { score: score };
    g.game.requestSaveSnapshot(() => {
      return { snapshot: snapshot };
    });
  }
});

このコードは画面がクリックされるたびに score の値を増やしていき、 data.type"SNAPSHOT" である g.MessageEvent を受信するたびにスナップショットを保存します。

このコードが、たまたま同一フレームで g.MessaveEventg.PointDownEvent を受信したとします。 g.MessaveEvent が先に処理された場合、その時点でスナップショットが生成されます。 しかしその後 g.PointDownEvent を処理するので score は 1 増えます。 そうなると、スナップショットに保存される score の値と、このフレームの終了時の score の値は 1 ずれてしまいます。

次のように requestSaveSnapshot() のコールバック内でスナップショットを生成すれば、この問題は起きません。 g.MessaveEvent によって更新された後の score の値がスナップショットに保存されます。

scne.onMessage.add(ev => {
  if (ev.data && ev.data.type === "SNAPSHOT") {
    g.game.requestSaveSnapshot(() => {
      const snapshot = { score: score };
      return { snapshot: snapshot };
    });
  }
});

言い換えれば、スナップショットに保存する実行状態は、requestSaveSnapshot() を呼び出す時点のものではなく、コールバックが呼び出された時点のものでなければなりません。

もし requestSaveSnapshot() のコールバックが呼ばれた時点 (フレームの終了時) で、 スナップショットが生成できない・生成しにくい状態 (アニメーション中のエンティティがあるなど) になっていた場合は、 null を返してください。 この場合エンジンは何も保存しません。 (もちろんコンテンツはこのような状況を作らないことができるはずです。)

# 保存と復元まとめ

以上をまとめると、スナップショットの保存と復元では次の点が重要です。

  • 保存
    • g.game.requestSaveSnapshot() で保存する
    • 保存は「内部状態がほぼない」フレームで行う
    • スナップショットは requestSaveSnapshot() に渡した関数の中で生成する
    • スナップショットには、そのフレームの終了時の状態を復元できるだけの内容を含める
  • 復元
    • ゲーム起動時の引数にスナップショットが渡されたらそれを使って復元する
    • シーンを生成しその onLoad ハンドラが終わるまでの間に、保存時の状態を再現する

# スナップショットの動作確認

スナップショットは、特定のフレームに「追いつく」必要がない限り利用されないので、動作確認のしにくい機能です。 akashic-cli@2.13.0 以降の akashic serve では、スナップショットの動作確認のためにいくつかの機能を提供しています。

  • Playback ツール
  • クエリパラメータ

# Playback ツール

Playback ツールは、 serve の画面右上のハンバーガーボタン (三本線のアイコン) をクリックした時に表示される開発者ツール (devtool) の一つです。

akashic-cli@2.13.0 の akashic serve の Playback ツールのスクリーンショット

主に次のような機能があります。

  • 実行中のプレイで保存されたスナップショットの一覧表示
  • スナップショットでのゲームのリセット
  • スナップショットのダンプ

スナップショット一覧の左側、三角形のボタン (再生ボタン) を押すと、そのスナップショットでゲームをリセットします。 それによってスナップショットを保存した時点に「ジャンプ」することができます。

一覧の右側 console.log() ボタンを押すと、対応するスナップショットの内容 (正確にはそれを含んだ「スタートポイント」と呼ばれるデータ) がコンソールに出力されます。

シークバーは、このインスタンスの現在のゲーム内時間を表しています。 serve の画面上部 (ツールバー) のシークバーと連動しています。 上の画像では、シークバーの濃い灰色のゲージが途中から始まっています。 ゲージの左端は、ゲームをリセットした時のゲーム内時間 (現在のゲーム実行がいつのスナップショットから始まったか) を表しています。

Pause active ボタンを押すと、他ウィンドウを含めた全インスタンスの実行を一時停止することができます。 (serve の画面上部 (ツールバー) の同じアイコンのボタンと同一の機能です。) 通常、あるウィンドウでリプレイ再生を行っている間も、ゲーム全体は進行し続けています。 たとえば他のウィンドウは止まりません。 これはスナップショットの動作確認の上では不便なことが多いので、その場合には Pause active ボタンを利用してください。

その他細かな機能は実験中のものです。将来のバージョンで変化することがあります。

# クエリパラメータ

保存したスナップショットのフレームが (Playback ツールなどで) わかっている場合、クエリパラメータを指定することでページを開いた直後の実行状態を制御できます。

例えばポート 3300 で akashic serve を実行中、次の URL にアクセスすると、

http://localhost:3300/public/?mode=replay&replayResetAge=1500&replayTargetTime=180000

以下の状態でウィンドウを開くことができます。

  • akashic serve の現在のプレイを、リプレイモード (mode=replay) で開始し、
  • age 1500 のスナップショットでゲームをリセット (replayResetAge=1500) して、
  • シークバーを 3 分 0 秒 (180000 ミリ秒) 地点に動かし (replayTargetTime=180000) た状態

以下のクエリパラメータがサポートされています。

パラメータ名 デフォルト値 内容
mode replay または passive passive replay の場合、リプレイモード(シークバーを操作して過去の状態を表示させている状態)で開始します。 passive (省略時) は最新フレームに追いつこうとする通常のモードで開始します。
replayResetAge 整数値 (age) なし mode=replayの場合のみ有効。指定した場合、その age (ゲーム開始からの経過フレーム数) で保存されたスナップショット (またはその age 以前で最も近いもの) でゲーム状態をリセットして開始します。
replayTargetTime 整数値 (ゲーム開始からのミリ秒の経過時刻) 0 mode=replayの場合のみ有効。指定した場合、シークバーがその値の位置にシークされた状態で開始します。 replayResetAge を指定する場合、実質的に必須のパラメータです (指定しないとリセット時刻と関係なく時刻 0 に行ってしまうため)。
paused true または false false true の場合、一時停止状態で開始します。

ただしクエリパラメータのキー名などは実験的なものです。URL 部分などを含め、今後のバージョンで変化する可能性があります。

またこれらの他にも、単純にマルチプレイでブラウザウィンドウ (インスタンス) を追加した時にも、スナップショットは利用されます。 追加されたウィンドウでは、最新のスナップショットから状態を復元してから実行を開始しようとするためです。

どのスナップショットで開始しても、ゲームは同じ時刻に同じ状態にならなければなりません。 例えば replayResetAge の値だけが異なるウィンドウを複数開いた時、それらは全て同じ画面表示になる必要があります (ローカルエンティティを除き)。 もしそうでなければ、スナップショットの保存・復元処理に問題があるので、修正が必要です。 スナップショットの扱いに問題があると、特にプレイヤー間の実行状態がずれてしまい、マルチプレイが破綻します。

# 補足 1: g.game.requestSaveSnapshot() の仕様

スナップショット保存を要求するメソッド g.game.requestSaveSnapshot() は、次のシグネチャを持ちます。

g.game.requestSaveSnapshot(fun: () => g.SnapshotSaveRequest | null, owner?: any): void

引数 fun は関数でなければなりません。 fun()requestSaveSnapshot() を呼び出したフレームの終了時に、引数なしで呼び出されます。 引数 owner は任意の値で、 fun() の呼び出し時に this として与えられます。

fun() は戻り値として null または g.SnapshotSaveRequest を返さなければなりません。 g.SnapshotSaveRequest を返した場合、その値を元にスナップショットの保存要求が行われます。 null を返した場合、保存は行われません。

g.SnapshotSaveRequest は、次のプロパティを持つオブジェクトです:

プロパティ名 内容
snapshot any スナップショットデータ。JSON として妥当な値である必要があります。
timestamp number 時刻。省略可能。通常、省略してください。この詳細は g.TimestampEvent (文書化されていない機能) と併せて文書化されます。

マルチプレイの場合、requestSaveSnapshot() は (他の非ローカルな処理と同様に) 全インスタンスで呼び出してください。 ただし fun() はそのうちの一部のインスタンスでのみ呼び出されます。 なぜなら、同じプレイの同じフレームのスナップショットは一意なはずなので、マルチプレイであっても一箇所でしか保存する必要がないためです。 実装的にもスナップショットを保存できるプレイヤー (インスタンス) は限られています。 requestSaveSnapshot() は、実際にスナップショットを保存できるインスタンスでのみ fun() を呼び出し、それ以外の場合は何もしません。 このことから、スナップショットにはローカルな状態を保存することはできません。

通常この「スナップショットの保存を実際に行うインスタンス」は、プレイの中で一つだけ存在します。 現在の akashic serve やニコ生ゲーム環境では、サーバサイドで動作する特殊なインスタンスが該当します。 自分がスナップショットの保存を行うインスタンスであるかどうかは、 g.game.shouldSaveSnapshot() で判定できます。 ただし通常この判定が必要になることはありません。(requestSaveSnapshot() が行うため)

保存を要求したスナップショットがすべて保存されることは保証されません。 高すぎる頻度の保存や、大きすぎるスナップショットまたはその他の理由で、保存要求が無視されることがあります。 具体的な制限はサービス依存ですが、保存頻度に関しては、現在のところ目安として「1 分以上の間隔をあけて保存する」を推奨します。 スナップショットが一部または全部保存されていなかったとしても、スキップ時間が延びる以外の影響はありませんし、あってはいけません。

# 補足 2: "合間" でないタイミングでのスナップショット

前述のとおり、スナップショットはできる限り内部状態がないタイミングでの保存を推奨します。

この推奨に従う限り、このページのここから先を読む必要はありません。

そうでないタイミングで保存する場合、ゲーム実行状態の復元に必要な情報はすべて、 ゲーム開発者によって収集されスナップショットとしてまとめられる必要があります。 これには以下のような情報が含まれうるでしょう。

  • その時点で存在するエンティティ
  • その時点で存在するシーン
    • 複数のシーンを作成しているならその全て
  • ゲーム内データ
    • キャラクターの残り HP など
    • g.Game#vars に保持している値
    • アニメーションの再生状態
  • 作成した乱数生成器
  • 作成したカメラと現在の g.Game#focusingCamera の値
  • プレイヤーの Join 状態

Akashic Engine のいくつかのクラスは、ゲーム開発者向けにスナップショット作成・復元を補助する API を提供しています。

# エンティティとローカルエンティティ

エンティティは、ID を含めて保存・復元される必要があります。

エンティティ ID は、通常エンジンによって暗黙に設定される値で、 ポイントダウンイベントなどの対象エンティティの識別に使われています。 スナップショットからの復元時は、この値も明示的に指定して元の値を再現する必要があります。 というのも、ある ID を持つエンティティが、「スナップショットから復元したか否か」によって異なってしまうと、 「どの ID のエンティティを操作した」という情報の解釈がプレイヤーによって変わってしまい、マルチプレイが破綻するためです。

位置やサイズなどの他プロパティと合わせて、ID (g.E#id) もスナップショットに加えてください。 g.E とその派生クラスは、スナップショットから復元時のために、 コンストラクタ引数 (g.EParameterObject) で id を指定できるようになっています。 指定すると、エンジンによって暗黙に生成された ID の代わりに、指定された値を利用します。

スナップショットからの復元時には、ID を指定して同じクラスのインスタンスを new した上で、 元と同じプロパティを設定することになります。

他方でローカルエンティティは、スナップショットに保存する必要がありません。 スナップショットはローカルエンティティの状態を保存できない(すべきでない)からです。 というのも、スナップショットは一つのプレイの一つのフレームにおいて一意です。 しかしローカルエンティティは、同じプレイの同じフレームにおいても 各デバイス (エンジンインスタンス) 上で異なる状態をとることができます。 この性質はスナップショットには保存できません。 ここまでスナップショットはゲームの実行状態を「完全に復元する必要がある」と書いていますが、 この点は例外であることに気をつけてください。

スナップショットはローカルエンティティの状態を保存・復元できません。これは Akashic の制限です。

# カメラ

利用している場合、カメラ (g.Camera) も復元が必要です。

これには g.Camera#serialize() を利用することができます。 serialize() の戻り値をスナップショットに含めておき、 適切なクラス (現在は g.Camera2D のみです) の static メソッド deserialize() に渡すことでカメラを復元することができます。

g.Game#focusingCamera の値も、保存するならば id で保持される必要があります。 ただしマルチプレイヤーゲームの focusingCamera の場合、その値はインスタンスごとに異なりうるでしょう。 その場合はローカルエンティティと同様、g.game.focusingCamera そのものの id を直接スナップショットに含めるべきではありません。

後述の「プレイヤーの状態」のデータとして各プレイヤーのカメラ ID も保時しておき、 復元時はプレイヤーに応じて focusingCamera を設定することになるでしょう。

# シーン

シーン g.Scene の状態も、エンティティと同様に保存・復元される必要があります。 スナップショットからの復元時には、g.Scene (あるいはゲーム開発者の定義した派生クラス) を new した上で、元と同じハンドラを設定することになります。

単純なケースでは、シーンの初期化は全て g.Scene#onLoad の処理の中に記述されているでしょう。 スナップショットからの復元時であっても、シーンは新規生成時と同じパスを通ることになります。 したがって他にシーンに持たせているゲーム内データがなければ、通常時と同じようにシーンを生成し初期化処理を記述すればよいはずです。

# 乱数生成器

エンジンのデフォルト乱数生成器 g.Game#random は、エンジンによって自動的に状態が保存・復元されます。 それ以外の乱数生成器をゲーム開発者が作成している場合は、自力で保存・復元を行ってください。

これを実現するため、乱数生成器 g.RandomGeneratorserialize() を提供しています。 この関数の戻り値をスナップショットに含めておき、 元のクラス(g.XorshiftRandomGenerator など)の static メソッド deserialize() に渡すことで乱数生成器を復元できます。

# プレイヤーの Join 状態

スナップショットからの復元時、その時点までに通知されていた g.JoinEventg.LeaveEvent が通知されなおすことはありません。 スナップショットには通知された結果として至った実行状態を直接保存し、またその状態を復元してください。

g.Game#onJoin, onLeave を利用して Join したプレイヤーの情報を g.Game#vars に保持しているのであれば、 vars の内容をスナップショットに含めることでプレイヤーの参加状態も保存されることになります。

# g.Game#vars

汎用のゲーム内データ保持領域として、エンジンは g.Game#vars: any を提供しています。 エンジンはこの値の内容に関知していないため、スナップショット保存上のサポートもありません。 必要なデータはゲーム開発者がスナップショットに保持する必要があります。

# g.Game#external

サービス依存の外部インターフェース g.Game#external も同様に、スナップショット保存上のサポートはありません。 external の各実装ごとに提供される方法がある場合はそれを利用してください。