커스텀 필요한 항목들 모두 커스텀 가능하도록 수정.
This commit is contained in:
@@ -1,23 +1,44 @@
|
||||
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 = 1;
|
||||
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
|
||||
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<Record<DiceFace, string>>;
|
||||
/** diceSets[1], diceSets[2] */
|
||||
diceSets?: Partial<Record<DiceSlot, Partial<Record<DiceFace, string>>>>;
|
||||
board?: string;
|
||||
cells?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,17 +76,34 @@ async function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function boardForExport(board: BoardConfig): BoardConfig {
|
||||
return {
|
||||
...board,
|
||||
cells: board.cells.map((cell) => ({
|
||||
type: cell.type,
|
||||
eventId: cell.eventId ?? null,
|
||||
imageSrc: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 적용 중인 애셋(커스텀 또는 기본 public)을 ZIP으로 내보낸다.
|
||||
* drinkmarble-pack.zip
|
||||
* 애셋 + 보드 설정을 ZIP으로 내보낸다.
|
||||
* ├── manifest.json
|
||||
* ├── board.json
|
||||
* ├── cells/N.*
|
||||
* ├── background.*
|
||||
* ├── chessman.*
|
||||
* └── dice/1.* … 6.*
|
||||
* ├── dice-floor.*
|
||||
* └── dice/1|2/면.*
|
||||
*/
|
||||
export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
|
||||
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)}`;
|
||||
@@ -77,17 +115,52 @@ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
|
||||
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;
|
||||
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<DiceSlot, DrinkmarbleAssets['dice']> = {
|
||||
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));
|
||||
@@ -97,11 +170,10 @@ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
|
||||
downloadBlob(zipBlob, `drinkmarble-pack-${stamp}.zip`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 팩을 읽어 localStorage에 반영한다.
|
||||
* manifest.json이 없으면 관례적 파일명(background.*, chessman.*, dice/1.*)을 시도한다.
|
||||
*/
|
||||
export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
||||
export async function importAssetPack(file: File): Promise<{
|
||||
assets: DrinkmarbleAssets;
|
||||
board: BoardConfig;
|
||||
}> {
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
throw new Error('ZIP 파일만 가져올 수 있습니다.');
|
||||
}
|
||||
@@ -135,7 +207,6 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
||||
const entry = findEntry(path);
|
||||
if (entry) return entry;
|
||||
}
|
||||
// 확장자만 다른 경우
|
||||
for (const path of candidates) {
|
||||
const base = path.replace(/\.[^.]+$/, '');
|
||||
const match = Object.keys(zip.files).find(
|
||||
@@ -160,6 +231,12 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
||||
'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;
|
||||
|
||||
@@ -171,23 +248,87 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
||||
setChessmanAsset(await blobToDataUrl(await manEntry.async('blob')));
|
||||
imported += 1;
|
||||
}
|
||||
if (floorEntry) {
|
||||
setDiceFloorImage(await blobToDataUrl(await floorEntry.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 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imported === 0) {
|
||||
throw new Error('팩에서 사용할 이미지를 찾지 못했습니다.');
|
||||
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;
|
||||
}
|
||||
|
||||
return getAssets();
|
||||
const cellImages: Record<number, string> = {};
|
||||
|
||||
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() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user