Files
drinkmarble/app/game/dice.tsx
kimyu 1b99e3961d - 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.
- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
2026-09-08 01:03:21 +09:00

294 lines
7.5 KiB
TypeScript

'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,
} from 'three';
import { getDiceFaceTextureList } 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;
// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z (기본 경로, 런타임에 localStorage로 교체)
const DEFAULT_DICE_FACE_TEXTURES = [
'/dice/2.png',
'/dice/5.png',
'/dice/1.png',
'/dice/6.png',
'/dice/3.png',
'/dice/4.png',
] as const;
// 결과 판정: 로컬 법선이 월드 +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);
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]} />
<CuboidCollider args={[t, h, f]} position={[-(f + t), h, 0]} />
<CuboidCollider args={[f, h, t]} position={[0, h, f + t]} />
<CuboidCollider args={[f, h, t]} position={[0, h, -(f + t)]} />
</>
);
}
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 DiceSettleWatcher({
diceRef,
rolling,
onSettled,
}: {
diceRef: React.RefObject<RapierRigidBody | null>;
rolling: boolean;
onSettled: (value: number) => void;
}) {
const stillFrames = useRef(0);
const reported = useRef(false);
useFrame(() => {
const body = diceRef.current;
if (!body || !rolling) {
stillFrames.current = 0;
reported.current = false;
return;
}
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;
if (speedSq < STILL_SPEED_SQ || body.isSleeping()) {
stillFrames.current += 1;
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
reported.current = true;
onSettled(readTopFace(body));
}
} else {
stillFrames.current = 0;
}
});
return null;
}
function RapierDiceExample({
diceRef,
rolling,
onSettled,
faceUrls,
}: {
diceRef: React.RefObject<RapierRigidBody | null>;
rolling: boolean;
onSettled: (value: number) => void;
faceUrls: string[];
}) {
return (
<Canvas
orthographic
camera={{ position: [0, 20, 0.01], zoom: 1 }}
className="h-full w-full"
gl={{ alpha: true }}
>
<FitSquareFloor />
<ambientLight intensity={0.85} />
<directionalLight position={[4, 10, 2]} intensity={0.9} />
<Physics>
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
<meshBasicMaterial color="blue" />
</mesh>
<CuboidCollider args={[FLOOR_HALF, 0.05, FLOOR_HALF]} position={[0, -0.05, 0]} />
<InvisibleWalls />
</RigidBody>
<RigidBody
ref={diceRef}
type="dynamic"
position={[0, 4, 0]}
colliders="cuboid"
restitution={0.2}
friction={0.6}
>
<Suspense fallback={null}>
<DiceMesh key={faceUrls.join('|')} faceUrls={faceUrls} />
</Suspense>
</RigidBody>
<DiceSettleWatcher diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
</Physics>
</Canvas>
);
}
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,
);
}
export default function Dice({
onRolled,
disabled = false,
}: {
onRolled?: (value: number) => void;
disabled?: boolean;
}) {
const diceRef = useRef<RapierRigidBody>(null);
const [rolling, setRolling] = useState(false);
const [result, setResult] = useState<number | null>(null);
const [faceUrls, setFaceUrls] = useState<string[]>([...DEFAULT_DICE_FACE_TEXTURES]);
useEffect(() => {
setFaceUrls(getDiceFaceTextureList());
}, []);
const onSettled = useCallback(
(value: number) => {
setResult(value);
setRolling(false);
onRolled?.(value);
},
[onRolled],
);
const busy = rolling || disabled;
return (
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
<RapierDiceExample
diceRef={diceRef}
rolling={rolling}
onSettled={onSettled}
faceUrls={faceUrls}
/>
<button
type="button"
className="absolute bottom-2 left-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"
disabled={busy}
onClick={() => {
setResult(null);
setRolling(true);
rollDice(diceRef.current);
}}
>
Roll
</button>
<div className="pointer-events-none absolute right-2 bottom-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white">
{rolling
? '굴리는 중…'
: disabled
? '이동 중…'
: result != null
? `결과: ${result}`
: '결과: —'}
</div>
</div>
</div>
);
}