// ドリームテーゼ - アプリ制御 + ダイアログ
const { useState, useEffect, useRef, useCallback } = React;

// スピナー用キーフレーム注入
(function () {
  if (document.getElementById('dt-app-style')) return;
  var s = document.createElement('style');
  s.id = 'dt-app-style';
  s.textContent = [
    '@keyframes dtSpin { to { transform: rotate(360deg); } }',
    // 0〜30%(0-300ms): opacity:1 で即座に暗転してhold、30〜100%(300-1000ms): ボードへのフェードアウト
    '@keyframes dtBoardFlash { 0%, 30% { opacity: 1; } 100% { opacity: 0; } }',
    '/* --- エンドスクリーンのCTA --- */',
    '.dt-end-cta-row {',
    '  position: absolute;',
    '  top: 80%;',
    '  left: 50%;',
    '  transform: translate(-50%, -50%);',
    '  display: flex;',
    '  align-items: center;',
    '  gap: min(4cqw, 2.6vh);',
    '  /* 罫線は flex: 1 1 0 で「行幅から文字と余白を引いた残り」を分け合うので、',
    '     罫線の長さはこの行幅だけで決まる。短くしたいときはここを詰めること',
    '     （罫線側に width を持たせると、余った分だけ行が左へ寄って中央が崩れる）。',
    '     cqw側は狭い画面、vh側は広い画面でそれぞれ効く。 */',
    '  width: min(66cqw, 42vh);',
    '  animation: dtEndCtaIn 0.6s 1.2s ease both;',
    '}',
    '.dt-end-rule {',
    '  flex: 1 1 0;',
    '  height: max(1.5px, 0.19vh);',
    '  background: #231815;',
    '  /* 静的値は各キーフレームの 0% と一致させること（下のコメント参照） */',
    '}',
    '.dt-end-rule--left {',
    '  transform: scaleX(0);',
    '  transform-origin: left center;',
    '  animation: dtEndRuleFlowRight 2.4s 1.2s ease-in-out infinite;',
    '}',
    '.dt-end-rule--right {',
    '  transform: scaleX(0);',
    '  transform-origin: right center;',
    '  animation: dtEndRuleFlowLeft 2.4s 1.2s ease-in-out infinite;',
    '}',
    '.dt-end-cta {',
    '  flex: 0 0 auto;',
    '  color: #231815;',
    '  text-align: center;',
    '  text-decoration: none;',
    '  font-family: inherit;',
    '  transition: opacity 0.2s ease;',
    '}',
    '.dt-end-cta:hover,',
    '.dt-end-cta:focus-visible {',
    '  opacity: 0.62;',
    '}',
    '.dt-end-cta:focus-visible {',
    '  outline: 2px solid #231815;',
    '  outline-offset: 4px;',
    '}',
    '.dt-end-cta__main {',
    '  display: block;',
    '  font-size: min(5.6cqw, 2.7vh);',
    '  font-weight: 600;',
    '  line-height: 1.5;',
    '  letter-spacing: 0.12em;',
    '  /* letter-spacing は最後の1文字の右側にも入るので、その分だけ字面の中心が',
    '     左へ letter-spacing/2 ずれる。text-indent に letter-spacing と同じ値を入れると',
    '     行頭が同量だけ右へ寄り、字面の中心がちょうど箱の中心へ戻る（実測で誤差0.5px）。',
    '     letter-spacing を変えるときは text-indent も同じ値に揃えること。 */',
    '  text-indent: 0.12em;',
    '}',
    '.dt-end-cta__sub {',
    '  display: block;',
    '  font-size: min(4.6cqw, 2.2vh);',
    '  font-weight: 500;',
    '  line-height: 1.5;',
    '  letter-spacing: 0.2em;',
    '  text-indent: 0.2em;',
    '}',
    '/*',
    ' * CTA全体の入場。エンドスクリーン本体の dimFadeIn(0.8s) が終わってから 1.2s で始める。',
    ' * visibility は 0% と 100% の間で visible 側に倒れるため、遅延中（backward fill）だけ',
    ' * hidden になり、再生開始と同時に操作できる。これで「まだ見えていない外部リンクに',
    ' * キーボードで到達できる」状態を、フェードを損なわずに塞いでいる。hidden を消すと',
    ' * 入場前からタブ順に現れるので消さないこと。既存の dimFadeIn は opacity だけを動かす',
    ' * 別物なので、こちらへ流用しないこと。',
    ' */',
    '@keyframes dtEndCtaIn {',
    '  from { opacity: 0; visibility: hidden; }',
    '  to   { opacity: 1; visibility: visible; }',
    '}',
    '/*',
    ' * 左側の罫線。前半は左端を原点に右へ伸ばし、伸びきった瞬間に原点を右端へ入れ替えて',
    ' * 後半で右へ吸い込ませる。scaleX(1) のまま原点だけ切り替えるので見た目は連続し、',
    ' * 「波が右＝リンクへ向かって進む」動きが途切れずに繰り返される。',
    ' * トップページ(別リポジトリ)の hero-scroll-flow を 90 度回した形。',
    ' */',
    '@keyframes dtEndRuleFlowRight {',
    '  0%     { transform: scaleX(0); transform-origin: left center; }',
    '  50%    { transform: scaleX(1); transform-origin: left center; }',
    '  50.01% { transform: scaleX(1); transform-origin: right center; }',
    '  100%   { transform: scaleX(0); transform-origin: right center; }',
    '}',
    '/*',
    ' * 右側の罫線。dtEndRuleFlowRight の左右反転で、波は左＝リンクへ向かって進む。',
    ' * 両側の波がリンクへ集まる見え方を作るための対なので、片方だけ差し替えないこと。',
    ' */',
    '@keyframes dtEndRuleFlowLeft {',
    '  0%     { transform: scaleX(0); transform-origin: right center; }',
    '  50%    { transform: scaleX(1); transform-origin: right center; }',
    '  50.01% { transform: scaleX(1); transform-origin: left center; }',
    '  100%   { transform: scaleX(0); transform-origin: left center; }',
    '}',
    '/*',
    ' * 動きを減らす設定では入場フェードと無限ループを止め、',
    ' * それぞれのアニメーションが担っていた最終状態を通常宣言で明示的に戻す。',
    ' * animation: none とこの復帰指定は必ず対で書くこと。片方だけ消すと',
    ' * CTA が透明のまま、または罫線が scaleX(0) のまま消えて表示されなくなる。',
    ' * ホバーの transition もここで止める。animation だけ止めても、',
    ' * 指やポインタを乗せたときの減光がぬるりと動いたままになる。',
    ' */',
    '@media (prefers-reduced-motion: reduce) {',
    '  .dt-end-cta-row { animation: none; opacity: 1; visibility: visible; }',
    '  .dt-end-rule    { animation: none; transform: scaleX(1); }',
    '  .dt-end-cta     { transition: none; }',
    '}',
  ].join('');
  document.head.appendChild(s);
}());

