メインコンテンツまでスキップ

リセット処理を書いたのに画面の状態が戻らない原因と対処

タグ:

保存してあるデータを消すリセット処理を書いたのに、画面の表示だけが元のまま残ることがあります。原因は、消す対象にした保存データとは別の場所に、画面を動かしている状態がもう 1 つあることです。

ブラウザで動く放置ゲームを作っていて、この不具合を出しました。放置ゲームというのは、置いておくだけで数値がたまっていくゲームです。画面に猫が歩いてきて、クリックして撫でるとコインがたまります。そのコインで能力を上げていく作りで、進行状況をすべて消す「最初からやり直す」ボタンを付けていました。

この記事では、そのボタンが保存データしか消していなかった時の症状と原因、そして直し方をまとめます。放置ゲームに限らず、ログアウトやフォームのクリアなど、画面の状態をまとめて初期化する処理を書く場面で同じことが起きます。

症状: 数値は 0 に戻るのに、画面の表示だけ残る​

遊んだ人から、「最初からやり直す」を押しても画面に猫が出たままだという報告がありました。確認ダイアログで OK を押すと、コインも能力のレベルもきちんと 0 に戻ります。戻らないのは、その時に画面へ表示されていたものだけです。

項目リセット後の状態
コイン0 に戻る
能力のレベルすべて 0 に戻る
保存されているデータ消える
画面にいる猫そのまま残る
ハートやコインの演出そのまま残る

再現の条件は、猫が画面にいる間にリセットを押すことです。猫がいないタイミングで押せば何も起きないので、ふだんの動作確認では気づきにくいです。

この症状が困るのは、見た目の問題で済まない点です。猫には種類があり、まれに出てくる種類ほど撫でた時のコインが増えます。リセット直後にその猫が残っていると、初期状態のはずなのに高い倍率で稼げてしまいます。

原因: 状態の置き場所が 2 つに分かれている​

リセット処理が、保存データとその写しだけを初期化していました。

$('reset').addEventListener('click', () => {
if (!confirm('進行状況をすべて消して最初からやり直します。よろしいですか?')) return;
removeKey(SAVE_KEY);
Object.assign(save, initialSave(now()));
persist();
renderUi();
});

removeKey() が保存領域からセーブデータを消し、Object.assign() が実行中に持っているセーブ用オブジェクトへ初期値を入れ直しています。コインとレベルはこのオブジェクトの中にあるので、数値は正しく 0 に戻ります。

一方、画面に出ている猫は、シミュレーションを受け持つ別のオブジェクトが持っていました。演出の粒も、また別の配列に入っています。どちらもリセット処理が触っていないです。

リセット処理が保存データと写しだけを初期化し、画面にいる猫と演出の配列には届いていない構造を示した図

状態を置き場所ごとに並べると、性質が 2 つに分かれます。

置き場所持っているもの再読み込みすると
保存領域コイン、レベル、保存した時刻そのまま残る
セーブ用オブジェクト保存領域と同じ内容の写し保存領域から作り直される
シミュレーションのオブジェクト画面にいる猫、次の猫までのタイマー初期状態になる
演出の配列ハートやコインの粒初期状態になる

下の 2 つは保存されないので、再読み込みすれば勝手に消えます。この性質が、見落としを誘う形で働きます。開発中は変更を反映するために何度も再読み込みをするため、実行時だけの状態は自然と初期化され、取りこぼしが表に出ないです。

もう 1 つ、機能を足した順番も効いています。リセット処理を書いた時点では、保存データとその写ししか状態がなかったです。あとから画面の演出を追加した時に、新しい状態の置き場所が増えました。リセット処理のほうは、その追加に合わせて見直されないまま残っていました。

対処: 実行時の状態にもリセットを用意する​

シミュレーションのオブジェクト側に、自分を初期状態へ戻すメソッドを足しました。猫と、猫が来るまでのタイマー、自動で撫でる処理の蓄積時間を、新品と同じ値に戻します。

export class CatSim {
cat: Cat | null = null;
private spawnTimer = 0;
private autoAcc = 0;

/** 画面上の猫と内部タイマーを初期状態に戻す(最初からやり直す時用)。 */
reset(): void {
this.cat = null;
this.spawnTimer = 0;
this.autoAcc = 0;
}
}

初期化の中身をオブジェクトの中に置いたのは、どんなフィールドを持っているかを知っているのがそのオブジェクト自身だからです。呼び出し側からフィールドを 1 つずつ書き換える形にすると、フィールドが増えた時に書き換える場所が遠くなります。

リセット処理からは、このメソッドと演出の配列の空にする処理を呼びます。

