戦闘の“外側”には、もう一つの世界がある。どの島へ行けるか、どこまで進んだか、そしてブラウザを閉じても続きから遊べること。ワールドマップ・画面遷移・セーブだ。ここも Archipelago Saga は徹底して「データを描くだけ/状態を1つにまとめるだけ」で作ってある。第4回は、蛇行する島マップ・シーンのハブ構造・localStorage セーブを実コードで開く。
▶ 第3回(CTB戦闘コア)の続き。全体像は連載ハブ。完成版でマップを動き回ると、これから作る構造が掴める。
目次
第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.js の x,y が決める。第2回で「ステージはただのデータ」と言った意味が、ここで効く。マップ画面のコードは、データを並べて描く以上のことをしない。

stages.js のデータを線でつないだもので、クリア記録は localStorage に自動保存され、次のノードが解放される。Step 2. ロック解放:「直前をクリアしたら次が開く」
進行は クリア済みステージの記録(S.cleared)で表す。あるステージの状態は、たった3つ。「クリア済み(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() を思い出そう。画面はこの司令塔ですべて切り替わる。ゲーム全体の流れは「一本道の本編」+「マップから開くサブ画面」という形だ。
島をクリックしたときの処理はこうだ。開いている島なら、(戦闘前会話があれば挟んで)戦闘へ。すべて 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)は、どちらで動いているかを知らなくていい。
// 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.js の x, y 座標だけだ。マップ画面は座標を結んで描いているだけなので、数値を差し替えれば道の形が変わる。方眼紙に島を並べる感覚で座標を書き、ブラウザで確かめて微調整する。コードでなくデータを編む作業なので、絵心や設計スキルは要らない。座標いじりはこのゲーム作りの中で一番気楽な工程なので、休憩がてら遊ぶくらいの気持ちで触ってみてほしい。地形が自分の手に馴染む感覚があるはずだ。世界地図を自分の手で編み直せるのは、データ駆動ならではの遊びでもある。
まとめ:第4回の要点
- マップ=データを描くだけ:
G.STAGESのx,yを結べば蛇行パスになる。 - ロック解放:状態は
S.clearedだけ。「直前がクリア済みなら open」。 - worldmap がハブ:本編は title→party→worldmap→battle→result。サブ画面はマップから開く。
- 状態は1つに集約:全セーブデータを
S1オブジェクトに。実効値は保存せず再計算。 - セーブ=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戦闘コア ・ 連載ハブ ・ ▶ 遊ぶ ・ ほかのアプリの作り方