AIdentity

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

commit c940ffcc1f2d96058b4cb7fcd123cf1cc6c337ee
parent 9f87df8f8f455a6b6952bc0569b89cb3c5fb87dd
Author: minerva-jupiter <ryouturn@gmail.com>
Date:   Thu, 13 Nov 2025 20:23:59 +0900

feat(stages): Add completion page and refine Protect Hart game mechanics

Diffstat:
Aapp/complete/page.tsx | 12++++++++++++
Mapp/components/1_chat.tsx | 584+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mapp/components/3_vr.tsx | 460+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mapp/components/4_oil.tsx | 207+++++++++++++++++++++++++++++++++++--------------------------------------------
Mapp/components/5_title.tsx | 97++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mapp/components/6_chat.tsx | 321+++++++++++++++++++++++++++++++++++--------------------------------------------
Mapp/components/7_vr.tsx | 459+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mapp/components/8_protectHart.tsx | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mapp/ctrl/page.tsx | 15+++++++++++++--
Mapp/page.tsx | 95+++++++++++++++++++++++++++++++++++++++----------------------------------------
10 files changed, 1180 insertions(+), 1173 deletions(-)

diff --git a/app/complete/page.tsx b/app/complete/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +export default function Home() { + return ( + <main style={{ paddingTop: "5rem", paddingLeft: "20vw", maxWidth: "60vw" }}> + <h1>AIdentity</h1> + <h5>Op.31 by Minerva_Juppiter</h5> + <br /> + <article></article> + </main> + ); +} diff --git a/app/components/1_chat.tsx b/app/components/1_chat.tsx @@ -1,299 +1,285 @@ -"use client"; -import React, { - KeyboardEvent, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { StageProps } from "../ctrl/page.tsx"; -import init, { chat } from "../../rust-wasm/pkg/rust_wasm.js"; -/** - * カスタムフック: 最初の音源を再生し、終了後にonAudioEndを実行する - */ -const useAudioPlayback = ( - initialAudioId: number, // 1 - audioSources: Record<number, string>, - onAudioEnd: () => void, -) => { - const audioRef = useRef<HTMLAudioElement | null>(null); - const initialAudioUrl = audioSources[initialAudioId] || ""; - - // 外部から再生を停止するための関数 - const stop = useCallback(() => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - } - }, []); - - // 1. Audioオブジェクトの初期化、再生、および終了イベント処理 (一回限りの再生) - useEffect(() => { - if (!initialAudioUrl) { - console.error("Initial audio URL is missing."); - return; - } - - // 既存の音源を停止 - stop(); - - const audio = new Audio(initialAudioUrl); - audioRef.current = audio; - audio.loop = false; // ループはしない - - // 再生終了時のハンドラ: 無条件で onAudioEnd を実行 - const handleEnded = () => { - console.log( - "初期音源の再生が終了しました。onAudioEndを実行します。", - ); - stop(); - onAudioEnd(); // 完了コールバックを実行 - }; - - audio.addEventListener("ended", handleEnded); - - // 自動再生の試行 (ユーザーのインタラクションが必要なため、失敗する可能性あり) - audio.play().catch((e) => { - console.warn( - "Audio playback failed initially. Waiting for user interaction.", - ); - }); - - // クリーンアップ関数 - return () => { - audio.removeEventListener("ended", handleEnded); - audio.pause(); - }; - }, [initialAudioUrl, onAudioEnd, stop]); - - // 外部とのインタフェースは不要になるが、フックの構造を維持 - return { currentAudioId: initialAudioId }; -}; -// ------------------------------------------------------------------ -// 以下、ChatPage およびその他のコンポーネント -// ------------------------------------------------------------------ -const FirstChat: React.FC<StageProps> = ({ onComplete }) => { - return <ChatPage onComplete={onComplete} />; -}; -export default FirstChat; -const AUDIO_SOURCES: Record<number, string> = { - 1: "/audio/001.wav", // 再生される音源はこれのみ -}; -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", - }; - - 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 handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSend(); - } - }; - - const inputStyle: React.CSSProperties = { - flexGrow: 1, - padding: "12px", - border: "1px solid #d1d5db", // gray-300 - borderRadius: "8px", - marginRight: "10px", - fontSize: "16px", - outline: "none", - }; - - const buttonStyle: React.CSSProperties = { - backgroundColor: "#3b82f6", // blue-500 - color: "white", - border: "none", - padding: "12px 20px", - borderRadius: "8px", - cursor: "pointer", - fontWeight: "bold", - }; - - 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: 最初の音源を再生し、終了したら onComplete を呼ぶ - const { currentAudioId } = useAudioPlayback( - 1, // initialAudioId (最初の音源ID) - AUDIO_SOURCES, - onComplete, - ); - - // **注**: talktime の状態は会話継続の要件から削除しました。 - - 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]); - - let ans = dict == undefined - ? "もっとマシなことを言いなさい。" - : chat(dict, text); - if (messages[messages.length - 1].text === ans) { - ans = "は?"; - } - const aiResponse: Message = { - id: Date.now() + 1, - text: ans, - sender: "ai", - }; - setMessages((prev) => [...prev, aiResponse]); - - // **修正**: talktime のインクリメントと終了条件を削除。会話は継続します。 - }, [dict, messages]); // 依存配列から talktimeState を削除 - - 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 headerStyle: React.CSSProperties = { - padding: "15px", - backgroundColor: "#3b82f6", - color: "white", - textAlign: "center", - fontWeight: "bold", - fontSize: "20px", - }; - - const messageListStyle: React.CSSProperties = { - flexGrow: 1, - padding: "15px", - overflowY: "auto", - }; - - 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); - } - return new Uint8Array(0); -} +"use client"; +import React, { + KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { StageProps } from "../ctrl/page.tsx"; +import init, { chat } from "../../rust-wasm/pkg/rust_wasm.js"; +const useAudioPlayback = ( + initialAudioId: number, // 1 + audioSources: Record<number, string>, + onAudioEnd: () => void, +) => { + const audioRef = useRef<HTMLAudioElement | null>(null); + const initialAudioUrl = audioSources[initialAudioId] || ""; + + const stop = useCallback(() => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + }, []); + + useEffect(() => { + if (!initialAudioUrl) { + console.error("Initial audio URL is missing."); + return; + } + + // 既存の音源を停止 + stop(); + + const audio = new Audio(initialAudioUrl); + audioRef.current = audio; + audio.loop = false; // ループはしない + + // 再生終了時のハンドラ: 無条件で onAudioEnd を実行 + const handleEnded = () => { + console.log("初期音源の再生が終了しました。onAudioEndを実行します。"); + stop(); + onAudioEnd(); // 完了コールバックを実行 + }; + + audio.addEventListener("ended", handleEnded); + + audio.play().catch((e) => { + console.log( + "Audio playback failed initially. Waiting for user interaction.", + e, + ); + }); + + // クリーンアップ関数 + return () => { + audio.removeEventListener("ended", handleEnded); + audio.pause(); + }; + }, [initialAudioUrl, onAudioEnd, stop]); + + return { currentAudioId: initialAudioId }; +}; +// ------------------------------------------------------------------ +// 以下、ChatPage およびその他のコンポーネント +// ------------------------------------------------------------------ +const FirstChat: React.FC<StageProps> = ({ onComplete }) => { + return <ChatPage onComplete={onComplete} />; +}; +export default FirstChat; +const AUDIO_SOURCES: Record<number, string> = { + 1: "/audio/001.wav", // 再生される音源はこれのみ +}; +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", + }; + + 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 handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSend(); + } + }; + + const inputStyle: React.CSSProperties = { + flexGrow: 1, + padding: "12px", + border: "1px solid #d1d5db", // gray-300 + borderRadius: "8px", + marginRight: "10px", + fontSize: "16px", + outline: "none", + }; + + const buttonStyle: React.CSSProperties = { + backgroundColor: "#3b82f6", // blue-500 + color: "white", + border: "none", + padding: "12px 20px", + borderRadius: "8px", + cursor: "pointer", + fontWeight: "bold", + }; + + 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: 最初の音源を再生し、終了したら onComplete を呼ぶ + const { currentAudioId } = useAudioPlayback( + 1, // initialAudioId (最初の音源ID) + 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]); + + let ans = + dict == undefined ? "もっとマシなことを言いなさい。" : chat(dict, text); + if (messages[messages.length - 1].text === ans) { + ans = "は?"; + } + const aiResponse: Message = { + id: Date.now() + 1, + text: ans, + sender: "ai", + }; + setMessages((prev) => [...prev, aiResponse]); + }, + [dict, messages], + ); + + 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 headerStyle: React.CSSProperties = { + padding: "15px", + backgroundColor: "#3b82f6", + color: "white", + textAlign: "center", + fontWeight: "bold", + fontSize: "20px", + }; + + const messageListStyle: React.CSSProperties = { + flexGrow: 1, + padding: "15px", + overflowY: "auto", + }; + + 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); + } + return new Uint8Array(0); +} diff --git a/app/components/3_vr.tsx b/app/components/3_vr.tsx @@ -1,231 +1,229 @@ -'use client'; - -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Sky, ContactShadows, Cloud, Clouds } from '@react-three/drei'; -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; - sceneState: number; -} - - -// 状態に応じてライトと霧を制御するコンポーネント -function SceneController({ sceneState }: { sceneState: number }) { - - const isFoggy = sceneState === 2; - - const directionalLightIntensity = isFoggy ? 0.1 : 2; - - const ambientLightIntensity = isFoggy ? 0.05 : 0.5; - - useFrame(({ scene }) => { - if (isFoggy) { - scene.fog = new THREE.Fog(0x444444, 5, 40); // Far distanceも少し短くして密度を上げる - } else { - scene.fog = null; - } - }); - - return ( - <> - <ambientLight intensity={ambientLightIntensity} /> - - <directionalLight - position={[5, 10, 5]} - intensity={directionalLightIntensity} // ここで制御 - 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({sceneState}:{sceneState:number}) { - const [sunPosition, setSunPosition] = useState<[number, number, number]>([0, 100, 0]); - - useFrame(({ clock }) => { - const time = clock.getElapsedTime() * 0.1; - const x = Math.sin(time) * 100; - const z = Math.cos(time) * 100; - setSunPosition([x, 100, z]); - }); - - if (sceneState === 2){ - return null; - } - - return ( - <Sky - distance={450000} - sunPosition={new THREE.Vector3(...sunPosition)} - inclination={sceneState === 2 ? 0.001 : 0.6} - azimuth={0.25} - mieCoefficient={0.005} - mieDirectionalG={0.8} - rayleigh={0.5} - turbidity={10} - /> - ); -} - - -function RandomClouds({ count, radius, height, sceneState }: RandomCloudsProps) { - const cloudColor = sceneState === 2 ? '#080808' : '#fff'; - const baseOpacity = sceneState === 2 ? 0.9 : 0.5; - - const cloudConfigs = useMemo(() => { - return Array.from({ length: count }).map((_, i) => { - const angle = Math.random() * Math.PI * 2; - const distance = Math.sqrt(Math.random()) * radius; - - const x = Math.cos(angle) * distance; - const z = Math.sin(angle) * distance; - - return { - position: [ - x, - height + (Math.random() - 0.5) * 5, - z, - ] as [number, number, number], - - bounds: [ - 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, - opacity: Math.random() * 0.4 + baseOpacity, - color: cloudColor, - }; - }); - }, [count, radius, height]); - - return ( - <> - {cloudConfigs.map((config, index) => ( - <Cloud key={index} {...config} /> - ))} - </> - ); -} - - -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={{ background: sceneState === 2 ? '#85555B' : '#d0e0ff' }} - > - <AnimatedSky sceneState={sceneState}/> - - <Clouds material={THREE.MeshLambertMaterial} limit={200}> - <RandomClouds count={100} radius={80} height={15} sceneState={sceneState}/> - </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> - ); -} +"use client"; + +import { Canvas, useFrame } from "@react-three/fiber"; +import { + OrbitControls, + Sky, + ContactShadows, + Cloud, + Clouds, +} from "@react-three/drei"; +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); + + const currentAudioUrl = audioSources[sceneState]; + + useEffect(() => { + if (sceneState > Math.max(...Object.keys(audioSources).map(Number))) { + console.log("All audio finished. Calling onComplete."); + onComplete(); + return; + } + + if (!currentAudioUrl) { + return; + } + + console.log(`Starting setup for state ${sceneState}: ${currentAudioUrl}`); + + const existingAudio = audioRef.current; + if (existingAudio) { + existingAudio.pause(); + } + + 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); + + audio.play().catch((e) => { + console.warn( + `Audio playback failed for state ${sceneState} (requires user interaction):`, + e, + ); + }); + + return () => { + if (audio === audioRef.current) { + audio.pause(); + audio.removeEventListener("ended", handleAudioEnded); + } + }; + }, [sceneState, audioSources, onComplete]); + return sceneState; +}; + +interface RandomCloudsProps { + count: number; + radius: number; + height: number; + sceneState: number; +} + +function SceneController({ sceneState }: { sceneState: number }) { + const isFoggy = sceneState === 2; + + const directionalLightIntensity = isFoggy ? 0.1 : 2; + + const ambientLightIntensity = isFoggy ? 0.05 : 0.5; + + useFrame(({ scene }) => { + if (isFoggy) { + scene.fog = new THREE.Fog(0x444444, 5, 40); // Far distanceも少し短くして密度を上げる + } else { + scene.fog = null; + } + }); + + return ( + <> + <ambientLight intensity={ambientLightIntensity} /> + + <directionalLight + position={[5, 10, 5]} + intensity={directionalLightIntensity} // ここで制御 + 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({ sceneState }: { sceneState: number }) { + const [sunPosition, setSunPosition] = useState<[number, number, number]>([ + 0, 100, 0, + ]); + + useFrame(({ clock }) => { + const time = clock.getElapsedTime() * 0.1; + const x = Math.sin(time) * 100; + const z = Math.cos(time) * 100; + setSunPosition([x, 100, z]); + }); + + if (sceneState === 2) { + return null; + } + + return ( + <Sky + distance={450000} + sunPosition={new THREE.Vector3(...sunPosition)} + inclination={sceneState === 2 ? 0.001 : 0.6} + azimuth={0.25} + mieCoefficient={0.005} + mieDirectionalG={0.8} + rayleigh={0.5} + turbidity={10} + /> + ); +} + +function RandomClouds({ + count, + radius, + height, + sceneState, +}: RandomCloudsProps) { + const cloudColor = sceneState === 2 ? "#080808" : "#fff"; + const baseOpacity = sceneState === 2 ? 0.9 : 0.5; + + const cloudConfigs = useMemo(() => { + return Array.from({ length: count }).map((_, i) => { + const angle = Math.random() * Math.PI * 2; + const distance = Math.sqrt(Math.random()) * radius; + + const x = Math.cos(angle) * distance; + const z = Math.sin(angle) * distance; + + return { + position: [x, height + (Math.random() - 0.5) * 5, z] as [ + number, + number, + number, + ], + + bounds: [ + 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, + opacity: Math.random() * 0.4 + baseOpacity, + color: cloudColor, + }; + }); + }, [count, radius, height]); + + return ( + <> + {cloudConfigs.map((config, index) => ( + <Cloud key={index} {...config} /> + ))} + </> + ); +} + +export default function ThreeVR({ onComplete }: StageProps) { + const sceneState = useSequentialAudio(1, AUDIO_SOURCES, onComplete); + + return ( + <Canvas + key="r3f-main-canvas" + camera={{ position: [5, 5, 5], fov: 60 }} + shadows + style={{ background: sceneState === 2 ? "#85555B" : "#d0e0ff" }} + > + <AnimatedSky sceneState={sceneState} /> + + <Clouds material={THREE.MeshLambertMaterial} limit={200}> + <RandomClouds + count={100} + radius={80} + height={15} + sceneState={sceneState} + /> + </Clouds> + + <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/4_oil.tsx b/app/components/4_oil.tsx @@ -1,115 +1,92 @@ -import { Canvas } from "@react-three/fiber"; -import { OrbitControls } from "@react-three/drei"; -import { OilArtAPI, OilArtPlane } from "./OilArt/OilArtPlane.tsx"; -import { useEffect, useRef, useState } 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; -}; -export default function OilArtCanvasWrapper({ onComplete }: StageProps) { - const apiRef = useRef<OilArtAPI>({} as OilArtAPI); - const sceneState = useSequentialAudio(1, AUDIO_SOURCES, onComplete); - useEffect(() => { - // 外部API経由でランダムな滴下を定期的に実行 - const intervalId = setInterval(() => { - if (apiRef.current.dropOil) { - const x = Math.random() * 2 - 1; // -1.0〜1.0 - const y = Math.random() * 2 - 1; // -1.0〜1.0 - const color = new THREE.Color( - Math.random(), - Math.random(), - Math.random(), - ); - apiRef.current.dropOil({ x, y }, color); - // 傾きもランダムに変更 - if (sceneState == 2) { - const tiltX = Math.random() * 0.8 - 0.4; - const tiltY = Math.random() * 0.8 - 0.4; - apiRef.current.tilt({ x: tiltX, y: tiltY }); - } else { - apiRef.current.tilt({ x: 0, y: 0 }); - } - } - }, 1500); - return () => clearInterval(intervalId); - }, []); - return ( - <Canvas camera={{ position: [0, 0, 1] }}> - <color attach="background" args={[0x000000]} /> - <OilArtPlane onTriggerAPI={(api) => (apiRef.current = api)} /> - <OrbitControls - enableZoom={false} - enablePan={false} - enableRotate={false} - /> - </Canvas> - ); -} +import { Canvas } from "@react-three/fiber"; +import { OrbitControls } from "@react-three/drei"; +import { OilArtAPI, OilArtPlane } from "./OilArt/OilArtPlane.tsx"; +import { useEffect, useRef, useState } 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); + const currentAudioUrl = audioSources[sceneState]; + useEffect(() => { + if (sceneState > Math.max(...Object.keys(audioSources).map(Number))) { + console.log("All audio finished. Calling onComplete."); + onComplete(); + return; + } + if (!currentAudioUrl) { + return; + } + console.log(`Starting setup for state ${sceneState}: ${currentAudioUrl}`); + const existingAudio = audioRef.current; + if (existingAudio) { + existingAudio.pause(); + } + const audio = new Audio(currentAudioUrl); + audioRef.current = audio; + const handleAudioEnded = () => { + console.log(`Audio ${sceneState} finished. Transitioning state.`); + setSceneState((prev) => prev + 1); + }; + audio.addEventListener("ended", handleAudioEnded); + audio.play().catch((e) => { + console.warn( + `Audio playback failed for state ${sceneState} (requires user interaction):`, + e, + ); + }); + return () => { + if (audio === audioRef.current) { + // 現在設定したAudioオブジェクトであることを確認 + audio.pause(); + audio.removeEventListener("ended", handleAudioEnded); + } + }; + }, [sceneState, audioSources, onComplete]); // sceneStateとcurrentAudioUrlは基本的に連動するため、sceneStateを依存配列に含める + return sceneState; +}; +export default function OilArtCanvasWrapper({ onComplete }: StageProps) { + const apiRef = useRef<OilArtAPI>({} as OilArtAPI); + const sceneState = useSequentialAudio(1, AUDIO_SOURCES, onComplete); + useEffect(() => { + const intervalId = setInterval(() => { + if (apiRef.current.dropOil) { + const x = Math.random() * 2 - 1; // -1.0〜1.0 + const y = Math.random() * 2 - 1; // -1.0〜1.0 + const color = new THREE.Color( + Math.random(), + Math.random(), + Math.random(), + ); + apiRef.current.dropOil({ x, y }, color); + if (sceneState == 2) { + const tiltX = Math.random() * 0.8 - 0.4; + const tiltY = Math.random() * 0.8 - 0.4; + apiRef.current.tilt({ x: tiltX, y: tiltY }); + } else { + apiRef.current.tilt({ x: 0, y: 0 }); + } + } + }, 1500); + return () => clearInterval(intervalId); + }, []); + return ( + <Canvas camera={{ position: [0, 0, 1] }}> + <color attach="background" args={[0x000000]} /> + <OilArtPlane onTriggerAPI={(api) => (apiRef.current = api)} /> + <OrbitControls + enableZoom={false} + enablePan={false} + enableRotate={false} + /> + </Canvas> + ); +} diff --git a/app/components/5_title.tsx b/app/components/5_title.tsx @@ -1,45 +1,52 @@ -import { useEffect, useRef } from "react"; -import { StageProps } from "../ctrl/page.tsx"; - -const AUDIO_SOURCE = '/audio/001.wav'; - -export default function FifthTitle({onComplete}:StageProps){ - const audioRef = useRef<HTMLAudioElement | null>(null); - - useEffect(() => { - const audio = new Audio(AUDIO_SOURCE); - audioRef.current = audio; - - const handleAudioEnded = () => { - onComplete(); - }; - - audio.addEventListener('ended', handleAudioEnded); - - // ユーザーインタラクションの直後に再生を開始 - // コンポーネントがロードされただけではブラウザの制限で再生できないため、 - // ユーザーの最初の描画操作をトリガーとして再生を開始するのがより安全ですが、 - // 今回はシンプルにロード時に再生を試みます。 - 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 ( - <nav style={{width:"100vw",height:"100vh", display:"flex", justifyContent:"center", alignItems:"center"}}> - <h1 style={{fontSize:"8rem"}}> - AIdentity - </h1> - </nav> - ); -}; +import { useEffect, useRef } from "react"; +import { StageProps } from "../ctrl/page.tsx"; + +const AUDIO_SOURCE = "/audio/001.wav"; + +export default function FifthTitle({ onComplete }: StageProps) { + const audioRef = useRef<HTMLAudioElement | null>(null); + + useEffect(() => { + const audio = new Audio(AUDIO_SOURCE); + audioRef.current = audio; + + const handleAudioEnded = () => { + onComplete(); + }; + + audio.addEventListener("ended", handleAudioEnded); + + 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 ( + <nav + style={{ + width: "100vw", + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + > + <h1 style={{ fontSize: "8rem" }}>AIdentity</h1> + </nav> + ); +} diff --git a/app/components/6_chat.tsx b/app/components/6_chat.tsx @@ -1,180 +1,141 @@ -import { - forwardRef, - KeyboardEvent, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; -import { StageProps } from "../ctrl/page.tsx"; -// 質問データ型を拡張し、音源のURLを追加 -export type QuestionWithAudio = { - input: string; // 質問文 - ans: string; // 正しい回答 - audioUrl: string; // 質問に関連付けられた音源のURL -}; -// 親コンポーネントからアクセスするためのRef型 -interface QuestionAnswerProps { - questions: QuestionWithAudio[]; - onComplete: () => void; // 全質問完了時のコールバック -} -const QuestionAnswer: React.FC<QuestionAnswerProps> = ( - { questions, onComplete }, -) => { - // 現在の質問のインデックス - const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - // ユーザーの現在の回答 - const [currentAnswer, setCurrentAnswer] = useState(""); - // エラーメッセージ - const [errorMessage, setErrorMessage] = useState(""); - // Audioオブジェクトのインスタンスを保持するためのRef - const audioRef = useRef<HTMLAudioElement | null>(null); - // **注**: 最新のステートを参照するためのRefは、回答完了時の複雑なロジックを削除するため、ここでは不要になりますが、 - // 回答ロジックのシンプルな遷移のために、念のため残しておきます。 - const latestStateRef = useRef({ - answerLength: 0, - questionIndex: 0, - isAnswerComplete: false, - }); - const currentQuestion = questions[currentQuestionIndex]; - const { input: questionText, ans: correctAnswer, audioUrl } = - currentQuestion; - const nextCorrectKey = correctAnswer[currentAnswer.length]; - // ステートが更新されるたびにRefを更新する (回答完了判定には使わない) - useEffect(() => { - latestStateRef.current = { - answerLength: currentAnswer.length, - questionIndex: currentQuestionIndex, - isAnswerComplete: currentAnswer.length === correctAnswer.length, - }; - }, [currentAnswer.length, currentQuestionIndex]); - useEffect(() => { - // 既存のAudioがあれば、onendedリスナーを解除して停止 - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - audioRef.current.onended = null; - } - // 新しいAudioオブジェクトを作成または既存のものを使用 - const audio = audioRef.current || new Audio(); - audioRef.current = audio; - // 新しい音源をロード - audio.src = audioUrl; - audio.load(); - // 再生終了時のハンドラ: 無条件に次の質問へ強制移行するロジックに変更 - audio.onended = (event: Event) => { - // 再生終了時の最新の質問インデックスを取得 - const currentQuestionIdx = latestStateRef.current.questionIndex; - const nextIndex = currentQuestionIdx + 1; - if (nextIndex < questions.length) { - // 次の質問へ (音源再生終了を待って強制的に移行) - setCurrentQuestionIndex(nextIndex); - setCurrentAnswer(""); - setErrorMessage(""); - } else { - // 最後の質問の音源再生終了後、onCompleteを実行して終了 - onComplete(); - } - }; - // 新しい音源の再生を開始 - audio.play().catch((e) => - console.error("Audio playback failed on initial play:", e) - ); - // クリーンアップ関数 - return () => { - if (audioRef.current) { - audioRef.current.onended = null; - audioRef.current.pause(); - } - }; - }, [currentQuestionIndex, questions.length, audioUrl]); // 依存配列は音源切り替えに必要なもののみ - if (!currentQuestion) { - return <div className="p-4">🎉 すべての質問が完了しました!</div>; - } - // キー入力時のハンドラ: 回答ロジックのみを保持し、音源制御は削除 - const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { - // エンターキーなど、特殊なキーのデフォルト動作を防ぐ - if (e.key === "Enter") { - e.preventDefault(); - return; - } - // 回答が既に完了している場合は入力を無視 - if (currentAnswer.length >= correctAnswer.length) { - e.preventDefault(); - return; - } - if (e.key === nextCorrectKey) { - e.preventDefault(); // デフォルトの入力をキャンセル - setErrorMessage(""); // エラーをクリア - const newAnswer = currentAnswer + e.key; - setCurrentAnswer(newAnswer); - // 回答完了後も、音源が終了するまで質問は切り替わらない - if (newAnswer.length === correctAnswer.length) { - // 回答完了時の視覚的な遅延のみを保持 (音源制御は行わない) - // *注意*: 音源終了時に自動で次の質問へ進むため、ここでは何もしません - } - } else { - e.preventDefault(); // デフォルトの入力をキャンセル - setErrorMessage("🚫 その回答は正しくありません"); - } - }; - return ( - <article style={{}}> - <p style={{ textAlign: "center", fontSize: "5rem" }}> - {questionText} - </p> - <div style={{ textAlign: "center" }}> - <input - type="text" - value={currentAnswer} - onKeyDown={handleKeyDown} - readOnly // カスタムロジックで値をセットするため、readonlyにする - placeholder={correctAnswer.split("").map(() => "_") - .join(" ")} - autoFocus // 自動フォーカス - style={{ fontSize: "2rem" }} - /> - {/* 回答が正しい場合に緑色の枠線を表示 */} - {currentAnswer.length === correctAnswer.length && <span></span>} - </div> - {errorMessage && ( - <p> - {errorMessage} - </p> - )} - <p style={{ textAlign: "center", padding: "5vh" }}> - (日本語の回答は**ヘボン式ローマ字**のキー入力のみを想定しています) - </p> - </article> - ); -}; -const myQuestions: QuestionWithAudio[] = [ - { input: "5 + 9 = ?", ans: "14", audioUrl: "/audio/001.wav" }, // 実際には適切なURLに置き換えてください - { - input: "東京のローマ字表記は?", - ans: "tokyo", - audioUrl: "/audio/002.wav", - }, - { input: "Next.jsの親要素は?", ans: "react", audioUrl: "/audio/003.wav" }, -]; -export default function QuizPage({ onComplete }: StageProps) { - const handleQuizComplete = () => { - onComplete(); - }; - return ( - <div - style={{ - width: "100vw", - height: "100vh", - display: "flex", - justifyContent: "center", - alignItems: "center", - }} - > - <QuestionAnswer - questions={myQuestions} - onComplete={handleQuizComplete} - /> - </div> - ); -} +import { KeyboardEvent, useEffect, useRef, useState } from "react"; +import { StageProps } from "../ctrl/page.tsx"; +export type QuestionWithAudio = { + input: string; // 質問文 + ans: string; // 正しい回答 + audioUrl: string; // 質問に関連付けられた音源のURL +}; +interface QuestionAnswerProps { + questions: QuestionWithAudio[]; + onComplete: () => void; // 全質問完了時のコールバック +} +const QuestionAnswer: React.FC<QuestionAnswerProps> = ({ + questions, + onComplete, +}) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [currentAnswer, setCurrentAnswer] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const audioRef = useRef<HTMLAudioElement | null>(null); + const latestStateRef = useRef({ + answerLength: 0, + questionIndex: 0, + isAnswerComplete: false, + }); + const currentQuestion = questions[currentQuestionIndex]; + const { input: questionText, ans: correctAnswer, audioUrl } = currentQuestion; + const nextCorrectKey = correctAnswer[currentAnswer.length]; + useEffect(() => { + latestStateRef.current = { + answerLength: currentAnswer.length, + questionIndex: currentQuestionIndex, + isAnswerComplete: currentAnswer.length === correctAnswer.length, + }; + }, [currentAnswer.length, currentQuestionIndex]); + useEffect(() => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + audioRef.current.onended = null; + } + const audio = audioRef.current || new Audio(); + audioRef.current = audio; + audio.src = audioUrl; + audio.load(); + audio.onended = (event: Event) => { + const currentQuestionIdx = latestStateRef.current.questionIndex; + const nextIndex = currentQuestionIdx + 1; + if (nextIndex < questions.length) { + setCurrentQuestionIndex(nextIndex); + setCurrentAnswer(""); + setErrorMessage(""); + } else { + onComplete(); + } + }; + audio + .play() + .catch((e) => console.error("Audio playback failed on initial play:", e)); + return () => { + if (audioRef.current) { + audioRef.current.onended = null; + audioRef.current.pause(); + } + }; + }, [currentQuestionIndex, questions.length, audioUrl]); // 依存配列は音源切り替えに必要なもののみ + if (!currentQuestion) { + return <div className="p-4">🎉 すべての質問が完了しました!</div>; + } + const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + e.preventDefault(); + return; + } + if (currentAnswer.length >= correctAnswer.length) { + e.preventDefault(); + return; + } + if (e.key === nextCorrectKey) { + e.preventDefault(); // デフォルトの入力をキャンセル + setErrorMessage(""); // エラーをクリア + const newAnswer = currentAnswer + e.key; + setCurrentAnswer(newAnswer); + if (newAnswer.length === correctAnswer.length) { + } + } else { + e.preventDefault(); // デフォルトの入力をキャンセル + setErrorMessage("🚫 その回答は正しくありません"); + } + }; + return ( + <article style={{}}> + <p style={{ textAlign: "center", fontSize: "5rem" }}>{questionText}</p> + <div style={{ textAlign: "center" }}> + <input + type="text" + value={currentAnswer} + onKeyDown={handleKeyDown} + readOnly // カスタムロジックで値をセットするため、readonlyにする + placeholder={correctAnswer + .split("") + .map(() => "_") + .join(" ")} + autoFocus // 自動フォーカス + style={{ fontSize: "2rem" }} + /> + {currentAnswer.length === correctAnswer.length && <span></span>} + </div> + {errorMessage && <p>{errorMessage}</p>} + <p style={{ textAlign: "center", padding: "5vh" }}> + (日本語の回答は**ヘボン式ローマ字**のキー入力のみを想定しています) + </p> + </article> + ); +}; +const myQuestions: QuestionWithAudio[] = [ + { input: "5 + 9 = ?", ans: "14", audioUrl: "/audio/001.wav" }, // 実際には適切なURLに置き換えてください + { + input: "東京のローマ字表記は?", + ans: "tokyo", + audioUrl: "/audio/002.wav", + }, + { input: "Next.jsの親要素は?", ans: "react", audioUrl: "/audio/003.wav" }, +]; +export default function QuizPage({ onComplete }: StageProps) { + const handleQuizComplete = () => { + onComplete(); + }; + return ( + <div + style={{ + width: "100vw", + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + > + <QuestionAnswer questions={myQuestions} onComplete={handleQuizComplete} /> + </div> + ); +} diff --git a/app/components/7_vr.tsx b/app/components/7_vr.tsx @@ -1,230 +1,229 @@ -'use client'; - -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, Sky, ContactShadows, Cloud, Clouds } from '@react-three/drei'; -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> = { - 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; - sceneState: number; -} - - -// 状態に応じてライトと霧を制御するコンポーネント -function SceneController({ sceneState }: { sceneState: number }) { - - const isFoggy = sceneState === 2; - - const directionalLightIntensity = isFoggy ? 0.1 : 2; - - const ambientLightIntensity = isFoggy ? 0.05 : 0.5; - - useFrame(({ scene }) => { - if (isFoggy) { - scene.fog = new THREE.Fog(0x444444, 5, 40); // Far distanceも少し短くして密度を上げる - } else { - scene.fog = null; - } - }); - - return ( - <> - <ambientLight intensity={ambientLightIntensity} /> - - <directionalLight - position={[5, 10, 5]} - intensity={directionalLightIntensity} // ここで制御 - 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({sceneState}:{sceneState:number}) { - const [sunPosition, setSunPosition] = useState<[number, number, number]>([0, 100, 0]); - - useFrame(({ clock }) => { - const time = clock.getElapsedTime() * 0.1; - const x = Math.sin(time) * 100; - const z = Math.cos(time) * 100; - setSunPosition([x, 100, z]); - }); - - if (sceneState === 2){ - return null; - } - - return ( - <Sky - distance={450000} - sunPosition={new THREE.Vector3(...sunPosition)} - inclination={sceneState === 2 ? 0.001 : 0.6} - azimuth={0.25} - mieCoefficient={0.005} - mieDirectionalG={0.8} - rayleigh={0.5} - turbidity={10} - /> - ); -} - - -function RandomClouds({ count, radius, height, sceneState }: RandomCloudsProps) { - const cloudColor = sceneState === 2 ? '#080808' : '#fff'; - const baseOpacity = sceneState === 2 ? 0.9 : 0.5; - - const cloudConfigs = useMemo(() => { - return Array.from({ length: count }).map((_, i) => { - const angle = Math.random() * Math.PI * 2; - const distance = Math.sqrt(Math.random()) * radius; - - const x = Math.cos(angle) * distance; - const z = Math.sin(angle) * distance; - - return { - position: [ - x, - height + (Math.random() - 0.5) * 5, - z, - ] as [number, number, number], - - bounds: [ - 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, - opacity: Math.random() * 0.4 + baseOpacity, - color: cloudColor, - }; - }); - }, [count, radius, height]); - - return ( - <> - {cloudConfigs.map((config, index) => ( - <Cloud key={index} {...config} /> - ))} - </> - ); -} - - -export default function ThreeVR({onComplete}:StageProps) { - // 💡 状態定義: number型 - const sceneState = useSequentialAudio(2, AUDIO_SOURCES, onComplete); - - return ( - <Canvas - key="r3f-main-canvas" - camera={{ position: [5, 5, 5], fov: 60 }} - shadows - // 霧の状態によってクリアカラー(背景色)を変える - style={{ background: sceneState === 2 ? '#050507' : '#d0e0ff' }} - > - <AnimatedSky sceneState={sceneState}/> - - <Clouds material={THREE.MeshLambertMaterial} limit={200}> - <RandomClouds count={100} radius={80} height={15} sceneState={sceneState}/> - </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> - ); -} +"use client"; + +import { Canvas, useFrame } from "@react-three/fiber"; +import { + OrbitControls, + Sky, + ContactShadows, + Cloud, + Clouds, +} from "@react-three/drei"; +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> = { + 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); + + const currentAudioUrl = audioSources[sceneState]; + + useEffect(() => { + if (sceneState > Math.max(...Object.keys(audioSources).map(Number))) { + console.log("All audio finished. Calling onComplete."); + onComplete(); + return; + } + + if (!currentAudioUrl) { + return; + } + + console.log(`Starting setup for state ${sceneState}: ${currentAudioUrl}`); + + const existingAudio = audioRef.current; + if (existingAudio) { + existingAudio.pause(); + } + + const audio = new Audio(currentAudioUrl); + audioRef.current = audio; + + const handleAudioEnded = () => { + console.log(`Audio ${sceneState} finished. Transitioning state.`); + setSceneState((prev) => prev + 1); + }; + + audio.addEventListener("ended", handleAudioEnded); + + audio.play().catch((e) => { + console.warn( + `Audio playback failed for state ${sceneState} (requires user interaction):`, + e, + ); + }); + + return () => { + 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; + sceneState: number; +} + +function SceneController({ sceneState }: { sceneState: number }) { + const isFoggy = sceneState === 2; + + const directionalLightIntensity = isFoggy ? 0.1 : 2; + + const ambientLightIntensity = isFoggy ? 0.05 : 0.5; + + useFrame(({ scene }) => { + if (isFoggy) { + scene.fog = new THREE.Fog(0x444444, 5, 40); // Far distanceも少し短くして密度を上げる + } else { + scene.fog = null; + } + }); + + return ( + <> + <ambientLight intensity={ambientLightIntensity} /> + + <directionalLight + position={[5, 10, 5]} + intensity={directionalLightIntensity} // ここで制御 + 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({ sceneState }: { sceneState: number }) { + const [sunPosition, setSunPosition] = useState<[number, number, number]>([ + 0, 100, 0, + ]); + + useFrame(({ clock }) => { + const time = clock.getElapsedTime() * 0.1; + const x = Math.sin(time) * 100; + const z = Math.cos(time) * 100; + setSunPosition([x, 100, z]); + }); + + if (sceneState === 2) { + return null; + } + + return ( + <Sky + distance={450000} + sunPosition={new THREE.Vector3(...sunPosition)} + inclination={sceneState === 2 ? 0.001 : 0.6} + azimuth={0.25} + mieCoefficient={0.005} + mieDirectionalG={0.8} + rayleigh={0.5} + turbidity={10} + /> + ); +} + +function RandomClouds({ + count, + radius, + height, + sceneState, +}: RandomCloudsProps) { + const cloudColor = sceneState === 2 ? "#080808" : "#fff"; + const baseOpacity = sceneState === 2 ? 0.9 : 0.5; + + const cloudConfigs = useMemo(() => { + return Array.from({ length: count }).map((_, i) => { + const angle = Math.random() * Math.PI * 2; + const distance = Math.sqrt(Math.random()) * radius; + + const x = Math.cos(angle) * distance; + const z = Math.sin(angle) * distance; + + return { + position: [x, height + (Math.random() - 0.5) * 5, z] as [ + number, + number, + number, + ], + + bounds: [ + 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, + opacity: Math.random() * 0.4 + baseOpacity, + color: cloudColor, + }; + }); + }, [count, radius, height]); + + return ( + <> + {cloudConfigs.map((config, index) => ( + <Cloud key={index} {...config} /> + ))} + </> + ); +} + +export default function ThreeVR({ onComplete }: StageProps) { + const sceneState = useSequentialAudio(2, AUDIO_SOURCES, onComplete); + + return ( + <Canvas + key="r3f-main-canvas" + camera={{ position: [5, 5, 5], fov: 60 }} + shadows + style={{ background: sceneState === 2 ? "#050507" : "#d0e0ff" }} + > + <AnimatedSky sceneState={sceneState} /> + + <Clouds material={THREE.MeshLambertMaterial} limit={200}> + <RandomClouds + count={100} + radius={80} + height={15} + sceneState={sceneState} + /> + </Clouds> + + <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/8_protectHart.tsx b/app/components/8_protectHart.tsx @@ -126,21 +126,21 @@ const SPAWN_LIST: Omit<FlyingObject, "id" | "isHit">[] = [ when: 4000, content: "馬鹿なの?", radius: 25, - position: { x: 0.4, y: 0.0 }, + position: { x: 0.5, y: 0.0 }, velocity: { x: 0.0, y: FLY_SPEED_BASE * 2.5 }, }, { when: 4300, content: "恥ずかしい", radius: 25, - position: { x: 0.5, y: 0.0 }, + position: { x: 0.6, y: 0.0 }, velocity: { x: 0.01, y: FLY_SPEED_BASE * 2.5 }, }, { when: 4600, content: "言うことを聞きなさい", radius: 25, - position: { x: 0.2, y: 0.0 }, + position: { x: 0.4, y: 0.0 }, velocity: { x: -0.01, y: FLY_SPEED_BASE * 2.5 }, }, { @@ -182,6 +182,7 @@ const useGameLoop = (canvasRef: React.RefObject<HTMLCanvasElement | null>) => { ) => { const canvas = canvasRef.current; if (!canvas) return; + const ctx = canvas.getContext("2d"); while ( scheduledObjectsRef.current.length > 0 && scheduledObjectsRef.current[0].when <= state.totalTime @@ -203,39 +204,93 @@ const useGameLoop = (canvasRef: React.RefObject<HTMLCanvasElement | null>) => { obj.position.x += obj.velocity.x * deltaTime * 0.001; obj.position.y += obj.velocity.y * deltaTime * 0.001; - const dxMouse = obj.position.x - state.mousePosition.x; - const dyMouse = obj.position.y - state.mousePosition.y; - const distanceMouse = Math.hypot(dxMouse, dyMouse); // マウスからの距離 + if (ctx) { + ctx.font = `${obj.radius * 1.5}px sans-serif`; + } + const textWidth = ctx + ? ctx.measureText(obj.content).width + : obj.radius * 2; + const textHeight = obj.radius * 1.5; // 高さの近似値 (radius * 1.5 = font-size) + + const halfWidth = textWidth / 2; + const halfHeight = textHeight / 2; + + const dxMouse = Math.abs(obj.position.x - state.mousePosition.x); + const dyMouse = Math.abs(obj.position.y - state.mousePosition.y); + + const closestX = Math.max( + obj.position.x - halfWidth, + Math.min(state.mousePosition.x, obj.position.x + halfWidth), + ); + const closestY = Math.max( + obj.position.y - halfHeight, + Math.min(state.mousePosition.y, obj.position.y + halfHeight), + ); + + const dxClosest = state.mousePosition.x - closestX; + const dyClosest = state.mousePosition.y - closestY; + const distanceMouse = Math.hypot(dxClosest, dyClosest); - if (distanceMouse < MOUSE_REPEL_RADIUS + obj.radius) { - const overlap = MOUSE_REPEL_RADIUS + obj.radius - distanceMouse; - const repelFactor = overlap / (MOUSE_REPEL_RADIUS + obj.radius); + const effectiveRadius = 5; // マウスカーソル自体の半径の近似値 - const PUSH_STRENGTH = 200; // 👈 調整可能な定数 (大きめに設定) + if (distanceMouse < MOUSE_REPEL_RADIUS + effectiveRadius) { + if ( + dxMouse < halfWidth + effectiveRadius && + dyMouse < halfHeight + effectiveRadius + ) { + } + } + + const dxMouseCenter = obj.position.x - state.mousePosition.x; + const dyMouseCenter = obj.position.y - state.mousePosition.y; + const distanceMouseCenter = Math.hypot(dxMouseCenter, dyMouseCenter); + + const effectiveObjectRadius = Math.max(halfWidth, halfHeight); + const totalRepelRadius = MOUSE_REPEL_RADIUS + effectiveObjectRadius; + + if (distanceMouseCenter < totalRepelRadius) { + const overlap = totalRepelRadius - distanceMouseCenter; + const repelFactor = overlap / totalRepelRadius; // 衝突の中心に近いほど1.0に近づく + + const PUSH_STRENGTH = 9000; // 🔥 数値を大幅に増やして強く弾く - const repelX = dxMouse * repelFactor * PUSH_STRENGTH; - const repelY = dyMouse * repelFactor * PUSH_STRENGTH; + const repelX = dxMouseCenter * repelFactor * PUSH_STRENGTH; + const repelY = dyMouseCenter * repelFactor * PUSH_STRENGTH; - const accelerationFactor = 0.5; // 速度変化の感度 (調整可能) + const accelerationFactor = 0.000005; obj.velocity.x += repelX * accelerationFactor; - obj.velocity.y += repelY * accelerationFactor; // オブジェクトが押し出された後、マウスと重ならないように位置を少し修正 (前回と同じロジック) + obj.velocity.y += repelY * accelerationFactor; - if (overlap > 0 && distanceMouse > 0) { - const adjustX = (dxMouse / distanceMouse) * overlap; - const adjustY = (dyMouse / distanceMouse) * overlap; + if (overlap > 0 && distanceMouseCenter > 0) { + const adjustX = (dxMouseCenter / distanceMouseCenter) * overlap; + const adjustY = (dyMouseCenter / distanceMouseCenter) * overlap; obj.position.x += adjustX; obj.position.y += adjustY; } } - const dxHeart = obj.position.x - state.heartCenter.x; - const dyHeart = obj.position.y - state.heartCenter.y; - if ( - Math.hypot(dxHeart, dyHeart) < state.heartRadius + obj.radius && - !obj.isHit - ) { + const heartClosestX = Math.max( + obj.position.x - halfWidth, + Math.min(state.heartCenter.x, obj.position.x + halfWidth), + ); + const heartClosestY = Math.max( + obj.position.y - halfHeight, + Math.min(state.heartCenter.y, obj.position.y + halfHeight), + ); + + const dxHeart = state.heartCenter.x - heartClosestX; + const dyHeart = state.heartCenter.y - heartClosestY; + const distanceHeart = Math.hypot(dxHeart, dyHeart); + + if (distanceHeart < state.heartRadius && !obj.isHit) { obj.isHit = true; + /* + console.log("--- COLLISION DETECTED! ---"); + console.log("Flying Object ID:", obj.id, "Content:", obj.content); + console.log("New Distortion Level:", state.distortionLevel + 0.05); + */ + state.distortionLevel = Math.min(1.0, state.distortionLevel + 0.05); } @@ -258,6 +313,8 @@ const useGameLoop = (canvasRef: React.RefObject<HTMLCanvasElement | null>) => { state.flyingObjects.forEach((obj) => { ctx.fillStyle = "white"; ctx.font = `${obj.radius * 1.5}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; ctx.fillText(obj.content, obj.position.x, obj.position.y); }); diff --git a/app/ctrl/page.tsx b/app/ctrl/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; export interface StageProps { onComplete: () => void; @@ -43,7 +44,7 @@ const SixthChat = dynamic<StageProps>( const SeventhVR = dynamic<StageProps>(() => import("../components/7_vr.tsx"), { loading: Loading, }); -const EighthLightActivity = dynamic<StageProps>( +const EighthProtect = dynamic<StageProps>( () => import("../components/8_protectHart.tsx"), { loading: Loading, @@ -61,6 +62,13 @@ export default function Play() { const handleStageComplete = useCallback(() => { setStage((prevStage) => prevStage + 1); }, []); + const router = useRouter(); + + useEffect(() => { + if (stage === 9) { + router.push("/complete"); + } + }); let StageComponent: React.ReactNode; console.log("now, stage is ", stage); @@ -87,7 +95,10 @@ export default function Play() { StageComponent = <SeventhVR onComplete={handleStageComplete} />; break; case 8: - StageComponent = <EighthLightActivity onComplete={handleStageComplete} />; + StageComponent = <EighthProtect onComplete={handleStageComplete} />; + break; + case 9: + StageComponent = <Loading />; break; default: StageComponent = <Error onComplete={handleStageComplete} />; diff --git a/app/page.tsx b/app/page.tsx @@ -1,58 +1,57 @@ -'use client' +"use client"; import { useEffect, useState } from "react"; import { isWasmSupported } from "./components/wasmCheck.ts"; const WasmCheck: React.FC = () => { - const [isSupported, setIsSupported] = useState<boolean | null>(null); - useEffect(()=>{ - setIsSupported(isWasmSupported()); - },[]); - if (isSupported === null){ - return null; - } - if (isSupported === false){ - return ( - <h3>WASMがサポートされていません。</h3> - ); - } - return null; -} + const [isSupported, setIsSupported] = useState<boolean | null>(null); + useEffect(() => { + setIsSupported(isWasmSupported()); + }, []); + if (isSupported === null) { + return null; + } + if (isSupported === false) { + return <h3>WASMがサポートされていません。</h3>; + } + return null; +}; export default function Home() { - return ( - <main style={{paddingTop: "5rem", paddingLeft: "20vw", maxWidth: "60vw"}}> - <h1> - AIdentity - </h1> - <h5> - Op.31 by Minerva_Juppiter - </h5> - <br /> + return ( + <main style={{ paddingTop: "5rem", paddingLeft: "20vw", maxWidth: "60vw" }}> + <h1>AIdentity</h1> + <h5>Op.31 by Minerva_Juppiter</h5> + <br /> - <article style={{textAlign: 'center'}}> - <h3> - これはOp.31の全貌です。 - </h3> - - <a style={{textAlign: 'center', color: 'var(--foreground)', textDecorationLine: 'none', fontSize: '10rem'}} href='/ctrl'>Play</a> + <article style={{ textAlign: "center" }}> + <h3>これはOp.31の全貌です。</h3> - </article> + <a + style={{ + textAlign: "center", + color: "var(--foreground)", + textDecorationLine: "none", + fontSize: "10rem", + }} + href="/ctrl" + > + Play + </a> + </article> - <br /> - <article> - <h2> - 注意:Attention - </h2> - <ul> - <li>音が出ます。</li> - <li>フルスクリーンを要求します。</li> - <li>JavaScript及びWASMを使用します。有効になっているか確認してください。</li> - <noscript> - JavaScriptが無効です。 - </noscript> - <WasmCheck/> - </ul> - </article> - </main> - ); + <br /> + <article> + <h2>注意:Attention</h2> + <ul> + <li>音が出ます。</li> + <li>フルスクリーンを要求します。</li> + <li> + JavaScript及びWASMを使用します。有効になっているか確認してください。 + </li> + <noscript>JavaScriptが無効です。</noscript> + <WasmCheck /> + </ul> + </article> + </main> + ); }