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 = [ '한잔마셔', '~~체 3분', '안주 한입', '노래 1절', '다같이한잔', '영어금지 3분', '두잔마셔', '애교/매도 50자', '무인도', '역방향', '세계여행', 'TMI 알려주기', '뒤로 3칸', '앞으로 4칸', '방어권', '방어권 전부삭제', '물한잔', ] 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; /** false면 게임에서 칸 이름(텍스트)만 숨김 (이미지는 유지) */ contentVisible?: boolean; }; export type BoardConfig = { version: number; width: number; height: number; cells: BoardCellConfig[]; }; const BOARD_STORAGE_KEY = 'drinkmarble.board'; /** v5: 추천 배치 아이콘(이모지) 제거 */ const BOARD_VERSION = 5; /** 기본 추천 배치 (시계방향, 시작 포함 28칸 = 9×7) */ const DEFAULT_BOARD_LAYOUT: ReadonlyArray<{ type: string; eventId?: string | null; }> = [ { type: '시작', eventId: null }, { type: '한잔마셔' }, { type: '~~체 3분' }, { type: '방어권' }, { type: '노래 1절' }, { type: '다같이한잔' }, { type: '앞으로 4칸' }, { type: '애교/매도 50자' }, { type: '무인도', eventId: 'island' }, { type: '안주 한입' }, { type: '두잔마셔' }, { type: '물한잔' }, { type: '한잔마셔' }, { type: 'TMI 알려주기' }, { type: '역방향', eventId: 'reverse' }, { type: '뒤로 3칸' }, { type: '안주 한입' }, { type: '다같이한잔' }, { type: '방어권 전부삭제' }, { type: '한잔마셔' }, { type: '~~체 3분' }, { type: '두잔마셔' }, { type: '세계여행', eventId: 'airplane' }, { type: '애교/매도 50자' }, { type: '안주 한입' }, { type: '영어금지 3분' }, { type: '다같이한잔' }, { type: '한잔마셔' }, ]; const SPECIAL_TYPE_NAMES = new Set(['무인도', '역방향', '비행기', '세계여행']); 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) => !SPECIAL_TYPE_NAMES.has(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 { 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' }, }; } function cellFromLayout( entry: { type: string; eventId?: string | null }, index: number, ): BoardCellConfig { return { type: index === 0 ? '시작' : entry.type, eventId: index === 0 ? null : (entry.eventId ?? null), imageSrc: null, contentVisible: true, }; } /** 추천 기본 배치 (9×7) */ export function createDefaultBoardConfig(): BoardConfig { const w = DEFAULT_PLANE_SIZE.width; const h = DEFAULT_PLANE_SIZE.height; const len = perimeterLength(w, h); if (DEFAULT_BOARD_LAYOUT.length !== len) { throw new Error( `DEFAULT_BOARD_LAYOUT length ${DEFAULT_BOARD_LAYOUT.length} != perimeter ${len}`, ); } const cells = DEFAULT_BOARD_LAYOUT.map((entry, index) => cellFromLayout(entry, index)); cells[0] = { type: '시작', eventId: null, imageSrc: null, contentVisible: true }; return { version: BOARD_VERSION, width: w, height: h, cells }; } /** 시계방향 둘레 좌표만 만든다. */ 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); if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) { return createDefaultBoardConfig(); } 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, contentVisible: true }; } if (specials[index]) { return { ...specials[index], imageSrc: null, contentVisible: true }; } return { type: randomPresetType(), eventId: null, imageSrc: null, contentVisible: true, }; }); 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; const contentVisible = src.contentVisible !== false; return { type: src.type.trim(), eventId, imageSrc, contentVisible }; } 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, contentVisible: cells[0]?.contentVisible !== false, }; 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; const parsed = JSON.parse(raw) as BoardConfig; // 구버전(랜덤 기본)은 추천 배치로 교체 if ((parsed.version ?? 0) < BOARD_VERSION) return null; return normalizeBoardConfig(parsed); } 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 = createDefaultBoardConfig(); 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); } /** 추천 기본 배치로 되돌린다 (크기 포함 9×7 고정). */ export function resetToDefaultBoard(): BoardConfig { const created = createDefaultBoardConfig(); writeStoredBoard(created); return created; } export function randomizeAndSaveBoard( width?: number, height?: number, current = getOrCreateBoardConfig(), ): BoardConfig { const w = width ?? current.width; const h = height ?? current.height; if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) { return resetToDefaultBoard(); } const created = createRandomBoardConfig(w, h); 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, contentVisible: prev.cells[0]?.contentVisible !== false, }); 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, contentVisible: src?.contentVisible !== false, }); } const next = { version: BOARD_VERSION, width: w, height: h, cells }; writeStoredBoard(next); return next; } export function updateBoardCell( index: number, patch: Partial, current = getOrCreateBoardConfig(), ): BoardConfig { if (index < 0 || index >= current.cells.length) return current; const cells = current.cells.map((cell, i) => { if (i !== index) return cell; const contentVisible = patch.contentVisible === undefined ? cell.contentVisible !== false : Boolean(patch.contentVisible); if (i === 0) { return { type: '시작', eventId: null, imageSrc: patch.imageSrc === undefined ? cell.imageSrc ?? null : patch.imageSrc === '' ? null : patch.imageSrc, contentVisible, }; } 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, contentVisible, }; }); const next = { version: BOARD_VERSION, width: current.width, height: current.height, cells, }; writeStoredBoard(next); return next; } /** 모든 칸의 내용 표시 여부를 한 번에 설정 */ export function setAllCellsContentVisible( visible: boolean, current = getOrCreateBoardConfig(), ): BoardConfig { const cells = current.cells.map((cell) => ({ ...cell, contentVisible: visible, })); 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, contentVisible: normalized.cells[index]?.contentVisible !== false, })); } 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) * 세로 7칸 기준: 시작 행을 건너뛰고 안쪽 2칸 = 절대 위치 3칸까지 */ 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;