Files
drinkmarble/app/game/dice.tsx
kimyu 347dfc1f4a 게임·입장 UX와 물리/고지 개선을 반영한다.
비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화,
개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 12:50:22 +09:00

620 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
});