- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.
- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
This commit is contained in:
193
app/game/asset-pack.ts
Normal file
193
app/game/asset-pack.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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<Record<DiceFace, string>>;
|
||||
};
|
||||
};
|
||||
|
||||
async function srcToBlob(src: string): Promise<Blob> {
|
||||
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<string> {
|
||||
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<DrinkmarbleAssets> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user