비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화, 개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
471 lines
13 KiB
TypeScript
471 lines
13 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 = [
|
||
'한잔마셔',
|
||
'~~체 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;
|