치지직 채널 ID로 입장하게 변경 및 일부 판 커스텀 설정 추가.

This commit is contained in:
kimyu
2026-09-16 05:29:50 +09:00
parent 7c402dfd8f
commit 6f72824598
5 changed files with 170 additions and 31 deletions

View File

@@ -83,6 +83,7 @@ function boardForExport(board: BoardConfig): BoardConfig {
type: cell.type,
eventId: cell.eventId ?? null,
imageSrc: null,
contentVisible: cell.contentVisible !== false,
})),
};
}

View File

@@ -34,6 +34,8 @@ export type BoardCellConfig = {
eventId?: string | null;
/** 칸 전용 이미지 (data URL 또는 상대 경로) */
imageSrc?: string | null;
/** false면 게임에서 이름·이미지를 숨김 (기본 true) */
contentVisible?: boolean;
};
export type BoardConfig = {
@@ -117,9 +119,18 @@ export function createRandomBoardConfig(
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 };
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 };
}
@@ -136,7 +147,8 @@ function sanitizeCell(src: BoardCellConfig | undefined, fallback: BoardCellConfi
: String(src.eventId);
const imageSrc =
typeof src.imageSrc === 'string' && src.imageSrc.length > 0 ? src.imageSrc : null;
return { type: src.type.trim(), eventId, imageSrc };
const contentVisible = src.contentVisible !== false;
return { type: src.type.trim(), eventId, imageSrc, contentVisible };
}
export function normalizeBoardConfig(input: BoardConfig | null | undefined): BoardConfig {
@@ -154,7 +166,12 @@ export function normalizeBoardConfig(input: BoardConfig | null | undefined): Boa
cells.push(sanitizeCell(input.cells[i], base.cells[i]));
}
cells[0] = { type: '시작', eventId: null, imageSrc: cells[0]?.imageSrc ?? null };
cells[0] = {
type: '시작',
eventId: null,
imageSrc: cells[0]?.imageSrc ?? null,
contentVisible: cells[0]?.contentVisible !== false,
};
return { version: BOARD_VERSION, width, height, cells };
}
@@ -227,6 +244,7 @@ export function resizeBoardConfig(
type: '시작',
eventId: null,
imageSrc: prev.cells[0]?.imageSrc ?? null,
contentVisible: prev.cells[0]?.contentVisible !== false,
});
continue;
}
@@ -242,6 +260,7 @@ export function resizeBoardConfig(
type: src?.type && src.type !== '시작' ? src.type : randomPresetType(),
eventId: src?.eventId ?? null,
imageSrc: src?.imageSrc ?? null,
contentVisible: src?.contentVisible !== false,
});
}
@@ -258,6 +277,10 @@ export function updateBoardCell(
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: '시작',
@@ -268,6 +291,7 @@ export function updateBoardCell(
: patch.imageSrc === ''
? null
: patch.imageSrc,
contentVisible,
};
}
return {
@@ -284,6 +308,7 @@ export function updateBoardCell(
: patch.imageSrc === ''
? null
: patch.imageSrc,
contentVisible,
};
});
const next = {
@@ -296,6 +321,25 @@ export function updateBoardCell(
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);
@@ -306,10 +350,19 @@ export function buildPlaneCells(config: BoardConfig = getOrCreateBoardConfig()):
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) {
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':

View File

@@ -10,6 +10,8 @@ export type PlaneCell = {
eventId?: string | null;
/** 칸 이미지 (없으면 텍스트만) */
imageSrc?: string | null;
/** false면 게임 판에서 이름·이미지를 숨김 */
contentVisible?: boolean;
};
export type TurnContext = {

View File

@@ -10,8 +10,10 @@ import { getAssets } from './asset-storage';
import {
buildPlaneCells,
cellColorFor,
DEFAULT_PLANE_SIZE,
diceAreaSpan,
getOrCreateBoardConfig,
perimeterLength,
} from './board-config';
const STEP_MS = 280;
@@ -34,15 +36,13 @@ export default function DicePlane({
chatFeed?: ChatFeedItem[];
showChatFeed?: boolean;
}) {
const initial = getOrCreateBoardConfig();
const [planeSize, setPlaneSize] = useState({
width: initial.width,
height: initial.height,
const [boardReady, setBoardReady] = useState(false);
const [planeSize, setPlaneSize] = useState<{ width: number; height: number }>({
width: DEFAULT_PLANE_SIZE.width,
height: DEFAULT_PLANE_SIZE.height,
});
const [dicePlaneArray, setDicePlaneArray] = useState<PlaneCell[]>(() =>
buildPlaneCells(initial),
);
const chessmanRef = useRef(new Chessman(initial.cells.length));
const [dicePlaneArray, setDicePlaneArray] = useState<PlaneCell[]>([]);
const chessmanRef = useRef(new Chessman(perimeterLength(DEFAULT_PLANE_SIZE.width, DEFAULT_PLANE_SIZE.height)));
const boardRef = useRef(dicePlaneArray);
const [chessmanIndex, setChessmanIndex] = useState(0);
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
@@ -59,6 +59,7 @@ export default function DicePlane({
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// SSR/초기 렌더에서 랜덤 판을 쓰지 않고, 브라우저에 저장된 설정을 로드
useEffect(() => {
const board = getOrCreateBoardConfig();
const cells = buildPlaneCells(board);
@@ -72,6 +73,7 @@ export default function DicePlane({
setStatusText('시계방향');
setIslandTurnsLeft(0);
setDicePlaneArray(cells);
setBoardReady(true);
}, []);
useEffect(() => {
@@ -175,6 +177,25 @@ export default function DicePlane({
const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0];
const diceSpan = diceAreaSpan(planeSize.width, planeSize.height);
if (!boardReady || dicePlaneArray.length === 0) {
return (
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.7)',
fontSize: 14,
}}
>
판 불러오는 중…
</div>
);
}
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div
@@ -192,6 +213,7 @@ export default function DicePlane({
{dicePlaneArray.map((plane, index) => {
const isStart = index === 0;
const isLanded = !moving && index === chessmanIndex && landedLabel != null;
const showContent = plane.contentVisible !== false;
return (
<div
key={plane.id}
@@ -201,8 +223,11 @@ export default function DicePlane({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: cellColorFor(plane.eventId, index),
backgroundImage: plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
backgroundColor: cellColorFor(plane.eventId, index, {
contentVisible: showContent,
}),
backgroundImage:
showContent && plane.imageSrc ? `url(${plane.imageSrc})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
color: 'white',
@@ -219,10 +244,13 @@ export default function DicePlane({
transform: isLanded ? 'scale(1.04)' : undefined,
transition: 'box-shadow 180ms ease, transform 180ms ease',
zIndex: isLanded ? 4 : undefined,
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
textShadow:
showContent && plane.imageSrc
? '0 1px 3px rgba(0,0,0,0.85)'
: undefined,
}}
>
{isStart ? '시작' : plane.type}
{showContent ? (isStart ? '시작' : plane.type) : null}
</div>
);
})}

View File

@@ -40,6 +40,8 @@ import {
perimeterLength,
randomizeAndSaveBoard,
resizeBoardConfig,
setAllCellsContentVisible,
setBoardConfig,
updateBoardCell,
} from './game/board-config';
@@ -145,16 +147,33 @@ function BoardEditor({
<div>
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
<p className="text-[11px] text-white/50">
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지를 설정합니다. 0번(시작) 이름은 고정입니다.
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지·내용 표시를 설정합니다. 0번(시작) 이름은
고정입니다.
</p>
</div>
<button
type="button"
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
onClick={() => onChange(randomizeAndSaveBoard(board.width, board.height, board))}
>
랜덤 다시 배치
</button>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
onClick={() => onChange(setAllCellsContentVisible(true, board))}
>
전체 내용 표시
</button>
<button
type="button"
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
onClick={() => onChange(setAllCellsContentVisible(false, board))}
>
전체 내용 숨김
</button>
<button
type="button"
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
onClick={() => onChange(randomizeAndSaveBoard(board.width, board.height, board))}
>
랜덤 다시 배치
</button>
</div>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-lg bg-black/25 p-3">
@@ -195,26 +214,35 @@ function BoardEditor({
{geo.map((g, index) => {
const cell = board.cells[index];
const active = selected === index;
const showContent = cell?.contentVisible !== false;
return (
<button
key={g.id}
type="button"
title={`#${index} ${cell?.type ?? ''}`}
title={`#${index} ${cell?.type ?? ''}${showContent ? '' : ' (내용 숨김)'}`}
onClick={() => setSelected(index)}
className="min-h-10 rounded px-0.5 text-[10px] font-semibold text-white sm:text-[11px]"
className="relative min-h-10 rounded px-0.5 text-[10px] font-semibold text-white sm:text-[11px]"
style={{
gridColumn: g.x + 1,
gridRow: g.y + 1,
backgroundColor: cellColorFor(cell?.eventId, index),
backgroundImage: cell?.imageSrc ? `url(${cell.imageSrc})` : undefined,
backgroundImage:
showContent && cell?.imageSrc ? `url(${cell.imageSrc})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
textShadow: cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
textShadow:
showContent && cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
outline: active ? '2px solid #fde68a' : undefined,
outlineOffset: 1,
opacity: showContent ? 1 : 0.7,
}}
>
{index === 0 ? '시작' : cell?.type}
{!showContent && (
<span className="absolute top-0.5 right-0.5 rounded bg-black/55 px-0.5 text-[8px] font-normal text-white/70">
숨김
</span>
)}
</button>
);
})}
@@ -262,6 +290,27 @@ function BoardEditor({
))}
</select>
</div>
<div className="flex flex-col gap-1 sm:col-span-2">
<label className="inline-flex items-center gap-2 text-sm text-white/85">
<input
type="checkbox"
checked={selectedCell.contentVisible !== false}
onChange={(e) =>
onChange(
updateBoardCell(
selected,
{ contentVisible: e.target.checked },
board,
),
)
}
/>
게임에서 이 칸 내용(이름·이미지) 표시
</label>
<p className="text-[11px] text-white/45">
끄면 판에는 색만 보이고, 착지 시 효과·안내는 그대로 동작합니다.
</p>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<div className="flex items-center justify-between gap-2">
<label className="text-xs text-white/60">
@@ -621,7 +670,13 @@ export default function Home() {
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
)}
<BoardEditor board={board} onChange={setBoard} />
<BoardEditor
board={board}
onChange={(next) => {
setBoardConfig(next);
setBoard(next);
}}
/>
<div className="grid gap-4 sm:grid-cols-2">
<AssetThumb