'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 { 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 MIN_VIEW_PX = 16; 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 _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, 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; body.wakeUp(); body.setTranslation({ x: body.translation().x, y: 4, z: body.translation().z }, true); body.setLinvel({ x: 0, y: 0, z: 0 }, true); body.setAngvel({ x: 0, y: 0, z: 0 }, true); 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, ); 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)}
); });