- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.
- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
This commit is contained in:
217
app/game/asset-storage.ts
Normal file
217
app/game/asset-storage.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
export type DiceFace = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export type DrinkmarbleAssets = {
|
||||
background: string;
|
||||
chessman: string;
|
||||
dice: Record<DiceFace, string>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'drinkmarble.assets';
|
||||
|
||||
export const DEFAULT_ASSETS: DrinkmarbleAssets = {
|
||||
background: '/background/default.png',
|
||||
chessman: '/chessman/default.png',
|
||||
dice: {
|
||||
1: '/dice/1.png',
|
||||
2: '/dice/2.png',
|
||||
3: '/dice/3.png',
|
||||
4: '/dice/4.png',
|
||||
5: '/dice/5.png',
|
||||
6: '/dice/6.png',
|
||||
},
|
||||
};
|
||||
|
||||
type StoredAssets = Partial<{
|
||||
background: string;
|
||||
chessman: string;
|
||||
dice: Partial<Record<DiceFace, string>>;
|
||||
}>;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/** 저장된 값이 있으면 쓰고, 없으면 서버 public 기본 경로를 쓴다. */
|
||||
export function getAssets(): DrinkmarbleAssets {
|
||||
const stored = readStored();
|
||||
return {
|
||||
background: stored.background || DEFAULT_ASSETS.background,
|
||||
chessman: stored.chessman || DEFAULT_ASSETS.chessman,
|
||||
dice: {
|
||||
1: stored.dice?.[1] || DEFAULT_ASSETS.dice[1],
|
||||
2: stored.dice?.[2] || DEFAULT_ASSETS.dice[2],
|
||||
3: stored.dice?.[3] || DEFAULT_ASSETS.dice[3],
|
||||
4: stored.dice?.[4] || DEFAULT_ASSETS.dice[4],
|
||||
5: stored.dice?.[5] || DEFAULT_ASSETS.dice[5],
|
||||
6: stored.dice?.[6] || DEFAULT_ASSETS.dice[6],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 setDiceFaceAsset(face: DiceFace, src: string) {
|
||||
const stored = readStored();
|
||||
writeStored({
|
||||
...stored,
|
||||
dice: { ...stored.dice, [face]: 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(face: DiceFace) {
|
||||
const stored = readStored();
|
||||
if (!stored.dice) return;
|
||||
const dice = { ...stored.dice };
|
||||
delete dice[face];
|
||||
writeStored({ ...stored, dice });
|
||||
}
|
||||
|
||||
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(assets: DrinkmarbleAssets = getAssets()): string[] {
|
||||
return [
|
||||
assets.dice[2],
|
||||
assets.dice[5],
|
||||
assets.dice[1],
|
||||
assets.dice[6],
|
||||
assets.dice[3],
|
||||
assets.dice[4],
|
||||
];
|
||||
}
|
||||
|
||||
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' },
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user