Files
drinkmarble/app/game/board-config.ts

333 lines
9.4 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;
};
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;