// 音声ファイルマッピング（シーンインデックス → パス）
var VOICE_FILES = [
  'assets/voice/sc0_うふふ.wav',
  'assets/voice/sc1_ねえ私の声.wav',
  'assets/voice/sc2_やっぱり.wav',
  'assets/voice/sc3_あなたなら当たり前.wav',
  'assets/voice/sc4_これは淡い夢.wav',
  'assets/voice/sc5_今はまだ言葉もない.wav',
  'assets/voice/sc6_けれどせめて.wav',
  'assets/voice/sc7_あなたはそれですら.wav',
  'assets/voice/sc8_見えるかしら.wav',
  'assets/voice/sc9_きっとやがて.wav',
  'assets/voice/sc10_少しだけ試してみましょう.wav',
  'assets/voice/sc11_あなたも次のアクトを.wav',
  'assets/voice/sc12_これはテーゼ.wav',
  'assets/voice/sc13_けれど動けばテーゼは.wav',
  'assets/voice/sc14_今はまほろしを.wav',
  'assets/voice/sc15_こちらが動けばあちらも.wav',
  'assets/voice/sc16_次は…黒のテーゼ.wav',
  'assets/voice/sc17_葛藤すれば黒.wav',
  'assets/voice/sc18_例えば守護魔力なら.wav',
  'assets/voice/sc19_そうしてまた相手も動いて.wav',
  'assets/voice/sc20_あなたも動いて.wav',
  'assets/voice/sc21_互いに3つ動いたら.wav',
  'assets/voice/sc22_攻めと守りを比べ.wav',
  'assets/voice/sc23_これで戦いの一幕.wav',
  'assets/voice/sc24_どうしたの心配.wav',
  'assets/voice/sc25_意識が掠れそう.wav',
  'assets/voice/sc26_私と契約.wav',
  'assets/voice/sc27_焦がれる夢.wav',
  'assets/voice/sc28_つまり、強くなって.wav',
  'assets/voice/sc29_かわりに体をひとつ.wav',
  'assets/voice/sc30_でもいまはここまで.wav',
  'assets/voice/sc31_あなたが本当に始まり.wav',
  'assets/voice/sc32_だから星が.wav',
];

function getVoiceVolumeFactor(scene) {
  var factor = scene && scene.voiceVolumeFactor;
  return Number.isFinite(factor) && factor >= 0 ? factor : 1;
}

// プリロード対象アセット（board.jsx の CARD_IMAGES も含める）
var PRELOAD_ASSETS = [
  'assets/background.webp',
  'assets/ルエ.webp',
  'assets/op_background.webp',
  'assets/op_rue_normal.webp',
  'assets/op_rue_shadow.webp',
  'assets/op_rue_whisp.webp',
  'uploads/board_default.jpg',
  'assets/board_lifelose.webp',
  'assets/end_screen.webp',
  'uploads/token_white.png',
  'uploads/token_black.png',
].concat(Object.values(window.CARD_IMAGES || {}));

