비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화, 개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
620 lines
18 KiB
TypeScript
620 lines
18 KiB
TypeScript
'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 (
|
||
<>
|
||
<CuboidCollider
|
||
args={[t, h, f]}
|
||
position={[f + t, h, 0]}
|
||
friction={WALL_FRICTION}
|
||
restitution={WALL_RESTITUTION}
|
||
/>
|
||
<CuboidCollider
|
||
args={[t, h, f]}
|
||
position={[-(f + t), h, 0]}
|
||
friction={WALL_FRICTION}
|
||
restitution={WALL_RESTITUTION}
|
||
/>
|
||
<CuboidCollider
|
||
args={[f, h, t]}
|
||
position={[0, h, f + t]}
|
||
friction={WALL_FRICTION}
|
||
restitution={WALL_RESTITUTION}
|
||
/>
|
||
<CuboidCollider
|
||
args={[f, h, t]}
|
||
position={[0, h, -(f + t)]}
|
||
friction={WALL_FRICTION}
|
||
restitution={WALL_RESTITUTION}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<mesh castShadow>
|
||
<boxGeometry args={[1, 1, 1]} />
|
||
{textures.map((texture, index) => (
|
||
<meshStandardMaterial
|
||
key={`${texture.uuid}-${index}`}
|
||
attach={`material-${index}`}
|
||
map={texture}
|
||
roughness={0.55}
|
||
metalness={0.05}
|
||
/>
|
||
))}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||
{imageSrc ? (
|
||
<meshBasicMaterial map={texture} />
|
||
) : (
|
||
<meshBasicMaterial color={color} />
|
||
)}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
function FloorSurface({
|
||
color,
|
||
imageSrc,
|
||
}: {
|
||
color: string;
|
||
imageSrc: string | null;
|
||
}) {
|
||
if (!imageSrc) {
|
||
return (
|
||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||
<meshBasicMaterial color={color} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Suspense
|
||
fallback={
|
||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||
<meshBasicMaterial color={color} />
|
||
</mesh>
|
||
}
|
||
>
|
||
<FloorMesh color={color} imageSrc={imageSrc} />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
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<RapierRigidBody | null>[];
|
||
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<RapierRigidBody | null>[];
|
||
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 (
|
||
<Canvas
|
||
key={canvasKey}
|
||
orthographic
|
||
camera={{ position: [0, 20, 0.01], zoom: 1 }}
|
||
className="h-full w-full"
|
||
gl={{ alpha: true, antialias: true }}
|
||
resize={{ scroll: false, debounce: { scroll: 0, resize: 0 } }}
|
||
>
|
||
<FitSquareFloor />
|
||
<ambientLight intensity={0.85} />
|
||
<directionalLight position={[4, 10, 2]} intensity={0.9} />
|
||
<Physics gravity={PHYSICS_GRAVITY}>
|
||
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
|
||
<FloorSurface
|
||
key={`${floorColor}|${floorImage ?? ''}`}
|
||
color={floorColor}
|
||
imageSrc={floorImage}
|
||
/>
|
||
<CuboidCollider
|
||
args={[FLOOR_HALF, 0.05, FLOOR_HALF]}
|
||
position={[0, -0.05, 0]}
|
||
friction={FLOOR_FRICTION}
|
||
restitution={FLOOR_RESTITUTION}
|
||
/>
|
||
<InvisibleWalls />
|
||
</RigidBody>
|
||
|
||
{spawnX.map((x, index) => (
|
||
<RigidBody
|
||
key={`die-${index}-${diceCount}-${canvasKey}`}
|
||
ref={diceRefs[index]}
|
||
type="dynamic"
|
||
position={[x, 0.65, index === 0 ? 0 : 0.15]}
|
||
colliders="cuboid"
|
||
mass={DICE_MASS}
|
||
restitution={DICE_RESTITUTION}
|
||
friction={DICE_FRICTION}
|
||
linearDamping={DICE_LINEAR_DAMPING}
|
||
angularDamping={DICE_ANGULAR_DAMPING}
|
||
gravityScale={DICE_GRAVITY_SCALE}
|
||
>
|
||
<Suspense
|
||
fallback={
|
||
<mesh>
|
||
<boxGeometry args={[1, 1, 1]} />
|
||
<meshStandardMaterial color="#f8fafc" />
|
||
</mesh>
|
||
}
|
||
>
|
||
<DiceMesh
|
||
key={faceUrlSets[index]?.join('|') ?? index}
|
||
faceUrls={faceUrlSets[index] ?? DEFAULT_DICE_FACE_TEXTURES}
|
||
/>
|
||
</Suspense>
|
||
</RigidBody>
|
||
))}
|
||
|
||
<DiceSettleWatcher
|
||
diceRefs={diceRefs.slice(0, diceCount)}
|
||
rolling={rolling}
|
||
onSettled={onSettled}
|
||
/>
|
||
</Physics>
|
||
</Canvas>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement>(null);
|
||
const diceRefA = useRef<RapierRigidBody>(null);
|
||
const diceRefB = useRef<RapierRigidBody>(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<DiceRollResult | null>(null);
|
||
const [diceCount, setDiceCount] = useState<DiceCount>(1);
|
||
const [faceUrlSets, setFaceUrlSets] = useState<string[][]>([
|
||
[...DEFAULT_DICE_FACE_TEXTURES],
|
||
]);
|
||
const [floorColor, setFloorColor] = useState(DEFAULT_DICE_FLOOR_COLOR);
|
||
const [floorImage, setFloorImage] = useState<string | null>(null);
|
||
const [queueHint, setQueueHint] = useState<string | null>(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 (
|
||
<div
|
||
className="relative flex h-full w-full min-h-0 min-w-0 flex-col"
|
||
style={{ containerType: 'size' }}
|
||
>
|
||
<div
|
||
ref={hostRef}
|
||
className="aspect-square w-[min(100%,calc(100cqh-2.1rem))] max-h-[calc(100%-2.1rem)] min-h-0 grow bg-sky-700/90"
|
||
>
|
||
{canvasReady ? (
|
||
<RapierDiceExample
|
||
diceRefs={diceRefs}
|
||
diceCount={diceCount}
|
||
rolling={rolling}
|
||
onSettled={onSettled}
|
||
faceUrlSets={faceUrlSets}
|
||
floorColor={floorColor}
|
||
floorImage={floorImage}
|
||
canvasKey={view.gen}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center text-xs text-white/70">
|
||
주사위 준비 중…
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-0.5 flex min-h-[1.85rem] w-[min(100%,calc(100cqh-2.1rem))] max-w-full shrink-0 items-center justify-between gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
void (async () => {
|
||
setQueueHint(null);
|
||
if (!canvasReady) {
|
||
setQueueHint('주사위 준비 중…');
|
||
return;
|
||
}
|
||
if (requireDiceQueue) {
|
||
if (!onConsumeDice) {
|
||
setQueueHint('주사위 큐 연동 오류');
|
||
return;
|
||
}
|
||
const consumed = await onConsumeDice(1);
|
||
if (!consumed.ok) {
|
||
setQueueHint(consumed.error || '남은 주사위 없음');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 물리 바디가 아직 없으면 한 프레임 뒤 재시도
|
||
const tryRoll = (attempt: number) => {
|
||
const bodies = Array.from({ length: diceCount }, (_, i) => diceRefs[i].current);
|
||
if (bodies.some((b) => !b)) {
|
||
if (attempt < 8) {
|
||
requestAnimationFrame(() => tryRoll(attempt + 1));
|
||
return;
|
||
}
|
||
setQueueHint('주사위 준비 중…');
|
||
setRolling(false);
|
||
return;
|
||
}
|
||
setResult(null);
|
||
setRolling(true);
|
||
for (const body of bodies) rollDice(body);
|
||
};
|
||
tryRoll(0);
|
||
})();
|
||
}}
|
||
>
|
||
Roll{requireDiceQueue ? ` (${diceQueue})` : ''}
|
||
</button>
|
||
<div className="rounded bg-black/70 px-3 py-1 text-sm text-white">
|
||
{queueHint
|
||
? queueHint
|
||
: !canvasReady
|
||
? '주사위 준비 중…'
|
||
: rolling
|
||
? '굴리는 중…'
|
||
: disabled
|
||
? '이동 중…'
|
||
: requireDiceQueue && diceQueue <= 0
|
||
? '후원 대기 중…'
|
||
: formatResult(result)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|