[JavaScript] Lain Clock プリセット管理機能の実装 — WebGL設定の保存・復元・JSON Export/Import
はじめに
前回の記事
[JavaScript] Lain Clock スマホ対応 — UI折りたたみ・カメラ自動調整・設定リアルタイムプレビュー」
に引き続き、今回は「Lain Clock」における プリセット管理機能(設定の保存・復元・JSONファイル入出力) の実装内容を解説します。
[JavaScript] Lain Clock スマホ対応 — UI折りたたみ・カメラ自動調整・設定リアルタイムプレビュー // PROTOCOL.LAIN
Three.js製MMD時計アプリ「Lain Clock」のスマホ対応。CSSメディアクエリによるUI最適化、HUD/ツールバーの折りたたみ機構、縦画面でのカメラZ自動調整、設定パネルのリアルタイムプレビューを実装
lain-lab.com概要
本機能では、ユーザーが調整した時計の配色、表示スケール、床のシェーダーパターン、デフォルトモーションなどの各種パラメータを一括で管理・持ち運びできるようにしました。
主な実装機能
- プリセットの保存: 現在の設定状態(
currentSettings)に任意の名称を付けてLocalStorageに保存。 - プリセットの復元(Restore): 保存された設定を読み込み、アプリケーションに適用。
- JSONファイルのExport: 登録済みプリセット(単体および全件)をJSONファイルとしてローカルへダウンロード。
- JSONファイルのImport: 外部のJSONファイルを読み込み、重複しないUnique IDを付与してリストに追加。
モジュール構成
機能の分離を図るため、データ操作ロジックとUI制御モジュールに分離して実装しています。
./src/preset-store.js: LocalStorageへのアクセス、JSONシリアライズ/デシリアライズ、ファイルIOを担当。./src/ui/settings-panel.js: 設定UIの描画、イベントハンドリング、設定適用時のリロード制御を担当。
実装コード
1. プリセット管理モジュール (./src/preset-store.js)
プリセットデータのCRUD操作と、Blob / FileReader APIを使用したJSONファイルの入出力を集約したモジュールです。
// ./src/preset-store.js
const PRESETS_STORAGE_KEY = "clock-app-presets-v1";
/**
* 全プリセットの取得
*/
export function getAllPresets() {
try {
const raw = localStorage.getItem(PRESETS_STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
console.warn("getAllPresets failed", e);
return [];
}
}
/**
* 設定オブジェクトをプリセットとして保存
*/
export function savePreset(name, settings) {
const presets = getAllPresets();
const newPreset = {
id: `preset_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
name: name || `Preset ${new Date().toLocaleDateString()}`,
createdAt: Date.now(),
settings: JSON.parse(JSON.stringify(settings)),
};
presets.push(newPreset);
localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(presets));
return newPreset;
}
/**
* プリセットの削除
*/
export function deletePreset(id) {
const presets = getAllPresets().filter((p) => p.id !== id);
localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(presets));
}
/**
* JSONファイルへExport (全件または指定ID)
*/
export function exportPresetsToJson(targetId = null) {
const all = getAllPresets();
const targets = targetId ? all.filter((p) => p.id === targetId) : all;
if (targets.length === 0) return;
const payload = {
app: "PROTOCOL.LAIN",
type: "clock-presets",
version: 1,
exportedAt: Date.now(),
presets: targets,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `lain_presets_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
/**
* JSONファイルからImport
*/
export function importPresetsFromJson(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.app !== "PROTOCOL.LAIN" || !Array.isArray(data.presets)) {
throw new Error("無効なプリセットファイル形式です。");
}
const existing = getAllPresets();
let importedCount = 0;
data.presets.forEach((imported) => {
if (!imported.id || !imported.settings) return;
// ID重複回避処理
const isDuplicate = existing.some((p) => p.id === imported.id);
const finalPreset = {
...imported,
id: isDuplicate ? `preset_${Date.now()}_${Math.random().toString(36).substring(2, 5)}` : imported.id,
};
existing.push(finalPreset);
importedCount++;
});
localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(existing));
resolve(importedCount);
} catch (err) {
reject(err);
}
};
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
2. 設定パネルUIとの統合 (./src/ui/settings-panel.js)
設定パネル最上部に「Presets」セクションを動的に挿入し、操作イベントをバインドします。
// ./src/ui/settings-panel.js 内のイベント処理部分(抜粋)
settingsBody.addEventListener("click", async (ev) => {
const btn = ev.target.closest("[data-act]");
if (!btn) return;
const act = btn.dataset.act;
const id = btn.dataset.id;
// プリセットの保存
if (act === "presetSave") {
collectCurrentSettings();
const name = prompt("プリセット名を入力してください", `Preset ${new Date().toLocaleTimeString()}`);
if (!name) return;
savePreset(name, currentSettings);
renderPresetsSection(settingsBody);
return;
}
// プリセットの復元(Apply)
if (act === "presetApply") {
const presets = getAllPresets();
const target = presets.find((p) => p.id === id);
if (!target) return;
// 設定を保存してページをリロード
saveSettings(cloneSettings(target.settings));
location.reload();
return;
}
// プリセットの削除
if (act === "presetDelete") {
if (!confirm("このプリセットを削除しますか?")) return;
deletePreset(id);
renderPresetsSection(settingsBody);
return;
}
// エクスポート・インポートのトリガー
if (act === "presetExportAll") return exportPresetsToJson(null);
if (act === "presetExportSingle") return exportPresetsToJson(id);
if (act === "presetImportTrigger") {
settingsBody.querySelector("#inputPresetFile")?.click();
return;
}
});
技術的なポイントと設計判断
1. WebGL / Three.js 状態同期における location.reload() の採用
復元処理(presetApply)において、JavaScript上のオブジェクト値を書き換えるだけでは、すでに生成されているThree.jsのメッシュ、マテリアルのUniform変数、シェーダープロパティ等へ即座に完全反映されない問題が発生します。
リアルタイム更新関数を全要素(床テクスチャ・シェーダーパラメータ・時計のUI構造)に対して構築するとコードの結合度が高まり、リソースの破棄漏れ(メモリリーク)などの原因となります。
そのため、復元時には以下のフローを採用しました。
- 対象プリセットの
settingsオブジェクトをsaveSettings()でLocalStorage(カレント設定領域)に上書き保存する。 location.reload()を呼び出してアプリケーション全体を再読み込みする。
このアプローチにより、最小限のコード複雑度で、Three.jsの描画コンテキストおよびDOM要素を確実に初期化・正しく復元できます。
2. JSON Import 時の ID 重複回避
外部ファイルから読み込まれるプリセットデータは、既存のLocalStorage内にすでに存在する id と衝突する可能性があります。
importPresetsFromJson 内部において、existing.some(...) による重複チェックを行い、重複が検知された場合はタイムスタンプと乱数を組み合わせた新IDを割り振ることで、データ損失を防いでいます。
まとめ
今回の実装により、パラメータ調整結果の永続化、別環境への移行、および状態復元が確実に動作する仕組みが整いました。