export type DiceFace = 1 | 2 | 3 | 4 | 5 | 6; export type DiceSlot = 1 | 2; export type DiceCount = 1 | 2; export type DiceFaceMap = Record; export type DrinkmarbleAssets = { background: string; chessman: string; diceCount: DiceCount; /** 1번 주사위 면 */ dice: DiceFaceMap; /** 2번 주사위 면 */ dice2: DiceFaceMap; /** 주사위판 바닥 색 (이미지가 없을 때) */ diceFloorColor: string; /** 주사위판 바닥 이미지 (있으면 색 대신 사용) */ diceFloorImage: string | null; }; const STORAGE_KEY = 'drinkmarble.assets'; const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6]; const DEFAULT_DICE: DiceFaceMap = { 1: '/dice/1.png', 2: '/dice/2.png', 3: '/dice/3.png', 4: '/dice/4.png', 5: '/dice/5.png', 6: '/dice/6.png', }; export const DEFAULT_DICE_FLOOR_COLOR = '#2563eb'; export const DEFAULT_ASSETS: DrinkmarbleAssets = { background: '/background/default.png', chessman: '/chessman/default.png', diceCount: 1, dice: { ...DEFAULT_DICE }, dice2: { ...DEFAULT_DICE }, diceFloorColor: DEFAULT_DICE_FLOOR_COLOR, diceFloorImage: null, }; type StoredAssets = Partial<{ background: string; chessman: string; diceCount: DiceCount | number; dice: Partial; dice2: Partial; diceFloorColor: string; diceFloorImage: string | null; }>; function readStored(): StoredAssets { if (typeof window === 'undefined') return {}; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (raw) return JSON.parse(raw) as StoredAssets; // 이전 단일 배경 키 마이그레이션 const legacyBg = window.localStorage.getItem('drinkmarble.backgroundSrc'); if (legacyBg) { const migrated: StoredAssets = { background: legacyBg }; writeStored(migrated); window.localStorage.removeItem('drinkmarble.backgroundSrc'); return migrated; } return {}; } catch { return {}; } } function writeStored(next: StoredAssets) { if (typeof window === 'undefined') return; window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } function mergeDiceFaces( stored: Partial | undefined, fallback: DiceFaceMap, ): DiceFaceMap { return { 1: stored?.[1] || fallback[1], 2: stored?.[2] || fallback[2], 3: stored?.[3] || fallback[3], 4: stored?.[4] || fallback[4], 5: stored?.[5] || fallback[5], 6: stored?.[6] || fallback[6], }; } function normalizeDiceCount(value: unknown): DiceCount { return value === 2 ? 2 : 1; } function normalizeFloorColor(value: unknown): string { if (typeof value !== 'string') return DEFAULT_DICE_FLOOR_COLOR; const trimmed = value.trim(); if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{3}$/.test(trimmed)) { return trimmed; } return DEFAULT_DICE_FLOOR_COLOR; } /** 저장된 값이 있으면 쓰고, 없으면 서버 public 기본 경로를 쓴다. */ export function getAssets(): DrinkmarbleAssets { const stored = readStored(); return { background: stored.background || DEFAULT_ASSETS.background, chessman: stored.chessman || DEFAULT_ASSETS.chessman, diceCount: normalizeDiceCount(stored.diceCount), dice: mergeDiceFaces(stored.dice, DEFAULT_ASSETS.dice), dice2: mergeDiceFaces(stored.dice2, DEFAULT_ASSETS.dice2), diceFloorColor: normalizeFloorColor(stored.diceFloorColor), diceFloorImage: typeof stored.diceFloorImage === 'string' && stored.diceFloorImage.length > 0 ? stored.diceFloorImage : null, }; } export function setBackgroundAsset(src: string) { const stored = readStored(); writeStored({ ...stored, background: src }); } export function setChessmanAsset(src: string) { const stored = readStored(); writeStored({ ...stored, chessman: src }); } export function setDiceCount(count: DiceCount) { const stored = readStored(); writeStored({ ...stored, diceCount: normalizeDiceCount(count) }); } export function setDiceFaceAsset(slot: DiceSlot, face: DiceFace, src: string) { const stored = readStored(); if (slot === 2) { writeStored({ ...stored, dice2: { ...stored.dice2, [face]: src }, }); return; } writeStored({ ...stored, dice: { ...stored.dice, [face]: src }, }); } export function setDiceFloorColor(color: string) { const stored = readStored(); writeStored({ ...stored, diceFloorColor: normalizeFloorColor(color) }); } export function setDiceFloorImage(src: string) { const stored = readStored(); writeStored({ ...stored, diceFloorImage: src }); } export function clearBackgroundAsset() { const stored = readStored(); const { background: _removed, ...rest } = stored; writeStored(rest); } export function clearChessmanAsset() { const stored = readStored(); const { chessman: _removed, ...rest } = stored; writeStored(rest); } export function clearDiceFaceAsset(slot: DiceSlot, face: DiceFace) { const stored = readStored(); if (slot === 2) { if (!stored.dice2) return; const dice2 = { ...stored.dice2 }; delete dice2[face]; writeStored({ ...stored, dice2 }); return; } if (!stored.dice) return; const dice = { ...stored.dice }; delete dice[face]; writeStored({ ...stored, dice }); } export function clearDiceFloorColor() { const stored = readStored(); const { diceFloorColor: _removed, ...rest } = stored; writeStored(rest); } export function clearDiceFloorImage() { const stored = readStored(); const { diceFloorImage: _removed, ...rest } = stored; writeStored(rest); } export function clearAllAssets() { if (typeof window === 'undefined') return; window.localStorage.removeItem(STORAGE_KEY); } /** boxGeometry material 순서: +X,-X,+Y,-Y,+Z,-Z = 2,5,1,6,3,4 */ export function getDiceFaceTextureList( faces: DiceFaceMap = getAssets().dice, ): string[] { return [faces[2], faces[5], faces[1], faces[6], faces[3], faces[4]]; } export function getDiceFaceMap(slot: DiceSlot, assets: DrinkmarbleAssets = getAssets()) { return slot === 2 ? assets.dice2 : assets.dice; } export { DICE_FACES }; export function isCustomAsset(src: string, fallback: string) { return src !== fallback && src.startsWith('data:'); } /** 애셋별 고정 비율. 업로드 시 중앙 크롭 후 이 비율로 저장한다. */ export const ASSET_ASPECT = { background: { w: 16, h: 9, label: '16:9' }, chessman: { w: 1, h: 1, label: '1:1' }, dice: { w: 1, h: 1, label: '1:1' }, cell: { w: 1, h: 1, label: '1:1' }, diceFloor: { w: 1, h: 1, label: '1:1' }, } as const; export type AssetKind = keyof typeof ASSET_ASPECT; function centerCropRect( srcW: number, srcH: number, aspectW: number, aspectH: number, ) { const target = aspectW / aspectH; const source = srcW / srcH; if (source > target) { const sw = srcH * target; return { sx: (srcW - sw) / 2, sy: 0, sw, sh: srcH }; } const sh = srcW / target; return { sx: 0, sy: (srcH - sh) / 2, sw: srcW, sh }; } /** 업로드 이미지를 지정 비율로 중앙 크롭·리사이즈해 data URL로 만든다. */ export function fileToAssetDataUrl( file: File, options: { aspectW: number; aspectH: number; maxW?: number; maxH?: number; quality?: number; }, ): Promise { const maxW = options.maxW ?? 1024; const maxH = options.maxH ?? 1024; const quality = options.quality ?? 0.85; const aspect = options.aspectW / options.aspectH; return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('파일을 읽을 수 없습니다.')); reader.onload = () => { const img = new Image(); img.onerror = () => reject(new Error('이미지 형식이 올바르지 않습니다.')); img.onload = () => { const crop = centerCropRect(img.width, img.height, options.aspectW, options.aspectH); // 출력 크기는 비율을 유지한 채 max 안에 맞춤 let outW = maxW; let outH = Math.round(outW / aspect); if (outH > maxH) { outH = maxH; outW = Math.round(outH * aspect); } const canvas = document.createElement('canvas'); canvas.width = outW; canvas.height = outH; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('canvas를 사용할 수 없습니다.')); return; } ctx.drawImage( img, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, outW, outH, ); resolve(canvas.toDataURL('image/jpeg', quality)); }; img.src = String(reader.result); }; reader.readAsDataURL(file); }); }