치지직 앱으로 연동하고, 연동 확인된 ID만 접속할 수 있도록 수정, 개발용으로는 채널ID를 수동으로 접속할 수 있도록 변경.
최초 추천 고정 배치 추가.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user