JavaScriptでRPGを作る④|ワールドマップ・シーン遷移・セーブ(localStorage)【Archipelago Saga制作】

アプリの作り方

JavaScriptでRPGを作る④|ワールドマップ・シーン遷移・セーブ(localStorage)【Archipelago Saga制作】

戦闘の“外側”には、もう一つの世界がある。どの島へ行けるか、どこまで進んだか、そしてブラウザを閉じても続きから遊べること。ワールドマップ・画面遷移・セーブだ。ここも Archipelago Saga は徹底して「データを描くだけ/状態を1つにまとめるだけ」で作ってある。第4回は、蛇行する島マップ・シーンのハブ構造・localStorage セーブを実コードで開く。

▶ 第3回(CTB戦闘コア)の続き。全体像は連載ハブ完成版でマップを動き回ると、これから作る構造が掴める。

目次
  1. 第4回のゴール
  2. Step 1. ワールドマップ=データを線でつなぐ
  3. Step 2. ロック解放:「直前をクリアしたら次が開く」
  4. Step 3. シーン遷移:worldmap を“ハブ”にする
  5. Step 4. 状態:すべてを1つのオブジェクトに集める
  6. Step 5. セーブ:S を JSON にして保存するだけ
  7. Step 6. 手を動かす:クリア記録→解放→オートセーブ
  8. 設計Q&A:セーブまわりで迷いやすい3つ
  9. まとめ:第4回の要点

第4回のゴール

(1) stages.js のデータから蛇行するワールドマップを描き、(2) クリア済みかどうかでロックを解放し、(3) 全進行を1つの状態オブジェクトに集約してセーブする。この3点を理解する。ブラウザ(localStorage)でもアプリ(SQLite)でも同じコードで動く保存設計も見る。

Step 1. ワールドマップ=データを線でつなぐ

マップ画面は、第2回の G.STAGES(ノードの配列)を読んで描くだけ。各ノードは x, y 座標を持つので、その点を順に結べば「蛇行する道」になる。道は SVG の path 一本だ。

// scenes/worldmap.js(道の生成・抜粋)
var stages = G.STAGES;
var d = "";
stages.forEach(function (s, i) {
  d += (i === 0 ? "M" : "L") + s.x + " " + s.y + " ";   // 1点目はMove、以降はLineで結ぶ
});
track.setAttribute("d", d);   // ← stages の座標を順に結んだ一本道(蛇行パス)

ノード(島・町・ボス)も、同じ配列をループして座標に画像を置くだけ。マップの形は stages.jsx,y が決める。第2回で「ステージはただのデータ」と言った意味が、ここで効く。マップ画面のコードは、データを並べて描く以上のことをしない。

Archipelago Saga のワールドマップ(ノードとセーブ)
ワールドマップはシーンの”ハブ”。ノードは stages.js のデータを線でつないだもので、クリア記録は localStorage に自動保存され、次のノードが解放される。

Step 2. ロック解放:「直前をクリアしたら次が開く」

進行は クリア済みステージの記録S.cleared)で表す。あるステージの状態は、たった3つ。「クリア済み(cleared)/挑戦可能(open)/未開放(locked)」。判定はシンプルだ。

図1:直前のステージをクリアすると、次が開く 現在 🔒 🔒 cleared cleared open(挑戦可) locked

function clearedOf(id) { return !!S.cleared[id]; }     // クリア記録にあるか
function statusOf(s) {
  if (clearedOf(s.id)) return "cleared";               // 自分がクリア済み
  var idx = stages.indexOf(s);
  if (idx === 0) return "open";                        // 最初のステージは常に開いている
  if (clearedOf(stages[idx - 1].id)) return "open";    // ★直前がクリア済みなら開放
  return "locked";                                     // それ以外は未開放
}

核心は 「直前のステージがクリア済みなら open」の一行。S.cleared はただの { "0": true, "1": true } のようなオブジェクトで、戦闘に勝つたびにキーが1つ増える。進行データはこれだけ。マップは毎回この記録を見て、開ける島・鍵のかかった島を色分けして描く。

Step 3. シーン遷移:worldmap を“ハブ”にする

第1回の G.game.show() を思い出そう。画面はこの司令塔ですべて切り替わる。ゲーム全体の流れは「一本道の本編」+「マップから開くサブ画面」という形だ。

図2:worldmap を中心に画面が回る title party worldmap battle result 勝ったら戻る(次の島が開く) status shop / 町 図鑑 本編は title→party→worldmap→battle→result。サブ画面は worldmap から開いて戻る。

