[Astro] #153 アナログ時計コンポーネントの高度カスタマイズ化 — JSONアセット管理、IndexedDBスキンギャラリー、SE/時報対応

[Astro] #153 アナログ時計コンポーネントの高度カスタマイズ化 — JSONアセット管理、IndexedDBスキンギャラリー、SE/時報対応

はじめに

PROTOCOL.LAIN のサイバーパンクな世界観を彩る要素として、デスクトップ上に自由に移動・配置できる「浮遊型アナログ時計コンポーネント(AnalogClock.astro)」を運用している。

これまでは背景画像や効果音(SE)のパスがコード内に直接ハードコードされており、スキンの追加やデザイン調整の自由度が限られていた。今回、アセット情報を外部 JSON 化して保守性を高めるとともに、IndexedDB を活用したカスタム背景スキンギャラリー、文字盤(ローマ数字/アラビア数字)切り替え、および正時(0分0秒)の時報機能を新たに実装した。

スクリーンショット

機能を大幅位に拡張

[Astro] #153 アナログ時計コンポーネントの高度カスタマイズ化 — JSONアセット管理、IndexedDBスキンギャラリー、SE/時報対応

右クリックメニューを拡張

[Astro] #153 アナログ時計コンポーネントの高度カスタマイズ化 — JSONアセット管理、IndexedDBスキンギャラリー、SE/時報対応

時計のサイズ、位置、フォント、色などを変更可能

[Astro] #153 アナログ時計コンポーネントの高度カスタマイズ化 — JSONアセット管理、IndexedDBスキンギャラリー、SE/時報対応

設定状態を「保存」&「リロード」
「インポート」「エクスポート」「リセット」

[Astro] #153 アナログ時計コンポーネントの高度カスタマイズ化 — JSONアセット管理、IndexedDBスキンギャラリー、SE/時報対応

背景スキンの追加と、アップロード、データ管理可能に。
indexedDBに保存される為、サーバへのアップロードは無し、ブラウザ完結。

[Astro] #153 アナログ時計コンポーネントの高度カスタマイズ化 — JSONアセット管理、IndexedDBスキンギャラリー、SE/時報対応

1. 課題と設計目標

従来の時計コンポーネントには以下の課題が存在していた:

  1. アセットパスのハードコード: 背景画像(clock-bg-chibi.webp 等)や SE パスが直接記述されており、画像や音源の変更時にソースコードの改修が必要だった。
  2. デザインの拡張性: 文字盤の数字(アラビア数字 / ローマ数字)、カラー、フォント、サイズなどをリアルタイムに調整する UI が未整備だった。
  3. ユーザー独自の画像保持: ユーザーが手元からアップロードした画像を背景スキンとして保存・削除・選択できる永続化ストレージの仕組みが必要だった。

これらを解決するため、外部 JSON によるアセット定義 + IndexedDB (Dexie.js) によるユーザーデータ保存 + 設定モーダル UI を組み合わせたモジュール構造へ刷新した。

2. アセット情報の一元管理 (list.json)

背景画像と音源(SE/時報)のパス情報を /public/assets/clockSkins/list.json に外部化した。

{
  "defaultSkinId": "chibi",
  "skins": [
    {
      "id": "chibi",
      "name": "Chibi Main",
      "url": "/assets/clockSkins/clock-bg-chibi.webp"
    },
    {
      "id": "floral_gold",
      "name": "Floral Gold",
      "url": "/assets/clockSkins/clock-bg-floral.webp"
    }
  ],
  "sounds": {
    "click": "/assets/se/決定ボタンを押す7.mp3",
    "tick": "/assets/se/決定ボタンを押す38.mp3",
    "chime": "/assets/se/ジングル10.mp3"
  }
}

JSON の動的非同期ロードとフォールバック処理

コンポーネント初期化時に fetch() で list.json を読み込み、万が一読み込みに失敗した場合でもフォールバック音源が適用される安全設計を設けた。

interface ClockAssets {
  defaultSkinId: string;
  skins: { id: string; name: string; url: string }[];
  sounds: { click: string; tick: string; chime: string };
}

let assetsConfig: ClockAssets | null = null;
let clickAudio: HTMLAudioElement;
let tickAudio: HTMLAudioElement;
let chimeAudio: HTMLAudioElement;

