import JSZip from 'jszip'; import { type DiceFace, type DrinkmarbleAssets, getAssets, setBackgroundAsset, setChessmanAsset, setDiceFaceAsset, } from './asset-storage'; const PACK_VERSION = 1; const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6]; type PackManifest = { version: number; app: 'drinkmarble'; files: { background?: string; chessman?: string; dice?: Partial>; }; }; 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); }); } /** * 현재 적용 중인 애셋(커스텀 또는 기본 public)을 ZIP으로 내보낸다. * drinkmarble-pack.zip * ├── manifest.json * ├── background.* * ├── chessman.* * └── dice/1.* … 6.* */ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) { const zip = new JSZip(); const files: PackManifest['files'] = {}; 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; files.dice = {}; for (const face of DICE_FACES) { const faceBlob = await srcToBlob(assets.dice[face]); const faceName = `dice/${face}.${extFromBlob(faceBlob)}`; zip.file(faceName, faceBlob); files.dice[face] = faceName; } const manifest: PackManifest = { version: PACK_VERSION, app: 'drinkmarble', 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`); } /** * ZIP 팩을 읽어 localStorage에 반영한다. * manifest.json이 없으면 관례적 파일명(background.*, chessman.*, dice/1.*)을 시도한다. */ export async function importAssetPack(file: File): Promise { 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', ]); 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; } for (const face of DICE_FACES) { const faceEntry = resolvePath(manifest?.files.dice?.[face], [ `dice/${face}.jpg`, `dice/${face}.jpeg`, `dice/${face}.png`, `dice/${face}.webp`, ]); if (faceEntry) { setDiceFaceAsset(face, await blobToDataUrl(await faceEntry.async('blob'))); imported += 1; } } if (imported === 0) { throw new Error('팩에서 사용할 이미지를 찾지 못했습니다.'); } return getAssets(); }