게임·입장 UX와 물리/고지 개선을 반영한다.

비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화,
개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kimyu
2026-09-21 12:50:22 +09:00
parent 73520f9f06
commit 347dfc1f4a
21 changed files with 316 additions and 148 deletions

View File

@@ -12,10 +12,16 @@ export const CELL_TYPE_PRESETS = [
'다같이한잔',
'영어금지 3분',
'두잔마셔',
'애교/매도 100자',
'애교/매도 50자',
'무인도',
'역방향',
'세계여행',
'TMI 알려주기',
'뒤로 3칸',
'앞으로 4칸',
'방어권',
'방어권 전부삭제',
'물한잔',
] as const;
export type CellEventOption = {
@@ -36,7 +42,7 @@ export type BoardCellConfig = {
eventId?: string | null;
/** 칸 전용 이미지 (data URL 또는 상대 경로) */
imageSrc?: string | null;
/** false면 게임에서 이름·이미지를 숨김 (기본 true) */
/** false면 게임에서 칸 이름(텍스트)만 숨김 (이미지는 유지) */
contentVisible?: boolean;
};
@@ -59,27 +65,27 @@ const DEFAULT_BOARD_LAYOUT: ReadonlyArray<{
{ type: '시작', eventId: null },
{ type: '한잔마셔' },
{ type: '~~체 3분' },
{ type: '안주 한입' },
{ type: '방어권' },
{ type: '노래 1절' },
{ type: '다같이한잔' },
{ type: '영어금지 3분' },
{ type: '애교/매도 100자' },
{ type: '앞으로 4칸' },
{ type: '애교/매도 50자' },
{ type: '무인도', eventId: 'island' },
{ type: '안주 한입' },
{ type: '두잔마셔' },
{ type: '~~체 3분' },
{ type: '물한잔' },
{ type: '한잔마셔' },
{ type: '애교/매도 100자' },
{ type: 'TMI 알려주기' },
{ type: '역방향', eventId: 'reverse' },
{ type: '영어금지 3분' },
{ type: '뒤로 3칸' },
{ type: '안주 한입' },
{ type: '다같이한잔' },
{ type: '노래 1절' },
{ type: '방어권 전부삭제' },
{ type: '한잔마셔' },
{ type: '~~체 3분' },
{ type: '두잔마셔' },
{ type: '세계여행', eventId: 'airplane' },
{ type: '애교/매도 100자' },
{ type: '애교/매도 50자' },
{ type: '안주 한입' },
{ type: '영어금지 3분' },
{ type: '다같이한잔' },
@@ -439,12 +445,7 @@ export function buildPlaneCells(config: BoardConfig = getOrCreateBoardConfig()):
export function cellColorFor(
eventId: string | null | undefined,
index: number,
options?: { contentVisible?: boolean },
) {
// 내용 숨김이면 종류가 색으로도 드러나지 않게 통일
if (options?.contentVisible === false && index !== 0) {
return '#be123c';
}
if (index === 0) return '#059669';
switch (eventId) {
case 'island':
@@ -458,7 +459,9 @@ export function cellColorFor(
}
}
/** 주사위 UI를 안쪽 빈 칸에 배치할 때 쓰는 span (1~2) */
/** 주사위 UI를 안쪽 빈 칸에 배치할 때 쓰는 span (1~2)
* 세로 7칸 기준: 시작 행을 건너뛰고 안쪽 2칸 = 절대 위치 3칸까지
*/
export function diceAreaSpan(width: number, height: number) {
return Math.min(2, Math.max(1, width - 2), Math.max(1, height - 2));
}

View File

@@ -10,7 +10,7 @@ export type PlaneCell = {
eventId?: string | null;
/** 칸 이미지 (없으면 텍스트만) */
imageSrc?: string | null;
/** false면 게임 판에서 이름·이미지를 숨김 */
/** false면 게임 판에서 이름(텍스트)만 숨김 */
contentVisible?: boolean;
};

View File

@@ -34,15 +34,15 @@ export default function ChatFeedPanel({
<div
className={
compact
? 'flex h-full max-h-[130px] min-h-0 w-full flex-col overflow-hidden rounded-md bg-black/75 text-white shadow-md'
? 'flex h-full max-h-[200px] min-h-0 w-full flex-col overflow-hidden rounded-md bg-black/75 text-white shadow-md'
: 'flex h-full min-h-0 w-full flex-col overflow-hidden rounded-lg bg-black/70 text-white shadow-lg'
}
>
<div
className={
compact
? 'shrink-0 border-b border-white/10 px-1.5 py-0.5 text-[10px] font-semibold text-white/80'
: 'shrink-0 border-b border-white/10 px-2 py-1 text-[11px] font-semibold text-white/80'
? 'shrink-0 border-b border-white/10 px-1.5 py-0.5 text-[20px] font-semibold text-white/80'
: 'shrink-0 border-b border-white/10 px-2 py-1 text-[22px] font-semibold text-white/80'
}
>
채팅
@@ -51,8 +51,8 @@ export default function ChatFeedPanel({
ref={listRef}
className={
compact
? 'min-h-0 flex-1 space-y-0.5 overflow-y-auto px-1.5 py-1 text-[10px] leading-snug'
: 'min-h-0 flex-1 space-y-1 overflow-y-auto px-2 py-1.5 text-[11px] leading-snug'
? 'min-h-0 flex-1 space-y-0.5 overflow-y-auto px-1.5 py-1 text-[20px] leading-snug'
: 'min-h-0 flex-1 space-y-1 overflow-y-auto px-2 py-1.5 text-[22px] leading-snug'
}
>
{items.length === 0 ? (

View File

@@ -18,6 +18,7 @@ import {
type RapierRigidBody,
} from '@react-three/rapier';
import {
Euler,
Quaternion,
SRGBColorSpace,
TextureLoader,
@@ -40,10 +41,33 @@ 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 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 = [
@@ -56,6 +80,7 @@ const DICE_FACES = [
];
const _quat = new Quaternion();
const _euler = new Euler();
const _normal = new Vector3();
const WORLD_UP = new Vector3(0, 1, 0);
@@ -105,10 +130,30 @@ function InvisibleWalls() {
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)]} />
<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}
/>
</>
);
}
@@ -231,12 +276,23 @@ function DiceSettleWatcher({
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((a, b) => a + b, 0);
const sum = faces.reduce((acc, value) => acc + value, 0);
onSettled({ sum, faces });
}
} else {
@@ -280,14 +336,19 @@ function RapierDiceExample({
<FitSquareFloor />
<ambientLight intensity={0.85} />
<directionalLight position={[4, 10, 2]} intensity={0.9} />
<Physics>
<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]} />
<CuboidCollider
args={[FLOOR_HALF, 0.05, FLOOR_HALF]}
position={[0, -0.05, 0]}
friction={FLOOR_FRICTION}
restitution={FLOOR_RESTITUTION}
/>
<InvisibleWalls />
</RigidBody>
@@ -296,10 +357,14 @@ function RapierDiceExample({
key={`die-${index}-${diceCount}-${canvasKey}`}
ref={diceRefs[index]}
type="dynamic"
position={[x, 4, index === 0 ? 0 : 0.15]}
position={[x, 0.65, index === 0 ? 0 : 0.15]}
colliders="cuboid"
restitution={0.2}
friction={0.6}
mass={DICE_MASS}
restitution={DICE_RESTITUTION}
friction={DICE_FRICTION}
linearDamping={DICE_LINEAR_DAMPING}
angularDamping={DICE_ANGULAR_DAMPING}
gravityScale={DICE_GRAVITY_SCALE}
>
<Suspense
fallback={
@@ -330,18 +395,45 @@ function RapierDiceExample({
function rollDice(body: RapierRigidBody | null) {
if (!body) return false;
const t = body.translation();
body.wakeUp();
body.setTranslation({ x: body.translation().x, y: 4, z: body.translation().z }, true);
// 탁자 위에 거의 붙여 두고 밀어, 낙하 중 공중회전을 없앰
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);
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,
);
// 네 벽 중 하나 (+ 약한 편향)
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;
}
@@ -436,10 +528,13 @@ export default memo(function Dice({
rolling || disabled || !canvasReady || (requireDiceQueue && diceQueue <= 0);
return (
<div className="relative h-full w-full min-h-0 min-w-0" style={{ containerType: 'size' }}>
<div
className="relative flex h-full w-full min-h-0 min-w-0 flex-col"
style={{ containerType: 'size' }}
>
<div
ref={hostRef}
className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)] bg-sky-700/90"
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
@@ -457,9 +552,12 @@ export default memo(function Dice({
주사위 준비 중…
</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="absolute bottom-2 left-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"
className="rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"
disabled={busy}
onClick={() => {
void (async () => {
@@ -502,7 +600,7 @@ export default memo(function Dice({
>
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">
<div className="rounded bg-black/70 px-3 py-1 text-sm text-white">
{queueHint
? queueHint
: !canvasReady

View File

@@ -52,11 +52,7 @@ export default function DicePlane({
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
const [hopKey, setHopKey] = useState(0);
const [moving, setMoving] = useState(false);
const [chatOpen, setChatOpen] = useState(true);
useEffect(() => {
if (showChatFeed) setChatOpen(true);
}, [showChatFeed]);
const [chatOpen, setChatOpen] = useState(false);
const [landedLabel, setLandedLabel] = useState<string | null>(null);
const [landedKey, setLandedKey] = useState(0);
const [statusText, setStatusText] = useState('시계방향');
@@ -243,7 +239,7 @@ export default function DicePlane({
{dicePlaneArray.map((plane, index) => {
const isStart = index === 0;
const isLanded = !moving && index === chessmanIndex && landedLabel != null;
const showContent = plane.contentVisible !== false;
const showText = plane.contentVisible !== false;
return (
<div
key={plane.id}
@@ -263,11 +259,8 @@ export default function DicePlane({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: cellColorFor(plane.eventId, index, {
contentVisible: showContent,
}),
backgroundImage:
showContent && plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
backgroundColor: cellColorFor(plane.eventId, index),
backgroundImage: plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
color: 'white',
@@ -288,13 +281,10 @@ export default function DicePlane({
zIndex: pickingDestination ? 8 : isLanded ? 4 : undefined,
cursor: pickingDestination ? 'pointer' : undefined,
pointerEvents: pickingDestination ? 'auto' : undefined,
textShadow:
showContent && plane.imageSrc
? '0 1px 3px rgba(0,0,0,0.85)'
: undefined,
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
}}
>
{showContent ? (isStart ? '시작' : plane.type) : null}
{showText ? (isStart ? '시작' : plane.type) : null}
</div>
);
})}