function preloadImages(srcs) {
  return new Promise(function(resolve) {
    const total = srcs.length;
    if (total === 0) { resolve(); return; }

    let done = 0;
    const onSettle = () => { if (++done >= total) resolve(); };
    srcs.forEach(src => {
      const img = new Image();
      img.onload = img.onerror = onSettle;
      img.src = src;
    });
  });
}

function preloadAudio(srcs) {
  // fetch + blob() でHTTPキャッシュに乗せる。Blobは消費後にGCされJSヒープに残らない。
  return Promise.all(srcs.map(function(src) {
    return fetch(src).then(function(r) { return r.blob(); }).catch(function() {});
  }));
}

function collectFontPreloadText() {
  const sceneText = (window.SCENES || []).map(function(scene) {
    return [(scene.speaker || ''), (scene.text || '')].join('\n');
  }).join('\n');
  return [
    sceneText,
    'このコンテンツは音が出ます。',
    'ご注意ください。',
    'ヘッドホンやイヤホンをご利用いただけると、',
    'よりお楽しみいただけます。',
    '読み込み中',
    'タップで次へ',
    'RESET',
    '終',
  ].join('\n');
}

function preloadDialogueFonts() {
  if (!document.fonts || !document.fonts.load) return Promise.resolve();

  const text = collectFontPreloadText();
  const loads = [
    document.fonts.load('400 16px "Shippori Mincho"', text),
    document.fonts.load('500 16px "Shippori Mincho"', text),
    document.fonts.load('600 16px "Shippori Mincho"', text),
    document.fonts.ready,
  ];

  // Firefox で日本語 Web フォントのサブセット読み込みがタイプライター中に走るとちらつくため、
  // 先に台詞全体を対象にロードする。失敗時は表示を止め続けない。
  return Promise.race([
    Promise.all(loads),
    new Promise(resolve => setTimeout(resolve, 3500)),
  ]).catch(function() {});
}

/* ==============================================================
   初期状態
   ============================================================== */

function createInitialState() {
  return {
    // 表示モード: 'dark' | 'character' | 'board'
    screen: 'dark',
    character: {
      visible: false,
      focus: 'mouth',  // 'mouth' | 'full'
      imageState: 'normal', // op_rue_*.webp の ** 部分
      scale: 1,        // 画像の拡大率 (1 = 等倍)
      shiftX: 0,       // 画像の横オフセット % (正=右、負=左)
      shiftY: 0,       // 画像の縦オフセット % (正=下、負=上)
      spotX: 50,       // スポット中心 X (%)
      spotY: 50,       // スポット中心 Y (%)
      spotSize: 28,    // スポット透明半径 (%)
      spotSoftness: 8, // スポット外縁のグラデーション幅 % (小さいほど急)
    },
    boardVisible: false,
    camera: 'normal',
    whiteOut: 0,
    fadeOut: false,
    opponent: {
      tokens: [],
      cards: [],
      guardianTokens: {},
      reinforcementTokens: {},
    },
    player: {
      tokens: [],
      cards: [],
      guardianTokens: {},
      reinforcementTokens: {},
    },
    dreamTokens: { player: [], opponent: [] },
    dreamMotion: null,
    floatingCard: null,
    glows: null,
    offering: null,
    momentCard: null,
    resolvedCards: null,
    tokenMotion: null,
    boardImage: 'uploads/board_default.jpg',
  };
}

/* ==============================================================
   アクション実行
   ============================================================== */

