'use client'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { ASSET_ASPECT, DEFAULT_ASSETS, DEFAULT_DICE_FLOOR_COLOR, DICE_FACES, type DiceCount, type DiceFace, type DiceSlot, type DrinkmarbleAssets, clearAllAssets, clearBackgroundAsset, clearChessmanAsset, clearDiceFaceAsset, clearDiceFloorColor, clearDiceFloorImage, fileToAssetDataUrl, getAssets, setBackgroundAsset, setChessmanAsset, setDiceCount, setDiceFaceAsset, setDiceFloorColor, setDiceFloorImage, } from './game/asset-storage'; import { exportAssetPack, importAssetPack } from './game/asset-pack'; import { type BoardConfig, CELL_EVENT_OPTIONS, CELL_TYPE_PRESETS, MAX_PLANE_SIZE, MIN_PLANE_SIZE, buildPerimeterGeometry, cellColorFor, clearBoardConfig, getOrCreateBoardConfig, perimeterLength, randomizeAndSaveBoard, resizeBoardConfig, updateBoardCell, } from './game/board-config'; function AssetThumb({ src, label, ratioLabel, previewClassName, onPick, onReset, }: { src: string; label: string; ratioLabel: string; previewClassName: string; onPick: (file: File) => void; onReset: () => void; }) { const inputRef = useRef(null); return (
{label} 비율 {ratioLabel} · 중앙 크롭
{/* eslint-disable-next-line @next/next/no-img-element */} {label}
{ const file = e.target.files?.[0]; if (file) onPick(file); e.target.value = ''; }} />
); } function BoardEditor({ board, onChange, }: { board: BoardConfig; onChange: (next: BoardConfig) => void; }) { const geo = buildPerimeterGeometry(board.width, board.height); const [selected, setSelected] = useState(1); const cellImageInputRef = useRef(null); useEffect(() => { if (selected >= board.cells.length) { setSelected(Math.min(1, board.cells.length - 1)); } }, [board.cells.length, selected]); const selectedCell = board.cells[selected]; const cellCount = perimeterLength(board.width, board.height); const applySize = (width: number, height: number) => { onChange(resizeBoardConfig(width, height, board)); }; const onCellImagePick = async (file: File) => { const aspect = ASSET_ASPECT.cell; const dataUrl = await fileToAssetDataUrl(file, { aspectW: aspect.w, aspectH: aspect.h, maxW: 512, maxH: 512, quality: 0.88, }); onChange(updateBoardCell(selected, { imageSrc: dataUrl }, board)); }; return (

주사위판 칸 (board)

가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지를 설정합니다. 0번(시작) 이름은 고정입니다.

applySize(Number(e.target.value), board.height)} className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white" />
applySize(board.width, Number(e.target.value))} className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white" />

{MIN_PLANE_SIZE}~{MAX_PLANE_SIZE} · 둘레 {cellCount}칸

{geo.map((g, index) => { const cell = board.cells[index]; const active = selected === index; return ( ); })}
{selectedCell && (
onChange(updateBoardCell(selected, { type: e.target.value }, board))} className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50" /> {CELL_TYPE_PRESETS.map((t) => (
{ const file = e.target.files?.[0]; if (file) void onCellImagePick(file).catch(() => undefined); e.target.value = ''; }} />
)}
); } export default function Home() { const router = useRouter(); const [assets, setAssets] = useState(DEFAULT_ASSETS); const [board, setBoard] = useState(null); const [ready, setReady] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const packInputRef = useRef(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(null); const reload = () => { setAssets(getAssets()); setBoard(getOrCreateBoardConfig()); }; useEffect(() => { reload(); 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, ) => { try { const aspectKind = kind === 'background' || kind === 'chessman' || kind === 'diceFloor' ? kind : 'dice'; const aspect = ASSET_ASPECT[aspectKind]; const isBg = kind === 'background'; const isFloor = kind === 'diceFloor'; const dataUrl = await fileToAssetDataUrl(file, { aspectW: aspect.w, aspectH: aspect.h, maxW: isBg ? 1920 : isFloor ? 1024 : 512, maxH: isBg ? 1080 : isFloor ? 1024 : 512, quality: isBg || isFloor ? 0.85 : 0.9, }); if (kind === 'background') setBackgroundAsset(dataUrl); else if (kind === 'chessman') setChessmanAsset(dataUrl); else if (kind === 'diceFloor') setDiceFloorImage(dataUrl); else setDiceFaceAsset(kind.slot, kind.face, dataUrl); reload(); setError(null); } catch (e) { setError(e instanceof Error ? e.message : '저장에 실패했습니다.'); } }; const onExport = async () => { try { setBusy(true); setError(null); await exportAssetPack(assets, board ?? getOrCreateBoardConfig()); } catch (e) { setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.'); } finally { setBusy(false); } }; const onImport = async (file: File | null) => { if (!file) return; try { setBusy(true); setError(null); await importAssetPack(file); reload(); } catch (e) { setError(e instanceof Error ? e.message : '가져오기에 실패했습니다.'); } finally { setBusy(false); } }; return (
{/*

온라인 주루마블

*/}

온라인 주루마블! by kimyu.



이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장됩니다. 처음이면 칸은 랜덤으로 채워집니다.

룸 입장

치즈 규칙을 정한 뒤, 치지직 채널로 입장하세요. 룸이 없으면 자동으로 만들어집니다.

{roomMultiplyByCheese && ( )}
{roomError && (

{roomError}

)}
{!ready || !board ? (

불러오는 중…

) : (

커스텀 설정

{ void onImport(e.target.files?.[0] ?? null); e.target.value = ''; }} />

ZIP: manifest.json, board.json, cells/N.*, background.*, chessman.*, dice/1|2/면.*, dice-floor.*

{error && (

{error}

)}
void save('background', file)} onReset={() => { clearBackgroundAsset(); reload(); }} /> void save('chessman', file)} onReset={() => { clearChessmanAsset(); reload(); }} />

주사위 설정

개수 {([1, 2] as DiceCount[]).map((count) => ( ))}

주사위 판(바닥)

색상 또는 이미지({ASSET_ASPECT.diceFloor.label}) · 이미지가 있으면 색 대신 사용

`, )}` } onPick={(file) => void save('diceFloor', file)} onReset={() => { clearDiceFloorImage(); reload(); }} />
{([1, 2] as DiceSlot[]) .filter((slot) => slot === 1 || assets.diceCount === 2) .map((slot) => { const faces = slot === 1 ? assets.dice : assets.dice2; return (

{assets.diceCount === 2 ? `${slot}번 주사위 면` : '주사위 면'} (1~6 ·{' '} {ASSET_ASPECT.dice.label})

{DICE_FACES.map((face) => ( void save({ slot, face }, file)} onReset={() => { clearDiceFaceAsset(slot, face); reload(); }} /> ))}
); })}
)}
); }