中核概念
セーブ、再生、決定性
Kagari のセーブはメモリのダンプではありません。物語の開始からのプレイヤーの選択の記録であり、 ロードはそれを再生します。この章では、セーブに何が含まれるか、既存のセーブを壊さずにパッチで 何を変えてよいか、そしてなぜ再生が正確なのかを説明します。
セーブは到達可能性の証拠
物語は プッシュダウン機械 で、入力はプレイヤーの選択と乱数生成器の抽選だけです。 エントリーシーン、その引数、乱数生成器のシード、選択の列が与えられれば、物語の位置は一意に 決まります。セーブが記録するのはまさにその列、トレース で、ロードはエントリーシーンから 物語をもう一度実行し、記録された選択を与えながら、セーブした位置に着くまで進めます。
したがってセーブは状態ではなく、その位置が到達可能であることの証拠 です。つまり開始からの 経路です。到達不能な位置のセーブは作れず、ロードによって、物語自身では作れない物語変数の組み合わせが 生まれることもありません。そのため、コンパイラが物語に対して行うすべての解析は、ロードした ゲームについても成り立ちます。
安定した選択肢 ID
トレースは行番号やアームの位置を記録しません。各選択を、三つの部分からなる 選択肢 ID として 記録します。
| 部分 | 例 | 由来 |
|---|---|---|
choice! を含む関数のパス | crate::story::Prologue | モジュールツリーと関数名 |
選択肢の id | confess | choice! の id = "…" |
| 選ばれたアーム | crate::story::Confess::Yes | アームの値。列挙型のバリアントならそのパス、そうでなければ #n(アームの位置) |
例えば crate::story::Prologue/confess/crate::story::Confess::Yes です。呼び出しの文脈は再生が
補うので、関数が多くの場所から呼ばれたりループの中で実行されたりしても、選択肢の id はその関数の
中で一意であれば十分です。アームの値は自分で宣言した列挙型のバリアントにしてください。位置で
記録されたアームは、アームの並べ替えで壊れます。
セーブの内容
| 部分 | 内容 |
|---|---|
| トレース | エントリーシーンのパス、エントリー引数、PRNG のシード、すべての選択の選択肢 ID、最後の選択の後のステップ数 |
| 終了ハンドラ | nav::start に on_exit として渡した関数のパス |
| スクリプトハッシュ | セーブを作ったときの .kagari ファイルの SHA-256 |
| スロット情報 | ローカル時刻、最後の行の話者とテキスト、画面の 320×180 のサムネイル |
| フォーマットのバージョン | セーブのレイアウトのバージョン |
セーブにはシステムの状態は含まれません。モードスタックも、設定も、メタもありません
(入れ子になった二つの機械 を参照)。スロット情報はセーブ
ページで saves::info(slot) と ui::thumbnail(slot) が表示するもので、ロードには使われません。
ロードと食い違い
nav::load(slot) はスロットを読み、そのトレースを実行中のプログラムで再生します。再生は s!
ステップを自動で進め、各 choice! では記録された選択肢 ID に出会い、記録されたアームを選ばなければ
なりません。トレースを使い切ると、最後の選択の後に記録されたステップ数だけ進んで止まります。
復元されたゲームはモードスタック全体を置き換えます。
再生は静かに行われます。既読フラグや既見集合を設定せず、メタへの書き込みを繰り返さず、音を 鳴らさず、オートセーブもしません。復元されたステップはステップへの入場から始め直します。 タイムラインが最初から始まるので、そのアニメーションはもう一度再生され、ボイスも再び流れます。
セーブがもうプログラムに合わない場合、ロードは 食い違い(Divergence)で止まります。
nav::load は Err(divergence) を返してスタックを変えず、divergence.describe() は
プレイヤー向けのメッセージを返します。ロードが食い違うのは次の場合です。
| 原因 | メッセージが伝えること |
|---|---|
| スロットが空、または範囲外 | スロットが空であること |
| ファイルが読めない、またはフォーマットのバージョンが未知 | セーブが読めないこと |
| エントリーシーンがもう存在しない | 見つからない物語 |
| エントリー引数がエントリーシーンのパラメータ型にもう合わない | 開始時の値が合わないこと |
| 物語が記録とは別の選択肢に到達する | 期待した選択肢と実際の選択肢 |
| 記録されたアームがない、またはそのガードが偽になった | 選択肢となくなったアーム |
| 記録された選択を使い切る前に物語が終わる | 残った選択の数 |
| 最後の選択の後に続くステップが記録より少ない | 期待したステップ数と到達したステップ数 |
| 再生中に物語が実行時エラーで止まる | そのエラー |
| 1,000,000 ステップ以内に位置に到達しない | ステップの上限 |
クイックロードのバインディングは食い違いをログにだけ出します。ロードページでは describe() で
表示します。
パッチで変えてよいもの
トレースが持つのは選択肢 ID と最後のステップ数だけなので、セーブはほとんどの編集を乗り越えます。 編集箇所より 後ろ の位置のセーブについては次の通りです。
| 変更 | セーブへの影響 |
|---|---|
| テキストの言い回し、話者、ボイス、テキスト効果の変更 | なし |
| レンダー、アセット、画面、アニメーション、オーディオの編集 | なし。画面は再生で作り直される |
二つの選択肢の間の s! ステップの追加・削除・並べ替え | なし |
| アームの値が列挙型のバリアントである選択肢のアームの並べ替え | なし |
| 選択肢へのアームの追加 | なし |
| ゲームシステムの変更:UI、モード、設定、バインディング | なし |
セーブ位置の手前、最後の選択の後にある s! ステップの追加・削除 | 別の行に着地する。残りのステップが足りなければ食い違う |
選択肢を含む関数、その id、アームの列挙型やバリアントの改名・移動 | その選択肢で食い違う |
| アームの削除、または記録されたアームを隠すガード | その選択肢で食い違う |
| 物語のロジックを変えて別の選択肢に先に着く、または物語が早く終わる | 食い違う |
| エントリーシーンの改名・移動、またはパラメータ型の変更 | 食い違う |
位置で記録されたアーム(#n)の並べ替え | セーブは黙って別のアームを選ぶ |
物語変数、その初期値、条件の変更は、記録された選択が引き続き同じ選択肢を辿る限り安全です。 辿らなくなった場合、再生は最初にそうなった場所を報告します。
既読フラグ(既読スキップとバックログでの既読表示に使われます)はセーブの外に保存され、関数と、
その関数の中でのステップのコード順の位置をキーにしています。したがって関数の中でステップ(s!
または choice!)を追加・削除すると、同じ関数の中でそれより後ろにあるすべてのステップの既読
フラグがずれます。そうしたパッチの後では、プレイヤーが読んだ行の一部が未読として扱われ、その逆も
起こります。他の関数のステップには影響しません。
スナップショットとトレース
トレースは 互換性の契約 です。スクリプトのバージョンをまたいで意味を持つのはセーブのこの部分 です。スナップショット(ある位置での物語の状態と論理画面)は、その位置に安く到達するための キャッシュにすぎません。正確に同じプログラムに依存するので、スクリプトハッシュが一致するときにだけ 信頼されます。それ以外のスクリプトでは、エンジンはトレースを再生します。
暫定: 現在のエンジンはセーブにスナップショットを書きません。すべてのロードと巻き戻しは、物語の 先頭からトレースを再生します。スクリプトハッシュはすでに記録されており、現在は一つの細部を 決めています:クロージャとして書いた終了ハンドラは同じスクリプトでだけ復元され、名前の付いた 関数はパスで探されます。
巻き戻し
game.rewind(i) は実行中のゲームをバックログのエントリー i に戻します。現在のトレースをその
エントリーで切り、ロードと同じように再生します。メタは決して変えません。既読フラグと既見集合は
設定されたままです。
セーブスロット
ゲームには三種類のスロット SaveSlot::Manual(k)、SaveSlot::Quick、SaveSlot::Auto(k) が
あります。
- 手動 スロット
0..save_slotsは、セーブページからgame.save(slot)で書き込みます。 - クイック スロットは、バインディング の
QuickSaveとQuickLoadアクションが書き込み、読み込みます。 - オート スロット
0..auto_slotsは、すべての選択肢ステップに入るときと終了時に、エンジンが スロットを順に巡りながら書き込みます。
設定された数を超えるスロット番号は無視されます。セーブは何もせず、スロットは空として読まれます。
API の仕様は std::system::saves にあります。
決定性
再生が正確なのは、選択と選択の間に物語が行うことがすべて決定的だからです。
- 乱数はシードされ、記録されます。 シーンは
rng_choose::<T>()で乱数を引きます。nav::startのたびに物語の PRNG が新しくシードされ、シードはトレースの一部、生成器の状態は 物語の状態の一部です。したがって再生はすべての抽選を再現し、ロードによって抽選をやり直すことは できません。 - 浮動小数点数はありません。 言語にあるのは有界整数、Rust の整数、厳密な型
RatとDecimalです(数値 を参照)。どの計算もすべてのプラットフォームで同じ 結果になります。 - レンダーは純粋です。 レンダーは直前の画面の
const fnなので、選択を再生すれば同じ論理画面が 作り直されます(レンダーは物語を見ない を 参照)。 - システムは物語に届きません。 設定、メタ、シグナル、実時間は物語に流れ込みません。メタが物語に 届くのはエントリー引数を通してだけで、エントリー引数はトレースに記録されます。
同じモデルが物語を解析可能にもしています。コンパイラにとって乱数の抽選は、プレイヤーの選択と
同じく、T のすべての値にわたる自由な選択です。
プレビュー
予定: 同じ仕組みが制作用のプレビューにも使えます。エディタでステップにジャンプすることは、 そこまでトレースを再生することです。そのコストはキーフレームで抑えられます。物語は先頭から (スクリプトが変わっていなければスナップショットから)再生されますが、レンダーは経路上の最後の キーフレーム からだけ、アニメーションなしで実行されます。 レンダーを編集しても、無効になるのは現在の経路でそのレンダーが最初に使われた後の画面だけです。 プレビューのツールはまだ提供されていません。