Compare commits
5 Commits
171a4e19f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e93c0bafa | ||
|
|
347dfc1f4a | ||
|
|
73520f9f06 | ||
|
|
6f72824598 | ||
|
|
7c402dfd8f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,6 +33,9 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# local runtime logs / pids
|
||||
/logs/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
16
app/api/auth/logout/route.ts
Normal file
16
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { AUTH_COOKIE } from '@/lib/auth/session';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(AUTH_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
19
app/api/auth/session/route.ts
Normal file
19
app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authModePublic } from '@/lib/auth/room-access';
|
||||
import { readAuthSession } from '@/lib/auth/session';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const session = await readAuthSession();
|
||||
return NextResponse.json({
|
||||
...authModePublic(),
|
||||
session: session
|
||||
? {
|
||||
channelId: session.channelId,
|
||||
channelName: session.channelName,
|
||||
exp: session.exp,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
121
app/api/chzzk/callback/route.ts
Normal file
121
app/api/chzzk/callback/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPublicOrigin } from '@/lib/auth/public-origin';
|
||||
import {
|
||||
AUTH_COOKIE,
|
||||
OAUTH_STATE_COOKIE,
|
||||
authCookieOptions,
|
||||
createAuthSession,
|
||||
parseOAuthStateCookie,
|
||||
serializeAuthSession,
|
||||
} from '@/lib/auth/session';
|
||||
import {
|
||||
exchangeAuthorizationCode,
|
||||
fetchAuthedUser,
|
||||
revokeToken,
|
||||
} from '@/lib/chzzk/oauth';
|
||||
import {
|
||||
cancelScheduledDisconnectRoomChat,
|
||||
ensureRoomChat,
|
||||
} from '@/lib/chzzk/chat-session';
|
||||
import {
|
||||
clearDiceQueue,
|
||||
createRoom,
|
||||
findRoomByChannelId,
|
||||
getRoom,
|
||||
notifyRoom,
|
||||
} from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function homeRedirect(origin: string, error?: string) {
|
||||
const url = new URL('/', origin);
|
||||
if (error) url.searchParams.set('authError', error);
|
||||
else url.searchParams.set('auth', 'ok');
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 치지직 OAuth 콜백.
|
||||
* 채널 ID 확인 → 우리 세션 쿠키만 저장 → 치지직 토큰 폐기 → 룸 입장
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const origin = getPublicOrigin(request);
|
||||
const code = url.searchParams.get('code') || '';
|
||||
const state = url.searchParams.get('state') || '';
|
||||
const stateCookie = request.headers
|
||||
.get('cookie')
|
||||
?.match(/(?:^|;\s*)drinkmarble_oauth_state=([^;]+)/)?.[1];
|
||||
|
||||
const decodedState = stateCookie
|
||||
? parseOAuthStateCookie(decodeURIComponent(stateCookie))
|
||||
: null;
|
||||
|
||||
if (!code || !state || !decodedState || decodedState.state !== state) {
|
||||
const res = NextResponse.redirect(
|
||||
homeRedirect(origin, '로그인 검증에 실패했습니다. 다시 시도해 주세요.'),
|
||||
);
|
||||
res.cookies.delete(OAUTH_STATE_COOKIE);
|
||||
return res;
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeAuthorizationCode(code, state);
|
||||
accessToken = tokens.accessToken;
|
||||
refreshToken = tokens.refreshToken;
|
||||
|
||||
const user = await fetchAuthedUser(accessToken);
|
||||
const session = createAuthSession(user.channelId, user.channelName);
|
||||
|
||||
// 치지직 토큰은 저장하지 않고 즉시 폐기
|
||||
await revokeToken(accessToken, 'access_token');
|
||||
if (refreshToken) await revokeToken(refreshToken, 'refresh_token');
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
|
||||
let room = findRoomByChannelId(user.channelId);
|
||||
if (!room) {
|
||||
room = createRoom({
|
||||
chzzkEnabled: true,
|
||||
liveUrl: `https://chzzk.naver.com/live/${user.channelId}`,
|
||||
channelId: user.channelId,
|
||||
cheesePerDice: decodedState.cheesePerDice,
|
||||
multiplyByCheese: decodedState.multiplyByCheese,
|
||||
maxMultiplier: decodedState.maxMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
cancelScheduledDisconnectRoomChat(room.id);
|
||||
|
||||
if (decodedState.resetDiceQueue) {
|
||||
clearDiceQueue(room.id);
|
||||
notifyRoom(room.id);
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureRoomChat(room.id);
|
||||
} catch {
|
||||
// 방송 중이 아니어도 세션·룸은 유지하고 게임으로 보냄
|
||||
}
|
||||
|
||||
// room.id = 서버 룸 UUID (게임 URL용)
|
||||
// user.channelId = 치지직 채널 (세션·room.settings.channelId에 보관)
|
||||
const roomId = getRoom(room.id)?.id ?? room.id;
|
||||
const res = NextResponse.redirect(new URL(`/game?roomId=${roomId}`, origin));
|
||||
res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions());
|
||||
res.cookies.delete(OAUTH_STATE_COOKIE);
|
||||
return res;
|
||||
} catch {
|
||||
if (accessToken) await revokeToken(accessToken, 'access_token');
|
||||
if (refreshToken) await revokeToken(refreshToken, 'refresh_token');
|
||||
// 외부 오류 원문에는 계정 식별자 등이 포함될 수 있어 고정 문구만 전달한다.
|
||||
const res = NextResponse.redirect(
|
||||
homeRedirect(origin, '치지직 로그인에 실패했습니다. 다시 시도해 주세요.'),
|
||||
);
|
||||
res.cookies.delete(OAUTH_STATE_COOKIE);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
46
app/api/chzzk/login/route.ts
Normal file
46
app/api/chzzk/login/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isChzzkOAuthConfigured } from '@/lib/auth/mode';
|
||||
import {
|
||||
createOAuthState,
|
||||
OAUTH_STATE_COOKIE,
|
||||
authCookieOptions,
|
||||
} from '@/lib/auth/session';
|
||||
import { buildChzzkLoginUrl } from '@/lib/chzzk/oauth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* 치지직 로그인 시작.
|
||||
* 쿼리로 치즈 설정을 넘기면 콜백에서 바로 룸 입장에 사용한다.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (!isChzzkOAuthConfigured()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'치지직 OAuth가 설정되지 않았습니다. CHZZK_CLIENT_ID / CHZZK_CLIENT_SECRET / CHZZK_REDIRECT_URI 를 확인해 주세요.',
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const cheesePerDice = Number(url.searchParams.get('cheesePerDice') || 1000);
|
||||
const multiplyByCheese = url.searchParams.get('multiplyByCheese') !== '0';
|
||||
const maxMultiplier = Number(url.searchParams.get('maxMultiplier') || 0);
|
||||
const resetDiceQueue = url.searchParams.get('resetDiceQueue') === '1';
|
||||
|
||||
const { state, sealed } = createOAuthState({
|
||||
cheesePerDice: Number.isFinite(cheesePerDice) ? cheesePerDice : 1000,
|
||||
multiplyByCheese,
|
||||
maxMultiplier: Number.isFinite(maxMultiplier) ? maxMultiplier : 0,
|
||||
resetDiceQueue,
|
||||
});
|
||||
|
||||
const res = NextResponse.redirect(buildChzzkLoginUrl(state));
|
||||
res.cookies.set(OAUTH_STATE_COOKIE, sealed, {
|
||||
...authCookieOptions(600),
|
||||
maxAge: 600,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
31
app/api/rooms/[roomId]/add-dice/route.ts
Normal file
31
app/api/rooms/[roomId]/add-dice/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import { addDice, getRoom, notifyRoom, toPublicRoom } from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { count?: number };
|
||||
const count = Number(body.count ?? 1);
|
||||
if (!Number.isFinite(count) || count < 1) {
|
||||
return NextResponse.json({ error: 'count는 1 이상이어야 합니다.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = addDice(roomId, count);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: '처리 실패' }, { status: 500 });
|
||||
}
|
||||
notifyRoom(roomId);
|
||||
return NextResponse.json({ room: toPublicRoom(updated) });
|
||||
}
|
||||
40
app/api/rooms/[roomId]/consume-dice/route.ts
Normal file
40
app/api/rooms/[roomId]/consume-dice/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import {
|
||||
consumeDice,
|
||||
getRoom,
|
||||
notifyRoom,
|
||||
toPublicRoom,
|
||||
} from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { count?: number };
|
||||
const count = Math.max(1, Math.trunc(body.count ?? 1));
|
||||
|
||||
if (!room.settings.chzzkEnabled) {
|
||||
return NextResponse.json({ room: toPublicRoom(room), consumed: 0 });
|
||||
}
|
||||
|
||||
const updated = consumeDice(roomId, count);
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ error: '남은 주사위가 없습니다.', room: toPublicRoom(room) },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
notifyRoom(roomId);
|
||||
return NextResponse.json({ room: toPublicRoom(updated), consumed: count });
|
||||
}
|
||||
85
app/api/rooms/[roomId]/events/route.ts
Normal file
85
app/api/rooms/[roomId]/events/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
cancelScheduledDisconnectRoomChat,
|
||||
scheduleDisconnectRoomChat,
|
||||
} from '@/lib/chzzk/chat-session';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import {
|
||||
addRoomViewer,
|
||||
getRoom,
|
||||
removeRoomViewer,
|
||||
subscribeRoom,
|
||||
toPublicRoom,
|
||||
type PublicRoomState,
|
||||
} from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return new Response(JSON.stringify({ error: '방을 찾을 수 없습니다.' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const clientId =
|
||||
new URL(request.url).searchParams.get('clientId')?.trim() ||
|
||||
crypto.randomUUID();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let presenceHeld = false;
|
||||
|
||||
const release = () => {
|
||||
if (!presenceHeld) return;
|
||||
presenceHeld = false;
|
||||
const { emptied } = removeRoomViewer(roomId, clientId);
|
||||
if (emptied) {
|
||||
scheduleDisconnectRoomChat(roomId);
|
||||
}
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
cancelScheduledDisconnectRoomChat(roomId);
|
||||
addRoomViewer(roomId, clientId);
|
||||
presenceHeld = true;
|
||||
|
||||
const send = (event: string, data: unknown) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
};
|
||||
|
||||
send('room', toPublicRoom(room));
|
||||
unsubscribe = subscribeRoom(roomId, (state: PublicRoomState) => {
|
||||
send('room', state);
|
||||
});
|
||||
heartbeat = setInterval(() => {
|
||||
controller.enqueue(encoder.encode(`: ping\n\n`));
|
||||
}, 15000);
|
||||
},
|
||||
cancel() {
|
||||
if (unsubscribe) unsubscribe();
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
release();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
59
app/api/rooms/[roomId]/leave/route.ts
Normal file
59
app/api/rooms/[roomId]/leave/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { scheduleDisconnectRoomChat } from '@/lib/chzzk/chat-session';
|
||||
import {
|
||||
getRoom,
|
||||
getRoomViewerCount,
|
||||
removeRoomViewer,
|
||||
toPublicRoom,
|
||||
} from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
/**
|
||||
* 탭/창 종료·페이지 이탈 시 호출.
|
||||
* 마지막 시청자면 치지직 채팅 웹소켓을 유예 후 끊는다 (빠른 재입장 대비).
|
||||
*/
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return NextResponse.json({ ok: true, emptied: true });
|
||||
}
|
||||
|
||||
let clientId = '';
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = (await request.json()) as { clientId?: string };
|
||||
clientId = body.clientId?.trim() || '';
|
||||
} else {
|
||||
const text = await request.text();
|
||||
if (text.startsWith('{')) {
|
||||
const body = JSON.parse(text) as { clientId?: string };
|
||||
clientId = body.clientId?.trim() || '';
|
||||
} else {
|
||||
clientId = new URLSearchParams(text).get('clientId')?.trim() || '';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clientId = '';
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return NextResponse.json({ ok: true, viewers: getRoomViewerCount(roomId) });
|
||||
}
|
||||
|
||||
const { viewers, emptied } = removeRoomViewer(roomId, clientId);
|
||||
if (emptied) {
|
||||
scheduleDisconnectRoomChat(roomId);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
viewers,
|
||||
emptied,
|
||||
room: toPublicRoom(getRoom(roomId) ?? room),
|
||||
});
|
||||
}
|
||||
18
app/api/rooms/[roomId]/route.ts
Normal file
18
app/api/rooms/[roomId]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import { getRoom, toPublicRoom } from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
return NextResponse.json({ room: toPublicRoom(room) });
|
||||
}
|
||||
54
app/api/rooms/[roomId]/simulate-donation/route.ts
Normal file
54
app/api/rooms/[roomId]/simulate-donation/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||
import {
|
||||
addDiceFromDonation,
|
||||
getRoom,
|
||||
notifyRoom,
|
||||
toPublicRoom,
|
||||
} from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type Params = { params: Promise<{ roomId: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { roomId } = await params;
|
||||
const room = getRoom(roomId);
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const access = await assertChannelRoomAccess(room, request);
|
||||
if (!access.ok) return access.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
payAmount?: number;
|
||||
donatorNickname?: string;
|
||||
};
|
||||
const payAmount = Number(body.payAmount ?? 0);
|
||||
if (!Number.isFinite(payAmount) || payAmount <= 0) {
|
||||
return NextResponse.json({ error: 'payAmount가 필요합니다.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!room.settings.chzzkEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: '연동 없는 룸에서는 후원 시뮬레이션을 사용할 수 없습니다.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = addDiceFromDonation(roomId, {
|
||||
payAmount,
|
||||
donatorNickname: body.donatorNickname || '테스트후원',
|
||||
donationText: 'simulate',
|
||||
simulated: true,
|
||||
});
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: '처리 실패' }, { status: 500 });
|
||||
}
|
||||
notifyRoom(roomId);
|
||||
return NextResponse.json({
|
||||
room: toPublicRoom(result.room),
|
||||
diceAdded: result.diceAdded,
|
||||
});
|
||||
}
|
||||
125
app/api/rooms/join/route.ts
Normal file
125
app/api/rooms/join/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { parseChzzkChannelId } from '@/lib/chzzk/channel';
|
||||
import {
|
||||
allowChannelIdJoin,
|
||||
isChzzkOAuthConfigured,
|
||||
requireChzzkLoginForChannelRooms,
|
||||
} from '@/lib/auth/mode';
|
||||
import { getRequestAuthSession } from '@/lib/auth/room-access';
|
||||
import {
|
||||
cancelScheduledDisconnectRoomChat,
|
||||
ensureRoomChat,
|
||||
} from '@/lib/chzzk/chat-session';
|
||||
import {
|
||||
clearDiceQueue,
|
||||
createRoom,
|
||||
findRoomByChannelId,
|
||||
getRoom,
|
||||
notifyRoom,
|
||||
toPublicRoom,
|
||||
} from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* 치지직 채널 ID(또는 라이브 URL)로 룸 입장.
|
||||
* - 개발: 채널 ID 직접 입장 가능 (+ 로그인 세션 있으면 본인 채널도 가능)
|
||||
* - 릴리즈: 로그인 세션의 채널 ID와 일치할 때만 입장
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
channelIdOrUrl?: string;
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
resetDiceQueue?: boolean;
|
||||
};
|
||||
|
||||
const session = await getRequestAuthSession(request);
|
||||
const raw = body.channelIdOrUrl || '';
|
||||
let channelId = parseChzzkChannelId(raw);
|
||||
|
||||
if (requireChzzkLoginForChannelRooms()) {
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'릴리즈 환경에서는 치지직 로그인 후 본인 채널로만 입장할 수 있습니다.',
|
||||
code: 'CHZZK_AUTH_REQUIRED',
|
||||
allowChannelIdJoin: allowChannelIdJoin(),
|
||||
oauthConfigured: isChzzkOAuthConfigured(),
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
// 입력된 채널이 있으면 세션과 일치해야 함. 없으면 세션 채널 사용.
|
||||
if (channelId && channelId !== session.channelId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '로그인한 채널과 다른 채널 ID로는 입장할 수 없습니다.',
|
||||
code: 'CHANNEL_MISMATCH',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
channelId = session.channelId;
|
||||
} else if (!channelId) {
|
||||
// 개발: 세션만 있으면 세션 채널로 입장 가능
|
||||
if (session?.channelId) channelId = session.channelId;
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'유효한 치지직 채널 ID 또는 라이브 URL이 필요합니다. 예: https://chzzk.naver.com/live/{채널ID}',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let room = findRoomByChannelId(channelId);
|
||||
let created = false;
|
||||
|
||||
if (!room) {
|
||||
created = true;
|
||||
room = createRoom({
|
||||
chzzkEnabled: true,
|
||||
liveUrl: raw.trim() || `https://chzzk.naver.com/live/${channelId}`,
|
||||
channelId,
|
||||
cheesePerDice: body.cheesePerDice,
|
||||
multiplyByCheese: body.multiplyByCheese,
|
||||
maxMultiplier: body.maxMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
cancelScheduledDisconnectRoomChat(room.id);
|
||||
|
||||
if (body.resetDiceQueue) {
|
||||
clearDiceQueue(room.id);
|
||||
notifyRoom(room.id);
|
||||
}
|
||||
|
||||
if (room.settings.chzzkEnabled) {
|
||||
try {
|
||||
await ensureRoomChat(room.id);
|
||||
} catch {
|
||||
if (created) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '채팅 연결에 실패했습니다. 방송 중인지 확인해 주세요.',
|
||||
room: toPublicRoom(room),
|
||||
created,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const latest = getRoom(room.id) ?? room;
|
||||
return NextResponse.json({
|
||||
room: toPublicRoom(latest),
|
||||
created,
|
||||
});
|
||||
}
|
||||
13
app/api/rooms/route.ts
Normal file
13
app/api/rooms/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createRoom, toPublicRoom } from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/** 치지직·채팅 연동 없이 수동 굴림 룸 생성 */
|
||||
export async function POST(_request: Request) {
|
||||
const room = createRoom({
|
||||
chzzkEnabled: false,
|
||||
});
|
||||
|
||||
return NextResponse.json({ room: toPublicRoom(room), reused: false });
|
||||
}
|
||||
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 |
@@ -83,6 +83,7 @@ function boardForExport(board: BoardConfig): BoardConfig {
|
||||
type: cell.type,
|
||||
eventId: cell.eventId ?? null,
|
||||
imageSrc: null,
|
||||
contentVisible: cell.contentVisible !== false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,15 +5,23 @@ export const MIN_PLANE_SIZE = 4;
|
||||
export const MAX_PLANE_SIZE = 16;
|
||||
|
||||
export const CELL_TYPE_PRESETS = [
|
||||
'한잔',
|
||||
'안주',
|
||||
'모두한잔',
|
||||
'두잔',
|
||||
'벌칙1',
|
||||
'벌칙2',
|
||||
'한잔마셔',
|
||||
'~~체 3분',
|
||||
'안주 한입',
|
||||
'노래 1절',
|
||||
'다같이한잔',
|
||||
'영어금지 3분',
|
||||
'두잔마셔',
|
||||
'애교/매도 50자',
|
||||
'무인도',
|
||||
'역방향',
|
||||
'비행기',
|
||||
'세계여행',
|
||||
'TMI 알려주기',
|
||||
'뒤로 3칸',
|
||||
'앞으로 4칸',
|
||||
'방어권',
|
||||
'방어권 전부삭제',
|
||||
'물한잔',
|
||||
] as const;
|
||||
|
||||
export type CellEventOption = {
|
||||
@@ -25,7 +33,7 @@ export const CELL_EVENT_OPTIONS: CellEventOption[] = [
|
||||
{ id: null, label: '없음' },
|
||||
{ id: 'island', label: '무인도' },
|
||||
{ id: 'reverse', label: '역방향' },
|
||||
{ id: 'airplane', label: '비행기 → 시작' },
|
||||
{ id: 'airplane', label: '세계여행 (원하는 칸)' },
|
||||
];
|
||||
|
||||
/** 저장용 칸 설정 (좌표는 지오메트리에서 합친다) */
|
||||
@@ -34,6 +42,8 @@ export type BoardCellConfig = {
|
||||
eventId?: string | null;
|
||||
/** 칸 전용 이미지 (data URL 또는 상대 경로) */
|
||||
imageSrc?: string | null;
|
||||
/** false면 게임에서 칸 이름(텍스트)만 숨김 (이미지는 유지) */
|
||||
contentVisible?: boolean;
|
||||
};
|
||||
|
||||
export type BoardConfig = {
|
||||
@@ -44,7 +54,46 @@ export type BoardConfig = {
|
||||
};
|
||||
|
||||
const BOARD_STORAGE_KEY = 'drinkmarble.board';
|
||||
const BOARD_VERSION = 2;
|
||||
/** v5: 추천 배치 아이콘(이모지) 제거 */
|
||||
const BOARD_VERSION = 5;
|
||||
|
||||
/** 기본 추천 배치 (시계방향, 시작 포함 28칸 = 9×7) */
|
||||
const DEFAULT_BOARD_LAYOUT: ReadonlyArray<{
|
||||
type: string;
|
||||
eventId?: string | null;
|
||||
}> = [
|
||||
{ type: '시작', eventId: null },
|
||||
{ type: '한잔마셔' },
|
||||
{ type: '~~체 3분' },
|
||||
{ type: '방어권' },
|
||||
{ type: '노래 1절' },
|
||||
{ type: '다같이한잔' },
|
||||
{ type: '앞으로 4칸' },
|
||||
{ type: '애교/매도 50자' },
|
||||
{ type: '무인도', eventId: 'island' },
|
||||
{ type: '안주 한입' },
|
||||
{ type: '두잔마셔' },
|
||||
{ type: '물한잔' },
|
||||
{ type: '한잔마셔' },
|
||||
{ type: 'TMI 알려주기' },
|
||||
{ type: '역방향', eventId: 'reverse' },
|
||||
{ type: '뒤로 3칸' },
|
||||
{ type: '안주 한입' },
|
||||
{ type: '다같이한잔' },
|
||||
{ type: '방어권 전부삭제' },
|
||||
{ type: '한잔마셔' },
|
||||
{ type: '~~체 3분' },
|
||||
{ type: '두잔마셔' },
|
||||
{ type: '세계여행', eventId: 'airplane' },
|
||||
{ type: '애교/매도 50자' },
|
||||
{ type: '안주 한입' },
|
||||
{ type: '영어금지 3분' },
|
||||
{ type: '다같이한잔' },
|
||||
{ type: '한잔마셔' },
|
||||
];
|
||||
|
||||
const SPECIAL_TYPE_NAMES = new Set(['무인도', '역방향', '비행기', '세계여행']);
|
||||
|
||||
|
||||
export function clampPlaneSize(n: number) {
|
||||
return Math.min(MAX_PLANE_SIZE, Math.max(MIN_PLANE_SIZE, Math.round(n)));
|
||||
@@ -55,9 +104,7 @@ export function perimeterLength(width: number, height: number) {
|
||||
}
|
||||
|
||||
function randomPresetType() {
|
||||
const pool = CELL_TYPE_PRESETS.filter(
|
||||
(t) => t !== '무인도' && t !== '역방향' && t !== '비행기',
|
||||
);
|
||||
const pool = CELL_TYPE_PRESETS.filter((t) => !SPECIAL_TYPE_NAMES.has(t));
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
@@ -78,10 +125,37 @@ function defaultSpecials(width: number, height: number): Record<number, BoardCel
|
||||
return {
|
||||
[topRight]: { type: '무인도', eventId: 'island' },
|
||||
[bottomRight]: { type: '역방향', eventId: 'reverse' },
|
||||
[bottomLeft]: { type: '비행기', eventId: 'airplane' },
|
||||
[bottomLeft]: { type: '세계여행', eventId: 'airplane' },
|
||||
};
|
||||
}
|
||||
|
||||
function cellFromLayout(
|
||||
entry: { type: string; eventId?: string | null },
|
||||
index: number,
|
||||
): BoardCellConfig {
|
||||
return {
|
||||
type: index === 0 ? '시작' : entry.type,
|
||||
eventId: index === 0 ? null : (entry.eventId ?? null),
|
||||
imageSrc: null,
|
||||
contentVisible: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 추천 기본 배치 (9×7) */
|
||||
export function createDefaultBoardConfig(): BoardConfig {
|
||||
const w = DEFAULT_PLANE_SIZE.width;
|
||||
const h = DEFAULT_PLANE_SIZE.height;
|
||||
const len = perimeterLength(w, h);
|
||||
if (DEFAULT_BOARD_LAYOUT.length !== len) {
|
||||
throw new Error(
|
||||
`DEFAULT_BOARD_LAYOUT length ${DEFAULT_BOARD_LAYOUT.length} != perimeter ${len}`,
|
||||
);
|
||||
}
|
||||
const cells = DEFAULT_BOARD_LAYOUT.map((entry, index) => cellFromLayout(entry, index));
|
||||
cells[0] = { type: '시작', eventId: null, imageSrc: null, contentVisible: true };
|
||||
return { version: BOARD_VERSION, width: w, height: h, cells };
|
||||
}
|
||||
|
||||
/** 시계방향 둘레 좌표만 만든다. */
|
||||
export function buildPerimeterGeometry(
|
||||
width: number,
|
||||
@@ -114,12 +188,25 @@ export function createRandomBoardConfig(
|
||||
): BoardConfig {
|
||||
const w = clampPlaneSize(width);
|
||||
const h = clampPlaneSize(height);
|
||||
if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) {
|
||||
return createDefaultBoardConfig();
|
||||
}
|
||||
|
||||
const geo = buildPerimeterGeometry(w, h);
|
||||
const specials = defaultSpecials(w, h);
|
||||
const cells: BoardCellConfig[] = geo.map((_, index) => {
|
||||
if (index === 0) return { type: '시작', eventId: null, imageSrc: null };
|
||||
if (specials[index]) return { ...specials[index], imageSrc: null };
|
||||
return { type: randomPresetType(), eventId: null, imageSrc: null };
|
||||
if (index === 0) {
|
||||
return { type: '시작', eventId: null, imageSrc: null, contentVisible: true };
|
||||
}
|
||||
if (specials[index]) {
|
||||
return { ...specials[index], imageSrc: null, contentVisible: true };
|
||||
}
|
||||
return {
|
||||
type: randomPresetType(),
|
||||
eventId: null,
|
||||
imageSrc: null,
|
||||
contentVisible: true,
|
||||
};
|
||||
});
|
||||
return { version: BOARD_VERSION, width: w, height: h, cells };
|
||||
}
|
||||
@@ -136,7 +223,8 @@ function sanitizeCell(src: BoardCellConfig | undefined, fallback: BoardCellConfi
|
||||
: String(src.eventId);
|
||||
const imageSrc =
|
||||
typeof src.imageSrc === 'string' && src.imageSrc.length > 0 ? src.imageSrc : null;
|
||||
return { type: src.type.trim(), eventId, imageSrc };
|
||||
const contentVisible = src.contentVisible !== false;
|
||||
return { type: src.type.trim(), eventId, imageSrc, contentVisible };
|
||||
}
|
||||
|
||||
export function normalizeBoardConfig(input: BoardConfig | null | undefined): BoardConfig {
|
||||
@@ -154,7 +242,12 @@ export function normalizeBoardConfig(input: BoardConfig | null | undefined): Boa
|
||||
cells.push(sanitizeCell(input.cells[i], base.cells[i]));
|
||||
}
|
||||
|
||||
cells[0] = { type: '시작', eventId: null, imageSrc: cells[0]?.imageSrc ?? null };
|
||||
cells[0] = {
|
||||
type: '시작',
|
||||
eventId: null,
|
||||
imageSrc: cells[0]?.imageSrc ?? null,
|
||||
contentVisible: cells[0]?.contentVisible !== false,
|
||||
};
|
||||
return { version: BOARD_VERSION, width, height, cells };
|
||||
}
|
||||
|
||||
@@ -163,7 +256,10 @@ export function readStoredBoard(): BoardConfig | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BOARD_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return normalizeBoardConfig(JSON.parse(raw) as BoardConfig);
|
||||
const parsed = JSON.parse(raw) as BoardConfig;
|
||||
// 구버전(랜덤 기본)은 추천 배치로 교체
|
||||
if ((parsed.version ?? 0) < BOARD_VERSION) return null;
|
||||
return normalizeBoardConfig(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -178,7 +274,7 @@ export function writeStoredBoard(config: BoardConfig) {
|
||||
export function getOrCreateBoardConfig(): BoardConfig {
|
||||
const existing = readStoredBoard();
|
||||
if (existing) return existing;
|
||||
const created = createRandomBoardConfig();
|
||||
const created = createDefaultBoardConfig();
|
||||
writeStoredBoard(created);
|
||||
return created;
|
||||
}
|
||||
@@ -192,15 +288,24 @@ export function clearBoardConfig() {
|
||||
window.localStorage.removeItem(BOARD_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** 추천 기본 배치로 되돌린다 (크기 포함 9×7 고정). */
|
||||
export function resetToDefaultBoard(): BoardConfig {
|
||||
const created = createDefaultBoardConfig();
|
||||
writeStoredBoard(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function randomizeAndSaveBoard(
|
||||
width?: number,
|
||||
height?: number,
|
||||
current = getOrCreateBoardConfig(),
|
||||
): BoardConfig {
|
||||
const created = createRandomBoardConfig(
|
||||
width ?? current.width,
|
||||
height ?? current.height,
|
||||
);
|
||||
const w = width ?? current.width;
|
||||
const h = height ?? current.height;
|
||||
if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) {
|
||||
return resetToDefaultBoard();
|
||||
}
|
||||
const created = createRandomBoardConfig(w, h);
|
||||
writeStoredBoard(created);
|
||||
return created;
|
||||
}
|
||||
@@ -227,6 +332,7 @@ export function resizeBoardConfig(
|
||||
type: '시작',
|
||||
eventId: null,
|
||||
imageSrc: prev.cells[0]?.imageSrc ?? null,
|
||||
contentVisible: prev.cells[0]?.contentVisible !== false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -242,6 +348,7 @@ export function resizeBoardConfig(
|
||||
type: src?.type && src.type !== '시작' ? src.type : randomPresetType(),
|
||||
eventId: src?.eventId ?? null,
|
||||
imageSrc: src?.imageSrc ?? null,
|
||||
contentVisible: src?.contentVisible !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,6 +365,10 @@ export function updateBoardCell(
|
||||
if (index < 0 || index >= current.cells.length) return current;
|
||||
const cells = current.cells.map((cell, i) => {
|
||||
if (i !== index) return cell;
|
||||
const contentVisible =
|
||||
patch.contentVisible === undefined
|
||||
? cell.contentVisible !== false
|
||||
: Boolean(patch.contentVisible);
|
||||
if (i === 0) {
|
||||
return {
|
||||
type: '시작',
|
||||
@@ -268,6 +379,7 @@ export function updateBoardCell(
|
||||
: patch.imageSrc === ''
|
||||
? null
|
||||
: patch.imageSrc,
|
||||
contentVisible,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -284,6 +396,7 @@ export function updateBoardCell(
|
||||
: patch.imageSrc === ''
|
||||
? null
|
||||
: patch.imageSrc,
|
||||
contentVisible,
|
||||
};
|
||||
});
|
||||
const next = {
|
||||
@@ -296,6 +409,25 @@ export function updateBoardCell(
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 모든 칸의 내용 표시 여부를 한 번에 설정 */
|
||||
export function setAllCellsContentVisible(
|
||||
visible: boolean,
|
||||
current = getOrCreateBoardConfig(),
|
||||
): BoardConfig {
|
||||
const cells = current.cells.map((cell) => ({
|
||||
...cell,
|
||||
contentVisible: visible,
|
||||
}));
|
||||
const next = {
|
||||
version: BOARD_VERSION,
|
||||
width: current.width,
|
||||
height: current.height,
|
||||
cells,
|
||||
};
|
||||
writeStoredBoard(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildPlaneCells(config: BoardConfig = getOrCreateBoardConfig()): PlaneCell[] {
|
||||
const normalized = normalizeBoardConfig(config);
|
||||
const geo = buildPerimeterGeometry(normalized.width, normalized.height);
|
||||
@@ -306,10 +438,14 @@ export function buildPlaneCells(config: BoardConfig = getOrCreateBoardConfig()):
|
||||
type: normalized.cells[index]?.type ?? (index === 0 ? '시작' : '한잔'),
|
||||
eventId: index === 0 ? null : (normalized.cells[index]?.eventId ?? null),
|
||||
imageSrc: normalized.cells[index]?.imageSrc ?? null,
|
||||
contentVisible: normalized.cells[index]?.contentVisible !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function cellColorFor(eventId: string | null | undefined, index: number) {
|
||||
export function cellColorFor(
|
||||
eventId: string | null | undefined,
|
||||
index: number,
|
||||
) {
|
||||
if (index === 0) return '#059669';
|
||||
switch (eventId) {
|
||||
case 'island':
|
||||
@@ -323,7 +459,9 @@ export function cellColorFor(eventId: string | null | undefined, index: number)
|
||||
}
|
||||
}
|
||||
|
||||
/** 주사위 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));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export type PlaneCell = {
|
||||
eventId?: string | null;
|
||||
/** 칸 이미지 (없으면 텍스트만) */
|
||||
imageSrc?: string | null;
|
||||
/** false면 게임 판에서 이름(텍스트)만 숨김 */
|
||||
contentVisible?: boolean;
|
||||
};
|
||||
|
||||
export type TurnContext = {
|
||||
@@ -23,6 +25,8 @@ export type TurnResult = {
|
||||
message: string;
|
||||
/** 비행기 등: 즉시 이동할 칸 */
|
||||
teleportTo?: number;
|
||||
/** 비행기: 도착 칸을 플레이어가 직접 고름 */
|
||||
chooseDestination?: boolean;
|
||||
};
|
||||
|
||||
export interface CellEvent {
|
||||
@@ -52,20 +56,14 @@ export class ReverseEvent implements CellEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/** 비행기: 지정한 칸으로 즉시 이동 */
|
||||
/** 비행기: 원하는 칸을 고른 뒤 그곳으로 이동 */
|
||||
export class AirplaneEvent implements CellEvent {
|
||||
readonly id = 'airplane';
|
||||
|
||||
constructor(private readonly targetIndex: number) {}
|
||||
|
||||
apply(ctx: TurnContext): TurnResult {
|
||||
const target =
|
||||
((this.targetIndex % ctx.board.length) + ctx.board.length) % ctx.board.length;
|
||||
ctx.chessman.teleport(target);
|
||||
const dest = ctx.board[target];
|
||||
apply(_ctx: TurnContext): TurnResult {
|
||||
return {
|
||||
message: `비행기! → ${dest?.type ?? target}`,
|
||||
teleportTo: target,
|
||||
message: '원하는 곳을 클릭해주세요',
|
||||
chooseDestination: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -73,8 +71,7 @@ export class AirplaneEvent implements CellEvent {
|
||||
export const CELL_EVENTS: Record<string, CellEvent> = {
|
||||
island: new IslandEvent(),
|
||||
reverse: new ReverseEvent(),
|
||||
// 기본 도착지는 시작(0). 판 생성 시 덮어쓸 수 있다.
|
||||
airplane: new AirplaneEvent(0),
|
||||
airplane: new AirplaneEvent(),
|
||||
};
|
||||
|
||||
export function getCellEvent(eventId: string | null | undefined): CellEvent | null {
|
||||
|
||||
78
app/game/chat-feed.tsx
Normal file
78
app/game/chat-feed.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export type ChatFeedItem = {
|
||||
id: string;
|
||||
at: number;
|
||||
nickname: string;
|
||||
message: string;
|
||||
kind: 'chat' | 'donation';
|
||||
payAmount?: number;
|
||||
};
|
||||
|
||||
export default function ChatFeedPanel({
|
||||
items,
|
||||
visible,
|
||||
compact = false,
|
||||
}: {
|
||||
items: ChatFeedItem[];
|
||||
visible: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [items.length, items[items.length - 1]?.id]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? '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-[20px] font-semibold text-white/80'
|
||||
: 'shrink-0 border-b border-white/10 px-2 py-1 text-[22px] font-semibold text-white/80'
|
||||
}
|
||||
>
|
||||
채팅
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
className={
|
||||
compact
|
||||
? '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 ? (
|
||||
<p className="text-white/40">채팅 대기 중…</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<p
|
||||
key={item.id}
|
||||
className={item.kind === 'donation' ? 'text-amber-200' : 'text-white/90'}
|
||||
>
|
||||
<span className="font-semibold text-white/70">{item.nickname}</span>
|
||||
<span className="text-white/40">: </span>
|
||||
<span>{item.message || (item.kind === 'donation' ? '치즈 후원' : '')}</span>
|
||||
{item.kind === 'donation' && item.payAmount != null ? (
|
||||
<span className="text-amber-300"> ({item.payAmount}치즈)</span>
|
||||
) : null}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -9,6 +18,7 @@ import {
|
||||
type RapierRigidBody,
|
||||
} from '@react-three/rapier';
|
||||
import {
|
||||
Euler,
|
||||
Quaternion,
|
||||
SRGBColorSpace,
|
||||
TextureLoader,
|
||||
@@ -31,12 +41,35 @@ 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);
|
||||
|
||||
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
||||
const DICE_FACES = [
|
||||
{ value: 1, localNormal: new Vector3(0, 1, 0) },
|
||||
{ value: 6, localNormal: new Vector3(0, -1, 0) },
|
||||
@@ -47,13 +80,12 @@ const DICE_FACES = [
|
||||
];
|
||||
|
||||
const _quat = new Quaternion();
|
||||
const _euler = new Euler();
|
||||
const _normal = new Vector3();
|
||||
const WORLD_UP = new Vector3(0, 1, 0);
|
||||
|
||||
export type DiceRollResult = {
|
||||
/** 이동에 쓰는 합 */
|
||||
sum: number;
|
||||
/** 각 주사위 윗면 */
|
||||
faces: number[];
|
||||
};
|
||||
|
||||
@@ -98,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -224,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 {
|
||||
@@ -248,6 +311,7 @@ function RapierDiceExample({
|
||||
faceUrlSets,
|
||||
floorColor,
|
||||
floorImage,
|
||||
canvasKey,
|
||||
}: {
|
||||
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||
diceCount: DiceCount;
|
||||
@@ -256,41 +320,60 @@ 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} />
|
||||
<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>
|
||||
|
||||
{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]}
|
||||
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={
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#f8fafc" />
|
||||
</mesh>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<DiceMesh
|
||||
key={faceUrlSets[index]?.join('|') ?? index}
|
||||
faceUrls={faceUrlSets[index] ?? DEFAULT_DICE_FACE_TEXTURES}
|
||||
@@ -310,17 +393,48 @@ function RapierDiceExample({
|
||||
}
|
||||
|
||||
function rollDice(body: RapierRigidBody | null) {
|
||||
if (!body) return;
|
||||
if (!body) return false;
|
||||
|
||||
const t = body.translation();
|
||||
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,
|
||||
);
|
||||
|
||||
// 탁자 위에 거의 붙여 두고 밀어, 낙하 중 공중회전을 없앰
|
||||
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);
|
||||
|
||||
// 네 벽 중 하나 (+ 약한 편향)
|
||||
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;
|
||||
}
|
||||
|
||||
function formatResult(result: DiceRollResult | null) {
|
||||
@@ -329,16 +443,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 +470,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 +484,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,11 +523,20 @@ 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)]">
|
||||
<div
|
||||
className="relative flex h-full w-full min-h-0 min-w-0 flex-col"
|
||||
style={{ containerType: 'size' }}
|
||||
>
|
||||
<div
|
||||
ref={hostRef}
|
||||
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
|
||||
diceRefs={diceRefs}
|
||||
diceCount={diceCount}
|
||||
@@ -382,29 +545,75 @@ export default function Dice({
|
||||
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>
|
||||
)}
|
||||
</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 () => {
|
||||
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 (let i = 0; i < diceCount; i++) {
|
||||
rollDice(diceRefs[i].current);
|
||||
}
|
||||
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
|
||||
<div className="rounded bg-black/70 px-3 py-1 text-sm text-white">
|
||||
{queueHint
|
||||
? queueHint
|
||||
: !canvasReady
|
||||
? '주사위 준비 중…'
|
||||
: rolling
|
||||
? '굴리는 중…'
|
||||
: disabled
|
||||
? '이동 중…'
|
||||
: requireDiceQueue && diceQueue <= 0
|
||||
? '후원 대기 중…'
|
||||
: formatResult(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import Dice from './dice';
|
||||
import ChatFeedPanel, { type ChatFeedItem } from './chat-feed';
|
||||
import { Chessman } from './chessman';
|
||||
import ChessmanPiece from './chessman-piece';
|
||||
import { applyLandEvent, type PlaneCell } from './cell-event';
|
||||
import { getAssets } from './asset-storage';
|
||||
import { SHOW_DEBUG_UI } from '@/lib/ui/debug';
|
||||
import {
|
||||
buildPlaneCells,
|
||||
cellColorFor,
|
||||
DEFAULT_PLANE_SIZE,
|
||||
diceAreaSpan,
|
||||
getOrCreateBoardConfig,
|
||||
perimeterLength,
|
||||
} from './board-config';
|
||||
|
||||
const STEP_MS = 280;
|
||||
@@ -20,27 +24,44 @@ function cellLabel(cell: PlaneCell | undefined, index: number) {
|
||||
return index === 0 ? '시작' : cell.type;
|
||||
}
|
||||
|
||||
export default function DicePlane() {
|
||||
const initial = getOrCreateBoardConfig();
|
||||
const [planeSize, setPlaneSize] = useState({
|
||||
width: initial.width,
|
||||
height: initial.height,
|
||||
export default function DicePlane({
|
||||
requireDiceQueue = false,
|
||||
diceQueue = 0,
|
||||
onConsumeDice,
|
||||
chatFeed = [],
|
||||
showChatFeed = false,
|
||||
settingsButton = null,
|
||||
}: {
|
||||
requireDiceQueue?: boolean;
|
||||
diceQueue?: number;
|
||||
onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>;
|
||||
chatFeed?: ChatFeedItem[];
|
||||
showChatFeed?: boolean;
|
||||
/** 판 안쪽 왼쪽 아래(둘레 칸 밖)에 배치할 설정 버튼 */
|
||||
settingsButton?: ReactNode;
|
||||
}) {
|
||||
const [boardReady, setBoardReady] = useState(false);
|
||||
const [planeSize, setPlaneSize] = useState<{ width: number; height: number }>({
|
||||
width: DEFAULT_PLANE_SIZE.width,
|
||||
height: DEFAULT_PLANE_SIZE.height,
|
||||
});
|
||||
const [dicePlaneArray, setDicePlaneArray] = useState<PlaneCell[]>(() =>
|
||||
buildPlaneCells(initial),
|
||||
);
|
||||
const chessmanRef = useRef(new Chessman(initial.cells.length));
|
||||
const [dicePlaneArray, setDicePlaneArray] = useState<PlaneCell[]>([]);
|
||||
const chessmanRef = useRef(new Chessman(perimeterLength(DEFAULT_PLANE_SIZE.width, DEFAULT_PLANE_SIZE.height)));
|
||||
const boardRef = useRef(dicePlaneArray);
|
||||
const [chessmanIndex, setChessmanIndex] = useState(0);
|
||||
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
||||
const [hopKey, setHopKey] = useState(0);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [landedLabel, setLandedLabel] = useState<string | null>(null);
|
||||
const [landedKey, setLandedKey] = useState(0);
|
||||
const [statusText, setStatusText] = useState('시계방향');
|
||||
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
|
||||
const [pickingDestination, setPickingDestination] = useState(false);
|
||||
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRollRef = useRef(0);
|
||||
|
||||
// SSR/초기 렌더에서 랜덤 판을 쓰지 않고, 브라우저에 저장된 설정을 로드
|
||||
useEffect(() => {
|
||||
const board = getOrCreateBoardConfig();
|
||||
const cells = buildPlaneCells(board);
|
||||
@@ -53,7 +74,9 @@ export default function DicePlane() {
|
||||
setLandedLabel(null);
|
||||
setStatusText('시계방향');
|
||||
setIslandTurnsLeft(0);
|
||||
setPickingDestination(false);
|
||||
setDicePlaneArray(cells);
|
||||
setBoardReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,6 +107,15 @@ export default function DicePlane() {
|
||||
setStatusText(man.facing === 1 ? '시계방향' : '반시계방향');
|
||||
setIslandTurnsLeft(man.islandTurnsLeft);
|
||||
|
||||
if (result?.chooseDestination) {
|
||||
pendingRollRef.current = roll;
|
||||
setPickingDestination(true);
|
||||
showBanner(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setPickingDestination(false);
|
||||
|
||||
if (result?.teleportTo != null) {
|
||||
setChessmanIndex(result.teleportTo);
|
||||
setHopKey((k) => k + 1);
|
||||
@@ -96,6 +128,19 @@ export default function DicePlane() {
|
||||
[showBanner],
|
||||
);
|
||||
|
||||
const onCellPick = useCallback(
|
||||
(index: number) => {
|
||||
if (!pickingDestination || moving) return;
|
||||
const man = chessmanRef.current;
|
||||
man.teleport(index);
|
||||
setChessmanIndex(index);
|
||||
setHopKey((k) => k + 1);
|
||||
setPickingDestination(false);
|
||||
applyLanding(index, pendingRollRef.current);
|
||||
},
|
||||
[pickingDestination, moving, applyLanding],
|
||||
);
|
||||
|
||||
const animateMove = useCallback(
|
||||
(steps: number, roll: number) => {
|
||||
const amount = Math.max(0, Math.trunc(steps));
|
||||
@@ -133,6 +178,7 @@ export default function DicePlane() {
|
||||
if (roll === 0) return;
|
||||
|
||||
setLandedLabel(null);
|
||||
setPickingDestination(false);
|
||||
const man = chessmanRef.current;
|
||||
|
||||
if (man.isOnIsland) {
|
||||
@@ -157,6 +203,25 @@ export default function DicePlane() {
|
||||
const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0];
|
||||
const diceSpan = diceAreaSpan(planeSize.width, planeSize.height);
|
||||
|
||||
if (!boardReady || dicePlaneArray.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
판 불러오는 중…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div
|
||||
@@ -174,9 +239,20 @@ export default function DicePlane() {
|
||||
{dicePlaneArray.map((plane, index) => {
|
||||
const isStart = index === 0;
|
||||
const isLanded = !moving && index === chessmanIndex && landedLabel != null;
|
||||
const showText = plane.contentVisible !== false;
|
||||
return (
|
||||
<div
|
||||
key={plane.id}
|
||||
role={pickingDestination ? 'button' : undefined}
|
||||
tabIndex={pickingDestination ? 0 : undefined}
|
||||
onClick={() => onCellPick(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (!pickingDestination) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onCellPick(index);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
gridColumn: plane.x + 1,
|
||||
gridRow: plane.y + 1,
|
||||
@@ -195,16 +271,20 @@ export default function DicePlane() {
|
||||
padding: 4,
|
||||
boxShadow: isLanded
|
||||
? '0 0 0 3px #fde68a, 0 0 18px rgba(253, 224, 71, 0.75)'
|
||||
: pickingDestination
|
||||
? '0 0 0 2px rgba(125, 211, 252, 0.85)'
|
||||
: isStart
|
||||
? 'inset 0 0 0 2px #a7f3d0'
|
||||
: undefined,
|
||||
transform: isLanded ? 'scale(1.04)' : undefined,
|
||||
transition: 'box-shadow 180ms ease, transform 180ms ease',
|
||||
zIndex: isLanded ? 4 : undefined,
|
||||
zIndex: pickingDestination ? 8 : isLanded ? 4 : undefined,
|
||||
cursor: pickingDestination ? 'pointer' : undefined,
|
||||
pointerEvents: pickingDestination ? 'auto' : undefined,
|
||||
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
|
||||
}}
|
||||
>
|
||||
{isStart ? '시작' : plane.type}
|
||||
{showText ? (isStart ? '시작' : plane.type) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -225,16 +305,98 @@ export default function DicePlane() {
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
zIndex: 6,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Dice onRolled={onDiceRolled} disabled={moving} />
|
||||
</div>
|
||||
<Dice
|
||||
onRolled={onDiceRolled}
|
||||
disabled={moving || pickingDestination}
|
||||
requireDiceQueue={requireDiceQueue}
|
||||
diceQueue={diceQueue}
|
||||
onConsumeDice={onConsumeDice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute top-3 right-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
|
||||
{settingsButton && (
|
||||
<div
|
||||
style={{
|
||||
// 둘레 칸 밖: 안쪽 영역 왼쪽 아래
|
||||
gridColumn: 2,
|
||||
gridRow: Math.max(3, planeSize.height - 1),
|
||||
zIndex: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
padding: 4,
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ pointerEvents: 'auto' }}>{settingsButton}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showChatFeed && (
|
||||
<div
|
||||
style={{
|
||||
// 주사위 영역(왼쪽)과 겹치지 않는 안쪽 오른쪽 위 칸만 사용
|
||||
gridColumn: `${Math.max(2 + diceSpan, planeSize.width - 2)} / ${planeSize.width}`,
|
||||
gridRow: `2 / ${Math.min(4, planeSize.height)}`,
|
||||
zIndex: 7,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
margin: 0,
|
||||
padding: '3px 7px',
|
||||
borderRadius: 7,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={chatOpen}
|
||||
onChange={(e) => setChatOpen(e.target.checked)}
|
||||
/>
|
||||
채팅
|
||||
</label>
|
||||
|
||||
{chatOpen && (
|
||||
<div style={{ minHeight: 0, flex: 1, overflow: 'hidden' }}>
|
||||
<ChatFeedPanel items={chatFeed} visible compact />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{SHOW_DEBUG_UI && (
|
||||
<div className="pointer-events-none absolute bottom-3 left-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
|
||||
{statusText}
|
||||
{islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{landedLabel && (
|
||||
<div
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import DicePlane from './diceplane';
|
||||
import { DEFAULT_ASSETS, getAssets } from './asset-storage';
|
||||
import { useRoom } from '@/lib/rooms/client';
|
||||
import { SHOW_DEBUG_UI } from '@/lib/ui/debug';
|
||||
|
||||
export default function GamePage() {
|
||||
function GamePageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const roomId = searchParams.get('roomId');
|
||||
const [backgroundSrc, setBackgroundSrc] = useState(DEFAULT_ASSETS.background);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [simAmount, setSimAmount] = useState(1000);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [addDiceCount, setAddDiceCount] = useState(1);
|
||||
const [addDiceHint, setAddDiceHint] = useState<string | null>(null);
|
||||
const { room, error, consumeDice, simulateDonation, addDice } = useRoom(roomId);
|
||||
const chzzkOn = Boolean(room?.settings.chzzkEnabled);
|
||||
const manualMode = Boolean(roomId) && room != null && !chzzkOn;
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundSrc(getAssets().background);
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) setAddDiceHint(null);
|
||||
}, [settingsOpen]);
|
||||
|
||||
const onAddDice = async () => {
|
||||
if (!roomId || !chzzkOn) return;
|
||||
const count = Math.max(1, Math.trunc(addDiceCount) || 1);
|
||||
const result = await addDice(count);
|
||||
if (!result.ok) {
|
||||
setAddDiceHint(result.error || '추가 실패');
|
||||
return;
|
||||
}
|
||||
setAddDiceHint(`주사위 +${count} 추가했습니다.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-screen overflow-hidden">
|
||||
{ready && (
|
||||
@@ -28,16 +55,167 @@ export default function GamePage() {
|
||||
)}
|
||||
<div className="absolute inset-0 z-0 bg-black/25" aria-hidden />
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="absolute top-3 left-3 z-40 rounded bg-black/70 px-3 py-1.5 text-sm text-white hover:bg-black/85"
|
||||
{SHOW_DEBUG_UI && (
|
||||
<div className="absolute top-3 left-3 z-40 flex flex-col items-start gap-2">
|
||||
{roomId && chzzkOn && (
|
||||
<div className="max-w-[min(100vw-2rem,20rem)] rounded-xl bg-black/75 px-3 py-2 text-xs text-white">
|
||||
<p className="font-semibold">주사위 큐: {room?.diceQueue ?? '…'}</p>
|
||||
{room?.settings.channelId && (
|
||||
<p className="mt-1 break-all font-mono text-[10px] text-white/50">
|
||||
채널 ID: {room.settings.channelId}
|
||||
</p>
|
||||
)}
|
||||
{room && (
|
||||
<p className="mt-1 text-white/70">
|
||||
{room.channelName || room.settings.channelId || '—'} · {room.chatStatus}
|
||||
{` · ${room.settings.cheesePerDice}원/${
|
||||
room.settings.multiplyByCheese
|
||||
? `배수${room.settings.maxMultiplier > 0 ? `≤${room.settings.maxMultiplier}` : ''}`
|
||||
: '1회'
|
||||
}`}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="mt-1 text-red-300">{error}</p>}
|
||||
{room?.chatError && <p className="mt-1 text-red-300">{room.chatError}</p>}
|
||||
{room?.lastDonations?.[0] && (
|
||||
<p className="mt-1 text-amber-200">
|
||||
최근: {room.lastDonations[0].donatorNickname}{' '}
|
||||
{room.lastDonations[0].payAmount}원 → +{room.lastDonations[0].diceAdded}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={simAmount}
|
||||
onChange={(e) => setSimAmount(Number(e.target.value) || 1)}
|
||||
className="w-24 rounded border border-white/20 bg-black/50 px-2 py-1 text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-white/15 px-2 py-1 hover:bg-white/25"
|
||||
onClick={() => void simulateDonation(simAmount).catch(() => undefined)}
|
||||
>
|
||||
설정으로
|
||||
</Link>
|
||||
후원 시뮬
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{manualMode && (
|
||||
<div className="max-w-[min(100vw-2rem,20rem)] rounded-xl bg-black/75 px-3 py-2 text-xs text-white">
|
||||
<p className="font-semibold">수동 모드</p>
|
||||
<p className="mt-1 text-white/70">
|
||||
치지직·채팅 연동 없음 · 주사위는 자유롭게 굴릴 수 있습니다.
|
||||
</p>
|
||||
{error && <p className="mt-1 text-red-300">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 h-full w-full">
|
||||
<DicePlane />
|
||||
<DicePlane
|
||||
requireDiceQueue={chzzkOn}
|
||||
diceQueue={room?.diceQueue ?? 0}
|
||||
onConsumeDice={consumeDice}
|
||||
showChatFeed={chzzkOn}
|
||||
chatFeed={chzzkOn ? room?.chatFeed ?? [] : []}
|
||||
settingsButton={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="설정"
|
||||
aria-expanded={settingsOpen}
|
||||
onClick={() => setSettingsOpen((v) => !v)}
|
||||
className="rounded-md bg-black/70 px-3 py-1.5 text-sm font-semibold text-white shadow-md hover:bg-black/85"
|
||||
>
|
||||
설정
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settingsOpen && (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="게임 설정"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-2xl border border-white/15 bg-slate-950/95 px-5 py-4 text-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">설정</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-sm text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg bg-white/10 px-4 py-2.5 text-center text-sm font-medium hover:bg-white/15"
|
||||
>
|
||||
홈으로
|
||||
</Link>
|
||||
|
||||
{roomId && chzzkOn ? (
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 p-3">
|
||||
<p className="text-xs text-white/60">주사위 큐 수동 추가</p>
|
||||
<p className="mt-1 text-sm text-white/80">
|
||||
현재 큐: <span className="font-semibold">{room?.diceQueue ?? 0}</span>
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={addDiceCount}
|
||||
onChange={(e) => setAddDiceCount(Number(e.target.value) || 1)}
|
||||
className="w-20 rounded border border-white/20 bg-black/50 px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-sky-700 px-3 py-1.5 text-sm font-medium hover:bg-sky-600"
|
||||
onClick={() => void onAddDice()}
|
||||
>
|
||||
카운트 추가
|
||||
</button>
|
||||
</div>
|
||||
{addDiceHint && (
|
||||
<p className="mt-2 text-xs text-amber-200">{addDiceHint}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-white/65">
|
||||
{manualMode
|
||||
? '수동 모드에서는 주사위 큐 없이 바로 굴릴 수 있습니다.'
|
||||
: '치지직 연동 방에 입장하면 여기서 주사위 카운트를 추가할 수 있습니다.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GamePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen items-center justify-center bg-black text-white">
|
||||
불러오는 중…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<GamePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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 = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "알코올 마블!",
|
||||
description: "치지직 온라인 주루마블",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
|
||||
@@ -1,31 +1,6 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function MainPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-[#140f1c] text-white">
|
||||
<h1 className="text-4xl font-bold">주루마블!</h1>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-amber-500 px-4 py-2 font-semibold text-stone-950 hover:bg-amber-400"
|
||||
onClick={() => {
|
||||
router.push('/game');
|
||||
}}
|
||||
>
|
||||
시작하기
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md bg-white/10 px-4 py-2 text-sm hover:bg-white/20"
|
||||
>
|
||||
커스텀 설정
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
/** /main 은 / 로 통합되었습니다. */
|
||||
export default function MainRedirect() {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
414
app/page.tsx
414
app/page.tsx
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ASSET_ASPECT,
|
||||
@@ -38,8 +39,10 @@ import {
|
||||
clearBoardConfig,
|
||||
getOrCreateBoardConfig,
|
||||
perimeterLength,
|
||||
randomizeAndSaveBoard,
|
||||
resetToDefaultBoard,
|
||||
resizeBoardConfig,
|
||||
setAllCellsContentVisible,
|
||||
setBoardConfig,
|
||||
updateBoardCell,
|
||||
} from './game/board-config';
|
||||
|
||||
@@ -145,17 +148,34 @@ function BoardEditor({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
|
||||
<p className="text-[11px] text-white/50">
|
||||
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지를 설정합니다. 0번(시작) 이름은 고정입니다.
|
||||
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지·텍스트 표시를 설정합니다. 0번(시작) 이름은
|
||||
고정입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
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))}
|
||||
>
|
||||
전체 텍스트 표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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))}
|
||||
>
|
||||
전체 텍스트 숨김
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
|
||||
onClick={() => onChange(randomizeAndSaveBoard(board.width, board.height, board))}
|
||||
onClick={() => onChange(resetToDefaultBoard())}
|
||||
>
|
||||
랜덤 다시 배치
|
||||
추천 배치로 되돌리기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg bg-black/25 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -195,13 +215,14 @@ function BoardEditor({
|
||||
{geo.map((g, index) => {
|
||||
const cell = board.cells[index];
|
||||
const active = selected === index;
|
||||
const showText = cell?.contentVisible !== false;
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
title={`#${index} ${cell?.type ?? ''}`}
|
||||
title={`#${index} ${cell?.type ?? ''}${showText ? '' : ' (텍스트 숨김)'}`}
|
||||
onClick={() => setSelected(index)}
|
||||
className="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={{
|
||||
gridColumn: g.x + 1,
|
||||
gridRow: g.y + 1,
|
||||
@@ -215,6 +236,11 @@ function BoardEditor({
|
||||
}}
|
||||
>
|
||||
{index === 0 ? '시작' : cell?.type}
|
||||
{!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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -262,6 +288,27 @@ function BoardEditor({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:col-span-2">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-white/85">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCell.contentVisible !== false}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
updateBoardCell(
|
||||
selected,
|
||||
{ contentVisible: e.target.checked },
|
||||
board,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
게임에서 이 칸 이름(텍스트) 표시
|
||||
</label>
|
||||
<p className="text-[11px] text-white/45">
|
||||
끄면 판에서 이름 글자만 숨깁니다. 이미지·색·착지 효과는 그대로입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs text-white/60">
|
||||
@@ -316,6 +363,7 @@ function BoardEditor({
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
|
||||
const [board, setBoard] = useState<BoardConfig | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -323,16 +371,168 @@ export default function Home() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const packInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [roomCheesePerDice, setRoomCheesePerDice] = useState(1000);
|
||||
const [roomMultiplyByCheese, setRoomMultiplyByCheese] = useState(true);
|
||||
const [roomMaxMultiplier, setRoomMaxMultiplier] = useState(10);
|
||||
const [resetDiceQueueOnJoin, setResetDiceQueueOnJoin] = useState(false);
|
||||
const [joinChannelInput, setJoinChannelInput] = useState('');
|
||||
const [roomBusy, setRoomBusy] = useState(false);
|
||||
const [roomError, setRoomError] = useState<string | null>(null);
|
||||
const [authMode, setAuthMode] = useState<{
|
||||
allowChannelIdJoin: boolean;
|
||||
requireChzzkLogin: boolean;
|
||||
oauthConfigured: boolean;
|
||||
} | null>(null);
|
||||
const [authSession, setAuthSession] = useState<{
|
||||
channelId: string;
|
||||
channelName: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const reload = () => {
|
||||
setAssets(getAssets());
|
||||
setBoard(getOrCreateBoardConfig());
|
||||
};
|
||||
|
||||
const refreshAuth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/session', { cache: 'no-store' });
|
||||
const json = await res.json();
|
||||
setAuthMode({
|
||||
allowChannelIdJoin: Boolean(json.allowChannelIdJoin),
|
||||
requireChzzkLogin: Boolean(json.requireChzzkLogin),
|
||||
oauthConfigured: Boolean(json.oauthConfigured),
|
||||
});
|
||||
setAuthSession(
|
||||
json.session
|
||||
? {
|
||||
channelId: String(json.session.channelId),
|
||||
channelName: json.session.channelName ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
} catch {
|
||||
setAuthMode({
|
||||
allowChannelIdJoin: true,
|
||||
requireChzzkLogin: false,
|
||||
oauthConfigured: false,
|
||||
});
|
||||
setAuthSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
setReady(true);
|
||||
void refreshAuth();
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const authError = params.get('authError');
|
||||
if (authError) setRoomError(authError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const roomCheeseOptions = () => ({
|
||||
cheesePerDice: roomCheesePerDice,
|
||||
multiplyByCheese: roomMultiplyByCheese,
|
||||
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
|
||||
resetDiceQueue: resetDiceQueueOnJoin,
|
||||
});
|
||||
|
||||
const startChzzkLogin = () => {
|
||||
const opts = roomCheeseOptions();
|
||||
const q = new URLSearchParams({
|
||||
cheesePerDice: String(opts.cheesePerDice),
|
||||
multiplyByCheese: opts.multiplyByCheese ? '1' : '0',
|
||||
maxMultiplier: String(opts.maxMultiplier),
|
||||
resetDiceQueue: opts.resetDiceQueue ? '1' : '0',
|
||||
});
|
||||
window.location.href = `/api/chzzk/login?${q.toString()}`;
|
||||
};
|
||||
|
||||
const joinWithSession = async () => {
|
||||
try {
|
||||
setRoomBusy(true);
|
||||
setRoomError(null);
|
||||
const res = await fetch('/api/rooms/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...roomCheeseOptions(),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setRoomError(json.error || '룸 입장에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
router.push(`/game?roomId=${json.room.id}`);
|
||||
} catch (e) {
|
||||
setRoomError(e instanceof Error ? e.message : '룸 입장에 실패했습니다.');
|
||||
} finally {
|
||||
setRoomBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logoutAuth = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
setAuthSession(null);
|
||||
};
|
||||
|
||||
const joinByChzzkId = async () => {
|
||||
const value = joinChannelInput.trim();
|
||||
if (!value) {
|
||||
setRoomError('치지직 채널 ID 또는 라이브 URL을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setRoomBusy(true);
|
||||
setRoomError(null);
|
||||
const res = await fetch('/api/rooms/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelIdOrUrl: value,
|
||||
...roomCheeseOptions(),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setRoomError(json.error || '룸을 찾을 수 없습니다.');
|
||||
if (json.room?.id) {
|
||||
router.push(`/game?roomId=${json.room.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
router.push(`/game?roomId=${json.room.id}`);
|
||||
} catch (e) {
|
||||
setRoomError(e instanceof Error ? e.message : '룸 입장에 실패했습니다.');
|
||||
} finally {
|
||||
setRoomBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enterWithoutIntegration = async () => {
|
||||
try {
|
||||
setRoomBusy(true);
|
||||
setRoomError(null);
|
||||
const res = await fetch('/api/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setRoomError(json.error || '룸 생성에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
router.push(`/game?roomId=${json.room.id}`);
|
||||
} catch (e) {
|
||||
setRoomError(e instanceof Error ? e.message : '입장에 실패했습니다.');
|
||||
} finally {
|
||||
setRoomBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (
|
||||
kind: 'background' | 'chessman' | 'diceFloor' | { slot: DiceSlot; face: DiceFace },
|
||||
file: File,
|
||||
@@ -390,23 +590,167 @@ export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1c1224] text-white">
|
||||
<div className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-6 py-10">
|
||||
<header className="flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white/60">온라인 주루마블</p>
|
||||
<h1 className="mt-1 text-4xl font-bold tracking-tight">Drinkmarble</h1>
|
||||
<p className="mt-2 max-w-md text-sm text-white/65">
|
||||
이미지와 판 칸 설정은 서버가 아니라 이 브라우저 localStorage에만 저장됩니다.
|
||||
처음이면 칸은 랜덤으로 채워집니다.
|
||||
<header className="flex w-full flex-col items-stretch gap-5">
|
||||
<div className="w-full">
|
||||
{/*<p className="text-sm text-white/60">알코올 마블</p>*/}
|
||||
<h1 className="relative mt-1 inline-block w-full text-center text-4xl font-bold tracking-tight">
|
||||
<span className="relative inline-block pr-12">
|
||||
알코올 마블!
|
||||
<span className="pointer-events-none absolute bottom-1 left-full -ml-0.5 whitespace-nowrap text-[11px] font-normal tracking-normal text-white/35">
|
||||
by kimyu.
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
<br></br>
|
||||
<p className="border-b border-white/10 w-full"></p>
|
||||
<p className="mt-2 w-full text-sm text-white/65">치지직 온라인 주루마블!</p>
|
||||
<p className="mt-2 w-full text-sm text-white/65">
|
||||
이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장되요.
|
||||
처음이면 추천 기본 배치(9×7)로 채워져요.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/game"
|
||||
className="inline-flex items-center justify-center rounded-full bg-amber-500 px-6 py-3 text-base font-semibold text-stone-950 transition hover:bg-amber-400"
|
||||
>
|
||||
게임 시작
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">룸 입장</h2>
|
||||
<p className="mt-1 text-[11px] text-white/50">
|
||||
{authMode?.requireChzzkLogin
|
||||
? '릴리즈 모드: 치지직 로그인으로 본인 채널만 입장할 수 있습니다.'
|
||||
: '개발 모드: 채널 ID 직접 입장과 치지직 로그인을 모두 사용할 수 있습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-white/70">몇 치즈(원)마다 주사위 1개</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={100}
|
||||
value={roomCheesePerDice}
|
||||
onChange={(e) => setRoomCheesePerDice(Number(e.target.value) || 1)}
|
||||
className="rounded-lg border border-white/15 bg-black/40 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 self-end pb-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roomMultiplyByCheese}
|
||||
onChange={(e) => setRoomMultiplyByCheese(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>배수 누적 (금액 ÷ 기준만큼 주사위)</span>
|
||||
</label>
|
||||
{roomMultiplyByCheese && (
|
||||
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span className="text-white/70">최대 배수 (한 후원당 최대 주사위)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={roomMaxMultiplier}
|
||||
onChange={(e) => setRoomMaxMultiplier(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="rounded-lg border border-white/15 bg-black/40 px-3 py-2"
|
||||
/>
|
||||
<span className="text-[11px] text-white/45">
|
||||
예: 기준 {roomCheesePerDice}원 · 최대 {roomMaxMultiplier}배 → 한 번에 최대{' '}
|
||||
{roomMaxMultiplier}개
|
||||
</span>
|
||||
</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 className="flex flex-col gap-2 border-t border-white/10 pt-3">
|
||||
<p className="text-xs font-medium text-white/70">치지직 로그인으로 입장</p>
|
||||
{authSession ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm text-emerald-200/90">
|
||||
로그인됨: {authSession.channelName || authSession.channelId}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={roomBusy}
|
||||
onClick={() => void joinWithSession()}
|
||||
className="rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
내 채널로 입장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void logoutAuth()}
|
||||
className="rounded-full bg-white/10 px-3 py-2 text-xs text-white/80 hover:bg-white/20"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={roomBusy || authMode?.oauthConfigured === false}
|
||||
onClick={startChzzkLogin}
|
||||
className="self-start rounded-full bg-sky-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-sky-500 disabled:opacity-50"
|
||||
>
|
||||
치지직으로 로그인 후 입장
|
||||
</button>
|
||||
)}
|
||||
{authMode && !authMode.oauthConfigured && (
|
||||
<p className="text-[11px] text-amber-200/80">
|
||||
OAuth 미설정: `.env`에 CHZZK_CLIENT_ID / SECRET / REDIRECT_URI 를 넣으면 활성화됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authMode?.allowChannelIdJoin !== false && (
|
||||
<div className="flex flex-wrap items-end gap-2 border-t border-white/10 pt-3">
|
||||
<label className="flex min-w-[220px] flex-1 flex-col gap-1 text-sm">
|
||||
<span className="text-white/70">
|
||||
치지직 채널 ID로 입장
|
||||
<span className="ml-1 text-[10px] text-amber-200/70">(개발용)</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="채널ID 또는 https://chzzk.naver.com/live/…"
|
||||
value={joinChannelInput}
|
||||
onChange={(e) => setJoinChannelInput(e.target.value)}
|
||||
className="rounded-lg border border-white/15 bg-black/40 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={roomBusy || !joinChannelInput.trim()}
|
||||
onClick={() => void joinByChzzkId()}
|
||||
className="rounded-full bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-violet-500 disabled:opacity-50"
|
||||
>
|
||||
{roomBusy ? '처리 중…' : '입장'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={roomBusy}
|
||||
onClick={() => void enterWithoutIntegration()}
|
||||
className="self-start rounded-full bg-white/15 px-4 py-2.5 text-sm text-white hover:bg-white/25 disabled:opacity-50"
|
||||
>
|
||||
연동 없이 입장
|
||||
<span className="ml-1 text-white/50">(채팅·치지직 없음 · 수동 굴림)</span>
|
||||
</button>
|
||||
|
||||
{roomError && (
|
||||
<p className="rounded-lg bg-red-900/70 px-3 py-2 text-sm">{roomError}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!ready || !board ? (
|
||||
<p className="text-white/50">불러오는 중…</p>
|
||||
) : (
|
||||
@@ -456,16 +800,22 @@ export default function Home() {
|
||||
</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.*,
|
||||
dice/1|2/면.*, dice-floor.*
|
||||
</p>
|
||||
</p> */}
|
||||
|
||||
{error && (
|
||||
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
|
||||
)}
|
||||
|
||||
<BoardEditor board={board} onChange={setBoard} />
|
||||
<BoardEditor
|
||||
board={board}
|
||||
onChange={(next) => {
|
||||
setBoardConfig(next);
|
||||
setBoard(next);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AssetThumb
|
||||
@@ -603,6 +953,26 @@ export default function Home() {
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
46
lib/auth/mode.ts
Normal file
46
lib/auth/mode.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 개발: 채널 ID 입장 + 치지직 로그인 모두 허용
|
||||
* 릴리즈(production): 치지직 로그인(세션)으로만 채널 룸 입장
|
||||
*
|
||||
* 강제 오버라이드:
|
||||
* - DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN=true → production에서도 채널 ID 입장 허용
|
||||
* - DRINKMARBLE_REQUIRE_CHZZK_LOGIN=true → development에서도 로그인 필수
|
||||
*/
|
||||
export function allowChannelIdJoin(): boolean {
|
||||
if (process.env.DRINKMARBLE_REQUIRE_CHZZK_LOGIN === 'true') return false;
|
||||
if (process.env.DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN === 'true') return true;
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
export function requireChzzkLoginForChannelRooms(): boolean {
|
||||
return !allowChannelIdJoin();
|
||||
}
|
||||
|
||||
export function isChzzkOAuthConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.CHZZK_CLIENT_ID?.trim() &&
|
||||
process.env.CHZZK_CLIENT_SECRET?.trim() &&
|
||||
process.env.CHZZK_REDIRECT_URI?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getChzzkOAuthConfig() {
|
||||
const clientId = process.env.CHZZK_CLIENT_ID?.trim() || '';
|
||||
const clientSecret = process.env.CHZZK_CLIENT_SECRET?.trim() || '';
|
||||
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim() || '';
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error(
|
||||
'치지직 OAuth 환경변수(CHZZK_CLIENT_ID, CHZZK_CLIENT_SECRET, CHZZK_REDIRECT_URI)가 필요합니다.',
|
||||
);
|
||||
}
|
||||
return { clientId, clientSecret, redirectUri };
|
||||
}
|
||||
|
||||
export function getAuthSessionSecret(): string {
|
||||
const explicit = process.env.AUTH_SESSION_SECRET?.trim();
|
||||
if (explicit) return explicit;
|
||||
const fallback = process.env.CHZZK_CLIENT_SECRET?.trim();
|
||||
if (fallback) return fallback;
|
||||
// 개발 전용 폴백 (production에서는 OAuth 설정이 있어야 함)
|
||||
return 'drinkmarble-dev-session-secret';
|
||||
}
|
||||
59
lib/auth/public-origin.ts
Normal file
59
lib/auth/public-origin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Caddy 등 리버스 프록시 뒤에서 request.url 이
|
||||
* http://0.0.0.0:13000 처럼 잡히는 경우가 있어, 공개 Origin 을 따로 계산한다.
|
||||
*/
|
||||
export function getPublicOrigin(request: Request): string {
|
||||
const fromEnv =
|
||||
process.env.APP_ORIGIN?.trim() ||
|
||||
process.env.NEXT_PUBLIC_APP_ORIGIN?.trim();
|
||||
if (fromEnv) {
|
||||
try {
|
||||
return new URL(fromEnv).origin;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim();
|
||||
if (redirectUri) {
|
||||
try {
|
||||
return new URL(redirectUri).origin;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const xfHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
|
||||
const xfProto =
|
||||
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'https';
|
||||
if (xfHost && !isBadHost(xfHost)) {
|
||||
return `${xfProto}://${xfHost}`;
|
||||
}
|
||||
|
||||
const host = request.headers.get('host')?.trim();
|
||||
if (host && !isBadHost(host)) {
|
||||
const proto =
|
||||
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ||
|
||||
(host.includes('localhost') || host.startsWith('127.') ? 'http' : 'https');
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const origin = new URL(request.url).origin;
|
||||
if (!isBadHost(new URL(origin).host)) return origin;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return 'https://drink.kimyu.xyz';
|
||||
}
|
||||
|
||||
function isBadHost(host: string) {
|
||||
const h = host.toLowerCase().split(':')[0];
|
||||
return (
|
||||
h === '0.0.0.0' ||
|
||||
h === '::' ||
|
||||
h === '[::]' ||
|
||||
h === 'host.docker.internal'
|
||||
);
|
||||
}
|
||||
68
lib/auth/room-access.ts
Normal file
68
lib/auth/room-access.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
allowChannelIdJoin,
|
||||
isChzzkOAuthConfigured,
|
||||
requireChzzkLoginForChannelRooms,
|
||||
} from '@/lib/auth/mode';
|
||||
import { readAuthSession, type AuthSession } from '@/lib/auth/session';
|
||||
import type { RoomRecord } from '@/lib/rooms/store';
|
||||
|
||||
export async function getRequestAuthSession(
|
||||
request?: Request,
|
||||
): Promise<AuthSession | null> {
|
||||
// Route Handler에서는 cookies()가 기본. request Cookie 헤더도 보조 파싱.
|
||||
const fromCookies = await readAuthSession();
|
||||
if (fromCookies) return fromCookies;
|
||||
if (!request) return null;
|
||||
const header = request.headers.get('cookie') || '';
|
||||
const match = /(?:^|;\s*)drinkmarble_auth=([^;]+)/.exec(header);
|
||||
if (!match?.[1]) return null;
|
||||
const { parseAuthSession } = await import('@/lib/auth/session');
|
||||
return parseAuthSession(decodeURIComponent(match[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 치지직 연동 룸 접근 검사.
|
||||
* - 개발(채널 ID 허용): 세션 없어도 OK
|
||||
* - 릴리즈: 세션의 channelId가 룸 채널과 일치해야 함
|
||||
*/
|
||||
export async function assertChannelRoomAccess(
|
||||
room: RoomRecord,
|
||||
request?: Request,
|
||||
): Promise<{ ok: true; session: AuthSession | null } | { ok: false; response: NextResponse }> {
|
||||
if (!room.settings.chzzkEnabled) {
|
||||
return { ok: true, session: null };
|
||||
}
|
||||
|
||||
if (!requireChzzkLoginForChannelRooms()) {
|
||||
return { ok: true, session: await getRequestAuthSession(request) };
|
||||
}
|
||||
|
||||
const session = await getRequestAuthSession(request);
|
||||
const roomChannel = room.settings.channelId?.toLowerCase() || '';
|
||||
if (!session || !roomChannel || session.channelId !== roomChannel) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'릴리즈 환경에서는 치지직 로그인으로 본인 채널만 입장할 수 있습니다. 홈에서 다시 로그인해 주세요.',
|
||||
code: 'CHZZK_AUTH_REQUIRED',
|
||||
allowChannelIdJoin: allowChannelIdJoin(),
|
||||
oauthConfigured: isChzzkOAuthConfigured(),
|
||||
},
|
||||
{ status: 401 },
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, session };
|
||||
}
|
||||
|
||||
export function authModePublic() {
|
||||
return {
|
||||
allowChannelIdJoin: allowChannelIdJoin(),
|
||||
requireChzzkLogin: requireChzzkLoginForChannelRooms(),
|
||||
oauthConfigured: isChzzkOAuthConfigured(),
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
};
|
||||
}
|
||||
140
lib/auth/session.ts
Normal file
140
lib/auth/session.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getAuthSessionSecret } from './mode';
|
||||
|
||||
export const AUTH_COOKIE = 'drinkmarble_auth';
|
||||
export const OAUTH_STATE_COOKIE = 'drinkmarble_oauth_state';
|
||||
|
||||
export type AuthSession = {
|
||||
channelId: string;
|
||||
channelName: string | null;
|
||||
/** unix ms */
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
state: string;
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
resetDiceQueue?: boolean;
|
||||
/** unix ms */
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function b64urlEncode(buf: Buffer | string) {
|
||||
const b = typeof buf === 'string' ? Buffer.from(buf, 'utf8') : buf;
|
||||
return b
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string) {
|
||||
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
||||
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
||||
return Buffer.from(b64, 'base64');
|
||||
}
|
||||
|
||||
function sign(payloadB64: string) {
|
||||
return createHmac('sha256', getAuthSessionSecret())
|
||||
.update(payloadB64)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function seal<T extends object>(data: T): string {
|
||||
const payloadB64 = b64urlEncode(JSON.stringify(data));
|
||||
return `${payloadB64}.${sign(payloadB64)}`;
|
||||
}
|
||||
|
||||
function unseal<T extends object>(token: string): T | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [payloadB64, sig] = parts;
|
||||
if (!payloadB64 || !sig) return null;
|
||||
const expected = sign(payloadB64);
|
||||
try {
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(b64urlDecode(payloadB64).toString('utf8')) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 브라우저 종료 시까지 + 최대 12시간 (치지직 토큰은 저장하지 않음) */
|
||||
const SESSION_MAX_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
export function createAuthSession(
|
||||
channelId: string,
|
||||
channelName: string | null,
|
||||
): AuthSession {
|
||||
return {
|
||||
channelId: channelId.trim().toLowerCase(),
|
||||
channelName,
|
||||
exp: Date.now() + SESSION_MAX_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeAuthSession(session: AuthSession) {
|
||||
return seal(session);
|
||||
}
|
||||
|
||||
export function parseAuthSession(token: string | undefined | null): AuthSession | null {
|
||||
if (!token) return null;
|
||||
const data = unseal<AuthSession>(token);
|
||||
if (!data?.channelId || !data.exp) return null;
|
||||
if (Date.now() > data.exp) return null;
|
||||
return {
|
||||
channelId: String(data.channelId).toLowerCase(),
|
||||
channelName: data.channelName ?? null,
|
||||
exp: data.exp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAuthSession(): Promise<AuthSession | null> {
|
||||
const jar = await cookies();
|
||||
return parseAuthSession(jar.get(AUTH_COOKIE)?.value);
|
||||
}
|
||||
|
||||
export function authCookieOptions(maxAgeSec = Math.floor(SESSION_MAX_MS / 1000)) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: maxAgeSec,
|
||||
};
|
||||
}
|
||||
|
||||
export function createOAuthState(input: {
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
resetDiceQueue?: boolean;
|
||||
}) {
|
||||
const state = b64urlEncode(randomBytes(24));
|
||||
const payload: OAuthStatePayload = {
|
||||
state,
|
||||
cheesePerDice: input.cheesePerDice,
|
||||
multiplyByCheese: input.multiplyByCheese,
|
||||
maxMultiplier: input.maxMultiplier,
|
||||
resetDiceQueue: input.resetDiceQueue,
|
||||
exp: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
return { state, sealed: seal(payload) };
|
||||
}
|
||||
|
||||
export function parseOAuthStateCookie(token: string | undefined | null): OAuthStatePayload | null {
|
||||
if (!token) return null;
|
||||
const data = unseal<OAuthStatePayload>(token);
|
||||
if (!data?.state || !data.exp) return null;
|
||||
if (Date.now() > data.exp) return null;
|
||||
return data;
|
||||
}
|
||||
136
lib/chzzk/channel.ts
Normal file
136
lib/chzzk/channel.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/** 치지직 라이브/채널 URL에서 channelId 추출 */
|
||||
export function parseChzzkChannelId(input: string): string | null {
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// 해시만 붙여 넣은 경우
|
||||
if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase();
|
||||
|
||||
try {
|
||||
const url = new URL(raw.startsWith('http') ? raw : `https://${raw}`);
|
||||
if (!url.hostname.includes('chzzk.naver.com')) return null;
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
// /live/{id} | /{id} | /channel/{id}
|
||||
const liveIdx = parts.indexOf('live');
|
||||
if (liveIdx >= 0 && parts[liveIdx + 1]) {
|
||||
const id = parts[liveIdx + 1];
|
||||
return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null;
|
||||
}
|
||||
const channelIdx = parts.indexOf('channel');
|
||||
if (channelIdx >= 0 && parts[channelIdx + 1]) {
|
||||
const id = parts[channelIdx + 1];
|
||||
return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null;
|
||||
}
|
||||
if (parts[0] && /^[a-f0-9]{32}$/i.test(parts[0])) {
|
||||
return parts[0].toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const CHZZK_API = 'https://api.chzzk.naver.com';
|
||||
const GAME_API = 'https://comm-api.game.naver.com/nng_main';
|
||||
|
||||
const DEFAULT_HEADERS = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
Origin: 'https://chzzk.naver.com',
|
||||
Referer: 'https://chzzk.naver.com/',
|
||||
};
|
||||
|
||||
async function getJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: DEFAULT_HEADERS,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`치지직 API 오류 (${res.status})`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export type ChannelInfo = {
|
||||
channelId: string;
|
||||
channelName: string | null;
|
||||
};
|
||||
|
||||
export async function fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
|
||||
const data = await getJson<{
|
||||
content?: { channelId?: string; channelName?: string };
|
||||
}>(`${CHZZK_API}/service/v1/channels/${channelId}`);
|
||||
return {
|
||||
channelId: data.content?.channelId || channelId,
|
||||
channelName: data.content?.channelName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type LiveChatInfo = {
|
||||
chatChannelId: string;
|
||||
liveTitle: string | null;
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
/** 방송 중일 때 chatChannelId를 얻는다. */
|
||||
export async function fetchLiveChatInfo(channelId: string): Promise<LiveChatInfo> {
|
||||
// polling live-status 우선
|
||||
try {
|
||||
const status = await getJson<{
|
||||
content?: { chatChannelId?: string; status?: string; liveTitle?: string };
|
||||
}>(`${CHZZK_API}/polling/v2/channels/${channelId}/live-status`);
|
||||
if (status.content?.chatChannelId) {
|
||||
return {
|
||||
chatChannelId: status.content.chatChannelId,
|
||||
liveTitle: status.content.liveTitle ?? null,
|
||||
status: status.content.status ?? null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const detail = await getJson<{
|
||||
content?: { chatChannelId?: string; liveTitle?: string; status?: string };
|
||||
}>(`${CHZZK_API}/service/v3/channels/${channelId}/live-detail`);
|
||||
|
||||
if (!detail.content?.chatChannelId) {
|
||||
throw new Error('방송이 꺼져 있거나 chatChannelId를 찾지 못했습니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
chatChannelId: detail.content.chatChannelId,
|
||||
liveTitle: detail.content.liveTitle ?? null,
|
||||
status: detail.content.status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchChatAccessToken(chatChannelId: string): Promise<string> {
|
||||
const data = await getJson<{
|
||||
code?: number;
|
||||
message?: string;
|
||||
content?: { accessToken?: string };
|
||||
}>(
|
||||
`${GAME_API}/v1/chats/access-token?channelId=${encodeURIComponent(chatChannelId)}&chatType=STREAMING`,
|
||||
);
|
||||
|
||||
if (data.code === 42601) {
|
||||
throw new Error('연령 제한 방송이라 익명 채팅 토큰을 받을 수 없습니다.');
|
||||
}
|
||||
const token = data.content?.accessToken;
|
||||
if (!token) {
|
||||
// 외부 응답 원문은 로그/화면으로 전달하지 않는다.
|
||||
throw new Error('채팅 연결 인증 정보를 받지 못했습니다.');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export function chatServerUrl(chatChannelId: string) {
|
||||
const sum = chatChannelId
|
||||
.split('')
|
||||
.map((c) => c.charCodeAt(0))
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const serverId = (Math.abs(sum) % 9) + 1;
|
||||
return `wss://kr-ss${serverId}.chat.naver.com/chat`;
|
||||
}
|
||||
514
lib/chzzk/chat-session.ts
Normal file
514
lib/chzzk/chat-session.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import WebSocket from 'ws';
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
chatServerUrl,
|
||||
fetchChannelInfo,
|
||||
fetchChatAccessToken,
|
||||
fetchLiveChatInfo,
|
||||
} from './channel';
|
||||
import {
|
||||
addChatMessage,
|
||||
addDiceFromDonation,
|
||||
clearChatFeed,
|
||||
getRoom,
|
||||
getRoomViewerCount,
|
||||
hasSeenDonationKey,
|
||||
markSeenDonationKey,
|
||||
notifyRoom,
|
||||
setRoomMeta,
|
||||
} from '../rooms/store';
|
||||
|
||||
const ChatCmd = {
|
||||
PING: 0,
|
||||
PONG: 10000,
|
||||
CONNECT: 100,
|
||||
CONNECTED: 10100,
|
||||
REQUEST_RECENT_CHAT: 5101,
|
||||
RECENT_CHAT: 15101,
|
||||
CHAT: 93101,
|
||||
DONATION: 93102,
|
||||
} as const;
|
||||
|
||||
const ChatType = {
|
||||
TEXT: 1,
|
||||
DONATION: 10,
|
||||
SUBSCRIPTION: 11,
|
||||
} as const;
|
||||
|
||||
type SessionHandle = {
|
||||
roomId: string;
|
||||
ws: WebSocket;
|
||||
chatChannelId: string;
|
||||
sid: string | null;
|
||||
pingTimer: ReturnType<typeof setTimeout> | null;
|
||||
pollTimer: ReturnType<typeof setInterval> | null;
|
||||
closed: boolean;
|
||||
/** 이번 연결에서 이미 피드에 넣은 메시지 키 */
|
||||
seenMessageKeys: Set<string>;
|
||||
};
|
||||
|
||||
const SEEN_KEYS_MAX = 800;
|
||||
const RECENT_CHAT_COUNT = 50;
|
||||
|
||||
const globalForSessions = globalThis as typeof globalThis & {
|
||||
__drinkmarbleChatSessions?: Map<string, SessionHandle>;
|
||||
};
|
||||
|
||||
function getSessions() {
|
||||
if (!globalForSessions.__drinkmarbleChatSessions) {
|
||||
globalForSessions.__drinkmarbleChatSessions = new Map();
|
||||
}
|
||||
return globalForSessions.__drinkmarbleChatSessions;
|
||||
}
|
||||
|
||||
function clearPing(handle: SessionHandle) {
|
||||
if (handle.pingTimer) clearTimeout(handle.pingTimer);
|
||||
handle.pingTimer = null;
|
||||
}
|
||||
|
||||
function schedulePing(handle: SessionHandle) {
|
||||
clearPing(handle);
|
||||
handle.pingTimer = setTimeout(() => {
|
||||
if (handle.closed || handle.ws.readyState !== WebSocket.OPEN) return;
|
||||
handle.ws.send(JSON.stringify({ cmd: ChatCmd.PING, ver: '2' }));
|
||||
schedulePing(handle);
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
function parseMaybeJson<T>(value: unknown): T | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object') return value as T;
|
||||
if (typeof value !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function messageTimeMs(chat: Record<string, unknown>): number | null {
|
||||
const raw = chat.msgTime ?? chat.messageTime;
|
||||
const n = typeof raw === 'string' ? Number(raw) : Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function messageKey(
|
||||
chat: Record<string, unknown>,
|
||||
kind: 'donation' | 'chat',
|
||||
): string | null {
|
||||
const id = chat.msgUid ?? chat.messageId ?? null;
|
||||
if (id != null && String(id).length > 0) {
|
||||
return hashMessageKey(`${kind}:${String(id)}`);
|
||||
}
|
||||
const time = messageTimeMs(chat);
|
||||
const profile = parseMaybeJson<{ nickname?: string; userIdHash?: string }>(
|
||||
chat.profile,
|
||||
);
|
||||
const extras = parseMaybeJson<{ payAmount?: number }>(chat.extras);
|
||||
const who = profile?.userIdHash || profile?.nickname || '';
|
||||
const msg = String(chat.msg ?? chat.content ?? '');
|
||||
if (time == null && !who && !msg) return null;
|
||||
if (kind === 'donation') {
|
||||
return hashMessageKey(
|
||||
`donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${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 {
|
||||
if (!key) return true;
|
||||
if (handle.seenMessageKeys.has(key)) return false;
|
||||
handle.seenMessageKeys.add(key);
|
||||
if (handle.seenMessageKeys.size > SEEN_KEYS_MAX) {
|
||||
const oldest = handle.seenMessageKeys.values().next().value;
|
||||
if (oldest != null) handle.seenMessageKeys.delete(oldest);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleDonationBody(
|
||||
roomId: string,
|
||||
handle: SessionHandle,
|
||||
chat: Record<string, unknown>,
|
||||
isRecent: boolean,
|
||||
): boolean {
|
||||
const key = messageKey(chat, 'donation');
|
||||
if (!rememberMessageKey(handle, key)) return false;
|
||||
|
||||
const extras = parseMaybeJson<{
|
||||
payAmount?: number;
|
||||
donationType?: string;
|
||||
}>(chat.extras);
|
||||
const profile = parseMaybeJson<{ nickname?: string }>(chat.profile);
|
||||
const payAmount = Number(extras?.payAmount ?? 0);
|
||||
if (!Number.isFinite(payAmount) || payAmount <= 0) return false;
|
||||
|
||||
// 히스토리 후원: 피드에만 표시하고, 이미 본 것으로 표시해 재적용 방지
|
||||
if (isRecent) {
|
||||
if (key) markSeenDonationKey(roomId, key);
|
||||
addDiceFromDonation(roomId, {
|
||||
payAmount,
|
||||
donatorNickname: profile?.nickname,
|
||||
donationText: String(chat.msg ?? chat.content ?? ''),
|
||||
applyDice: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key && hasSeenDonationKey(roomId, key)) {
|
||||
return false;
|
||||
}
|
||||
if (key) markSeenDonationKey(roomId, key);
|
||||
|
||||
addDiceFromDonation(roomId, {
|
||||
payAmount,
|
||||
donatorNickname: profile?.nickname,
|
||||
donationText: String(chat.msg ?? chat.content ?? ''),
|
||||
applyDice: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleChatBody(
|
||||
roomId: string,
|
||||
handle: SessionHandle,
|
||||
chat: Record<string, unknown>,
|
||||
): boolean {
|
||||
const key = messageKey(chat, 'chat');
|
||||
if (!rememberMessageKey(handle, key)) return false;
|
||||
|
||||
const profile = parseMaybeJson<{ nickname?: string }>(chat.profile);
|
||||
const message = String(chat.msg ?? chat.content ?? '').trim();
|
||||
if (!message) return false;
|
||||
addChatMessage(roomId, {
|
||||
nickname: profile?.nickname,
|
||||
message,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function processMessageList(
|
||||
roomId: string,
|
||||
handle: SessionHandle,
|
||||
body: Record<string, unknown> | unknown[] | undefined,
|
||||
cmd: number,
|
||||
) {
|
||||
const isRecent = cmd === ChatCmd.RECENT_CHAT;
|
||||
const list = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray((body as { messageList?: unknown[] })?.messageList)
|
||||
? ((body as { messageList: unknown[] }).messageList)
|
||||
: body
|
||||
? [body]
|
||||
: [];
|
||||
|
||||
let changed = false;
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const chat = item as Record<string, unknown>;
|
||||
const type = Number(chat.msgTypeCode ?? chat.messageTypeCode ?? 0);
|
||||
if (cmd === ChatCmd.DONATION || type === ChatType.DONATION) {
|
||||
if (handleDonationBody(roomId, handle, chat, isRecent)) changed = true;
|
||||
} else if (
|
||||
type === ChatType.TEXT ||
|
||||
type === ChatType.SUBSCRIPTION ||
|
||||
!type
|
||||
) {
|
||||
if (handleChatBody(roomId, handle, chat)) changed = true;
|
||||
}
|
||||
}
|
||||
// 최근 채팅 50개를 메시지마다 SSE로 보내면 주사위 Canvas가 깨질 수 있어 한 번만 알림
|
||||
if (changed) notifyRoom(roomId);
|
||||
}
|
||||
|
||||
function requestRecentChat(handle: SessionHandle) {
|
||||
if (
|
||||
handle.closed ||
|
||||
!handle.sid ||
|
||||
handle.ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handle.ws.send(
|
||||
JSON.stringify({
|
||||
ver: '2',
|
||||
cmd: ChatCmd.REQUEST_RECENT_CHAT,
|
||||
svcid: 'game',
|
||||
cid: handle.chatChannelId,
|
||||
sid: handle.sid,
|
||||
tid: 2,
|
||||
bdy: { recentMessageCount: RECENT_CHAT_COUNT },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function onSocketMessage(roomId: string, handle: SessionHandle, raw: WebSocket.RawData) {
|
||||
let json: {
|
||||
cmd?: number;
|
||||
bdy?: unknown;
|
||||
};
|
||||
try {
|
||||
json = JSON.parse(String(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = json.cmd;
|
||||
const body = json.bdy as Record<string, unknown> | unknown[] | undefined;
|
||||
|
||||
if (cmd === ChatCmd.CONNECTED) {
|
||||
const sid =
|
||||
body && !Array.isArray(body) && typeof body.sid === 'string'
|
||||
? body.sid
|
||||
: null;
|
||||
handle.sid = sid;
|
||||
handle.seenMessageKeys.clear();
|
||||
clearChatFeed(roomId);
|
||||
setRoomMeta(roomId, { chatStatus: 'connected', chatError: null });
|
||||
notifyRoom(roomId);
|
||||
schedulePing(handle);
|
||||
requestRecentChat(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === ChatCmd.PING) {
|
||||
if (handle.ws.readyState === WebSocket.OPEN) {
|
||||
handle.ws.send(JSON.stringify({ cmd: ChatCmd.PONG, ver: '2' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cmd === ChatCmd.CHAT ||
|
||||
cmd === ChatCmd.DONATION ||
|
||||
cmd === ChatCmd.RECENT_CHAT
|
||||
) {
|
||||
processMessageList(roomId, handle, body, cmd ?? 0);
|
||||
schedulePing(handle);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSocket(roomId: string, channelId: string): Promise<SessionHandle> {
|
||||
const live = await fetchLiveChatInfo(channelId);
|
||||
const accessToken = await fetchChatAccessToken(live.chatChannelId);
|
||||
const url = chatServerUrl(live.chatChannelId);
|
||||
|
||||
setRoomMeta(roomId, {
|
||||
chatChannelId: live.chatChannelId,
|
||||
chatStatus: 'connecting',
|
||||
chatError: null,
|
||||
});
|
||||
notifyRoom(roomId);
|
||||
|
||||
const ws = new WebSocket(url, {
|
||||
headers: {
|
||||
Origin: 'https://chzzk.naver.com',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
const handle: SessionHandle = {
|
||||
roomId,
|
||||
ws,
|
||||
chatChannelId: live.chatChannelId,
|
||||
sid: null,
|
||||
pingTimer: null,
|
||||
pollTimer: null,
|
||||
closed: false,
|
||||
seenMessageKeys: new Set(),
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('채팅 웹소켓 연결 시간 초과'));
|
||||
}, 12000);
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
ver: '2',
|
||||
cmd: ChatCmd.CONNECT,
|
||||
svcid: 'game',
|
||||
cid: live.chatChannelId,
|
||||
tid: 1,
|
||||
bdy: {
|
||||
uid: null,
|
||||
devType: 2001,
|
||||
accTkn: accessToken,
|
||||
auth: 'READ',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const json = JSON.parse(String(data)) as { cmd?: number };
|
||||
if (json.cmd === ChatCmd.CONNECTED) {
|
||||
clearTimeout(timer);
|
||||
onSocketMessage(roomId, handle, data);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
onSocketMessage(roomId, handle, data);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err instanceof Error ? err : new Error('웹소켓 오류'));
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
handle.closed = true;
|
||||
clearPing(handle);
|
||||
if (handle.pollTimer) clearInterval(handle.pollTimer);
|
||||
const current = getSessions().get(roomId);
|
||||
if (current === handle) {
|
||||
getSessions().delete(roomId);
|
||||
setRoomMeta(roomId, { chatStatus: 'disconnected' });
|
||||
notifyRoom(roomId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// chatChannelId 변경 폴링 (방송 재시작 대응)
|
||||
handle.pollTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await fetchLiveChatInfo(channelId);
|
||||
if (next.chatChannelId && next.chatChannelId !== handle.chatChannelId) {
|
||||
await disconnectRoomChat(roomId);
|
||||
await connectRoomChat(roomId);
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
})();
|
||||
}, 30000);
|
||||
|
||||
getSessions().set(roomId, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function isRoomChatLive(roomId: string) {
|
||||
const handle = getSessions().get(roomId);
|
||||
return Boolean(
|
||||
handle &&
|
||||
!handle.closed &&
|
||||
handle.ws.readyState === WebSocket.OPEN &&
|
||||
handle.sid,
|
||||
);
|
||||
}
|
||||
|
||||
const globalForDisconnect = globalThis as typeof globalThis & {
|
||||
__drinkmarbleChatDisconnectTimers?: Map<string, ReturnType<typeof setTimeout>>;
|
||||
};
|
||||
|
||||
function getDisconnectTimers() {
|
||||
if (!globalForDisconnect.__drinkmarbleChatDisconnectTimers) {
|
||||
globalForDisconnect.__drinkmarbleChatDisconnectTimers = new Map();
|
||||
}
|
||||
return globalForDisconnect.__drinkmarbleChatDisconnectTimers;
|
||||
}
|
||||
|
||||
/** 빠른 재입장 시 이전 leave가 새 연결을 끊지 않도록 예약 해제 */
|
||||
export function cancelScheduledDisconnectRoomChat(roomId: string) {
|
||||
const timers = getDisconnectTimers();
|
||||
const t = timers.get(roomId);
|
||||
if (!t) return;
|
||||
clearTimeout(t);
|
||||
timers.delete(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시청자가 0이 되어도 바로 끊지 않고 잠시 대기.
|
||||
* 그 사이 재입장하면 채팅 세션을 유지한다.
|
||||
*/
|
||||
export function scheduleDisconnectRoomChat(roomId: string, delayMs = 4000) {
|
||||
cancelScheduledDisconnectRoomChat(roomId);
|
||||
const timers = getDisconnectTimers();
|
||||
const t = setTimeout(() => {
|
||||
timers.delete(roomId);
|
||||
if (getRoomViewerCount(roomId) > 0) return;
|
||||
void disconnectRoomChat(roomId);
|
||||
}, delayMs);
|
||||
timers.set(roomId, t);
|
||||
}
|
||||
|
||||
/** 살아 있으면 유지(최근 채팅만 재요청), 아니면 재연결 */
|
||||
export async function ensureRoomChat(roomId: string) {
|
||||
cancelScheduledDisconnectRoomChat(roomId);
|
||||
const room = getRoom(roomId);
|
||||
if (!room) throw new Error('방을 찾을 수 없습니다.');
|
||||
if (!room.settings.chzzkEnabled) {
|
||||
throw new Error('이 방은 치지직 연동이 꺼져 있습니다.');
|
||||
}
|
||||
|
||||
if (isRoomChatLive(roomId)) {
|
||||
const handle = getSessions().get(roomId);
|
||||
if (handle) requestRecentChat(handle);
|
||||
setRoomMeta(roomId, { chatStatus: 'connected', chatError: null });
|
||||
notifyRoom(roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
await connectRoomChat(roomId);
|
||||
}
|
||||
|
||||
export async function connectRoomChat(roomId: string) {
|
||||
cancelScheduledDisconnectRoomChat(roomId);
|
||||
const room = getRoom(roomId);
|
||||
if (!room) throw new Error('방을 찾을 수 없습니다.');
|
||||
if (!room.settings.chzzkEnabled) {
|
||||
throw new Error('이 방은 치지직 연동이 꺼져 있습니다.');
|
||||
}
|
||||
const channelId = room.settings.channelId;
|
||||
if (!channelId) throw new Error('channelId가 없습니다.');
|
||||
|
||||
await disconnectRoomChat(roomId);
|
||||
|
||||
try {
|
||||
const info = await fetchChannelInfo(channelId);
|
||||
setRoomMeta(roomId, { channelName: info.channelName, chatStatus: 'connecting' });
|
||||
notifyRoom(roomId);
|
||||
await openSocket(roomId, channelId);
|
||||
} catch {
|
||||
// 외부 라이브러리 오류에는 URL·토큰·식별자가 섞일 수 있어 원문을 보관하지 않는다.
|
||||
const message = '채팅 연결에 실패했습니다. 방송 상태를 확인해 주세요.';
|
||||
setRoomMeta(roomId, { chatStatus: 'error', chatError: message });
|
||||
notifyRoom(roomId);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectRoomChat(roomId: string) {
|
||||
cancelScheduledDisconnectRoomChat(roomId);
|
||||
const handle = getSessions().get(roomId);
|
||||
if (!handle) {
|
||||
setRoomMeta(roomId, { chatStatus: 'disconnected' });
|
||||
notifyRoom(roomId);
|
||||
return;
|
||||
}
|
||||
handle.closed = true;
|
||||
clearPing(handle);
|
||||
if (handle.pollTimer) clearInterval(handle.pollTimer);
|
||||
getSessions().delete(roomId);
|
||||
try {
|
||||
handle.ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setRoomMeta(roomId, { chatStatus: 'disconnected' });
|
||||
notifyRoom(roomId);
|
||||
}
|
||||
88
lib/chzzk/oauth.ts
Normal file
88
lib/chzzk/oauth.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getChzzkOAuthConfig } from '@/lib/auth/mode';
|
||||
|
||||
const OPENAPI = 'https://openapi.chzzk.naver.com';
|
||||
|
||||
export function buildChzzkLoginUrl(state: string) {
|
||||
const { clientId, redirectUri } = getChzzkOAuthConfig();
|
||||
const url = new URL('https://chzzk.naver.com/account-interlock');
|
||||
url.searchParams.set('clientId', clientId);
|
||||
url.searchParams.set('redirectUri', redirectUri);
|
||||
url.searchParams.set('state', state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeAuthorizationCode(code: string, state: string) {
|
||||
const { clientId, clientSecret } = getChzzkOAuthConfig();
|
||||
const res = await fetch(`${OPENAPI}/auth/v1/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grantType: 'authorization_code',
|
||||
clientId,
|
||||
clientSecret,
|
||||
code,
|
||||
state,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
content?: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
tokenType?: string;
|
||||
expiresIn?: string | number;
|
||||
};
|
||||
code?: number;
|
||||
};
|
||||
if (!res.ok || !json.content?.accessToken) {
|
||||
// 외부 API 원문은 계정 정보 등이 섞일 수 있으므로 전달·기록하지 않는다.
|
||||
throw new Error(`치지직 인증 처리 실패 (${res.status})`);
|
||||
}
|
||||
return {
|
||||
accessToken: json.content.accessToken,
|
||||
refreshToken: json.content.refreshToken ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthedUser(accessToken: string) {
|
||||
const res = await fetch(`${OPENAPI}/open/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
content?: { channelId?: string; channelName?: string };
|
||||
};
|
||||
if (!res.ok || !json.content?.channelId) {
|
||||
throw new Error(`치지직 사용자 확인 실패 (${res.status})`);
|
||||
}
|
||||
return {
|
||||
channelId: json.content.channelId.toLowerCase(),
|
||||
channelName: json.content.channelName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 확인 후 치지직 토큰 폐기 (저장하지 않음) */
|
||||
export async function revokeToken(
|
||||
token: string,
|
||||
tokenTypeHint: 'access_token' | 'refresh_token' = 'access_token',
|
||||
) {
|
||||
const { clientId, clientSecret } = getChzzkOAuthConfig();
|
||||
try {
|
||||
await fetch(`${OPENAPI}/auth/v1/token/revoke`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
clientSecret,
|
||||
token,
|
||||
tokenTypeHint,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
} catch {
|
||||
// 폐기는 best-effort
|
||||
}
|
||||
}
|
||||
172
lib/rooms/client.ts
Normal file
172
lib/rooms/client.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type PublicRoomState = {
|
||||
id: string;
|
||||
settings: {
|
||||
chzzkEnabled: boolean;
|
||||
liveUrl: string;
|
||||
channelId: string | null;
|
||||
cheesePerDice: number;
|
||||
multiplyByCheese: boolean;
|
||||
maxMultiplier: number;
|
||||
};
|
||||
diceQueue: number;
|
||||
channelName: string | null;
|
||||
chatChannelId: string | null;
|
||||
chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||
chatError: string | null;
|
||||
lastDonations: Array<{
|
||||
id: string;
|
||||
at: number;
|
||||
payAmount: number;
|
||||
diceAdded: number;
|
||||
donatorNickname: string;
|
||||
donationText?: string;
|
||||
simulated?: boolean;
|
||||
}>;
|
||||
chatFeed: Array<{
|
||||
id: string;
|
||||
at: number;
|
||||
nickname: string;
|
||||
message: string;
|
||||
kind: 'chat' | 'donation';
|
||||
payAmount?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function useRoom(roomId: string | null) {
|
||||
const [room, setRoom] = useState<PublicRoomState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
|
||||
const clientId =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `c-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
void fetch(`/api/rooms/${roomId}`)
|
||||
.then(async (r) => {
|
||||
const j = await r.json();
|
||||
if (cancelled) return;
|
||||
if (!r.ok) {
|
||||
setError(j.error || '방을 불러오지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
if (j.room) setRoom(j.room as PublicRoomState);
|
||||
else setError(j.error || '방을 불러오지 못했습니다.');
|
||||
|
||||
es = new EventSource(
|
||||
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
|
||||
);
|
||||
es.addEventListener('room', (ev) => {
|
||||
try {
|
||||
setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState);
|
||||
setError(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
es.onerror = () => {
|
||||
// keep last state; browser will retry SSE
|
||||
};
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('방을 불러오지 못했습니다.');
|
||||
});
|
||||
|
||||
const leave = () => {
|
||||
try {
|
||||
const payload = JSON.stringify({ clientId });
|
||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||
navigator.sendBeacon(
|
||||
`/api/rooms/${roomId}/leave`,
|
||||
new Blob([payload], { type: 'application/json' }),
|
||||
);
|
||||
} else {
|
||||
void fetch(`/api/rooms/${roomId}/leave`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pagehide', leave);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('pagehide', leave);
|
||||
es?.close();
|
||||
leave();
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
const consumeDice = useCallback(
|
||||
async (count = 1) => {
|
||||
if (!roomId) return { ok: true as const };
|
||||
const res = await fetch(`/api/rooms/${roomId}/consume-dice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: (json.error as string) || '주사위를 소모할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
setRoom(json.room as PublicRoomState);
|
||||
return { ok: true as const };
|
||||
},
|
||||
[roomId],
|
||||
);
|
||||
|
||||
const simulateDonation = useCallback(
|
||||
async (payAmount: number) => {
|
||||
if (!roomId) return;
|
||||
const res = await fetch(`/api/rooms/${roomId}/simulate-donation`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ payAmount }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '시뮬레이션 실패');
|
||||
setRoom(json.room as PublicRoomState);
|
||||
},
|
||||
[roomId],
|
||||
);
|
||||
|
||||
const addDice = useCallback(
|
||||
async (count = 1) => {
|
||||
if (!roomId) return { ok: false as const, error: '방이 없습니다.' };
|
||||
const res = await fetch(`/api/rooms/${roomId}/add-dice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: (json.error as string) || '주사위를 추가할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
setRoom(json.room as PublicRoomState);
|
||||
return { ok: true as const };
|
||||
},
|
||||
[roomId],
|
||||
);
|
||||
|
||||
return { room, error, consumeDice, simulateDonation, addDice };
|
||||
}
|
||||
370
lib/rooms/store.ts
Normal file
370
lib/rooms/store.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type RoomSettings = {
|
||||
/** 치지직 채팅 연동 사용 */
|
||||
chzzkEnabled: boolean;
|
||||
/** 라이브/채널 URL 원문 */
|
||||
liveUrl: string;
|
||||
channelId: string | null;
|
||||
/** 이 원(치즈)당 주사위 1개 */
|
||||
cheesePerDice: number;
|
||||
/** true면 floor(금액/기준), false면 기준 이상 시 1개 */
|
||||
multiplyByCheese: boolean;
|
||||
/**
|
||||
* 배수 누적 시 한 번 후원으로 쌓을 수 있는 최대 주사위 수.
|
||||
* 0 이하면 제한 없음.
|
||||
*/
|
||||
maxMultiplier: number;
|
||||
};
|
||||
|
||||
export type DonationLogItem = {
|
||||
id: string;
|
||||
at: number;
|
||||
payAmount: number;
|
||||
diceAdded: number;
|
||||
donatorNickname: string;
|
||||
donationText?: string;
|
||||
simulated?: boolean;
|
||||
};
|
||||
|
||||
export type ChatFeedItem = {
|
||||
id: string;
|
||||
at: number;
|
||||
nickname: string;
|
||||
message: string;
|
||||
kind: 'chat' | 'donation';
|
||||
/** 후원일 때 치즈(원) */
|
||||
payAmount?: number;
|
||||
};
|
||||
|
||||
export type RoomRecord = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
settings: RoomSettings;
|
||||
diceQueue: number;
|
||||
channelName: string | null;
|
||||
chatChannelId: string | null;
|
||||
chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||
chatError: string | null;
|
||||
lastDonations: DonationLogItem[];
|
||||
chatFeed: ChatFeedItem[];
|
||||
/** 이미 반영한 치지직 후원 메시지 키 (재연결 시 중복 방지) */
|
||||
seenDonationKeys: Set<string>;
|
||||
};
|
||||
|
||||
export type PublicRoomState = {
|
||||
id: string;
|
||||
settings: RoomSettings;
|
||||
diceQueue: number;
|
||||
channelName: string | null;
|
||||
chatChannelId: string | null;
|
||||
chatStatus: RoomRecord['chatStatus'];
|
||||
chatError: string | null;
|
||||
lastDonations: DonationLogItem[];
|
||||
chatFeed: ChatFeedItem[];
|
||||
};
|
||||
|
||||
type RoomStore = {
|
||||
rooms: Map<string, RoomRecord>;
|
||||
/** 룸별 접속 클라이언트 ID 집합 */
|
||||
viewers: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
const globalForRooms = globalThis as typeof globalThis & {
|
||||
__drinkmarbleChatRooms?: RoomStore;
|
||||
};
|
||||
|
||||
function getStore(): RoomStore {
|
||||
if (!globalForRooms.__drinkmarbleChatRooms) {
|
||||
globalForRooms.__drinkmarbleChatRooms = {
|
||||
rooms: new Map(),
|
||||
viewers: new Map(),
|
||||
};
|
||||
}
|
||||
if (!globalForRooms.__drinkmarbleChatRooms.viewers) {
|
||||
globalForRooms.__drinkmarbleChatRooms.viewers = new Map();
|
||||
}
|
||||
return globalForRooms.__drinkmarbleChatRooms;
|
||||
}
|
||||
|
||||
export function createRoom(input: {
|
||||
chzzkEnabled: boolean;
|
||||
liveUrl?: string;
|
||||
channelId?: string | null;
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
}): RoomRecord {
|
||||
const room: RoomRecord = {
|
||||
id: randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
settings: {
|
||||
chzzkEnabled: Boolean(input.chzzkEnabled),
|
||||
liveUrl: input.liveUrl?.trim() || '',
|
||||
channelId: input.channelId ?? null,
|
||||
cheesePerDice: Math.max(1, Math.trunc(input.cheesePerDice ?? 1000)),
|
||||
multiplyByCheese: input.multiplyByCheese !== false,
|
||||
maxMultiplier: Math.max(0, Math.trunc(input.maxMultiplier ?? 0)),
|
||||
},
|
||||
diceQueue: 0,
|
||||
channelName: null,
|
||||
chatChannelId: null,
|
||||
chatStatus: 'idle',
|
||||
chatError: null,
|
||||
lastDonations: [],
|
||||
chatFeed: [],
|
||||
seenDonationKeys: new Set(),
|
||||
};
|
||||
getStore().rooms.set(room.id, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function getRoom(roomId: string) {
|
||||
return getStore().rooms.get(roomId) ?? null;
|
||||
}
|
||||
|
||||
/** 치지직 channelId로 기존 룸 찾기 (가장 최근 생성된 것) */
|
||||
export function findRoomByChannelId(channelId: string): RoomRecord | null {
|
||||
const id = channelId.trim().toLowerCase();
|
||||
if (!id) return null;
|
||||
let latest: RoomRecord | null = null;
|
||||
for (const room of getStore().rooms.values()) {
|
||||
if (room.settings.channelId?.toLowerCase() !== id) continue;
|
||||
if (!latest || room.createdAt > latest.createdAt) latest = room;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function setRoomMeta(
|
||||
roomId: string,
|
||||
patch: Partial<
|
||||
Pick<RoomRecord, 'channelName' | 'chatChannelId' | 'chatStatus' | 'chatError'>
|
||||
>,
|
||||
) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
Object.assign(room, patch);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function calcDiceFromDonation(payAmount: number, settings: RoomSettings) {
|
||||
const amount = Math.max(0, Math.trunc(payAmount));
|
||||
const unit = Math.max(1, settings.cheesePerDice);
|
||||
if (amount < unit) return 0;
|
||||
if (!settings.multiplyByCheese) return 1;
|
||||
let dice = Math.floor(amount / unit);
|
||||
const max = Math.max(0, Math.trunc(settings.maxMultiplier ?? 0));
|
||||
if (max > 0) dice = Math.min(dice, max);
|
||||
return dice;
|
||||
}
|
||||
|
||||
export function hasSeenDonationKey(roomId: string, key: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room || !key) return false;
|
||||
if (!room.seenDonationKeys) room.seenDonationKeys = new Set();
|
||||
return room.seenDonationKeys.has(key);
|
||||
}
|
||||
|
||||
export function markSeenDonationKey(roomId: string, key: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room || !key) return;
|
||||
if (!room.seenDonationKeys) room.seenDonationKeys = new Set();
|
||||
room.seenDonationKeys.add(key);
|
||||
// 삽입 순서 기준 오래된 키부터 정리
|
||||
while (room.seenDonationKeys.size > 800) {
|
||||
const oldest = room.seenDonationKeys.values().next().value;
|
||||
if (oldest == null) break;
|
||||
room.seenDonationKeys.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearChatFeed(roomId: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
room.chatFeed = [];
|
||||
return room;
|
||||
}
|
||||
|
||||
export function addDiceFromDonation(
|
||||
roomId: string,
|
||||
input: {
|
||||
payAmount: number;
|
||||
donatorNickname?: string;
|
||||
donationText?: string;
|
||||
simulated?: boolean;
|
||||
/** false면 피드/로그만 남기고 주사위 큐에는 넣지 않음 (최근 채팅 히스토리용) */
|
||||
applyDice?: boolean;
|
||||
},
|
||||
) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const applyDice = input.applyDice !== false;
|
||||
const diceAdded = applyDice
|
||||
? calcDiceFromDonation(input.payAmount, room.settings)
|
||||
: 0;
|
||||
if (diceAdded > 0) room.diceQueue += diceAdded;
|
||||
const nickname = input.donatorNickname?.trim() || '익명';
|
||||
const payAmount = Math.max(0, Math.trunc(input.payAmount));
|
||||
const log: DonationLogItem = {
|
||||
id: randomUUID(),
|
||||
at: Date.now(),
|
||||
payAmount,
|
||||
diceAdded,
|
||||
donatorNickname: nickname,
|
||||
donationText: input.donationText,
|
||||
simulated: input.simulated,
|
||||
};
|
||||
room.lastDonations = [log, ...room.lastDonations].slice(0, 20);
|
||||
appendChatFeed(room, {
|
||||
nickname,
|
||||
message: input.donationText?.trim() || '치즈 후원',
|
||||
kind: 'donation',
|
||||
payAmount,
|
||||
});
|
||||
return { room, diceAdded };
|
||||
}
|
||||
|
||||
export function addChatMessage(
|
||||
roomId: string,
|
||||
input: { nickname?: string; message: string },
|
||||
) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const message = input.message?.trim();
|
||||
if (!message) return room;
|
||||
appendChatFeed(room, {
|
||||
nickname: input.nickname?.trim() || '익명',
|
||||
message,
|
||||
kind: 'chat',
|
||||
});
|
||||
return room;
|
||||
}
|
||||
|
||||
function appendChatFeed(
|
||||
room: RoomRecord,
|
||||
item: Omit<ChatFeedItem, 'id' | 'at'>,
|
||||
) {
|
||||
const entry: ChatFeedItem = {
|
||||
id: randomUUID(),
|
||||
at: Date.now(),
|
||||
...item,
|
||||
};
|
||||
room.chatFeed = [...room.chatFeed, entry].slice(-60);
|
||||
}
|
||||
|
||||
export function consumeDice(roomId: string, count = 1) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const n = Math.max(1, Math.trunc(count));
|
||||
if (room.diceQueue < n) return null;
|
||||
room.diceQueue -= n;
|
||||
return room;
|
||||
}
|
||||
|
||||
/** 후원 없이 주사위 큐만 수동으로 추가 */
|
||||
export function addDice(roomId: string, count = 1) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const n = Math.max(1, Math.trunc(count));
|
||||
room.diceQueue += n;
|
||||
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 {
|
||||
return {
|
||||
id: room.id,
|
||||
settings: { ...room.settings },
|
||||
diceQueue: room.diceQueue,
|
||||
channelName: room.channelName,
|
||||
chatChannelId: room.chatChannelId,
|
||||
chatStatus: room.chatStatus,
|
||||
chatError: room.chatError,
|
||||
lastDonations: room.lastDonations,
|
||||
chatFeed: room.chatFeed,
|
||||
};
|
||||
}
|
||||
|
||||
type RoomListener = (room: PublicRoomState) => void;
|
||||
|
||||
const globalForListeners = globalThis as typeof globalThis & {
|
||||
__drinkmarbleChatRoomListeners?: Map<string, Set<RoomListener>>;
|
||||
};
|
||||
|
||||
function getListeners() {
|
||||
if (!globalForListeners.__drinkmarbleChatRoomListeners) {
|
||||
globalForListeners.__drinkmarbleChatRoomListeners = new Map();
|
||||
}
|
||||
return globalForListeners.__drinkmarbleChatRoomListeners;
|
||||
}
|
||||
|
||||
export function subscribeRoom(roomId: string, listener: RoomListener) {
|
||||
const map = getListeners();
|
||||
let set = map.get(roomId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(roomId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set!.delete(listener);
|
||||
if (set!.size === 0) map.delete(roomId);
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyRoom(roomId: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return;
|
||||
const state = toPublicRoom(room);
|
||||
const set = getListeners().get(roomId);
|
||||
if (!set) return;
|
||||
for (const listener of set) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRoomViewerCount(roomId: string) {
|
||||
return getStore().viewers.get(roomId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/** 클라이언트 입장. 같은 clientId면 한 번만 집계 */
|
||||
export function addRoomViewer(roomId: string, clientId: string) {
|
||||
const store = getStore();
|
||||
if (!store.rooms.has(roomId) || !clientId) return 0;
|
||||
let set = store.viewers.get(roomId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
store.viewers.set(roomId, set);
|
||||
}
|
||||
set.add(clientId);
|
||||
return set.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 퇴장. 이미 빠진 clientId면 no-op.
|
||||
* viewer가 0이 되면 emptied=true (채팅 끊어도 됨).
|
||||
*/
|
||||
export function removeRoomViewer(roomId: string, clientId: string) {
|
||||
const store = getStore();
|
||||
const set = store.viewers.get(roomId);
|
||||
if (!set || !clientId) {
|
||||
return { viewers: set?.size ?? 0, emptied: !set || set.size === 0 };
|
||||
}
|
||||
set.delete(clientId);
|
||||
if (set.size === 0) {
|
||||
store.viewers.delete(roomId);
|
||||
return { viewers: 0, emptied: true };
|
||||
}
|
||||
return { viewers: set.size, emptied: false };
|
||||
}
|
||||
9
lib/ui/debug.ts
Normal file
9
lib/ui/debug.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 디버그용 오버레이(룸 상태, 후원 시뮬, 진행 방향 등) 표시 여부.
|
||||
* 릴리즈(production)에서는 기본으로 숨기고, 주사위 Roll·결과만 남긴다.
|
||||
*
|
||||
* NEXT_PUBLIC_DRINKMARBLE_SHOW_DEBUG=true 로 릴리즈에서도 강제로 켤 수 있다.
|
||||
*/
|
||||
export const SHOW_DEBUG_UI =
|
||||
process.env.NEXT_PUBLIC_DRINKMARBLE_SHOW_DEBUG === 'true' ||
|
||||
process.env.NODE_ENV !== 'production';
|
||||
@@ -1,11 +1,28 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// localhost가 아닌 IP로 개발 서버에 붙을 때 /_next/* (HMR, Rapier, Three 청크 등)가
|
||||
// 기본으로 막힌다. 허용하지 않으면 Canvas/물리 스크립트가 로드되지 않아 주사위가 안 보인다.
|
||||
// 개발 서버에서도 OAuth code/state가 포함된 요청 URL을 콘솔에 남기지 않음.
|
||||
// 운영 서버는 별도 access log를 사용하지 않으며, 앱에서도 요청 본문을 기록하지 않는다.
|
||||
logging: {
|
||||
incomingRequests: false,
|
||||
},
|
||||
// 홈 디렉터리 package-lock.json 오인 방지 (외부 도메인/IP 접속 시에도 안정)
|
||||
turbopack: {
|
||||
root: projectRoot,
|
||||
},
|
||||
// Caddy(https://drink.kimyu.xyz) → localhost:13000 으로 붙을 때
|
||||
// /_next/* · HMR 이 cross-origin 으로 막히지 않게 허용
|
||||
allowedDevOrigins: [
|
||||
"172.30.1.31", // LAN
|
||||
"175.214.193.201", // 외부 접속 IP
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"172.30.1.31",
|
||||
"175.214.193.201",
|
||||
"drink.kimyu.xyz",
|
||||
"*.kimyu.xyz",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -16,13 +16,15 @@
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"three": "^0.185.1",
|
||||
"three.js": "^0.77.0"
|
||||
"three.js": "^0.77.0",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"tailwindcss": "^4",
|
||||
@@ -1744,6 +1746,15 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
|
||||
@@ -6940,6 +6951,26 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 13000",
|
||||
"dev": "next dev -H 0.0.0.0 -p 13000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 13000",
|
||||
"start": "next start -H 0.0.0.0 -p 13000",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,13 +17,15 @@
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"three": "^0.185.1",
|
||||
"three.js": "^0.77.0"
|
||||
"three.js": "^0.77.0",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Reference in New Issue
Block a user