치지직 채팅창 인식 추가 및 주사위 횟수 증가 추가.
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
memo,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber';
|
||||
import {
|
||||
Physics,
|
||||
@@ -33,10 +42,10 @@ const WALL_HALF_H = WALL_HEIGHT / 2;
|
||||
|
||||
const STILL_SPEED_SQ = 0.05;
|
||||
const STILL_FRAMES = 18;
|
||||
const MIN_VIEW_PX = 16;
|
||||
|
||||
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) },
|
||||
@@ -51,9 +60,7 @@ const _normal = new Vector3();
|
||||
const WORLD_UP = new Vector3(0, 1, 0);
|
||||
|
||||
export type DiceRollResult = {
|
||||
/** 이동에 쓰는 합 */
|
||||
sum: number;
|
||||
/** 각 주사위 윗면 */
|
||||
faces: number[];
|
||||
};
|
||||
|
||||
@@ -248,6 +255,7 @@ function RapierDiceExample({
|
||||
faceUrlSets,
|
||||
floorColor,
|
||||
floorImage,
|
||||
canvasKey,
|
||||
}: {
|
||||
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||
diceCount: DiceCount;
|
||||
@@ -256,15 +264,18 @@ function RapierDiceExample({
|
||||
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 }}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
resize={{ scroll: false, debounce: { scroll: 0, resize: 0 } }}
|
||||
>
|
||||
<FitSquareFloor />
|
||||
<ambientLight intensity={0.85} />
|
||||
@@ -282,7 +293,7 @@ function RapierDiceExample({
|
||||
|
||||
{spawnX.map((x, index) => (
|
||||
<RigidBody
|
||||
key={`die-${index}-${diceCount}`}
|
||||
key={`die-${index}-${diceCount}-${canvasKey}`}
|
||||
ref={diceRefs[index]}
|
||||
type="dynamic"
|
||||
position={[x, 4, index === 0 ? 0 : 0.15]}
|
||||
@@ -290,7 +301,14 @@ function RapierDiceExample({
|
||||
restitution={0.2}
|
||||
friction={0.6}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<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}
|
||||
@@ -310,9 +328,12 @@ function RapierDiceExample({
|
||||
}
|
||||
|
||||
function rollDice(body: RapierRigidBody | null) {
|
||||
if (!body) return;
|
||||
if (!body) return false;
|
||||
|
||||
body.wakeUp();
|
||||
body.setTranslation({ x: body.translation().x, y: 4, z: body.translation().z }, true);
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.applyImpulse(
|
||||
{ x: (Math.random() - 0.5) * 8, y: 4 + Math.random() * 4, z: (Math.random() - 0.5) * 8 },
|
||||
true,
|
||||
@@ -321,6 +342,7 @@ function rollDice(body: RapierRigidBody | null) {
|
||||
{ x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 6, z: (Math.random() - 0.5) * 6 },
|
||||
true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatResult(result: DiceRollResult | null) {
|
||||
@@ -329,16 +351,25 @@ function formatResult(result: DiceRollResult | null) {
|
||||
return `결과: ${result.faces.join(' + ')} = ${result.sum}`;
|
||||
}
|
||||
|
||||
export default function Dice({
|
||||
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 = [diceRefA, diceRefB];
|
||||
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);
|
||||
@@ -347,6 +378,7 @@ export default function Dice({
|
||||
]);
|
||||
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();
|
||||
@@ -360,6 +392,36 @@ export default function Dice({
|
||||
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);
|
||||
@@ -369,42 +431,91 @@ export default function Dice({
|
||||
[onRolled],
|
||||
);
|
||||
|
||||
const busy = rolling || disabled;
|
||||
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 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}
|
||||
/>
|
||||
<div className="relative h-full w-full min-h-0 min-w-0" style={{ containerType: 'size' }}>
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)] 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>
|
||||
)}
|
||||
<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);
|
||||
}
|
||||
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
|
||||
Roll{requireDiceQueue ? ` (${diceQueue})` : ''}
|
||||
</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)}
|
||||
{queueHint
|
||||
? queueHint
|
||||
: !canvasReady
|
||||
? '주사위 준비 중…'
|
||||
: rolling
|
||||
? '굴리는 중…'
|
||||
: disabled
|
||||
? '이동 중…'
|
||||
: requireDiceQueue && diceQueue <= 0
|
||||
? '후원 대기 중…'
|
||||
: formatResult(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user