// ドリームテーゼ - アプリ制御 + ダイアログ 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 (
{ e.preventDefault(); handleTap(); }} > {/* 台詞テキスト */}
{lines.map((line, i) => (
{line.chars.length === 0 ? ' ' : line.chars.map((ch, j) => ( {ch} ))}
))}
{/* 続きインジケータ / 終了マーク */} {isComplete ? (
— 終 —
) : (
)}
); } /* ============================================================== ヘッダー(ボードシーンのみ表示) ============================================================== */ function BoardHeader({ onReset, volume, onVolumeChange, hideVolume }) { return ( <>
VOL
); } /* ============================================================== ロード画面 ============================================================== */ function LoadingScreen({ loading, ready, onReady }) { const visible = !ready; return (
{loading ? ( <>
読み込み中
) : ( <>
このコンテンツは音が出ます。
ご注意ください。

ヘッドホンやイヤホンをご利用いただけると、
よりお楽しみいただけます。
タップで次へ
)}
); } /* ============================================================== メイン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 (
{/* 常時表示のRESETボタン+音量スライダー(zIndex: 80) */} {/* 盤面 / キャラクター */} {/* ホワイトアウト(全画面・ダイアログも含む) */}
{/* 画面切り替え暗転オーバーレイ(ダイアログは z-index で上に出す) */}
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 && (
)} {/* エンドスクリーン */} {showEndScreen && (
{/* 画像内の文字は高さ 61.25% で終わるので、余白の中央にあたる 80% へ置く。 罫線はリンクの当たり判定に含めない(余白での誤タップを防ぐため、 は文字だけを包み、罫線は兄弟要素として並べている)。 */}
)}
); } /* ============================================================== マウント ============================================================== */ const root = ReactDOM.createRoot(document.getElementById('root')); root.render();