AIdentity

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

commit b6d9942f86cbe19b53344cc6dd31a1ff576d4ca7
parent 56597476bab06a521c55160aa074d9923f444d9f
Author: minerva-jupiter <ryouturn@gmail.com>
Date:   Sun,  9 Nov 2025 22:48:27 +0900

feat(game): Implement 'Protect Heart' canvas game for Stage 8

This new component replaces the previous Stage 8 (kaleidoscope) and
introduces an interactive canvas game where players use the mouse to
repel flying objects and protect a central heart.

Additionally, this commit includes minor dependency updates for
@sentry/nextjs and eslint, and adds type definitions for a custom R3F
water shader material.

Diffstat:
Aapp/components/8_protectHart.tsx | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/ctrl/page.tsx | 310+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mbun.lock | 5+++--
Mtsconfig.json | 2+-
Mtypes/r3f-custom.d.ts | 22+++++++++++++++++++++-
5 files changed, 576 insertions(+), 145 deletions(-)

diff --git a/app/components/8_protectHart.tsx b/app/components/8_protectHart.tsx @@ -0,0 +1,382 @@ +"use client"; + +import React, { useRef, useCallback, useEffect } from "react"; + +export type Point = { + x: number; + y: number; +}; + +export type FlyingObject = { + id: number; + position: Point; + velocity: Point; // vx, vy + content: string; + radius: number; + isHit: boolean; + when: number; +}; + +export type GameState = { + heartCenter: Point; + heartRadius: number; + distortionLevel: number; // 歪み度合い (0.0 ~ 1.0) + flyingObjects: FlyingObject[]; + mousePosition: Point; + isGameOver: boolean; + lastTime: number | undefined; + totalTime: number; +}; + +// --- 設定値 --- +const HEART_RADIUS = 50; +const MOUSE_REPEL_RADIUS = 40; +const FLY_SPEED = 2; +const MAX_OBJECTS = 15; + +// (省略: 飛来物生成や衝突判定などのヘルパー関数はここでは割愛) +const HEART_IMAGE_PATHS = [ + "/images/heart_0.png", // distortionLevel 0.0-0.2 + "/images/heart_1.png", // distortionLevel 0.2-0.4 + "/images/heart_2.png", // distortionLevel 0.4-0.6 + "/images/heart_3.png", // distortionLevel 0.6-0.8 + "/images/heart_4.png", // distortionLevel 0.8-1.0 +]; + +const heartImages = new Map<string, HTMLImageElement>(); +let imagesLoaded = false; // 全ての画像が読み込まれたかのフラグ + +const preloadImages = async () => { + if (imagesLoaded) return; // 既に読み込み済みなら何もしない + + const promises = HEART_IMAGE_PATHS.map((path) => { + return new Promise<void>((resolve, reject) => { + const img = new Image(); + img.src = path; + img.onload = () => { + heartImages.set(path, img); + resolve(); + }; + img.onerror = reject; + }); + }); + + try { + await Promise.all(promises); + imagesLoaded = true; + console.log("All heart images loaded."); + } catch (error) { + console.error("Failed to load heart images:", error); + } +}; + +const drawHeart = (ctx: CanvasRenderingContext2D, state: GameState) => { + const { x, y } = state.heartCenter; + const R = state.heartRadius; // このRは画像のサイズ調整に使われる + + const imageIndex = Math.min( + HEART_IMAGE_PATHS.length - 1, + Math.floor(state.distortionLevel * HEART_IMAGE_PATHS.length), + ); + const imagePath = HEART_IMAGE_PATHS[imageIndex]; + const heartImage = heartImages.get(imagePath); + + if (heartImage && imagesLoaded) { + const drawX = x - heartImage.width / 2; + const drawY = y - heartImage.height / 2; + ctx.drawImage( + heartImage, + drawX, + drawY, + heartImage.width, + heartImage.height, + ); + } else { + ctx.fillStyle = "red"; + ctx.fillRect(x - R / 2, y - R / 2, R, R); + } +}; + +const FLY_SPEED_BASE = 0.05; // 速度の基準値 (deltaTimeとの積で調整) + +const SPAWN_LIST: Omit<FlyingObject, "id" | "isHit">[] = [ + { + when: 1000, + content: "H", + radius: 20, + position: { x: 0.1, y: 0.1 }, + velocity: { x: FLY_SPEED_BASE, y: FLY_SPEED_BASE }, + }, + { + when: 2500, + content: "W", + radius: 20, + position: { x: 0.9, y: 0.1 }, + velocity: { x: -FLY_SPEED_BASE, y: FLY_SPEED_BASE }, + }, + { + when: 3200, + content: "X", + radius: 20, + position: { x: 0.5, y: 1.0 }, + velocity: { x: 0.0, y: -FLY_SPEED_BASE * 1.5 }, + }, + { + when: 4000, + content: "!!", + radius: 25, + position: { x: 0.0, y: 0.5 }, + velocity: { x: FLY_SPEED_BASE, y: 0.0 }, + }, +]; + +const useGameLoop = (canvasRef: React.RefObject<HTMLCanvasElement | null>) => { + // ゲームの状態をuseRefで保持し、再レンダーに依存しないようにする + const gameStateRef = useRef<GameState>({ + heartCenter: { x: 0, y: 0 }, + heartRadius: HEART_RADIUS, + distortionLevel: 0, + flyingObjects: [], + mousePosition: { x: -100, y: -100 }, // 初期値は画面外 + isGameOver: false, + lastTime: undefined, + totalTime: 0, + }); + + const scheduledObjectsRef = useRef<FlyingObject[]>( + JSON.parse( + JSON.stringify( + SPAWN_LIST.map((obj, index) => ({ ...obj, id: index, isHit: false })), + ), + ), + ); + + // requestAnimationFrameのIDを保持 + const animationFrameId = useRef<number | undefined>(undefined); + + // --- ゲーム更新ロジック --- + const updateGame = (state: GameState, deltaTime: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + while ( + scheduledObjectsRef.current.length > 0 && + scheduledObjectsRef.current[0].when <= state.totalTime + ) { + const objToSpawn = scheduledObjectsRef.current.shift(); // 先頭を抜き取る + + if (objToSpawn) { + objToSpawn.position.x = objToSpawn.position.x * canvas.width; + objToSpawn.position.y = objToSpawn.position.y * canvas.height; + + objToSpawn.velocity.x = objToSpawn.velocity.x * canvas.width; + objToSpawn.velocity.y = objToSpawn.velocity.y * canvas.height; + + // 飛来中オブジェクトリストに、新しくスポーンしたオブジェクトを追加 + state.flyingObjects.push(objToSpawn); + } + } + state.flyingObjects = state.flyingObjects + .map((obj) => { + // ハートに向かって移動 + obj.position.x += obj.velocity.x * deltaTime * 0.001; + obj.position.y += obj.velocity.y * deltaTime * 0.001; + + // a. マウスとの衝突判定(弾くロジック) + const dxMouse = obj.position.x - state.mousePosition.x; + const dyMouse = obj.position.y - state.mousePosition.y; + const distanceMouse = Math.hypot(dxMouse, dyMouse); // マウスからの距離 + + if (distanceMouse < MOUSE_REPEL_RADIUS + obj.radius) { + // 弾かれた後に止まらないように、現在の速度ベクトルに反発力を加算する + + // 衝突の中心に近いほど反発が強くなる係数を計算 (0.0 ~ 1.0) + const overlap = MOUSE_REPEL_RADIUS + obj.radius - distanceMouse; + const repelFactor = overlap / (MOUSE_REPEL_RADIUS + obj.radius); + + // 反発力の強さの基準 (値を大きくするとより勢いよく弾かれる) + const PUSH_STRENGTH = 200; // 👈 調整可能な定数 (大きめに設定) + + // 反発力のベクトルを計算 + const repelX = dxMouse * repelFactor * PUSH_STRENGTH; + const repelY = dyMouse * repelFactor * PUSH_STRENGTH; + + // 🔥 修正: 現在の速度に反発力を加える(完全上書きを避ける) + // deltaTimeで調整するため、速度の変化量として加える + const accelerationFactor = 0.5; // 速度変化の感度 (調整可能) + + obj.velocity.x += repelX * accelerationFactor; + obj.velocity.y += repelY * accelerationFactor; // オブジェクトが押し出された後、マウスと重ならないように位置を少し修正 (前回と同じロジック) + + if (overlap > 0 && distanceMouse > 0) { + const adjustX = (dxMouse / distanceMouse) * overlap; + const adjustY = (dyMouse / distanceMouse) * overlap; + obj.position.x += adjustX; + obj.position.y += adjustY; + } + } + + // b. ハートとの衝突判定(蹂躙ロジック) + 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 + ) { + obj.isHit = true; + state.distortionLevel = Math.min(1.0, state.distortionLevel + 0.05); + } + + return obj; + }) + .filter((obj) => !obj.isHit && obj.position.y < canvas.height * 1.5); // 画面外に出たものと当たったものを削除 + + // 3. ハートとマウスの衝突判定(ハートも弾かれる) + const dxHeartMouse = state.heartCenter.x - state.mousePosition.x; + const dyHeartMouse = state.heartCenter.y - state.mousePosition.y; + if (Math.hypot(dxHeartMouse, dyHeartMouse) < state.heartRadius + 30) { + // マウスによってハートが押しやられる + state.heartCenter.x += dxHeartMouse * 0.05; + state.heartCenter.y += dyHeartMouse * 0.05; + } + state.heartCenter.x += (canvas.width / 2 - state.heartCenter.x) * 0.01; + state.heartCenter.y += (canvas.height / 2 - state.heartCenter.y) * 0.01; + }; + + const drawGame = (ctx: CanvasRenderingContext2D, state: GameState) => { + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + state.flyingObjects.forEach((obj) => { + ctx.fillStyle = "white"; + ctx.font = `${obj.radius * 1.5}px sans-serif`; + ctx.fillText(obj.content, obj.position.x, obj.position.y); + }); + + // ハートの描画(画像を使用) + drawHeart(ctx, state); + }; + + // --- メインゲームループ --- + const gameLoop = useCallback( + (timestamp: DOMHighResTimeStamp) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // 前回の実行時刻を保持 (deltaTime計算のため) + const lastTime = gameStateRef.current.lastTime || timestamp; + const deltaTime = timestamp - lastTime; + gameStateRef.current.totalTime += deltaTime; + // 1. **更新 (Update):** ゲームの状態を更新 + updateGame(gameStateRef.current, deltaTime); + + // 2. **描画 (Draw):** Canvasに描画 + drawGame(ctx, gameStateRef.current); + + gameStateRef.current.lastTime = timestamp; + + // ループを継続 + animationFrameId.current = requestAnimationFrame(gameLoop); + }, + [canvasRef], + ); + + // マウスイベントハンドラ (コンポーネントで登録し、ここで使う) + const handleMouseMove = useCallback( + (e: React.MouseEvent<HTMLCanvasElement>) => { + if (canvasRef.current) { + // キャンバス内の相対座標に変換 + const rect = canvasRef.current.getBoundingClientRect(); + gameStateRef.current.mousePosition = { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + } + }, + [canvasRef], + ); + + // ゲームの開始・停止関数 + const startGame = async () => { + await preloadImages(); + const canvas = canvasRef.current; + if (!canvas) return; // nullチェック + gameStateRef.current.heartCenter = { + x: canvas.width / 2, + y: canvas.height / 2, + }; + animationFrameId.current = requestAnimationFrame(gameLoop); + }; + + const stopGame = () => { + if (animationFrameId.current) { + cancelAnimationFrame(animationFrameId.current); + } + }; + + return { + startGame, + stopGame, + handleMouseMove, + gameState: gameStateRef.current, + }; +}; + +export default function GameCanvas() { + const canvasRef = useRef<HTMLCanvasElement>(null); + + // useGameLoopカスタムフックを呼び出し、ゲームロジックのAPIを取得 + const { startGame, stopGame, handleMouseMove, gameState } = + useGameLoop(canvasRef); + + useEffect(() => { + const initGame = async () => { + const canvas = canvasRef.current; + if (!canvas) return; // nullチェック + if (canvasRef.current) { + canvasRef.current.width = globalThis.innerWidth; + canvasRef.current.height = globalThis.innerHeight; + await startGame(); // awaitで画像の読み込み完了を待つ + } + }; + initGame(); // 実行 + + return () => { + stopGame(); + }; + }, []); + + return ( + <div + style={{ + width: "100vw", + height: "100vh", + }} + > + <canvas + ref={canvasRef} + onMouseMove={handleMouseMove} // マウスイベントをフックに渡す + style={{ + display: "block", + width: "100vw", + height: "100vh", + }} + /> + {gameState.isGameOver && ( + <div + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + color: "red", + fontSize: "48px", + }} + > + GAME OVER + </div> + )} + </div> + ); +} diff --git a/app/ctrl/page.tsx b/app/ctrl/page.tsx @@ -1,141 +1,169 @@ -'use client'; - -import { useEffect, useRef, useState, useCallback } from 'react'; -import dynamic from 'next/dynamic'; - -export interface StageProps { - onComplete: () => void; -} - -const Loading = () => <p>loading...</p>; - -//component dynamic import handler -const FirstChat = dynamic<StageProps>(() => import('../components/1_chat.tsx'), { - loading: Loading, -}); -const SecondDraw = dynamic<StageProps>(() => import('../components/2_draw.tsx'), { - loading: Loading, -}); -const ThreeVR = dynamic<StageProps>(()=>import('../components/3_vr.tsx'),{ - loading: Loading, -}); -const OilArt = dynamic<StageProps>(()=>import('../components/4_oil.tsx'),{ - loading: Loading, -}); -const FifthTitle = dynamic<StageProps>(()=>import('../components/5_title.tsx'),{ - loading: Loading, -}); -const SixthChat = dynamic<StageProps>(()=>import('../components/6_chat.tsx'),{ - loading: Loading, -}); -const SeventhVR = dynamic<StageProps>(()=>import('../components/7_vr.tsx'),{ - loading: Loading, -}); -const EighthLightActivity = dynamic<StageProps>(()=>import('../components/8_kaleidoscope.tsx'),{ - loading: Loading, -}); -const Error = dynamic<StageProps>(() => import('../components/error.tsx'), { - loading: Loading, -}); - -export default function Play() { - - const pageRef = useRef<HTMLDivElement>(null); - const [isFullscreen, setIsFullscreen] = useState(false); - const [isInitialCheckDone, setIsInitialCheckDone] = useState(false); - const [ stage, setStage ] = useState(8); - const handleStageComplete = useCallback(() => { - setStage(prevStage => prevStage + 1); - }, []); - - let StageComponent: React.ReactNode; - console.log("now, stage is ",stage); - switch (stage) { - case 1: - StageComponent = <FirstChat onComplete={handleStageComplete}/>; - break; - case 2: - StageComponent = <SecondDraw onComplete={handleStageComplete}/>; - break; - case 3: - StageComponent = <ThreeVR onComplete={handleStageComplete}/>; - break; - case 4: - StageComponent = <OilArt onComplete={handleStageComplete}/> - break; - case 5: - StageComponent = <FifthTitle onComplete={handleStageComplete}/> - break; - case 6: - StageComponent = <SixthChat onComplete={handleStageComplete}/> - break; - case 7: - StageComponent = <SeventhVR onComplete={handleStageComplete}/> - break; - case 8: - StageComponent = <EighthLightActivity onComplete={handleStageComplete}/> - break; - default: - StageComponent = <Error onComplete={handleStageComplete}/>; - } - - const checkFullscreen = () => { - const isTargetFullscreen = document.fullscreenElement === pageRef.current; - setIsFullscreen(isTargetFullscreen); - }; - - useEffect(() => { - // for fullscreen - const element = pageRef.current; - if (element && element.requestFullscreen) { - element.requestFullscreen() - .then(() => { - checkFullscreen(); - }) - .catch(() => { - setIsFullscreen(false); - }) - .finally(() => { - setIsInitialCheckDone(true); - }); - }else{ - setIsFullscreen(false); - setIsInitialCheckDone(true); - setStage(1); - } - - document.addEventListener('fullscreenchange', checkFullscreen); - return () => { - document.removeEventListener('fullscreenchange', checkFullscreen); - }; - }, []); - - const enterFullScreen = () => { - const element = pageRef.current; - if (element && element.requestFullscreen) { - element.requestFullscreen().catch(() => { - alert("フルスクリーンにできませんでした。") - setIsFullscreen(false) - }) - } - }; - - if (!isInitialCheckDone) { - return <div ref={pageRef} style={{ height: '100vh', width: '100vw' }}></div>; - } - - return ( - <main ref={pageRef} style={{ height: '100vh', width: '100vw' }}> - { - !isFullscreen ? ( - <article style={{height: '100%',width: '100%', alignItems:'center', justifyContent:'center', display:'flex'}}> - <button type='button' onClick={enterFullScreen} style={{fontSize:'xxx-large'}}>フルスクリーンにする</button> - </article> - ):( - StageComponent - ) - } - </main> - ); -}; - +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import dynamic from "next/dynamic"; + +export interface StageProps { + onComplete: () => void; +} + +const Loading = () => <p>loading...</p>; + +//component dynamic import handler +const FirstChat = dynamic<StageProps>( + () => import("../components/1_chat.tsx"), + { + loading: Loading, + }, +); +const SecondDraw = dynamic<StageProps>( + () => import("../components/2_draw.tsx"), + { + loading: Loading, + }, +); +const ThreeVR = dynamic<StageProps>(() => import("../components/3_vr.tsx"), { + loading: Loading, +}); +const OilArt = dynamic<StageProps>(() => import("../components/4_oil.tsx"), { + loading: Loading, +}); +const FifthTitle = dynamic<StageProps>( + () => import("../components/5_title.tsx"), + { + loading: Loading, + }, +); +const SixthChat = dynamic<StageProps>( + () => import("../components/6_chat.tsx"), + { + loading: Loading, + }, +); +const SeventhVR = dynamic<StageProps>(() => import("../components/7_vr.tsx"), { + loading: Loading, +}); +const EighthLightActivity = dynamic<StageProps>( + () => import("../components/8_protectHart.tsx"), + { + loading: Loading, + }, +); +const Error = dynamic<StageProps>(() => import("../components/error.tsx"), { + loading: Loading, +}); + +export default function Play() { + const pageRef = useRef<HTMLDivElement>(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isInitialCheckDone, setIsInitialCheckDone] = useState(false); + const [stage, setStage] = useState(8); + const handleStageComplete = useCallback(() => { + setStage((prevStage) => prevStage + 1); + }, []); + + let StageComponent: React.ReactNode; + console.log("now, stage is ", stage); + switch (stage) { + case 1: + StageComponent = <FirstChat onComplete={handleStageComplete} />; + break; + case 2: + StageComponent = <SecondDraw onComplete={handleStageComplete} />; + break; + case 3: + StageComponent = <ThreeVR onComplete={handleStageComplete} />; + break; + case 4: + StageComponent = <OilArt onComplete={handleStageComplete} />; + break; + case 5: + StageComponent = <FifthTitle onComplete={handleStageComplete} />; + break; + case 6: + StageComponent = <SixthChat onComplete={handleStageComplete} />; + break; + case 7: + StageComponent = <SeventhVR onComplete={handleStageComplete} />; + break; + case 8: + StageComponent = <EighthLightActivity onComplete={handleStageComplete} />; + break; + default: + StageComponent = <Error onComplete={handleStageComplete} />; + } + + const checkFullscreen = () => { + const isTargetFullscreen = document.fullscreenElement === pageRef.current; + setIsFullscreen(isTargetFullscreen); + }; + + useEffect(() => { + // for fullscreen + const element = pageRef.current; + if (element && element.requestFullscreen) { + element + .requestFullscreen() + .then(() => { + checkFullscreen(); + }) + .catch(() => { + setIsFullscreen(false); + }) + .finally(() => { + setIsInitialCheckDone(true); + }); + } else { + setIsFullscreen(false); + setIsInitialCheckDone(true); + setStage(1); + } + + document.addEventListener("fullscreenchange", checkFullscreen); + return () => { + document.removeEventListener("fullscreenchange", checkFullscreen); + }; + }, []); + + const enterFullScreen = () => { + const element = pageRef.current; + if (element && element.requestFullscreen) { + element.requestFullscreen().catch(() => { + alert("フルスクリーンにできませんでした。"); + setIsFullscreen(false); + }); + } + }; + + if (!isInitialCheckDone) { + return ( + <div ref={pageRef} style={{ height: "100vh", width: "100vw" }}></div> + ); + } + + return ( + <main ref={pageRef} style={{ height: "100vh", width: "100vw" }}> + {!isFullscreen ? ( + <article + style={{ + height: "100%", + width: "100%", + alignItems: "center", + justifyContent: "center", + display: "flex", + }} + > + <button + type="button" + onClick={enterFullScreen} + style={{ fontSize: "xxx-large" }} + > + フルスクリーンにする + </button> + </article> + ) : ( + StageComponent + )} + </main> + ); +} diff --git a/bun.lock b/bun.lock @@ -1,12 +1,13 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "aidentity", "dependencies": { "@react-three/drei": "^10.7.6", "@react-three/fiber": "^9.4.0", - "@sentry/nextjs": "^10.22.0", + "@sentry/nextjs": "^10.23.0", "next": "15.5.5", "react": "19.1.0", "react-dom": "19.1.0", @@ -18,7 +19,7 @@ "@types/node": "^20.19.24", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "eslint": "^9.39.0", + "eslint": "^9.39.1", "eslint-config-next": "15.5.5", "typescript": "^5.9.3", }, diff --git a/tsconfig.json b/tsconfig.json @@ -23,6 +23,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "types/*.d.ts"], "exclude": ["node_modules"] } diff --git a/types/r3f-custom.d.ts b/types/r3f-custom.d.ts @@ -1,6 +1,5 @@ // THREEとR3Fの型をインポート import * as THREE from 'three'; -import { ThreeElements } from '@react-three/fiber'; // OilArtMaterial のユニフォーム型を定義 interface OilArtUniforms { @@ -20,3 +19,24 @@ declare module '@react-three/fiber' { oilArtMaterial: ThreeElements['shaderMaterial'] & OilArtUniforms; } } + +interface WaterMaterialProps extends THREE.ShaderMaterialParameters { + uTime: number; + uDropPosition: THREE.Vector3; + uDropTime: number; +} + +// 2. JSXの組み込み要素 (IntrinsicElements) に新しいタグを追加 +// これにより、R3Fが 'waterMaterial' を認識し、 +// Reactがそのプロパティの型チェックを行えるようになります。 +declare global { + namespace JSX { + interface IntrinsicElements { + // JSXタグ名 'waterMaterial' に WaterMaterialProps を関連付け + waterMaterial: WaterMaterialProps & { + // refを使用する場合のために THREE.ShaderMaterial も追加 + ref?: React.Ref<THREE.ShaderMaterial>; + }; + } + } +}