function applyAction(prev, action) {
  const s = JSON.parse(JSON.stringify(prev));

  switch (action.type) {

    // ---- 画面制御 ----
    case 'setScreen':
      s.screen = action.mode;
      if (action.mode === 'dark') {
        s.character.visible = false;
      }
      return s;

    case 'showCharacter':
      s.screen = 'character';
      s.character.visible = true;
      s.character.focus = action.focus;
      if (action.state    !== undefined) s.character.imageState = action.state;
      if (action.scale    !== undefined) s.character.scale    = action.scale;
      if (action.shiftX   !== undefined) s.character.shiftX   = action.shiftX;
      if (action.shiftY   !== undefined) s.character.shiftY   = action.shiftY;
      if (action.spotX    !== undefined) s.character.spotX    = action.spotX;
      if (action.spotY    !== undefined) s.character.spotY    = action.spotY;
      if (action.spotSize     !== undefined) s.character.spotSize     = action.spotSize;
      if (action.spotSoftness !== undefined) s.character.spotSoftness = action.spotSoftness;
      return s;

    // ---- ボード制御 ----
    case 'showBoard':
      s.boardVisible = true;
      return s;

    case 'initTokens': {
      // ダイヤモンド配置 8枚: index 3(row1,col2) と 6(row2,col2) が player 視点の左列 → 黒
      var initToks = ['white','white','white','black','white','white','black','white'];
      s.player.tokens   = initToks.slice();
      s.opponent.tokens = initToks.slice();
      s.momentCard = { faceUp: true };
      return s;
    }

    case 'flipTokens': {
      const side = s[action.side];
      const to = action.to;
      const from = to === 'black' ? 'white' : 'black';
      let flipped = 0;
      for (let i = 0; i < side.tokens.length && flipped < action.count; i++) {
        if (side.tokens[i] === from) { side.tokens[i] = to; flipped++; }
      }
      return s;
    }

    case 'playCard':
      s[action.side].cards.push({ name: action.name, flipped: action.flipped || false });
      return s;

    case 'floatCards':
      s.floatingCard = { name: action.name, tilt: true };
      return s;

    case 'floatCard':
      s.floatingCard = { name: action.name, displayName: action.name };
      return s;

    case 'rotateCard':
      if (s.floatingCard) {
        s.floatingCard.flipped = true;
      }
      return s;

    case 'dismissFloat':
      s.floatingCard = null;
      return s;

    case 'zoom':
      s.camera = action.target;
      return s;

    case 'glow':
      s.glows = { targets: action.targets };
      return s;

    case 'clearGlow':
      s.glows = null;
      return s;

    case 'moveTokensToMagic': {
      const tSide = action.side || 'player';
      const slotIdx = action.targetSlot || 0;
      const card = s[tSide].cards[slotIdx];
      if (!card) return s;
      const zone = action.zone === 'guardian' ? 'guardianTokens' : 'reinforcementTokens';
      const count = action.count || 0;
      // 先頭 count 枚の黒トークンのインデックスを収集（auto-glow + アニメ用）
      const indices = [];
      for (let i = 0; i < s[tSide].tokens.length && indices.length < count; i++) {
        if (s[tSide].tokens[i] === 'black') indices.push(i);
      }
      s.tokenMotion = {
        side: tSide,
        indices: indices,
        targetSide: tSide,
        targetSlot: slotIdx,
        zone: action.zone, // 'guardian' | 'reinforcement'
        key: Date.now(),
      };
      // 移動元のトークンを非表示（null = 魔力に送られた）
      indices.forEach(function (idx) { s[tSide].tokens[idx] = null; });
      s[tSide][zone][card.name] = (s[tSide][zone][card.name] || 0) + count;
      return s;
    }

    case 'clearMotion':
      s.tokenMotion = null;
      return s;

    case 'clearDreamMotion':
      s.dreamMotion = null;
      return s;

    case 'flipDreamTokens': {
      const dtSide = s.dreamTokens[action.side];
      const dtFrom = action.to === 'white' ? 'black' : 'white';
      let dtFlipped = 0;
      for (let i = 0; i < dtSide.length && dtFlipped < action.count; i++) {
        if (dtSide[i] === dtFrom) { dtSide[i] = action.to; dtFlipped++; }
      }
      return s;
    }

    case 'resolveCard': {
      if (!s.resolvedCards) s.resolvedCards = {};
      var rcKey = action.side ? action.side + ':' + action.name : action.name;
      s.resolvedCards[rcKey] = action.result;
      return s;
    }

    case 'clearAllCards':
      s.player.cards = [];
      s.opponent.cards = [];
      s.player.guardianTokens = {};
      s.player.reinforcementTokens = {};
      s.opponent.guardianTokens = {};
      s.opponent.reinforcementTokens = {};
      s.resolvedCards = null;
      s.tokenMotion = null;
      s.dreamMotion = null;
      return s;

    case 'moveMagicToDream': {
      const pGuardian = s.player.cards
        .map((c, i) => ({ slotIndex: i, count: s.player.guardianTokens[c.name] || 0 }))
        .filter(x => x.count > 0);
      const pReinforce = s.player.cards
        .map((c, i) => ({ slotIndex: i, count: s.player.reinforcementTokens[c.name] || 0 }))
        .filter(x => x.count > 0);
      const oGuardian = s.opponent.cards
        .map((c, i) => ({ slotIndex: i, count: s.opponent.guardianTokens[c.name] || 0 }))
        .filter(x => x.count > 0);
      const oReinforce = s.opponent.cards
        .map((c, i) => ({ slotIndex: i, count: s.opponent.reinforcementTokens[c.name] || 0 }))
        .filter(x => x.count > 0);

      let pCount = 0, oCount = 0;
      [...pGuardian, ...pReinforce].forEach(x => pCount += x.count);
      [...oGuardian, ...oReinforce].forEach(x => oCount += x.count);

      for (let pi = 0; pi < pCount; pi++) s.dreamTokens.player.push('black');
      for (let oi = 0; oi < oCount; oi++) s.dreamTokens.opponent.push('black');
      s.player.guardianTokens = {};
      s.player.reinforcementTokens = {};
      s.opponent.guardianTokens = {};
      s.opponent.reinforcementTokens = {};

      if (pCount > 0 || oCount > 0) {
        s.dreamMotion = {
          key: Date.now(),
          player:   { guardian: pGuardian,   reinforcement: pReinforce },
          opponent: { guardian: oGuardian,   reinforcement: oReinforce },
        };
      }
      return s;
    }

    case 'flipMoment':
      if (s.momentCard) s.momentCard.faceUp = action.faceUp;
      return s;

    case 'placeOffering':
      s.offering = true;
      return s;

    case 'whiteOut':
      s.whiteOut = action.value;
      return s;

    case 'fadeOut':
      s.fadeOut = true;
      return s;

    case 'lifeLose':
      s.boardImage = 'assets/board_lifelose.webp';
      return s;

    default:
      return s;
  }
}

