386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
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;
|
|
/** false면 게임에서 이름·이미지를 숨김 (기본 true) */
|
|
contentVisible?: boolean;
|
|
};
|
|
|
|
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, 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;
|
|
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,
|
|
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<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;
|
|
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,
|
|
options?: { contentVisible?: boolean },
|
|
) {
|
|
// 내용 숨김이면 종류가 색으로도 드러나지 않게 통일
|
|
if (options?.contentVisible === false && index !== 0) {
|
|
return '#be123c';
|
|
}
|
|
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;
|