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

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 {
DICE_FACES,
type DiceFace,
type DiceSlot,
type DrinkmarbleAssets,
getAssets,
setBackgroundAsset,
setChessmanAsset,
setDiceCount,
setDiceFaceAsset,
setDiceFloorColor,
setDiceFloorImage,
} from './asset-storage';
import {
type BoardConfig,
getOrCreateBoardConfig,
normalizeBoardConfig,
setBoardConfig,
} from './board-config';
const PACK_VERSION = 1;
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
const PACK_VERSION = 4;
const DICE_SLOTS: DiceSlot[] = [1, 2];
type PackManifest = {
version: number;
app: 'drinkmarble';
settings?: {
diceCount?: 1 | 2;
diceFloorColor?: string;
};
files: {
background?: string;
chessman?: string;
diceFloor?: string;
/** 하위 호환: 단일 주사위 면 */
dice?: Partial<Record<DiceFace, string>>;
/** diceSets[1], diceSets[2] */
diceSets?: Partial<Record<DiceSlot, Partial<Record<DiceFace, string>>>>;
board?: string;
cells?: Record<string, string>;
};
};
@@ -55,17 +76,34 @@ async function blobToDataUrl(blob: Blob): Promise<string> {
});
}
function boardForExport(board: BoardConfig): BoardConfig {
return {
...board,
cells: board.cells.map((cell) => ({
type: cell.type,
eventId: cell.eventId ?? null,
imageSrc: null,
})),
};
}
/**
* 현재 적용 중인 애셋(커스텀 또는 기본 public)을 ZIP으로 내보낸다.
* drinkmarble-pack.zip
* 애셋 + 보드 설정을 ZIP으로 내보낸다.
* ├── manifest.json
* ├── board.json
* ├── cells/N.*
* ├── background.*
* ├── chessman.*
* └── dice/1.* … 6.*
* ├── dice-floor.*
* └── dice/1|2/면.*
*/
export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
export async function exportAssetPack(
assets: DrinkmarbleAssets = getAssets(),
board: BoardConfig = getOrCreateBoardConfig(),
) {
const zip = new JSZip();
const files: PackManifest['files'] = {};
const normalized = normalizeBoardConfig(board);
const bgBlob = await srcToBlob(assets.background);
const bgName = `background.${extFromBlob(bgBlob)}`;
@@ -77,17 +115,52 @@ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
zip.file(manName, manBlob);
files.chessman = manName;
files.dice = {};
for (const face of DICE_FACES) {
const faceBlob = await srcToBlob(assets.dice[face]);
const faceName = `dice/${face}.${extFromBlob(faceBlob)}`;
zip.file(faceName, faceBlob);
files.dice[face] = faceName;
if (assets.diceFloorImage) {
const floorBlob = await srcToBlob(assets.diceFloorImage);
const floorName = `dice-floor.${extFromBlob(floorBlob)}`;
zip.file(floorName, floorBlob);
files.diceFloor = floorName;
}
files.diceSets = {};
const slotMaps: Record<DiceSlot, DrinkmarbleAssets['dice']> = {
1: assets.dice,
2: assets.dice2,
};
for (const slot of DICE_SLOTS) {
if (slot === 2 && assets.diceCount < 2) continue;
files.diceSets[slot] = {};
for (const face of DICE_FACES) {
const faceBlob = await srcToBlob(slotMaps[slot][face]);
const faceName = `dice/${slot}/${face}.${extFromBlob(faceBlob)}`;
zip.file(faceName, faceBlob);
files.diceSets[slot]![face] = faceName;
}
}
// 하위 호환: dice/면.* 도 1번 세트로 넣어둠
files.dice = files.diceSets[1];
files.cells = {};
for (let i = 0; i < normalized.cells.length; i++) {
const src = normalized.cells[i]?.imageSrc;
if (!src) continue;
const cellBlob = await srcToBlob(src);
const cellName = `cells/${i}.${extFromBlob(cellBlob)}`;
zip.file(cellName, cellBlob);
files.cells[String(i)] = cellName;
}
zip.file('board.json', JSON.stringify(boardForExport(normalized), null, 2));
files.board = 'board.json';
const manifest: PackManifest = {
version: PACK_VERSION,
app: 'drinkmarble',
settings: {
diceCount: assets.diceCount,
diceFloorColor: assets.diceFloorColor,
},
files,
};
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
@@ -97,11 +170,10 @@ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
downloadBlob(zipBlob, `drinkmarble-pack-${stamp}.zip`);
}
/**
* ZIP 팩을 읽어 localStorage에 반영한다.
* manifest.json이 없으면 관례적 파일명(background.*, chessman.*, dice/1.*)을 시도한다.
*/
export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
export async function importAssetPack(file: File): Promise<{
assets: DrinkmarbleAssets;
board: BoardConfig;
}> {
if (!file.name.toLowerCase().endsWith('.zip')) {
throw new Error('ZIP 파일만 가져올 수 있습니다.');
}
@@ -135,7 +207,6 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
const entry = findEntry(path);
if (entry) return entry;
}
// 확장자만 다른 경우
for (const path of candidates) {
const base = path.replace(/\.[^.]+$/, '');
const match = Object.keys(zip.files).find(
@@ -160,6 +231,12 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
'chessman.png',
'chessman.webp',
]);
const floorEntry = resolvePath(manifest?.files.diceFloor, [
'dice-floor.jpg',
'dice-floor.jpeg',
'dice-floor.png',
'dice-floor.webp',
]);
let imported = 0;
@@ -171,23 +248,87 @@ export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
setChessmanAsset(await blobToDataUrl(await manEntry.async('blob')));
imported += 1;
}
if (floorEntry) {
setDiceFloorImage(await blobToDataUrl(await floorEntry.async('blob')));
imported += 1;
}
if (manifest?.settings?.diceFloorColor) {
setDiceFloorColor(manifest.settings.diceFloorColor);
imported += 1;
}
if (manifest?.settings?.diceCount === 1 || manifest?.settings?.diceCount === 2) {
setDiceCount(manifest.settings.diceCount);
imported += 1;
}
for (const slot of DICE_SLOTS) {
for (const face of DICE_FACES) {
const faceEntry = resolvePath(manifest?.files.dice?.[face], [
const preferred =
manifest?.files.diceSets?.[slot]?.[face] ??
(slot === 1 ? manifest?.files.dice?.[face] : undefined);
const faceEntry = resolvePath(preferred, [
`dice/${slot}/${face}.jpg`,
`dice/${slot}/${face}.jpeg`,
`dice/${slot}/${face}.png`,
`dice/${slot}/${face}.webp`,
...(slot === 1
? [
`dice/${face}.jpg`,
`dice/${face}.jpeg`,
`dice/${face}.png`,
`dice/${face}.webp`,
]
: []),
]);
if (faceEntry) {
setDiceFaceAsset(face, await blobToDataUrl(await faceEntry.async('blob')));
setDiceFaceAsset(slot, face, await blobToDataUrl(await faceEntry.async('blob')));
imported += 1;
}
}
if (imported === 0) {
throw new Error('팩에서 사용할 이미지를 찾지 못했습니다.');
}
return getAssets();
const boardEntry = resolvePath(manifest?.files.board, ['board.json']);
let board = getOrCreateBoardConfig();
if (boardEntry) {
const text = await boardEntry.async('string');
board = normalizeBoardConfig(JSON.parse(text) as BoardConfig);
imported += 1;
}
const cellImages: Record<number, string> = {};
if (manifest?.files.cells) {
for (const [indexKey, path] of Object.entries(manifest.files.cells)) {
const entry = resolvePath(path);
if (!entry) continue;
const index = Number(indexKey);
if (!Number.isFinite(index)) continue;
cellImages[index] = await blobToDataUrl(await entry.async('blob'));
}
} else {
for (const name of Object.keys(zip.files)) {
if (zip.files[name].dir) continue;
const match = /^cells\/(\d+)\./i.exec(name);
if (!match) continue;
const entry = zip.file(name);
if (!entry) continue;
cellImages[Number(match[1])] = await blobToDataUrl(await entry.async('blob'));
}
}
if (Object.keys(cellImages).length > 0 || boardEntry) {
const cells = board.cells.map((cell, index) => ({
...cell,
imageSrc: cellImages[index] ?? cell.imageSrc ?? null,
}));
board = normalizeBoardConfig({ ...board, cells });
setBoardConfig(board);
}
if (imported === 0 && Object.keys(cellImages).length === 0) {
throw new Error('팩에서 사용할 데이터를 찾지 못했습니다.');
}
return { assets: getAssets(), board: getOrCreateBoardConfig() };
}

View File

@@ -1,30 +1,54 @@
export type DiceFace = 1 | 2 | 3 | 4 | 5 | 6;
export type DiceSlot = 1 | 2;
export type DiceCount = 1 | 2;
export type DiceFaceMap = Record<DiceFace, string>;
export type DrinkmarbleAssets = {
background: string;
chessman: string;
dice: Record<DiceFace, string>;
diceCount: DiceCount;
/** 1번 주사위 면 */
dice: DiceFaceMap;
/** 2번 주사위 면 */
dice2: DiceFaceMap;
/** 주사위판 바닥 색 (이미지가 없을 때) */
diceFloorColor: string;
/** 주사위판 바닥 이미지 (있으면 색 대신 사용) */
diceFloorImage: string | null;
};
const STORAGE_KEY = 'drinkmarble.assets';
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
export const DEFAULT_ASSETS: DrinkmarbleAssets = {
background: '/background/default.png',
chessman: '/chessman/default.png',
dice: {
const DEFAULT_DICE: DiceFaceMap = {
1: '/dice/1.png',
2: '/dice/2.png',
3: '/dice/3.png',
4: '/dice/4.png',
5: '/dice/5.png',
6: '/dice/6.png',
},
};
export const DEFAULT_DICE_FLOOR_COLOR = '#2563eb';
export const DEFAULT_ASSETS: DrinkmarbleAssets = {
background: '/background/default.png',
chessman: '/chessman/default.png',
diceCount: 1,
dice: { ...DEFAULT_DICE },
dice2: { ...DEFAULT_DICE },
diceFloorColor: DEFAULT_DICE_FLOOR_COLOR,
diceFloorImage: null,
};
type StoredAssets = Partial<{
background: string;
chessman: string;
dice: Partial<Record<DiceFace, string>>;
diceCount: DiceCount | number;
dice: Partial<DiceFaceMap>;
dice2: Partial<DiceFaceMap>;
diceFloorColor: string;
diceFloorImage: string | null;
}>;
function readStored(): StoredAssets {
@@ -52,20 +76,47 @@ function writeStored(next: StoredAssets) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
function mergeDiceFaces(
stored: Partial<DiceFaceMap> | undefined,
fallback: DiceFaceMap,
): DiceFaceMap {
return {
1: stored?.[1] || fallback[1],
2: stored?.[2] || fallback[2],
3: stored?.[3] || fallback[3],
4: stored?.[4] || fallback[4],
5: stored?.[5] || fallback[5],
6: stored?.[6] || fallback[6],
};
}
function normalizeDiceCount(value: unknown): DiceCount {
return value === 2 ? 2 : 1;
}
function normalizeFloorColor(value: unknown): string {
if (typeof value !== 'string') return DEFAULT_DICE_FLOOR_COLOR;
const trimmed = value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{3}$/.test(trimmed)) {
return trimmed;
}
return DEFAULT_DICE_FLOOR_COLOR;
}
/** 저장된 값이 있으면 쓰고, 없으면 서버 public 기본 경로를 쓴다. */
export function getAssets(): DrinkmarbleAssets {
const stored = readStored();
return {
background: stored.background || DEFAULT_ASSETS.background,
chessman: stored.chessman || DEFAULT_ASSETS.chessman,
dice: {
1: stored.dice?.[1] || DEFAULT_ASSETS.dice[1],
2: stored.dice?.[2] || DEFAULT_ASSETS.dice[2],
3: stored.dice?.[3] || DEFAULT_ASSETS.dice[3],
4: stored.dice?.[4] || DEFAULT_ASSETS.dice[4],
5: stored.dice?.[5] || DEFAULT_ASSETS.dice[5],
6: stored.dice?.[6] || DEFAULT_ASSETS.dice[6],
},
diceCount: normalizeDiceCount(stored.diceCount),
dice: mergeDiceFaces(stored.dice, DEFAULT_ASSETS.dice),
dice2: mergeDiceFaces(stored.dice2, DEFAULT_ASSETS.dice2),
diceFloorColor: normalizeFloorColor(stored.diceFloorColor),
diceFloorImage:
typeof stored.diceFloorImage === 'string' && stored.diceFloorImage.length > 0
? stored.diceFloorImage
: null,
};
}
@@ -79,14 +130,36 @@ export function setChessmanAsset(src: string) {
writeStored({ ...stored, chessman: src });
}
export function setDiceFaceAsset(face: DiceFace, src: string) {
export function setDiceCount(count: DiceCount) {
const stored = readStored();
writeStored({ ...stored, diceCount: normalizeDiceCount(count) });
}
export function setDiceFaceAsset(slot: DiceSlot, face: DiceFace, src: string) {
const stored = readStored();
if (slot === 2) {
writeStored({
...stored,
dice2: { ...stored.dice2, [face]: src },
});
return;
}
writeStored({
...stored,
dice: { ...stored.dice, [face]: src },
});
}
export function setDiceFloorColor(color: string) {
const stored = readStored();
writeStored({ ...stored, diceFloorColor: normalizeFloorColor(color) });
}
export function setDiceFloorImage(src: string) {
const stored = readStored();
writeStored({ ...stored, diceFloorImage: src });
}
export function clearBackgroundAsset() {
const stored = readStored();
const { background: _removed, ...rest } = stored;
@@ -99,31 +172,51 @@ export function clearChessmanAsset() {
writeStored(rest);
}
export function clearDiceFaceAsset(face: DiceFace) {
export function clearDiceFaceAsset(slot: DiceSlot, face: DiceFace) {
const stored = readStored();
if (slot === 2) {
if (!stored.dice2) return;
const dice2 = { ...stored.dice2 };
delete dice2[face];
writeStored({ ...stored, dice2 });
return;
}
if (!stored.dice) return;
const dice = { ...stored.dice };
delete dice[face];
writeStored({ ...stored, dice });
}
export function clearDiceFloorColor() {
const stored = readStored();
const { diceFloorColor: _removed, ...rest } = stored;
writeStored(rest);
}
export function clearDiceFloorImage() {
const stored = readStored();
const { diceFloorImage: _removed, ...rest } = stored;
writeStored(rest);
}
export function clearAllAssets() {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(STORAGE_KEY);
}
/** boxGeometry material 순서: +X,-X,+Y,-Y,+Z,-Z = 2,5,1,6,3,4 */
export function getDiceFaceTextureList(assets: DrinkmarbleAssets = getAssets()): string[] {
return [
assets.dice[2],
assets.dice[5],
assets.dice[1],
assets.dice[6],
assets.dice[3],
assets.dice[4],
];
export function getDiceFaceTextureList(
faces: DiceFaceMap = getAssets().dice,
): string[] {
return [faces[2], faces[5], faces[1], faces[6], faces[3], faces[4]];
}
export function getDiceFaceMap(slot: DiceSlot, assets: DrinkmarbleAssets = getAssets()) {
return slot === 2 ? assets.dice2 : assets.dice;
}
export { DICE_FACES };
export function isCustomAsset(src: string, fallback: string) {
return src !== fallback && src.startsWith('data:');
}
@@ -133,6 +226,8 @@ export const ASSET_ASPECT = {
background: { w: 16, h: 9, label: '16:9' },
chessman: { w: 1, h: 1, label: '1:1' },
dice: { w: 1, h: 1, label: '1:1' },
cell: { w: 1, h: 1, label: '1:1' },
diceFloor: { w: 1, h: 1, label: '1:1' },
} as const;
export type AssetKind = keyof typeof ASSET_ASPECT;

332
app/game/board-config.ts Normal file
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;
/** 효과가 있을 때만 설정. 없으면 착지 시 아무 일도 없음 */
eventId?: string | null;
/** 칸 이미지 (없으면 텍스트만) */
imageSrc?: string | null;
};
export type TurnContext = {

View File

@@ -46,14 +46,15 @@ export class Chessman {
/**
* 무인도 상태에서의 주사위 처리.
* - escape: 6(또는 escapeOn)으로 탈출 → 이후 정상 이동
* - escape: escapeOn(기본 6)이 한 면이라도 나오면 탈출 → 이후 정상 이동
* - wait: 턴 소모 후 대기 (turnsLeft 0이면 자동 해제, 이번 턴은 이동 없음)
* - free: 이미 무인도 아님
*/
resolveIslandRoll(roll: number): 'escape' | 'wait' | 'free' {
resolveIslandRoll(roll: number, faces?: number[]): 'escape' | 'wait' | 'free' {
if (!this.island) return 'free';
if (roll === this.island.escapeOn) {
const values = faces && faces.length > 0 ? faces : [roll];
if (values.some((value) => value === this.island!.escapeOn)) {
this.island = null;
return 'escape';
}

View File

@@ -14,8 +14,15 @@ import {
TextureLoader,
Vector3,
type OrthographicCamera,
type Texture,
} from 'three';
import { getDiceFaceTextureList } from './asset-storage';
import {
DEFAULT_ASSETS,
DEFAULT_DICE_FLOOR_COLOR,
getAssets,
getDiceFaceTextureList,
type DiceCount,
} from './asset-storage';
const FLOOR_SIZE = 6;
const FLOOR_HALF = FLOOR_SIZE / 2;
@@ -27,15 +34,7 @@ const WALL_HALF_H = WALL_HEIGHT / 2;
const STILL_SPEED_SQ = 0.05;
const STILL_FRAMES = 18;
// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z (기본 경로, 런타임에 localStorage로 교체)
const DEFAULT_DICE_FACE_TEXTURES = [
'/dice/2.png',
'/dice/5.png',
'/dice/1.png',
'/dice/6.png',
'/dice/3.png',
'/dice/4.png',
] as const;
const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice);
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
const DICE_FACES = [
@@ -51,6 +50,13 @@ const _quat = new Quaternion();
const _normal = new Vector3();
const WORLD_UP = new Vector3(0, 1, 0);
export type DiceRollResult = {
/** 이동에 쓰는 합 */
sum: number;
/** 각 주사위 윗면 */
faces: number[];
};
function readTopFace(body: RapierRigidBody): number {
const r = body.rotation();
_quat.set(r.x, r.y, r.z, r.w);
@@ -127,36 +133,104 @@ function DiceMesh({ faceUrls }: { faceUrls: string[] }) {
);
}
function FloorMesh({
color,
imageSrc,
}: {
color: string;
imageSrc: string | null;
}) {
const texture = useLoader(
TextureLoader,
imageSrc ?? '/background/default.png',
) as Texture;
useLayoutEffect(() => {
if (!imageSrc) return;
texture.colorSpace = SRGBColorSpace;
texture.anisotropy = 8;
texture.needsUpdate = true;
}, [imageSrc, texture]);
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
{imageSrc ? (
<meshBasicMaterial map={texture} />
) : (
<meshBasicMaterial color={color} />
)}
</mesh>
);
}
function FloorSurface({
color,
imageSrc,
}: {
color: string;
imageSrc: string | null;
}) {
if (!imageSrc) {
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
<meshBasicMaterial color={color} />
</mesh>
);
}
return (
<Suspense
fallback={
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
<meshBasicMaterial color={color} />
</mesh>
}
>
<FloorMesh color={color} imageSrc={imageSrc} />
</Suspense>
);
}
function isBodyStill(body: RapierRigidBody) {
const lv = body.linvel();
const av = body.angvel();
const speedSq =
lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z;
return speedSq < STILL_SPEED_SQ || body.isSleeping();
}
function DiceSettleWatcher({
diceRef,
diceRefs,
rolling,
onSettled,
}: {
diceRef: React.RefObject<RapierRigidBody | null>;
diceRefs: React.RefObject<RapierRigidBody | null>[];
rolling: boolean;
onSettled: (value: number) => void;
onSettled: (result: DiceRollResult) => void;
}) {
const stillFrames = useRef(0);
const reported = useRef(false);
useFrame(() => {
const body = diceRef.current;
if (!body || !rolling) {
if (!rolling) {
stillFrames.current = 0;
reported.current = false;
return;
}
const lv = body.linvel();
const av = body.angvel();
const speedSq =
lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z;
const bodies = diceRefs.map((ref) => ref.current);
if (bodies.some((body) => !body)) return;
if (speedSq < STILL_SPEED_SQ || body.isSleeping()) {
if (bodies.every((body) => isBodyStill(body!))) {
stillFrames.current += 1;
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
reported.current = true;
onSettled(readTopFace(body));
const faces = bodies.map((body) => readTopFace(body!));
const sum = faces.reduce((a, b) => a + b, 0);
onSettled({ sum, faces });
}
} else {
stillFrames.current = 0;
@@ -167,16 +241,24 @@ function DiceSettleWatcher({
}
function RapierDiceExample({
diceRef,
diceRefs,
diceCount,
rolling,
onSettled,
faceUrls,
faceUrlSets,
floorColor,
floorImage,
}: {
diceRef: React.RefObject<RapierRigidBody | null>;
diceRefs: React.RefObject<RapierRigidBody | null>[];
diceCount: DiceCount;
rolling: boolean;
onSettled: (value: number) => void;
faceUrls: string[];
onSettled: (result: DiceRollResult) => void;
faceUrlSets: string[][];
floorColor: string;
floorImage: string | null;
}) {
const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0];
return (
<Canvas
orthographic
@@ -189,28 +271,39 @@ function RapierDiceExample({
<directionalLight position={[4, 10, 2]} intensity={0.9} />
<Physics>
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
<meshBasicMaterial color="blue" />
</mesh>
<FloorSurface
key={`${floorColor}|${floorImage ?? ''}`}
color={floorColor}
imageSrc={floorImage}
/>
<CuboidCollider args={[FLOOR_HALF, 0.05, FLOOR_HALF]} position={[0, -0.05, 0]} />
<InvisibleWalls />
</RigidBody>
{spawnX.map((x, index) => (
<RigidBody
ref={diceRef}
key={`die-${index}-${diceCount}`}
ref={diceRefs[index]}
type="dynamic"
position={[0, 4, 0]}
position={[x, 4, index === 0 ? 0 : 0.15]}
colliders="cuboid"
restitution={0.2}
friction={0.6}
>
<Suspense fallback={null}>
<DiceMesh key={faceUrls.join('|')} faceUrls={faceUrls} />
<DiceMesh
key={faceUrlSets[index]?.join('|') ?? index}
faceUrls={faceUrlSets[index] ?? DEFAULT_DICE_FACE_TEXTURES}
/>
</Suspense>
</RigidBody>
))}
<DiceSettleWatcher diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
<DiceSettleWatcher
diceRefs={diceRefs.slice(0, diceCount)}
rolling={rolling}
onSettled={onSettled}
/>
</Physics>
</Canvas>
);
@@ -230,27 +323,48 @@ function rollDice(body: RapierRigidBody | null) {
);
}
function formatResult(result: DiceRollResult | null) {
if (!result) return '결과: —';
if (result.faces.length <= 1) return `결과: ${result.sum}`;
return `결과: ${result.faces.join(' + ')} = ${result.sum}`;
}
export default function Dice({
onRolled,
disabled = false,
}: {
onRolled?: (value: number) => void;
onRolled?: (result: DiceRollResult) => void;
disabled?: boolean;
}) {
const diceRef = useRef<RapierRigidBody>(null);
const diceRefA = useRef<RapierRigidBody>(null);
const diceRefB = useRef<RapierRigidBody>(null);
const diceRefs = [diceRefA, diceRefB];
const [rolling, setRolling] = useState(false);
const [result, setResult] = useState<number | null>(null);
const [faceUrls, setFaceUrls] = useState<string[]>([...DEFAULT_DICE_FACE_TEXTURES]);
const [result, setResult] = useState<DiceRollResult | null>(null);
const [diceCount, setDiceCount] = useState<DiceCount>(1);
const [faceUrlSets, setFaceUrlSets] = useState<string[][]>([
[...DEFAULT_DICE_FACE_TEXTURES],
]);
const [floorColor, setFloorColor] = useState(DEFAULT_DICE_FLOOR_COLOR);
const [floorImage, setFloorImage] = useState<string | null>(null);
useEffect(() => {
setFaceUrls(getDiceFaceTextureList());
const assets = getAssets();
setDiceCount(assets.diceCount);
setFaceUrlSets(
assets.diceCount === 2
? [getDiceFaceTextureList(assets.dice), getDiceFaceTextureList(assets.dice2)]
: [getDiceFaceTextureList(assets.dice)],
);
setFloorColor(assets.diceFloorColor);
setFloorImage(assets.diceFloorImage);
}, []);
const onSettled = useCallback(
(value: number) => {
setResult(value);
(next: DiceRollResult) => {
setResult(next);
setRolling(false);
onRolled?.(value);
onRolled?.(next);
},
[onRolled],
);
@@ -261,10 +375,13 @@ export default function Dice({
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
<RapierDiceExample
diceRef={diceRef}
diceRefs={diceRefs}
diceCount={diceCount}
rolling={rolling}
onSettled={onSettled}
faceUrls={faceUrls}
faceUrlSets={faceUrlSets}
floorColor={floorColor}
floorImage={floorImage}
/>
<button
type="button"
@@ -273,7 +390,9 @@ export default function Dice({
onClick={() => {
setResult(null);
setRolling(true);
rollDice(diceRef.current);
for (let i = 0; i < diceCount; i++) {
rollDice(diceRefs[i].current);
}
}}
>
Roll
@@ -283,9 +402,7 @@ export default function Dice({
? '굴리는 중…'
: disabled
? '이동 중…'
: result != null
? `결과: ${result}`
: '결과: —'}
: formatResult(result)}
</div>
</div>
</div>

View File

@@ -4,86 +4,32 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import Dice from './dice';
import { Chessman } from './chessman';
import ChessmanPiece from './chessman-piece';
import {
applyLandEvent,
type PlaneCell,
} from './cell-event';
import { applyLandEvent, type PlaneCell } from './cell-event';
import { getAssets } from './asset-storage';
import {
buildPlaneCells,
cellColorFor,
diceAreaSpan,
getOrCreateBoardConfig,
} from './board-config';
const PLANE_SIZE = { width: 9, height: 7 } as const;
const STEP_MS = 280;
const CELL_TYPES = ['한잔', '안주', '모두한잔', '두잔', '벌칙1', '벌칙2'] as const;
/** 특수 칸 배치 (28칸 기준 인덱스) */
const SPECIAL_CELLS: Record<number, { type: string; eventId: string }> = {
8: { type: '무인도', eventId: 'island' },
14: { type: '역방향', eventId: 'reverse' },
22: { type: '비행기', eventId: 'airplane' }, // → 시작(0)
};
function randomType() {
return CELL_TYPES[Math.floor(Math.random() * CELL_TYPES.length)];
}
function cellLabel(cell: PlaneCell | undefined, index: number) {
if (!cell) return '';
return index === 0 ? '시작' : cell.type;
}
function cellColor(cell: PlaneCell, index: number) {
if (index === 0) return '#059669';
switch (cell.eventId) {
case 'island':
return '#0f766e';
case 'reverse':
return '#7c3aed';
case 'airplane':
return '#0284c7';
default:
return '#e11d48';
}
}
/** 시계방향 둘레 칸. 모서리는 한 번만 넣어서 9x7 기준 28칸이 된다. */
function buildPerimeter(width: number, height: number, assignType: () => string): PlaneCell[] {
const cells: PlaneCell[] = [];
let id = 0;
for (let x = 0; x < width; x++) {
cells.push({ id: id++, x, y: 0, type: assignType() });
}
for (let y = 1; y < height; y++) {
cells.push({ id: id++, x: width - 1, y, type: assignType() });
}
for (let x = width - 2; x >= 0; x--) {
cells.push({ id: id++, x, y: height - 1, type: assignType() });
}
for (let y = height - 2; y >= 1; y--) {
cells.push({ id: id++, x: 0, y, type: assignType() });
}
if (cells[0]) {
cells[0].type = '시작';
cells[0].eventId = null;
}
for (const [indexText, special] of Object.entries(SPECIAL_CELLS)) {
const index = Number(indexText);
if (cells[index]) {
cells[index].type = special.type;
cells[index].eventId = special.eventId;
}
}
return cells;
}
export default function DicePlane() {
const [dicePlaneArray, setDicePlaneArray] = useState(() =>
buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, () => 'none'),
const initial = getOrCreateBoardConfig();
const [planeSize, setPlaneSize] = useState({
width: initial.width,
height: initial.height,
});
const [dicePlaneArray, setDicePlaneArray] = useState<PlaneCell[]>(() =>
buildPlaneCells(initial),
);
const chessmanRef = useRef(new Chessman(28));
const chessmanRef = useRef(new Chessman(initial.cells.length));
const boardRef = useRef(dicePlaneArray);
const [chessmanIndex, setChessmanIndex] = useState(0);
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
@@ -96,9 +42,11 @@ export default function DicePlane() {
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const cells = buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, randomType);
const board = getOrCreateBoardConfig();
const cells = buildPlaneCells(board);
chessmanRef.current = new Chessman(cells.length, 0);
boardRef.current = cells;
setPlaneSize({ width: board.width, height: board.height });
setChessmanIndex(0);
setChessmanSrc(getAssets().chessman);
setHopKey(0);
@@ -180,15 +128,15 @@ export default function DicePlane() {
);
const onDiceRolled = useCallback(
(value: number) => {
const roll = Math.max(0, Math.trunc(value));
(rollResult: { sum: number; faces: number[] }) => {
const roll = Math.max(0, Math.trunc(rollResult.sum));
if (roll === 0) return;
setLandedLabel(null);
const man = chessmanRef.current;
if (man.isOnIsland) {
const outcome = man.resolveIslandRoll(roll);
const outcome = man.resolveIslandRoll(roll, rollResult.faces);
setIslandTurnsLeft(man.islandTurnsLeft);
if (outcome === 'wait') {
showBanner(
@@ -198,7 +146,6 @@ export default function DicePlane() {
);
return;
}
// escape with 6
showBanner('무인도 탈출!');
}
@@ -208,14 +155,15 @@ export default function DicePlane() {
);
const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0];
const diceSpan = diceAreaSpan(planeSize.width, planeSize.height);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${PLANE_SIZE.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${PLANE_SIZE.height}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${planeSize.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${planeSize.height}, minmax(0, 1fr))`,
gap: 'clamp(6px, 1vw, 12px)',
padding: 'clamp(8px, 1.2vw, 16px)',
width: '100%',
@@ -235,7 +183,10 @@ export default function DicePlane() {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: cellColor(plane, index),
backgroundColor: cellColorFor(plane.eventId, index),
backgroundImage: plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
color: 'white',
borderRadius: 8,
fontSize: 'clamp(11px, 1.6vw, 16px)',
@@ -250,6 +201,7 @@ export default function DicePlane() {
transform: isLanded ? 'scale(1.04)' : undefined,
transition: 'box-shadow 180ms ease, transform 180ms ease',
zIndex: isLanded ? 4 : undefined,
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
}}
>
{isStart ? '시작' : plane.type}
@@ -268,8 +220,8 @@ export default function DicePlane() {
<div
style={{
gridColumn: '2 / 4',
gridRow: '2 / 4',
gridColumn: `2 / ${2 + diceSpan}`,
gridRow: `2 / ${2 + diceSpan}`,
minWidth: 0,
minHeight: 0,
zIndex: 6,

View File

@@ -5,21 +5,43 @@ import { useEffect, useRef, useState } from 'react';
import {
ASSET_ASPECT,
DEFAULT_ASSETS,
DEFAULT_DICE_FLOOR_COLOR,
DICE_FACES,
type DiceCount,
type DiceFace,
type DiceSlot,
type DrinkmarbleAssets,
clearAllAssets,
clearBackgroundAsset,
clearChessmanAsset,
clearDiceFaceAsset,
clearDiceFloorColor,
clearDiceFloorImage,
fileToAssetDataUrl,
getAssets,
setBackgroundAsset,
setChessmanAsset,
setDiceCount,
setDiceFaceAsset,
setDiceFloorColor,
setDiceFloorImage,
} from './game/asset-storage';
import { exportAssetPack, importAssetPack } from './game/asset-pack';
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
import {
type BoardConfig,
CELL_EVENT_OPTIONS,
CELL_TYPE_PRESETS,
MAX_PLANE_SIZE,
MIN_PLANE_SIZE,
buildPerimeterGeometry,
cellColorFor,
clearBoardConfig,
getOrCreateBoardConfig,
perimeterLength,
randomizeAndSaveBoard,
resizeBoardConfig,
updateBoardCell,
} from './game/board-config';
function AssetThumb({
src,
@@ -81,8 +103,221 @@ function AssetThumb({
);
}
function BoardEditor({
board,
onChange,
}: {
board: BoardConfig;
onChange: (next: BoardConfig) => void;
}) {
const geo = buildPerimeterGeometry(board.width, board.height);
const [selected, setSelected] = useState(1);
const cellImageInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selected >= board.cells.length) {
setSelected(Math.min(1, board.cells.length - 1));
}
}, [board.cells.length, selected]);
const selectedCell = board.cells[selected];
const cellCount = perimeterLength(board.width, board.height);
const applySize = (width: number, height: number) => {
onChange(resizeBoardConfig(width, height, board));
};
const onCellImagePick = async (file: File) => {
const aspect = ASSET_ASPECT.cell;
const dataUrl = await fileToAssetDataUrl(file, {
aspectW: aspect.w,
aspectH: aspect.h,
maxW: 512,
maxH: 512,
quality: 0.88,
});
onChange(updateBoardCell(selected, { imageSrc: dataUrl }, board));
};
return (
<div className="flex flex-col gap-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
<p className="text-[11px] text-white/50">
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지를 설정합니다. 0번(시작) 이름은 고정입니다.
</p>
</div>
<button
type="button"
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
onClick={() => onChange(randomizeAndSaveBoard(board.width, board.height, board))}
>
랜덤 다시 배치
</button>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-lg bg-black/25 p-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">가로 (칸)</label>
<input
type="number"
min={MIN_PLANE_SIZE}
max={MAX_PLANE_SIZE}
value={board.width}
onChange={(e) => applySize(Number(e.target.value), board.height)}
className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">세로 (칸)</label>
<input
type="number"
min={MIN_PLANE_SIZE}
max={MAX_PLANE_SIZE}
value={board.height}
onChange={(e) => applySize(board.width, Number(e.target.value))}
className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white"
/>
</div>
<p className="pb-1.5 text-xs text-white/50">
{MIN_PLANE_SIZE}~{MAX_PLANE_SIZE} · 둘레 {cellCount}칸
</p>
</div>
<div
className="grid gap-1"
style={{
gridTemplateColumns: `repeat(${board.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board.height}, minmax(0, 1fr))`,
}}
>
{geo.map((g, index) => {
const cell = board.cells[index];
const active = selected === index;
return (
<button
key={g.id}
type="button"
title={`#${index} ${cell?.type ?? ''}`}
onClick={() => setSelected(index)}
className="min-h-10 rounded px-0.5 text-[10px] font-semibold text-white sm:text-[11px]"
style={{
gridColumn: g.x + 1,
gridRow: g.y + 1,
backgroundColor: cellColorFor(cell?.eventId, index),
backgroundImage: cell?.imageSrc ? `url(${cell.imageSrc})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
textShadow: cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
outline: active ? '2px solid #fde68a' : undefined,
outlineOffset: 1,
}}
>
{index === 0 ? '시작' : cell?.type}
</button>
);
})}
</div>
{selectedCell && (
<div className="grid gap-3 rounded-lg bg-black/25 p-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">칸 #{selected} 이름</label>
<input
type="text"
list="cell-type-presets"
disabled={selected === 0}
value={selected === 0 ? '시작' : selectedCell.type}
onChange={(e) => onChange(updateBoardCell(selected, { type: e.target.value }, board))}
className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50"
/>
<datalist id="cell-type-presets">
{CELL_TYPE_PRESETS.map((t) => (
<option key={t} value={t} />
))}
</datalist>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">효과</label>
<select
disabled={selected === 0}
value={selectedCell.eventId ?? ''}
onChange={(e) => {
const value = e.target.value;
onChange(
updateBoardCell(
selected,
{ eventId: value === '' ? null : value },
board,
),
);
}}
className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50"
>
{CELL_EVENT_OPTIONS.map((opt) => (
<option key={String(opt.id)} value={opt.id ?? ''}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<div className="flex items-center justify-between gap-2">
<label className="text-xs text-white/60">
칸 이미지 ({ASSET_ASPECT.cell.label} · 선택)
</label>
<div className="flex gap-1">
<button
type="button"
className="rounded bg-white/15 px-2 py-1 text-xs text-white hover:bg-white/25"
onClick={() => cellImageInputRef.current?.click()}
>
변경
</button>
<button
type="button"
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
onClick={() =>
onChange(updateBoardCell(selected, { imageSrc: null }, board))
}
>
제거
</button>
</div>
</div>
<div
className="h-20 w-20 overflow-hidden rounded-lg bg-black/40"
style={{
backgroundColor: cellColorFor(selectedCell.eventId, selected),
backgroundImage: selectedCell.imageSrc
? `url(${selectedCell.imageSrc})`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<input
ref={cellImageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void onCellImagePick(file).catch(() => undefined);
e.target.value = '';
}}
/>
</div>
</div>
)}
</div>
);
}
export default function Home() {
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
const [board, setBoard] = useState<BoardConfig | null>(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -90,6 +325,7 @@ export default function Home() {
const reload = () => {
setAssets(getAssets());
setBoard(getOrCreateBoardConfig());
};
useEffect(() => {
@@ -97,21 +333,27 @@ export default function Home() {
setReady(true);
}, []);
const save = async (kind: 'background' | 'chessman' | DiceFace, file: File) => {
const save = async (
kind: 'background' | 'chessman' | 'diceFloor' | { slot: DiceSlot; face: DiceFace },
file: File,
) => {
try {
const aspectKind = kind === 'background' || kind === 'chessman' ? kind : 'dice';
const aspectKind =
kind === 'background' || kind === 'chessman' || kind === 'diceFloor' ? kind : 'dice';
const aspect = ASSET_ASPECT[aspectKind];
const isBg = kind === 'background';
const isFloor = kind === 'diceFloor';
const dataUrl = await fileToAssetDataUrl(file, {
aspectW: aspect.w,
aspectH: aspect.h,
maxW: isBg ? 1920 : 512,
maxH: isBg ? 1080 : 512,
quality: isBg ? 0.85 : 0.9,
maxW: isBg ? 1920 : isFloor ? 1024 : 512,
maxH: isBg ? 1080 : isFloor ? 1024 : 512,
quality: isBg || isFloor ? 0.85 : 0.9,
});
if (kind === 'background') setBackgroundAsset(dataUrl);
else if (kind === 'chessman') setChessmanAsset(dataUrl);
else setDiceFaceAsset(kind, dataUrl);
else if (kind === 'diceFloor') setDiceFloorImage(dataUrl);
else setDiceFaceAsset(kind.slot, kind.face, dataUrl);
reload();
setError(null);
} catch (e) {
@@ -123,7 +365,7 @@ export default function Home() {
try {
setBusy(true);
setError(null);
await exportAssetPack(assets);
await exportAssetPack(assets, board ?? getOrCreateBoardConfig());
} catch (e) {
setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.');
} finally {
@@ -153,9 +395,8 @@ export default function Home() {
<p className="text-sm text-white/60">온라인 주루마블</p>
<h1 className="mt-1 text-4xl font-bold tracking-tight">Drinkmarble</h1>
<p className="mt-2 max-w-md text-sm text-white/65">
이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다.
값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙
크롭됩니다.
이미지와 판 칸 설정은 서버가 아니라 이 브라우저 localStorage에만 저장됩니다.
처음이면 칸은 랜덤으로 채워집니다.
</p>
</div>
<Link
@@ -166,12 +407,12 @@ export default function Home() {
</Link>
</header>
{!ready ? (
{!ready || !board ? (
<p className="text-white/50">불러오는 중…</p>
) : (
<section className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold">커스텀 이미지</h2>
<h2 className="text-lg font-semibold">커스텀 설정</h2>
<div className="flex flex-wrap gap-2">
<button
type="button"
@@ -195,11 +436,12 @@ export default function Home() {
className="rounded bg-white/10 px-3 py-1.5 text-xs text-white/80 hover:bg-white/20 disabled:opacity-50"
onClick={() => {
clearAllAssets();
clearBoardConfig();
reload();
setError(null);
}}
>
전부 기본값으로
전부 초기화
</button>
<input
ref={packInputRef}
@@ -215,13 +457,16 @@ export default function Home() {
</div>
<p className="text-xs text-white/45">
내보내기 ZIP 구조: manifest.json, background.*, chessman.*, dice/1.* … 6.*
ZIP: manifest.json, board.json, cells/N.*, background.*, chessman.*,
dice/1|2/면.*, dice-floor.*
</p>
{error && (
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
)}
<BoardEditor board={board} onChange={setBoard} />
<div className="grid gap-4 sm:grid-cols-2">
<AssetThumb
label="배경 (background)"
@@ -248,30 +493,114 @@ export default function Home() {
</div>
<div>
<h3 className="mb-3 text-sm font-medium text-white/80">
주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label})
</h3>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-medium text-white/80">주사위 설정</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-white/50">개수</span>
{([1, 2] as DiceCount[]).map((count) => (
<button
key={count}
type="button"
className={`rounded px-3 py-1 text-xs font-medium ${
assets.diceCount === count
? 'bg-amber-500 text-stone-950'
: 'bg-white/10 text-white/80 hover:bg-white/20'
}`}
onClick={() => {
setDiceCount(count);
reload();
}}
>
{count}개
</button>
))}
</div>
</div>
<div className="mb-4 flex flex-col gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium text-white">주사위 판(바닥)</p>
<p className="text-[11px] text-white/50">
색상 또는 이미지({ASSET_ASPECT.diceFloor.label}) · 이미지가 있으면 색 대신 사용
</p>
</div>
<div className="flex gap-1">
<button
type="button"
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
onClick={() => {
clearDiceFloorColor();
clearDiceFloorImage();
reload();
}}
>
기본
</button>
</div>
</div>
<div className="flex flex-wrap items-end gap-4">
<label className="flex flex-col gap-1 text-xs text-white/60">
바닥 색
<input
type="color"
value={assets.diceFloorColor || DEFAULT_DICE_FLOOR_COLOR}
onChange={(e) => {
setDiceFloorColor(e.target.value);
reload();
}}
className="h-10 w-16 cursor-pointer rounded border border-white/15 bg-transparent"
/>
</label>
<AssetThumb
label="바닥 이미지"
ratioLabel={ASSET_ASPECT.diceFloor.label}
previewClassName="aspect-square w-28"
src={
assets.diceFloorImage ??
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="${assets.diceFloorColor}"/></svg>`,
)}`
}
onPick={(file) => void save('diceFloor', file)}
onReset={() => {
clearDiceFloorImage();
reload();
}}
/>
</div>
</div>
{([1, 2] as DiceSlot[])
.filter((slot) => slot === 1 || assets.diceCount === 2)
.map((slot) => {
const faces = slot === 1 ? assets.dice : assets.dice2;
return (
<div key={slot} className="mb-4">
<h4 className="mb-2 text-sm font-medium text-white/70">
{assets.diceCount === 2 ? `${slot}번 주사위 면` : '주사위 면'} (1~6 ·{' '}
{ASSET_ASPECT.dice.label})
</h4>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{DICE_FACES.map((face) => (
<AssetThumb
key={face}
key={`${slot}-${face}`}
label={`${face}면`}
ratioLabel={ASSET_ASPECT.dice.label}
previewClassName="aspect-square w-full"
src={assets.dice[face]}
onPick={(file) => void save(face, file)}
src={faces[face]}
onPick={(file) => void save({ slot, face }, file)}
onReset={() => {
clearDiceFaceAsset(face);
clearDiceFaceAsset(slot, face);
reload();
}}
/>
))}
</div>
</div>
<p className="text-xs text-white/45">
이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다.
</p>
);
})}
</div>
</section>
)}
</div>