/* ==============================================================
   タイプライター
   ============================================================== */

function useTypewriter(text, speed) {
  // forText と一緒に保持することで、text が変わった瞬間のレンダーで
  // 古いシーンの pos/done が見えなくなる
  const [tw, setTw] = useState({ pos: 1, done: false, forText: '' });
  const timerRef = useRef(null);
  const totalChars = Array.from(text).length;

  useEffect(() => {
    clearInterval(timerRef.current);
    const firstPos = totalChars > 0 ? 1 : 0;
    if (totalChars <= firstPos) {
      setTw({ pos: firstPos, done: true, forText: text });
      return;
    }

    setTw({ pos: firstPos, done: false, forText: text });
    let idx = firstPos;
    timerRef.current = setInterval(() => {
      idx++;
      if (idx >= totalChars) {
        setTw({ pos: totalChars, done: true, forText: text });
        clearInterval(timerRef.current);
      } else {
        setTw({ pos: idx, done: false, forText: text });
      }
    }, speed);
    return () => clearInterval(timerRef.current);
  }, [text, speed, totalChars]);

  const skip = useCallback(() => {
    clearInterval(timerRef.current);
    setTw({ pos: totalChars, done: true, forText: text });
  }, [text, totalChars]);

  // text が変わった直後（useEffect 発火前）は pos=1, done=false を返す
  const isCurrent = tw.forText === text;
  return {
    pos: isCurrent ? tw.pos : (totalChars > 0 ? 1 : 0),
    done: isCurrent ? tw.done : false,
    skip,
  };
}

/* ==============================================================
   ダイアログボックス
   ============================================================== */

function DialogueBox({ text, isComplete, onTap }) {
  const tw = useTypewriter(text, 35);
  // onClick と onTouchEnd の二重発火でシーンが2つ飛ぶのを防ぐ
  const advancedRef = useRef(false);
  useEffect(() => { advancedRef.current = false; }, [text]);

  const handleTap = useCallback(() => {
    if (!tw.done) {
      tw.skip();
    } else {
      if (advancedRef.current) return;
      advancedRef.current = true;
      onTap();
    }
  }, [tw.done, onTap, tw.skip]);

  // 全文を固定配置し、文字ごとの visibility だけを切り替える。
  // Firefox でテキストノードを毎 tick 差し替えたときの再描画ちらつきを避ける。
  const fullLines = text.split('\n');
  let charBudget = tw.pos;
  const lines = fullLines.map(function(line) {
    const chars = Array.from(line);
    const visibleChars = Math.max(0, Math.min(chars.length, charBudget));
    charBudget -= chars.length + 1;
    return { chars: chars, visibleChars: visibleChars };
  });

  return (
    <div
      style={{
        flexShrink: 0,
        position: 'relative',
        background: 'linear-gradient(180deg,rgba(12,12,24,.96),rgba(8,8,16,.99))',
        borderTop: '1px solid rgba(124,58,237,.2)',
        padding: 'clamp(10px,2.5vh,16px) clamp(14px,4cqw,20px) clamp(14px,3vh,22px)',
        minHeight: 'clamp(120px,22vh,160px)',
        display: 'flex', flexDirection: 'column',
        cursor: 'pointer',
        userSelect: 'none',
        WebkitUserSelect: 'none',
        WebkitTapHighlightColor: 'transparent',
      }}
      onClick={handleTap}
      onTouchEnd={e => { e.preventDefault(); handleTap(); }}
    >
      {/* 台詞テキスト */}
      <div style={{
        fontSize: 'clamp(13px,3.8cqw,16px)',
        lineHeight: 1.8,
        color: '#dddde8',
        fontFamily: "'Shippori Mincho',serif",
        fontWeight: 400,
        flex: 1,
      }}>
        {lines.map((line, i) => (
          <div key={i} style={{ minHeight: '1.8em' }}>
            {line.chars.length === 0 ? ' ' : line.chars.map((ch, j) => (
              <span
                key={j}
                style={{ visibility: j < line.visibleChars ? 'visible' : 'hidden' }}
              >{ch}</span>
            ))}
          </div>
        ))}
      </div>

      {/* 続きインジケータ / 終了マーク */}
      {isComplete ? (
        <div style={{
          position: 'absolute', bottom: 'clamp(8px,1.5vh,14px)', right: 'clamp(12px,4cqw,20px)',
          fontSize: 11, color: 'rgba(167,139,250,.5)',
          fontFamily: "'Shippori Mincho',serif",
          opacity: tw.done ? 1 : 0,
          transition: 'opacity 0.3s',
        }}>— 終 —</div>
      ) : (
        <div style={{
          position: 'absolute', bottom: 'clamp(8px,1.5vh,14px)', right: 'clamp(12px,4cqw,20px)',
          width: 0, height: 0,
          borderLeft: '5px solid transparent',
          borderRight: '5px solid transparent',
          borderTop: '6px solid rgba(167,139,250,.6)',
          animation: tw.done ? 'bounceDown 1s ease-in-out infinite' : 'none',
          opacity: tw.done ? 1 : 0,
          transition: 'opacity 0.3s',
        }} />
      )}
    </div>
  );
}

