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

468 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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분',
'두잔마셔',
'애교/매도 100자',
'무인도',
'역방향',
'세계여행',
] 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';
/** 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: '영어금지 3분' },
{ type: '애교/매도 100자' },
{ type: '무인도', eventId: 'island' },
{ type: '안주 한입' },
{ type: '두잔마셔' },
{ type: '~~체 3분' },
{ type: '한잔마셔' },
{ type: '애교/매도 100자' },
{ type: '역방향', eventId: 'reverse' },
{ type: '영어금지 3분' },
{ type: '안주 한입' },
{ type: '다같이한잔' },
{ type: '노래 1절' },
{ type: '한잔마셔' },
{ type: '~~체 3분' },
{ type: '두잔마셔' },
{ type: '세계여행', eventId: 'airplane' },
{ type: '애교/매도 100자' },
{ 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<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' },
};
}
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<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;