커스텀 필요한 항목들 모두 커스텀 가능하도록 수정.
This commit is contained in:
@@ -14,8 +14,15 @@ import {
|
||||
TextureLoader,
|
||||
Vector3,
|
||||
type OrthographicCamera,
|
||||
type Texture,
|
||||
} from 'three';
|
||||
import { getDiceFaceTextureList } from './asset-storage';
|
||||
import {
|
||||
DEFAULT_ASSETS,
|
||||
DEFAULT_DICE_FLOOR_COLOR,
|
||||
getAssets,
|
||||
getDiceFaceTextureList,
|
||||
type DiceCount,
|
||||
} from './asset-storage';
|
||||
|
||||
const FLOOR_SIZE = 6;
|
||||
const FLOOR_HALF = FLOOR_SIZE / 2;
|
||||
@@ -27,15 +34,7 @@ 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;
|
||||
const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice);
|
||||
|
||||
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
||||
const DICE_FACES = [
|
||||
@@ -51,6 +50,13 @@ 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);
|
||||
@@ -127,36 +133,104 @@ function DiceMesh({ faceUrls }: { faceUrls: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
diceRef,
|
||||
diceRefs,
|
||||
rolling,
|
||||
onSettled,
|
||||
}: {
|
||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
||||
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||
rolling: boolean;
|
||||
onSettled: (value: number) => void;
|
||||
onSettled: (result: DiceRollResult) => void;
|
||||
}) {
|
||||
const stillFrames = useRef(0);
|
||||
const reported = useRef(false);
|
||||
|
||||
useFrame(() => {
|
||||
const body = diceRef.current;
|
||||
if (!body || !rolling) {
|
||||
if (!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;
|
||||
const bodies = diceRefs.map((ref) => ref.current);
|
||||
if (bodies.some((body) => !body)) return;
|
||||
|
||||
if (speedSq < STILL_SPEED_SQ || body.isSleeping()) {
|
||||
if (bodies.every((body) => isBodyStill(body!))) {
|
||||
stillFrames.current += 1;
|
||||
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
|
||||
reported.current = true;
|
||||
onSettled(readTopFace(body));
|
||||
const faces = bodies.map((body) => readTopFace(body!));
|
||||
const sum = faces.reduce((a, b) => a + b, 0);
|
||||
onSettled({ sum, faces });
|
||||
}
|
||||
} else {
|
||||
stillFrames.current = 0;
|
||||
@@ -167,16 +241,24 @@ function DiceSettleWatcher({
|
||||
}
|
||||
|
||||
function RapierDiceExample({
|
||||
diceRef,
|
||||
diceRefs,
|
||||
diceCount,
|
||||
rolling,
|
||||
onSettled,
|
||||
faceUrls,
|
||||
faceUrlSets,
|
||||
floorColor,
|
||||
floorImage,
|
||||
}: {
|
||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
||||
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||
diceCount: DiceCount;
|
||||
rolling: boolean;
|
||||
onSettled: (value: number) => void;
|
||||
faceUrls: string[];
|
||||
onSettled: (result: DiceRollResult) => void;
|
||||
faceUrlSets: string[][];
|
||||
floorColor: string;
|
||||
floorImage: string | null;
|
||||
}) {
|
||||
const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0];
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
orthographic
|
||||
@@ -189,28 +271,39 @@ function RapierDiceExample({
|
||||
<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>
|
||||
<FloorSurface
|
||||
key={`${floorColor}|${floorImage ?? ''}`}
|
||||
color={floorColor}
|
||||
imageSrc={floorImage}
|
||||
/>
|
||||
<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>
|
||||
{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 diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
|
||||
<DiceSettleWatcher
|
||||
diceRefs={diceRefs.slice(0, diceCount)}
|
||||
rolling={rolling}
|
||||
onSettled={onSettled}
|
||||
/>
|
||||
</Physics>
|
||||
</Canvas>
|
||||
);
|
||||
@@ -230,27 +323,48 @@ function rollDice(body: RapierRigidBody | null) {
|
||||
);
|
||||
}
|
||||
|
||||
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?: (value: number) => void;
|
||||
onRolled?: (result: DiceRollResult) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const diceRef = useRef<RapierRigidBody>(null);
|
||||
const diceRefA = useRef<RapierRigidBody>(null);
|
||||
const diceRefB = useRef<RapierRigidBody>(null);
|
||||
const diceRefs = [diceRefA, diceRefB];
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [faceUrls, setFaceUrls] = useState<string[]>([...DEFAULT_DICE_FACE_TEXTURES]);
|
||||
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(() => {
|
||||
setFaceUrls(getDiceFaceTextureList());
|
||||
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(
|
||||
(value: number) => {
|
||||
setResult(value);
|
||||
(next: DiceRollResult) => {
|
||||
setResult(next);
|
||||
setRolling(false);
|
||||
onRolled?.(value);
|
||||
onRolled?.(next);
|
||||
},
|
||||
[onRolled],
|
||||
);
|
||||
@@ -261,10 +375,13 @@ export default function Dice({
|
||||
<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}
|
||||
diceRefs={diceRefs}
|
||||
diceCount={diceCount}
|
||||
rolling={rolling}
|
||||
onSettled={onSettled}
|
||||
faceUrls={faceUrls}
|
||||
faceUrlSets={faceUrlSets}
|
||||
floorColor={floorColor}
|
||||
floorImage={floorImage}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -273,7 +390,9 @@ export default function Dice({
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setRolling(true);
|
||||
rollDice(diceRef.current);
|
||||
for (let i = 0; i < diceCount; i++) {
|
||||
rollDice(diceRefs[i].current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Roll
|
||||
@@ -283,9 +402,7 @@ export default function Dice({
|
||||
? '굴리는 중…'
|
||||
: disabled
|
||||
? '이동 중…'
|
||||
: result != null
|
||||
? `결과: ${result}`
|
||||
: '결과: —'}
|
||||
: formatResult(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user