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

313 lines
8.5 KiB
TypeScript

export type DiceFace = 1 | 2 | 3 | 4 | 5 | 6;
export type DiceSlot = 1 | 2;
export type DiceCount = 1 | 2;
export type DiceFaceMap = Record<DiceFace, string>;
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<DiceFaceMap>;
dice2: Partial<DiceFaceMap>;
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<DiceFaceMap> | 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<string> {
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);
});
}