/* ==============================================================
   ヘッダー（ボードシーンのみ表示）
   ============================================================== */

function BoardHeader({ onReset, volume, onVolumeChange, hideVolume }) {
  return (
    <>
      <div style={{
        position: 'absolute', top: 0, left: 0,
        padding: '12px 12px',
        zIndex: 80,
        pointerEvents: hideVolume ? 'none' : 'auto',
        display: 'flex', alignItems: 'center', gap: 5,
        opacity: hideVolume ? 0 : 1,
        transition: 'opacity 0.6s ease',
      }}>
        <span style={{
          color: 'rgba(200,190,240,.6)', fontSize: 10, letterSpacing: '0.05em',
          fontFamily: 'inherit', userSelect: 'none',
        }}>VOL</span>
        <input
          type="range" min="0" max="20" step="0.05"
          value={volume}
          onChange={function(e) { onVolumeChange(parseFloat(e.target.value)); }}
          style={{ width: 72, cursor: 'pointer', accentColor: 'rgba(167,139,250,0.85)' }}
        />
      </div>
      <div style={{
        position: 'absolute', top: 0, right: 0,
        padding: '6px 12px',
        zIndex: 80,
        pointerEvents: 'none',
      }}>
        <button
          style={{
            pointerEvents: 'auto',
            background: 'rgba(8,4,16,.75)', border: '1px solid rgba(180,170,220,.5)',
            backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
            borderRadius: 4, padding: '4px 10px',
            color: 'rgba(220,210,250,.95)', fontSize: 11,
            textShadow: '0 1px 3px rgba(0,0,0,.9)',
            cursor: 'pointer', fontFamily: 'inherit',
            WebkitTapHighlightColor: 'transparent',
          }}
          onClick={onReset}
        >RESET</button>
      </div>
    </>
  );
}

/* ==============================================================
   ロード画面
   ============================================================== */

function LoadingScreen({ loading, ready, onReady }) {
  const visible = !ready;
  return (
    <div
      onClick={!loading ? onReady : undefined}
      style={{
        position: 'absolute', inset: 0, zIndex: 100,
        background: '#08080f',
        display: 'flex', flexDirection: 'column',
        alignItems: 'center', justifyContent: 'center',
        gap: 20,
        opacity: visible ? 1 : 0,
        transition: 'opacity 0.8s ease',
        pointerEvents: visible ? 'auto' : 'none',
        cursor: !loading ? 'pointer' : 'default',
      }}
    >
      {loading ? (
        <>
          <div style={{
            width: 28, height: 28, borderRadius: '50%',
            border: '2px solid rgba(124,58,237,0.15)',
            borderTopColor: 'rgba(167,139,250,0.7)',
            animation: 'dtSpin 1s linear infinite',
          }} />
          <div style={{
            color: 'rgba(167,139,250,0.45)',
            fontSize: 11, letterSpacing: '0.25em',
            fontFamily: "'Shippori Mincho', serif",
          }}>読み込み中</div>
        </>
      ) : (
        <>
          <div style={{
            color: 'rgba(220,210,250,0.85)',
            fontSize: 13, letterSpacing: '0.1em', textAlign: 'center',
            fontFamily: "'Shippori Mincho', serif",
            lineHeight: 1.8,
          }}>このコンテンツは音が出ます。<br/>ご注意ください。<br/><br/>ヘッドホンやイヤホンをご利用いただけると、<br/>よりお楽しみいただけます。</div>
          <div style={{
            color: 'rgba(167,139,250,0.55)',
            fontSize: 11, letterSpacing: '0.25em',
            fontFamily: "'Shippori Mincho', serif",
            animation: 'bounceDown 1.2s ease-in-out infinite',
          }}>タップで次へ</div>
        </>
      )}
    </div>
  );
}

/* ==============================================================
   メインApp
   ============================================================== */