島をクリックしたときの処理はこうだ。開いている島なら、(戦闘前会話があれば挟んで)戦闘へ。すべて G.game.show() 経由なので、画面同士は直接つながらない。

function onStageClick(s) {
  if (statusOf(s) === "locked") return;                 // 鍵つきは無視
  if (s.type === "town") { G.game.show("town", { stage: s }); return; }
  if (s.story && !G.state.data.story[s.story]) {        // 未読の戦闘前ストーリーがあれば
    G.game.show("story", { id: s.story, next: "battle", stage: s });
  } else {
    G.game.show("battle", { stage: s });                // 戦闘へ(第3回の G.createBattle がここで走る)
  }
}

Step 4. 状態:すべてを1つのオブジェクトに集める

セーブの設計で迷ったら、原則はこれだ。「セーブしたいものは、全部1つのオブジェクトに入れる」。Archipelago Saga の全セーブデータは、S というオブジェクト1個だ。

// engine/state.js(状態オブジェクト S・抜粋)
var S = {
  party: [],                       // 編成中の職業id(例 ["ranger","mage",…])
  heroes: {},                      // 職業id → { level, xp, curHP, equip:{…}, … }
  inventory: { potion: 3 },        // アイテム所持数
  gear: {},                        // 装備の在庫
  gold: 0,
  cleared: {},                     // ★クリア済みステージ(Step2のロック解放が読む)
  story: {},                       // 既読フラグ
  bestiary: {},                    // 図鑑:敵id → { seen, kills, drops }
  started: false
};
G.state.data = S;   // ← 全画面がここを読み書きする(第1回の「Gにぶら下げる」)

レベルアップやステータスも、この S から計算する。第2回の base + growth がここで使われる。

function statsFor(classId, level) {
  var c = G.CLASSES[classId], s = {};
  for (var k in c.base) s[k] = c.base[k] + c.growth[k] * (level - 1);  // 初期値+伸び×(Lv-1)
  return s;
}
// 装備込みの実効ステータス:レベルの素ステ+装備のmodsを合算(戦闘コアが参照する)
function effStats(classId) { /* heroes[classId].stats に equip.mods を足す */ }

「セーブ=この S を丸ごと保存」「ロード=S を丸ごと差し替え」。データを1か所に集めておくと、保存処理がたった1行になる。次の Step で見よう。

Step 5. セーブ:S を JSON にして保存するだけ

保存は S を文字列化してストレージに渡すだけ。戦闘に勝った直後などに呼ばれるオートセーブはスロット0、プレイヤーが選ぶ手動セーブはスロット1〜5に書く。

// engine/state.js(抜粋)
save:   function () { G.storage.saveSlot(0, S, this.buildMeta()); },          // オートセーブ(slot0・撃ちっぱなし)
saveTo: function (slot) { return G.storage.saveSlot(slot, S, this.buildMeta()); }, // 手動(slot1〜5)
load:   async function (slot) { /* slotを読み S に流し込む → normalize() */ },
// セーブ一覧に出す表示用メタ(攻略数・パーティ・所持金)
buildMeta: function () {
  var cleared = Object.keys(S.cleared || {}).length;
  return { updated_at: new Date().toISOString(), location: "攻略 " + cleared + " ステージ", gold: S.gold };
}

実際にディスク(ブラウザ)へ書くのが storage.js。ここが賢い。同じ saveSlot() という窓口で、ブラウザなら localStorage、デスクトップアプリ(Tauri)なら SQLite に書き分ける。呼ぶ側(state.js)は、どちらで動いているかを知らなくていい。

図3:状態(S) → 保存窓口 → 環境ごとの保存先 G.state(S) 全セーブデータ1つ ▶ JSON ▶ G.storage 保存の窓口(共通) localStorage SQLite(Tauri) ブラウザ アプリ slot0=オートセーブ/slot1〜5=手動。呼ぶ側は保存先を意識しない。

// engine/storage.js(localStorage側・抜粋)
async function saveSlot(slot, S, meta) {
  var data = JSON.stringify(S);                          // ① 状態を丸ごと文字列化
  if (mode === "sql") { await invoke("save_slot", { slot: slot, data: data, /*…*/ }); } // アプリ:SQLite
  else {                                                 // ブラウザ:localStorage
    localStorage.setItem("archipelago.slot." + slot, data);
    localStorage.setItem("archipelago.slot." + slot + ".meta", JSON.stringify(meta));
  }
}

