치지직 채팅창 인식 추가 및 주사위 횟수 증가 추가.

This commit is contained in:
kimyu
2026-09-16 02:21:39 +09:00
parent 171a4e19f6
commit 7c402dfd8f
22 changed files with 2133 additions and 90 deletions

View 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 });
}

View 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',
},
});
}

View 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),
});
}

View 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) });
}

View 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,
});
}

View 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
View 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
View 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>
);
}

View File

@@ -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>
);
}
});

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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('/');
}

View File

@@ -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>
) : (