Compare commits
2 Commits
feature/ch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e93c0bafa | ||
|
|
347dfc1f4a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,6 +33,9 @@ yarn-error.log*
|
|||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# local runtime logs / pids
|
||||||
|
/logs/
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
ensureRoomChat,
|
ensureRoomChat,
|
||||||
} from '@/lib/chzzk/chat-session';
|
} from '@/lib/chzzk/chat-session';
|
||||||
import {
|
import {
|
||||||
|
clearDiceQueue,
|
||||||
createRoom,
|
createRoom,
|
||||||
findRoomByChannelId,
|
findRoomByChannelId,
|
||||||
getRoom,
|
getRoom,
|
||||||
|
notifyRoom,
|
||||||
} from '@/lib/rooms/store';
|
} from '@/lib/rooms/store';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -87,6 +89,12 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelScheduledDisconnectRoomChat(room.id);
|
cancelScheduledDisconnectRoomChat(room.id);
|
||||||
|
|
||||||
|
if (decodedState.resetDiceQueue) {
|
||||||
|
clearDiceQueue(room.id);
|
||||||
|
notifyRoom(room.id);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureRoomChat(room.id);
|
await ensureRoomChat(room.id);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -100,12 +108,13 @@ export async function GET(request: Request) {
|
|||||||
res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions());
|
res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions());
|
||||||
res.cookies.delete(OAUTH_STATE_COOKIE);
|
res.cookies.delete(OAUTH_STATE_COOKIE);
|
||||||
return res;
|
return res;
|
||||||
} catch (e) {
|
} catch {
|
||||||
if (accessToken) await revokeToken(accessToken, 'access_token');
|
if (accessToken) await revokeToken(accessToken, 'access_token');
|
||||||
if (refreshToken) await revokeToken(refreshToken, 'refresh_token');
|
if (refreshToken) await revokeToken(refreshToken, 'refresh_token');
|
||||||
const message =
|
// 외부 오류 원문에는 계정 식별자 등이 포함될 수 있어 고정 문구만 전달한다.
|
||||||
e instanceof Error ? e.message : '치지직 로그인에 실패했습니다.';
|
const res = NextResponse.redirect(
|
||||||
const res = NextResponse.redirect(homeRedirect(origin, message));
|
homeRedirect(origin, '치지직 로그인에 실패했습니다. 다시 시도해 주세요.'),
|
||||||
|
);
|
||||||
res.cookies.delete(OAUTH_STATE_COOKIE);
|
res.cookies.delete(OAUTH_STATE_COOKIE);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ export async function GET(request: Request) {
|
|||||||
const cheesePerDice = Number(url.searchParams.get('cheesePerDice') || 1000);
|
const cheesePerDice = Number(url.searchParams.get('cheesePerDice') || 1000);
|
||||||
const multiplyByCheese = url.searchParams.get('multiplyByCheese') !== '0';
|
const multiplyByCheese = url.searchParams.get('multiplyByCheese') !== '0';
|
||||||
const maxMultiplier = Number(url.searchParams.get('maxMultiplier') || 0);
|
const maxMultiplier = Number(url.searchParams.get('maxMultiplier') || 0);
|
||||||
|
const resetDiceQueue = url.searchParams.get('resetDiceQueue') === '1';
|
||||||
|
|
||||||
const { state, sealed } = createOAuthState({
|
const { state, sealed } = createOAuthState({
|
||||||
cheesePerDice: Number.isFinite(cheesePerDice) ? cheesePerDice : 1000,
|
cheesePerDice: Number.isFinite(cheesePerDice) ? cheesePerDice : 1000,
|
||||||
multiplyByCheese,
|
multiplyByCheese,
|
||||||
maxMultiplier: Number.isFinite(maxMultiplier) ? maxMultiplier : 0,
|
maxMultiplier: Number.isFinite(maxMultiplier) ? maxMultiplier : 0,
|
||||||
|
resetDiceQueue,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = NextResponse.redirect(buildChzzkLoginUrl(state));
|
const res = NextResponse.redirect(buildChzzkLoginUrl(state));
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
ensureRoomChat,
|
ensureRoomChat,
|
||||||
} from '@/lib/chzzk/chat-session';
|
} from '@/lib/chzzk/chat-session';
|
||||||
import {
|
import {
|
||||||
|
clearDiceQueue,
|
||||||
createRoom,
|
createRoom,
|
||||||
findRoomByChannelId,
|
findRoomByChannelId,
|
||||||
getRoom,
|
getRoom,
|
||||||
|
notifyRoom,
|
||||||
toPublicRoom,
|
toPublicRoom,
|
||||||
} from '@/lib/rooms/store';
|
} from '@/lib/rooms/store';
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ export async function POST(request: Request) {
|
|||||||
cheesePerDice?: number;
|
cheesePerDice?: number;
|
||||||
multiplyByCheese?: boolean;
|
multiplyByCheese?: boolean;
|
||||||
maxMultiplier?: number;
|
maxMultiplier?: number;
|
||||||
|
resetDiceQueue?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const session = await getRequestAuthSession(request);
|
const session = await getRequestAuthSession(request);
|
||||||
@@ -92,17 +95,19 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
cancelScheduledDisconnectRoomChat(room.id);
|
cancelScheduledDisconnectRoomChat(room.id);
|
||||||
|
|
||||||
|
if (body.resetDiceQueue) {
|
||||||
|
clearDiceQueue(room.id);
|
||||||
|
notifyRoom(room.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (room.settings.chzzkEnabled) {
|
if (room.settings.chzzkEnabled) {
|
||||||
try {
|
try {
|
||||||
await ensureRoomChat(room.id);
|
await ensureRoomChat(room.id);
|
||||||
} catch (e) {
|
} catch {
|
||||||
if (created) {
|
if (created) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error: '채팅 연결에 실패했습니다. 방송 중인지 확인해 주세요.',
|
||||||
e instanceof Error
|
|
||||||
? e.message
|
|
||||||
: '채팅 연결에 실패했습니다. 방송 중인지 확인해 주세요.',
|
|
||||||
room: toPublicRoom(room),
|
room: toPublicRoom(room),
|
||||||
created,
|
created,
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
app/apple-icon.png
Normal file
BIN
app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -12,10 +12,16 @@ export const CELL_TYPE_PRESETS = [
|
|||||||
'다같이한잔',
|
'다같이한잔',
|
||||||
'영어금지 3분',
|
'영어금지 3분',
|
||||||
'두잔마셔',
|
'두잔마셔',
|
||||||
'애교/매도 100자',
|
'애교/매도 50자',
|
||||||
'무인도',
|
'무인도',
|
||||||
'역방향',
|
'역방향',
|
||||||
'세계여행',
|
'세계여행',
|
||||||
|
'TMI 알려주기',
|
||||||
|
'뒤로 3칸',
|
||||||
|
'앞으로 4칸',
|
||||||
|
'방어권',
|
||||||
|
'방어권 전부삭제',
|
||||||
|
'물한잔',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type CellEventOption = {
|
export type CellEventOption = {
|
||||||
@@ -36,7 +42,7 @@ export type BoardCellConfig = {
|
|||||||
eventId?: string | null;
|
eventId?: string | null;
|
||||||
/** 칸 전용 이미지 (data URL 또는 상대 경로) */
|
/** 칸 전용 이미지 (data URL 또는 상대 경로) */
|
||||||
imageSrc?: string | null;
|
imageSrc?: string | null;
|
||||||
/** false면 게임에서 이름·이미지를 숨김 (기본 true) */
|
/** false면 게임에서 칸 이름(텍스트)만 숨김 (이미지는 유지) */
|
||||||
contentVisible?: boolean;
|
contentVisible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,27 +65,27 @@ const DEFAULT_BOARD_LAYOUT: ReadonlyArray<{
|
|||||||
{ type: '시작', eventId: null },
|
{ type: '시작', eventId: null },
|
||||||
{ type: '한잔마셔' },
|
{ type: '한잔마셔' },
|
||||||
{ type: '~~체 3분' },
|
{ type: '~~체 3분' },
|
||||||
{ type: '안주 한입' },
|
{ type: '방어권' },
|
||||||
{ type: '노래 1절' },
|
{ type: '노래 1절' },
|
||||||
{ type: '다같이한잔' },
|
{ type: '다같이한잔' },
|
||||||
{ type: '영어금지 3분' },
|
{ type: '앞으로 4칸' },
|
||||||
{ type: '애교/매도 100자' },
|
{ type: '애교/매도 50자' },
|
||||||
{ type: '무인도', eventId: 'island' },
|
{ type: '무인도', eventId: 'island' },
|
||||||
{ type: '안주 한입' },
|
{ type: '안주 한입' },
|
||||||
{ type: '두잔마셔' },
|
{ type: '두잔마셔' },
|
||||||
{ type: '~~체 3분' },
|
{ type: '물한잔' },
|
||||||
{ type: '한잔마셔' },
|
{ type: '한잔마셔' },
|
||||||
{ type: '애교/매도 100자' },
|
{ type: 'TMI 알려주기' },
|
||||||
{ type: '역방향', eventId: 'reverse' },
|
{ type: '역방향', eventId: 'reverse' },
|
||||||
{ type: '영어금지 3분' },
|
{ type: '뒤로 3칸' },
|
||||||
{ type: '안주 한입' },
|
{ type: '안주 한입' },
|
||||||
{ type: '다같이한잔' },
|
{ type: '다같이한잔' },
|
||||||
{ type: '노래 1절' },
|
{ type: '방어권 전부삭제' },
|
||||||
{ type: '한잔마셔' },
|
{ type: '한잔마셔' },
|
||||||
{ type: '~~체 3분' },
|
{ type: '~~체 3분' },
|
||||||
{ type: '두잔마셔' },
|
{ type: '두잔마셔' },
|
||||||
{ type: '세계여행', eventId: 'airplane' },
|
{ type: '세계여행', eventId: 'airplane' },
|
||||||
{ type: '애교/매도 100자' },
|
{ type: '애교/매도 50자' },
|
||||||
{ type: '안주 한입' },
|
{ type: '안주 한입' },
|
||||||
{ type: '영어금지 3분' },
|
{ type: '영어금지 3분' },
|
||||||
{ type: '다같이한잔' },
|
{ type: '다같이한잔' },
|
||||||
@@ -439,12 +445,7 @@ export function buildPlaneCells(config: BoardConfig = getOrCreateBoardConfig()):
|
|||||||
export function cellColorFor(
|
export function cellColorFor(
|
||||||
eventId: string | null | undefined,
|
eventId: string | null | undefined,
|
||||||
index: number,
|
index: number,
|
||||||
options?: { contentVisible?: boolean },
|
|
||||||
) {
|
) {
|
||||||
// 내용 숨김이면 종류가 색으로도 드러나지 않게 통일
|
|
||||||
if (options?.contentVisible === false && index !== 0) {
|
|
||||||
return '#be123c';
|
|
||||||
}
|
|
||||||
if (index === 0) return '#059669';
|
if (index === 0) return '#059669';
|
||||||
switch (eventId) {
|
switch (eventId) {
|
||||||
case 'island':
|
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) {
|
export function diceAreaSpan(width: number, height: number) {
|
||||||
return Math.min(2, Math.max(1, width - 2), Math.max(1, height - 2));
|
return Math.min(2, Math.max(1, width - 2), Math.max(1, height - 2));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type PlaneCell = {
|
|||||||
eventId?: string | null;
|
eventId?: string | null;
|
||||||
/** 칸 이미지 (없으면 텍스트만) */
|
/** 칸 이미지 (없으면 텍스트만) */
|
||||||
imageSrc?: string | null;
|
imageSrc?: string | null;
|
||||||
/** false면 게임 판에서 이름·이미지를 숨김 */
|
/** false면 게임 판에서 이름(텍스트)만 숨김 */
|
||||||
contentVisible?: boolean;
|
contentVisible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,15 +34,15 @@ export default function ChatFeedPanel({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
compact
|
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'
|
: 'flex h-full min-h-0 w-full flex-col overflow-hidden rounded-lg bg-black/70 text-white shadow-lg'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
compact
|
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-1.5 py-0.5 text-[20px] 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-2 py-1 text-[22px] font-semibold text-white/80'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
채팅
|
채팅
|
||||||
@@ -51,8 +51,8 @@ export default function ChatFeedPanel({
|
|||||||
ref={listRef}
|
ref={listRef}
|
||||||
className={
|
className={
|
||||||
compact
|
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-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-[11px] 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 ? (
|
{items.length === 0 ? (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type RapierRigidBody,
|
type RapierRigidBody,
|
||||||
} from '@react-three/rapier';
|
} from '@react-three/rapier';
|
||||||
import {
|
import {
|
||||||
|
Euler,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
SRGBColorSpace,
|
SRGBColorSpace,
|
||||||
TextureLoader,
|
TextureLoader,
|
||||||
@@ -40,10 +41,33 @@ const WALL_HEIGHT = 8;
|
|||||||
const WALL_HALF_T = WALL_THICKNESS / 2;
|
const WALL_HALF_T = WALL_THICKNESS / 2;
|
||||||
const WALL_HALF_H = WALL_HEIGHT / 2;
|
const WALL_HALF_H = WALL_HEIGHT / 2;
|
||||||
|
|
||||||
const STILL_SPEED_SQ = 0.05;
|
const STILL_SPEED_SQ = 0.035;
|
||||||
const STILL_FRAMES = 18;
|
const STILL_FRAMES = 24;
|
||||||
const MIN_VIEW_PX = 16;
|
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 DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice);
|
||||||
|
|
||||||
const DICE_FACES = [
|
const DICE_FACES = [
|
||||||
@@ -56,6 +80,7 @@ const DICE_FACES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const _quat = new Quaternion();
|
const _quat = new Quaternion();
|
||||||
|
const _euler = new Euler();
|
||||||
const _normal = new Vector3();
|
const _normal = new Vector3();
|
||||||
const WORLD_UP = new Vector3(0, 1, 0);
|
const WORLD_UP = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
@@ -105,10 +130,30 @@ function InvisibleWalls() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CuboidCollider args={[t, h, f]} position={[f + t, h, 0]} />
|
<CuboidCollider
|
||||||
<CuboidCollider args={[t, h, f]} position={[-(f + t), h, 0]} />
|
args={[t, h, f]}
|
||||||
<CuboidCollider args={[f, h, t]} position={[0, h, f + t]} />
|
position={[f + t, h, 0]}
|
||||||
<CuboidCollider args={[f, h, t]} position={[0, h, -(f + t)]} />
|
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);
|
const bodies = diceRefs.map((ref) => ref.current);
|
||||||
if (bodies.some((body) => !body)) return;
|
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!))) {
|
if (bodies.every((body) => isBodyStill(body!))) {
|
||||||
stillFrames.current += 1;
|
stillFrames.current += 1;
|
||||||
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
|
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
|
||||||
reported.current = true;
|
reported.current = true;
|
||||||
const faces = bodies.map((body) => readTopFace(body!));
|
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 });
|
onSettled({ sum, faces });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -280,14 +336,19 @@ function RapierDiceExample({
|
|||||||
<FitSquareFloor />
|
<FitSquareFloor />
|
||||||
<ambientLight intensity={0.85} />
|
<ambientLight intensity={0.85} />
|
||||||
<directionalLight position={[4, 10, 2]} intensity={0.9} />
|
<directionalLight position={[4, 10, 2]} intensity={0.9} />
|
||||||
<Physics>
|
<Physics gravity={PHYSICS_GRAVITY}>
|
||||||
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
|
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
|
||||||
<FloorSurface
|
<FloorSurface
|
||||||
key={`${floorColor}|${floorImage ?? ''}`}
|
key={`${floorColor}|${floorImage ?? ''}`}
|
||||||
color={floorColor}
|
color={floorColor}
|
||||||
imageSrc={floorImage}
|
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 />
|
<InvisibleWalls />
|
||||||
</RigidBody>
|
</RigidBody>
|
||||||
|
|
||||||
@@ -296,10 +357,14 @@ function RapierDiceExample({
|
|||||||
key={`die-${index}-${diceCount}-${canvasKey}`}
|
key={`die-${index}-${diceCount}-${canvasKey}`}
|
||||||
ref={diceRefs[index]}
|
ref={diceRefs[index]}
|
||||||
type="dynamic"
|
type="dynamic"
|
||||||
position={[x, 4, index === 0 ? 0 : 0.15]}
|
position={[x, 0.65, index === 0 ? 0 : 0.15]}
|
||||||
colliders="cuboid"
|
colliders="cuboid"
|
||||||
restitution={0.2}
|
mass={DICE_MASS}
|
||||||
friction={0.6}
|
restitution={DICE_RESTITUTION}
|
||||||
|
friction={DICE_FRICTION}
|
||||||
|
linearDamping={DICE_LINEAR_DAMPING}
|
||||||
|
angularDamping={DICE_ANGULAR_DAMPING}
|
||||||
|
gravityScale={DICE_GRAVITY_SCALE}
|
||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -330,18 +395,45 @@ function RapierDiceExample({
|
|||||||
function rollDice(body: RapierRigidBody | null) {
|
function rollDice(body: RapierRigidBody | null) {
|
||||||
if (!body) return false;
|
if (!body) return false;
|
||||||
|
|
||||||
|
const t = body.translation();
|
||||||
body.wakeUp();
|
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.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
body.setAngvel({ 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,
|
const wallDirs: Array<[number, number]> = [
|
||||||
);
|
[1, 0],
|
||||||
body.applyTorqueImpulse(
|
[-1, 0],
|
||||||
{ x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 6, z: (Math.random() - 0.5) * 6 },
|
[0, 1],
|
||||||
true,
|
[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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,10 +528,13 @@ export default memo(function Dice({
|
|||||||
rolling || disabled || !canvasReady || (requireDiceQueue && diceQueue <= 0);
|
rolling || disabled || !canvasReady || (requireDiceQueue && diceQueue <= 0);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={hostRef}
|
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 ? (
|
{canvasReady ? (
|
||||||
<RapierDiceExample
|
<RapierDiceExample
|
||||||
@@ -457,9 +552,12 @@ export default memo(function Dice({
|
|||||||
주사위 준비 중…
|
주사위 준비 중…
|
||||||
</div>
|
</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
|
<button
|
||||||
type="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}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -502,7 +600,7 @@ export default memo(function Dice({
|
|||||||
>
|
>
|
||||||
Roll{requireDiceQueue ? ` (${diceQueue})` : ''}
|
Roll{requireDiceQueue ? ` (${diceQueue})` : ''}
|
||||||
</button>
|
</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
|
||||||
? queueHint
|
? queueHint
|
||||||
: !canvasReady
|
: !canvasReady
|
||||||
|
|||||||
@@ -52,11 +52,7 @@ export default function DicePlane({
|
|||||||
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
||||||
const [hopKey, setHopKey] = useState(0);
|
const [hopKey, setHopKey] = useState(0);
|
||||||
const [moving, setMoving] = useState(false);
|
const [moving, setMoving] = useState(false);
|
||||||
const [chatOpen, setChatOpen] = useState(true);
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showChatFeed) setChatOpen(true);
|
|
||||||
}, [showChatFeed]);
|
|
||||||
const [landedLabel, setLandedLabel] = useState<string | null>(null);
|
const [landedLabel, setLandedLabel] = useState<string | null>(null);
|
||||||
const [landedKey, setLandedKey] = useState(0);
|
const [landedKey, setLandedKey] = useState(0);
|
||||||
const [statusText, setStatusText] = useState('시계방향');
|
const [statusText, setStatusText] = useState('시계방향');
|
||||||
@@ -243,7 +239,7 @@ export default function DicePlane({
|
|||||||
{dicePlaneArray.map((plane, index) => {
|
{dicePlaneArray.map((plane, index) => {
|
||||||
const isStart = index === 0;
|
const isStart = index === 0;
|
||||||
const isLanded = !moving && index === chessmanIndex && landedLabel != null;
|
const isLanded = !moving && index === chessmanIndex && landedLabel != null;
|
||||||
const showContent = plane.contentVisible !== false;
|
const showText = plane.contentVisible !== false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={plane.id}
|
key={plane.id}
|
||||||
@@ -263,11 +259,8 @@ export default function DicePlane({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: cellColorFor(plane.eventId, index, {
|
backgroundColor: cellColorFor(plane.eventId, index),
|
||||||
contentVisible: showContent,
|
backgroundImage: plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
|
||||||
}),
|
|
||||||
backgroundImage:
|
|
||||||
showContent && plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
|
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
@@ -288,13 +281,10 @@ export default function DicePlane({
|
|||||||
zIndex: pickingDestination ? 8 : isLanded ? 4 : undefined,
|
zIndex: pickingDestination ? 8 : isLanded ? 4 : undefined,
|
||||||
cursor: pickingDestination ? 'pointer' : undefined,
|
cursor: pickingDestination ? 'pointer' : undefined,
|
||||||
pointerEvents: pickingDestination ? 'auto' : undefined,
|
pointerEvents: pickingDestination ? 'auto' : undefined,
|
||||||
textShadow:
|
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
|
||||||
showContent && plane.imageSrc
|
|
||||||
? '0 1px 3px rgba(0,0,0,0.85)'
|
|
||||||
: undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showContent ? (isStart ? '시작' : plane.type) : null}
|
{showText ? (isStart ? '시작' : plane.type) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
BIN
app/icon.png
Normal file
BIN
app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "알코올 마블!",
|
||||||
description: "Generated by create next app",
|
description: "치지직 온라인 주루마블",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
|||||||
62
app/page.tsx
62
app/page.tsx
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -147,7 +148,7 @@ function BoardEditor({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
|
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
|
||||||
<p className="text-[11px] text-white/50">
|
<p className="text-[11px] text-white/50">
|
||||||
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지·내용 표시를 설정합니다. 0번(시작) 이름은
|
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지·텍스트 표시를 설정합니다. 0번(시작) 이름은
|
||||||
고정입니다.
|
고정입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,14 +158,14 @@ function BoardEditor({
|
|||||||
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
|
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
|
||||||
onClick={() => onChange(setAllCellsContentVisible(true, board))}
|
onClick={() => onChange(setAllCellsContentVisible(true, board))}
|
||||||
>
|
>
|
||||||
전체 내용 표시
|
전체 텍스트 표시
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
|
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
|
||||||
onClick={() => onChange(setAllCellsContentVisible(false, board))}
|
onClick={() => onChange(setAllCellsContentVisible(false, board))}
|
||||||
>
|
>
|
||||||
전체 내용 숨김
|
전체 텍스트 숨김
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -214,33 +215,30 @@ function BoardEditor({
|
|||||||
{geo.map((g, index) => {
|
{geo.map((g, index) => {
|
||||||
const cell = board.cells[index];
|
const cell = board.cells[index];
|
||||||
const active = selected === index;
|
const active = selected === index;
|
||||||
const showContent = cell?.contentVisible !== false;
|
const showText = cell?.contentVisible !== false;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
type="button"
|
type="button"
|
||||||
title={`#${index} ${cell?.type ?? ''}${showContent ? '' : ' (내용 숨김)'}`}
|
title={`#${index} ${cell?.type ?? ''}${showText ? '' : ' (텍스트 숨김)'}`}
|
||||||
onClick={() => setSelected(index)}
|
onClick={() => setSelected(index)}
|
||||||
className="relative min-h-10 rounded px-0.5 text-[10px] font-semibold text-white sm:text-[11px]"
|
className="relative min-h-10 rounded px-0.5 text-[10px] font-semibold text-white sm:text-[11px]"
|
||||||
style={{
|
style={{
|
||||||
gridColumn: g.x + 1,
|
gridColumn: g.x + 1,
|
||||||
gridRow: g.y + 1,
|
gridRow: g.y + 1,
|
||||||
backgroundColor: cellColorFor(cell?.eventId, index),
|
backgroundColor: cellColorFor(cell?.eventId, index),
|
||||||
backgroundImage:
|
backgroundImage: cell?.imageSrc ? `url(${cell.imageSrc})` : undefined,
|
||||||
showContent && cell?.imageSrc ? `url(${cell.imageSrc})` : undefined,
|
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
textShadow:
|
textShadow: cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
|
||||||
showContent && cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
|
|
||||||
outline: active ? '2px solid #fde68a' : undefined,
|
outline: active ? '2px solid #fde68a' : undefined,
|
||||||
outlineOffset: 1,
|
outlineOffset: 1,
|
||||||
opacity: showContent ? 1 : 0.7,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{index === 0 ? '시작' : cell?.type}
|
{index === 0 ? '시작' : cell?.type}
|
||||||
{!showContent && (
|
{!showText && (
|
||||||
<span className="absolute top-0.5 right-0.5 rounded bg-black/55 px-0.5 text-[8px] font-normal text-white/70">
|
<span className="absolute top-0.5 right-0.5 rounded bg-black/55 px-0.5 text-[8px] font-normal text-white/70">
|
||||||
숨김
|
텍스트숨김
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -305,10 +303,10 @@ function BoardEditor({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
게임에서 이 칸 내용(이름·이미지) 표시
|
게임에서 이 칸 이름(텍스트) 표시
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[11px] text-white/45">
|
<p className="text-[11px] text-white/45">
|
||||||
끄면 판에는 색만 보이고, 착지 시 효과·안내는 그대로 동작합니다.
|
끄면 판에서 이름 글자만 숨깁니다. 이미지·색·착지 효과는 그대로입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||||
@@ -376,6 +374,7 @@ export default function Home() {
|
|||||||
const [roomCheesePerDice, setRoomCheesePerDice] = useState(1000);
|
const [roomCheesePerDice, setRoomCheesePerDice] = useState(1000);
|
||||||
const [roomMultiplyByCheese, setRoomMultiplyByCheese] = useState(true);
|
const [roomMultiplyByCheese, setRoomMultiplyByCheese] = useState(true);
|
||||||
const [roomMaxMultiplier, setRoomMaxMultiplier] = useState(10);
|
const [roomMaxMultiplier, setRoomMaxMultiplier] = useState(10);
|
||||||
|
const [resetDiceQueueOnJoin, setResetDiceQueueOnJoin] = useState(false);
|
||||||
const [joinChannelInput, setJoinChannelInput] = useState('');
|
const [joinChannelInput, setJoinChannelInput] = useState('');
|
||||||
const [roomBusy, setRoomBusy] = useState(false);
|
const [roomBusy, setRoomBusy] = useState(false);
|
||||||
const [roomError, setRoomError] = useState<string | null>(null);
|
const [roomError, setRoomError] = useState<string | null>(null);
|
||||||
@@ -436,6 +435,7 @@ export default function Home() {
|
|||||||
cheesePerDice: roomCheesePerDice,
|
cheesePerDice: roomCheesePerDice,
|
||||||
multiplyByCheese: roomMultiplyByCheese,
|
multiplyByCheese: roomMultiplyByCheese,
|
||||||
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
|
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
|
||||||
|
resetDiceQueue: resetDiceQueueOnJoin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const startChzzkLogin = () => {
|
const startChzzkLogin = () => {
|
||||||
@@ -444,6 +444,7 @@ export default function Home() {
|
|||||||
cheesePerDice: String(opts.cheesePerDice),
|
cheesePerDice: String(opts.cheesePerDice),
|
||||||
multiplyByCheese: opts.multiplyByCheese ? '1' : '0',
|
multiplyByCheese: opts.multiplyByCheese ? '1' : '0',
|
||||||
maxMultiplier: String(opts.maxMultiplier),
|
maxMultiplier: String(opts.maxMultiplier),
|
||||||
|
resetDiceQueue: opts.resetDiceQueue ? '1' : '0',
|
||||||
});
|
});
|
||||||
window.location.href = `/api/chzzk/login?${q.toString()}`;
|
window.location.href = `/api/chzzk/login?${q.toString()}`;
|
||||||
};
|
};
|
||||||
@@ -658,6 +659,15 @@ export default function Home() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={resetDiceQueueOnJoin}
|
||||||
|
onChange={(e) => setResetDiceQueueOnJoin(e.target.checked)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span>남은 주사위 개수를 초기화</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-white/10 pt-3">
|
<div className="flex flex-col gap-2 border-t border-white/10 pt-3">
|
||||||
@@ -790,10 +800,10 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-white/45">
|
{/* <p className="text-xs text-white/45">
|
||||||
ZIP: manifest.json, board.json, cells/N.*, background.*, chessman.*,
|
ZIP: manifest.json, board.json, cells/N.*, background.*, chessman.*,
|
||||||
dice/1|2/면.*, dice-floor.*
|
dice/1|2/면.*, dice-floor.*
|
||||||
</p>
|
</p> */}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
|
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
|
||||||
@@ -943,6 +953,26 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<footer className="mt-auto border-t border-white/10 pt-6 pb-2 text-center text-[12px] text-white/45">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-white/60 underline decoration-white/25 underline-offset-2 hover:text-white/85"
|
||||||
|
>
|
||||||
|
개인정보·쿠키 고지
|
||||||
|
</Link>
|
||||||
|
<span className="text-white/25" aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="mailto:kimyu@kimyu.xyz"
|
||||||
|
className="text-white/60 underline decoration-white/25 underline-offset-2 hover:text-white/85"
|
||||||
|
>
|
||||||
|
연락: kimyu@kimyu.xyz
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
82
app/privacy/page.tsx
Normal file
82
app/privacy/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: '개인정보·쿠키 고지 | 알코올 마블',
|
||||||
|
description: '알코올 마블 서비스의 쿠키 및 저장 정보 안내',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#1c1224] text-white">
|
||||||
|
<div className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 px-6 py-10">
|
||||||
|
<header className="flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="self-start rounded bg-white/10 px-3 py-1.5 text-sm text-white/80 hover:bg-white/15"
|
||||||
|
>
|
||||||
|
← 홈
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">개인정보·쿠키 고지</h1>
|
||||||
|
<p className="text-sm text-white/55">
|
||||||
|
본 서비스(알코올 마블)에서 사용하는 저장 정보와 연락처를 안내합니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-2xl border border-white/10 bg-white/5 p-5 text-sm leading-relaxed text-white/80">
|
||||||
|
<h2 className="text-base font-semibold text-white">1. 수집·이용하는 정보</h2>
|
||||||
|
<p>
|
||||||
|
광고·분석용 추적 쿠키나 마케팅 SDK는 사용하지 않습니다. 서비스 제공에 필요한
|
||||||
|
범위에서만 아래 정보를 사용합니다.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-2 pl-5 text-white/75">
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-white/90">인증 쿠키</span>: 치지직 로그인 후
|
||||||
|
본인 채널 확인용 세션입니다. 브라우저에서만 쓰이며(HttpOnly), 치지직
|
||||||
|
액세스/리프레시 토큰은 저장하지 않습니다.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-white/90">로그인 보안 쿠키</span>: OAuth
|
||||||
|
진행 중 CSRF 방지용으로 짧은 시간만 유지됩니다.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-white/90">브라우저 로컬 저장소</span>: 주사위판
|
||||||
|
칸 설정·이미지 에셋 등은 서버가 아니라 사용자 브라우저에만 저장됩니다.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-white/90">룸·채팅 연동</span>: 입장한 치지직
|
||||||
|
채널의 채팅/후원 이벤트는 해당 방송 룸 진행을 위해 서버 메모리에서 일시적으로
|
||||||
|
처리됩니다.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-2xl border border-white/10 bg-white/5 p-5 text-sm leading-relaxed text-white/80">
|
||||||
|
<h2 className="text-base font-semibold text-white">2. 쿠키 안내</h2>
|
||||||
|
<p>
|
||||||
|
위에 적은 쿠키는 로그인·보안 등 서비스 제공에 필요한 항목입니다. 선택형
|
||||||
|
광고/통계 쿠키는 두지 않습니다. 브라우저에서 쿠키를 차단하면 치지직 로그인
|
||||||
|
입장이 동작하지 않을 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-2xl border border-white/10 bg-white/5 p-5 text-sm leading-relaxed text-white/80">
|
||||||
|
<h2 className="text-base font-semibold text-white">3. 문의</h2>
|
||||||
|
<p>
|
||||||
|
개인정보·쿠키 관련 문의는 아래로 연락해 주세요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
이메일:{' '}
|
||||||
|
<a
|
||||||
|
href="mailto:kimyu@kimyu.xyz"
|
||||||
|
className="text-sky-300 underline decoration-sky-300/40 underline-offset-2 hover:text-sky-200"
|
||||||
|
>
|
||||||
|
kimyu@kimyu.xyz
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="pb-6 text-[11px] text-white/40">최종 안내일: 2026-09-21</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export type OAuthStatePayload = {
|
|||||||
cheesePerDice?: number;
|
cheesePerDice?: number;
|
||||||
multiplyByCheese?: boolean;
|
multiplyByCheese?: boolean;
|
||||||
maxMultiplier?: number;
|
maxMultiplier?: number;
|
||||||
|
resetDiceQueue?: boolean;
|
||||||
/** unix ms */
|
/** unix ms */
|
||||||
exp: number;
|
exp: number;
|
||||||
};
|
};
|
||||||
@@ -116,6 +117,7 @@ export function createOAuthState(input: {
|
|||||||
cheesePerDice?: number;
|
cheesePerDice?: number;
|
||||||
multiplyByCheese?: boolean;
|
multiplyByCheese?: boolean;
|
||||||
maxMultiplier?: number;
|
maxMultiplier?: number;
|
||||||
|
resetDiceQueue?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const state = b64urlEncode(randomBytes(24));
|
const state = b64urlEncode(randomBytes(24));
|
||||||
const payload: OAuthStatePayload = {
|
const payload: OAuthStatePayload = {
|
||||||
@@ -123,6 +125,7 @@ export function createOAuthState(input: {
|
|||||||
cheesePerDice: input.cheesePerDice,
|
cheesePerDice: input.cheesePerDice,
|
||||||
multiplyByCheese: input.multiplyByCheese,
|
multiplyByCheese: input.multiplyByCheese,
|
||||||
maxMultiplier: input.maxMultiplier,
|
maxMultiplier: input.maxMultiplier,
|
||||||
|
resetDiceQueue: input.resetDiceQueue,
|
||||||
exp: Date.now() + 10 * 60 * 1000,
|
exp: Date.now() + 10 * 60 * 1000,
|
||||||
};
|
};
|
||||||
return { state, sealed: seal(payload) };
|
return { state, sealed: seal(payload) };
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ export async function fetchChatAccessToken(chatChannelId: string): Promise<strin
|
|||||||
}
|
}
|
||||||
const token = data.content?.accessToken;
|
const token = data.content?.accessToken;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(data.message || '채팅 accessToken을 받지 못했습니다.');
|
// 외부 응답 원문은 로그/화면으로 전달하지 않는다.
|
||||||
|
throw new Error('채팅 연결 인증 정보를 받지 못했습니다.');
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import {
|
import {
|
||||||
chatServerUrl,
|
chatServerUrl,
|
||||||
fetchChannelInfo,
|
fetchChannelInfo,
|
||||||
@@ -98,7 +99,7 @@ function messageKey(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const id = chat.msgUid ?? chat.messageId ?? null;
|
const id = chat.msgUid ?? chat.messageId ?? null;
|
||||||
if (id != null && String(id).length > 0) {
|
if (id != null && String(id).length > 0) {
|
||||||
return `${kind}:${String(id)}`;
|
return hashMessageKey(`${kind}:${String(id)}`);
|
||||||
}
|
}
|
||||||
const time = messageTimeMs(chat);
|
const time = messageTimeMs(chat);
|
||||||
const profile = parseMaybeJson<{ nickname?: string; userIdHash?: string }>(
|
const profile = parseMaybeJson<{ nickname?: string; userIdHash?: string }>(
|
||||||
@@ -109,9 +110,16 @@ function messageKey(
|
|||||||
const msg = String(chat.msg ?? chat.content ?? '');
|
const msg = String(chat.msg ?? chat.content ?? '');
|
||||||
if (time == null && !who && !msg) return null;
|
if (time == null && !who && !msg) return null;
|
||||||
if (kind === 'donation') {
|
if (kind === 'donation') {
|
||||||
return `donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${msg}`;
|
return hashMessageKey(
|
||||||
|
`donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${msg}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return `chat:${time ?? 0}:${who}:${msg}`;
|
return hashMessageKey(`chat:${time ?? 0}:${who}:${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 중복 검사용 키에는 사용자 해시·닉네임·메시지 원문을 보관하지 않는다. */
|
||||||
|
function hashMessageKey(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberMessageKey(handle: SessionHandle, key: string | null): boolean {
|
function rememberMessageKey(handle: SessionHandle, key: string | null): boolean {
|
||||||
@@ -475,11 +483,12 @@ export async function connectRoomChat(roomId: string) {
|
|||||||
setRoomMeta(roomId, { channelName: info.channelName, chatStatus: 'connecting' });
|
setRoomMeta(roomId, { channelName: info.channelName, chatStatus: 'connecting' });
|
||||||
notifyRoom(roomId);
|
notifyRoom(roomId);
|
||||||
await openSocket(roomId, channelId);
|
await openSocket(roomId, channelId);
|
||||||
} catch (e) {
|
} catch {
|
||||||
const message = e instanceof Error ? e.message : '채팅 연결 실패';
|
// 외부 라이브러리 오류에는 URL·토큰·식별자가 섞일 수 있어 원문을 보관하지 않는다.
|
||||||
|
const message = '채팅 연결에 실패했습니다. 방송 상태를 확인해 주세요.';
|
||||||
setRoomMeta(roomId, { chatStatus: 'error', chatError: message });
|
setRoomMeta(roomId, { chatStatus: 'error', chatError: message });
|
||||||
notifyRoom(roomId);
|
notifyRoom(roomId);
|
||||||
throw e instanceof Error ? e : new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ export async function exchangeAuthorizationCode(code: string, state: string) {
|
|||||||
tokenType?: string;
|
tokenType?: string;
|
||||||
expiresIn?: string | number;
|
expiresIn?: string | number;
|
||||||
};
|
};
|
||||||
message?: string;
|
|
||||||
code?: number;
|
code?: number;
|
||||||
};
|
};
|
||||||
if (!res.ok || !json.content?.accessToken) {
|
if (!res.ok || !json.content?.accessToken) {
|
||||||
throw new Error(json.message || `토큰 발급 실패 (${res.status})`);
|
// 외부 API 원문은 계정 정보 등이 섞일 수 있으므로 전달·기록하지 않는다.
|
||||||
|
throw new Error(`치지직 인증 처리 실패 (${res.status})`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
accessToken: json.content.accessToken,
|
accessToken: json.content.accessToken,
|
||||||
@@ -54,10 +54,9 @@ export async function fetchAuthedUser(accessToken: string) {
|
|||||||
});
|
});
|
||||||
const json = (await res.json().catch(() => ({}))) as {
|
const json = (await res.json().catch(() => ({}))) as {
|
||||||
content?: { channelId?: string; channelName?: string };
|
content?: { channelId?: string; channelName?: string };
|
||||||
message?: string;
|
|
||||||
};
|
};
|
||||||
if (!res.ok || !json.content?.channelId) {
|
if (!res.ok || !json.content?.channelId) {
|
||||||
throw new Error(json.message || `유저 정보 조회 실패 (${res.status})`);
|
throw new Error(`치지직 사용자 확인 실패 (${res.status})`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
channelId: json.content.channelId.toLowerCase(),
|
channelId: json.content.channelId.toLowerCase(),
|
||||||
|
|||||||
@@ -270,6 +270,14 @@ export function addDice(roomId: string, count = 1) {
|
|||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 남은 주사위 큐를 0으로 초기화 */
|
||||||
|
export function clearDiceQueue(roomId: string) {
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
room.diceQueue = 0;
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
export function toPublicRoom(room: RoomRecord): PublicRoomState {
|
export function toPublicRoom(room: RoomRecord): PublicRoomState {
|
||||||
return {
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
|
|
||||||
> drinkmarble@0.1.0 build
|
|
||||||
> next build
|
|
||||||
|
|
||||||
▲ Next.js 16.3.3 (Turbopack)
|
|
||||||
⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble).
|
|
||||||
To use this directory, set `turbopack.root` in your Next.js config.
|
|
||||||
|
|
||||||
✓ Running next.config.ts took 25ms
|
|
||||||
|
|
||||||
Creating an optimized production build ...
|
|
||||||
✓ Compiled successfully in 3.6s
|
|
||||||
Running TypeScript ...
|
|
||||||
Finished TypeScript in 2.6s ...
|
|
||||||
Collecting page data using 7 workers ...
|
|
||||||
Generating static pages using 7 workers (0/6) ...
|
|
||||||
Generating static pages using 7 workers (1/6)
|
|
||||||
Generating static pages using 7 workers (2/6)
|
|
||||||
Generating static pages using 7 workers (4/6)
|
|
||||||
✓ Generating static pages using 7 workers (6/6) in 677ms
|
|
||||||
Finalizing page optimization ...
|
|
||||||
|
|
||||||
Route (app)
|
|
||||||
┌ ○ /
|
|
||||||
├ ○ /_not-found
|
|
||||||
├ ○ /game
|
|
||||||
└ ○ /main
|
|
||||||
|
|
||||||
|
|
||||||
○ (Static) prerendered as static content
|
|
||||||
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
|
|
||||||
> drinkmarble@0.1.0 start
|
|
||||||
> next start -p 13000
|
|
||||||
|
|
||||||
▲ Next.js 16.3.3
|
|
||||||
- Local: http://localhost:13000
|
|
||||||
- Network: http://172.30.1.31:13000
|
|
||||||
✓ Ready in 149ms
|
|
||||||
⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble).
|
|
||||||
To use this directory, set `outputFileTracingRoot` in your Next.js config.
|
|
||||||
|
|
||||||
✓ Running next.config.ts took 30ms
|
|
||||||
Error: Could not find a production build in the '.next' directory. Try building your app with 'next build' before starting the production server. https://nextjs.org/docs/messages/production-start-no-build-id
|
|
||||||
at ignore-listed frames
|
|
||||||
|
|
||||||
> drinkmarble@0.1.0 start
|
|
||||||
> next start -p 13000
|
|
||||||
|
|
||||||
▲ Next.js 16.3.3
|
|
||||||
- Local: http://localhost:13000
|
|
||||||
- Network: http://172.30.1.31:13000
|
|
||||||
✓ Ready in 151ms
|
|
||||||
⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble).
|
|
||||||
To use this directory, set `outputFileTracingRoot` in your Next.js config.
|
|
||||||
|
|
||||||
✓ Running next.config.ts took 30ms
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
2448360
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
|
|
||||||
> drinkmarble@0.1.0 start
|
|
||||||
> next start -H 0.0.0.0 -p 13000
|
|
||||||
|
|
||||||
▲ Next.js 16.3.3
|
|
||||||
- Local: http://localhost:13000
|
|
||||||
- Network: http://0.0.0.0:13000
|
|
||||||
✓ Ready in 153ms
|
|
||||||
✓ Running next.config.ts took 31ms
|
|
||||||
@@ -5,6 +5,11 @@ import { fileURLToPath } from "url";
|
|||||||
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// 개발 서버에서도 OAuth code/state가 포함된 요청 URL을 콘솔에 남기지 않음.
|
||||||
|
// 운영 서버는 별도 access log를 사용하지 않으며, 앱에서도 요청 본문을 기록하지 않는다.
|
||||||
|
logging: {
|
||||||
|
incomingRequests: false,
|
||||||
|
},
|
||||||
// 홈 디렉터리 package-lock.json 오인 방지 (외부 도메인/IP 접속 시에도 안정)
|
// 홈 디렉터리 package-lock.json 오인 방지 (외부 도메인/IP 접속 시에도 안정)
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: projectRoot,
|
root: projectRoot,
|
||||||
|
|||||||
Reference in New Issue
Block a user