この「窓口を共通化して、中身だけ環境で切り替える」のは アダプタと呼ばれる設計だ。おかげで、ブラウザ版(このサイトで遊べる版)とデスクトップ配布版が、セーブ処理を1行も変えずに両立する。

Step 6. 手を動かす:クリア記録→解放→オートセーブ

3つがどう連動するかを最小コードで。戦闘に勝った後、result 画面はこうする。

// 戦闘勝利後の処理(要点)
G.state.data.cleared[stage.id] = true;   // ① クリアを記録(Step2のロック解放が次回これを見る)
G.state.data.gold += reward.gold;        // ② 報酬を反映
G.state.save();                          // ③ オートセーブ(S を slot0 に丸ごと保存)
G.game.show("worldmap");                 // ④ マップへ戻る → 次の島が "open" になっている

これだけで「勝つ → 次の島が開く → 進行が自動保存される」が成立する。ブラウザを閉じても、次回 load() すれば S がそのまま戻り、cleared も復元されるので、続きから遊べる。状態を1つにまとめた恩恵が、ここで全部回収できる。

💡 つまずきポイント:セーブに「関数」や「DOM要素」を入れると JSON.stringify で消える/壊れる。S には素の値(数値・文字列・配列・ただのオブジェクト)だけを入れること。計算で求まるもの(実効ステータス等)は保存せず、ロード後に S から再計算する。これがセーブを小さく頑丈に保つコツだ。

設計Q&A:セーブまわりで迷いやすい3つ

Q. オートセーブがあるなら、手動セーブ(slot1〜5)は要らないのでは?
A. 役割が違う。オートセーブ(slot0)は「事故で進行を失わない」ための保険で、常に最新に上書きされる。手動セーブは「ボス前に取っておいて、負けたらやり直す」といったプレイヤーの意思で残す地点だ。上書きされない置き場所が無いと、挑戦的なプレイができなくなる。保険と拠点、両方あって初めてセーブ機能になる。実装コストはスロット番号が変わるだけなので、両方入れない理由がない。

Q. 後のアップデートで S に新しい項目を足したら、古いセーブは壊れない?
A. そのために load() の最後で normalize() を通す。読み込んだデータに無いキーは既定値で補い、知らないキーは無視する。「古いセーブ+新しいコード」の組み合わせは配布後に必ず起きるので、読み込み側で吸収する口を最初から作っておく。ここを怠ると、アップデートのたびに既存プレイヤーのセーブが飛ぶ。「セーブが消えた」は、ゲームへの信頼を一発で壊す事故だ。

Q. ステージを1つ増やしたら、マップとセーブはどうなる?
A. stages.js の配列に1要素足すだけで、両方とも勝手に追随する。マップは配列を結んで描くので道が1区間伸び、ロック解放は「直前がクリア済みか」の相対判定なので新ステージにもそのまま効く。セーブとの互換も保たれる。S.cleared はステージidのキー集合であって「何番まで進んだ」という通し番号ではないから、既存のクリア記録は新ステージ追加の影響を受けない。データ駆動+idベースの設計が、コンテンツ追加を「配列に1行」まで軽くしている。

Q. 例えば、島の配置を自分のマップに差し替えたいときは?
A. 触るのは stages.jsx, y 座標だけだ。マップ画面は座標を結んで描いているだけなので、数値を差し替えれば道の形が変わる。方眼紙に島を並べる感覚で座標を書き、ブラウザで確かめて微調整する。コードでなくデータを編む作業なので、絵心や設計スキルは要らない。座標いじりはこのゲーム作りの中で一番気楽な工程なので、休憩がてら遊ぶくらいの気持ちで触ってみてほしい。地形が自分の手に馴染む感覚があるはずだ。世界地図を自分の手で編み直せるのは、データ駆動ならではの遊びでもある。

まとめ:第4回の要点

  • マップ=データを描くだけG.STAGESx,y を結べば蛇行パスになる。
  • ロック解放:状態は S.cleared だけ。「直前がクリア済みなら open」。
  • worldmap がハブ:本編は title→party→worldmap→battle→result。サブ画面はマップから開く。
  • 状態は1つに集約:全セーブデータを S 1オブジェクトに。実効値は保存せず再計算。
  • セーブ=JSON化S を丸ごと保存。slot0=オート/1-5=手動。
  • 保存はアダプタ:同じ窓口で localStorage(ブラウザ)/ SQLite(アプリ)を切り替え。