function App() {
  const scenes = window.SCENES;
  const [sceneIdx, setSceneIdx] = useState(0);
  const [boardState, setBoardState] = useState(createInitialState);
  const [isEnded, setIsEnded] = useState(false);
  const [showEndScreen, setShowEndScreen] = useState(false);
  const [loading, setLoading] = useState(true);
  const [ready, setReady] = useState(false);
  const [boardFlash, setBoardFlash] = useState(false); // board 切り替え暗転フラグ
  const timersRef = useRef([]);
  const pendingRef = useRef(new Map()); // delay → actions[] (未発火分)
  const audioRef = useRef(null);
  const fadeTRef = useRef(null);
  const audioCtxRef = useRef(null);
  const gainNodeRef = useRef(null);
  const [volume, setVolume] = useState(10);
  const volumeRef = useRef(1.0);

  // アセットプリロード
  useEffect(() => {
    let cancelled = false;
    Promise.all([
      preloadImages(PRELOAD_ASSETS),
      preloadDialogueFonts(),
      preloadAudio(VOICE_FILES.filter(Boolean)),
    ]).then(function() {
      if (!cancelled) setLoading(false);
    });
    return function() { cancelled = true; };
  }, []);

  // 'board' への切り替わり時のみ暗転アニメーション発火
  useEffect(() => {
    if (boardState.screen !== 'board') return;
    setBoardFlash(true);
  }, [boardState.screen]);

  // max-width外のbody背景も、終盤のfadeOutに合わせて白くする
  useEffect(() => {
    document.body.classList.toggle('dt-outer-white', !!boardState.fadeOut);
    return function() {
      document.body.classList.remove('dt-outer-white');
    };
  }, [boardState.fadeOut]);

  // 音量変更時にゲインノードへ反映
  useEffect(() => {
    volumeRef.current = volume;
    if (gainNodeRef.current) gainNodeRef.current.gain.value = volume;
  }, [volume]);

  // シーン音声再生（ready になってから、localStorage 復元中は再生しない）
  useEffect(() => {
    if (!ready) return;

    if (fadeTRef.current) { clearInterval(fadeTRef.current); fadeTRef.current = null; }

    var prev = audioRef.current;
    if (prev && !prev.paused) {
      fadeTRef.current = setInterval(function() {
        prev.volume = Math.max(0, prev.volume - 0.1);
        if (prev.volume <= 0) { prev.pause(); clearInterval(fadeTRef.current); fadeTRef.current = null; }
      }, 50);
    }

    var path = VOICE_FILES[sceneIdx];
    if (!path) return;

    var voiceVolumeFactor = getVoiceVolumeFactor(scenes[sceneIdx]);
    var audio = new Audio(path);
    var hasVoiceGain = false;
    try {
      var ctx = audioCtxRef.current;
      var gain = gainNodeRef.current;
      if (ctx && gain) {
        if (ctx.state === 'suspended') ctx.resume();
        var voiceGain = ctx.createGain();
        voiceGain.gain.value = voiceVolumeFactor;
        ctx.createMediaElementSource(audio).connect(voiceGain).connect(gain);
        hasVoiceGain = true;
      }
    } catch (e) {}
    audio.volume = hasVoiceGain ? 1 : Math.min(1, voiceVolumeFactor);
    audioRef.current = audio;
    audio.play().catch(function() {});

    return function() { audio.pause(); };
  }, [sceneIdx, ready]);

  const currentScene = scenes[sceneIdx];
  const isBoard = boardState.screen === 'board';

  // シーン変更時にアクション実行
  useEffect(() => {
    timersRef.current.forEach(clearTimeout);
    timersRef.current = [];
    pendingRef.current = new Map();
    if (!currentScene) return;

    // 同じdelayのアクションをまとめて1回のsetBoardStateで処理する
    const groups = new Map();
    currentScene.actions.forEach(action => {
      const delay = action.delay || 0;
      if (!groups.has(delay)) groups.set(delay, []);
      groups.get(delay).push(action);
    });

    groups.forEach((actions, delay) => {
      pendingRef.current.set(delay, actions);
      const t = setTimeout(() => {
        pendingRef.current.delete(delay);
        setBoardState(prev => {
          let s = prev;
          actions.forEach(a => { s = applyAction(s, a); });
          return s;
        });
      }, delay);
      timersRef.current.push(t);
    });

    return () => { timersRef.current.forEach(clearTimeout); timersRef.current = []; };
  }, [sceneIdx, currentScene]);

  const handleAdvance = useCallback(() => {
    if (isEnded) return;
    // 未発火のアクションを即時適用してからシーンを進める
    timersRef.current.forEach(clearTimeout);
    timersRef.current = [];
    const pending = pendingRef.current;
    pendingRef.current = new Map();
    if (pending.size > 0) {
      const remaining = [];
      pending.forEach(actions => remaining.push(...actions));
      setBoardState(prev => {
        let s = prev;
        remaining.forEach(a => { s = applyAction(s, a); });
        return s;
      });
    }
    if (sceneIdx < scenes.length - 1) {
      setSceneIdx(prev => prev + 1);
    } else {
      setIsEnded(true);
      setShowEndScreen(true);
    }
  }, [sceneIdx, scenes.length, isEnded]);

  // 進捗を localStorage に保存（v2 キーで旧データと分離）
  useEffect(() => {
    try { localStorage.setItem('dreamthesis_scene_v2', sceneIdx.toString()); }
    catch (e) {}
  }, [sceneIdx]);

  // 初回ロード時に復帰
  useEffect(() => {
    try {
      const saved = localStorage.getItem('dreamthesis_scene_v2');
      if (saved !== null) {
        const idx = parseInt(saved, 10);
        if (idx > 0 && idx < scenes.length) {
          let state = createInitialState();
          for (let i = 0; i <= idx; i++) {
            scenes[i].actions.forEach(a => { state = applyAction(state, a); });
          }
          setBoardState(state);
          setSceneIdx(idx);
        }
      }
    } catch (e) {}
  }, []);

  const handleReset = useCallback(() => {
    setSceneIdx(0);
    setBoardState(createInitialState());
    setIsEnded(false);
    setShowEndScreen(false);
    if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
    try { localStorage.removeItem('dreamthesis_scene_v2'); } catch (e) {}
  }, []);

  const handleReady = useCallback(() => {
    setReady(true);
    // ユーザージェスチャー内で AudioContext を作成（autoplay policy 対策）
    if (!audioCtxRef.current) {
      try {
        var ctx = new (window.AudioContext || window.webkitAudioContext)();
        var gain = ctx.createGain();
        gain.gain.value = volumeRef.current;
        gain.connect(ctx.destination);
        audioCtxRef.current = ctx;
        gainNodeRef.current = gain;
      } catch (e) {}
    }
  }, []);

  const handleVolumeChange = useCallback(function(v) {
    setVolume(v);
  }, []);

  return (
    <div style={{
      width: '100%',
      height: '100dvh',
      display: 'flex',
      flexDirection: 'column',
      background: '#08080f url(assets/background.webp) center/cover no-repeat',
      overflow: 'hidden',
      position: 'relative',
    }}>
      <LoadingScreen loading={loading} ready={ready} onReady={handleReady} />

      {/* 常時表示のRESETボタン＋音量スライダー（zIndex: 80） */}
      <BoardHeader
        onReset={handleReset}
        volume={volume}
        onVolumeChange={handleVolumeChange}
        hideVolume={showEndScreen}
      />

      {/* 盤面 / キャラクター */}
      <Board state={boardState} />

      {/* ホワイトアウト（全画面・ダイアログも含む） */}
      <div style={{
        position: 'absolute', inset: 0, zIndex: 65,
        background: 'white',
        opacity: boardState.whiteOut,
        transition: 'opacity 1.5s ease',
        pointerEvents: 'none',
      }} />

      {/* 画面切り替え暗転オーバーレイ（ダイアログは z-index で上に出す） */}
      <div
        onAnimationEnd={() => setBoardFlash(false)}
        style={{
          position: 'absolute', inset: 0, zIndex: 50,
          background: '#08080f',
          opacity: boardFlash ? undefined : 0,
          animation: boardFlash ? 'dtBoardFlash 1s linear both' : 'none',
          pointerEvents: 'none',
        }}
      />

      {/* ダイアログ（z-index: 70 で whiteOut オーバーレイより上） */}
      {currentScene && (
        <div style={{
          flexShrink: 0, position: 'relative', zIndex: 70,
          opacity: showEndScreen ? 0 : 1,
          transition: 'opacity 0.6s ease',
          pointerEvents: showEndScreen ? 'none' : 'auto',
        }}>
          <DialogueBox
            text={currentScene.text}
            isComplete={false}
            onTap={handleAdvance}
          />
        </div>
      )}

      {/* エンドスクリーン */}
      {showEndScreen && (
        <div style={{
          position: 'absolute', inset: 0, zIndex: 72,
          backgroundImage: 'url(assets/end_screen.webp)',
          backgroundSize: 'cover',
          backgroundPosition: 'center',
          animation: 'dimFadeIn 0.8s ease forwards',
        }}>
          {/*
            画像内の文字は高さ 61.25% で終わるので、余白の中央にあたる 80% へ置く。
            罫線はリンクの当たり判定に含めない（余白での誤タップを防ぐため、
            <a> は文字だけを包み、罫線は兄弟要素として並べている）。
          */}
          <div className="dt-end-cta-row">
            <span className="dt-end-rule dt-end-rule--left" aria-hidden="true" />
            <a
              className="dt-end-cta"
              href="https://blog.sekiseiro.com/posts/2026072701"
              target="_blank"
              rel="noopener noreferrer"
            >
              <span className="dt-end-cta__main">最速試遊体験会</span>
              <span className="dt-end-cta__sub">参加受付中</span>
            </a>
            <span className="dt-end-rule dt-end-rule--right" aria-hidden="true" />
          </div>
        </div>
      )}
    </div>
  );
}

/* ==============================================================
   マウント
   ============================================================== */
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
