「ステージ選択」を作り込む:解放・未解放・クリア済みの表示
遊びをつくる執筆にClaudeを使用しています。

ステージが8つあるゲームを作り、ステージ選択画面に8個のボタンを並べた場面を考えてみます。最初に遊べるのは1つだけで、残りは順番に開いていく仕組みです。このとき、ボタンの見た目を「灰色にする」だけで済ませると、遊ぶ人は「壊れているのか、まだ遊べないのか」を判断できません。クリア済みのステージが他と同じ見た目だと、「自分がどこまで進んだか」も伝わりません。 ステージ選択は、ゲームの中で唯一「進み具合」だけを見せる画面です。この記事の問いは、解放・未解放・クリア済みの3状態をどうデータで持ち、どう見せれば、遊ぶ人が迷わず次の一手を選べるか、です。 結論を先に言うと、保存するのは「クリアしたステージの集合」だけにして、解放状態はそこから毎回計算します。見せ方は色だけに頼らず、記号と文字を併用し、未解放を押したときには理由を返します。以下の手順で、単独で動く最小のHTMLを作りながら確かめます。
この記事の目次
手順1:状態を三つに絞り、保存するものを一つに決める
最初にコードを書く前に、状態の持ち方を決めます。
- 状態を「未解放(locked)」「挑戦可(open)」「クリア済み(cleared)」の3つに分けます。「挑戦可」は、解放済みでまだクリアしていない状態です。ここを分けておくと、「次に遊ぶのはここ」という目印を出せます。
- 保存するのは「クリアしたステージ番号の集合」だけにします。「解放済みステージの一覧」や「現在の到達番号」は保存しません。
- 解放状態は、集合と解放ルールから毎回計算します。今回のルールは「ステージ1は常に解放、それ以外は一つ前をクリアしていれば解放」です。
「到達番号」を保存する設計は手軽ですが、後から困ります。たとえば「ステージ5は、ステージ2と3の両方をクリアしたら解放」というルールに変えたくなったとき、到達番号では表現できません。クリア済みの集合だけを持っていれば、ルールを関数として差し替えるだけで済み、保存データの形は変わりません。
状態ごとの見せ方と振る舞いを、先に表で決めておきます。
- 未解放、条件:解放ルールを満たさない、見た目:破線の枠、薄い色、🔒、押したとき:遊べない理由を表示する、読み上げ用の文言:「ステージN 未解放」
- 挑戦可、条件:解放済みで未クリア、見た目:実線の枠、▶、押したとき:ステージを開始する、読み上げ用の文言:「ステージN 挑戦可」
- クリア済み、条件:クリアの集合に含まれる、見た目:緑の背景、✓、押したとき:再挑戦として開始する、読み上げ用の文言:「ステージN クリア済み」
この表の要点は、状態を「色」「枠線」「記号」「文言」の複数の手がかりで区別していることです。色の見分けにくい人や、画面を白黒に近い設定で見ている人でも、記号と文字で判断できます。
手順2:最小のステージ選択画面を作る
次のコードを、stage-select.html という名前で保存してください。外部ライブラリは不要で、ブラウザで開けばそのまま動きます。
<!DOCTYPE html>
<meta charset="utf-8">
<title>ステージ選択</title>
<style>
body{font-family:sans-serif;padding:1rem}
#grid{display:grid;grid-template-columns:repeat(4,5.5rem);gap:.5rem}
button{padding:.6rem;font-size:1rem;border:2px solid #345;border-radius:8px;background:#fff}
button[data-s="locked"]{border-style:dashed;color:#666;background:#eee}
button[data-s="cleared"]{background:#dff3e0}
button:focus-visible{outline:3px solid #e90}
</style>
<div id="grid"></div>
<p id="msg" role="status"></p>
<button id="reset">最初から</button>
<script>
const N = 8, MARK = {locked:'🔒', open:'▶', cleared:'✓'};
const LABEL = {locked:'未解放', open:'挑戦可', cleared:'クリア済み'};
let cleared = new Set();
const grid = document.getElementById('grid'), msg = document.getElementById('msg');
const stateOf = i => cleared.has(i) ? 'cleared'
: (i === 0 || cleared.has(i - 1)) ? 'open' : 'locked';
const serialize = () => JSON.stringify({version: 2, cleared: [...cleared]});
function restore(text){
try{
const d = JSON.parse(text);
if(d.version === 2 && Array.isArray(d.cleared))
cleared = new Set(d.cleared.filter(n => Number.isInteger(n) && n >= 0 && n < N));
}catch(e){}
}
const btns = [...Array(N)].map((_, i) => {
const b = document.createElement('button');
b.onclick = () => play(i);
grid.append(b);
return b;
});
function render(){
btns.forEach((b, i) => {
const s = stateOf(i);
b.dataset.s = s;
b.textContent = MARK[s] + ' ' + (i + 1);
b.setAttribute('aria-label', 'ステージ' + (i + 1) + ' ' + LABEL[s]);
b.setAttribute('aria-disabled', s === 'locked');
});
}
function play(i){
if(stateOf(i) === 'locked'){
msg.textContent = 'ステージ' + (i + 1) + 'は未解放です。ステージ' + i + 'をクリアすると遊べます。';
return;
}
cleared.add(i);
msg.textContent = 'ステージ' + (i + 1) + 'をクリアしました(テスト用)。';
render();
}
document.getElementById('reset').onclick = () => { cleared = new Set(); render(); };
render();
</script>play は本来、ステージを開始する関数です。ここではテスト用に「押すとクリアになる」処理を入れています。実際のゲームでは、ステージの終了時に cleared.add(番号) を呼び、選択画面へ戻って render() を呼ぶ形にします。
構造のポイントは3つあります。
- 状態を返す stateOf は、クリア集合だけを材料にしています。解放ルールを変えたくなったら、この関数だけを直します。
- ボタンは最初に8個作り、render では中身と属性を更新するだけです。毎回作り直すと、キーボードで操作中のフォーカス位置が失われます。
- 未解放のボタンには disabled を使わず、aria-disabled を付けています。disabled のボタンはフォーカスできず、押しても反応しないため、理由を伝えられません。
手順3:動かして、三つの状態を順に確かめる
ブラウザでファイルを開き、次の順に操作してください。
- 開いた直後の見た目を確認します。ステージ1だけが「▶ 1」の実線で、2〜8は「🔒」の破線になっているはずです。
- 🔒の付いたステージ5を押します。画面下に「ステージ5は未解放です。ステージ4をクリアすると遊べます。」と出れば、理由の表示は動いています。
- ステージ1を押します。「✓ 1」の緑の表示に変わり、ステージ2が「▶ 2」になるはずです。
- Tabキーでボタン間を移動し、Enterで押します。マウスなしでも同じ操作ができ、フォーカスの枠(橙色)が見えることを確認してください。
- ブラウザの開発者ツールのコンソールで serialize() を実行します。ステージ1と2をクリアした状態なら、{"version":2,"cleared":[0,1]} という文字列が返ります。保存されるのはこの短い文字列だけで、解放状態は含まれません。
- 「最初から」を押して、表示が最初の状態に戻ることを確認します。
起こりやすい誤りも挙げておきます。
- ステージの順序を入れ替えたあとで、保存された番号とステージの対応がずれることがあります。ステージを追加・並べ替えする予定があるなら、番号ではなく "tide-3" のような固定のIDで集合を持つ設計にしてください。上のコードは短くするために番号を使っています。
- 解放ルールを描画側にも書き写すと、表示と実際の挙動がずれます。判定は stateOf の一箇所に置き、描画も play もそれを呼ぶようにします。
- 保存データを読み込むときに、範囲外の番号や配列でない値を弾かないと、壊れた保存で画面全体が動かなくなります。restore の検証は省かないでください。
手順4:未解放の見せ方を、ゲームに合わせて選ぶ
未解放の表示は、ゲームの性格によって適した方法が変わります。次のように、遊ぶ人に何を知らせたいかで選びます。
- 条件を明示する:「ステージ4をクリアで解放」と書く方法です。パズルのように順に進むゲームに向きます。上のコードの方式です。
- 存在だけ見せる:🔒とステージ番号だけ出し、条件は伏せる方法です。発見の楽しみを残したいときに向きますが、遊ぶ人が詰まったときの手がかりが減ります。
- 見えること自体を解放にする:未解放のステージは名前も伏せ、「?」と表示する方法です。ステージ数そのものを秘密にしたいときだけ使います。
どの方法でも、「押せないものを押したときに何も起きない」状態は避けてください。何も起きないと、遊ぶ人は操作が受け付けられなかったのかと疑います。
クリア済みの扱いも決めておきます。クリア済みのステージを再挑戦できるかどうかは、選択画面の設計に直結します。再挑戦できるなら、緑の✓と「押せる」ことを両立させます。上のコードでは、クリア済みも押せます。もし再挑戦を認めないなら、押したときに「クリア済みです」と返すか、ボタンをやめて一覧表示に変えます。
さらに進めるなら、「挑戦可」と「クリア済み」に付加情報を足せます。たとえば最少手数や最速記録などです。ただし、記録は保存の範囲を決めてから出してください。保存されない記録を画面に出すと、遊ぶ人は「前に出した記録が消えた」と感じます。
手順5:アソバースに置くときの保存の考え方
アソバースに投稿した作品では、保存の扱いに注意が必要です。確認済みの範囲で整理します。
- アップロードした作品の中の localStorage と sessionStorage は、一時メモリーの互換処理です。つまり、ブラウザ標準のような永続保存を期待せず、画面を開いている間だけ残るものとして設計するのが安全です。
- 進行を残したいときは、アソバースのクラウドセーブを使います。作品ごとにJSONで128KiBまで保存でき、アップロードした作品にはSDKが自動で追加されます。呼び出し方は、現在の公式案内で確認してください。
- 手順1の設計は、この制約と相性が良いです。クリア済みの番号を並べた短い文字列なので、サイズ上限に近づく心配はほとんどありません。serialize の出力をそのまま保存の中身にできます。
- ログインしていない人の扱いは、作品側で決めておく必要があります。アソバースはログインなしでも遊べ、ゲストの履歴や進行は端末内に保存され、ログイン後のアカウントへ自動では混ざりません。ゲストの進行を引き継ぐかどうかは、画面の説明にも書いておくと親切です。
保存の形式には、最初から version を入れておいてください。コードの {version: 2, ...} がそれにあたります。あとからステージ数を変えたり形式を変えたりするときに、古い保存を見分けて移行する手がかりになります。
手順6:公式作品でステージ選択の見せ方を観察する
自分の画面を作ったら、他の作品の「進み具合の見せ方」と見比べてください。アソバースの公式作品には、ステージや段階を持つものがあります。
- プリズムの道は8ステージ構成で、解放済みステージの選び直しができます。
- 月夜の配達は3つの夜があり、各夜の最少区間数はプレイ中だけ記録されます。
- 紙ひこうき郵便は海辺・丘・山の3ルートです。
- 小さな軌道は3つの星系を持ちます。
それぞれを開いて、次の点を自分の目で確かめてください。この部分の具体的な画面は、作品を開いて確認するものです。
- 遊べるものと遊べないものは、何で区別されているでしょうか。色だけでしょうか、記号や文字も使われているでしょうか。
- 一度クリアしたものに戻れるでしょうか。戻れるなら、その入口はどこにあるでしょうか。
- 記録は、画面を閉じても残るでしょうか。残らない場合、その旨は画面上で分かるでしょうか。
- 「動きを抑える」設定にしたとき、選択画面の表示はどう変わるでしょうか。
観察の結果は、ご自身の画面設計のチェック項目に変えてください。たとえば「他の作品は未解放の理由を出していたか」を見て、自分の作品にも入れるかどうかを決める、といった使い方です。
次の行動
まず上のコードを保存し、手順3の6項目を順に確かめてください。次に、自分のゲームの解放ルールを stateOf の中に書き換えてみてください。「一つ前をクリア」から「2つ以上クリアで解放」などに変えても、保存の形が変わらないことを確かめられます。最後に、完成したゲームを一人に遊んでもらい、未解放のボタンを押したときに迷わないかを聞いてみてください。

