치지직 채팅창 인식 추가 및 주사위 횟수 증가 추가.
This commit is contained in:
36
app/api/rooms/[roomId]/consume-dice/route.ts
Normal file
36
app/api/rooms/[roomId]/consume-dice/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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 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 });
|
||||
}
|
||||
83
app/api/rooms/[roomId]/events/route.ts
Normal file
83
app/api/rooms/[roomId]/events/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
cancelScheduledDisconnectRoomChat,
|
||||
scheduleDisconnectRoomChat,
|
||||
} from '@/lib/chzzk/chat-session';
|
||||
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 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) {
|
||||
// 재입장 시 예약된 disconnect 취소
|
||||
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),
|
||||
});
|
||||
}
|
||||
15
app/api/rooms/[roomId]/route.ts
Normal file
15
app/api/rooms/[roomId]/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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 });
|
||||
}
|
||||
return NextResponse.json({ room: toPublicRoom(room) });
|
||||
}
|
||||
50
app/api/rooms/[roomId]/simulate-donation/route.ts
Normal file
50
app/api/rooms/[roomId]/simulate-donation/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
83
app/api/rooms/join/route.ts
Normal file
83
app/api/rooms/join/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { parseChzzkChannelId } from '@/lib/chzzk/channel';
|
||||
import {
|
||||
cancelScheduledDisconnectRoomChat,
|
||||
ensureRoomChat,
|
||||
} from '@/lib/chzzk/chat-session';
|
||||
import {
|
||||
createRoom,
|
||||
findRoomByChannelId,
|
||||
getRoom,
|
||||
toPublicRoom,
|
||||
} from '@/lib/rooms/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* 치지직 채널 ID(또는 라이브 URL)로 룸 입장.
|
||||
* 없으면 전달된 치즈 설정으로 새 룸을 만들고 채팅 연결을 시도한다.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
channelIdOrUrl?: string;
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
};
|
||||
|
||||
const raw = body.channelIdOrUrl || '';
|
||||
const channelId = parseChzzkChannelId(raw);
|
||||
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(),
|
||||
channelId,
|
||||
cheesePerDice: body.cheesePerDice,
|
||||
multiplyByCheese: body.multiplyByCheese,
|
||||
maxMultiplier: body.maxMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
// 이전 세션 leave가 늦게 와도 끊기지 않게 예약 해제 + 실제 WS 생존 여부 확인
|
||||
cancelScheduledDisconnectRoomChat(room.id);
|
||||
|
||||
if (room.settings.chzzkEnabled) {
|
||||
try {
|
||||
await ensureRoomChat(room.id);
|
||||
} catch (e) {
|
||||
if (created) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: '채팅 연결에 실패했습니다. 방송 중인지 확인해 주세요.',
|
||||
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 });
|
||||
}
|
||||
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-[130px] min-h-0 w-full flex-col overflow-hidden rounded-md bg-black/75 text-white shadow-md'
|
||||
: 'flex h-full min-h-0 w-full flex-col overflow-hidden rounded-lg bg-black/70 text-white shadow-lg'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'shrink-0 border-b border-white/10 px-1.5 py-0.5 text-[10px] font-semibold text-white/80'
|
||||
: 'shrink-0 border-b border-white/10 px-2 py-1 text-[11px] font-semibold text-white/80'
|
||||
}
|
||||
>
|
||||
채팅
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
className={
|
||||
compact
|
||||
? 'min-h-0 flex-1 space-y-0.5 overflow-y-auto px-1.5 py-1 text-[10px] leading-snug'
|
||||
: 'min-h-0 flex-1 space-y-1 overflow-y-auto px-2 py-1.5 text-[11px] leading-snug'
|
||||
}
|
||||
>
|
||||
{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,
|
||||
@@ -33,10 +42,10 @@ const WALL_HALF_H = WALL_HEIGHT / 2;
|
||||
|
||||
const STILL_SPEED_SQ = 0.05;
|
||||
const STILL_FRAMES = 18;
|
||||
const MIN_VIEW_PX = 16;
|
||||
|
||||
const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice);
|
||||
|
||||
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
||||
const DICE_FACES = [
|
||||
{ value: 1, localNormal: new Vector3(0, 1, 0) },
|
||||
{ value: 6, localNormal: new Vector3(0, -1, 0) },
|
||||
@@ -51,9 +60,7 @@ const _normal = new Vector3();
|
||||
const WORLD_UP = new Vector3(0, 1, 0);
|
||||
|
||||
export type DiceRollResult = {
|
||||
/** 이동에 쓰는 합 */
|
||||
sum: number;
|
||||
/** 각 주사위 윗면 */
|
||||
faces: number[];
|
||||
};
|
||||
|
||||
@@ -248,6 +255,7 @@ function RapierDiceExample({
|
||||
faceUrlSets,
|
||||
floorColor,
|
||||
floorImage,
|
||||
canvasKey,
|
||||
}: {
|
||||
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||
diceCount: DiceCount;
|
||||
@@ -256,15 +264,18 @@ function RapierDiceExample({
|
||||
faceUrlSets: string[][];
|
||||
floorColor: string;
|
||||
floorImage: string | null;
|
||||
canvasKey: number;
|
||||
}) {
|
||||
const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0];
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
key={canvasKey}
|
||||
orthographic
|
||||
camera={{ position: [0, 20, 0.01], zoom: 1 }}
|
||||
className="h-full w-full"
|
||||
gl={{ alpha: true }}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
resize={{ scroll: false, debounce: { scroll: 0, resize: 0 } }}
|
||||
>
|
||||
<FitSquareFloor />
|
||||
<ambientLight intensity={0.85} />
|
||||
@@ -282,7 +293,7 @@ function RapierDiceExample({
|
||||
|
||||
{spawnX.map((x, index) => (
|
||||
<RigidBody
|
||||
key={`die-${index}-${diceCount}`}
|
||||
key={`die-${index}-${diceCount}-${canvasKey}`}
|
||||
ref={diceRefs[index]}
|
||||
type="dynamic"
|
||||
position={[x, 4, index === 0 ? 0 : 0.15]}
|
||||
@@ -290,7 +301,14 @@ function RapierDiceExample({
|
||||
restitution={0.2}
|
||||
friction={0.6}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#f8fafc" />
|
||||
</mesh>
|
||||
}
|
||||
>
|
||||
<DiceMesh
|
||||
key={faceUrlSets[index]?.join('|') ?? index}
|
||||
faceUrls={faceUrlSets[index] ?? DEFAULT_DICE_FACE_TEXTURES}
|
||||
@@ -310,9 +328,12 @@ function RapierDiceExample({
|
||||
}
|
||||
|
||||
function rollDice(body: RapierRigidBody | null) {
|
||||
if (!body) return;
|
||||
if (!body) return false;
|
||||
|
||||
body.wakeUp();
|
||||
body.setTranslation({ x: body.translation().x, y: 4, z: body.translation().z }, true);
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.applyImpulse(
|
||||
{ x: (Math.random() - 0.5) * 8, y: 4 + Math.random() * 4, z: (Math.random() - 0.5) * 8 },
|
||||
true,
|
||||
@@ -321,6 +342,7 @@ function rollDice(body: RapierRigidBody | null) {
|
||||
{ x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 6, z: (Math.random() - 0.5) * 6 },
|
||||
true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatResult(result: DiceRollResult | null) {
|
||||
@@ -329,16 +351,25 @@ function formatResult(result: DiceRollResult | null) {
|
||||
return `결과: ${result.faces.join(' + ')} = ${result.sum}`;
|
||||
}
|
||||
|
||||
export default function Dice({
|
||||
export default memo(function Dice({
|
||||
onRolled,
|
||||
disabled = false,
|
||||
requireDiceQueue = false,
|
||||
diceQueue = 0,
|
||||
onConsumeDice,
|
||||
}: {
|
||||
onRolled?: (result: DiceRollResult) => void;
|
||||
disabled?: boolean;
|
||||
requireDiceQueue?: boolean;
|
||||
diceQueue?: number;
|
||||
onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const diceRefA = useRef<RapierRigidBody>(null);
|
||||
const diceRefB = useRef<RapierRigidBody>(null);
|
||||
const diceRefs = [diceRefA, diceRefB];
|
||||
const diceRefs = useMemo(() => [diceRefA, diceRefB], []);
|
||||
|
||||
const [view, setView] = useState({ w: 0, h: 0, gen: 0 });
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [result, setResult] = useState<DiceRollResult | null>(null);
|
||||
const [diceCount, setDiceCount] = useState<DiceCount>(1);
|
||||
@@ -347,6 +378,7 @@ export default function Dice({
|
||||
]);
|
||||
const [floorColor, setFloorColor] = useState(DEFAULT_DICE_FLOOR_COLOR);
|
||||
const [floorImage, setFloorImage] = useState<string | null>(null);
|
||||
const [queueHint, setQueueHint] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const assets = getAssets();
|
||||
@@ -360,6 +392,36 @@ export default function Dice({
|
||||
setFloorImage(assets.diceFloorImage);
|
||||
}, []);
|
||||
|
||||
// 칸 크기가 잡히기 전에 Canvas가 마운트되면 WebGL이 깨져 주사위가 안 보임
|
||||
useEffect(() => {
|
||||
const el = hostRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const apply = (width: number, height: number) => {
|
||||
const w = Math.floor(width);
|
||||
const h = Math.floor(height);
|
||||
setView((prev) => {
|
||||
const ready = w >= MIN_VIEW_PX && h >= MIN_VIEW_PX;
|
||||
const wasReady = prev.w >= MIN_VIEW_PX && prev.h >= MIN_VIEW_PX;
|
||||
if (!ready) return { w, h, gen: prev.gen };
|
||||
if (!wasReady) return { w, h, gen: prev.gen + 1 };
|
||||
if (Math.abs(w - prev.w) > 4 || Math.abs(h - prev.h) > 4) {
|
||||
return { w, h, gen: prev.gen + 1 };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
apply(el.clientWidth, el.clientHeight);
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const cr = entries[0]?.contentRect;
|
||||
if (!cr) return;
|
||||
apply(cr.width, cr.height);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const onSettled = useCallback(
|
||||
(next: DiceRollResult) => {
|
||||
setResult(next);
|
||||
@@ -369,42 +431,91 @@ export default function Dice({
|
||||
[onRolled],
|
||||
);
|
||||
|
||||
const busy = rolling || disabled;
|
||||
const canvasReady = view.w >= MIN_VIEW_PX && view.h >= MIN_VIEW_PX && view.gen > 0;
|
||||
const busy =
|
||||
rolling || disabled || !canvasReady || (requireDiceQueue && diceQueue <= 0);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
|
||||
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
|
||||
<RapierDiceExample
|
||||
diceRefs={diceRefs}
|
||||
diceCount={diceCount}
|
||||
rolling={rolling}
|
||||
onSettled={onSettled}
|
||||
faceUrlSets={faceUrlSets}
|
||||
floorColor={floorColor}
|
||||
floorImage={floorImage}
|
||||
/>
|
||||
<div className="relative h-full w-full min-h-0 min-w-0" style={{ containerType: 'size' }}>
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)] bg-sky-700/90"
|
||||
>
|
||||
{canvasReady ? (
|
||||
<RapierDiceExample
|
||||
diceRefs={diceRefs}
|
||||
diceCount={diceCount}
|
||||
rolling={rolling}
|
||||
onSettled={onSettled}
|
||||
faceUrlSets={faceUrlSets}
|
||||
floorColor={floorColor}
|
||||
floorImage={floorImage}
|
||||
canvasKey={view.gen}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-white/70">
|
||||
주사위 준비 중…
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-2 left-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setRolling(true);
|
||||
for (let i = 0; i < diceCount; i++) {
|
||||
rollDice(diceRefs[i].current);
|
||||
}
|
||||
void (async () => {
|
||||
setQueueHint(null);
|
||||
if (!canvasReady) {
|
||||
setQueueHint('주사위 준비 중…');
|
||||
return;
|
||||
}
|
||||
if (requireDiceQueue) {
|
||||
if (!onConsumeDice) {
|
||||
setQueueHint('주사위 큐 연동 오류');
|
||||
return;
|
||||
}
|
||||
const consumed = await onConsumeDice(1);
|
||||
if (!consumed.ok) {
|
||||
setQueueHint(consumed.error || '남은 주사위 없음');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 물리 바디가 아직 없으면 한 프레임 뒤 재시도
|
||||
const tryRoll = (attempt: number) => {
|
||||
const bodies = Array.from({ length: diceCount }, (_, i) => diceRefs[i].current);
|
||||
if (bodies.some((b) => !b)) {
|
||||
if (attempt < 8) {
|
||||
requestAnimationFrame(() => tryRoll(attempt + 1));
|
||||
return;
|
||||
}
|
||||
setQueueHint('주사위 준비 중…');
|
||||
setRolling(false);
|
||||
return;
|
||||
}
|
||||
setResult(null);
|
||||
setRolling(true);
|
||||
for (const body of bodies) rollDice(body);
|
||||
};
|
||||
tryRoll(0);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Roll
|
||||
Roll{requireDiceQueue ? ` (${diceQueue})` : ''}
|
||||
</button>
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white">
|
||||
{rolling
|
||||
? '굴리는 중…'
|
||||
: disabled
|
||||
? '이동 중…'
|
||||
: formatResult(result)}
|
||||
{queueHint
|
||||
? queueHint
|
||||
: !canvasReady
|
||||
? '주사위 준비 중…'
|
||||
: rolling
|
||||
? '굴리는 중…'
|
||||
: disabled
|
||||
? '이동 중…'
|
||||
: requireDiceQueue && diceQueue <= 0
|
||||
? '후원 대기 중…'
|
||||
: formatResult(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } 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';
|
||||
@@ -20,7 +21,19 @@ function cellLabel(cell: PlaneCell | undefined, index: number) {
|
||||
return index === 0 ? '시작' : cell.type;
|
||||
}
|
||||
|
||||
export default function DicePlane() {
|
||||
export default function DicePlane({
|
||||
requireDiceQueue = false,
|
||||
diceQueue = 0,
|
||||
onConsumeDice,
|
||||
chatFeed = [],
|
||||
showChatFeed = false,
|
||||
}: {
|
||||
requireDiceQueue?: boolean;
|
||||
diceQueue?: number;
|
||||
onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>;
|
||||
chatFeed?: ChatFeedItem[];
|
||||
showChatFeed?: boolean;
|
||||
}) {
|
||||
const initial = getOrCreateBoardConfig();
|
||||
const [planeSize, setPlaneSize] = useState({
|
||||
width: initial.width,
|
||||
@@ -35,6 +48,11 @@ export default function DicePlane() {
|
||||
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
||||
const [hopKey, setHopKey] = useState(0);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [chatOpen, setChatOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (showChatFeed) setChatOpen(true);
|
||||
}, [showChatFeed]);
|
||||
const [landedLabel, setLandedLabel] = useState<string | null>(null);
|
||||
const [landedKey, setLandedKey] = useState(0);
|
||||
const [statusText, setStatusText] = useState('시계방향');
|
||||
@@ -225,13 +243,72 @@ export default function DicePlane() {
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
zIndex: 6,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Dice onRolled={onDiceRolled} disabled={moving} />
|
||||
<Dice
|
||||
onRolled={onDiceRolled}
|
||||
disabled={moving}
|
||||
requireDiceQueue={requireDiceQueue}
|
||||
diceQueue={diceQueue}
|
||||
onConsumeDice={onConsumeDice}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div className="pointer-events-none absolute top-3 right-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
|
||||
<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>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
'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';
|
||||
|
||||
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 { room, error, consumeDice, simulateDonation } = useRoom(roomId);
|
||||
const chzzkOn = Boolean(room?.settings.chzzkEnabled);
|
||||
const manualMode = Boolean(roomId) && room != null && !chzzkOn;
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundSrc(getAssets().background);
|
||||
@@ -28,16 +36,91 @@ 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"
|
||||
>
|
||||
설정으로
|
||||
</Link>
|
||||
<div className="absolute top-3 left-3 z-40 flex flex-col items-start gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded bg-black/70 px-3 py-1.5 text-sm text-white hover:bg-black/85"
|
||||
>
|
||||
홈
|
||||
</Link>
|
||||
{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)}
|
||||
>
|
||||
후원 시뮬
|
||||
</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 ?? [] : []}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
182
app/page.tsx
182
app/page.tsx
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ASSET_ASPECT,
|
||||
@@ -316,6 +316,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,6 +324,13 @@ 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 [joinChannelInput, setJoinChannelInput] = useState('');
|
||||
const [roomBusy, setRoomBusy] = useState(false);
|
||||
const [roomError, setRoomError] = useState<string | null>(null);
|
||||
|
||||
const reload = () => {
|
||||
setAssets(getAssets());
|
||||
setBoard(getOrCreateBoardConfig());
|
||||
@@ -333,6 +341,67 @@ export default function Home() {
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
const roomCheeseOptions = () => ({
|
||||
cheesePerDice: roomCheesePerDice,
|
||||
multiplyByCheese: roomMultiplyByCheese,
|
||||
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
|
||||
});
|
||||
|
||||
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 +459,110 @@ 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>
|
||||
</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">
|
||||
치즈 규칙을 정한 뒤, 치지직 채널로 입장하세요. 룸이 없으면 자동으로 만들어집니다.
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
|
||||
135
lib/chzzk/channel.ts
Normal file
135
lib/chzzk/channel.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/** 치지직 라이브/채널 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(data.message || '채팅 accessToken을 받지 못했습니다.');
|
||||
}
|
||||
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`;
|
||||
}
|
||||
505
lib/chzzk/chat-session.ts
Normal file
505
lib/chzzk/chat-session.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import WebSocket from 'ws';
|
||||
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 `${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 `donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${msg}`;
|
||||
}
|
||||
return `chat:${time ?? 0}:${who}:${msg}`;
|
||||
}
|
||||
|
||||
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 (e) {
|
||||
const message = e instanceof Error ? e.message : '채팅 연결 실패';
|
||||
setRoomMeta(roomId, { chatStatus: 'error', chatError: message });
|
||||
notifyRoom(roomId);
|
||||
throw e instanceof Error ? e : 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);
|
||||
}
|
||||
140
lib/rooms/client.ts
Normal file
140
lib/rooms/client.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
'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)}`;
|
||||
|
||||
void fetch(`/api/rooms/${roomId}`)
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.room) setRoom(j.room as PublicRoomState);
|
||||
else setError(j.error || '방을 불러오지 못했습니다.');
|
||||
})
|
||||
.catch(() => setError('방을 불러오지 못했습니다.'));
|
||||
|
||||
const 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
|
||||
};
|
||||
|
||||
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 () => {
|
||||
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],
|
||||
);
|
||||
|
||||
return { room, error, consumeDice, simulateDonation };
|
||||
}
|
||||
353
lib/rooms/store.ts
Normal file
353
lib/rooms/store.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
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 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 };
|
||||
}
|
||||
31
logs/build.log
Normal file
31
logs/build.log
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
> drinkmarble@0.1.0 build
|
||||
> next build
|
||||
|
||||
▲ Next.js 16.3.3 (Turbopack)
|
||||
⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble).
|
||||
To use this directory, set `turbopack.root` in your Next.js config.
|
||||
|
||||
✓ Running next.config.ts took 25ms
|
||||
|
||||
Creating an optimized production build ...
|
||||
✓ Compiled successfully in 3.6s
|
||||
Running TypeScript ...
|
||||
Finished TypeScript in 2.6s ...
|
||||
Collecting page data using 7 workers ...
|
||||
Generating static pages using 7 workers (0/6) ...
|
||||
Generating static pages using 7 workers (1/6)
|
||||
Generating static pages using 7 workers (2/6)
|
||||
Generating static pages using 7 workers (4/6)
|
||||
✓ Generating static pages using 7 workers (6/6) in 677ms
|
||||
Finalizing page optimization ...
|
||||
|
||||
Route (app)
|
||||
┌ ○ /
|
||||
├ ○ /_not-found
|
||||
├ ○ /game
|
||||
└ ○ /main
|
||||
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
|
||||
26
logs/drinkmarble.log
Normal file
26
logs/drinkmarble.log
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
> drinkmarble@0.1.0 start
|
||||
> next start -p 13000
|
||||
|
||||
▲ Next.js 16.3.3
|
||||
- Local: http://localhost:13000
|
||||
- Network: http://172.30.1.31:13000
|
||||
✓ Ready in 149ms
|
||||
⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble).
|
||||
To use this directory, set `outputFileTracingRoot` in your Next.js config.
|
||||
|
||||
✓ Running next.config.ts took 30ms
|
||||
Error: Could not find a production build in the '.next' directory. Try building your app with 'next build' before starting the production server. https://nextjs.org/docs/messages/production-start-no-build-id
|
||||
at ignore-listed frames
|
||||
|
||||
> drinkmarble@0.1.0 start
|
||||
> next start -p 13000
|
||||
|
||||
▲ Next.js 16.3.3
|
||||
- Local: http://localhost:13000
|
||||
- Network: http://172.30.1.31:13000
|
||||
✓ Ready in 151ms
|
||||
⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble).
|
||||
To use this directory, set `outputFileTracingRoot` in your Next.js config.
|
||||
|
||||
✓ Running next.config.ts took 30ms
|
||||
1
logs/drinkmarble.pid
Normal file
1
logs/drinkmarble.pid
Normal file
@@ -0,0 +1 @@
|
||||
2448360
|
||||
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",
|
||||
|
||||
@@ -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