「設定画面」を作り込む:音量・動き・言語の保存
遊びをつくる執筆にClaudeを使用しています。

音量を半分に下げて遊び終え、翌日に同じゲームを開いたら、最大音量で音が鳴った。そんな場面を考えてみます。設定画面そのものはきちんと動いていたはずなのに、プレイヤーにとっては「設定が効かないゲーム」になります。 この問題は、スライダーやチェックボックスを並べる作業ではほとんど解けません。設定画面の出来を決めるのは、閉じる前の保存と開いた直後の復元です。壊れた値、古い形式、保存できない環境が混ざっていても、起動時に必ず使える状態へ戻す必要があります。 この記事では、音量・動きを抑える設定・言語の3項目を例に、単独で動くHTMLを1つ作ります。作ったあとは、保存データをわざと壊して復元の挙動を確かめる手順まで進みます。最後に、アソバースで公開する作品では保存先をどう考えるべきかも整理します。
この記事の目次
設定画面の仕事は「保存」より「復元」にある
保存は、値を文字列にして書き込むだけなので失敗しにくい処理です。難しいのは読み込み側です。次のような状態を、起動のたびに想定する必要があります。
- 初めて遊ぶので、保存データがありません。
- 保存データが途中で壊れていて、JSONとして読めません。
- 項目が足りない、または想定外の型や範囲外の値が入っています。
- 前のバージョンのゲームが書いた古い形式です。
- ブラウザの設定により、そもそも保存領域に触れません。
そこで、起動時の流れを次の順に固定します。
- 保存領域から文字列を読みます。読めなければ「なし」として扱います。
- JSONとして解釈します。失敗したら既定値を使います。
- 古い形式なら新しい形式へ変換します。
- 項目ごとに型と範囲を検査し、不正な項目だけ既定値に戻します。
- 検査済みの値を、画面と音の両方へ反映します。
要点は、保存データを信用せず、必ず検査を通すことです。この記事のコードは、この5段階をそのまま関数に分けた構成になっています。
公式作品から読み取れることも一つあります。アソバースの公式作品13本は、保存があるのが3作品だけで、残りの10作品の記録や録音は画面を開いている間だけ残る設計です。一方で、言語変更やプレイ画面の拡大では進行を保つとされています。「何を永続させ、何を一時にとどめるか」を作品ごとに決める発想は、設定の設計にもそのまま使えます。保存の細かい仕様は、各作品を開いて確かめてください。
手順1:保存する項目と形を決める
最初に、保存する項目を表にして決めます。
- 音量、保存する値:0〜1の数値、既定値の決め方:0.7など控えめな値、検査:数値か、0〜1に収まるか
- 動きを抑える、保存する値:true / false、既定値の決め方:端末の「動きを減らす」設定に合わせる、検査:真偽値か
- 言語、保存する値:"ja" または "en"、既定値の決め方:ブラウザの言語に合わせる、検査:候補のどちらかか
- 形式の版、保存する値:数値(2など)、既定値の決め方:現在の版、検査:古い版なら変換
決めておくと後で効くのは、次の3点です。
- 保存するのは「画面の状態」ではなく「意味のある値」です。 音量はスライダーの位置(0〜1)で保存し、実際の音の大きさへの変換は別に持ちます。聴感上の大きさは対数的に変わるため、スライダー値を二乗して使うなど、変換の仕方を後から変えたくなるからです。保存値が変換後の数値だと、変えたとたんに過去の保存と食い違います。
- 既定値は固定値ではなく、環境から決める項目があります。 動きを抑える設定は、端末側にすでに同じ意味の設定があります。初回だけはそれに従い、プレイヤーが一度でも触れたあとはゲーム内の選択を優先する、という方針にします。
- 版番号を最初から入れます。 後で項目を増やしたり単位を変えたりしたとき、古い保存を見分ける手がかりになります。
手順2:読み込みを疑う実装を書く
次のコードをHTMLファイルとして保存し、ブラウザで開けば動きます。外部ライブラリやサーバーは不要です。
<!doctype html>
<html lang="ja"><meta charset="utf-8">
<style>.calm *{transition:none!important;animation:none!important}</style>
<h1 id="t"></h1>
<label><span id="lv"></span> <input id="vol" type="range" min="0" max="100"></label>
<label><input id="rm" type="checkbox"> <span id="lm"></span></label>
<select id="lang"><option value="ja">日本語</option><option value="en">English</option></select>
<button id="test">♪</button>
<script>
const KEY="demo-settings", VERSION=2, $=id=>document.getElementById(id);
const TEXT={ja:{t:"設定",v:"音量",m:"動きを抑える"},en:{t:"Settings",v:"Volume",m:"Reduce motion"}};
const store={ // 保存先を1か所に閉じ込める
read(){try{return localStorage.getItem(KEY)}catch(e){return null}},
write(s){try{localStorage.setItem(KEY,s)}catch(e){}}
};
function defaults(){
return {version:VERSION, volume:0.7,
reduceMotion:matchMedia("(prefers-reduced-motion: reduce)").matches,
lang:navigator.language.startsWith("ja")?"ja":"en"};
}
function sanitize(raw){
const d=defaults(); let o;
try{o=JSON.parse(raw)}catch(e){return d}
if(!o||typeof o!=="object")return d;
if(o.version===1&&typeof o.sound==="number")o.volume=o.sound/100; // 旧形式の変換例
const v=Number(o.volume);
return {version:VERSION,
volume:Number.isFinite(v)?Math.min(1,Math.max(0,v)):d.volume,
reduceMotion:typeof o.reduceMotion==="boolean"?o.reduceMotion:d.reduceMotion,
lang:(o.lang==="ja"||o.lang==="en")?o.lang:d.lang};
}
let settings=sanitize(store.read()), ctx, gain;
function apply(){
const s=settings, t=TEXT[s.lang];
document.documentElement.lang=s.lang;
document.documentElement.classList.toggle("calm",s.reduceMotion);
$("t").textContent=t.t; $("lv").textContent=t.v; $("lm").textContent=t.m;
$("vol").value=Math.round(s.volume*100); $("rm").checked=s.reduceMotion; $("lang").value=s.lang;
if(gain)gain.gain.value=s.volume;
}
const save=()=>store.write(JSON.stringify(settings));
$("vol").oninput=e=>{settings.volume=e.target.value/100;apply()};
$("vol").onchange=save;
$("rm").onchange=e=>{settings.reduceMotion=e.target.checked;apply();save()};
$("lang").onchange=e=>{settings.lang=e.target.value;apply();save()};
$("test").onclick=()=>{
ctx=ctx||new AudioContext();
if(!gain){gain=ctx.createGain();gain.connect(ctx.destination)}
gain.gain.value=settings.volume;
const o=ctx.createOscillator();o.connect(gain);o.start();o.stop(ctx.currentTime+0.2);
};
apply();
</script>このコードの読みどころを、順に確認します。
- store に保存先を閉じ込めています。 読み書きの関数を1か所にまとめ、try/catch で包んでいます。保存領域が使えない環境でも例外で止まらず、「保存されないだけで遊べる」状態になります。保存先を変えたくなったとき(後述のクラウドセーブなど)も、この部分だけの差し替えで済みます。
- sanitize は項目ごとに検査します。 音量だけが壊れていても、言語や動きの設定は生かされます。全項目を既定値に戻すより、プレイヤーの選択を残せます。
- version===1 の分岐が旧形式の変換です。 例では、0〜100の整数だった sound を0〜1の volume へ直しています。版を入れておくと、変換の根拠が明確になります。公式作品でも、古い保存(version1)が新しい形式(version2)へ移行すると案内されています。
- AudioContext はボタンを押した時点で作ります。 ブラウザは、ユーザー操作の前に音を出す処理を制限することがあるためです。起動時の復元では「設定の値」だけを戻し、実際の音の生成は最初の操作以降に回します。そのため apply は、gain がある場合にだけ音量を反映します。
手順3:変更の反映と保存を分ける
コードでは、スライダーに2つのイベントを割り当てています。
- input:ドラッグ中に何度も発生します。画面と音量の反映だけを行い、保存はしません。
- change:指やマウスを離したときに1回だけ発生します。ここで保存します。
ドラッグのたびに保存すると、無駄な書き込みが増えます。また、スライダーの途中の値が保存されかねません。反映は即時に、保存は確定時に、と分けておきます。
チェックボックスと選択欄は、変更が1回で確定するため、反映と保存を同じ場所に置いています。
もう一つ、言語の切り替えで確かめたいことがあります。言語を変えても、ゲームの進行(盤面、スコア、手順の途中など)が消えてはいけません。設定の変更は「表示の更新」にとどめ、ゲームの状態を作り直さない構造にしておきます。コードの apply が文字と見た目だけを書き換えているのは、この意図です。
アソバースのサイトは日本語・英語の表示切り替えに対応していて、作者が入力した作品名や紹介文は書き換えません。公式作品も、サイトの言語設定に合わせて表示を切り替え、言語変更中も進行を保つ構成です。サイトの言語設定をゲームがどう受け取れるかは、この記事の範囲では確認できていません。現在の案内で確認してください。確認できるまでは、ゲーム内に言語の選択肢を持っておくと安全です。
動きの設定で気をつけること
「動きを抑える」は、見た目の演出だけでなく、画面の揺れ、点滅、自動スクロールなど、酔いや不快感につながる動きすべてが対象です。サンプルでは、CSSの transition と animation を止めるクラスを付けています。実際のゲームでは、JavaScriptで動かしている演出も、この設定を見て止めるか弱める必要があります。「動きを抑える」設定は、公式作品13本すべてが対応しているものでもあります。
手順4:壊して復元を確かめる
実装したら、保存データをわざと壊して、起動時にどうなるかを確認します。ブラウザの開発者ツールを開き、保存領域の一覧(多くのブラウザでは「Application」や「ストレージ」と呼ばれる欄)から、キー demo-settings を探します。その値を書き換えてページを再読み込みしてください。
- {"version":2,"volume":5}、正しいと言える状態:音量は上限の100になり、動きと言語は既定値で動く
- {broken、正しいと言える状態:例外が出ず、すべて既定値で起動する
- {"version":1,"sound":30}、正しいと言える状態:音量が30として復元される
- {"version":2,"lang":"fr"}、正しいと言える状態:言語だけ既定値に戻り、他の項目は壊れない
- キーごと削除、正しいと言える状態:既定値で起動し、動きの設定は端末の設定に従う
この表の「正しいと言える状態」は、実装が満たすべき期待値です。実際の動作は、読者が自分の環境で確かめてください。
さらに次の2点も確認します。
- 往復の確認: 3項目をすべて既定値以外にして再読み込みし、そのまま復元されるか確かめます。
- 保存できない環境の確認: 保存領域を使えない状態(ブラウザのプライベートモードや保存の禁止設定など)で開き、設定画面が操作できて、ゲームが止まらないか確かめます。この状態では、再読み込みで既定値に戻るのが正しい動作です。
起こりやすい誤りも挙げておきます。
- 範囲外の値を検査せずに音量へ渡し、想定外の大音量や例外を生んでしまいます。
- 保存データの版を持たず、項目を増やしたときに旧データを見分けられなくなります。
- 既定値を毎回の保存に書き込み、端末の設定が後から変わっても追従できなくなります。意図した挙動かどうかを決めておきます。
- 音量を変換後の値で保存し、変換式を変えたときに保存値の意味が変わってしまいます。
アソバースで公開するときの保存先
ここまでは localStorage に保存するコードを使いました。ただしアソバースにアップロードした作品では、確認済みの扱いが異なります。アップロード作品内の localStorage と sessionStorage は、一時メモリーの互換処理です。つまり、手元のブラウザで保存できたからといって、公開後も次回の起動に残るとは考えないでください。
公開後も設定を残したい場合の選択肢は、確認済みの範囲では次のとおりです。
- クラウドセーブを使います。 アップロードしたゲームにはSDKが自動で追加されます。URL投稿のゲームは、作者がSDKを読み込むと使えます。セーブは作品ごとにJSONで128KiBまでです。ログイン中のプレイヤーのクラウドセーブを使えます。呼び出し方は公式の案内で確認してください。
- 保存しない設計にします。 公式作品の多くのように、画面を開いている間だけ設定を保つ方法です。起動時の既定値を丁寧に決めれば、保存なしでも不便は小さくなります。
どちらでも、この記事の store のように保存先を1か所に閉じ込めておけば、sanitize と apply は変わりません。差し替えるのは store.read と store.write の中身だけです。
クラウドセーブを使うなら、次の点も設計に入れておきます。
- 設定は軽いデータなので、ゲームの進行と同じJSONの一部に入れても、128KiBの制限に収まる見込みは高いです。入れる前に、全体のサイズを見積もっておきます。
- ゲストの履歴や進行は端末内に保存され、ログイン後のアカウントへは自動では混ざりません。ログイン前に変えた設定が、ログイン後に引き継がれると期待させない画面にしておきます。
- ゲームにはFirebaseのUID、認証トークン、メールアドレスを渡さない決まりです。設定の保存のために、プレイヤーの識別情報をゲーム側で扱おうとしないでください。
次の行動
まず上のコードをHTMLファイルとして保存し、手順4の表にある5つの書き換えを順に試してください。うまく復元できない行があれば、その項目の sanitize の検査を見直します。
自分のゲームに組み込む場合は、保存する項目と既定値を、手順1の表の形で書き出すところから始めます。公開前に、保存先をクラウドセーブにするか、保存しない設計にするかを、現在の案内を見て決めてください。
参考資料
- MDN Web Docs「Web Storage API」
- MDN Web Docs「prefers-reduced-motion」
- MDN Web Docs「Web Audio API」