async function loadAssetsJson() {
  try {
    const res = await fetch('/assets/clockSkins/list.json');
    assetsConfig = await res.json();

    if (assetsConfig) {
      clickAudio = new Audio(assetsConfig.sounds.click);
      tickAudio  = new Audio(assetsConfig.sounds.tick);
      chimeAudio = new Audio(assetsConfig.sounds.chime);
    }
  } catch (e) {
    console.warn('[Clock] Failed to load list.json, fallback to defaults:', e);
    clickAudio = new Audio('/assets/se/決定ボタンを押す7.mp3');
    tickAudio  = new Audio('/assets/se/決定ボタンを押す38.mp3');
    chimeAudio = new Audio('/assets/se/ジングル10.mp3');
  }
}

3. スキンギャラリーと IndexedDB の統合

背景スキンは以下の優先順位で読み込まれる:

  1. IndexedDB (ユーザーアップロード画像): ローカルに保存されたカスタム画像 Blob
  2. JSON プリセット: list.json で用意されている標準アセット
  3. デフォルト画像: フォールバック画像
async function loadActiveSkin() {
  const clockEl = document.getElementById('analog-clock');
  if (!clockEl) return;

  const defaultUrl = assetsConfig?.skins[0]?.url || '/assets/clockSkins/clock-bg-chibi.webp';

  if (!config.activeSkinId || !db) {
    clockEl.style.backgroundImage = `url("${defaultUrl}")`;
    return;
  }

  // 1. IndexedDB から探す
  try {
    const skin = await db.clockSkins.get(config.activeSkinId);
    if (skin && skin.data) {
      const blobUrl = URL.createObjectURL(skin.data);
      clockEl.style.backgroundImage = `url("${blobUrl}")`;
      return;
    }
  } catch {}

  // 2. JSON プリセットから探す
  const preset = assetsConfig?.skins.find(s => s.id === config.activeSkinId);
  if (preset) {
    clockEl.style.backgroundImage = `url("${preset.url}")`;
    return;
  }

  // 3. デフォルト画像
  clockEl.style.backgroundImage = `url("${defaultUrl}")`;
}

4. 時報(Chime)と秒針効果音(SE)の発火ロジック

requestAnimationFrame ループの中で、秒の更新タイミングおよび正時(分 === 0 かつ 秒 === 0)のチェックを行い、時報音を鳴らす仕組みを構築した。

let lastTickSecond = -1;
let lastChimeMinute = -1;

function updateClock() {
  const now = new Date();
  const rawSec = now.getSeconds();
  const m = now.getMinutes();

  // 秒針 SE (ステップ移動時)
  if (!config.isSmooth && rawSec !== lastTickSecond) {
    playSE(tickAudio);
    lastTickSecond = rawSec;
  }

  // 0分0秒の正時で時報チャイムを再生
  if (m === 0 && rawSec === 0 && lastChimeMinute !== m) {
    playSE(chimeAudio);
    lastChimeMinute = m;
  } else if (m !== 0) {
    lastChimeMinute = -1;
  }

  animationFrameId = requestAnimationFrame(updateClock);
}

5. UI/UX の改善とモーダルのドラッグ移動

設定モーダル(⚙ SYSTEM CONFIGURATION)を表示している最中でも後ろのアナログ時計を確認・操作できるよう、暗幕(Backdrop)の透明化とモーダル自体のドラッグ移動機能を実装した。

/* 設定モーダル(背景を透明にし、後ろの視認性を確保) */
.config-modal {
  position: fixed;
  inset: 0;
  background: transparent;
  backdrop-filter: none;
  z-index: 20000;
  pointer-events: none; /* 背後のクリックを透過 */
}

.modal-content {
  position: absolute;
  pointer-events: auto; /* ウィンドウ内のみ操作有効化 */
}

6. まとめ

機能概要・利点
JSON アセット管理背景スキンや効果音パスを list.json に集約し、コード改修なしでアセット追加可能
IndexedDB ギャラリーユーザー独自の背景画像をローカルに永続化・動的カード生成
文字盤 & SVG カスタマイズローマ数字 (Ⅰ〜Ⅻ) / アラビア数字、カラー、フォント、半径のリアルタイム調整
正時時報 (Chime)毎時 0分0秒に自動で時報 SE を再生
ドラッグ可能モーダル透過背景とドラッグ移動により、デザイン調整中のリアルタイムプレビューを実現

背景スキン(花柄、ゴールド枠、木目調など)や文字盤フォントを変更するだけで、デスクトップ全体の雰囲気が一変するフレキシブルな時計コンポーネントへと進化させることができた。