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:
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>
+ );
}