커스텀 필요한 항목들 모두 커스텀 가능하도록 수정.
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;
|
||||
}
|
||||
|
||||
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 faceEntry = resolvePath(manifest?.files.dice?.[face], [
|
||||
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(face, await blobToDataUrl(await faceEntry.async('blob')));
|
||||
setDiceFaceAsset(slot, face, await blobToDataUrl(await faceEntry.async('blob')));
|
||||
imported += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (imported === 0) {
|
||||
throw new Error('팩에서 사용할 이미지를 찾지 못했습니다.');
|
||||
}
|
||||
|
||||
return getAssets();
|
||||
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() };
|
||||
}
|
||||
|
||||
@@ -1,30 +1,54 @@
|
||||
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;
|
||||
dice: Record<DiceFace, 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];
|
||||
|
||||
export const DEFAULT_ASSETS: DrinkmarbleAssets = {
|
||||
background: '/background/default.png',
|
||||
chessman: '/chessman/default.png',
|
||||
dice: {
|
||||
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;
|
||||
dice: Partial<Record<DiceFace, string>>;
|
||||
diceCount: DiceCount | number;
|
||||
dice: Partial<DiceFaceMap>;
|
||||
dice2: Partial<DiceFaceMap>;
|
||||
diceFloorColor: string;
|
||||
diceFloorImage: string | null;
|
||||
}>;
|
||||
|
||||
function readStored(): StoredAssets {
|
||||
@@ -52,20 +76,47 @@ function writeStored(next: StoredAssets) {
|
||||
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,
|
||||
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],
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,14 +130,36 @@ export function setChessmanAsset(src: string) {
|
||||
writeStored({ ...stored, chessman: src });
|
||||
}
|
||||
|
||||
export function setDiceFaceAsset(face: DiceFace, src: string) {
|
||||
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;
|
||||
@@ -99,31 +172,51 @@ export function clearChessmanAsset() {
|
||||
writeStored(rest);
|
||||
}
|
||||
|
||||
export function clearDiceFaceAsset(face: DiceFace) {
|
||||
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(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 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:');
|
||||
}
|
||||
@@ -133,6 +226,8 @@ 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;
|
||||
|
||||
332
app/game/board-config.ts
Normal file
332
app/game/board-config.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { PlaneCell } from './cell-event';
|
||||
|
||||
export const DEFAULT_PLANE_SIZE = { width: 9, height: 7 } as const;
|
||||
export const MIN_PLANE_SIZE = 4;
|
||||
export const MAX_PLANE_SIZE = 16;
|
||||
|
||||
export const CELL_TYPE_PRESETS = [
|
||||
'한잔',
|
||||
'안주',
|
||||
'모두한잔',
|
||||
'두잔',
|
||||
'벌칙1',
|
||||
'벌칙2',
|
||||
'무인도',
|
||||
'역방향',
|
||||
'비행기',
|
||||
] as const;
|
||||
|
||||
export type CellEventOption = {
|
||||
id: string | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const CELL_EVENT_OPTIONS: CellEventOption[] = [
|
||||
{ id: null, label: '없음' },
|
||||
{ id: 'island', label: '무인도' },
|
||||
{ id: 'reverse', label: '역방향' },
|
||||
{ id: 'airplane', label: '비행기 → 시작' },
|
||||
];
|
||||
|
||||
/** 저장용 칸 설정 (좌표는 지오메트리에서 합친다) */
|
||||
export type BoardCellConfig = {
|
||||
type: string;
|
||||
eventId?: string | null;
|
||||
/** 칸 전용 이미지 (data URL 또는 상대 경로) */
|
||||
imageSrc?: string | null;
|
||||
};
|
||||
|
||||
export type BoardConfig = {
|
||||
version: number;
|
||||
width: number;
|
||||
height: number;
|
||||
cells: BoardCellConfig[];
|
||||
};
|
||||
|
||||
const BOARD_STORAGE_KEY = 'drinkmarble.board';
|
||||
const BOARD_VERSION = 2;
|
||||
|
||||
export function clampPlaneSize(n: number) {
|
||||
return Math.min(MAX_PLANE_SIZE, Math.max(MIN_PLANE_SIZE, Math.round(n)));
|
||||
}
|
||||
|
||||
export function perimeterLength(width: number, height: number) {
|
||||
return 2 * (width + height - 2);
|
||||
}
|
||||
|
||||
function randomPresetType() {
|
||||
const pool = CELL_TYPE_PRESETS.filter(
|
||||
(t) => t !== '무인도' && t !== '역방향' && t !== '비행기',
|
||||
);
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
/** 시작(좌상)을 제외한 세 모서리 인덱스 */
|
||||
function cornerIndices(width: number, height: number) {
|
||||
const w = clampPlaneSize(width);
|
||||
const h = clampPlaneSize(height);
|
||||
return {
|
||||
topRight: w - 1,
|
||||
bottomRight: w + h - 2,
|
||||
bottomLeft: 2 * w + h - 3,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultSpecials(width: number, height: number): Record<number, BoardCellConfig> {
|
||||
if (perimeterLength(width, height) < 8) return {};
|
||||
const { topRight, bottomRight, bottomLeft } = cornerIndices(width, height);
|
||||
return {
|
||||
[topRight]: { type: '무인도', eventId: 'island' },
|
||||
[bottomRight]: { type: '역방향', eventId: 'reverse' },
|
||||
[bottomLeft]: { type: '비행기', eventId: 'airplane' },
|
||||
};
|
||||
}
|
||||
|
||||
/** 시계방향 둘레 좌표만 만든다. */
|
||||
export function buildPerimeterGeometry(
|
||||
width: number,
|
||||
height: number,
|
||||
): { id: number; x: number; y: number }[] {
|
||||
const w = clampPlaneSize(width);
|
||||
const h = clampPlaneSize(height);
|
||||
const cells: { id: number; x: number; y: number }[] = [];
|
||||
let id = 0;
|
||||
|
||||
for (let x = 0; x < w; x++) {
|
||||
cells.push({ id: id++, x, y: 0 });
|
||||
}
|
||||
for (let y = 1; y < h; y++) {
|
||||
cells.push({ id: id++, x: w - 1, y });
|
||||
}
|
||||
for (let x = w - 2; x >= 0; x--) {
|
||||
cells.push({ id: id++, x, y: h - 1 });
|
||||
}
|
||||
for (let y = h - 2; y >= 1; y--) {
|
||||
cells.push({ id: id++, x: 0, y });
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function createRandomBoardConfig(
|
||||
width: number = DEFAULT_PLANE_SIZE.width,
|
||||
height: number = DEFAULT_PLANE_SIZE.height,
|
||||
): BoardConfig {
|
||||
const w = clampPlaneSize(width);
|
||||
const h = clampPlaneSize(height);
|
||||
const geo = buildPerimeterGeometry(w, h);
|
||||
const specials = defaultSpecials(w, h);
|
||||
const cells: BoardCellConfig[] = geo.map((_, index) => {
|
||||
if (index === 0) return { type: '시작', eventId: null, imageSrc: null };
|
||||
if (specials[index]) return { ...specials[index], imageSrc: null };
|
||||
return { type: randomPresetType(), eventId: null, imageSrc: null };
|
||||
});
|
||||
return { version: BOARD_VERSION, width: w, height: h, cells };
|
||||
}
|
||||
|
||||
function sanitizeCell(src: BoardCellConfig | undefined, fallback: BoardCellConfig): BoardCellConfig {
|
||||
if (!src || typeof src.type !== 'string' || src.type.trim() === '') {
|
||||
return { ...fallback };
|
||||
}
|
||||
const eventId =
|
||||
src.eventId === undefined
|
||||
? null
|
||||
: src.eventId === null || src.eventId === ''
|
||||
? null
|
||||
: String(src.eventId);
|
||||
const imageSrc =
|
||||
typeof src.imageSrc === 'string' && src.imageSrc.length > 0 ? src.imageSrc : null;
|
||||
return { type: src.type.trim(), eventId, imageSrc };
|
||||
}
|
||||
|
||||
export function normalizeBoardConfig(input: BoardConfig | null | undefined): BoardConfig {
|
||||
const width = clampPlaneSize(input?.width ?? DEFAULT_PLANE_SIZE.width);
|
||||
const height = clampPlaneSize(input?.height ?? DEFAULT_PLANE_SIZE.height);
|
||||
const geoLen = perimeterLength(width, height);
|
||||
const base = createRandomBoardConfig(width, height);
|
||||
|
||||
if (!input?.cells || !Array.isArray(input.cells)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const cells: BoardCellConfig[] = [];
|
||||
for (let i = 0; i < geoLen; i++) {
|
||||
cells.push(sanitizeCell(input.cells[i], base.cells[i]));
|
||||
}
|
||||
|
||||
cells[0] = { type: '시작', eventId: null, imageSrc: cells[0]?.imageSrc ?? null };
|
||||
return { version: BOARD_VERSION, width, height, cells };
|
||||
}
|
||||
|
||||
export function readStoredBoard(): BoardConfig | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BOARD_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return normalizeBoardConfig(JSON.parse(raw) as BoardConfig);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredBoard(config: BoardConfig) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const normalized = normalizeBoardConfig(config);
|
||||
window.localStorage.setItem(BOARD_STORAGE_KEY, JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
export function getOrCreateBoardConfig(): BoardConfig {
|
||||
const existing = readStoredBoard();
|
||||
if (existing) return existing;
|
||||
const created = createRandomBoardConfig();
|
||||
writeStoredBoard(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function setBoardConfig(config: BoardConfig) {
|
||||
writeStoredBoard(config);
|
||||
}
|
||||
|
||||
export function clearBoardConfig() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(BOARD_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function randomizeAndSaveBoard(
|
||||
width?: number,
|
||||
height?: number,
|
||||
current = getOrCreateBoardConfig(),
|
||||
): BoardConfig {
|
||||
const created = createRandomBoardConfig(
|
||||
width ?? current.width,
|
||||
height ?? current.height,
|
||||
);
|
||||
writeStoredBoard(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 판 크기를 바꾸고, 가능한 칸 설정을 비율에 맞게 이어받는다. */
|
||||
export function resizeBoardConfig(
|
||||
width: number,
|
||||
height: number,
|
||||
current = getOrCreateBoardConfig(),
|
||||
): BoardConfig {
|
||||
const w = clampPlaneSize(width);
|
||||
const h = clampPlaneSize(height);
|
||||
if (w === current.width && h === current.height) {
|
||||
return normalizeBoardConfig(current);
|
||||
}
|
||||
|
||||
const nextLen = perimeterLength(w, h);
|
||||
const prev = normalizeBoardConfig(current);
|
||||
const cells: BoardCellConfig[] = [];
|
||||
|
||||
for (let i = 0; i < nextLen; i++) {
|
||||
if (i === 0) {
|
||||
cells.push({
|
||||
type: '시작',
|
||||
eventId: null,
|
||||
imageSrc: prev.cells[0]?.imageSrc ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const mapped =
|
||||
prev.cells.length <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
prev.cells.length - 1,
|
||||
Math.round((i / (nextLen - 1)) * (prev.cells.length - 1)),
|
||||
);
|
||||
const src = prev.cells[mapped] ?? prev.cells[1];
|
||||
cells.push({
|
||||
type: src?.type && src.type !== '시작' ? src.type : randomPresetType(),
|
||||
eventId: src?.eventId ?? null,
|
||||
imageSrc: src?.imageSrc ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const next = { version: BOARD_VERSION, width: w, height: h, cells };
|
||||
writeStoredBoard(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function updateBoardCell(
|
||||
index: number,
|
||||
patch: Partial<BoardCellConfig>,
|
||||
current = getOrCreateBoardConfig(),
|
||||
): BoardConfig {
|
||||
if (index < 0 || index >= current.cells.length) return current;
|
||||
const cells = current.cells.map((cell, i) => {
|
||||
if (i !== index) return cell;
|
||||
if (i === 0) {
|
||||
return {
|
||||
type: '시작',
|
||||
eventId: null,
|
||||
imageSrc:
|
||||
patch.imageSrc === undefined
|
||||
? cell.imageSrc ?? null
|
||||
: patch.imageSrc === ''
|
||||
? null
|
||||
: patch.imageSrc,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: patch.type?.trim() || cell.type,
|
||||
eventId:
|
||||
patch.eventId === undefined
|
||||
? cell.eventId ?? null
|
||||
: patch.eventId === ''
|
||||
? null
|
||||
: patch.eventId,
|
||||
imageSrc:
|
||||
patch.imageSrc === undefined
|
||||
? cell.imageSrc ?? null
|
||||
: patch.imageSrc === ''
|
||||
? null
|
||||
: patch.imageSrc,
|
||||
};
|
||||
});
|
||||
const next = {
|
||||
version: BOARD_VERSION,
|
||||
width: current.width,
|
||||
height: current.height,
|
||||
cells,
|
||||
};
|
||||
writeStoredBoard(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildPlaneCells(config: BoardConfig = getOrCreateBoardConfig()): PlaneCell[] {
|
||||
const normalized = normalizeBoardConfig(config);
|
||||
const geo = buildPerimeterGeometry(normalized.width, normalized.height);
|
||||
return geo.map((g, index) => ({
|
||||
id: g.id,
|
||||
x: g.x,
|
||||
y: g.y,
|
||||
type: normalized.cells[index]?.type ?? (index === 0 ? '시작' : '한잔'),
|
||||
eventId: index === 0 ? null : (normalized.cells[index]?.eventId ?? null),
|
||||
imageSrc: normalized.cells[index]?.imageSrc ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function cellColorFor(eventId: string | null | undefined, index: number) {
|
||||
if (index === 0) return '#059669';
|
||||
switch (eventId) {
|
||||
case 'island':
|
||||
return '#0f766e';
|
||||
case 'reverse':
|
||||
return '#7c3aed';
|
||||
case 'airplane':
|
||||
return '#0284c7';
|
||||
default:
|
||||
return '#e11d48';
|
||||
}
|
||||
}
|
||||
|
||||
/** 주사위 UI를 안쪽 빈 칸에 배치할 때 쓰는 span (1~2) */
|
||||
export function diceAreaSpan(width: number, height: number) {
|
||||
return Math.min(2, Math.max(1, width - 2), Math.max(1, height - 2));
|
||||
}
|
||||
|
||||
/** 하위 호환: 예전 PLANE_SIZE 참조 */
|
||||
export const PLANE_SIZE = DEFAULT_PLANE_SIZE;
|
||||
@@ -8,6 +8,8 @@ export type PlaneCell = {
|
||||
type: string;
|
||||
/** 효과가 있을 때만 설정. 없으면 착지 시 아무 일도 없음 */
|
||||
eventId?: string | null;
|
||||
/** 칸 이미지 (없으면 텍스트만) */
|
||||
imageSrc?: string | null;
|
||||
};
|
||||
|
||||
export type TurnContext = {
|
||||
|
||||
@@ -46,14 +46,15 @@ export class Chessman {
|
||||
|
||||
/**
|
||||
* 무인도 상태에서의 주사위 처리.
|
||||
* - escape: 6(또는 escapeOn)으로 탈출 → 이후 정상 이동
|
||||
* - escape: escapeOn(기본 6)이 한 면이라도 나오면 탈출 → 이후 정상 이동
|
||||
* - wait: 턴 소모 후 대기 (turnsLeft 0이면 자동 해제, 이번 턴은 이동 없음)
|
||||
* - free: 이미 무인도 아님
|
||||
*/
|
||||
resolveIslandRoll(roll: number): 'escape' | 'wait' | 'free' {
|
||||
resolveIslandRoll(roll: number, faces?: number[]): 'escape' | 'wait' | 'free' {
|
||||
if (!this.island) return 'free';
|
||||
|
||||
if (roll === this.island.escapeOn) {
|
||||
const values = faces && faces.length > 0 ? faces : [roll];
|
||||
if (values.some((value) => value === this.island!.escapeOn)) {
|
||||
this.island = null;
|
||||
return 'escape';
|
||||
}
|
||||
|
||||
@@ -14,8 +14,15 @@ import {
|
||||
TextureLoader,
|
||||
Vector3,
|
||||
type OrthographicCamera,
|
||||
type Texture,
|
||||
} from 'three';
|
||||
import { getDiceFaceTextureList } from './asset-storage';
|
||||
import {
|
||||
DEFAULT_ASSETS,
|
||||
DEFAULT_DICE_FLOOR_COLOR,
|
||||
getAssets,
|
||||
getDiceFaceTextureList,
|
||||
type DiceCount,
|
||||
} from './asset-storage';
|
||||
|
||||
const FLOOR_SIZE = 6;
|
||||
const FLOOR_HALF = FLOOR_SIZE / 2;
|
||||
@@ -27,15 +34,7 @@ const WALL_HALF_H = WALL_HEIGHT / 2;
|
||||
const STILL_SPEED_SQ = 0.05;
|
||||
const STILL_FRAMES = 18;
|
||||
|
||||
// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z (기본 경로, 런타임에 localStorage로 교체)
|
||||
const DEFAULT_DICE_FACE_TEXTURES = [
|
||||
'/dice/2.png',
|
||||
'/dice/5.png',
|
||||
'/dice/1.png',
|
||||
'/dice/6.png',
|
||||
'/dice/3.png',
|
||||
'/dice/4.png',
|
||||
] as const;
|
||||
const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice);
|
||||
|
||||
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
||||
const DICE_FACES = [
|
||||
@@ -51,6 +50,13 @@ const _quat = new Quaternion();
|
||||
const _normal = new Vector3();
|
||||
const WORLD_UP = new Vector3(0, 1, 0);
|
||||
|
||||
export type DiceRollResult = {
|
||||
/** 이동에 쓰는 합 */
|
||||
sum: number;
|
||||
/** 각 주사위 윗면 */
|
||||
faces: number[];
|
||||
};
|
||||
|
||||
function readTopFace(body: RapierRigidBody): number {
|
||||
const r = body.rotation();
|
||||
_quat.set(r.x, r.y, r.z, r.w);
|
||||
@@ -127,36 +133,104 @@ function DiceMesh({ faceUrls }: { faceUrls: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FloorMesh({
|
||||
color,
|
||||
imageSrc,
|
||||
}: {
|
||||
color: string;
|
||||
imageSrc: string | null;
|
||||
}) {
|
||||
const texture = useLoader(
|
||||
TextureLoader,
|
||||
imageSrc ?? '/background/default.png',
|
||||
) as Texture;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!imageSrc) return;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.anisotropy = 8;
|
||||
texture.needsUpdate = true;
|
||||
}, [imageSrc, texture]);
|
||||
|
||||
return (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
{imageSrc ? (
|
||||
<meshBasicMaterial map={texture} />
|
||||
) : (
|
||||
<meshBasicMaterial color={color} />
|
||||
)}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorSurface({
|
||||
color,
|
||||
imageSrc,
|
||||
}: {
|
||||
color: string;
|
||||
imageSrc: string | null;
|
||||
}) {
|
||||
if (!imageSrc) {
|
||||
return (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
</mesh>
|
||||
}
|
||||
>
|
||||
<FloorMesh color={color} imageSrc={imageSrc} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function isBodyStill(body: RapierRigidBody) {
|
||||
const lv = body.linvel();
|
||||
const av = body.angvel();
|
||||
const speedSq =
|
||||
lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z;
|
||||
return speedSq < STILL_SPEED_SQ || body.isSleeping();
|
||||
}
|
||||
|
||||
function DiceSettleWatcher({
|
||||
diceRef,
|
||||
diceRefs,
|
||||
rolling,
|
||||
onSettled,
|
||||
}: {
|
||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
||||
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||
rolling: boolean;
|
||||
onSettled: (value: number) => void;
|
||||
onSettled: (result: DiceRollResult) => void;
|
||||
}) {
|
||||
const stillFrames = useRef(0);
|
||||
const reported = useRef(false);
|
||||
|
||||
useFrame(() => {
|
||||
const body = diceRef.current;
|
||||
if (!body || !rolling) {
|
||||
if (!rolling) {
|
||||
stillFrames.current = 0;
|
||||
reported.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lv = body.linvel();
|
||||
const av = body.angvel();
|
||||
const speedSq =
|
||||
lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z;
|
||||
const bodies = diceRefs.map((ref) => ref.current);
|
||||
if (bodies.some((body) => !body)) return;
|
||||
|
||||
if (speedSq < STILL_SPEED_SQ || body.isSleeping()) {
|
||||
if (bodies.every((body) => isBodyStill(body!))) {
|
||||
stillFrames.current += 1;
|
||||
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
|
||||
reported.current = true;
|
||||
onSettled(readTopFace(body));
|
||||
const faces = bodies.map((body) => readTopFace(body!));
|
||||
const sum = faces.reduce((a, b) => a + b, 0);
|
||||
onSettled({ sum, faces });
|
||||
}
|
||||
} else {
|
||||
stillFrames.current = 0;
|
||||
@@ -167,16 +241,24 @@ function DiceSettleWatcher({
|
||||
}
|
||||
|
||||
function RapierDiceExample({
|
||||
diceRef,
|
||||
diceRefs,
|
||||
diceCount,
|
||||
rolling,
|
||||
onSettled,
|
||||
faceUrls,
|
||||
faceUrlSets,
|
||||
floorColor,
|
||||
floorImage,
|
||||
}: {
|
||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
||||
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||
diceCount: DiceCount;
|
||||
rolling: boolean;
|
||||
onSettled: (value: number) => void;
|
||||
faceUrls: string[];
|
||||
onSettled: (result: DiceRollResult) => void;
|
||||
faceUrlSets: string[][];
|
||||
floorColor: string;
|
||||
floorImage: string | null;
|
||||
}) {
|
||||
const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0];
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
orthographic
|
||||
@@ -189,28 +271,39 @@ function RapierDiceExample({
|
||||
<directionalLight position={[4, 10, 2]} intensity={0.9} />
|
||||
<Physics>
|
||||
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
<meshBasicMaterial color="blue" />
|
||||
</mesh>
|
||||
<FloorSurface
|
||||
key={`${floorColor}|${floorImage ?? ''}`}
|
||||
color={floorColor}
|
||||
imageSrc={floorImage}
|
||||
/>
|
||||
<CuboidCollider args={[FLOOR_HALF, 0.05, FLOOR_HALF]} position={[0, -0.05, 0]} />
|
||||
<InvisibleWalls />
|
||||
</RigidBody>
|
||||
|
||||
{spawnX.map((x, index) => (
|
||||
<RigidBody
|
||||
ref={diceRef}
|
||||
key={`die-${index}-${diceCount}`}
|
||||
ref={diceRefs[index]}
|
||||
type="dynamic"
|
||||
position={[0, 4, 0]}
|
||||
position={[x, 4, index === 0 ? 0 : 0.15]}
|
||||
colliders="cuboid"
|
||||
restitution={0.2}
|
||||
friction={0.6}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<DiceMesh key={faceUrls.join('|')} faceUrls={faceUrls} />
|
||||
<DiceMesh
|
||||
key={faceUrlSets[index]?.join('|') ?? index}
|
||||
faceUrls={faceUrlSets[index] ?? DEFAULT_DICE_FACE_TEXTURES}
|
||||
/>
|
||||
</Suspense>
|
||||
</RigidBody>
|
||||
))}
|
||||
|
||||
<DiceSettleWatcher diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
|
||||
<DiceSettleWatcher
|
||||
diceRefs={diceRefs.slice(0, diceCount)}
|
||||
rolling={rolling}
|
||||
onSettled={onSettled}
|
||||
/>
|
||||
</Physics>
|
||||
</Canvas>
|
||||
);
|
||||
@@ -230,27 +323,48 @@ function rollDice(body: RapierRigidBody | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatResult(result: DiceRollResult | null) {
|
||||
if (!result) return '결과: —';
|
||||
if (result.faces.length <= 1) return `결과: ${result.sum}`;
|
||||
return `결과: ${result.faces.join(' + ')} = ${result.sum}`;
|
||||
}
|
||||
|
||||
export default function Dice({
|
||||
onRolled,
|
||||
disabled = false,
|
||||
}: {
|
||||
onRolled?: (value: number) => void;
|
||||
onRolled?: (result: DiceRollResult) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const diceRef = useRef<RapierRigidBody>(null);
|
||||
const diceRefA = useRef<RapierRigidBody>(null);
|
||||
const diceRefB = useRef<RapierRigidBody>(null);
|
||||
const diceRefs = [diceRefA, diceRefB];
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [faceUrls, setFaceUrls] = useState<string[]>([...DEFAULT_DICE_FACE_TEXTURES]);
|
||||
const [result, setResult] = useState<DiceRollResult | null>(null);
|
||||
const [diceCount, setDiceCount] = useState<DiceCount>(1);
|
||||
const [faceUrlSets, setFaceUrlSets] = useState<string[][]>([
|
||||
[...DEFAULT_DICE_FACE_TEXTURES],
|
||||
]);
|
||||
const [floorColor, setFloorColor] = useState(DEFAULT_DICE_FLOOR_COLOR);
|
||||
const [floorImage, setFloorImage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFaceUrls(getDiceFaceTextureList());
|
||||
const assets = getAssets();
|
||||
setDiceCount(assets.diceCount);
|
||||
setFaceUrlSets(
|
||||
assets.diceCount === 2
|
||||
? [getDiceFaceTextureList(assets.dice), getDiceFaceTextureList(assets.dice2)]
|
||||
: [getDiceFaceTextureList(assets.dice)],
|
||||
);
|
||||
setFloorColor(assets.diceFloorColor);
|
||||
setFloorImage(assets.diceFloorImage);
|
||||
}, []);
|
||||
|
||||
const onSettled = useCallback(
|
||||
(value: number) => {
|
||||
setResult(value);
|
||||
(next: DiceRollResult) => {
|
||||
setResult(next);
|
||||
setRolling(false);
|
||||
onRolled?.(value);
|
||||
onRolled?.(next);
|
||||
},
|
||||
[onRolled],
|
||||
);
|
||||
@@ -261,10 +375,13 @@ export default function Dice({
|
||||
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
|
||||
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
|
||||
<RapierDiceExample
|
||||
diceRef={diceRef}
|
||||
diceRefs={diceRefs}
|
||||
diceCount={diceCount}
|
||||
rolling={rolling}
|
||||
onSettled={onSettled}
|
||||
faceUrls={faceUrls}
|
||||
faceUrlSets={faceUrlSets}
|
||||
floorColor={floorColor}
|
||||
floorImage={floorImage}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -273,7 +390,9 @@ export default function Dice({
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setRolling(true);
|
||||
rollDice(diceRef.current);
|
||||
for (let i = 0; i < diceCount; i++) {
|
||||
rollDice(diceRefs[i].current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Roll
|
||||
@@ -283,9 +402,7 @@ export default function Dice({
|
||||
? '굴리는 중…'
|
||||
: disabled
|
||||
? '이동 중…'
|
||||
: result != null
|
||||
? `결과: ${result}`
|
||||
: '결과: —'}
|
||||
: formatResult(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,86 +4,32 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Dice from './dice';
|
||||
import { Chessman } from './chessman';
|
||||
import ChessmanPiece from './chessman-piece';
|
||||
import {
|
||||
applyLandEvent,
|
||||
type PlaneCell,
|
||||
} from './cell-event';
|
||||
import { applyLandEvent, type PlaneCell } from './cell-event';
|
||||
import { getAssets } from './asset-storage';
|
||||
import {
|
||||
buildPlaneCells,
|
||||
cellColorFor,
|
||||
diceAreaSpan,
|
||||
getOrCreateBoardConfig,
|
||||
} from './board-config';
|
||||
|
||||
const PLANE_SIZE = { width: 9, height: 7 } as const;
|
||||
const STEP_MS = 280;
|
||||
|
||||
const CELL_TYPES = ['한잔', '안주', '모두한잔', '두잔', '벌칙1', '벌칙2'] as const;
|
||||
|
||||
/** 특수 칸 배치 (28칸 기준 인덱스) */
|
||||
const SPECIAL_CELLS: Record<number, { type: string; eventId: string }> = {
|
||||
8: { type: '무인도', eventId: 'island' },
|
||||
14: { type: '역방향', eventId: 'reverse' },
|
||||
22: { type: '비행기', eventId: 'airplane' }, // → 시작(0)
|
||||
};
|
||||
|
||||
function randomType() {
|
||||
return CELL_TYPES[Math.floor(Math.random() * CELL_TYPES.length)];
|
||||
}
|
||||
|
||||
function cellLabel(cell: PlaneCell | undefined, index: number) {
|
||||
if (!cell) return '';
|
||||
return index === 0 ? '시작' : cell.type;
|
||||
}
|
||||
|
||||
function cellColor(cell: PlaneCell, index: number) {
|
||||
if (index === 0) return '#059669';
|
||||
switch (cell.eventId) {
|
||||
case 'island':
|
||||
return '#0f766e';
|
||||
case 'reverse':
|
||||
return '#7c3aed';
|
||||
case 'airplane':
|
||||
return '#0284c7';
|
||||
default:
|
||||
return '#e11d48';
|
||||
}
|
||||
}
|
||||
|
||||
/** 시계방향 둘레 칸. 모서리는 한 번만 넣어서 9x7 기준 28칸이 된다. */
|
||||
function buildPerimeter(width: number, height: number, assignType: () => string): PlaneCell[] {
|
||||
const cells: PlaneCell[] = [];
|
||||
let id = 0;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
cells.push({ id: id++, x, y: 0, type: assignType() });
|
||||
}
|
||||
for (let y = 1; y < height; y++) {
|
||||
cells.push({ id: id++, x: width - 1, y, type: assignType() });
|
||||
}
|
||||
for (let x = width - 2; x >= 0; x--) {
|
||||
cells.push({ id: id++, x, y: height - 1, type: assignType() });
|
||||
}
|
||||
for (let y = height - 2; y >= 1; y--) {
|
||||
cells.push({ id: id++, x: 0, y, type: assignType() });
|
||||
}
|
||||
|
||||
if (cells[0]) {
|
||||
cells[0].type = '시작';
|
||||
cells[0].eventId = null;
|
||||
}
|
||||
|
||||
for (const [indexText, special] of Object.entries(SPECIAL_CELLS)) {
|
||||
const index = Number(indexText);
|
||||
if (cells[index]) {
|
||||
cells[index].type = special.type;
|
||||
cells[index].eventId = special.eventId;
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
export default function DicePlane() {
|
||||
const [dicePlaneArray, setDicePlaneArray] = useState(() =>
|
||||
buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, () => 'none'),
|
||||
const initial = getOrCreateBoardConfig();
|
||||
const [planeSize, setPlaneSize] = useState({
|
||||
width: initial.width,
|
||||
height: initial.height,
|
||||
});
|
||||
const [dicePlaneArray, setDicePlaneArray] = useState<PlaneCell[]>(() =>
|
||||
buildPlaneCells(initial),
|
||||
);
|
||||
const chessmanRef = useRef(new Chessman(28));
|
||||
const chessmanRef = useRef(new Chessman(initial.cells.length));
|
||||
const boardRef = useRef(dicePlaneArray);
|
||||
const [chessmanIndex, setChessmanIndex] = useState(0);
|
||||
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
||||
@@ -96,9 +42,11 @@ export default function DicePlane() {
|
||||
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const cells = buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, randomType);
|
||||
const board = getOrCreateBoardConfig();
|
||||
const cells = buildPlaneCells(board);
|
||||
chessmanRef.current = new Chessman(cells.length, 0);
|
||||
boardRef.current = cells;
|
||||
setPlaneSize({ width: board.width, height: board.height });
|
||||
setChessmanIndex(0);
|
||||
setChessmanSrc(getAssets().chessman);
|
||||
setHopKey(0);
|
||||
@@ -180,15 +128,15 @@ export default function DicePlane() {
|
||||
);
|
||||
|
||||
const onDiceRolled = useCallback(
|
||||
(value: number) => {
|
||||
const roll = Math.max(0, Math.trunc(value));
|
||||
(rollResult: { sum: number; faces: number[] }) => {
|
||||
const roll = Math.max(0, Math.trunc(rollResult.sum));
|
||||
if (roll === 0) return;
|
||||
|
||||
setLandedLabel(null);
|
||||
const man = chessmanRef.current;
|
||||
|
||||
if (man.isOnIsland) {
|
||||
const outcome = man.resolveIslandRoll(roll);
|
||||
const outcome = man.resolveIslandRoll(roll, rollResult.faces);
|
||||
setIslandTurnsLeft(man.islandTurnsLeft);
|
||||
if (outcome === 'wait') {
|
||||
showBanner(
|
||||
@@ -198,7 +146,6 @@ export default function DicePlane() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// escape with 6
|
||||
showBanner('무인도 탈출!');
|
||||
}
|
||||
|
||||
@@ -208,14 +155,15 @@ export default function DicePlane() {
|
||||
);
|
||||
|
||||
const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0];
|
||||
const diceSpan = diceAreaSpan(planeSize.width, planeSize.height);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${PLANE_SIZE.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${PLANE_SIZE.height}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${planeSize.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${planeSize.height}, minmax(0, 1fr))`,
|
||||
gap: 'clamp(6px, 1vw, 12px)',
|
||||
padding: 'clamp(8px, 1.2vw, 16px)',
|
||||
width: '100%',
|
||||
@@ -235,7 +183,10 @@ export default function DicePlane() {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: cellColor(plane, index),
|
||||
backgroundColor: cellColorFor(plane.eventId, index),
|
||||
backgroundImage: plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
color: 'white',
|
||||
borderRadius: 8,
|
||||
fontSize: 'clamp(11px, 1.6vw, 16px)',
|
||||
@@ -250,6 +201,7 @@ export default function DicePlane() {
|
||||
transform: isLanded ? 'scale(1.04)' : undefined,
|
||||
transition: 'box-shadow 180ms ease, transform 180ms ease',
|
||||
zIndex: isLanded ? 4 : undefined,
|
||||
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
|
||||
}}
|
||||
>
|
||||
{isStart ? '시작' : plane.type}
|
||||
@@ -268,8 +220,8 @@ export default function DicePlane() {
|
||||
|
||||
<div
|
||||
style={{
|
||||
gridColumn: '2 / 4',
|
||||
gridRow: '2 / 4',
|
||||
gridColumn: `2 / ${2 + diceSpan}`,
|
||||
gridRow: `2 / ${2 + diceSpan}`,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
zIndex: 6,
|
||||
|
||||
383
app/page.tsx
383
app/page.tsx
@@ -5,21 +5,43 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ASSET_ASPECT,
|
||||
DEFAULT_ASSETS,
|
||||
DEFAULT_DICE_FLOOR_COLOR,
|
||||
DICE_FACES,
|
||||
type DiceCount,
|
||||
type DiceFace,
|
||||
type DiceSlot,
|
||||
type DrinkmarbleAssets,
|
||||
clearAllAssets,
|
||||
clearBackgroundAsset,
|
||||
clearChessmanAsset,
|
||||
clearDiceFaceAsset,
|
||||
clearDiceFloorColor,
|
||||
clearDiceFloorImage,
|
||||
fileToAssetDataUrl,
|
||||
getAssets,
|
||||
setBackgroundAsset,
|
||||
setChessmanAsset,
|
||||
setDiceCount,
|
||||
setDiceFaceAsset,
|
||||
setDiceFloorColor,
|
||||
setDiceFloorImage,
|
||||
} from './game/asset-storage';
|
||||
import { exportAssetPack, importAssetPack } from './game/asset-pack';
|
||||
|
||||
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
|
||||
import {
|
||||
type BoardConfig,
|
||||
CELL_EVENT_OPTIONS,
|
||||
CELL_TYPE_PRESETS,
|
||||
MAX_PLANE_SIZE,
|
||||
MIN_PLANE_SIZE,
|
||||
buildPerimeterGeometry,
|
||||
cellColorFor,
|
||||
clearBoardConfig,
|
||||
getOrCreateBoardConfig,
|
||||
perimeterLength,
|
||||
randomizeAndSaveBoard,
|
||||
resizeBoardConfig,
|
||||
updateBoardCell,
|
||||
} from './game/board-config';
|
||||
|
||||
function AssetThumb({
|
||||
src,
|
||||
@@ -81,8 +103,221 @@ function AssetThumb({
|
||||
);
|
||||
}
|
||||
|
||||
function BoardEditor({
|
||||
board,
|
||||
onChange,
|
||||
}: {
|
||||
board: BoardConfig;
|
||||
onChange: (next: BoardConfig) => void;
|
||||
}) {
|
||||
const geo = buildPerimeterGeometry(board.width, board.height);
|
||||
const [selected, setSelected] = useState(1);
|
||||
const cellImageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected >= board.cells.length) {
|
||||
setSelected(Math.min(1, board.cells.length - 1));
|
||||
}
|
||||
}, [board.cells.length, selected]);
|
||||
|
||||
const selectedCell = board.cells[selected];
|
||||
const cellCount = perimeterLength(board.width, board.height);
|
||||
|
||||
const applySize = (width: number, height: number) => {
|
||||
onChange(resizeBoardConfig(width, height, board));
|
||||
};
|
||||
|
||||
const onCellImagePick = async (file: File) => {
|
||||
const aspect = ASSET_ASPECT.cell;
|
||||
const dataUrl = await fileToAssetDataUrl(file, {
|
||||
aspectW: aspect.w,
|
||||
aspectH: aspect.h,
|
||||
maxW: 512,
|
||||
maxH: 512,
|
||||
quality: 0.88,
|
||||
});
|
||||
onChange(updateBoardCell(selected, { imageSrc: dataUrl }, board));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
|
||||
<p className="text-[11px] text-white/50">
|
||||
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지를 설정합니다. 0번(시작) 이름은 고정입니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
|
||||
onClick={() => onChange(randomizeAndSaveBoard(board.width, board.height, board))}
|
||||
>
|
||||
랜덤 다시 배치
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg bg-black/25 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">가로 (칸)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_PLANE_SIZE}
|
||||
max={MAX_PLANE_SIZE}
|
||||
value={board.width}
|
||||
onChange={(e) => applySize(Number(e.target.value), board.height)}
|
||||
className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">세로 (칸)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_PLANE_SIZE}
|
||||
max={MAX_PLANE_SIZE}
|
||||
value={board.height}
|
||||
onChange={(e) => applySize(board.width, Number(e.target.value))}
|
||||
className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<p className="pb-1.5 text-xs text-white/50">
|
||||
{MIN_PLANE_SIZE}~{MAX_PLANE_SIZE} · 둘레 {cellCount}칸
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid gap-1"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${board.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${board.height}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{geo.map((g, index) => {
|
||||
const cell = board.cells[index];
|
||||
const active = selected === index;
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
title={`#${index} ${cell?.type ?? ''}`}
|
||||
onClick={() => setSelected(index)}
|
||||
className="min-h-10 rounded px-0.5 text-[10px] font-semibold text-white sm:text-[11px]"
|
||||
style={{
|
||||
gridColumn: g.x + 1,
|
||||
gridRow: g.y + 1,
|
||||
backgroundColor: cellColorFor(cell?.eventId, index),
|
||||
backgroundImage: cell?.imageSrc ? `url(${cell.imageSrc})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
textShadow: cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
|
||||
outline: active ? '2px solid #fde68a' : undefined,
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
>
|
||||
{index === 0 ? '시작' : cell?.type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedCell && (
|
||||
<div className="grid gap-3 rounded-lg bg-black/25 p-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">칸 #{selected} 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
list="cell-type-presets"
|
||||
disabled={selected === 0}
|
||||
value={selected === 0 ? '시작' : selectedCell.type}
|
||||
onChange={(e) => onChange(updateBoardCell(selected, { type: e.target.value }, board))}
|
||||
className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
/>
|
||||
<datalist id="cell-type-presets">
|
||||
{CELL_TYPE_PRESETS.map((t) => (
|
||||
<option key={t} value={t} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">효과</label>
|
||||
<select
|
||||
disabled={selected === 0}
|
||||
value={selectedCell.eventId ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onChange(
|
||||
updateBoardCell(
|
||||
selected,
|
||||
{ eventId: value === '' ? null : value },
|
||||
board,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{CELL_EVENT_OPTIONS.map((opt) => (
|
||||
<option key={String(opt.id)} value={opt.id ?? ''}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs text-white/60">
|
||||
칸 이미지 ({ASSET_ASPECT.cell.label} · 선택)
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-white/15 px-2 py-1 text-xs text-white hover:bg-white/25"
|
||||
onClick={() => cellImageInputRef.current?.click()}
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
|
||||
onClick={() =>
|
||||
onChange(updateBoardCell(selected, { imageSrc: null }, board))
|
||||
}
|
||||
>
|
||||
제거
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="h-20 w-20 overflow-hidden rounded-lg bg-black/40"
|
||||
style={{
|
||||
backgroundColor: cellColorFor(selectedCell.eventId, selected),
|
||||
backgroundImage: selectedCell.imageSrc
|
||||
? `url(${selectedCell.imageSrc})`
|
||||
: undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cellImageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void onCellImagePick(file).catch(() => undefined);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
|
||||
const [board, setBoard] = useState<BoardConfig | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -90,6 +325,7 @@ export default function Home() {
|
||||
|
||||
const reload = () => {
|
||||
setAssets(getAssets());
|
||||
setBoard(getOrCreateBoardConfig());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,21 +333,27 @@ export default function Home() {
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
const save = async (kind: 'background' | 'chessman' | DiceFace, file: File) => {
|
||||
const save = async (
|
||||
kind: 'background' | 'chessman' | 'diceFloor' | { slot: DiceSlot; face: DiceFace },
|
||||
file: File,
|
||||
) => {
|
||||
try {
|
||||
const aspectKind = kind === 'background' || kind === 'chessman' ? kind : 'dice';
|
||||
const aspectKind =
|
||||
kind === 'background' || kind === 'chessman' || kind === 'diceFloor' ? kind : 'dice';
|
||||
const aspect = ASSET_ASPECT[aspectKind];
|
||||
const isBg = kind === 'background';
|
||||
const isFloor = kind === 'diceFloor';
|
||||
const dataUrl = await fileToAssetDataUrl(file, {
|
||||
aspectW: aspect.w,
|
||||
aspectH: aspect.h,
|
||||
maxW: isBg ? 1920 : 512,
|
||||
maxH: isBg ? 1080 : 512,
|
||||
quality: isBg ? 0.85 : 0.9,
|
||||
maxW: isBg ? 1920 : isFloor ? 1024 : 512,
|
||||
maxH: isBg ? 1080 : isFloor ? 1024 : 512,
|
||||
quality: isBg || isFloor ? 0.85 : 0.9,
|
||||
});
|
||||
if (kind === 'background') setBackgroundAsset(dataUrl);
|
||||
else if (kind === 'chessman') setChessmanAsset(dataUrl);
|
||||
else setDiceFaceAsset(kind, dataUrl);
|
||||
else if (kind === 'diceFloor') setDiceFloorImage(dataUrl);
|
||||
else setDiceFaceAsset(kind.slot, kind.face, dataUrl);
|
||||
reload();
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
@@ -123,7 +365,7 @@ export default function Home() {
|
||||
try {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
await exportAssetPack(assets);
|
||||
await exportAssetPack(assets, board ?? getOrCreateBoardConfig());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -153,9 +395,8 @@ export default function Home() {
|
||||
<p className="text-sm text-white/60">온라인 주루마블</p>
|
||||
<h1 className="mt-1 text-4xl font-bold tracking-tight">Drinkmarble</h1>
|
||||
<p className="mt-2 max-w-md text-sm text-white/65">
|
||||
이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다.
|
||||
값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙
|
||||
크롭됩니다.
|
||||
이미지와 판 칸 설정은 서버가 아니라 이 브라우저 localStorage에만 저장됩니다.
|
||||
처음이면 칸은 랜덤으로 채워집니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
@@ -166,12 +407,12 @@ export default function Home() {
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{!ready ? (
|
||||
{!ready || !board ? (
|
||||
<p className="text-white/50">불러오는 중…</p>
|
||||
) : (
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">커스텀 이미지</h2>
|
||||
<h2 className="text-lg font-semibold">커스텀 설정</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -195,11 +436,12 @@ export default function Home() {
|
||||
className="rounded bg-white/10 px-3 py-1.5 text-xs text-white/80 hover:bg-white/20 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
clearAllAssets();
|
||||
clearBoardConfig();
|
||||
reload();
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
전부 기본값으로
|
||||
전부 초기화
|
||||
</button>
|
||||
<input
|
||||
ref={packInputRef}
|
||||
@@ -215,13 +457,16 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-white/45">
|
||||
내보내기 ZIP 구조: manifest.json, background.*, chessman.*, dice/1.* … 6.*
|
||||
ZIP: manifest.json, board.json, cells/N.*, background.*, chessman.*,
|
||||
dice/1|2/면.*, dice-floor.*
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
|
||||
)}
|
||||
|
||||
<BoardEditor board={board} onChange={setBoard} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AssetThumb
|
||||
label="배경 (background)"
|
||||
@@ -248,30 +493,114 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white/80">
|
||||
주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label})
|
||||
</h3>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-medium text-white/80">주사위 설정</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white/50">개수</span>
|
||||
{([1, 2] as DiceCount[]).map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
className={`rounded px-3 py-1 text-xs font-medium ${
|
||||
assets.diceCount === count
|
||||
? 'bg-amber-500 text-stone-950'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDiceCount(count);
|
||||
reload();
|
||||
}}
|
||||
>
|
||||
{count}개
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">주사위 판(바닥)</p>
|
||||
<p className="text-[11px] text-white/50">
|
||||
색상 또는 이미지({ASSET_ASPECT.diceFloor.label}) · 이미지가 있으면 색 대신 사용
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
|
||||
onClick={() => {
|
||||
clearDiceFloorColor();
|
||||
clearDiceFloorImage();
|
||||
reload();
|
||||
}}
|
||||
>
|
||||
기본
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex flex-col gap-1 text-xs text-white/60">
|
||||
바닥 색
|
||||
<input
|
||||
type="color"
|
||||
value={assets.diceFloorColor || DEFAULT_DICE_FLOOR_COLOR}
|
||||
onChange={(e) => {
|
||||
setDiceFloorColor(e.target.value);
|
||||
reload();
|
||||
}}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-white/15 bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
<AssetThumb
|
||||
label="바닥 이미지"
|
||||
ratioLabel={ASSET_ASPECT.diceFloor.label}
|
||||
previewClassName="aspect-square w-28"
|
||||
src={
|
||||
assets.diceFloorImage ??
|
||||
`data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="${assets.diceFloorColor}"/></svg>`,
|
||||
)}`
|
||||
}
|
||||
onPick={(file) => void save('diceFloor', file)}
|
||||
onReset={() => {
|
||||
clearDiceFloorImage();
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{([1, 2] as DiceSlot[])
|
||||
.filter((slot) => slot === 1 || assets.diceCount === 2)
|
||||
.map((slot) => {
|
||||
const faces = slot === 1 ? assets.dice : assets.dice2;
|
||||
return (
|
||||
<div key={slot} className="mb-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-white/70">
|
||||
{assets.diceCount === 2 ? `${slot}번 주사위 면` : '주사위 면'} (1~6 ·{' '}
|
||||
{ASSET_ASPECT.dice.label})
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{DICE_FACES.map((face) => (
|
||||
<AssetThumb
|
||||
key={face}
|
||||
key={`${slot}-${face}`}
|
||||
label={`${face}면`}
|
||||
ratioLabel={ASSET_ASPECT.dice.label}
|
||||
previewClassName="aspect-square w-full"
|
||||
src={assets.dice[face]}
|
||||
onPick={(file) => void save(face, file)}
|
||||
src={faces[face]}
|
||||
onPick={(file) => void save({ slot, face }, file)}
|
||||
onReset={() => {
|
||||
clearDiceFaceAsset(face);
|
||||
clearDiceFaceAsset(slot, face);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-white/45">
|
||||
이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다.
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user