'use client'; import { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'; import { Physics, RigidBody, CuboidCollider, type RapierRigidBody, } from '@react-three/rapier'; import { Quaternion, SRGBColorSpace, TextureLoader, Vector3, type OrthographicCamera, } from 'three'; import { getDiceFaceTextureList } from './asset-storage'; const FLOOR_SIZE = 6; const FLOOR_HALF = FLOOR_SIZE / 2; const WALL_THICKNESS = 0.25; const WALL_HEIGHT = 8; const WALL_HALF_T = WALL_THICKNESS / 2; const WALL_HALF_H = WALL_HEIGHT / 2; const STILL_SPEED_SQ = 0.05; const STILL_FRAMES = 18; // boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z (기본 경로, 런타임에 localStorage로 교체) const DEFAULT_DICE_FACE_TEXTURES = [ '/dice/2.png', '/dice/5.png', '/dice/1.png', '/dice/6.png', '/dice/3.png', '/dice/4.png', ] as const; // 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면 const DICE_FACES = [ { value: 1, localNormal: new Vector3(0, 1, 0) }, { value: 6, localNormal: new Vector3(0, -1, 0) }, { value: 2, localNormal: new Vector3(1, 0, 0) }, { value: 5, localNormal: new Vector3(-1, 0, 0) }, { value: 3, localNormal: new Vector3(0, 0, 1) }, { value: 4, localNormal: new Vector3(0, 0, -1) }, ]; const _quat = new Quaternion(); const _normal = new Vector3(); const WORLD_UP = new Vector3(0, 1, 0); function readTopFace(body: RapierRigidBody): number { const r = body.rotation(); _quat.set(r.x, r.y, r.z, r.w); let bestValue = 1; let bestDot = -Infinity; for (const face of DICE_FACES) { _normal.copy(face.localNormal).applyQuaternion(_quat); const dot = _normal.dot(WORLD_UP); if (dot > bestDot) { bestDot = dot; bestValue = face.value; } } return bestValue; } function FitSquareFloor() { const { camera, size } = useThree(); useLayoutEffect(() => { const cam = camera as OrthographicCamera; cam.left = -FLOOR_HALF; cam.right = FLOOR_HALF; cam.top = FLOOR_HALF; cam.bottom = -FLOOR_HALF; cam.near = 0.1; cam.far = 50; cam.updateProjectionMatrix(); }, [camera, size.width, size.height]); return null; } function InvisibleWalls() { const t = WALL_HALF_T; const h = WALL_HALF_H; const f = FLOOR_HALF; return ( <> ); } function DiceMesh({ faceUrls }: { faceUrls: string[] }) { const textures = useLoader(TextureLoader, faceUrls); useLayoutEffect(() => { for (const texture of textures) { texture.colorSpace = SRGBColorSpace; texture.anisotropy = 8; texture.needsUpdate = true; } }, [textures]); return ( {textures.map((texture, index) => ( ))} ); } function DiceSettleWatcher({ diceRef, rolling, onSettled, }: { diceRef: React.RefObject; rolling: boolean; onSettled: (value: number) => void; }) { const stillFrames = useRef(0); const reported = useRef(false); useFrame(() => { const body = diceRef.current; if (!body || !rolling) { stillFrames.current = 0; reported.current = false; return; } const lv = body.linvel(); const av = body.angvel(); const speedSq = lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z; if (speedSq < STILL_SPEED_SQ || body.isSleeping()) { stillFrames.current += 1; if (stillFrames.current >= STILL_FRAMES && !reported.current) { reported.current = true; onSettled(readTopFace(body)); } } else { stillFrames.current = 0; } }); return null; } function RapierDiceExample({ diceRef, rolling, onSettled, faceUrls, }: { diceRef: React.RefObject; rolling: boolean; onSettled: (value: number) => void; faceUrls: string[]; }) { return ( ); } function rollDice(body: RapierRigidBody | null) { if (!body) return; body.wakeUp(); body.applyImpulse( { x: (Math.random() - 0.5) * 8, y: 4 + Math.random() * 4, z: (Math.random() - 0.5) * 8 }, true, ); body.applyTorqueImpulse( { x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 6, z: (Math.random() - 0.5) * 6 }, true, ); } export default function Dice({ onRolled, disabled = false, }: { onRolled?: (value: number) => void; disabled?: boolean; }) { const diceRef = useRef(null); const [rolling, setRolling] = useState(false); const [result, setResult] = useState(null); const [faceUrls, setFaceUrls] = useState([...DEFAULT_DICE_FACE_TEXTURES]); useEffect(() => { setFaceUrls(getDiceFaceTextureList()); }, []); const onSettled = useCallback( (value: number) => { setResult(value); setRolling(false); onRolled?.(value); }, [onRolled], ); const busy = rolling || disabled; return (
{rolling ? '굴리는 중…' : disabled ? '이동 중…' : result != null ? `결과: ${result}` : '결과: —'}
); }