Files
drinkmarble/app/game/board-config.ts
kimyu 347dfc1f4a 게임·입장 UX와 물리/고지 개선을 반영한다.
비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화,
개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 12:50:22 +09:00

471 lines
13 KiB
TypeScript
Raw Permalink 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분',
'두잔마셔',
'애교/매도 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<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,
) {
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;