411 lines
10 KiB
TypeScript
411 lines
10 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,
|
|
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 DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice);
|
|
|
|
// 결과 판정: 로컬 법선이 월드 +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);
|
|
|
|
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]} />
|
|
<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 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;
|
|
|
|
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,
|
|
}: {
|
|
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
|
diceCount: DiceCount;
|
|
rolling: boolean;
|
|
onSettled: (result: DiceRollResult) => void;
|
|
faceUrlSets: string[][];
|
|
floorColor: string;
|
|
floorImage: string | null;
|
|
}) {
|
|
const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0];
|
|
|
|
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]}>
|
|
<FloorSurface
|
|
key={`${floorColor}|${floorImage ?? ''}`}
|
|
color={floorColor}
|
|
imageSrc={floorImage}
|
|
/>
|
|
<CuboidCollider args={[FLOOR_HALF, 0.05, FLOOR_HALF]} position={[0, -0.05, 0]} />
|
|
<InvisibleWalls />
|
|
</RigidBody>
|
|
|
|
{spawnX.map((x, index) => (
|
|
<RigidBody
|
|
key={`die-${index}-${diceCount}`}
|
|
ref={diceRefs[index]}
|
|
type="dynamic"
|
|
position={[x, 4, index === 0 ? 0 : 0.15]}
|
|
colliders="cuboid"
|
|
restitution={0.2}
|
|
friction={0.6}
|
|
>
|
|
<Suspense fallback={null}>
|
|
<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;
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
function formatResult(result: DiceRollResult | null) {
|
|
if (!result) return '결과: —';
|
|
if (result.faces.length <= 1) return `결과: ${result.sum}`;
|
|
return `결과: ${result.faces.join(' + ')} = ${result.sum}`;
|
|
}
|
|
|
|
export default function Dice({
|
|
onRolled,
|
|
disabled = false,
|
|
}: {
|
|
onRolled?: (result: DiceRollResult) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const diceRefA = useRef<RapierRigidBody>(null);
|
|
const diceRefB = useRef<RapierRigidBody>(null);
|
|
const diceRefs = [diceRefA, diceRefB];
|
|
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);
|
|
|
|
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);
|
|
}, []);
|
|
|
|
const onSettled = useCallback(
|
|
(next: DiceRollResult) => {
|
|
setResult(next);
|
|
setRolling(false);
|
|
onRolled?.(next);
|
|
},
|
|
[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
|
|
diceRefs={diceRefs}
|
|
diceCount={diceCount}
|
|
rolling={rolling}
|
|
onSettled={onSettled}
|
|
faceUrlSets={faceUrlSets}
|
|
floorColor={floorColor}
|
|
floorImage={floorImage}
|
|
/>
|
|
<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);
|
|
for (let i = 0; i < diceCount; i++) {
|
|
rollDice(diceRefs[i].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
|
|
? '이동 중…'
|
|
: formatResult(result)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|