$('reset').addEventListener('click', () => {
if (!confirm('進行状況をすべて消して最初からやり直します。よろしいですか?')) return;
removeKey(SAVE_KEY);
Object.assign(save, initialSave(now()));
sim.reset();
particles.length = 0;
persist();
renderUi();
});

配列を空にするのに particles.length = 0 を使っているのは、描画処理が同じ配列を参照し続けているためです。particles = [] と書くと新しい配列に差し替わるだけで、描画側が見ている配列には古い粒が残ります。

再読み込みで済ませなかった理由​

状態を初期化する手軽な方法として、保存データを消してからページを再読み込みするやり方があります。実行時だけの状態はすべて作り直されるので、一見するともっとも確実です。

ただ、この作りでは選べなかったです。定期的な自動保存と、ページを離れる時の保存を入れていたためです。

window.addEventListener('pagehide', persist);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') persist();
});
setInterval(persist, 5000);

再読み込みはページを離れる動作でもあるので、途中で pagehide が発火します。この時、実行中のセーブ用オブジェクトにリセット前の内容が残っていれば、消したはずのデータが保存領域へ書き戻されます。再読み込みに頼るなら、先に自動保存を止めるか、セーブ用オブジェクトを初期化しておく必要があります。手間としては、状態を持つ側にリセットを足すのと変わらないです。

同じ取りこぼしはリセット以外でも起きる​

この不具合の形は、放置ゲーム特有のものではないです。保存される状態と実行時だけの状態を別々に持つ画面であれば、まとめて初期化する処理すべてに当てはまります。

まとめて初期化する処理消し忘れやすい実行時の状態
ログアウト画面に表示中の一覧、開いているモーダル、動いたままのポーリング
別のアカウントへの切り替え取得結果のキャッシュ、スクロール位置、選択中の行
フォームのクリア入力エラーの表示、選んだファイル、確認ダイアログの開閉
検索条件のリセット前回の検索結果、ページ番号、並び替えの指定

ログアウトが分かりやすい例です。保存してあるトークンを消しても、画面に出ている利用者名や一覧のデータは、実行中のオブジェクトが持ったままになります。数秒ごとにデータを取りに行くタイマーも動き続けるので、ログアウト後に失敗したリクエストが流れることになります。

共通しているのは、どれも「消す対象」として最初に思い浮かぶのが保存されている側だという点です。保存されている側は、消す場所がはっきりしていて目に見えます。実行時だけの状態は、持ち主がコードのあちこちに散らばっているので、数え上げるまで存在を思い出しにくいです。

再発を防ぐ​

まず、リセットが効いていることを単体テストで固定しました。時間を進めて猫が画面に出た状態を作り、リセットしたあとに消えていることを確認しています。

it('reset で猫が消え、タイマーも初期化される', () => {
const sim = new CatSim(() => 0.1);
for (let i = 0; i < 5 * 30; i++) sim.update(1 / 30, stats);
expect(sim.cat).not.toBeNull();
sim.reset();
expect(sim.cat).toBeNull();
});

テストの形として大事なのは、リセットの前に状態を作っている点です。初期状態のままリセットを呼んで初期状態であることを確かめても、何も検証できていないです。何かが入っている状態を先に作ってから、消えることを見ます。

次に、リセット処理を書く時の進め方を変えました。処理から書き始めず、状態を持っている場所を先に書き出します。

  • どこかに保存されている値
  • 実行中のオブジェクトやクラスが持つフィールド
  • 画面の表示に使っている配列
  • 動いているタイマーや、受け取り待ちの通信

書き出してから、1 つずつリセットの対象に入れるかどうかを決めます。対象にしないものがあれば、その理由も残しておくと、あとから読んだ時に漏れと区別が付きます。

レビューの観点としては、新しく状態を持つものを足した変更で、リセット処理が一緒に変わっているかを見ます。今回の漏れも、画面の演出を足した時に露見しました。状態を増やす変更は、初期化する側にも影響が及びます。

状態の置き場所を増やさない設計にしておくのも手です。実行時の状態を 1 つのオブジェクトにまとめておけば、リセットはそのオブジェクトを作り直すだけで済みます。置き場所が増えるほど、列挙する手間と漏れる確率が上がります。

まとめ​

リセット処理が保存データだけを消していて、画面に出ている状態が残りました。保存される状態と実行時だけの状態が別の場所にあり、片方しか見ていなかったことが原因です。同じ取りこぼしを避けるために、次の点を押さえておくとよいでしょう。

  • リセットは保存してある内容を消すだけでは足りず、画面を動かしている実行中の状態も初期状態に戻す
  • 実行中の状態は再読み込みすれば消えるため、開発中の確認では取りこぼしが表に出にくい
  • 処理から書き始めず、状態を持っている場所を先に書き出してから、対象に入れるかどうかを決める