'use client'; import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'; import { Physics, RigidBody, CuboidCollider, type RapierRigidBody, } from '@react-three/rapier'; import { Euler, 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.035; const STILL_FRAMES = 24; const MIN_VIEW_PX = 16; /** * 과한 회전감 원인: * 1) 시작 자세가 완전 랜덤 → 처음부터 면이 빙글 * 2) 마찰이 선속도를 각속도로 바꿈 * 3) 충돌 접점이 COM에서 벗어나며 각속도 발생 * 대응: 세워 두고 벽 쪽 병진을 강하게, 각감쇠+각속도 상한으로 억제. */ const DICE_MASS = 4.5; const DICE_RESTITUTION = 0.06; const DICE_FRICTION = 0.38; const DICE_LINEAR_DAMPING = 0.18; const DICE_ANGULAR_DAMPING = 3.4; const DICE_GRAVITY_SCALE = 1.2; const FLOOR_FRICTION = 0.42; const FLOOR_RESTITUTION = 0.03; const WALL_FRICTION = 0.22; const WALL_RESTITUTION = 0.28; const PHYSICS_GRAVITY: [number, number, number] = [0, -12.5, 0]; /** 각속도 상한(rad/s) */ const MAX_ANGULAR_SPEED = 7.5; /** 벽 방향 초기 선속도 */ const WALL_PUSH_SPEED = 11.5; const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice); 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 _euler = new Euler(); 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; // 각속도만 과도하면 잘라서, 벽으로 미는 병진은 유지한 채 회전기만 줄임 for (const body of bodies) { if (!body) continue; const a = body.angvel(); const mag = Math.hypot(a.x, a.y, a.z); if (mag > MAX_ANGULAR_SPEED) { const s = MAX_ANGULAR_SPEED / mag; body.setAngvel({ x: a.x * s, y: a.y * s, z: a.z * s }, false); } } 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((acc, value) => acc + value, 0); onSettled({ sum, faces }); } } else { stillFrames.current = 0; } }); return null; } function RapierDiceExample({ diceRefs, diceCount, rolling, onSettled, faceUrlSets, floorColor, floorImage, canvasKey, }: { diceRefs: React.RefObject[]; diceCount: DiceCount; rolling: boolean; onSettled: (result: DiceRollResult) => void; faceUrlSets: string[][]; floorColor: string; floorImage: string | null; canvasKey: number; }) { const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0]; return ( {spawnX.map((x, index) => ( } > ))} ); } function rollDice(body: RapierRigidBody | null) { if (!body) return false; const t = body.translation(); body.wakeUp(); // 탁자 위에 거의 붙여 두고 밀어, 낙하 중 공중회전을 없앰 body.setTranslation({ x: t.x, y: 0.62, z: t.z }, true); body.setLinvel({ x: 0, y: 0, z: 0 }, true); body.setAngvel({ x: 0, y: 0, z: 0 }, true); // 네 벽 중 하나 (+ 약한 편향) const wallDirs: Array<[number, number]> = [ [1, 0], [-1, 0], [0, 1], [0, -1], ]; const [wx, wz] = wallDirs[Math.floor(Math.random() * wallDirs.length)]!; let dx = wx + (Math.random() - 0.5) * 0.35; let dz = wz + (Math.random() - 0.5) * 0.35; const dLen = Math.hypot(dx, dz) || 1; dx /= dLen; dz /= dLen; // 완전 랜덤 자세 대신: 세워 둔 채 약한 기울기 + 진행 방향 쪽 살짝 기울임 // (위에서 볼 때 면이 빙글빙글 도는 느낌을 크게 줄임) const yaw = Math.random() * Math.PI * 2; const tipToward = 0.18 + Math.random() * 0.22; // ~10–23° const tipSide = (Math.random() - 0.5) * 0.16; // 진행 방향이 +X면 Z축 회전으로 기울고, +Z면 X축 회전으로 기울게 _euler.set(-dz * tipToward + tipSide, yaw, dx * tipToward + tipSide, 'YXZ'); _quat.setFromEuler(_euler); body.setRotation({ x: _quat.x, y: _quat.y, z: _quat.z, w: _quat.w }, true); // 병진 위주: 선속도로 벽을 향해 밀기 (토크 임펄스 없음) const speed = WALL_PUSH_SPEED + Math.random() * 3.5; body.setLinvel({ x: dx * speed, y: 0.35 + Math.random() * 0.45, z: dz * speed }, true); // 질량 대비 추가 밀기(COM). 회전 임펄스는 넣지 않음 const pushImpulse = DICE_MASS * (2.8 + Math.random() * 1.2); body.applyImpulse({ x: dx * pushImpulse, y: 0.4, z: dz * pushImpulse }, true); return 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 memo(function Dice({ onRolled, disabled = false, requireDiceQueue = false, diceQueue = 0, onConsumeDice, }: { onRolled?: (result: DiceRollResult) => void; disabled?: boolean; requireDiceQueue?: boolean; diceQueue?: number; onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>; }) { const hostRef = useRef(null); const diceRefA = useRef(null); const diceRefB = useRef(null); const diceRefs = useMemo(() => [diceRefA, diceRefB], []); const [view, setView] = useState({ w: 0, h: 0, gen: 0 }); 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); const [queueHint, setQueueHint] = 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); }, []); // 칸 크기가 잡히기 전에 Canvas가 마운트되면 WebGL이 깨져 주사위가 안 보임 useEffect(() => { const el = hostRef.current; if (!el || typeof ResizeObserver === 'undefined') return; const apply = (width: number, height: number) => { const w = Math.floor(width); const h = Math.floor(height); setView((prev) => { const ready = w >= MIN_VIEW_PX && h >= MIN_VIEW_PX; const wasReady = prev.w >= MIN_VIEW_PX && prev.h >= MIN_VIEW_PX; if (!ready) return { w, h, gen: prev.gen }; if (!wasReady) return { w, h, gen: prev.gen + 1 }; if (Math.abs(w - prev.w) > 4 || Math.abs(h - prev.h) > 4) { return { w, h, gen: prev.gen + 1 }; } return prev; }); }; apply(el.clientWidth, el.clientHeight); const ro = new ResizeObserver((entries) => { const cr = entries[0]?.contentRect; if (!cr) return; apply(cr.width, cr.height); }); ro.observe(el); return () => ro.disconnect(); }, []); const onSettled = useCallback( (next: DiceRollResult) => { setResult(next); setRolling(false); onRolled?.(next); }, [onRolled], ); const canvasReady = view.w >= MIN_VIEW_PX && view.h >= MIN_VIEW_PX && view.gen > 0; const busy = rolling || disabled || !canvasReady || (requireDiceQueue && diceQueue <= 0); return (
{canvasReady ? ( ) : (
주사위 준비 중…
)}
{queueHint ? queueHint : !canvasReady ? '주사위 준비 중…' : rolling ? '굴리는 중…' : disabled ? '이동 중…' : requireDiceQueue && diceQueue <= 0 ? '후원 대기 중…' : formatResult(result)}
); });