커스텀 필요한 항목들 모두 커스텀 가능하도록 수정.
This commit is contained in:
@@ -1,23 +1,44 @@
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import {
|
import {
|
||||||
|
DICE_FACES,
|
||||||
type DiceFace,
|
type DiceFace,
|
||||||
|
type DiceSlot,
|
||||||
type DrinkmarbleAssets,
|
type DrinkmarbleAssets,
|
||||||
getAssets,
|
getAssets,
|
||||||
setBackgroundAsset,
|
setBackgroundAsset,
|
||||||
setChessmanAsset,
|
setChessmanAsset,
|
||||||
|
setDiceCount,
|
||||||
setDiceFaceAsset,
|
setDiceFaceAsset,
|
||||||
|
setDiceFloorColor,
|
||||||
|
setDiceFloorImage,
|
||||||
} from './asset-storage';
|
} from './asset-storage';
|
||||||
|
import {
|
||||||
|
type BoardConfig,
|
||||||
|
getOrCreateBoardConfig,
|
||||||
|
normalizeBoardConfig,
|
||||||
|
setBoardConfig,
|
||||||
|
} from './board-config';
|
||||||
|
|
||||||
const PACK_VERSION = 1;
|
const PACK_VERSION = 4;
|
||||||
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
|
const DICE_SLOTS: DiceSlot[] = [1, 2];
|
||||||
|
|
||||||
type PackManifest = {
|
type PackManifest = {
|
||||||
version: number;
|
version: number;
|
||||||
app: 'drinkmarble';
|
app: 'drinkmarble';
|
||||||
|
settings?: {
|
||||||
|
diceCount?: 1 | 2;
|
||||||
|
diceFloorColor?: string;
|
||||||
|
};
|
||||||
files: {
|
files: {
|
||||||
background?: string;
|
background?: string;
|
||||||
chessman?: string;
|
chessman?: string;
|
||||||
|
diceFloor?: string;
|
||||||
|
/** 하위 호환: 단일 주사위 면 */
|
||||||
dice?: Partial<Record<DiceFace, 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으로 내보낸다.
|
* 애셋 + 보드 설정을 ZIP으로 내보낸다.
|
||||||
* drinkmarble-pack.zip
|
|
||||||
* ├── manifest.json
|
* ├── manifest.json
|
||||||
|
* ├── board.json
|
||||||
|
* ├── cells/N.*
|
||||||
* ├── background.*
|
* ├── background.*
|
||||||
* ├── chessman.*
|
* ├── 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 zip = new JSZip();
|
||||||
const files: PackManifest['files'] = {};
|
const files: PackManifest['files'] = {};
|
||||||
|
const normalized = normalizeBoardConfig(board);
|
||||||
|
|
||||||
const bgBlob = await srcToBlob(assets.background);
|
const bgBlob = await srcToBlob(assets.background);
|
||||||
const bgName = `background.${extFromBlob(bgBlob)}`;
|
const bgName = `background.${extFromBlob(bgBlob)}`;
|
||||||
@@ -77,17 +115,52 @@ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
|
|||||||
zip.file(manName, manBlob);
|
zip.file(manName, manBlob);
|
||||||
files.chessman = manName;
|
files.chessman = manName;
|
||||||
|
|
||||||
files.dice = {};
|
if (assets.diceFloorImage) {
|
||||||
for (const face of DICE_FACES) {
|
const floorBlob = await srcToBlob(assets.diceFloorImage);
|
||||||
const faceBlob = await srcToBlob(assets.dice[face]);
|
const floorName = `dice-floor.${extFromBlob(floorBlob)}`;
|
||||||
const faceName = `dice/${face}.${extFromBlob(faceBlob)}`;
|
zip.file(floorName, floorBlob);
|
||||||
zip.file(faceName, faceBlob);
|
files.diceFloor = floorName;
|
||||||
files.dice[face] = faceName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
const manifest: PackManifest = {
|
||||||
version: PACK_VERSION,
|
version: PACK_VERSION,
|
||||||
app: 'drinkmarble',
|
app: 'drinkmarble',
|
||||||
|
settings: {
|
||||||
|
diceCount: assets.diceCount,
|
||||||
|
diceFloorColor: assets.diceFloorColor,
|
||||||
|
},
|
||||||
files,
|
files,
|
||||||
};
|
};
|
||||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
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`);
|
downloadBlob(zipBlob, `drinkmarble-pack-${stamp}.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function importAssetPack(file: File): Promise<{
|
||||||
* ZIP 팩을 읽어 localStorage에 반영한다.
|
assets: DrinkmarbleAssets;
|
||||||
* manifest.json이 없으면 관례적 파일명(background.*, chessman.*, dice/1.*)을 시도한다.
|
board: BoardConfig;
|
||||||
*/
|
}> {
|
||||||
export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
|
||||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||||
throw new Error('ZIP 파일만 가져올 수 있습니다.');
|
throw new Error('ZIP 파일만 가져올 수 있습니다.');
|
||||||
}
|
}
|
||||||
@@ -135,7 +207,6 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
|||||||
const entry = findEntry(path);
|
const entry = findEntry(path);
|
||||||
if (entry) return entry;
|
if (entry) return entry;
|
||||||
}
|
}
|
||||||
// 확장자만 다른 경우
|
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
const base = path.replace(/\.[^.]+$/, '');
|
const base = path.replace(/\.[^.]+$/, '');
|
||||||
const match = Object.keys(zip.files).find(
|
const match = Object.keys(zip.files).find(
|
||||||
@@ -160,6 +231,12 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
|||||||
'chessman.png',
|
'chessman.png',
|
||||||
'chessman.webp',
|
'chessman.webp',
|
||||||
]);
|
]);
|
||||||
|
const floorEntry = resolvePath(manifest?.files.diceFloor, [
|
||||||
|
'dice-floor.jpg',
|
||||||
|
'dice-floor.jpeg',
|
||||||
|
'dice-floor.png',
|
||||||
|
'dice-floor.webp',
|
||||||
|
]);
|
||||||
|
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
|
||||||
@@ -171,23 +248,87 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
|
|||||||
setChessmanAsset(await blobToDataUrl(await manEntry.async('blob')));
|
setChessmanAsset(await blobToDataUrl(await manEntry.async('blob')));
|
||||||
imported += 1;
|
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) {
|
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}.jpg`,
|
||||||
`dice/${face}.jpeg`,
|
`dice/${face}.jpeg`,
|
||||||
`dice/${face}.png`,
|
`dice/${face}.png`,
|
||||||
`dice/${face}.webp`,
|
`dice/${face}.webp`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]);
|
]);
|
||||||
if (faceEntry) {
|
if (faceEntry) {
|
||||||
setDiceFaceAsset(face, await blobToDataUrl(await faceEntry.async('blob')));
|
setDiceFaceAsset(slot, face, await blobToDataUrl(await faceEntry.async('blob')));
|
||||||
imported += 1;
|
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 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 = {
|
export type DrinkmarbleAssets = {
|
||||||
background: string;
|
background: string;
|
||||||
chessman: 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 STORAGE_KEY = 'drinkmarble.assets';
|
||||||
|
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
export const DEFAULT_ASSETS: DrinkmarbleAssets = {
|
const DEFAULT_DICE: DiceFaceMap = {
|
||||||
background: '/background/default.png',
|
|
||||||
chessman: '/chessman/default.png',
|
|
||||||
dice: {
|
|
||||||
1: '/dice/1.png',
|
1: '/dice/1.png',
|
||||||
2: '/dice/2.png',
|
2: '/dice/2.png',
|
||||||
3: '/dice/3.png',
|
3: '/dice/3.png',
|
||||||
4: '/dice/4.png',
|
4: '/dice/4.png',
|
||||||
5: '/dice/5.png',
|
5: '/dice/5.png',
|
||||||
6: '/dice/6.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<{
|
type StoredAssets = Partial<{
|
||||||
background: string;
|
background: string;
|
||||||
chessman: 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 {
|
function readStored(): StoredAssets {
|
||||||
@@ -52,20 +76,47 @@ function writeStored(next: StoredAssets) {
|
|||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
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 기본 경로를 쓴다. */
|
/** 저장된 값이 있으면 쓰고, 없으면 서버 public 기본 경로를 쓴다. */
|
||||||
export function getAssets(): DrinkmarbleAssets {
|
export function getAssets(): DrinkmarbleAssets {
|
||||||
const stored = readStored();
|
const stored = readStored();
|
||||||
return {
|
return {
|
||||||
background: stored.background || DEFAULT_ASSETS.background,
|
background: stored.background || DEFAULT_ASSETS.background,
|
||||||
chessman: stored.chessman || DEFAULT_ASSETS.chessman,
|
chessman: stored.chessman || DEFAULT_ASSETS.chessman,
|
||||||
dice: {
|
diceCount: normalizeDiceCount(stored.diceCount),
|
||||||
1: stored.dice?.[1] || DEFAULT_ASSETS.dice[1],
|
dice: mergeDiceFaces(stored.dice, DEFAULT_ASSETS.dice),
|
||||||
2: stored.dice?.[2] || DEFAULT_ASSETS.dice[2],
|
dice2: mergeDiceFaces(stored.dice2, DEFAULT_ASSETS.dice2),
|
||||||
3: stored.dice?.[3] || DEFAULT_ASSETS.dice[3],
|
diceFloorColor: normalizeFloorColor(stored.diceFloorColor),
|
||||||
4: stored.dice?.[4] || DEFAULT_ASSETS.dice[4],
|
diceFloorImage:
|
||||||
5: stored.dice?.[5] || DEFAULT_ASSETS.dice[5],
|
typeof stored.diceFloorImage === 'string' && stored.diceFloorImage.length > 0
|
||||||
6: stored.dice?.[6] || DEFAULT_ASSETS.dice[6],
|
? stored.diceFloorImage
|
||||||
},
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,14 +130,36 @@ export function setChessmanAsset(src: string) {
|
|||||||
writeStored({ ...stored, chessman: src });
|
writeStored({ ...stored, chessman: src });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDiceFaceAsset(face: DiceFace, src: string) {
|
export function setDiceCount(count: DiceCount) {
|
||||||
const stored = readStored();
|
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({
|
writeStored({
|
||||||
...stored,
|
...stored,
|
||||||
dice: { ...stored.dice, [face]: src },
|
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() {
|
export function clearBackgroundAsset() {
|
||||||
const stored = readStored();
|
const stored = readStored();
|
||||||
const { background: _removed, ...rest } = stored;
|
const { background: _removed, ...rest } = stored;
|
||||||
@@ -99,31 +172,51 @@ export function clearChessmanAsset() {
|
|||||||
writeStored(rest);
|
writeStored(rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearDiceFaceAsset(face: DiceFace) {
|
export function clearDiceFaceAsset(slot: DiceSlot, face: DiceFace) {
|
||||||
const stored = readStored();
|
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;
|
if (!stored.dice) return;
|
||||||
const dice = { ...stored.dice };
|
const dice = { ...stored.dice };
|
||||||
delete dice[face];
|
delete dice[face];
|
||||||
writeStored({ ...stored, dice });
|
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() {
|
export function clearAllAssets() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** boxGeometry material 순서: +X,-X,+Y,-Y,+Z,-Z = 2,5,1,6,3,4 */
|
/** boxGeometry material 순서: +X,-X,+Y,-Y,+Z,-Z = 2,5,1,6,3,4 */
|
||||||
export function getDiceFaceTextureList(assets: DrinkmarbleAssets = getAssets()): string[] {
|
export function getDiceFaceTextureList(
|
||||||
return [
|
faces: DiceFaceMap = getAssets().dice,
|
||||||
assets.dice[2],
|
): string[] {
|
||||||
assets.dice[5],
|
return [faces[2], faces[5], faces[1], faces[6], faces[3], faces[4]];
|
||||||
assets.dice[1],
|
|
||||||
assets.dice[6],
|
|
||||||
assets.dice[3],
|
|
||||||
assets.dice[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) {
|
export function isCustomAsset(src: string, fallback: string) {
|
||||||
return src !== fallback && src.startsWith('data:');
|
return src !== fallback && src.startsWith('data:');
|
||||||
}
|
}
|
||||||
@@ -133,6 +226,8 @@ export const ASSET_ASPECT = {
|
|||||||
background: { w: 16, h: 9, label: '16:9' },
|
background: { w: 16, h: 9, label: '16:9' },
|
||||||
chessman: { w: 1, h: 1, label: '1:1' },
|
chessman: { w: 1, h: 1, label: '1:1' },
|
||||||
dice: { 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;
|
} as const;
|
||||||
|
|
||||||
export type AssetKind = keyof typeof ASSET_ASPECT;
|
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;
|
type: string;
|
||||||
/** 효과가 있을 때만 설정. 없으면 착지 시 아무 일도 없음 */
|
/** 효과가 있을 때만 설정. 없으면 착지 시 아무 일도 없음 */
|
||||||
eventId?: string | null;
|
eventId?: string | null;
|
||||||
|
/** 칸 이미지 (없으면 텍스트만) */
|
||||||
|
imageSrc?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TurnContext = {
|
export type TurnContext = {
|
||||||
|
|||||||
@@ -46,14 +46,15 @@ export class Chessman {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 무인도 상태에서의 주사위 처리.
|
* 무인도 상태에서의 주사위 처리.
|
||||||
* - escape: 6(또는 escapeOn)으로 탈출 → 이후 정상 이동
|
* - escape: escapeOn(기본 6)이 한 면이라도 나오면 탈출 → 이후 정상 이동
|
||||||
* - wait: 턴 소모 후 대기 (turnsLeft 0이면 자동 해제, 이번 턴은 이동 없음)
|
* - wait: 턴 소모 후 대기 (turnsLeft 0이면 자동 해제, 이번 턴은 이동 없음)
|
||||||
* - free: 이미 무인도 아님
|
* - free: 이미 무인도 아님
|
||||||
*/
|
*/
|
||||||
resolveIslandRoll(roll: number): 'escape' | 'wait' | 'free' {
|
resolveIslandRoll(roll: number, faces?: number[]): 'escape' | 'wait' | 'free' {
|
||||||
if (!this.island) return '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;
|
this.island = null;
|
||||||
return 'escape';
|
return 'escape';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,15 @@ import {
|
|||||||
TextureLoader,
|
TextureLoader,
|
||||||
Vector3,
|
Vector3,
|
||||||
type OrthographicCamera,
|
type OrthographicCamera,
|
||||||
|
type Texture,
|
||||||
} from 'three';
|
} 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_SIZE = 6;
|
||||||
const FLOOR_HALF = FLOOR_SIZE / 2;
|
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_SPEED_SQ = 0.05;
|
||||||
const STILL_FRAMES = 18;
|
const STILL_FRAMES = 18;
|
||||||
|
|
||||||
// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z (기본 경로, 런타임에 localStorage로 교체)
|
const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice);
|
||||||
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;
|
|
||||||
|
|
||||||
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
||||||
const DICE_FACES = [
|
const DICE_FACES = [
|
||||||
@@ -51,6 +50,13 @@ const _quat = new Quaternion();
|
|||||||
const _normal = new Vector3();
|
const _normal = new Vector3();
|
||||||
const WORLD_UP = new Vector3(0, 1, 0);
|
const WORLD_UP = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
export type DiceRollResult = {
|
||||||
|
/** 이동에 쓰는 합 */
|
||||||
|
sum: number;
|
||||||
|
/** 각 주사위 윗면 */
|
||||||
|
faces: number[];
|
||||||
|
};
|
||||||
|
|
||||||
function readTopFace(body: RapierRigidBody): number {
|
function readTopFace(body: RapierRigidBody): number {
|
||||||
const r = body.rotation();
|
const r = body.rotation();
|
||||||
_quat.set(r.x, r.y, r.z, r.w);
|
_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({
|
function DiceSettleWatcher({
|
||||||
diceRef,
|
diceRefs,
|
||||||
rolling,
|
rolling,
|
||||||
onSettled,
|
onSettled,
|
||||||
}: {
|
}: {
|
||||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||||
rolling: boolean;
|
rolling: boolean;
|
||||||
onSettled: (value: number) => void;
|
onSettled: (result: DiceRollResult) => void;
|
||||||
}) {
|
}) {
|
||||||
const stillFrames = useRef(0);
|
const stillFrames = useRef(0);
|
||||||
const reported = useRef(false);
|
const reported = useRef(false);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const body = diceRef.current;
|
if (!rolling) {
|
||||||
if (!body || !rolling) {
|
|
||||||
stillFrames.current = 0;
|
stillFrames.current = 0;
|
||||||
reported.current = false;
|
reported.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lv = body.linvel();
|
const bodies = diceRefs.map((ref) => ref.current);
|
||||||
const av = body.angvel();
|
if (bodies.some((body) => !body)) return;
|
||||||
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;
|
|
||||||
|
|
||||||
if (speedSq < STILL_SPEED_SQ || body.isSleeping()) {
|
if (bodies.every((body) => isBodyStill(body!))) {
|
||||||
stillFrames.current += 1;
|
stillFrames.current += 1;
|
||||||
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
|
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
|
||||||
reported.current = true;
|
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 {
|
} else {
|
||||||
stillFrames.current = 0;
|
stillFrames.current = 0;
|
||||||
@@ -167,16 +241,24 @@ function DiceSettleWatcher({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RapierDiceExample({
|
function RapierDiceExample({
|
||||||
diceRef,
|
diceRefs,
|
||||||
|
diceCount,
|
||||||
rolling,
|
rolling,
|
||||||
onSettled,
|
onSettled,
|
||||||
faceUrls,
|
faceUrlSets,
|
||||||
|
floorColor,
|
||||||
|
floorImage,
|
||||||
}: {
|
}: {
|
||||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
diceRefs: React.RefObject<RapierRigidBody | null>[];
|
||||||
|
diceCount: DiceCount;
|
||||||
rolling: boolean;
|
rolling: boolean;
|
||||||
onSettled: (value: number) => void;
|
onSettled: (result: DiceRollResult) => void;
|
||||||
faceUrls: string[];
|
faceUrlSets: string[][];
|
||||||
|
floorColor: string;
|
||||||
|
floorImage: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
orthographic
|
orthographic
|
||||||
@@ -189,28 +271,39 @@ function RapierDiceExample({
|
|||||||
<directionalLight position={[4, 10, 2]} intensity={0.9} />
|
<directionalLight position={[4, 10, 2]} intensity={0.9} />
|
||||||
<Physics>
|
<Physics>
|
||||||
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
|
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<FloorSurface
|
||||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
key={`${floorColor}|${floorImage ?? ''}`}
|
||||||
<meshBasicMaterial color="blue" />
|
color={floorColor}
|
||||||
</mesh>
|
imageSrc={floorImage}
|
||||||
|
/>
|
||||||
<CuboidCollider args={[FLOOR_HALF, 0.05, FLOOR_HALF]} position={[0, -0.05, 0]} />
|
<CuboidCollider args={[FLOOR_HALF, 0.05, FLOOR_HALF]} position={[0, -0.05, 0]} />
|
||||||
<InvisibleWalls />
|
<InvisibleWalls />
|
||||||
</RigidBody>
|
</RigidBody>
|
||||||
|
|
||||||
|
{spawnX.map((x, index) => (
|
||||||
<RigidBody
|
<RigidBody
|
||||||
ref={diceRef}
|
key={`die-${index}-${diceCount}`}
|
||||||
|
ref={diceRefs[index]}
|
||||||
type="dynamic"
|
type="dynamic"
|
||||||
position={[0, 4, 0]}
|
position={[x, 4, index === 0 ? 0 : 0.15]}
|
||||||
colliders="cuboid"
|
colliders="cuboid"
|
||||||
restitution={0.2}
|
restitution={0.2}
|
||||||
friction={0.6}
|
friction={0.6}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DiceMesh key={faceUrls.join('|')} faceUrls={faceUrls} />
|
<DiceMesh
|
||||||
|
key={faceUrlSets[index]?.join('|') ?? index}
|
||||||
|
faceUrls={faceUrlSets[index] ?? DEFAULT_DICE_FACE_TEXTURES}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</RigidBody>
|
</RigidBody>
|
||||||
|
))}
|
||||||
|
|
||||||
<DiceSettleWatcher diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
|
<DiceSettleWatcher
|
||||||
|
diceRefs={diceRefs.slice(0, diceCount)}
|
||||||
|
rolling={rolling}
|
||||||
|
onSettled={onSettled}
|
||||||
|
/>
|
||||||
</Physics>
|
</Physics>
|
||||||
</Canvas>
|
</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({
|
export default function Dice({
|
||||||
onRolled,
|
onRolled,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
onRolled?: (value: number) => void;
|
onRolled?: (result: DiceRollResult) => void;
|
||||||
disabled?: boolean;
|
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 [rolling, setRolling] = useState(false);
|
||||||
const [result, setResult] = useState<number | null>(null);
|
const [result, setResult] = useState<DiceRollResult | null>(null);
|
||||||
const [faceUrls, setFaceUrls] = useState<string[]>([...DEFAULT_DICE_FACE_TEXTURES]);
|
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(() => {
|
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(
|
const onSettled = useCallback(
|
||||||
(value: number) => {
|
(next: DiceRollResult) => {
|
||||||
setResult(value);
|
setResult(next);
|
||||||
setRolling(false);
|
setRolling(false);
|
||||||
onRolled?.(value);
|
onRolled?.(next);
|
||||||
},
|
},
|
||||||
[onRolled],
|
[onRolled],
|
||||||
);
|
);
|
||||||
@@ -261,10 +375,13 @@ export default function Dice({
|
|||||||
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
|
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
|
||||||
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
|
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
|
||||||
<RapierDiceExample
|
<RapierDiceExample
|
||||||
diceRef={diceRef}
|
diceRefs={diceRefs}
|
||||||
|
diceCount={diceCount}
|
||||||
rolling={rolling}
|
rolling={rolling}
|
||||||
onSettled={onSettled}
|
onSettled={onSettled}
|
||||||
faceUrls={faceUrls}
|
faceUrlSets={faceUrlSets}
|
||||||
|
floorColor={floorColor}
|
||||||
|
floorImage={floorImage}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -273,7 +390,9 @@ export default function Dice({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setRolling(true);
|
setRolling(true);
|
||||||
rollDice(diceRef.current);
|
for (let i = 0; i < diceCount; i++) {
|
||||||
|
rollDice(diceRefs[i].current);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Roll
|
Roll
|
||||||
@@ -283,9 +402,7 @@ export default function Dice({
|
|||||||
? '굴리는 중…'
|
? '굴리는 중…'
|
||||||
: disabled
|
: disabled
|
||||||
? '이동 중…'
|
? '이동 중…'
|
||||||
: result != null
|
: formatResult(result)}
|
||||||
? `결과: ${result}`
|
|
||||||
: '결과: —'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,86 +4,32 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import Dice from './dice';
|
import Dice from './dice';
|
||||||
import { Chessman } from './chessman';
|
import { Chessman } from './chessman';
|
||||||
import ChessmanPiece from './chessman-piece';
|
import ChessmanPiece from './chessman-piece';
|
||||||
import {
|
import { applyLandEvent, type PlaneCell } from './cell-event';
|
||||||
applyLandEvent,
|
|
||||||
type PlaneCell,
|
|
||||||
} from './cell-event';
|
|
||||||
import { getAssets } from './asset-storage';
|
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 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) {
|
function cellLabel(cell: PlaneCell | undefined, index: number) {
|
||||||
if (!cell) return '';
|
if (!cell) return '';
|
||||||
return index === 0 ? '시작' : cell.type;
|
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() {
|
export default function DicePlane() {
|
||||||
const [dicePlaneArray, setDicePlaneArray] = useState(() =>
|
const initial = getOrCreateBoardConfig();
|
||||||
buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, () => 'none'),
|
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 boardRef = useRef(dicePlaneArray);
|
||||||
const [chessmanIndex, setChessmanIndex] = useState(0);
|
const [chessmanIndex, setChessmanIndex] = useState(0);
|
||||||
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
||||||
@@ -96,9 +42,11 @@ export default function DicePlane() {
|
|||||||
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
chessmanRef.current = new Chessman(cells.length, 0);
|
||||||
boardRef.current = cells;
|
boardRef.current = cells;
|
||||||
|
setPlaneSize({ width: board.width, height: board.height });
|
||||||
setChessmanIndex(0);
|
setChessmanIndex(0);
|
||||||
setChessmanSrc(getAssets().chessman);
|
setChessmanSrc(getAssets().chessman);
|
||||||
setHopKey(0);
|
setHopKey(0);
|
||||||
@@ -180,15 +128,15 @@ export default function DicePlane() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onDiceRolled = useCallback(
|
const onDiceRolled = useCallback(
|
||||||
(value: number) => {
|
(rollResult: { sum: number; faces: number[] }) => {
|
||||||
const roll = Math.max(0, Math.trunc(value));
|
const roll = Math.max(0, Math.trunc(rollResult.sum));
|
||||||
if (roll === 0) return;
|
if (roll === 0) return;
|
||||||
|
|
||||||
setLandedLabel(null);
|
setLandedLabel(null);
|
||||||
const man = chessmanRef.current;
|
const man = chessmanRef.current;
|
||||||
|
|
||||||
if (man.isOnIsland) {
|
if (man.isOnIsland) {
|
||||||
const outcome = man.resolveIslandRoll(roll);
|
const outcome = man.resolveIslandRoll(roll, rollResult.faces);
|
||||||
setIslandTurnsLeft(man.islandTurnsLeft);
|
setIslandTurnsLeft(man.islandTurnsLeft);
|
||||||
if (outcome === 'wait') {
|
if (outcome === 'wait') {
|
||||||
showBanner(
|
showBanner(
|
||||||
@@ -198,7 +146,6 @@ export default function DicePlane() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// escape with 6
|
|
||||||
showBanner('무인도 탈출!');
|
showBanner('무인도 탈출!');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,14 +155,15 @@ export default function DicePlane() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0];
|
const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0];
|
||||||
|
const diceSpan = diceAreaSpan(planeSize.width, planeSize.height);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: `repeat(${PLANE_SIZE.width}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${planeSize.width}, minmax(0, 1fr))`,
|
||||||
gridTemplateRows: `repeat(${PLANE_SIZE.height}, minmax(0, 1fr))`,
|
gridTemplateRows: `repeat(${planeSize.height}, minmax(0, 1fr))`,
|
||||||
gap: 'clamp(6px, 1vw, 12px)',
|
gap: 'clamp(6px, 1vw, 12px)',
|
||||||
padding: 'clamp(8px, 1.2vw, 16px)',
|
padding: 'clamp(8px, 1.2vw, 16px)',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -235,7 +183,10 @@ export default function DicePlane() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: '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',
|
color: 'white',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
fontSize: 'clamp(11px, 1.6vw, 16px)',
|
fontSize: 'clamp(11px, 1.6vw, 16px)',
|
||||||
@@ -250,6 +201,7 @@ export default function DicePlane() {
|
|||||||
transform: isLanded ? 'scale(1.04)' : undefined,
|
transform: isLanded ? 'scale(1.04)' : undefined,
|
||||||
transition: 'box-shadow 180ms ease, transform 180ms ease',
|
transition: 'box-shadow 180ms ease, transform 180ms ease',
|
||||||
zIndex: isLanded ? 4 : undefined,
|
zIndex: isLanded ? 4 : undefined,
|
||||||
|
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isStart ? '시작' : plane.type}
|
{isStart ? '시작' : plane.type}
|
||||||
@@ -268,8 +220,8 @@ export default function DicePlane() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
gridColumn: '2 / 4',
|
gridColumn: `2 / ${2 + diceSpan}`,
|
||||||
gridRow: '2 / 4',
|
gridRow: `2 / ${2 + diceSpan}`,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
zIndex: 6,
|
zIndex: 6,
|
||||||
|
|||||||
383
app/page.tsx
383
app/page.tsx
@@ -5,21 +5,43 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
ASSET_ASPECT,
|
ASSET_ASPECT,
|
||||||
DEFAULT_ASSETS,
|
DEFAULT_ASSETS,
|
||||||
|
DEFAULT_DICE_FLOOR_COLOR,
|
||||||
|
DICE_FACES,
|
||||||
|
type DiceCount,
|
||||||
type DiceFace,
|
type DiceFace,
|
||||||
|
type DiceSlot,
|
||||||
type DrinkmarbleAssets,
|
type DrinkmarbleAssets,
|
||||||
clearAllAssets,
|
clearAllAssets,
|
||||||
clearBackgroundAsset,
|
clearBackgroundAsset,
|
||||||
clearChessmanAsset,
|
clearChessmanAsset,
|
||||||
clearDiceFaceAsset,
|
clearDiceFaceAsset,
|
||||||
|
clearDiceFloorColor,
|
||||||
|
clearDiceFloorImage,
|
||||||
fileToAssetDataUrl,
|
fileToAssetDataUrl,
|
||||||
getAssets,
|
getAssets,
|
||||||
setBackgroundAsset,
|
setBackgroundAsset,
|
||||||
setChessmanAsset,
|
setChessmanAsset,
|
||||||
|
setDiceCount,
|
||||||
setDiceFaceAsset,
|
setDiceFaceAsset,
|
||||||
|
setDiceFloorColor,
|
||||||
|
setDiceFloorImage,
|
||||||
} from './game/asset-storage';
|
} from './game/asset-storage';
|
||||||
import { exportAssetPack, importAssetPack } from './game/asset-pack';
|
import { exportAssetPack, importAssetPack } from './game/asset-pack';
|
||||||
|
import {
|
||||||
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
|
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({
|
function AssetThumb({
|
||||||
src,
|
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() {
|
export default function Home() {
|
||||||
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
|
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
|
||||||
|
const [board, setBoard] = useState<BoardConfig | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -90,6 +325,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
setAssets(getAssets());
|
setAssets(getAssets());
|
||||||
|
setBoard(getOrCreateBoardConfig());
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,21 +333,27 @@ export default function Home() {
|
|||||||
setReady(true);
|
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 {
|
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 aspect = ASSET_ASPECT[aspectKind];
|
||||||
const isBg = kind === 'background';
|
const isBg = kind === 'background';
|
||||||
|
const isFloor = kind === 'diceFloor';
|
||||||
const dataUrl = await fileToAssetDataUrl(file, {
|
const dataUrl = await fileToAssetDataUrl(file, {
|
||||||
aspectW: aspect.w,
|
aspectW: aspect.w,
|
||||||
aspectH: aspect.h,
|
aspectH: aspect.h,
|
||||||
maxW: isBg ? 1920 : 512,
|
maxW: isBg ? 1920 : isFloor ? 1024 : 512,
|
||||||
maxH: isBg ? 1080 : 512,
|
maxH: isBg ? 1080 : isFloor ? 1024 : 512,
|
||||||
quality: isBg ? 0.85 : 0.9,
|
quality: isBg || isFloor ? 0.85 : 0.9,
|
||||||
});
|
});
|
||||||
if (kind === 'background') setBackgroundAsset(dataUrl);
|
if (kind === 'background') setBackgroundAsset(dataUrl);
|
||||||
else if (kind === 'chessman') setChessmanAsset(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();
|
reload();
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -123,7 +365,7 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
await exportAssetPack(assets);
|
await exportAssetPack(assets, board ?? getOrCreateBoardConfig());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.');
|
setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,9 +395,8 @@ export default function Home() {
|
|||||||
<p className="text-sm text-white/60">온라인 주루마블</p>
|
<p className="text-sm text-white/60">온라인 주루마블</p>
|
||||||
<h1 className="mt-1 text-4xl font-bold tracking-tight">Drinkmarble</h1>
|
<h1 className="mt-1 text-4xl font-bold tracking-tight">Drinkmarble</h1>
|
||||||
<p className="mt-2 max-w-md text-sm text-white/65">
|
<p className="mt-2 max-w-md text-sm text-white/65">
|
||||||
이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다.
|
이미지와 판 칸 설정은 서버가 아니라 이 브라우저 localStorage에만 저장됩니다.
|
||||||
값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙
|
처음이면 칸은 랜덤으로 채워집니다.
|
||||||
크롭됩니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -166,12 +407,12 @@ export default function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!ready ? (
|
{!ready || !board ? (
|
||||||
<p className="text-white/50">불러오는 중…</p>
|
<p className="text-white/50">불러오는 중…</p>
|
||||||
) : (
|
) : (
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="rounded bg-white/10 px-3 py-1.5 text-xs text-white/80 hover:bg-white/20 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearAllAssets();
|
clearAllAssets();
|
||||||
|
clearBoardConfig();
|
||||||
reload();
|
reload();
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
전부 기본값으로
|
전부 초기화
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={packInputRef}
|
ref={packInputRef}
|
||||||
@@ -215,13 +457,16 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-white/45">
|
<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>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<AssetThumb
|
<AssetThumb
|
||||||
label="배경 (background)"
|
label="배경 (background)"
|
||||||
@@ -248,30 +493,114 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-medium text-white/80">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label})
|
<h3 className="text-sm font-medium text-white/80">주사위 설정</h3>
|
||||||
</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">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{DICE_FACES.map((face) => (
|
{DICE_FACES.map((face) => (
|
||||||
<AssetThumb
|
<AssetThumb
|
||||||
key={face}
|
key={`${slot}-${face}`}
|
||||||
label={`${face}면`}
|
label={`${face}면`}
|
||||||
ratioLabel={ASSET_ASPECT.dice.label}
|
ratioLabel={ASSET_ASPECT.dice.label}
|
||||||
previewClassName="aspect-square w-full"
|
previewClassName="aspect-square w-full"
|
||||||
src={assets.dice[face]}
|
src={faces[face]}
|
||||||
onPick={(file) => void save(face, file)}
|
onPick={(file) => void save({ slot, face }, file)}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
clearDiceFaceAsset(face);
|
clearDiceFaceAsset(slot, face);
|
||||||
reload();
|
reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<p className="text-xs text-white/45">
|
})}
|
||||||
이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다.
|
</div>
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user