커스텀 필요한 항목들 모두 커스텀 가능하도록 수정.

This commit is contained in:
kimyu
2026-09-10 23:05:17 +09:00
parent 1b99e3961d
commit a505b8a28b
8 changed files with 1201 additions and 232 deletions

View File

@@ -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;
}
for (const face of DICE_FACES) { if (manifest?.settings?.diceFloorColor) {
const faceEntry = resolvePath(manifest?.files.dice?.[face], [ setDiceFloorColor(manifest.settings.diceFloorColor);
`dice/${face}.jpg`, imported += 1;
`dice/${face}.jpeg`, }
`dice/${face}.png`, if (manifest?.settings?.diceCount === 1 || manifest?.settings?.diceCount === 2) {
`dice/${face}.webp`, setDiceCount(manifest.settings.diceCount);
]); imported += 1;
if (faceEntry) { }
setDiceFaceAsset(face, await blobToDataUrl(await faceEntry.async('blob')));
imported += 1; for (const slot of DICE_SLOTS) {
for (const face of DICE_FACES) {
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(slot, face, await blobToDataUrl(await faceEntry.async('blob')));
imported += 1;
}
} }
} }
if (imported === 0) { const boardEntry = resolvePath(manifest?.files.board, ['board.json']);
throw new Error('팩에서 사용할 이미지를 찾지 못했습니다.'); let board = getOrCreateBoardConfig();
if (boardEntry) {
const text = await boardEntry.async('string');
board = normalizeBoardConfig(JSON.parse(text) as BoardConfig);
imported += 1;
} }
return getAssets(); 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() };
} }

View File

@@ -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];
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 = { export const DEFAULT_ASSETS: DrinkmarbleAssets = {
background: '/background/default.png', background: '/background/default.png',
chessman: '/chessman/default.png', chessman: '/chessman/default.png',
dice: { diceCount: 1,
1: '/dice/1.png', dice: { ...DEFAULT_DICE },
2: '/dice/2.png', dice2: { ...DEFAULT_DICE },
3: '/dice/3.png', diceFloorColor: DEFAULT_DICE_FLOOR_COLOR,
4: '/dice/4.png', diceFloorImage: null,
5: '/dice/5.png',
6: '/dice/6.png',
},
}; };
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
View 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;

View File

@@ -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 = {

View File

@@ -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';
} }

View File

@@ -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>
<RigidBody {spawnX.map((x, index) => (
ref={diceRef} <RigidBody
type="dynamic" key={`die-${index}-${diceCount}`}
position={[0, 4, 0]} ref={diceRefs[index]}
colliders="cuboid" type="dynamic"
restitution={0.2} position={[x, 4, index === 0 ? 0 : 0.15]}
friction={0.6} colliders="cuboid"
> restitution={0.2}
<Suspense fallback={null}> friction={0.6}
<DiceMesh key={faceUrls.join('|')} faceUrls={faceUrls} /> >
</Suspense> <Suspense fallback={null}>
</RigidBody> <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> </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>

View File

@@ -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,

View File

@@ -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">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <span className="text-xs text-white/50">개수</span>
{DICE_FACES.map((face) => ( {([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 <AssetThumb
key={face} label="바닥 이미지"
label={`${face}면`} ratioLabel={ASSET_ASPECT.diceFloor.label}
ratioLabel={ASSET_ASPECT.dice.label} previewClassName="aspect-square w-28"
previewClassName="aspect-square w-full" src={
src={assets.dice[face]} assets.diceFloorImage ??
onPick={(file) => void save(face, file)} `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={() => { onReset={() => {
clearDiceFaceAsset(face); clearDiceFloorImage();
reload(); reload();
}} }}
/> />
))} </div>
</div> </div>
</div>
<p className="text-xs text-white/45"> {([1, 2] as DiceSlot[])
이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다. .filter((slot) => slot === 1 || assets.diceCount === 2)
</p> .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={`${slot}-${face}`}
label={`${face}면`}
ratioLabel={ASSET_ASPECT.dice.label}
previewClassName="aspect-square w-full"
src={faces[face]}
onPick={(file) => void save({ slot, face }, file)}
onReset={() => {
clearDiceFaceAsset(slot, face);
reload();
}}
/>
))}
</div>
</div>
);
})}
</div>
</section> </section>
)} )}
</div> </div>