커스텀 필요한 항목들 모두 커스텀 가능하도록 수정.
This commit is contained in:
332
app/game/board-config.ts
Normal file
332
app/game/board-config.ts
Normal 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;
|
||||
Reference in New Issue
Block a user