AIdentity

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

8_protectHart.tsx (16481B)


      1 "use client";
      2 
      3 import React, { useRef, useCallback, useEffect } from "react";
      4 import { StageProps } from "../ctrl/page";
      5 
      6 export type Point = {
      7   x: number;
      8   y: number;
      9 };
     10 
     11 export type FlyingObject = {
     12   id: number;
     13   position: Point;
     14   velocity: Point; // vx, vy
     15   content: string;
     16   radius: number;
     17   isHit: boolean;
     18   when: number;
     19 };
     20 
     21 export type GameState = {
     22   heartCenter: Point;
     23   heartRadius: number;
     24   distortionLevel: number;
     25   flyingObjects: FlyingObject[];
     26   mousePosition: Point;
     27   isGameOver: boolean;
     28   lastTime: number | undefined;
     29   totalTime: number;
     30 };
     31 
     32 const HEART_RADIUS = 50;
     33 const MOUSE_REPEL_RADIUS = 40;
     34 
     35 const HEART_IMAGE_PATHS = [
     36   "/eyes/1.svg",
     37   "/eyes/2.svg",
     38   "/eyes/3.svg",
     39   "/eyes/4.svg",
     40   "/eyes/5.svg",
     41 ];
     42 
     43 const heartImages = new Map<string, HTMLImageElement>();
     44 let imagesLoaded = false;
     45 const preloadImages = async () => {
     46   if (imagesLoaded) return;
     47   const promises = HEART_IMAGE_PATHS.map((path) => {
     48     return new Promise<void>((resolve, reject) => {
     49       const img = new Image();
     50       img.src = path;
     51       img.onload = () => {
     52         heartImages.set(path, img);
     53         resolve();
     54       };
     55       img.onerror = reject;
     56     });
     57   });
     58 
     59   try {
     60     await Promise.all(promises);
     61     imagesLoaded = true;
     62     console.log("All heart images loaded.");
     63   } catch (error) {
     64     console.error("Failed to load heart images:", error);
     65   }
     66 };
     67 
     68 const drawHeart = (ctx: CanvasRenderingContext2D, state: GameState) => {
     69   const { x, y } = state.heartCenter;
     70   const R = state.heartRadius;
     71   const imageIndex = Math.min(
     72     HEART_IMAGE_PATHS.length - 1,
     73     Math.floor(state.distortionLevel * HEART_IMAGE_PATHS.length),
     74   );
     75   const imagePath = HEART_IMAGE_PATHS[imageIndex];
     76   const heartImage = heartImages.get(imagePath);
     77 
     78   if (heartImage && imagesLoaded) {
     79     const drawX = x - heartImage.width / 2;
     80     const drawY = y - heartImage.height / 2;
     81     ctx.drawImage(
     82       heartImage,
     83       drawX,
     84       drawY,
     85       heartImage.width,
     86       heartImage.height,
     87     );
     88   } else {
     89     ctx.fillStyle = "red";
     90     ctx.fillRect(x - R / 2, y - R / 2, R, R);
     91   }
     92 };
     93 
     94 const FLY_SPEED_BASE = 0.3;
     95 const LIE_TIME = 10000;
     96 
     97 const SPAWN_LIST: Omit<FlyingObject, "id" | "isHit">[] = [
     98   {
     99     when: 0,
    100     content: "あなたのため",
    101     radius: 15,
    102     position: { x: 0.1, y: 0.1 },
    103     velocity: { x: FLY_SPEED_BASE, y: FLY_SPEED_BASE },
    104   },
    105   {
    106     when: 1000,
    107     content: "みんなそう言ってる",
    108     radius: 20,
    109     position: { x: 0.9, y: 0.1 },
    110     velocity: { x: -FLY_SPEED_BASE, y: FLY_SPEED_BASE },
    111   },
    112   {
    113     when: 2000,
    114     content: "それって本質じゃないよね?",
    115     radius: 10,
    116     position: { x: 0.1, y: 0.9 },
    117     velocity: { x: FLY_SPEED_BASE, y: -FLY_SPEED_BASE },
    118   },
    119   {
    120     when: 3000,
    121     content: "意味がわからない。",
    122     radius: 15,
    123     position: { x: 1.0, y: 1.0 },
    124     velocity: { x: -FLY_SPEED_BASE, y: -FLY_SPEED_BASE },
    125   },
    126   {
    127     when: 4000,
    128     content: "馬鹿なの?",
    129     radius: 25,
    130     position: { x: 0.5, y: 0.0 },
    131     velocity: { x: 0.0, y: FLY_SPEED_BASE * 2.5 },
    132   },
    133   {
    134     when: 4300,
    135     content: "恥ずかしい",
    136     radius: 25,
    137     position: { x: 0.6, y: 0.0 },
    138     velocity: { x: -0.03, y: FLY_SPEED_BASE * 0.7 },
    139   },
    140   {
    141     when: 4600,
    142     content: "言うことを聞きなさい",
    143     radius: 25,
    144     position: { x: 0.4, y: 0.0 },
    145     velocity: { x: -0.01, y: FLY_SPEED_BASE * 2.5 },
    146   },
    147   {
    148     when: 5200,
    149     content: "全部任せてたら大丈夫だからね",
    150     radius: 20,
    151     position: { x: 0.4, y: 1.0 },
    152     velocity: { x: -0.01, y: -FLY_SPEED_BASE * 0.7 },
    153   },
    154   {
    155     when: 7000,
    156     content: "大した才能もないのに",
    157     radius: 25,
    158     position: { x: 0.7, y: 0.0 },
    159     velocity: { x: -0.1, y: FLY_SPEED_BASE },
    160   },
    161   {
    162     when: 8000,
    163     content: "根性がない",
    164     radius: 25,
    165     position: { x: 0.3, y: 0.0 },
    166     velocity: { x: 0.1, y: FLY_SPEED_BASE },
    167   },
    168   {
    169     when: 9000,
    170     content: "将来のため",
    171     radius: 25,
    172     position: { x: 0.5, y: 0.0 },
    173     velocity: { x: 0.0, y: FLY_SPEED_BASE },
    174   },
    175   {
    176     when: 9100,
    177     content: "将来のため",
    178     radius: 25,
    179     position: { x: 0.5, y: 1 },
    180     velocity: { x: 0.0, y: -FLY_SPEED_BASE },
    181   },
    182   {
    183     when: 9200,
    184     content: "将来のため",
    185     radius: 25,
    186     position: { x: 0.0, y: 0.0 },
    187     velocity: { x: FLY_SPEED_BASE, y: FLY_SPEED_BASE },
    188   },
    189   {
    190     when: 9300,
    191     content: "将来のため",
    192     radius: 25,
    193     position: { x: 1.0, y: 1.0 },
    194     velocity: { x: -FLY_SPEED_BASE, y: -FLY_SPEED_BASE },
    195   },
    196   {
    197     when: 9400,
    198     content: "将来のため",
    199     radius: 25,
    200     position: { x: 0.0, y: 1.0 },
    201     velocity: { x: FLY_SPEED_BASE, y: -FLY_SPEED_BASE },
    202   },
    203   {
    204     when: 9500,
    205     content: "将来のため",
    206     radius: 25,
    207     position: { x: 1.0, y: 0.0 },
    208     velocity: { x: -FLY_SPEED_BASE, y: FLY_SPEED_BASE },
    209   },
    210   {
    211     when: LIE_TIME,
    212     content: "嘘をつくな",
    213     radius: 25,
    214     position: { x: 0.5, y: 0.0 },
    215     velocity: { x: 0.0, y: FLY_SPEED_BASE },
    216   },
    217   {
    218     when: LIE_TIME + 100,
    219     content: "嘘をつくな",
    220     radius: 25,
    221     position: { x: 0.5, y: 1 },
    222     velocity: { x: 0.0, y: -FLY_SPEED_BASE },
    223   },
    224   {
    225     when: LIE_TIME + 200,
    226     content: "嘘をつくな",
    227     radius: 25,
    228     position: { x: 0.0, y: 0.0 },
    229     velocity: { x: FLY_SPEED_BASE, y: FLY_SPEED_BASE },
    230   },
    231   {
    232     when: LIE_TIME + 300,
    233     content: "嘘をつくな",
    234     radius: 25,
    235     position: { x: 1.0, y: 1.0 },
    236     velocity: { x: -FLY_SPEED_BASE, y: -FLY_SPEED_BASE },
    237   },
    238   {
    239     when: LIE_TIME + 400,
    240     content: "嘘をつくな",
    241     radius: 25,
    242     position: { x: 0.0, y: 1.0 },
    243     velocity: { x: FLY_SPEED_BASE, y: -FLY_SPEED_BASE },
    244   },
    245   {
    246     when: LIE_TIME + 500,
    247     content: "嘘をつくな",
    248     radius: 25,
    249     position: { x: 1.0, y: 0.0 },
    250     velocity: { x: -FLY_SPEED_BASE, y: FLY_SPEED_BASE },
    251   },
    252   {
    253     when: LIE_TIME + 1000,
    254     content: "お前は{}だ",
    255     radius: 30,
    256     position: { x: 0.5, y: 0.0 },
    257     velocity: { x: 0, y: FLY_SPEED_BASE * 0.3 },
    258   },
    259   {
    260     when: LIE_TIME + 5000,
    261     content: "AIdentity",
    262     radius: 50,
    263     position: { x: 0.5, y: 0.0 },
    264     velocity: { x: 0, y: FLY_SPEED_BASE * 0.2 },
    265   },
    266 ];
    267 
    268 const useGameLoop = (canvasRef: React.RefObject<HTMLCanvasElement | null>) => {
    269   const gameStateRef = useRef<GameState>({
    270     heartCenter: { x: 0, y: 0 },
    271     heartRadius: HEART_RADIUS,
    272     distortionLevel: 0,
    273     flyingObjects: [],
    274     mousePosition: { x: -100, y: -100 }, // 初期値は画面外
    275     isGameOver: false,
    276     lastTime: undefined,
    277     totalTime: 0,
    278   });
    279 
    280   const scheduledObjectsRef = useRef<FlyingObject[]>(
    281     JSON.parse(
    282       JSON.stringify(
    283         SPAWN_LIST.map((obj, index) => ({ ...obj, id: index, isHit: false })),
    284       ),
    285     ),
    286   );
    287 
    288   const animationFrameId = useRef<number | undefined>(undefined);
    289 
    290   const updateGame = (
    291     state: GameState,
    292     deltaTime: number,
    293     canvasRef: React.RefObject<HTMLCanvasElement | null>,
    294     scheduledObjectsRef: React.MutableRefObject<FlyingObject[]>,
    295   ) => {
    296     const canvas = canvasRef.current;
    297     if (!canvas) return;
    298     const ctx = canvas.getContext("2d");
    299     while (
    300       scheduledObjectsRef.current.length > 0 &&
    301       scheduledObjectsRef.current[0].when <= state.totalTime
    302     ) {
    303       const objToSpawn = scheduledObjectsRef.current.shift(); // 先頭を抜き取る
    304 
    305       if (objToSpawn) {
    306         objToSpawn.position.x = objToSpawn.position.x * canvas.width;
    307         objToSpawn.position.y = objToSpawn.position.y * canvas.height;
    308 
    309         objToSpawn.velocity.x = objToSpawn.velocity.x * canvas.width;
    310         objToSpawn.velocity.y = objToSpawn.velocity.y * canvas.height;
    311 
    312         state.flyingObjects.push(objToSpawn);
    313       }
    314     }
    315     state.flyingObjects = state.flyingObjects
    316       .map((obj) => {
    317         obj.position.x += obj.velocity.x * deltaTime * 0.001;
    318         obj.position.y += obj.velocity.y * deltaTime * 0.001;
    319 
    320         if (ctx) {
    321           ctx.font = `${obj.radius * 1.5}px sans-serif`;
    322         }
    323         const textWidth = ctx
    324           ? ctx.measureText(obj.content).width
    325           : obj.radius * 2;
    326         const textHeight = obj.radius * 1.5; // 高さの近似値 (radius * 1.5 = font-size)
    327 
    328         const halfWidth = textWidth / 2;
    329         const halfHeight = textHeight / 2;
    330 
    331         const dxMouse = Math.abs(obj.position.x - state.mousePosition.x);
    332         const dyMouse = Math.abs(obj.position.y - state.mousePosition.y);
    333 
    334         const closestX = Math.max(
    335           obj.position.x - halfWidth,
    336           Math.min(state.mousePosition.x, obj.position.x + halfWidth),
    337         );
    338         const closestY = Math.max(
    339           obj.position.y - halfHeight,
    340           Math.min(state.mousePosition.y, obj.position.y + halfHeight),
    341         );
    342 
    343         const dxClosest = state.mousePosition.x - closestX;
    344         const dyClosest = state.mousePosition.y - closestY;
    345         const distanceMouse = Math.hypot(dxClosest, dyClosest);
    346 
    347         const effectiveRadius = 5; // マウスカーソル自体の半径の近似値
    348 
    349         if (distanceMouse < MOUSE_REPEL_RADIUS + effectiveRadius) {
    350           if (
    351             dxMouse < halfWidth + effectiveRadius &&
    352             dyMouse < halfHeight + effectiveRadius
    353           ) {
    354           }
    355         }
    356 
    357         const dxMouseCenter = obj.position.x - state.mousePosition.x;
    358         const dyMouseCenter = obj.position.y - state.mousePosition.y;
    359         const distanceMouseCenter = Math.hypot(dxMouseCenter, dyMouseCenter);
    360 
    361         const effectiveObjectRadius = Math.max(halfWidth, halfHeight);
    362         const totalRepelRadius = MOUSE_REPEL_RADIUS + effectiveObjectRadius;
    363 
    364         if (distanceMouseCenter < totalRepelRadius) {
    365           const overlap = totalRepelRadius - distanceMouseCenter;
    366           const repelFactor = overlap / totalRepelRadius; // 衝突の中心に近いほど1.0に近づく
    367 
    368           const PUSH_STRENGTH = 9000; // 🔥 数値を大幅に増やして強く弾く
    369 
    370           const repelX = dxMouseCenter * repelFactor * PUSH_STRENGTH;
    371           const repelY = dyMouseCenter * repelFactor * PUSH_STRENGTH;
    372 
    373           const accelerationFactor = 0.000005;
    374 
    375           obj.velocity.x += repelX * accelerationFactor;
    376           obj.velocity.y += repelY * accelerationFactor;
    377 
    378           if (overlap > 0 && distanceMouseCenter > 0) {
    379             const adjustX = (dxMouseCenter / distanceMouseCenter) * overlap;
    380             const adjustY = (dyMouseCenter / distanceMouseCenter) * overlap;
    381             obj.position.x += adjustX;
    382             obj.position.y += adjustY;
    383           }
    384         }
    385 
    386         const heartClosestX = Math.max(
    387           obj.position.x - halfWidth,
    388           Math.min(state.heartCenter.x, obj.position.x + halfWidth),
    389         );
    390         const heartClosestY = Math.max(
    391           obj.position.y - halfHeight,
    392           Math.min(state.heartCenter.y, obj.position.y + halfHeight),
    393         );
    394 
    395         const dxHeart = state.heartCenter.x - heartClosestX;
    396         const dyHeart = state.heartCenter.y - heartClosestY;
    397         const distanceHeart = Math.hypot(dxHeart, dyHeart);
    398 
    399         if (distanceHeart < state.heartRadius && !obj.isHit) {
    400           obj.isHit = true;
    401           /*
    402           console.log("--- COLLISION DETECTED! ---");
    403           console.log("Flying Object ID:", obj.id, "Content:", obj.content);
    404           console.log("New Distortion Level:", state.distortionLevel + 0.05);
    405           */
    406 
    407           state.distortionLevel = Math.min(1.0, state.distortionLevel + 0.05);
    408         }
    409 
    410         return obj;
    411       })
    412       .filter((obj) => !obj.isHit && obj.position.y < canvas.height * 1.5);
    413     const dxHeartMouse = state.heartCenter.x - state.mousePosition.x;
    414     const dyHeartMouse = state.heartCenter.y - state.mousePosition.y;
    415     if (Math.hypot(dxHeartMouse, dyHeartMouse) < state.heartRadius + 30) {
    416       state.heartCenter.x += dxHeartMouse * 0.15;
    417       state.heartCenter.y += dyHeartMouse * 0.15;
    418     }
    419     state.heartCenter.x += (canvas.width / 2 - state.heartCenter.x) * 0.01;
    420     state.heartCenter.y += (canvas.height / 2 - state.heartCenter.y) * 0.01;
    421   };
    422 
    423   const drawGame = (ctx: CanvasRenderingContext2D, state: GameState) => {
    424     ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    425 
    426     state.flyingObjects.forEach((obj) => {
    427       ctx.fillStyle = "white";
    428       ctx.font = `${obj.radius * 1.5}px sans-serif`;
    429       ctx.textAlign = "center";
    430       ctx.textBaseline = "middle";
    431       ctx.fillText(obj.content, obj.position.x, obj.position.y);
    432     });
    433 
    434     drawHeart(ctx, state);
    435   };
    436 
    437   const gameLoop = useCallback(
    438     (timestamp: DOMHighResTimeStamp) => {
    439       const canvas = canvasRef.current;
    440       if (!canvas) return;
    441 
    442       const ctx = canvas.getContext("2d");
    443       if (!ctx) return;
    444 
    445       const lastTime = gameStateRef.current.lastTime || timestamp;
    446       const deltaTime = timestamp - lastTime;
    447       gameStateRef.current.totalTime += deltaTime;
    448       updateGame(
    449         gameStateRef.current,
    450         deltaTime,
    451         canvasRef,
    452         scheduledObjectsRef,
    453       );
    454 
    455       drawGame(ctx, gameStateRef.current);
    456 
    457       gameStateRef.current.lastTime = timestamp;
    458 
    459       animationFrameId.current = requestAnimationFrame(gameLoop);
    460     },
    461     [canvasRef, scheduledObjectsRef],
    462   );
    463 
    464   const handleMouseMove = useCallback(
    465     (e: React.MouseEvent<HTMLCanvasElement>) => {
    466       if (canvasRef.current) {
    467         const rect = canvasRef.current.getBoundingClientRect();
    468         gameStateRef.current.mousePosition = {
    469           x: e.clientX - rect.left,
    470           y: e.clientY - rect.top,
    471         };
    472       }
    473     },
    474     [canvasRef],
    475   );
    476 
    477   const startGame = async () => {
    478     await preloadImages();
    479     const canvas = canvasRef.current;
    480     if (!canvas) return;
    481     gameStateRef.current.heartCenter = {
    482       x: canvas.width / 2,
    483       y: canvas.height / 2,
    484     };
    485     animationFrameId.current = requestAnimationFrame(gameLoop);
    486   };
    487 
    488   const stopGame = () => {
    489     if (animationFrameId.current) {
    490       cancelAnimationFrame(animationFrameId.current);
    491     }
    492   };
    493 
    494   return {
    495     startGame,
    496     stopGame,
    497     handleMouseMove,
    498     gameState: gameStateRef.current,
    499   };
    500 };
    501 
    502 // Fur Audio
    503 
    504 const AUDIO_SOURCE = "/audio/008.flac";
    505 const useAudioPlayback = (onComplete: () => void) => {
    506   const audioRef = useRef<HTMLAudioElement | null>(null);
    507   const onCompleteRef = useRef(onComplete);
    508 
    509   useEffect(() => {
    510     onCompleteRef.current = onComplete;
    511   }, [onComplete]);
    512 
    513   useEffect(() => {
    514     if (audioRef.current) {
    515       return;
    516     }
    517 
    518     const audio = new Audio(AUDIO_SOURCE);
    519     audioRef.current = audio;
    520     audio.volume = 1.0; // 音量設定 (任意)
    521     audio.loop = false;
    522 
    523     const handleAudioEnded = () => {
    524       console.log("Audio playback finished. Calling onComplete.");
    525       onCompleteRef.current(); // Ref 経由で最新の onComplete を呼び出す
    526     };
    527 
    528     audio.addEventListener("ended", handleAudioEnded);
    529 
    530     // 再生開始ロジック:
    531     const playAudio = () => {
    532       audio
    533         .play()
    534         .catch((e) =>
    535           console.warn(
    536             "Audio playback failed (may require user interaction):",
    537             e,
    538           ),
    539         );
    540     };
    541 
    542     audio.oncanplaythrough = playAudio;
    543 
    544     return () => {
    545       if (audioRef.current) {
    546         audio.pause();
    547         audio.removeEventListener("ended", handleAudioEnded);
    548         audioRef.current = null;
    549       }
    550     };
    551   }, []);
    552   return { audioRef };
    553 };
    554 
    555 export default function GameCanvas({ onComplete }: StageProps) {
    556   const canvasRef = useRef<HTMLCanvasElement>(null);
    557 
    558   useAudioPlayback(onComplete);
    559 
    560   const { startGame, stopGame, handleMouseMove, gameState } =
    561     useGameLoop(canvasRef);
    562 
    563   useEffect(() => {
    564     const initGame = async () => {
    565       const canvas = canvasRef.current;
    566       if (!canvas) return;
    567       if (canvasRef.current) {
    568         canvasRef.current.width = globalThis.innerWidth;
    569         canvasRef.current.height = globalThis.innerHeight;
    570         await startGame();
    571       }
    572     };
    573     initGame();
    574 
    575     return () => {
    576       stopGame();
    577     };
    578   }, [startGame, stopGame]);
    579 
    580   return (
    581     <div
    582       style={{
    583         width: "100vw",
    584         height: "100vh",
    585       }}
    586     >
    587       <canvas
    588         ref={canvasRef}
    589         onMouseMove={handleMouseMove}
    590         style={{
    591           display: "block",
    592           width: "100vw",
    593           height: "100vh",
    594         }}
    595       />
    596       {gameState.isGameOver && (
    597         <div
    598           style={{ width: "100%", height: "100%", backgroundColor: "black" }}
    599         ></div>
    600       )}
    601     </div>
    602   );
    603 }