'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, type Texture, } from 'three'; import { DEFAULT_ASSETS, DEFAULT_DICE_FLOOR_COLOR, getAssets, getDiceFaceTextureList, type DiceCount, } 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; const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice); // 결과 판정: 로컬 법선이 월드 +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); export type DiceRollResult = { /** 이동에 쓰는 합 */ sum: number; /** 각 주사위 윗면 */ faces: number[]; }; 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 FloorMesh({ color, imageSrc, }: { color: string; imageSrc: string | null; }) { const texture = useLoader( TextureLoader, imageSrc ?? '/background/default.png', ) as Texture; useLayoutEffect(() => { if (!imageSrc) return; texture.colorSpace = SRGBColorSpace; texture.anisotropy = 8; texture.needsUpdate = true; }, [imageSrc, texture]); return ( {imageSrc ? ( ) : ( )} ); } function FloorSurface({ color, imageSrc, }: { color: string; imageSrc: string | null; }) { if (!imageSrc) { return ( ); } return ( } > ); } function isBodyStill(body: RapierRigidBody) { 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; return speedSq < STILL_SPEED_SQ || body.isSleeping(); } function DiceSettleWatcher({ diceRefs, rolling, onSettled, }: { diceRefs: React.RefObject[]; rolling: boolean; onSettled: (result: DiceRollResult) => void; }) { const stillFrames = useRef(0); const reported = useRef(false); useFrame(() => { if (!rolling) { stillFrames.current = 0; reported.current = false; return; } const bodies = diceRefs.map((ref) => ref.current); if (bodies.some((body) => !body)) return; if (bodies.every((body) => isBodyStill(body!))) { stillFrames.current += 1; if (stillFrames.current >= STILL_FRAMES && !reported.current) { reported.current = true; const faces = bodies.map((body) => readTopFace(body!)); const sum = faces.reduce((a, b) => a + b, 0); onSettled({ sum, faces }); } } else { stillFrames.current = 0; } }); return null; } function RapierDiceExample({ diceRefs, diceCount, rolling, onSettled, faceUrlSets, floorColor, floorImage, }: { diceRefs: React.RefObject[]; diceCount: DiceCount; rolling: boolean; onSettled: (result: DiceRollResult) => void; faceUrlSets: string[][]; floorColor: string; floorImage: string | null; }) { const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0]; return ( {spawnX.map((x, index) => ( ))} ); } 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, ); } function formatResult(result: DiceRollResult | null) { if (!result) return '결과: —'; if (result.faces.length <= 1) return `결과: ${result.sum}`; return `결과: ${result.faces.join(' + ')} = ${result.sum}`; } export default function Dice({ onRolled, disabled = false, }: { onRolled?: (result: DiceRollResult) => void; disabled?: boolean; }) { const diceRefA = useRef(null); const diceRefB = useRef(null); const diceRefs = [diceRefA, diceRefB]; const [rolling, setRolling] = useState(false); const [result, setResult] = useState(null); const [diceCount, setDiceCount] = useState(1); const [faceUrlSets, setFaceUrlSets] = useState([ [...DEFAULT_DICE_FACE_TEXTURES], ]); const [floorColor, setFloorColor] = useState(DEFAULT_DICE_FLOOR_COLOR); const [floorImage, setFloorImage] = useState(null); useEffect(() => { const assets = getAssets(); setDiceCount(assets.diceCount); setFaceUrlSets( assets.diceCount === 2 ? [getDiceFaceTextureList(assets.dice), getDiceFaceTextureList(assets.dice2)] : [getDiceFaceTextureList(assets.dice)], ); setFloorColor(assets.diceFloorColor); setFloorImage(assets.diceFloorImage); }, []); const onSettled = useCallback( (next: DiceRollResult) => { setResult(next); setRolling(false); onRolled?.(next); }, [onRolled], ); const busy = rolling || disabled; return (
{rolling ? '굴리는 중…' : disabled ? '이동 중…' : formatResult(result)}
); }