'use client'; import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import { ASSET_ASPECT, DEFAULT_ASSETS, type DiceFace, type DrinkmarbleAssets, clearAllAssets, clearBackgroundAsset, clearChessmanAsset, clearDiceFaceAsset, fileToAssetDataUrl, getAssets, setBackgroundAsset, setChessmanAsset, setDiceFaceAsset, } from './game/asset-storage'; import { exportAssetPack, importAssetPack } from './game/asset-pack'; const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6]; 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 = ''; }} />
); } export default function Home() { const [assets, setAssets] = useState(DEFAULT_ASSETS); const [ready, setReady] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const packInputRef = useRef(null); const reload = () => { setAssets(getAssets()); }; useEffect(() => { reload(); setReady(true); }, []); const save = async (kind: 'background' | 'chessman' | DiceFace, file: File) => { try { const aspectKind = kind === 'background' || kind === 'chessman' ? kind : 'dice'; const aspect = ASSET_ASPECT[aspectKind]; const isBg = kind === 'background'; const dataUrl = await fileToAssetDataUrl(file, { aspectW: aspect.w, aspectH: aspect.h, maxW: isBg ? 1920 : 512, maxH: isBg ? 1080 : 512, quality: isBg ? 0.85 : 0.9, }); if (kind === 'background') setBackgroundAsset(dataUrl); else if (kind === 'chessman') setChessmanAsset(dataUrl); else setDiceFaceAsset(kind, dataUrl); reload(); setError(null); } catch (e) { setError(e instanceof Error ? e.message : '저장에 실패했습니다.'); } }; const onExport = async () => { try { setBusy(true); setError(null); await exportAssetPack(assets); } 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 (

온라인 주루마블

Drinkmarble

이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다. 값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙 크롭됩니다.

게임 시작
{!ready ? (

불러오는 중…

) : (

커스텀 이미지

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

내보내기 ZIP 구조: manifest.json, background.*, chessman.*, dice/1.* … 6.*

{error && (

{error}

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

주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label})

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

이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다.

)}
); }