AIdentity

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 2e10db4c6ef9b0d32eaf356da22118a3285e0b4d
parent 0189e9951b9408c97d4a48dd782122fd7c0b7650
Author: minerva-jupiter <ryouturn@gmail.com>
Date:   Sun, 26 Oct 2025 15:54:23 +0900

refactor(audio): Decouple audio playback from global sequencer

Diffstat:
Mapp/components/1_chat.tsx | 567++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mapp/components/2_draw.tsx | 363+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mapp/components/3_vr.tsx | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Dapp/components/useAudioSequencer.ts | 212-------------------------------------------------------------------------------
Mapp/ctrl/page.tsx | 10+---------
5 files changed, 657 insertions(+), 693 deletions(-)

diff --git a/app/components/1_chat.tsx b/app/components/1_chat.tsx @@ -3,266 +3,355 @@ import React, { useState, useRef, useEffect, KeyboardEvent, useCallback } from 'react'; import { StageProps } from '../ctrl/page.tsx'; import init, { chat } from '../../rust-wasm/pkg/rust_wasm.js'; -import useAudioSequencer from './useAudioSequencer.ts'; - -const FirstChat: React.FC<StageProps> = ({ onComplete }) => { - return( - <ChatPage onComplete={onComplete}/> - ) -}; - -export default FirstChat; - -const BGM_MAP: {[key:number]:string } = { - 1: '/audio/001.wav', - 2: '/audio/002.wav', - 3: '/audio/003.wav', -} - -const getBgmUrl = (times: number): string | null => { - const stages = Object.keys(BGM_MAP).map(Number).sort((a,b) => b - a); - for (const stage of stages) { - if (times >= stage) { - return BGM_MAP[stage]; - } - } - return null; -} - -interface Message { - id: number; - text: string; - sender: 'user' | 'ai'; -} - -const initialMessages: Message[] = [ - { id: 1, text: 'そろそろどうするか決めないとだよ?', sender: 'ai' }, - { id: 2, text: 'はやく話して。', sender:'ai'}, -]; - -const MessageBubble: React.FC<{ message: Message }> = ({ message }) => { - const isUser = message.sender === 'user'; - - const bubbleStyle: React.CSSProperties = { - padding: '10px 15px', - borderRadius: '15px', - maxWidth: '70%', - wordBreak: 'break-word', - fontSize: '16px', - backgroundColor: isUser ? '#3b82f6' : '#e5e7eb', // blue-500 or gray-200 - color: isUser ? 'white' : '#1f2937', // white or gray-800 - marginLeft: isUser ? 'auto' : '0', - marginRight: isUser ? '0' : 'auto', - }; - const containerStyle: React.CSSProperties = { - display: 'flex', - marginBottom: '10px', - justifyContent: isUser ? 'flex-end' : 'flex-start', +// useWebAudioControllerの代替となるカスタムフックを定義 +const useAudioPlayback = ( + initialTalktime: number, + audioSources: Record<number, string>, + onAudioEnd: () => void, +) => { + // Audioオブジェクトの参照を保持 + const audioRef = useRef<HTMLAudioElement | null>(null); + // 現在のトークタイムを保持し、変更を監視するためのState + const talktimesRef = useRef(initialTalktime); + const [currentAudioUrl, setCurrentAudioUrl] = useState(audioSources[initialTalktime] || ''); + + // 外部から再生を停止するための関数 + const stop = useCallback(() => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; // 最初に戻す + } + }, []); + + // 外部からループ設定を変更するための関数(AudioRefのcurrentが更新されるたびに適用される) + const setLoop = useCallback((isLooping: boolean) => { + // audioRef.currentが存在する場合にのみ設定を試みる + if (audioRef.current) { + audioRef.current.loop = isLooping; + } + }, []); // 依存配列は空でOK + + // 1. Audioオブジェクトの初期化、クリーンアップ、および終了イベント処理 + useEffect(() => { + // 古い音源を停止 + stop(); + + const audio = new Audio(currentAudioUrl); + audioRef.current = audio; + audio.volume = 0.5; // 必要に応じて音量を設定 + + // 常に最新のtalktimesRef.currentに基づいてループ設定 + const isLooping = talktimesRef.current < 4; + audio.loop = isLooping; + setLoop(isLooping); // 念のため + + // 自動再生の試行 (ユーザーのインタラクションが必要なため、失敗する可能性あり) + audio.play().catch(e => { + console.error("Audio playback error on URL change/init:", e); + // ユーザーのインタラクションがない場合は再生できないため、ここではエラーを無視するか、ユーザーに操作を促す + }); + + + const handleEnded = () => { + const currentTalktime = talktimesRef.current; + + // 終了条件を満たしている場合は停止し、onAudioEndを実行 + if (currentTalktime >= 4) { + console.log("最終音源の再生が終了しました。onAudioEndを実行します。"); + stop(); + onAudioEnd(); + return; + } + + // ループがtrueの場合はonendedは呼ばれないはずだが、フォールバックとして再再生を試みる + if (!audio.loop) { + console.log(`音源 ${currentTalktime} の再生が終了しました。ループ再生を再開します。`); + audio.play().catch(e => console.error("Audio playback error on loop restart:", e)); + } + }; + + audio.addEventListener('ended', handleEnded); + + // コンポーネントがアンマウントされる際のクリーンアップ + return () => { + audio.removeEventListener('ended', handleEnded); + audio.pause(); + // audioRef.current = null; // Audioオブジェクトが再生成されるため、ここではnullにしない + }; + }, [currentAudioUrl, onAudioEnd, stop, setLoop]); // currentAudioUrlが変わるとAudioオブジェクトが再生成される + + // 2. talktimesRef.currentの変更を監視し、音源の切り替えを行う + useEffect(() => { + const currentTalktime = talktimesRef.current; + const newAudioUrl = audioSources[currentTalktime]; + + console.log("talktimesRef is ", currentTalktime); + + if (newAudioUrl && newAudioUrl !== currentAudioUrl) { + // URLが変わったら、Audioオブジェクトを再生成するためにstateを更新 + // 新しいcurrentAudioUrlで上のuseEffectがトリガーされる + setCurrentAudioUrl(newAudioUrl); + + // 新しい音源に対するループ設定を即座に更新 + const isLooping = currentTalktime < 4; + setLoop(isLooping); + } else if (currentTalktime === initialTalktime && audioRef.current) { + // 初回ロード時のみ、初期音源を再生(ブラウザの制限のため、ユーザー操作後の初回にのみ有効) + audioRef.current.play().catch(e => console.error("Initial audio playback error:", e)); + } + + }, [talktimesRef.current]); + + // 外部からの更新用にtalktimesRefと制御関数を返す + return { talktimesRef, stop, setLoop, currentAudioUrl }; }; - return ( - <div style={containerStyle}> - <div style={bubbleStyle}> - {message.text} - </div> - </div> - ); -}; - -const MessageInput: React.FC<{ onSend: (text: string) => void }> = ({ onSend }) => { - const [input, setInput] = useState(''); - - const handleSend = () => { - if (input.trim() === '') return; - onSend(input); - setInput(''); + const FirstChat: React.FC<StageProps> = ({ onComplete }) => { + return( + <ChatPage onComplete={onComplete}/> + ) }; - const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSend(); - } - }; + export default FirstChat; - const inputStyle: React.CSSProperties = { - flexGrow: 1, - padding: '12px', - border: '1px solid #d1d5db', // gray-300 - borderRadius: '8px', - marginRight: '10px', - fontSize: '16px', - outline: 'none', + const AUDIO_SOURCES: Record<number, string> = { + 1: '/audio/001.wav', + 2: '/audio/002.wav', + 3: '/audio/003.wav', + 4: '/audio/004.wav', + // 5以降は終了条件を満たすため、再生する音源は設定不要 }; - const buttonStyle: React.CSSProperties = { - backgroundColor: '#3b82f6', // blue-500 - color: 'white', - border: 'none', - padding: '12px 20px', - borderRadius: '8px', - cursor: 'pointer', - fontWeight: 'bold', - }; + interface Message { + id: number; + text: string; + sender: 'user' | 'ai'; + } + const initialMessages: Message[] = [ + { id: 1, text: 'そろそろどうするか決めないとだよ?', sender: 'ai' }, + { id: 2, text: 'はやく話して。', sender:'ai'}, + ]; + + const MessageBubble: React.FC<{ message: Message }> = ({ message }) => { + const isUser = message.sender === 'user'; + + const bubbleStyle: React.CSSProperties = { + padding: '10px 15px', + borderRadius: '15px', + maxWidth: '70%', + wordBreak: 'break-word', + fontSize: '16px', + backgroundColor: isUser ? '#3b82f6' : '#e5e7eb', // blue-500 or gray-200 + color: isUser ? 'white' : '#1f2937', // white or gray-800 + marginLeft: isUser ? 'auto' : '0', + marginRight: isUser ? '0' : 'auto', + }; - return ( - <div style={{ padding: '15px', backgroundColor: '#f9fafb', display: 'flex', alignItems: 'center' }}> - <input - type="text" - style={inputStyle} - placeholder="メッセージを入力してください..." - value={input} - onChange={(e) => setInput(e.target.value)} - onKeyDown={handleKeyPress} - /> - <button - type='submit' - style={buttonStyle} - onClick={handleSend} - disabled={input.trim() === ''} - > - 送信 - </button> - </div> - ); -}; - - - -function ChatPage({onComplete}:StageProps) { - const [messages, setMessages] = useState<Message[]>(initialMessages); - const [ dict, setDict ] = useState<Uint8Array|undefined>(undefined); - const messagesEndRef = useRef<HTMLDivElement>(null); - const dictPath = '/system.dic.zst'; - - // for audio - const talktimesRef = useRef(1); - const [currentTalktimes, setCurrentTalktimes] = useState(1); - const currentBgmUrl = getBgmUrl(currentTalktimes); - const { isPlaying, isSwitching, endLoopAndAwaitCompletion } = useAudioSequencer(currentBgmUrl); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - useEffect(() => { - const loadData = async () => { - const loadedDict = await LoadDict(dictPath); - setDict(loadedDict); - } - loadData(); - }, []); + const containerStyle: React.CSSProperties = { + display: 'flex', + marginBottom: '10px', + justifyContent: isUser ? 'flex-end' : 'flex-start', + }; - useEffect(() => { - init(); - }, []); + return ( + <div style={containerStyle}> + <div style={bubbleStyle}> + {message.text} + </div> + </div> + ); + }; - const handleSendMessage = useCallback(async (text: string) => { - if (text.trim() === '') return; + const MessageInput: React.FC<{ onSend: (text: string) => void }> = ({ onSend }) => { + const [input, setInput] = useState(''); - const newUserMessage: Message = { - id: Date.now(), - text, - sender: 'user', + const handleSend = () => { + if (input.trim() === '') return; + onSend(input); + setInput(''); }; - setMessages((prev) => [...prev, newUserMessage]); - const ans = dict == undefined ? 'もっとまともなことを言いなさい。' : chat(dict, text); - const aiResponse: Message = { - id: Date.now() + 1, - text: ans, - sender: 'ai', + + const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSend(); + } }; - setMessages((prev) => [...prev, aiResponse]); - talktimesRef.current += 1; + const inputStyle: React.CSSProperties = { + flexGrow: 1, + padding: '12px', + border: '1px solid #d1d5db', // gray-300 + borderRadius: '8px', + marginRight: '10px', + fontSize: '16px', + outline: 'none', + }; - // termination condition - if(talktimesRef.current >= 4){ - await endLoopAndAwaitCompletion(); - onComplete(); - return; - } - setCurrentTalktimes(talktimesRef.current); - - }, [dict, onComplete, endLoopAndAwaitCompletion]); - - const pageContainerStyle: React.CSSProperties = { - display: 'flex', - flexDirection: 'column', - height: '100vh', // 全画面の高さ - maxWidth: '800px', // 最大幅を制限して中央に寄せる - margin: '0 auto', - backgroundColor: 'white', - boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', - }; + const buttonStyle: React.CSSProperties = { + backgroundColor: '#3b82f6', // blue-500 + color: 'white', + border: 'none', + padding: '12px 20px', + borderRadius: '8px', + cursor: 'pointer', + fontWeight: 'bold', + }; - const headerStyle: React.CSSProperties = { - padding: '15px', - backgroundColor: '#3b82f6', // blue-500 - color: 'white', - textAlign: 'center', - fontWeight: 'bold', - fontSize: '20px', - }; - const messageListStyle: React.CSSProperties = { - flexGrow: 1, // 残りのスペースをすべて占める - padding: '15px', - overflowY: 'auto', // スクロール可能にする + return ( + <div style={{ padding: '15px', backgroundColor: '#f9fafb', display: 'flex', alignItems: 'center' }}> + <input + type="text" + style={inputStyle} + placeholder="メッセージを入力してください..." + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={handleKeyPress} + /> + <button + type='submit' + style={buttonStyle} + onClick={handleSend} + disabled={input.trim() === ''} + > + 送信 + </button> + </div> + ); }; - // for audio - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + function ChatPage({onComplete}:StageProps) { + const [messages, setMessages] = useState<Message[]>(initialMessages); + const [ dict, setDict ] = useState<Uint8Array|undefined>(undefined); + const messagesEndRef = useRef<HTMLDivElement>(null); + const dictPath = '/system.dic.zst'; + + // for audio + const { talktimesRef } = useAudioPlayback( + 1, // initialTalktime + AUDIO_SOURCES, + onComplete + ); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + useEffect(() => { + const loadData = async () => { + const loadedDict = await LoadDict(dictPath); + setDict(loadedDict); + } + loadData(); + }, []); + + useEffect(() => { + init(); + }, []); + + const handleSendMessage = useCallback(async (text: string) => { + if (text.trim() === '') return; + + const newUserMessage: Message = { + id: Date.now(), + text, + sender: 'user', + }; + setMessages((prev) => [...prev, newUserMessage]); + const ans = dict == undefined ? 'もっとまともなことを言いなさい。' : chat(dict, text); + const aiResponse: Message = { + id: Date.now() + 1, + text: ans, + sender: 'ai', + }; + setMessages((prev) => [...prev, aiResponse]); + + talktimesRef.current += 1; + + // termination condition + if(talktimesRef.current >= 4){ + return; + } + + }, [dict, talktimesRef ]); + + const pageContainerStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + height: '100vh', // 全画面の高さ + maxWidth: '800px', // 最大幅を制限して中央に寄せる + margin: '0 auto', + backgroundColor: 'white', + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + }; - useEffect(() => { - const loadData = async () => { - const loadedDict = await LoadDict(dictPath); - setDict(loadedDict); - } - loadData(); - }, []); - - useEffect(() => { - init(); - setCurrentTalktimes(1); - }, []); - - // function ChatPage's return - - return ( - <div style={pageContainerStyle}> - <div style={headerStyle}> - チャット - </div> - - <div style={messageListStyle}> - {messages.map((msg) => ( - <MessageBubble key={msg.id} message={msg} /> - ))} - <div ref={messagesEndRef} /> - </div> - - <MessageInput onSend={handleSendMessage} /> - </div> - ); -} - -async function LoadDict(dictPath: string): Promise<Uint8Array> { - try{ - const response = await fetch(dictPath); - if(!response.ok){ - throw new Error(`fail to fetch file: ${response.statusText}`); + const headerStyle: React.CSSProperties = { + padding: '15px', + backgroundColor: '#3b82f6', // blue-500 + color: 'white', + textAlign: 'center', + fontWeight: 'bold', + fontSize: '20px', + }; + + const messageListStyle: React.CSSProperties = { + flexGrow: 1, // 残りのスペースをすべて占める + padding: '15px', + overflowY: 'auto', // スクロール可能にする + }; + + // for audio + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + useEffect(() => { + const loadData = async () => { + const loadedDict = await LoadDict(dictPath); + setDict(loadedDict); + } + loadData(); + }, []); + + useEffect(() => { + init(); + }, []); + + // function ChatPage's return + + return ( + <div style={pageContainerStyle}> + <div style={headerStyle}> + チャット + </div> + + <div style={messageListStyle}> + {messages.map((msg) => ( + <MessageBubble key={msg.id} message={msg} /> + ))} + <div ref={messagesEndRef} /> + </div> + + <MessageInput onSend={handleSendMessage} /> + </div> + ); + } + + async function LoadDict(dictPath: string): Promise<Uint8Array> { + try{ + const response = await fetch(dictPath); + if(!response.ok){ + throw new Error(`fail to fetch file: ${response.statusText}`); + } + const arrayBuffer = await response.arrayBuffer(); + const byte = new Uint8Array(arrayBuffer); + return byte; + } catch (error) { + console.log("error occur",error); } - const arrayBuffer = await response.arrayBuffer(); - const byte = new Uint8Array(arrayBuffer); - return byte; - } catch (error) { - console.log("error occur",error); + return new Uint8Array(0); } - return new Uint8Array(0); -} diff --git a/app/components/2_draw.tsx b/app/components/2_draw.tsx @@ -3,14 +3,13 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import init, { find_nearest_point_on_path, NearestPointResult } from '../../rust-wasm/pkg/rust_wasm.js'; import { StageProps } from '../ctrl/page.tsx'; -import useAudioSequencer from './useAudioSequencer.ts'; type Tool = 'pen'; type Point = { x: number; y: number; }; interface LineData { - id: number; - tool: Tool; + id: number; + tool: Tool; points: Point[]; targetPoints: Point[]; } @@ -19,45 +18,26 @@ const BACKGROUND_SVG_PATH_D_DEFAULT = ""; const VIEWSBOX_SIZE = 500; const SNAPPING_DISTANCE_PIXELS = 30; -const BGM_MAP: {[key:number]:string } = { - 1: '/audio/001.wav', - 2: '/audio/002.wav', - 3: '/audio/003.wav', -} - -const getBgmUrl = (times: number): string | null => { - const stages = Object.keys(BGM_MAP).map(Number).sort((a,b) => b - a); - for (const stage of stages) { - if (times >= stage) { - return BGM_MAP[stage]; - } - } - return null; -} +const AUDIO_SOURCE = '/audio/001.wav'; -function DrawingApp({onComplete}: StageProps) { +export default function DrawingApp({onComplete}: StageProps) { const [isClient, setIsClient] = useState(false); - const lineIdCounter = useRef(0); + const lineIdCounter = useRef(0); const [lines, setLines] = useState<LineData[]>([]); const [currentLines, setCurrentLines] = useState<LineData[]>([]); - const [stageWidth, setStageWidth] = useState(0); - const [stageHeight, setStageHeight] = useState(0); - const [backgroundPathD, setBackgroundPathD] = useState(BACKGROUND_SVG_PATH_D_DEFAULT); - const [viewBoxSize, setViewBoxSize] = useState(VIEWSBOX_SIZE); - + const [stageWidth, setStageWidth] = useState(0); + const [stageHeight, setStageHeight] = useState(0); + const [backgroundPathD, setBackgroundPathD] = useState(BACKGROUND_SVG_PATH_D_DEFAULT); + const [viewBoxSize, setViewBoxSize] = useState(VIEWSBOX_SIZE); + const isDrawing = useRef(false); - + const animationRef = useRef<number>(0); const startTimeRef = useRef<number|undefined>(undefined); const stageRef = useRef<HTMLDivElement>(null); - // for audio - const currentTalktimes = useState<number>(1); - const currentBgmUrl = getBgmUrl(1); - const { isPlaying, isSwitching, endLoopAndAwaitCompletion } = useAudioSequencer(currentBgmUrl); - useEffect(() => { init(); },[]) @@ -65,7 +45,7 @@ function DrawingApp({onComplete}: StageProps) { // --- 1. サイズ計算とステージ設定 (画面全体を使用) --- useEffect(() => { setIsClient(true); - + const handleResize = () => { if (typeof globalThis !== 'undefined') { setStageWidth(globalThis.innerWidth); @@ -80,153 +60,153 @@ function DrawingApp({onComplete}: StageProps) { globalThis.removeEventListener('resize', handleResize); }; }, []); - - // --- 2. 座標変換ユーティリティ --- - - const scaleToViewBox = useCallback((p: Point): Point => { - if (stageWidth === 0 || stageHeight === 0) return p; - - const effectiveSize = Math.min(stageWidth, stageHeight); - const scale = viewBoxSize / effectiveSize; - - const offsetX = (stageWidth - effectiveSize) / 2; - const offsetY = (stageHeight - effectiveSize) / 2; - - return { - x: (p.x - offsetX) * scale, - y: (p.y - offsetY) * scale, - }; - }, [stageWidth, stageHeight, viewBoxSize]); - - const scaleToScreen = useCallback((p: Point): Point => { - if (stageWidth === 0 || stageHeight === 0) return p; - - const effectiveSize = Math.min(stageWidth, stageHeight); - const scale = effectiveSize / viewBoxSize; - - const offsetX = (stageWidth - effectiveSize) / 2; - const offsetY = (stageHeight - effectiveSize) / 2; - - return { - x: p.x * scale + offsetX, - y: p.y * scale + offsetY, - }; - }, [stageWidth, stageHeight, viewBoxSize]); - - const getPointerPosition = useCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent): Point | null => { + + // --- 2. 座標変換ユーティリティ --- + + const scaleToViewBox = useCallback((p: Point): Point => { + if (stageWidth === 0 || stageHeight === 0) return p; + + const effectiveSize = Math.min(stageWidth, stageHeight); + const scale = viewBoxSize / effectiveSize; + + const offsetX = (stageWidth - effectiveSize) / 2; + const offsetY = (stageHeight - effectiveSize) / 2; + + return { + x: (p.x - offsetX) * scale, + y: (p.y - offsetY) * scale, + }; + }, [stageWidth, stageHeight, viewBoxSize]); + + const scaleToScreen = useCallback((p: Point): Point => { + if (stageWidth === 0 || stageHeight === 0) return p; + + const effectiveSize = Math.min(stageWidth, stageHeight); + const scale = effectiveSize / viewBoxSize; + + const offsetX = (stageWidth - effectiveSize) / 2; + const offsetY = (stageHeight - effectiveSize) / 2; + + return { + x: p.x * scale + offsetX, + y: p.y * scale + offsetY, + }; + }, [stageWidth, stageHeight, viewBoxSize]); + + const getPointerPosition = useCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent): Point | null => { if (!stageRef.current) return null; const rect = stageRef.current.getBoundingClientRect(); - - let clientX: number, clientY: number; - if ('touches' in e) { - if (e.touches.length === 0) return null; - clientX = e.touches[0].clientX; - clientY = e.touches[0].clientY; - } else { - clientX = e.clientX; - clientY = e.clientY; - } + + let clientX: number, clientY: number; + if ('touches' in e) { + if (e.touches.length === 0) return null; + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } return { x: clientX - rect.left, y: clientY - rect.top }; }, []); - - // --- 3. SVGコンテンツの取得ロジック (DOMParser) --- - useEffect(() => { - const fetchAndParseSvg = async () => { - try { - const response = await fetch('/whiteperson.svg'); - const svgText = await response.text(); - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgText, "image/svg+xml"); - - const svgElement = doc.querySelector('svg'); - if (svgElement) { - const viewBoxAttr = svgElement.getAttribute('viewBox'); - if (viewBoxAttr) { - const parts = viewBoxAttr.trim().split(/\s+/); - if (parts.length === 4) { - const size = Math.max(parseFloat(parts[2]), parseFloat(parts[3])); - if (!isNaN(size) && size > 0) { - setViewBoxSize(size); - } - } - } - } - - const pathElements = doc.querySelectorAll('path'); - let foundDValue = null; - - for (const element of Array.from(pathElements)) { - const dValue = element.getAttribute('d'); - if (dValue && dValue.trim().length > 0) { - foundDValue = dValue; - break; - } - } - - if (foundDValue) { - setBackgroundPathD(foundDValue); - return; - } - - console.error("Error: <path> element or 'd' attribute not found in whiteperson.svg."); - - } catch (error) { - console.error("Failed to load or parse whiteperson.svg:", error); - } - }; - - if (isClient && backgroundPathD === "") { - fetchAndParseSvg(); - } - }, [isClient, backgroundPathD]); - - - // --- 4. 吸着ロジック (WASM呼び出し) --- - const calculateNearestTargetPoint = useCallback((p: Point): Point => { - const p_viewbox = scaleToViewBox(p); - - const effectiveSize = Math.min(stageWidth, stageHeight); - const snapping_distance_viewbox = SNAPPING_DISTANCE_PIXELS * (viewBoxSize / effectiveSize); - - // ⚠️ WASM呼び出し - const nearest_viewbox: NearestPointResult | undefined = find_nearest_point_on_path( - backgroundPathD, - p_viewbox.x, - p_viewbox.y, - snapping_distance_viewbox - ); + + // --- 3. SVGコンテンツの取得ロジック (DOMParser) --- + useEffect(() => { + const fetchAndParseSvg = async () => { + try { + const response = await fetch('/whiteperson.svg'); + const svgText = await response.text(); + + const parser = new DOMParser(); + const doc = parser.parseFromString(svgText, "image/svg+xml"); + + const svgElement = doc.querySelector('svg'); + if (svgElement) { + const viewBoxAttr = svgElement.getAttribute('viewBox'); + if (viewBoxAttr) { + const parts = viewBoxAttr.trim().split(/\s+/); + if (parts.length === 4) { + const size = Math.max(parseFloat(parts[2]), parseFloat(parts[3])); + if (!isNaN(size) && size > 0) { + setViewBoxSize(size); + } + } + } + } + + const pathElements = doc.querySelectorAll('path'); + let foundDValue = null; + + for (const element of Array.from(pathElements)) { + const dValue = element.getAttribute('d'); + if (dValue && dValue.trim().length > 0) { + foundDValue = dValue; + break; + } + } + + if (foundDValue) { + setBackgroundPathD(foundDValue); + return; + } + + console.error("Error: <path> element or 'd' attribute not found in whiteperson.svg."); + + } catch (error) { + console.error("Failed to load or parse whiteperson.svg:", error); + } + }; + + if (isClient && backgroundPathD === "") { + fetchAndParseSvg(); + } + }, [isClient, backgroundPathD]); + + + // --- 4. 吸着ロジック (WASM呼び出し) --- + const calculateNearestTargetPoint = useCallback((p: Point): Point => { + const p_viewbox = scaleToViewBox(p); + + const effectiveSize = Math.min(stageWidth, stageHeight); + const snapping_distance_viewbox = SNAPPING_DISTANCE_PIXELS * (viewBoxSize / effectiveSize); + + // ⚠️ WASM呼び出し + const nearest_viewbox: NearestPointResult | undefined = find_nearest_point_on_path( + backgroundPathD, + p_viewbox.x, + p_viewbox.y, + snapping_distance_viewbox + ); console.log("nearest_viewbox is ",nearest_viewbox); - if (nearest_viewbox) { - return scaleToScreen(nearest_viewbox); - } - return p; - }, [scaleToViewBox, scaleToScreen, stageWidth, stageHeight, viewBoxSize, backgroundPathD]); + if (nearest_viewbox) { + return scaleToScreen(nearest_viewbox); + } + return p; + }, [scaleToViewBox, scaleToScreen, stageWidth, stageHeight, viewBoxSize, backgroundPathD]); const calculateTargetPoints = useCallback((points: Point[]): Point[] => { - return points.map(p => calculateNearestTargetPoint(p)); + return points.map(p => calculateNearestTargetPoint(p)); }, [calculateNearestTargetPoint]); - // --- 5. イベントハンドラ (吹き飛んでいた部分を再定義) --- + // --- 5. イベントハンドラ (吹き飛んでいた部分を再定義) --- - /** + /** * マウス・タッチ開始時の処理 */ - const handleMouseDown = useCallback((e: React.MouseEvent | React.TouchEvent) => { + const handleMouseDown = useCallback((e: React.MouseEvent | React.TouchEvent) => { isDrawing.current = true; const pos = getPointerPosition(e); if (pos) { - lineIdCounter.current += 1; - const newLine: LineData = { id: lineIdCounter.current, tool: 'pen', points: [pos], targetPoints: [] }; + lineIdCounter.current += 1; + const newLine: LineData = { id: lineIdCounter.current, tool: 'pen', points: [pos], targetPoints: [] }; setLines(prev => [...prev, newLine]); - setCurrentLines(prev => [...prev, newLine]); + setCurrentLines(prev => [...prev, newLine]); } }, [getPointerPosition]); - /** + /** * マウス・タッチ移動時の処理 */ const handleMouseMove = useCallback((e: React.MouseEvent | React.TouchEvent) => { @@ -241,13 +221,13 @@ function DrawingApp({onComplete}: StageProps) { const newPoints: Point[] = [...lastLine.points, point]; return [...prevLines.slice(0, lastLineIndex), { ...lastLine, points: newPoints }]; }; - + setLines(updateLines); - setCurrentLines(updateLines); + setCurrentLines(updateLines); }, [getPointerPosition]); - /** + /** * マウス・タッチ終了時の処理 */ const handleMouseUp = useCallback(() => { @@ -257,7 +237,7 @@ function DrawingApp({onComplete}: StageProps) { const lastLineIndex = prevLines.length - 1; if (lastLineIndex < 0) return prevLines; const lastLine = prevLines[lastLineIndex]; - + const targetPoints = calculateTargetPoints(lastLine.points); const newLine: LineData = { @@ -267,7 +247,7 @@ function DrawingApp({onComplete}: StageProps) { return [...prevLines.slice(0, lastLineIndex), newLine]; }); }, [calculateTargetPoints]); - + // --- 6. アニメーションロジック (変更なし) --- const animateLines = useCallback((timestamp: number) => { @@ -280,11 +260,11 @@ function DrawingApp({onComplete}: StageProps) { if (line.targetPoints.length === 0) return line; const newPoints: Point[] = line.points.map((originalP, index) => { - const targetP = line.targetPoints[index]; + const targetP = line.targetPoints[index]; return { - x: originalP.x + (targetP.x - originalP.x) * progress, - y: originalP.y + (targetP.y - originalP.y) * progress, - }; + x: originalP.x + (targetP.x - originalP.x) * progress, + y: originalP.y + (targetP.y - originalP.y) * progress, + }; }); return { ...line, points: newPoints }; @@ -297,10 +277,10 @@ function DrawingApp({onComplete}: StageProps) { } else { startTimeRef.current = undefined; setLines(newCurrentLines.map(line => ({ - ...line, - points: line.targetPoints, - targetPoints: [] - }))); + ...line, + points: line.targetPoints, + targetPoints: [] + }))); } }, [lines]); @@ -319,24 +299,46 @@ function DrawingApp({onComplete}: StageProps) { }, [lines, animateLines]); - const pointsToSvgString = (points: Point[]): string => { - return points.map(p => `${p.x},${p.y}`).join(' '); - }; + const pointsToSvgString = (points: Point[]): string => { + return points.map(p => `${p.x},${p.y}`).join(' '); + }; // for audio + const audioRef = useRef<HTMLAudioElement | null>(null); useEffect(() => { - const handleAudioSequence = async () => { + init(); + },[]) - await endLoopAndAwaitCompletion(); + useEffect(() => { + const audio = new Audio(AUDIO_SOURCE); + audioRef.current = audio; + const handleAudioEnded = () => { + console.log("Audio playback finished. Calling onComplete."); onComplete(); }; - handleAudioSequence(); - return + audio.addEventListener('ended', handleAudioEnded); - }, [currentBgmUrl, onComplete, endLoopAndAwaitCompletion]); + // ユーザーインタラクションの直後に再生を開始 + // コンポーネントがロードされただけではブラウザの制限で再生できないため、 + // ユーザーの最初の描画操作をトリガーとして再生を開始するのがより安全ですが、 + // 今回はシンプルにロード時に再生を試みます。 + const playAudio = () => { + audio.play().catch(e => console.log("Audio playback failed (may require user interaction):", e)); + }; + + // ロード完了を待って再生を試みる + audio.oncanplaythrough = playAudio; + + // アンマウント時のクリーンアップ + return () => { + audio.pause(); + audio.removeEventListener('ended', handleAudioEnded); + audioRef.current = null; + }; + }, [onComplete]); return ( <div @@ -405,8 +407,3 @@ function DrawingApp({onComplete}: StageProps) { ); } -const SecondDraw: React.FC<StageProps> = (props) => { - return <DrawingApp {...props} />; -}; - -export default SecondDraw; diff --git a/app/components/3_vr.tsx b/app/components/3_vr.tsx @@ -2,13 +2,134 @@ import { Canvas, useFrame } from '@react-three/fiber'; import { OrbitControls, Sky, ContactShadows, Cloud, Clouds } from '@react-three/drei'; -import BenchScene from '../components/BenchScene'; -import { useState, useMemo } from 'react'; +import BenchScene from './BenchScene.tsx'; +import { useState, useMemo, useRef, useEffect } from 'react'; import * as THREE from 'three'; +import { StageProps } from '../ctrl/page.tsx'; + +// --- オーディオ関連の定数とカスタムフック --- +const AUDIO_SOURCES: Record<number, string> = { + 1: '/audio/001.wav', + 2: '/audio/002.wav', +}; + +// シーケンシャルな音声再生と状態遷移を制御するカスタムフック (修正版) +const useSequentialAudio = (initialState: number, audioSources: Record<number, string>, onComplete: () => void) => { + const [sceneState, setSceneState] = useState(initialState); + const audioRef = useRef<HTMLAudioElement | null>(null); + + // 現在の状態に対応する音源のURLを取得 + const currentAudioUrl = audioSources[sceneState]; + + // 音声再生のロジック + useEffect(() => { + // --- 1. 終了条件のチェック --- + if (sceneState > Math.max(...Object.keys(audioSources).map(Number))) { + // 定義された全ての音源が再生された後、onCompleteを実行 + console.log("All audio finished. Calling onComplete."); + onComplete(); + return; + } + + // 現在の状態に対応する音源がない場合、ここで処理を停止 + if (!currentAudioUrl) { + return; + } + + console.log(`Starting setup for state ${sceneState}: ${currentAudioUrl}`); + + // --- 2. 古いAudioオブジェクトのクリーンアップ --- + const existingAudio = audioRef.current; + if (existingAudio) { + existingAudio.pause(); + // イベントリスナーの削除 (次のステップで新しいAudioオブジェクトにリスナーを設定するため) + // このクリーンアップが重要です。古いAudioオブジェクトが'ended'イベントをトリガーするのを防ぎます。 + // `oncanplaythrough`リスナーはここでは不要ですが、もしあれば削除すべきです。 + // Audioオブジェクトのライフサイクルを明確にするため、毎回新しいインスタンスを生成します。 + } + + // --- 3. 新しいAudioオブジェクトの作成と設定 --- + const audio = new Audio(currentAudioUrl); + audioRef.current = audio; + + // 再生が終了したときのハンドラを定義 + const handleAudioEnded = () => { + console.log(`Audio ${sceneState} finished. Transitioning state.`); + // 次の状態へ遷移 (例: 1 -> 2, 2 -> 3) + setSceneState(prev => prev + 1); + }; + + audio.addEventListener('ended', handleAudioEnded); + + // --- 4. 再生の開始 --- + // ロードやイベントを待たずに、すぐに再生を試みる + audio.play().catch(e => { + console.warn(`Audio playback failed for state ${sceneState} (requires user interaction):`, e); + // ユーザーに最初のクリックを促すメッセージなどを表示すると良い + }); + + + // --- 5. クリーンアップ関数 --- + return () => { + // アンマウント/状態遷移時に現在のAudioオブジェクトを確実に停止し、リスナーを削除 + if (audio === audioRef.current) { // 現在設定したAudioオブジェクトであることを確認 + audio.pause(); + audio.removeEventListener('ended', handleAudioEnded); + } + }; + + }, [sceneState, audioSources, onComplete]); // sceneStateとcurrentAudioUrlは基本的に連動するため、sceneStateを依存配列に含める + + return sceneState; +}; + +// 雲のランダム生成のための型 +interface RandomCloudsProps { + count: number; + radius: number; + height: number; +} + + +// 状態に応じてライトと霧を制御するコンポーネント +function SceneController({ sceneState }: { sceneState: number }) { + + const isFoggy = sceneState === 2; + // 状態 1 (晴れ): 強さ 2 / 状態 2 (霧): 強さ 0.3 + const lightIntensity = isFoggy ? 0.3 : 2; + + // 霧の制御ロジック + useFrame(({ scene }) => { + if (isFoggy) { + // 💡 状態 2: 暗く濃い霧 (色: 灰色、Near 5, Far 50) + scene.fog = new THREE.Fog(0x909090, 5, 50); + } else { + // 状態 1: 霧を削除(今まで通り) + scene.fog = null; + } + }); + + return ( + <> + <ambientLight intensity={0.5} /> + <directionalLight + position={[5, 10, 5]} + intensity={lightIntensity} // 状態によって明るさを変更 + castShadow + shadow-mapSize-width={2048} + shadow-mapSize-height={2048} + shadow-camera-far={50} + shadow-camera-left={-10} + shadow-camera-right={10} + shadow-camera-top={10} + shadow-camera-bottom={-10} + /> + </> + ); +} function AnimatedSky() { - // このコンポーネントはそのまま維持し、空のグラデーションと太陽をアニメーションさせます。 const [sunPosition, setSunPosition] = useState<[number, number, number]>([0, 100, 0]); useFrame(({ clock }) => { @@ -32,42 +153,27 @@ function AnimatedSky() { ); } -// 必要なプロパティ: 雲の数、空の範囲の半径、雲の高さ -interface RandomCloudsProps { - count: number; - radius: number; - height: number; -} function RandomClouds({ count, radius, height }: RandomCloudsProps) { const cloudConfigs = useMemo(() => { return Array.from({ length: count }).map((_, i) => { - // 💡 修正点: 極座標 (角度と距離) を使って円形に均一に分布させる - - // 1. 角度 (0 から 2π) をランダムに決定 const angle = Math.random() * Math.PI * 2; - - // 2. 距離 (0 から radius) をランダムに決定 (二乗根で遠くへ均一に分布) - // Math.sqrt() を使うことで、原点付近への集中を避け、円内での分布を均一にします const distance = Math.sqrt(Math.random()) * radius; - // 3. 極座標を直交座標 (X, Z) に変換 const x = Math.cos(angle) * distance; const z = Math.sin(angle) * distance; return { - // 新しいランダムなX, Z座標 position: [ x, height + (Math.random() - 0.5) * 5, z, ] as [number, number, number], - // ... (bounds などの他の設定はそのまま) bounds: [ - Math.random() * 10 + 10, - Math.random() * 2 + 1, - Math.random() * 10 + 10, + Math.random() * 10 + 10, + Math.random() * 2 + 1, + Math.random() * 10 + 10, ] as [number, number, number], seed: Math.floor(Math.random() * 1000) + i, volume: Math.random() * 20 + 5, @@ -85,40 +191,32 @@ function RandomClouds({ count, radius, height }: RandomCloudsProps) { ); } -export default function ThreeVR() { + +export default function ThreeVR({onComplete}:StageProps) { + // 💡 状態定義: number型 + const sceneState = useSequentialAudio(1, AUDIO_SOURCES, onComplete); + return ( <Canvas + key="r3f-main-canvas" camera={{ position: [5, 5, 5], fov: 60 }} shadows + // 霧の状態によってクリアカラー(背景色)を変える + style={{ backgroundColor: sceneState === 2 ? '#a0aa90' : '#d0e0ff' }} > - {/* 太陽と空 */} <AnimatedSky /> - - <Clouds material={THREE.MeshLambertMaterial} limit={400}> {/* limitで最大雲数を設定 */} - <RandomClouds count={100} radius={80} height={15} /> {/* 100個の雲を半径80の範囲、高さ15に生成 */} - </Clouds> - - - {/* ライトとモデル */} - <ambientLight intensity={0.5} /> - <directionalLight - position={[5, 10, 5]} - intensity={2} - castShadow - shadow-mapSize-width={2048} - shadow-mapSize-height={2048} - shadow-camera-far={50} - shadow-camera-left={-10} - shadow-camera-right={10} - shadow-camera-top={10} - shadow-camera-bottom={-10} - /> - - <BenchScene /> - <ContactShadows position={[0, -0.5, 0]} opacity={0.7} scale={10} blur={1} far={10} /> - <OrbitControls enableZoom={true} enablePan={true} enableRotate={true} /> - - </Canvas> - ); + <Clouds material={THREE.MeshLambertMaterial} limit={200}> + <RandomClouds count={100} radius={80} height={15} /> + </Clouds> + + {/* 💡 SceneControllerを配置し、状態を渡す */} + <SceneController sceneState={sceneState} /> + + <BenchScene /> + <ContactShadows position={[0, -0.5, 0]} opacity={0.7} scale={10} blur={1} far={10} /> + <OrbitControls enableZoom={true} enablePan={true} enableRotate={true} /> + + </Canvas> + ); } diff --git a/app/components/useAudioSequencer.ts b/app/components/useAudioSequencer.ts @@ -1,212 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; - -interface AudioCacheEntry { - buffer: AudioBuffer; - url: string; // 元のURLを保持 -} - -// Web Audio APIのインスタンスを管理するフック -const useAudioSequencer = (currentAudioUrl: string | null) => { - // AudioContext、再生中のノード、ロード済みのバッファを保持 - const contextRef = useRef<AudioContext | null>(null); - const sourceRef = useRef<AudioBufferSourceNode | null>(null); - const bufferCache = useRef<Map<string, AudioCacheEntry>>(new Map()); - - const [isPlaying, setIsPlaying] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); - - // AudioContextの初期化 - useEffect(() => { - if (typeof globalThis !== 'undefined' && !contextRef.current) { - contextRef.current = new (globalThis.AudioContext)(); - } - }, []); - - // 音源をロードし、AudioBufferにデコードする関数 - const loadAudio = useCallback(async (url: string): Promise<AudioBuffer> => { - if (bufferCache.current.has(url)) { - return bufferCache.current.get(url)!.buffer; - } - - const response = await fetch(url); - const arrayBuffer = await response.arrayBuffer(); - const buffer = await contextRef.current!.decodeAudioData(arrayBuffer); - - bufferCache.current.set(url, { buffer, url }); - return buffer; - }, []); - - // 再生を開始・切り替えするメインロジック - const playSource = ( - buffer: AudioBuffer, - context: AudioContext, - onEndCallback: () => void // 再生終了時に呼び出すコールバック - ) => { - const source = context.createBufferSource(); - source.buffer = buffer; - // source.loop = true; // ❌ 標準のループ機能を無効化して手動で実装。 - - source.connect(context.destination); - - // onended イベントをフックして、手動ループまたはシーケンス制御を行う - source.onended = onEndCallback; - - source.start(0); - return source; - }; - - // ループを解除し、現在の再生が終了するのを待つ - const endLoopAndAwaitCompletion = useCallback((): Promise<void> => { - const source = sourceRef.current; - if (!source || !isPlaying) { - return Promise.resolve(); - } - - // ループを無効化 - source.loop = false; - - return new Promise((resolve) => { - // onended が発火するのを待つ - source.onended = () => { - sourceRef.current = null; - setIsPlaying(false); - resolve(); - }; - }); - }, [isPlaying]); - - // currentAudioUrl の変更監視 - // useAudioSequencer.ts の useEffect の修正 - - useEffect(() => { - let active = true; - let isLooping = true; // 💡 手動ループの状態管理用フラグ - - const context = contextRef.current; - if (!context) return; - - // 💡 関数: 現在の音源が終了したときに呼ばれるコールバック - const handleSourceEnded = () => { - // 現在のノードをクリア - sourceRef.current = null; - - // 1. ループ継続が許可されている場合、即座に同じ音源を再再生(手動ループ) - if (isLooping && currentAudioUrl) { - console.log("-> 手動ループ: 同じ音源を再再生"); - sequencePlayback(currentAudioUrl, true); - } else { - // 2. ループが解除されている場合、外部の待機処理(Promise)を完了させる - // このとき、Promiseの解決は外部のendLoopAndAwaitCompletionで処理されます。 - setIsPlaying(false); - // 何もしないことで、onendedイベントが外部のPromiseを解決するのを待つ - } - }; - - // 💡 関数: 再生シーケンスの開始 - const sequencePlayback = async (url: string, isLoopRestart: boolean = false) => { - if (!active || !context) return; - - try { - const buffer = await loadAudio(url); - - // 💡 停止中の場合は、古いノードを破棄してから新しいノードを作成 - if (!isLoopRestart && sourceRef.current) { - // 新しいURLが来たら、古いノードを停止 - sourceRef.current.stop(context.currentTime); - } - - // 新しい音源の再生開始 - const newSource = playSource(buffer, context, handleSourceEnded); - sourceRef.current = newSource; - setIsPlaying(true); - setIsSwitching(false); - - } catch (error) { - console.error('Audio playback failed:', error); - setIsPlaying(false); - setIsSwitching(false); - } - }; - - // 💡 関数: ループを解除し、現在の再生が終了するのを待つ (Promiseを返す) - const endLoopAndAwaitCompletion = (): Promise<void> => { - if (!sourceRef.current || !isPlaying) { - return Promise.resolve(); - } - - // 💡 重要な修正: ループフラグを false に設定することで、handleSourceEndedで再ループが起こるのを防ぐ - isLooping = false; - - return new Promise((resolve) => { - // 現在再生中のノードが終了した時に resolve するための処理 - // handleSourceEndedが呼ばれ、その中で isLooping=false の処理が走るのを待つ。 - // 💡 ここで onended を直接上書きするのではなく、元の handleSourceEnded の動作に依存させる - sourceRef.current!.onended = () => { - sourceRef.current = null; - setIsPlaying(false); - resolve(); - }; - }); - }; - - const getCurrentAudioUrl = (buffer: AudioBuffer): string | undefined => { - for (const [url, entry] of bufferCache.current.entries()) { - if (entry.buffer === buffer) { - return url; - } - } - return undefined; - }; - - // 💡 メイン処理: currentAudioUrlの変更を監視 - const mainSequence = async () => { - if (!currentAudioUrl) { - sourceRef.current?.stop(); - sourceRef.current = null; - setIsPlaying(false); - return; - } - - setIsSwitching(true); - - // 1. URLが変わった場合、古い音源の終了を待つ - if (sourceRef.current && sourceRef.current.buffer) { - - // 💡 修正されたチェックロジック - const currentUrl = getCurrentAudioUrl(sourceRef.current.buffer); - - // 現在のURLが新しいURLと異なる場合 - if (currentUrl && currentUrl !== currentAudioUrl) { - // ループを切り、終了を待機 - isLooping = false; // ループを停止 - await endLoopAndAwaitCompletion(); - } - } - - if (!active) return; - - // 2. 新しい音源の再生開始 - isLooping = true; - sequencePlayback(currentAudioUrl); - setIsSwitching(false); - }; - - if (currentAudioUrl) { - mainSequence(); - } - - return () => { - active = false; - // ... (クリーンアップ) - }; - }, [currentAudioUrl, loadAudio]); // 依存配列はシンプルに保つ - - return { - isPlaying, - isSwitching, - stop: () => sourceRef.current?.stop(), - endLoopAndAwaitCompletion, - }; -}; - -export default useAudioSequencer; diff --git a/app/ctrl/page.tsx b/app/ctrl/page.tsx @@ -33,8 +33,8 @@ export default function Chat() { setStage(stage + 1); }, []); - /* let StageComponent: React.ReactNode; + console.log("now, stage is ",stage); switch (stage) { case 1: StageComponent = <FirstChat onComplete={handleStageComplete}/>; @@ -48,20 +48,12 @@ export default function Chat() { default: StageComponent = <Error onComplete={handleStageComplete}/>; } - */ - //for debug - const StageComponent: React.ReactNode = <ThreeVR onComplete={handleStageComplete}/>; - const checkFullscreen = () => { const isTargetFullscreen = document.fullscreenElement === pageRef.current; setIsFullscreen(isTargetFullscreen); }; -// const checkStage = () => { -// const stage; -// }; - useEffect(() => { // for fullscreen const element = pageRef.current;