치지직 앱으로 연동하고, 연동 확인된 ID만 접속할 수 있도록 수정, 개발용으로는 채널ID를 수동으로 접속할 수 있도록 변경.

최초 추천 고정 배치 추가.
This commit is contained in:
kimyu
2026-09-18 04:09:31 +09:00
parent 6f72824598
commit 73520f9f06
26 changed files with 1272 additions and 162 deletions

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { AUTH_COOKIE } from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(AUTH_COOKIE, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
});
return res;
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { authModePublic } from '@/lib/auth/room-access';
import { readAuthSession } from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function GET() {
const session = await readAuthSession();
return NextResponse.json({
...authModePublic(),
session: session
? {
channelId: session.channelId,
channelName: session.channelName,
exp: session.exp,
}
: null,
});
}

View File

@@ -0,0 +1,112 @@
import { NextResponse } from 'next/server';
import { getPublicOrigin } from '@/lib/auth/public-origin';
import {
AUTH_COOKIE,
OAUTH_STATE_COOKIE,
authCookieOptions,
createAuthSession,
parseOAuthStateCookie,
serializeAuthSession,
} from '@/lib/auth/session';
import {
exchangeAuthorizationCode,
fetchAuthedUser,
revokeToken,
} from '@/lib/chzzk/oauth';
import {
cancelScheduledDisconnectRoomChat,
ensureRoomChat,
} from '@/lib/chzzk/chat-session';
import {
createRoom,
findRoomByChannelId,
getRoom,
} from '@/lib/rooms/store';
export const runtime = 'nodejs';
function homeRedirect(origin: string, error?: string) {
const url = new URL('/', origin);
if (error) url.searchParams.set('authError', error);
else url.searchParams.set('auth', 'ok');
return url;
}
/**
* 치지직 OAuth 콜백.
* 채널 ID 확인 → 우리 세션 쿠키만 저장 → 치지직 토큰 폐기 → 룸 입장
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const origin = getPublicOrigin(request);
const code = url.searchParams.get('code') || '';
const state = url.searchParams.get('state') || '';
const stateCookie = request.headers
.get('cookie')
?.match(/(?:^|;\s*)drinkmarble_oauth_state=([^;]+)/)?.[1];
const decodedState = stateCookie
? parseOAuthStateCookie(decodeURIComponent(stateCookie))
: null;
if (!code || !state || !decodedState || decodedState.state !== state) {
const res = NextResponse.redirect(
homeRedirect(origin, '로그인 검증에 실패했습니다. 다시 시도해 주세요.'),
);
res.cookies.delete(OAUTH_STATE_COOKIE);
return res;
}
let accessToken: string | null = null;
let refreshToken: string | null = null;
try {
const tokens = await exchangeAuthorizationCode(code, state);
accessToken = tokens.accessToken;
refreshToken = tokens.refreshToken;
const user = await fetchAuthedUser(accessToken);
const session = createAuthSession(user.channelId, user.channelName);
// 치지직 토큰은 저장하지 않고 즉시 폐기
await revokeToken(accessToken, 'access_token');
if (refreshToken) await revokeToken(refreshToken, 'refresh_token');
accessToken = null;
refreshToken = null;
let room = findRoomByChannelId(user.channelId);
if (!room) {
room = createRoom({
chzzkEnabled: true,
liveUrl: `https://chzzk.naver.com/live/${user.channelId}`,
channelId: user.channelId,
cheesePerDice: decodedState.cheesePerDice,
multiplyByCheese: decodedState.multiplyByCheese,
maxMultiplier: decodedState.maxMultiplier,
});
}
cancelScheduledDisconnectRoomChat(room.id);
try {
await ensureRoomChat(room.id);
} catch {
// 방송 중이 아니어도 세션·룸은 유지하고 게임으로 보냄
}
// room.id = 서버 룸 UUID (게임 URL용)
// user.channelId = 치지직 채널 (세션·room.settings.channelId에 보관)
const roomId = getRoom(room.id)?.id ?? room.id;
const res = NextResponse.redirect(new URL(`/game?roomId=${roomId}`, origin));
res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions());
res.cookies.delete(OAUTH_STATE_COOKIE);
return res;
} catch (e) {
if (accessToken) await revokeToken(accessToken, 'access_token');
if (refreshToken) await revokeToken(refreshToken, 'refresh_token');
const message =
e instanceof Error ? e.message : '치지직 로그인에 실패했습니다.';
const res = NextResponse.redirect(homeRedirect(origin, message));
res.cookies.delete(OAUTH_STATE_COOKIE);
return res;
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { isChzzkOAuthConfigured } from '@/lib/auth/mode';
import {
createOAuthState,
OAUTH_STATE_COOKIE,
authCookieOptions,
} from '@/lib/auth/session';
import { buildChzzkLoginUrl } from '@/lib/chzzk/oauth';
export const runtime = 'nodejs';
/**
* 치지직 로그인 시작.
* 쿼리로 치즈 설정을 넘기면 콜백에서 바로 룸 입장에 사용한다.
*/
export async function GET(request: Request) {
if (!isChzzkOAuthConfigured()) {
return NextResponse.json(
{
error:
'치지직 OAuth가 설정되지 않았습니다. CHZZK_CLIENT_ID / CHZZK_CLIENT_SECRET / CHZZK_REDIRECT_URI 를 확인해 주세요.',
},
{ status: 503 },
);
}
const url = new URL(request.url);
const cheesePerDice = Number(url.searchParams.get('cheesePerDice') || 1000);
const multiplyByCheese = url.searchParams.get('multiplyByCheese') !== '0';
const maxMultiplier = Number(url.searchParams.get('maxMultiplier') || 0);
const { state, sealed } = createOAuthState({
cheesePerDice: Number.isFinite(cheesePerDice) ? cheesePerDice : 1000,
multiplyByCheese,
maxMultiplier: Number.isFinite(maxMultiplier) ? maxMultiplier : 0,
});
const res = NextResponse.redirect(buildChzzkLoginUrl(state));
res.cookies.set(OAUTH_STATE_COOKIE, sealed, {
...authCookieOptions(600),
maxAge: 600,
});
return res;
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
import { addDice, getRoom, notifyRoom, toPublicRoom } from '@/lib/rooms/store';
export const runtime = 'nodejs';
type Params = { params: Promise<{ roomId: string }> };
export async function POST(request: Request, { params }: Params) {
const { roomId } = await params;
const room = getRoom(roomId);
if (!room) {
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
}
const access = await assertChannelRoomAccess(room, request);
if (!access.ok) return access.response;
const body = (await request.json().catch(() => ({}))) as { count?: number };
const count = Number(body.count ?? 1);
if (!Number.isFinite(count) || count < 1) {
return NextResponse.json({ error: 'count는 1 이상이어야 합니다.' }, { status: 400 });
}
const updated = addDice(roomId, count);
if (!updated) {
return NextResponse.json({ error: '처리 실패' }, { status: 500 });
}
notifyRoom(roomId);
return NextResponse.json({ room: toPublicRoom(updated) });
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
import {
consumeDice,
getRoom,
@@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) {
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
}
const access = await assertChannelRoomAccess(room, request);
if (!access.ok) return access.response;
const body = (await request.json().catch(() => ({}))) as { count?: number };
const count = Math.max(1, Math.trunc(body.count ?? 1));

View File

@@ -2,6 +2,7 @@ import {
cancelScheduledDisconnectRoomChat,
scheduleDisconnectRoomChat,
} from '@/lib/chzzk/chat-session';
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
import {
addRoomViewer,
getRoom,
@@ -26,6 +27,9 @@ export async function GET(request: Request, { params }: Params) {
});
}
const access = await assertChannelRoomAccess(room, request);
if (!access.ok) return access.response;
const clientId =
new URL(request.url).searchParams.get('clientId')?.trim() ||
crypto.randomUUID();
@@ -40,14 +44,12 @@ export async function GET(request: Request, { params }: Params) {
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;

View File

@@ -1,15 +1,18 @@
import { NextResponse } from 'next/server';
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
import { getRoom, toPublicRoom } from '@/lib/rooms/store';
export const runtime = 'nodejs';
type Params = { params: Promise<{ roomId: string }> };
export async function GET(_request: Request, { params }: Params) {
export async function GET(request: Request, { params }: Params) {
const { roomId } = await params;
const room = getRoom(roomId);
if (!room) {
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
}
const access = await assertChannelRoomAccess(room, request);
if (!access.ok) return access.response;
return NextResponse.json({ room: toPublicRoom(room) });
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
import {
addDiceFromDonation,
getRoom,
@@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) {
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
}
const access = await assertChannelRoomAccess(room, request);
if (!access.ok) return access.response;
const body = (await request.json().catch(() => ({}))) as {
payAmount?: number;
donatorNickname?: string;

View File

@@ -1,5 +1,11 @@
import { NextResponse } from 'next/server';
import { parseChzzkChannelId } from '@/lib/chzzk/channel';
import {
allowChannelIdJoin,
isChzzkOAuthConfigured,
requireChzzkLoginForChannelRooms,
} from '@/lib/auth/mode';
import { getRequestAuthSession } from '@/lib/auth/room-access';
import {
cancelScheduledDisconnectRoomChat,
ensureRoomChat,
@@ -15,7 +21,8 @@ export const runtime = 'nodejs';
/**
* 치지직 채널 ID(또는 라이브 URL)로 룸 입장.
* 없으면 전달된 치즈 설정으로 새 룸을 만들고 채팅 연결을 시도한다.
* - 개발: 채널 ID 직접 입장 가능 (+ 로그인 세션 있으면 본인 채널도 가능)
* - 릴리즈: 로그인 세션의 채널 ID와 일치할 때만 입장
*/
export async function POST(request: Request) {
const body = (await request.json().catch(() => ({}))) as {
@@ -25,8 +32,39 @@ export async function POST(request: Request) {
maxMultiplier?: number;
};
const session = await getRequestAuthSession(request);
const raw = body.channelIdOrUrl || '';
const channelId = parseChzzkChannelId(raw);
let channelId = parseChzzkChannelId(raw);
if (requireChzzkLoginForChannelRooms()) {
if (!session) {
return NextResponse.json(
{
error:
'릴리즈 환경에서는 치지직 로그인 후 본인 채널로만 입장할 수 있습니다.',
code: 'CHZZK_AUTH_REQUIRED',
allowChannelIdJoin: allowChannelIdJoin(),
oauthConfigured: isChzzkOAuthConfigured(),
},
{ status: 401 },
);
}
// 입력된 채널이 있으면 세션과 일치해야 함. 없으면 세션 채널 사용.
if (channelId && channelId !== session.channelId) {
return NextResponse.json(
{
error: '로그인한 채널과 다른 채널 ID로는 입장할 수 없습니다.',
code: 'CHANNEL_MISMATCH',
},
{ status: 403 },
);
}
channelId = session.channelId;
} else if (!channelId) {
// 개발: 세션만 있으면 세션 채널로 입장 가능
if (session?.channelId) channelId = session.channelId;
}
if (!channelId) {
return NextResponse.json(
{
@@ -44,7 +82,7 @@ export async function POST(request: Request) {
created = true;
room = createRoom({
chzzkEnabled: true,
liveUrl: raw.trim(),
liveUrl: raw.trim() || `https://chzzk.naver.com/live/${channelId}`,
channelId,
cheesePerDice: body.cheesePerDice,
multiplyByCheese: body.multiplyByCheese,
@@ -52,7 +90,6 @@ export async function POST(request: Request) {
});
}
// 이전 세션 leave가 늦게 와도 끊기지 않게 예약 해제 + 실제 WS 생존 여부 확인
cancelScheduledDisconnectRoomChat(room.id);
if (room.settings.chzzkEnabled) {

View File

@@ -5,15 +5,17 @@ export const MIN_PLANE_SIZE = 4;
export const MAX_PLANE_SIZE = 16;
export const CELL_TYPE_PRESETS = [
'한잔',
'안주',
'모두한잔',
'두잔',
'벌칙1',
'벌칙2',
'한잔마셔',
'~~체 3분',
'안주 한입',
'노래 1절',
'다같이한잔',
'영어금지 3분',
'두잔마셔',
'애교/매도 100자',
'무인도',
'역방향',
'비행기',
'세계여행',
] as const;
export type CellEventOption = {
@@ -25,7 +27,7 @@ export const CELL_EVENT_OPTIONS: CellEventOption[] = [
{ id: null, label: '없음' },
{ id: 'island', label: '무인도' },
{ id: 'reverse', label: '역방향' },
{ id: 'airplane', label: '비행기 → 시작' },
{ id: 'airplane', label: '세계여행 (원하는 칸)' },
];
/** 저장용 칸 설정 (좌표는 지오메트리에서 합친다) */
@@ -46,7 +48,46 @@ export type BoardConfig = {
};
const BOARD_STORAGE_KEY = 'drinkmarble.board';
const BOARD_VERSION = 2;
/** v5: 추천 배치 아이콘(이모지) 제거 */
const BOARD_VERSION = 5;
/** 기본 추천 배치 (시계방향, 시작 포함 28칸 = 9×7) */
const DEFAULT_BOARD_LAYOUT: ReadonlyArray<{
type: string;
eventId?: string | null;
}> = [
{ type: '시작', eventId: null },
{ type: '한잔마셔' },
{ type: '~~체 3분' },
{ type: '안주 한입' },
{ type: '노래 1절' },
{ type: '다같이한잔' },
{ type: '영어금지 3분' },
{ type: '애교/매도 100자' },
{ type: '무인도', eventId: 'island' },
{ type: '안주 한입' },
{ type: '두잔마셔' },
{ type: '~~체 3분' },
{ type: '한잔마셔' },
{ type: '애교/매도 100자' },
{ type: '역방향', eventId: 'reverse' },
{ type: '영어금지 3분' },
{ type: '안주 한입' },
{ type: '다같이한잔' },
{ type: '노래 1절' },
{ type: '한잔마셔' },
{ type: '~~체 3분' },
{ type: '두잔마셔' },
{ type: '세계여행', eventId: 'airplane' },
{ type: '애교/매도 100자' },
{ type: '안주 한입' },
{ type: '영어금지 3분' },
{ type: '다같이한잔' },
{ type: '한잔마셔' },
];
const SPECIAL_TYPE_NAMES = new Set(['무인도', '역방향', '비행기', '세계여행']);
export function clampPlaneSize(n: number) {
return Math.min(MAX_PLANE_SIZE, Math.max(MIN_PLANE_SIZE, Math.round(n)));
@@ -57,9 +98,7 @@ export function perimeterLength(width: number, height: number) {
}
function randomPresetType() {
const pool = CELL_TYPE_PRESETS.filter(
(t) => t !== '무인도' && t !== '역방향' && t !== '비행기',
);
const pool = CELL_TYPE_PRESETS.filter((t) => !SPECIAL_TYPE_NAMES.has(t));
return pool[Math.floor(Math.random() * pool.length)];
}
@@ -80,10 +119,37 @@ function defaultSpecials(width: number, height: number): Record<number, BoardCel
return {
[topRight]: { type: '무인도', eventId: 'island' },
[bottomRight]: { type: '역방향', eventId: 'reverse' },
[bottomLeft]: { type: '비행기', eventId: 'airplane' },
[bottomLeft]: { type: '세계여행', eventId: 'airplane' },
};
}
function cellFromLayout(
entry: { type: string; eventId?: string | null },
index: number,
): BoardCellConfig {
return {
type: index === 0 ? '시작' : entry.type,
eventId: index === 0 ? null : (entry.eventId ?? null),
imageSrc: null,
contentVisible: true,
};
}
/** 추천 기본 배치 (9×7) */
export function createDefaultBoardConfig(): BoardConfig {
const w = DEFAULT_PLANE_SIZE.width;
const h = DEFAULT_PLANE_SIZE.height;
const len = perimeterLength(w, h);
if (DEFAULT_BOARD_LAYOUT.length !== len) {
throw new Error(
`DEFAULT_BOARD_LAYOUT length ${DEFAULT_BOARD_LAYOUT.length} != perimeter ${len}`,
);
}
const cells = DEFAULT_BOARD_LAYOUT.map((entry, index) => cellFromLayout(entry, index));
cells[0] = { type: '시작', eventId: null, imageSrc: null, contentVisible: true };
return { version: BOARD_VERSION, width: w, height: h, cells };
}
/** 시계방향 둘레 좌표만 만든다. */
export function buildPerimeterGeometry(
width: number,
@@ -116,6 +182,10 @@ export function createRandomBoardConfig(
): BoardConfig {
const w = clampPlaneSize(width);
const h = clampPlaneSize(height);
if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) {
return createDefaultBoardConfig();
}
const geo = buildPerimeterGeometry(w, h);
const specials = defaultSpecials(w, h);
const cells: BoardCellConfig[] = geo.map((_, index) => {
@@ -180,7 +250,10 @@ export function readStoredBoard(): BoardConfig | null {
try {
const raw = window.localStorage.getItem(BOARD_STORAGE_KEY);
if (!raw) return null;
return normalizeBoardConfig(JSON.parse(raw) as BoardConfig);
const parsed = JSON.parse(raw) as BoardConfig;
// 구버전(랜덤 기본)은 추천 배치로 교체
if ((parsed.version ?? 0) < BOARD_VERSION) return null;
return normalizeBoardConfig(parsed);
} catch {
return null;
}
@@ -195,7 +268,7 @@ export function writeStoredBoard(config: BoardConfig) {
export function getOrCreateBoardConfig(): BoardConfig {
const existing = readStoredBoard();
if (existing) return existing;
const created = createRandomBoardConfig();
const created = createDefaultBoardConfig();
writeStoredBoard(created);
return created;
}
@@ -209,15 +282,24 @@ export function clearBoardConfig() {
window.localStorage.removeItem(BOARD_STORAGE_KEY);
}
/** 추천 기본 배치로 되돌린다 (크기 포함 9×7 고정). */
export function resetToDefaultBoard(): BoardConfig {
const created = createDefaultBoardConfig();
writeStoredBoard(created);
return created;
}
export function randomizeAndSaveBoard(
width?: number,
height?: number,
current = getOrCreateBoardConfig(),
): BoardConfig {
const created = createRandomBoardConfig(
width ?? current.width,
height ?? current.height,
);
const w = width ?? current.width;
const h = height ?? current.height;
if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) {
return resetToDefaultBoard();
}
const created = createRandomBoardConfig(w, h);
writeStoredBoard(created);
return created;
}

View File

@@ -25,6 +25,8 @@ export type TurnResult = {
message: string;
/** 비행기 등: 즉시 이동할 칸 */
teleportTo?: number;
/** 비행기: 도착 칸을 플레이어가 직접 고름 */
chooseDestination?: boolean;
};
export interface CellEvent {
@@ -54,20 +56,14 @@ export class ReverseEvent implements CellEvent {
}
}
/** 비행기: 지정한 칸으로 즉시 이동 */
/** 비행기: 원하는 칸을 고른 뒤 그곳으로 이동 */
export class AirplaneEvent implements CellEvent {
readonly id = 'airplane';
constructor(private readonly targetIndex: number) {}
apply(ctx: TurnContext): TurnResult {
const target =
((this.targetIndex % ctx.board.length) + ctx.board.length) % ctx.board.length;
ctx.chessman.teleport(target);
const dest = ctx.board[target];
apply(_ctx: TurnContext): TurnResult {
return {
message: `비행기! → ${dest?.type ?? target}`,
teleportTo: target,
message: '원하는 곳을 클릭해주세요',
chooseDestination: true,
};
}
}
@@ -75,8 +71,7 @@ export class AirplaneEvent implements CellEvent {
export const CELL_EVENTS: Record<string, CellEvent> = {
island: new IslandEvent(),
reverse: new ReverseEvent(),
// 기본 도착지는 시작(0). 판 생성 시 덮어쓸 수 있다.
airplane: new AirplaneEvent(0),
airplane: new AirplaneEvent(),
};
export function getCellEvent(eventId: string | null | undefined): CellEvent | null {

View File

@@ -1,12 +1,13 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import Dice from './dice';
import ChatFeedPanel, { type ChatFeedItem } from './chat-feed';
import { Chessman } from './chessman';
import ChessmanPiece from './chessman-piece';
import { applyLandEvent, type PlaneCell } from './cell-event';
import { getAssets } from './asset-storage';
import { SHOW_DEBUG_UI } from '@/lib/ui/debug';
import {
buildPlaneCells,
cellColorFor,
@@ -29,12 +30,15 @@ export default function DicePlane({
onConsumeDice,
chatFeed = [],
showChatFeed = false,
settingsButton = null,
}: {
requireDiceQueue?: boolean;
diceQueue?: number;
onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>;
chatFeed?: ChatFeedItem[];
showChatFeed?: boolean;
/** 판 안쪽 왼쪽 아래(둘레 칸 밖)에 배치할 설정 버튼 */
settingsButton?: ReactNode;
}) {
const [boardReady, setBoardReady] = useState(false);
const [planeSize, setPlaneSize] = useState<{ width: number; height: number }>({
@@ -57,7 +61,9 @@ export default function DicePlane({
const [landedKey, setLandedKey] = useState(0);
const [statusText, setStatusText] = useState('시계방향');
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
const [pickingDestination, setPickingDestination] = useState(false);
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRollRef = useRef(0);
// SSR/초기 렌더에서 랜덤 판을 쓰지 않고, 브라우저에 저장된 설정을 로드
useEffect(() => {
@@ -72,6 +78,7 @@ export default function DicePlane({
setLandedLabel(null);
setStatusText('시계방향');
setIslandTurnsLeft(0);
setPickingDestination(false);
setDicePlaneArray(cells);
setBoardReady(true);
}, []);
@@ -104,6 +111,15 @@ export default function DicePlane({
setStatusText(man.facing === 1 ? '시계방향' : '반시계방향');
setIslandTurnsLeft(man.islandTurnsLeft);
if (result?.chooseDestination) {
pendingRollRef.current = roll;
setPickingDestination(true);
showBanner(result.message);
return;
}
setPickingDestination(false);
if (result?.teleportTo != null) {
setChessmanIndex(result.teleportTo);
setHopKey((k) => k + 1);
@@ -116,6 +132,19 @@ export default function DicePlane({
[showBanner],
);
const onCellPick = useCallback(
(index: number) => {
if (!pickingDestination || moving) return;
const man = chessmanRef.current;
man.teleport(index);
setChessmanIndex(index);
setHopKey((k) => k + 1);
setPickingDestination(false);
applyLanding(index, pendingRollRef.current);
},
[pickingDestination, moving, applyLanding],
);
const animateMove = useCallback(
(steps: number, roll: number) => {
const amount = Math.max(0, Math.trunc(steps));
@@ -153,6 +182,7 @@ export default function DicePlane({
if (roll === 0) return;
setLandedLabel(null);
setPickingDestination(false);
const man = chessmanRef.current;
if (man.isOnIsland) {
@@ -217,6 +247,16 @@ export default function DicePlane({
return (
<div
key={plane.id}
role={pickingDestination ? 'button' : undefined}
tabIndex={pickingDestination ? 0 : undefined}
onClick={() => onCellPick(index)}
onKeyDown={(e) => {
if (!pickingDestination) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCellPick(index);
}
}}
style={{
gridColumn: plane.x + 1,
gridRow: plane.y + 1,
@@ -238,12 +278,16 @@ export default function DicePlane({
padding: 4,
boxShadow: isLanded
? '0 0 0 3px #fde68a, 0 0 18px rgba(253, 224, 71, 0.75)'
: isStart
? 'inset 0 0 0 2px #a7f3d0'
: undefined,
: pickingDestination
? '0 0 0 2px rgba(125, 211, 252, 0.85)'
: isStart
? 'inset 0 0 0 2px #a7f3d0'
: undefined,
transform: isLanded ? 'scale(1.04)' : undefined,
transition: 'box-shadow 180ms ease, transform 180ms ease',
zIndex: isLanded ? 4 : undefined,
zIndex: pickingDestination ? 8 : isLanded ? 4 : undefined,
cursor: pickingDestination ? 'pointer' : undefined,
pointerEvents: pickingDestination ? 'auto' : undefined,
textShadow:
showContent && plane.imageSrc
? '0 1px 3px rgba(0,0,0,0.85)'
@@ -276,13 +320,34 @@ export default function DicePlane({
>
<Dice
onRolled={onDiceRolled}
disabled={moving}
disabled={moving || pickingDestination}
requireDiceQueue={requireDiceQueue}
diceQueue={diceQueue}
onConsumeDice={onConsumeDice}
/>
</div>
{settingsButton && (
<div
style={{
// 둘레 칸 밖: 안쪽 영역 왼쪽 아래
gridColumn: 2,
gridRow: Math.max(3, planeSize.height - 1),
zIndex: 8,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'flex-start',
padding: 4,
boxSizing: 'border-box',
minWidth: 0,
minHeight: 0,
pointerEvents: 'none',
}}
>
<div style={{ pointerEvents: 'auto' }}>{settingsButton}</div>
</div>
)}
{showChatFeed && (
<div
style={{
@@ -336,10 +401,12 @@ export default function DicePlane({
)}
</div>
<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>
{SHOW_DEBUG_UI && (
<div className="pointer-events-none absolute bottom-3 left-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
{statusText}
{islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
</div>
)}
{landedLabel && (
<div

View File

@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
import DicePlane from './diceplane';
import { DEFAULT_ASSETS, getAssets } from './asset-storage';
import { useRoom } from '@/lib/rooms/client';
import { SHOW_DEBUG_UI } from '@/lib/ui/debug';
function GamePageInner() {
const searchParams = useSearchParams();
@@ -13,7 +14,10 @@ function GamePageInner() {
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 [settingsOpen, setSettingsOpen] = useState(false);
const [addDiceCount, setAddDiceCount] = useState(1);
const [addDiceHint, setAddDiceHint] = useState<string | null>(null);
const { room, error, consumeDice, simulateDonation, addDice } = useRoom(roomId);
const chzzkOn = Boolean(room?.settings.chzzkEnabled);
const manualMode = Boolean(roomId) && room != null && !chzzkOn;
@@ -22,6 +26,21 @@ function GamePageInner() {
setReady(true);
}, []);
useEffect(() => {
if (!settingsOpen) setAddDiceHint(null);
}, [settingsOpen]);
const onAddDice = async () => {
if (!roomId || !chzzkOn) return;
const count = Math.max(1, Math.trunc(addDiceCount) || 1);
const result = await addDice(count);
if (!result.ok) {
setAddDiceHint(result.error || '추가 실패');
return;
}
setAddDiceHint(`주사위 +${count} 추가했습니다.`);
};
return (
<div className="relative h-screen w-screen overflow-hidden">
{ready && (
@@ -36,67 +55,63 @@ function GamePageInner() {
)}
<div className="absolute inset-0 z-0 bg-black/25" aria-hidden />
<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>
{SHOW_DEBUG_UI && (
<div className="absolute top-3 left-3 z-40 flex flex-col items-start gap-2">
{roomId && chzzkOn && (
<div className="max-w-[min(100vw-2rem,20rem)] rounded-xl bg-black/75 px-3 py-2 text-xs text-white">
<p className="font-semibold">주사위 큐: {room?.diceQueue ?? '…'}</p>
{room?.settings.channelId && (
<p className="mt-1 break-all font-mono text-[10px] text-white/50">
채널 ID: {room.settings.channelId}
</p>
)}
{room && (
<p className="mt-1 text-white/70">
{room.channelName || room.settings.channelId || '—'} · {room.chatStatus}
{` · ${room.settings.cheesePerDice}원/${
room.settings.multiplyByCheese
? `배수${room.settings.maxMultiplier > 0 ? `≤${room.settings.maxMultiplier}` : ''}`
: '1회'
}`}
</p>
)}
{error && <p className="mt-1 text-red-300">{error}</p>}
{room?.chatError && <p className="mt-1 text-red-300">{room.chatError}</p>}
{room?.lastDonations?.[0] && (
<p className="mt-1 text-amber-200">
최근: {room.lastDonations[0].donatorNickname}{' '}
{room.lastDonations[0].payAmount}원 → +{room.lastDonations[0].diceAdded}
</p>
)}
<div className="mt-2 flex items-center gap-1">
<input
type="number"
min={1}
value={simAmount}
onChange={(e) => setSimAmount(Number(e.target.value) || 1)}
className="w-24 rounded border border-white/20 bg-black/50 px-2 py-1 text-white"
/>
<button
type="button"
className="rounded bg-white/15 px-2 py-1 hover:bg-white/25"
onClick={() => void simulateDonation(simAmount).catch(() => undefined)}
>
후원 시뮬
</button>
</div>
</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>
)}
{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
@@ -105,8 +120,88 @@ function GamePageInner() {
onConsumeDice={consumeDice}
showChatFeed={chzzkOn}
chatFeed={chzzkOn ? room?.chatFeed ?? [] : []}
settingsButton={
<button
type="button"
aria-label="설정"
aria-expanded={settingsOpen}
onClick={() => setSettingsOpen((v) => !v)}
className="rounded-md bg-black/70 px-3 py-1.5 text-sm font-semibold text-white shadow-md hover:bg-black/85"
>
설정
</button>
}
/>
</div>
{settingsOpen && (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
role="dialog"
aria-modal="true"
aria-label="게임 설정"
onClick={() => setSettingsOpen(false)}
>
<div
className="w-full max-w-sm rounded-2xl border border-white/15 bg-slate-950/95 px-5 py-4 text-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold">설정</h2>
<button
type="button"
className="rounded px-2 py-1 text-sm text-white/70 hover:bg-white/10 hover:text-white"
onClick={() => setSettingsOpen(false)}
>
닫기
</button>
</div>
<div className="mt-4 flex flex-col gap-3">
<Link
href="/"
className="rounded-lg bg-white/10 px-4 py-2.5 text-center text-sm font-medium hover:bg-white/15"
>
홈으로
</Link>
{roomId && chzzkOn ? (
<div className="rounded-lg border border-white/10 bg-black/30 p-3">
<p className="text-xs text-white/60">주사위 큐 수동 추가</p>
<p className="mt-1 text-sm text-white/80">
현재 큐: <span className="font-semibold">{room?.diceQueue ?? 0}</span>
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={1}
value={addDiceCount}
onChange={(e) => setAddDiceCount(Number(e.target.value) || 1)}
className="w-20 rounded border border-white/20 bg-black/50 px-2 py-1.5 text-sm text-white"
/>
<button
type="button"
className="rounded-lg bg-sky-700 px-3 py-1.5 text-sm font-medium hover:bg-sky-600"
onClick={() => void onAddDice()}
>
카운트 추가
</button>
</div>
{addDiceHint && (
<p className="mt-2 text-xs text-amber-200">{addDiceHint}</p>
)}
</div>
) : (
<p className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-white/65">
{manualMode
? '수동 모드에서는 주사위 큐 없이 바로 굴릴 수 있습니다.'
: '치지직 연동 방에 입장하면 여기서 주사위 카운트를 추가할 수 있습니다.'}
</p>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -38,7 +38,7 @@ import {
clearBoardConfig,
getOrCreateBoardConfig,
perimeterLength,
randomizeAndSaveBoard,
resetToDefaultBoard,
resizeBoardConfig,
setAllCellsContentVisible,
setBoardConfig,
@@ -169,9 +169,9 @@ function BoardEditor({
<button
type="button"
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
onClick={() => onChange(randomizeAndSaveBoard(board.width, board.height, board))}
onClick={() => onChange(resetToDefaultBoard())}
>
랜덤 다시 배치
추천 배치로 되돌리기
</button>
</div>
</div>
@@ -379,15 +379,57 @@ export default function Home() {
const [joinChannelInput, setJoinChannelInput] = useState('');
const [roomBusy, setRoomBusy] = useState(false);
const [roomError, setRoomError] = useState<string | null>(null);
const [authMode, setAuthMode] = useState<{
allowChannelIdJoin: boolean;
requireChzzkLogin: boolean;
oauthConfigured: boolean;
} | null>(null);
const [authSession, setAuthSession] = useState<{
channelId: string;
channelName: string | null;
} | null>(null);
const reload = () => {
setAssets(getAssets());
setBoard(getOrCreateBoardConfig());
};
const refreshAuth = async () => {
try {
const res = await fetch('/api/auth/session', { cache: 'no-store' });
const json = await res.json();
setAuthMode({
allowChannelIdJoin: Boolean(json.allowChannelIdJoin),
requireChzzkLogin: Boolean(json.requireChzzkLogin),
oauthConfigured: Boolean(json.oauthConfigured),
});
setAuthSession(
json.session
? {
channelId: String(json.session.channelId),
channelName: json.session.channelName ?? null,
}
: null,
);
} catch {
setAuthMode({
allowChannelIdJoin: true,
requireChzzkLogin: false,
oauthConfigured: false,
});
setAuthSession(null);
}
};
useEffect(() => {
reload();
setReady(true);
void refreshAuth();
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const authError = params.get('authError');
if (authError) setRoomError(authError);
}
}, []);
const roomCheeseOptions = () => ({
@@ -396,6 +438,45 @@ export default function Home() {
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
});
const startChzzkLogin = () => {
const opts = roomCheeseOptions();
const q = new URLSearchParams({
cheesePerDice: String(opts.cheesePerDice),
multiplyByCheese: opts.multiplyByCheese ? '1' : '0',
maxMultiplier: String(opts.maxMultiplier),
});
window.location.href = `/api/chzzk/login?${q.toString()}`;
};
const joinWithSession = async () => {
try {
setRoomBusy(true);
setRoomError(null);
const res = await fetch('/api/rooms/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...roomCheeseOptions(),
}),
});
const json = await res.json();
if (!res.ok) {
setRoomError(json.error || '룸 입장에 실패했습니다.');
return;
}
router.push(`/game?roomId=${json.room.id}`);
} catch (e) {
setRoomError(e instanceof Error ? e.message : '룸 입장에 실패했습니다.');
} finally {
setRoomBusy(false);
}
};
const logoutAuth = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
setAuthSession(null);
};
const joinByChzzkId = async () => {
const value = joinChannelInput.trim();
if (!value) {
@@ -510,10 +591,10 @@ export default function Home() {
<div className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-6 py-10">
<header className="flex w-full flex-col items-stretch gap-5">
<div className="w-full">
{/*<p className="text-sm text-white/60">온라인 주루마블</p>*/}
{/*<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>
@@ -521,9 +602,10 @@ export default function Home() {
</h1>
<br></br>
<p className="border-b border-white/10 w-full"></p>
<p className="mt-2 w-full text-sm text-white/65">치지직 온라인 주루마블!</p>
<p className="mt-2 w-full text-sm text-white/65">
이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장됩니다.
처음이면 칸은 랜덤으로 채워집니다.
이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장되요.
처음이면 추천 기본 배치(9×7)로 채워져요.
</p>
</div>
</header>
@@ -532,7 +614,9 @@ export default function Home() {
<div>
<h2 className="text-lg font-semibold">룸 입장</h2>
<p className="mt-1 text-[11px] text-white/50">
치즈 규칙을 정한 뒤, 치지직 채널로 입장하세요. 룸이 없으면 자동으로 만들어집니다.
{authMode?.requireChzzkLogin
? '릴리즈 모드: 치지직 로그인으로 본인 채널만 입장할 수 있습니다.'
: '개발 모드: 채널 ID 직접 입장과 치지직 로그인을 모두 사용할 수 있습니다.'}
</p>
</div>
@@ -576,27 +660,72 @@ export default function Home() {
)}
</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 className="flex flex-col gap-2 border-t border-white/10 pt-3">
<p className="text-xs font-medium text-white/70">치지직 로그인으로 입장</p>
{authSession ? (
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-emerald-200/90">
로그인됨: {authSession.channelName || authSession.channelId}
</p>
<button
type="button"
disabled={roomBusy}
onClick={() => void joinWithSession()}
className="rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
>
내 채널로 입장
</button>
<button
type="button"
onClick={() => void logoutAuth()}
className="rounded-full bg-white/10 px-3 py-2 text-xs text-white/80 hover:bg-white/20"
>
로그아웃
</button>
</div>
) : (
<button
type="button"
disabled={roomBusy || authMode?.oauthConfigured === false}
onClick={startChzzkLogin}
className="self-start rounded-full bg-sky-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-sky-500 disabled:opacity-50"
>
치지직으로 로그인 후 입장
</button>
)}
{authMode && !authMode.oauthConfigured && (
<p className="text-[11px] text-amber-200/80">
OAuth 미설정: `.env`에 CHZZK_CLIENT_ID / SECRET / REDIRECT_URI 를 넣으면 활성화됩니다.
</p>
)}
</div>
{authMode?.allowChannelIdJoin !== false && (
<div className="flex flex-wrap items-end gap-2 border-t border-white/10 pt-3">
<label className="flex min-w-[220px] flex-1 flex-col gap-1 text-sm">
<span className="text-white/70">
치지직 채널 ID로 입장
<span className="ml-1 text-[10px] text-amber-200/70">(개발용)</span>
</span>
<input
type="text"
placeholder="채널ID 또는 https://chzzk.naver.com/live/…"
value={joinChannelInput}
onChange={(e) => setJoinChannelInput(e.target.value)}
className="rounded-lg border border-white/15 bg-black/40 px-3 py-2 text-sm"
/>
</label>
<button
type="button"
disabled={roomBusy || !joinChannelInput.trim()}
onClick={() => void joinByChzzkId()}
className="rounded-full bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-violet-500 disabled:opacity-50"
>
{roomBusy ? '처리 중…' : '입장'}
</button>
</div>
)}
<button
type="button"
disabled={roomBusy}