次回(第5回)は、ここまで「データ」として扱ってきた敵やキャラの見た目=AIアセットの量産フローへ。生成 → 正規化 → 透過(BiRefNet)→ 配置を型にして、絵が描けなくても世界観の揃った立ち絵・背景・アイコンを揃える方法を開く。

AI生成コンテンツの開示:本作の BGM・効果音・キャラクター画像・ボイスは、すべて生成AIの出力物です(BGM=ACE-Step 1.5〈MIT・出力の商用利用可を配布元モデルカードで確認済み〉 / ボイス=Irodori-TTS 系の合成設計音声・実在人物の複製ではありません / 効果音=数値合成 / 画像=画像生成AI〈各サービスの利用条件で出力の商用利用可を確認のうえ使用。透過処理は BiRefNet〈MIT〉を rembg 経由で使用〉)。第三者の著作物は同梱していません。© 2026 Cooliris(本作のコード・構成・データの権利は作者に帰属します)。


👉 ← 第3回:CTB戦闘コア連載ハブ▶ 遊ぶほかのアプリの作り方

JavaScriptでRPGを作る③|CTB戦闘システムをDOM非依存で実装する【Archipelago Saga制作】JavaScriptでRPGを作る③|CTB戦闘システムをDOM非依存で実装する【Archipelago Saga制作】前のページ

AIでゲーム素材を量産する⑤|生成→透過切り出し(BiRefNet)→配置のパイプライン【Archipelago Saga制作】次のページAIでゲーム素材を量産する⑤|生成→透過切り出し(BiRefNet)→配置のパイプライン【Archipelago Saga制作】

ピックアップ記事

  1. 高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyIns…

  2. New Eden Intelligence Hub の作り方 — EVE Onl…

  3. Claude Mission Control の作り方 — Tauri+Pyth…

  4. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…

関連記事

  1. 都市開発シミュを作る⑤|「面白くない」を目標・施設・イベント・音の4軸で解消【Aurum City制作】

    アプリの作り方

    都市開発シミュを作る⑤|「面白くない」を目標・施設・イベント・音の4軸で解消【Aurum City制…

    「面白くない」を4軸(目標・打ち手・変化・音)で解消する。都市ランクと…

  2. アプリの作り方

    Claude Codeのトークンを削るOSS 11本 — ライセンス本文まで確かめた一覧

    GitHubで「Claude Codeのトークンを削る」と名の付くリポ…

  3. 背景除去アプリの作り方 — PyQt6+ONNX(BiRefNet)でローカル完結の透過ツールを作る
  4. 都市開発シミュを作る②|AIでドット絵タイルを生成+道路オートタイリング【Aurum City制作】
  5. 都市開発シミュを作る③|需要・成長・経済の決定論シミュレーション(MVP完成)【Aurum City制作】

    アプリの作り方

    都市開発シミュを作る③|需要・成長・経済の決定論シミュレーション(MVP完成)【Aurum City…

    需要・成長・経済で街を動かす決定論シミュレーションの作り方。種から回す…

  6. ローカルAIで背景除去アプリを作る①|PyQt6と3枚の純関数レイヤで「壊れない土台」を作る【背景除去Studio制作】

注目

AIで、ここまで作れる

AIで作った2D RPGを、ブラウザでそのまま遊べます。その「作り方=最後まで完成させる進め方」も実例つきで公開中。

▶ ゲームを遊ぶやり方を読む

PR

ロリポップ!レンタルサーバー(PR)

レンタルサーバ:ロリポップ!(本サイトの稼働環境・PR)

  1. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド

    アプリの作り方

    競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…
  2. Qwen-Audio-3.0-TTS は重みが無い — 音声合成を「撤退できるか」で選ぶという判断

    AI・テック動向

    Qwen-Audio-3.0-TTS は重みが無い — 音声合成を「撤退できるか…
  3. OpenAI GPT-5.6 発表 — ultra mode と max reasoning effort。Claude Sonnet 5 と価格を並べて読む

    AI・テック動向

    OpenAI GPT-5.6 発表 — ultra mode と max rea…
  4. AI・テック動向

    DeepSeek-V4-Flash-0731 は実費20分の1、それでも要約モデ…
  5. Claude 音声モードが Opus / Sonnet 対応 — 音声が「入力手段」から「実行手段」に変わった

    AI・テック動向

    Claude 音声モードが Opus / Sonnet 対応 — 音声が「入力手…
PAGE TOP

TAG CLOUD

ドラッグで回転・クリックでそのタグの記事一覧へ