import JSZip from 'jszip'; import { DICE_FACES, type DiceFace, type DiceSlot, type DrinkmarbleAssets, getAssets, setBackgroundAsset, setChessmanAsset, setDiceCount, setDiceFaceAsset, setDiceFloorColor, setDiceFloorImage, } from './asset-storage'; import { type BoardConfig, getOrCreateBoardConfig, normalizeBoardConfig, setBoardConfig, } from './board-config'; const PACK_VERSION = 4; const DICE_SLOTS: DiceSlot[] = [1, 2]; type PackManifest = { version: number; app: 'drinkmarble'; settings?: { diceCount?: 1 | 2; diceFloorColor?: string; }; files: { background?: string; chessman?: string; diceFloor?: string; /** 하위 호환: 단일 주사위 면 */ dice?: Partial>; /** diceSets[1], diceSets[2] */ diceSets?: Partial>>>; board?: string; cells?: Record; }; }; async function srcToBlob(src: string): Promise { const res = await fetch(src); if (!res.ok) { throw new Error(`이미지를 읽지 못했습니다: ${src}`); } return res.blob(); } function extFromBlob(blob: Blob, fallback = 'png') { const type = blob.type; if (type.includes('jpeg') || type.includes('jpg')) return 'jpg'; if (type.includes('webp')) return 'webp'; if (type.includes('png')) return 'png'; return fallback; } function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('파일을 읽을 수 없습니다.')); reader.onload = () => resolve(String(reader.result)); reader.readAsDataURL(blob); }); } function boardForExport(board: BoardConfig): BoardConfig { return { ...board, cells: board.cells.map((cell) => ({ type: cell.type, eventId: cell.eventId ?? null, imageSrc: null, contentVisible: cell.contentVisible !== false, })), }; } /** * 애셋 + 보드 설정을 ZIP으로 내보낸다. * ├── manifest.json * ├── board.json * ├── cells/N.* * ├── background.* * ├── chessman.* * ├── dice-floor.* * └── dice/1|2/면.* */ export async function exportAssetPack( assets: DrinkmarbleAssets = getAssets(), board: BoardConfig = getOrCreateBoardConfig(), ) { const zip = new JSZip(); const files: PackManifest['files'] = {}; const normalized = normalizeBoardConfig(board); const bgBlob = await srcToBlob(assets.background); const bgName = `background.${extFromBlob(bgBlob)}`; zip.file(bgName, bgBlob); files.background = bgName; const manBlob = await srcToBlob(assets.chessman); const manName = `chessman.${extFromBlob(manBlob)}`; zip.file(manName, manBlob); files.chessman = manName; if (assets.diceFloorImage) { const floorBlob = await srcToBlob(assets.diceFloorImage); const floorName = `dice-floor.${extFromBlob(floorBlob)}`; zip.file(floorName, floorBlob); files.diceFloor = floorName; } files.diceSets = {}; const slotMaps: Record = { 1: assets.dice, 2: assets.dice2, }; for (const slot of DICE_SLOTS) { if (slot === 2 && assets.diceCount < 2) continue; files.diceSets[slot] = {}; for (const face of DICE_FACES) { const faceBlob = await srcToBlob(slotMaps[slot][face]); const faceName = `dice/${slot}/${face}.${extFromBlob(faceBlob)}`; zip.file(faceName, faceBlob); files.diceSets[slot]![face] = faceName; } } // 하위 호환: dice/면.* 도 1번 세트로 넣어둠 files.dice = files.diceSets[1]; files.cells = {}; for (let i = 0; i < normalized.cells.length; i++) { const src = normalized.cells[i]?.imageSrc; if (!src) continue; const cellBlob = await srcToBlob(src); const cellName = `cells/${i}.${extFromBlob(cellBlob)}`; zip.file(cellName, cellBlob); files.cells[String(i)] = cellName; } zip.file('board.json', JSON.stringify(boardForExport(normalized), null, 2)); files.board = 'board.json'; const manifest: PackManifest = { version: PACK_VERSION, app: 'drinkmarble', settings: { diceCount: assets.diceCount, diceFloorColor: assets.diceFloorColor, }, files, }; zip.file('manifest.json', JSON.stringify(manifest, null, 2)); const zipBlob = await zip.generateAsync({ type: 'blob' }); const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); downloadBlob(zipBlob, `drinkmarble-pack-${stamp}.zip`); } export async function importAssetPack(file: File): Promise<{ assets: DrinkmarbleAssets; board: BoardConfig; }> { if (!file.name.toLowerCase().endsWith('.zip')) { throw new Error('ZIP 파일만 가져올 수 있습니다.'); } const zip = await JSZip.loadAsync(file); const manifestFile = zip.file('manifest.json'); let manifest: PackManifest | null = null; if (manifestFile) { const text = await manifestFile.async('string'); const parsed = JSON.parse(text) as PackManifest; if (parsed.app && parsed.app !== 'drinkmarble') { throw new Error('Drinkmarble 팩이 아닙니다.'); } manifest = parsed; } const findEntry = (path: string) => { const exact = zip.file(path); if (exact) return exact; const lower = path.toLowerCase(); const match = Object.keys(zip.files).find( (name) => !zip.files[name].dir && name.toLowerCase() === lower, ); return match ? zip.file(match) : null; }; const resolvePath = (preferred?: string, fallbacks: string[] = []) => { const candidates = [preferred, ...fallbacks].filter(Boolean) as string[]; for (const path of candidates) { const entry = findEntry(path); if (entry) return entry; } for (const path of candidates) { const base = path.replace(/\.[^.]+$/, ''); const match = Object.keys(zip.files).find( (name) => !zip.files[name].dir && name.replace(/\.[^.]+$/, '').toLowerCase() === base.toLowerCase(), ); if (match) return zip.file(match); } return null; }; const bgEntry = resolvePath(manifest?.files.background, [ 'background.jpg', 'background.jpeg', 'background.png', 'background.webp', ]); const manEntry = resolvePath(manifest?.files.chessman, [ 'chessman.jpg', 'chessman.jpeg', 'chessman.png', 'chessman.webp', ]); const floorEntry = resolvePath(manifest?.files.diceFloor, [ 'dice-floor.jpg', 'dice-floor.jpeg', 'dice-floor.png', 'dice-floor.webp', ]); let imported = 0; if (bgEntry) { setBackgroundAsset(await blobToDataUrl(await bgEntry.async('blob'))); imported += 1; } if (manEntry) { setChessmanAsset(await blobToDataUrl(await manEntry.async('blob'))); imported += 1; } if (floorEntry) { setDiceFloorImage(await blobToDataUrl(await floorEntry.async('blob'))); imported += 1; } if (manifest?.settings?.diceFloorColor) { setDiceFloorColor(manifest.settings.diceFloorColor); imported += 1; } if (manifest?.settings?.diceCount === 1 || manifest?.settings?.diceCount === 2) { setDiceCount(manifest.settings.diceCount); imported += 1; } for (const slot of DICE_SLOTS) { for (const face of DICE_FACES) { const preferred = manifest?.files.diceSets?.[slot]?.[face] ?? (slot === 1 ? manifest?.files.dice?.[face] : undefined); const faceEntry = resolvePath(preferred, [ `dice/${slot}/${face}.jpg`, `dice/${slot}/${face}.jpeg`, `dice/${slot}/${face}.png`, `dice/${slot}/${face}.webp`, ...(slot === 1 ? [ `dice/${face}.jpg`, `dice/${face}.jpeg`, `dice/${face}.png`, `dice/${face}.webp`, ] : []), ]); if (faceEntry) { setDiceFaceAsset(slot, face, await blobToDataUrl(await faceEntry.async('blob'))); imported += 1; } } } const boardEntry = resolvePath(manifest?.files.board, ['board.json']); let board = getOrCreateBoardConfig(); if (boardEntry) { const text = await boardEntry.async('string'); board = normalizeBoardConfig(JSON.parse(text) as BoardConfig); imported += 1; } const cellImages: Record = {}; if (manifest?.files.cells) { for (const [indexKey, path] of Object.entries(manifest.files.cells)) { const entry = resolvePath(path); if (!entry) continue; const index = Number(indexKey); if (!Number.isFinite(index)) continue; cellImages[index] = await blobToDataUrl(await entry.async('blob')); } } else { for (const name of Object.keys(zip.files)) { if (zip.files[name].dir) continue; const match = /^cells\/(\d+)\./i.exec(name); if (!match) continue; const entry = zip.file(name); if (!entry) continue; cellImages[Number(match[1])] = await blobToDataUrl(await entry.async('blob')); } } if (Object.keys(cellImages).length > 0 || boardEntry) { const cells = board.cells.map((cell, index) => ({ ...cell, imageSrc: cellImages[index] ?? cell.imageSrc ?? null, })); board = normalizeBoardConfig({ ...board, cells }); setBoardConfig(board); } if (imported === 0 && Object.keys(cellImages).length === 0) { throw new Error('팩에서 사용할 데이터를 찾지 못했습니다.'); } return { assets: getAssets(), board: getOrCreateBoardConfig() }; }