// ドリームテーゼ - アプリ制御 + ダイアログ 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 (