Files
drinkmarble/app/game/asset-pack.ts

336 lines
9.5 KiB
TypeScript

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<Record<DiceFace, string>>;
/** diceSets[1], diceSets[2] */
diceSets?: Partial<Record<DiceSlot, Partial<Record<DiceFace, string>>>>;
board?: string;
cells?: Record<string, 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);
});
}
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<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));
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<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() };
}