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 }