diff --git a/app/game/asset-pack.ts b/app/game/asset-pack.ts index 72d3e7f..f01e6ae 100644 --- a/app/game/asset-pack.ts +++ b/app/game/asset-pack.ts @@ -1,23 +1,44 @@ import JSZip from 'jszip'; import { + DICE_FACES, type DiceFace, + type DiceSlot, type DrinkmarbleAssets, getAssets, setBackgroundAsset, setChessmanAsset, + setDiceCount, setDiceFaceAsset, + setDiceFloorColor, + setDiceFloorImage, } from './asset-storage'; +import { + type BoardConfig, + getOrCreateBoardConfig, + normalizeBoardConfig, + setBoardConfig, +} from './board-config'; -const PACK_VERSION = 1; -const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6]; +const PACK_VERSION = 4; +const DICE_SLOTS: DiceSlot[] = [1, 2]; type PackManifest = { version: number; app: 'drinkmarble'; + settings?: { + diceCount?: 1 | 2; + diceFloorColor?: string; + }; files: { background?: string; chessman?: string; + diceFloor?: string; + /** 하위 호환: 단일 주사위 면 */ dice?: Partial>; + /** diceSets[1], diceSets[2] */ + diceSets?: Partial>>>; + board?: string; + cells?: Record; }; }; @@ -55,17 +76,34 @@ async function blobToDataUrl(blob: Blob): Promise { }); } +function boardForExport(board: BoardConfig): BoardConfig { + return { + ...board, + cells: board.cells.map((cell) => ({ + type: cell.type, + eventId: cell.eventId ?? null, + imageSrc: null, + })), + }; +} + /** - * 현재 적용 중인 애셋(커스텀 또는 기본 public)을 ZIP으로 내보낸다. - * drinkmarble-pack.zip + * 애셋 + 보드 설정을 ZIP으로 내보낸다. * ├── manifest.json + * ├── board.json + * ├── cells/N.* * ├── background.* * ├── chessman.* - * └── dice/1.* … 6.* + * ├── dice-floor.* + * └── dice/1|2/면.* */ -export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) { +export async function exportAssetPack( + assets: DrinkmarbleAssets = getAssets(), + board: BoardConfig = getOrCreateBoardConfig(), +) { const zip = new JSZip(); const files: PackManifest['files'] = {}; + const normalized = normalizeBoardConfig(board); const bgBlob = await srcToBlob(assets.background); const bgName = `background.${extFromBlob(bgBlob)}`; @@ -77,17 +115,52 @@ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) { zip.file(manName, manBlob); files.chessman = manName; - files.dice = {}; - for (const face of DICE_FACES) { - const faceBlob = await srcToBlob(assets.dice[face]); - const faceName = `dice/${face}.${extFromBlob(faceBlob)}`; - zip.file(faceName, faceBlob); - files.dice[face] = faceName; + if (assets.diceFloorImage) { + const floorBlob = await srcToBlob(assets.diceFloorImage); + const floorName = `dice-floor.${extFromBlob(floorBlob)}`; + zip.file(floorName, floorBlob); + files.diceFloor = floorName; } + files.diceSets = {}; + const slotMaps: Record = { + 1: assets.dice, + 2: assets.dice2, + }; + for (const slot of DICE_SLOTS) { + if (slot === 2 && assets.diceCount < 2) continue; + files.diceSets[slot] = {}; + for (const face of DICE_FACES) { + const faceBlob = await srcToBlob(slotMaps[slot][face]); + const faceName = `dice/${slot}/${face}.${extFromBlob(faceBlob)}`; + zip.file(faceName, faceBlob); + files.diceSets[slot]![face] = faceName; + } + } + + // 하위 호환: dice/면.* 도 1번 세트로 넣어둠 + files.dice = files.diceSets[1]; + + files.cells = {}; + for (let i = 0; i < normalized.cells.length; i++) { + const src = normalized.cells[i]?.imageSrc; + if (!src) continue; + const cellBlob = await srcToBlob(src); + const cellName = `cells/${i}.${extFromBlob(cellBlob)}`; + zip.file(cellName, cellBlob); + files.cells[String(i)] = cellName; + } + + zip.file('board.json', JSON.stringify(boardForExport(normalized), null, 2)); + files.board = 'board.json'; + const manifest: PackManifest = { version: PACK_VERSION, app: 'drinkmarble', + settings: { + diceCount: assets.diceCount, + diceFloorColor: assets.diceFloorColor, + }, files, }; zip.file('manifest.json', JSON.stringify(manifest, null, 2)); @@ -97,11 +170,10 @@ export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) { downloadBlob(zipBlob, `drinkmarble-pack-${stamp}.zip`); } -/** - * ZIP 팩을 읽어 localStorage에 반영한다. - * manifest.json이 없으면 관례적 파일명(background.*, chessman.*, dice/1.*)을 시도한다. - */ -export async function importAssetPack(file: File): Promise { +export async function importAssetPack(file: File): Promise<{ + assets: DrinkmarbleAssets; + board: BoardConfig; +}> { if (!file.name.toLowerCase().endsWith('.zip')) { throw new Error('ZIP 파일만 가져올 수 있습니다.'); } @@ -135,7 +207,6 @@ export async function importAssetPack(file: File): Promise { const entry = findEntry(path); if (entry) return entry; } - // 확장자만 다른 경우 for (const path of candidates) { const base = path.replace(/\.[^.]+$/, ''); const match = Object.keys(zip.files).find( @@ -160,6 +231,12 @@ export async function importAssetPack(file: File): Promise { 'chessman.png', 'chessman.webp', ]); + const floorEntry = resolvePath(manifest?.files.diceFloor, [ + 'dice-floor.jpg', + 'dice-floor.jpeg', + 'dice-floor.png', + 'dice-floor.webp', + ]); let imported = 0; @@ -171,23 +248,87 @@ export async function importAssetPack(file: File): Promise { setChessmanAsset(await blobToDataUrl(await manEntry.async('blob'))); imported += 1; } + if (floorEntry) { + setDiceFloorImage(await blobToDataUrl(await floorEntry.async('blob'))); + imported += 1; + } - for (const face of DICE_FACES) { - const faceEntry = resolvePath(manifest?.files.dice?.[face], [ - `dice/${face}.jpg`, - `dice/${face}.jpeg`, - `dice/${face}.png`, - `dice/${face}.webp`, - ]); - if (faceEntry) { - setDiceFaceAsset(face, await blobToDataUrl(await faceEntry.async('blob'))); - imported += 1; + if (manifest?.settings?.diceFloorColor) { + setDiceFloorColor(manifest.settings.diceFloorColor); + imported += 1; + } + if (manifest?.settings?.diceCount === 1 || manifest?.settings?.diceCount === 2) { + setDiceCount(manifest.settings.diceCount); + imported += 1; + } + + for (const slot of DICE_SLOTS) { + for (const face of DICE_FACES) { + const preferred = + manifest?.files.diceSets?.[slot]?.[face] ?? + (slot === 1 ? manifest?.files.dice?.[face] : undefined); + const faceEntry = resolvePath(preferred, [ + `dice/${slot}/${face}.jpg`, + `dice/${slot}/${face}.jpeg`, + `dice/${slot}/${face}.png`, + `dice/${slot}/${face}.webp`, + ...(slot === 1 + ? [ + `dice/${face}.jpg`, + `dice/${face}.jpeg`, + `dice/${face}.png`, + `dice/${face}.webp`, + ] + : []), + ]); + if (faceEntry) { + setDiceFaceAsset(slot, face, await blobToDataUrl(await faceEntry.async('blob'))); + imported += 1; + } } } - if (imported === 0) { - throw new Error('팩에서 사용할 이미지를 찾지 못했습니다.'); + const boardEntry = resolvePath(manifest?.files.board, ['board.json']); + let board = getOrCreateBoardConfig(); + if (boardEntry) { + const text = await boardEntry.async('string'); + board = normalizeBoardConfig(JSON.parse(text) as BoardConfig); + imported += 1; } - return getAssets(); + const cellImages: Record = {}; + + if (manifest?.files.cells) { + for (const [indexKey, path] of Object.entries(manifest.files.cells)) { + const entry = resolvePath(path); + if (!entry) continue; + const index = Number(indexKey); + if (!Number.isFinite(index)) continue; + cellImages[index] = await blobToDataUrl(await entry.async('blob')); + } + } else { + for (const name of Object.keys(zip.files)) { + if (zip.files[name].dir) continue; + const match = /^cells\/(\d+)\./i.exec(name); + if (!match) continue; + const entry = zip.file(name); + if (!entry) continue; + cellImages[Number(match[1])] = await blobToDataUrl(await entry.async('blob')); + } + } + + if (Object.keys(cellImages).length > 0 || boardEntry) { + const cells = board.cells.map((cell, index) => ({ + ...cell, + imageSrc: cellImages[index] ?? cell.imageSrc ?? null, + })); + board = normalizeBoardConfig({ ...board, cells }); + setBoardConfig(board); + } + + if (imported === 0 && Object.keys(cellImages).length === 0) { + throw new Error('팩에서 사용할 데이터를 찾지 못했습니다.'); + } + + return { assets: getAssets(), board: getOrCreateBoardConfig() }; } diff --git a/app/game/asset-storage.ts b/app/game/asset-storage.ts index 78a98ed..df5b8f0 100644 --- a/app/game/asset-storage.ts +++ b/app/game/asset-storage.ts @@ -1,30 +1,54 @@ export type DiceFace = 1 | 2 | 3 | 4 | 5 | 6; +export type DiceSlot = 1 | 2; +export type DiceCount = 1 | 2; +export type DiceFaceMap = Record; export type DrinkmarbleAssets = { background: string; chessman: string; - dice: Record; + diceCount: DiceCount; + /** 1번 주사위 면 */ + dice: DiceFaceMap; + /** 2번 주사위 면 */ + dice2: DiceFaceMap; + /** 주사위판 바닥 색 (이미지가 없을 때) */ + diceFloorColor: string; + /** 주사위판 바닥 이미지 (있으면 색 대신 사용) */ + diceFloorImage: string | null; }; const STORAGE_KEY = 'drinkmarble.assets'; +const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6]; + +const DEFAULT_DICE: DiceFaceMap = { + 1: '/dice/1.png', + 2: '/dice/2.png', + 3: '/dice/3.png', + 4: '/dice/4.png', + 5: '/dice/5.png', + 6: '/dice/6.png', +}; + +export const DEFAULT_DICE_FLOOR_COLOR = '#2563eb'; export const DEFAULT_ASSETS: DrinkmarbleAssets = { background: '/background/default.png', chessman: '/chessman/default.png', - dice: { - 1: '/dice/1.png', - 2: '/dice/2.png', - 3: '/dice/3.png', - 4: '/dice/4.png', - 5: '/dice/5.png', - 6: '/dice/6.png', - }, + diceCount: 1, + dice: { ...DEFAULT_DICE }, + dice2: { ...DEFAULT_DICE }, + diceFloorColor: DEFAULT_DICE_FLOOR_COLOR, + diceFloorImage: null, }; type StoredAssets = Partial<{ background: string; chessman: string; - dice: Partial>; + diceCount: DiceCount | number; + dice: Partial; + dice2: Partial; + diceFloorColor: string; + diceFloorImage: string | null; }>; function readStored(): StoredAssets { @@ -52,20 +76,47 @@ function writeStored(next: StoredAssets) { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } +function mergeDiceFaces( + stored: Partial | undefined, + fallback: DiceFaceMap, +): DiceFaceMap { + return { + 1: stored?.[1] || fallback[1], + 2: stored?.[2] || fallback[2], + 3: stored?.[3] || fallback[3], + 4: stored?.[4] || fallback[4], + 5: stored?.[5] || fallback[5], + 6: stored?.[6] || fallback[6], + }; +} + +function normalizeDiceCount(value: unknown): DiceCount { + return value === 2 ? 2 : 1; +} + +function normalizeFloorColor(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_DICE_FLOOR_COLOR; + const trimmed = value.trim(); + if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{3}$/.test(trimmed)) { + return trimmed; + } + return DEFAULT_DICE_FLOOR_COLOR; +} + /** 저장된 값이 있으면 쓰고, 없으면 서버 public 기본 경로를 쓴다. */ export function getAssets(): DrinkmarbleAssets { const stored = readStored(); return { background: stored.background || DEFAULT_ASSETS.background, chessman: stored.chessman || DEFAULT_ASSETS.chessman, - dice: { - 1: stored.dice?.[1] || DEFAULT_ASSETS.dice[1], - 2: stored.dice?.[2] || DEFAULT_ASSETS.dice[2], - 3: stored.dice?.[3] || DEFAULT_ASSETS.dice[3], - 4: stored.dice?.[4] || DEFAULT_ASSETS.dice[4], - 5: stored.dice?.[5] || DEFAULT_ASSETS.dice[5], - 6: stored.dice?.[6] || DEFAULT_ASSETS.dice[6], - }, + diceCount: normalizeDiceCount(stored.diceCount), + dice: mergeDiceFaces(stored.dice, DEFAULT_ASSETS.dice), + dice2: mergeDiceFaces(stored.dice2, DEFAULT_ASSETS.dice2), + diceFloorColor: normalizeFloorColor(stored.diceFloorColor), + diceFloorImage: + typeof stored.diceFloorImage === 'string' && stored.diceFloorImage.length > 0 + ? stored.diceFloorImage + : null, }; } @@ -79,14 +130,36 @@ export function setChessmanAsset(src: string) { writeStored({ ...stored, chessman: src }); } -export function setDiceFaceAsset(face: DiceFace, src: string) { +export function setDiceCount(count: DiceCount) { const stored = readStored(); + writeStored({ ...stored, diceCount: normalizeDiceCount(count) }); +} + +export function setDiceFaceAsset(slot: DiceSlot, face: DiceFace, src: string) { + const stored = readStored(); + if (slot === 2) { + writeStored({ + ...stored, + dice2: { ...stored.dice2, [face]: src }, + }); + return; + } writeStored({ ...stored, dice: { ...stored.dice, [face]: src }, }); } +export function setDiceFloorColor(color: string) { + const stored = readStored(); + writeStored({ ...stored, diceFloorColor: normalizeFloorColor(color) }); +} + +export function setDiceFloorImage(src: string) { + const stored = readStored(); + writeStored({ ...stored, diceFloorImage: src }); +} + export function clearBackgroundAsset() { const stored = readStored(); const { background: _removed, ...rest } = stored; @@ -99,31 +172,51 @@ export function clearChessmanAsset() { writeStored(rest); } -export function clearDiceFaceAsset(face: DiceFace) { +export function clearDiceFaceAsset(slot: DiceSlot, face: DiceFace) { const stored = readStored(); + if (slot === 2) { + if (!stored.dice2) return; + const dice2 = { ...stored.dice2 }; + delete dice2[face]; + writeStored({ ...stored, dice2 }); + return; + } if (!stored.dice) return; const dice = { ...stored.dice }; delete dice[face]; writeStored({ ...stored, dice }); } +export function clearDiceFloorColor() { + const stored = readStored(); + const { diceFloorColor: _removed, ...rest } = stored; + writeStored(rest); +} + +export function clearDiceFloorImage() { + const stored = readStored(); + const { diceFloorImage: _removed, ...rest } = stored; + writeStored(rest); +} + export function clearAllAssets() { if (typeof window === 'undefined') return; window.localStorage.removeItem(STORAGE_KEY); } /** boxGeometry material 순서: +X,-X,+Y,-Y,+Z,-Z = 2,5,1,6,3,4 */ -export function getDiceFaceTextureList(assets: DrinkmarbleAssets = getAssets()): string[] { - return [ - assets.dice[2], - assets.dice[5], - assets.dice[1], - assets.dice[6], - assets.dice[3], - assets.dice[4], - ]; +export function getDiceFaceTextureList( + faces: DiceFaceMap = getAssets().dice, +): string[] { + return [faces[2], faces[5], faces[1], faces[6], faces[3], faces[4]]; } +export function getDiceFaceMap(slot: DiceSlot, assets: DrinkmarbleAssets = getAssets()) { + return slot === 2 ? assets.dice2 : assets.dice; +} + +export { DICE_FACES }; + export function isCustomAsset(src: string, fallback: string) { return src !== fallback && src.startsWith('data:'); } @@ -133,6 +226,8 @@ export const ASSET_ASPECT = { background: { w: 16, h: 9, label: '16:9' }, chessman: { w: 1, h: 1, label: '1:1' }, dice: { w: 1, h: 1, label: '1:1' }, + cell: { w: 1, h: 1, label: '1:1' }, + diceFloor: { w: 1, h: 1, label: '1:1' }, } as const; export type AssetKind = keyof typeof ASSET_ASPECT; diff --git a/app/game/board-config.ts b/app/game/board-config.ts new file mode 100644 index 0000000..eeb5937 --- /dev/null +++ b/app/game/board-config.ts @@ -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 { + 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, + 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; diff --git a/app/game/cell-event.ts b/app/game/cell-event.ts index 7b9d004..3590998 100644 --- a/app/game/cell-event.ts +++ b/app/game/cell-event.ts @@ -8,6 +8,8 @@ export type PlaneCell = { type: string; /** 효과가 있을 때만 설정. 없으면 착지 시 아무 일도 없음 */ eventId?: string | null; + /** 칸 이미지 (없으면 텍스트만) */ + imageSrc?: string | null; }; export type TurnContext = { diff --git a/app/game/chessman.ts b/app/game/chessman.ts index 4a477b3..8f8f2fb 100644 --- a/app/game/chessman.ts +++ b/app/game/chessman.ts @@ -46,14 +46,15 @@ export class Chessman { /** * 무인도 상태에서의 주사위 처리. - * - escape: 6(또는 escapeOn)으로 탈출 → 이후 정상 이동 + * - escape: escapeOn(기본 6)이 한 면이라도 나오면 탈출 → 이후 정상 이동 * - wait: 턴 소모 후 대기 (turnsLeft 0이면 자동 해제, 이번 턴은 이동 없음) * - free: 이미 무인도 아님 */ - resolveIslandRoll(roll: number): 'escape' | 'wait' | 'free' { + resolveIslandRoll(roll: number, faces?: number[]): 'escape' | 'wait' | 'free' { if (!this.island) return 'free'; - if (roll === this.island.escapeOn) { + const values = faces && faces.length > 0 ? faces : [roll]; + if (values.some((value) => value === this.island!.escapeOn)) { this.island = null; return 'escape'; } diff --git a/app/game/dice.tsx b/app/game/dice.tsx index 0901776..ebe3f6b 100644 --- a/app/game/dice.tsx +++ b/app/game/dice.tsx @@ -14,8 +14,15 @@ import { TextureLoader, Vector3, type OrthographicCamera, + type Texture, } from 'three'; -import { getDiceFaceTextureList } from './asset-storage'; +import { + DEFAULT_ASSETS, + DEFAULT_DICE_FLOOR_COLOR, + getAssets, + getDiceFaceTextureList, + type DiceCount, +} from './asset-storage'; const FLOOR_SIZE = 6; const FLOOR_HALF = FLOOR_SIZE / 2; @@ -27,15 +34,7 @@ const WALL_HALF_H = WALL_HEIGHT / 2; const STILL_SPEED_SQ = 0.05; const STILL_FRAMES = 18; -// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z (기본 경로, 런타임에 localStorage로 교체) -const DEFAULT_DICE_FACE_TEXTURES = [ - '/dice/2.png', - '/dice/5.png', - '/dice/1.png', - '/dice/6.png', - '/dice/3.png', - '/dice/4.png', -] as const; +const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice); // 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면 const DICE_FACES = [ @@ -51,6 +50,13 @@ const _quat = new Quaternion(); const _normal = new Vector3(); const WORLD_UP = new Vector3(0, 1, 0); +export type DiceRollResult = { + /** 이동에 쓰는 합 */ + sum: number; + /** 각 주사위 윗면 */ + faces: number[]; +}; + function readTopFace(body: RapierRigidBody): number { const r = body.rotation(); _quat.set(r.x, r.y, r.z, r.w); @@ -127,36 +133,104 @@ function DiceMesh({ faceUrls }: { faceUrls: string[] }) { ); } +function FloorMesh({ + color, + imageSrc, +}: { + color: string; + imageSrc: string | null; +}) { + const texture = useLoader( + TextureLoader, + imageSrc ?? '/background/default.png', + ) as Texture; + + useLayoutEffect(() => { + if (!imageSrc) return; + texture.colorSpace = SRGBColorSpace; + texture.anisotropy = 8; + texture.needsUpdate = true; + }, [imageSrc, texture]); + + return ( + + + {imageSrc ? ( + + ) : ( + + )} + + ); +} + +function FloorSurface({ + color, + imageSrc, +}: { + color: string; + imageSrc: string | null; +}) { + if (!imageSrc) { + return ( + + + + + ); + } + + return ( + + + + + } + > + + + ); +} + +function isBodyStill(body: RapierRigidBody) { + const lv = body.linvel(); + const av = body.angvel(); + const speedSq = + lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z; + return speedSq < STILL_SPEED_SQ || body.isSleeping(); +} + function DiceSettleWatcher({ - diceRef, + diceRefs, rolling, onSettled, }: { - diceRef: React.RefObject; + diceRefs: React.RefObject[]; rolling: boolean; - onSettled: (value: number) => void; + onSettled: (result: DiceRollResult) => void; }) { const stillFrames = useRef(0); const reported = useRef(false); useFrame(() => { - const body = diceRef.current; - if (!body || !rolling) { + if (!rolling) { stillFrames.current = 0; reported.current = false; return; } - const lv = body.linvel(); - const av = body.angvel(); - const speedSq = - lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z; + const bodies = diceRefs.map((ref) => ref.current); + if (bodies.some((body) => !body)) return; - if (speedSq < STILL_SPEED_SQ || body.isSleeping()) { + if (bodies.every((body) => isBodyStill(body!))) { stillFrames.current += 1; if (stillFrames.current >= STILL_FRAMES && !reported.current) { reported.current = true; - onSettled(readTopFace(body)); + const faces = bodies.map((body) => readTopFace(body!)); + const sum = faces.reduce((a, b) => a + b, 0); + onSettled({ sum, faces }); } } else { stillFrames.current = 0; @@ -167,16 +241,24 @@ function DiceSettleWatcher({ } function RapierDiceExample({ - diceRef, + diceRefs, + diceCount, rolling, onSettled, - faceUrls, + faceUrlSets, + floorColor, + floorImage, }: { - diceRef: React.RefObject; + diceRefs: React.RefObject[]; + diceCount: DiceCount; rolling: boolean; - onSettled: (value: number) => void; - faceUrls: string[]; + onSettled: (result: DiceRollResult) => void; + faceUrlSets: string[][]; + floorColor: string; + floorImage: string | null; }) { + const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0]; + return ( - - - - + - - - - - + {spawnX.map((x, index) => ( + + + + + + ))} - + ); @@ -230,27 +323,48 @@ function rollDice(body: RapierRigidBody | null) { ); } +function formatResult(result: DiceRollResult | null) { + if (!result) return '결과: —'; + if (result.faces.length <= 1) return `결과: ${result.sum}`; + return `결과: ${result.faces.join(' + ')} = ${result.sum}`; +} + export default function Dice({ onRolled, disabled = false, }: { - onRolled?: (value: number) => void; + onRolled?: (result: DiceRollResult) => void; disabled?: boolean; }) { - const diceRef = useRef(null); + const diceRefA = useRef(null); + const diceRefB = useRef(null); + const diceRefs = [diceRefA, diceRefB]; const [rolling, setRolling] = useState(false); - const [result, setResult] = useState(null); - const [faceUrls, setFaceUrls] = useState([...DEFAULT_DICE_FACE_TEXTURES]); + const [result, setResult] = useState(null); + const [diceCount, setDiceCount] = useState(1); + const [faceUrlSets, setFaceUrlSets] = useState([ + [...DEFAULT_DICE_FACE_TEXTURES], + ]); + const [floorColor, setFloorColor] = useState(DEFAULT_DICE_FLOOR_COLOR); + const [floorImage, setFloorImage] = useState(null); useEffect(() => { - setFaceUrls(getDiceFaceTextureList()); + const assets = getAssets(); + setDiceCount(assets.diceCount); + setFaceUrlSets( + assets.diceCount === 2 + ? [getDiceFaceTextureList(assets.dice), getDiceFaceTextureList(assets.dice2)] + : [getDiceFaceTextureList(assets.dice)], + ); + setFloorColor(assets.diceFloorColor); + setFloorImage(assets.diceFloorImage); }, []); const onSettled = useCallback( - (value: number) => { - setResult(value); + (next: DiceRollResult) => { + setResult(next); setRolling(false); - onRolled?.(value); + onRolled?.(next); }, [onRolled], ); @@ -261,10 +375,13 @@ export default function Dice({
diff --git a/app/game/diceplane.tsx b/app/game/diceplane.tsx index d471d5e..0abc616 100644 --- a/app/game/diceplane.tsx +++ b/app/game/diceplane.tsx @@ -4,86 +4,32 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import Dice from './dice'; import { Chessman } from './chessman'; import ChessmanPiece from './chessman-piece'; -import { - applyLandEvent, - type PlaneCell, -} from './cell-event'; +import { applyLandEvent, type PlaneCell } from './cell-event'; import { getAssets } from './asset-storage'; +import { + buildPlaneCells, + cellColorFor, + diceAreaSpan, + getOrCreateBoardConfig, +} from './board-config'; -const PLANE_SIZE = { width: 9, height: 7 } as const; const STEP_MS = 280; -const CELL_TYPES = ['한잔', '안주', '모두한잔', '두잔', '벌칙1', '벌칙2'] as const; - -/** 특수 칸 배치 (28칸 기준 인덱스) */ -const SPECIAL_CELLS: Record = { - 8: { type: '무인도', eventId: 'island' }, - 14: { type: '역방향', eventId: 'reverse' }, - 22: { type: '비행기', eventId: 'airplane' }, // → 시작(0) -}; - -function randomType() { - return CELL_TYPES[Math.floor(Math.random() * CELL_TYPES.length)]; -} - function cellLabel(cell: PlaneCell | undefined, index: number) { if (!cell) return ''; return index === 0 ? '시작' : cell.type; } -function cellColor(cell: PlaneCell, index: number) { - if (index === 0) return '#059669'; - switch (cell.eventId) { - case 'island': - return '#0f766e'; - case 'reverse': - return '#7c3aed'; - case 'airplane': - return '#0284c7'; - default: - return '#e11d48'; - } -} - -/** 시계방향 둘레 칸. 모서리는 한 번만 넣어서 9x7 기준 28칸이 된다. */ -function buildPerimeter(width: number, height: number, assignType: () => string): PlaneCell[] { - const cells: PlaneCell[] = []; - let id = 0; - - for (let x = 0; x < width; x++) { - cells.push({ id: id++, x, y: 0, type: assignType() }); - } - for (let y = 1; y < height; y++) { - cells.push({ id: id++, x: width - 1, y, type: assignType() }); - } - for (let x = width - 2; x >= 0; x--) { - cells.push({ id: id++, x, y: height - 1, type: assignType() }); - } - for (let y = height - 2; y >= 1; y--) { - cells.push({ id: id++, x: 0, y, type: assignType() }); - } - - if (cells[0]) { - cells[0].type = '시작'; - cells[0].eventId = null; - } - - for (const [indexText, special] of Object.entries(SPECIAL_CELLS)) { - const index = Number(indexText); - if (cells[index]) { - cells[index].type = special.type; - cells[index].eventId = special.eventId; - } - } - - return cells; -} - export default function DicePlane() { - const [dicePlaneArray, setDicePlaneArray] = useState(() => - buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, () => 'none'), + const initial = getOrCreateBoardConfig(); + const [planeSize, setPlaneSize] = useState({ + width: initial.width, + height: initial.height, + }); + const [dicePlaneArray, setDicePlaneArray] = useState(() => + buildPlaneCells(initial), ); - const chessmanRef = useRef(new Chessman(28)); + const chessmanRef = useRef(new Chessman(initial.cells.length)); const boardRef = useRef(dicePlaneArray); const [chessmanIndex, setChessmanIndex] = useState(0); const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png'); @@ -96,9 +42,11 @@ export default function DicePlane() { const moveTimerRef = useRef | null>(null); useEffect(() => { - const cells = buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, randomType); + const board = getOrCreateBoardConfig(); + const cells = buildPlaneCells(board); chessmanRef.current = new Chessman(cells.length, 0); boardRef.current = cells; + setPlaneSize({ width: board.width, height: board.height }); setChessmanIndex(0); setChessmanSrc(getAssets().chessman); setHopKey(0); @@ -180,15 +128,15 @@ export default function DicePlane() { ); const onDiceRolled = useCallback( - (value: number) => { - const roll = Math.max(0, Math.trunc(value)); + (rollResult: { sum: number; faces: number[] }) => { + const roll = Math.max(0, Math.trunc(rollResult.sum)); if (roll === 0) return; setLandedLabel(null); const man = chessmanRef.current; if (man.isOnIsland) { - const outcome = man.resolveIslandRoll(roll); + const outcome = man.resolveIslandRoll(roll, rollResult.faces); setIslandTurnsLeft(man.islandTurnsLeft); if (outcome === 'wait') { showBanner( @@ -198,7 +146,6 @@ export default function DicePlane() { ); return; } - // escape with 6 showBanner('무인도 탈출!'); } @@ -208,14 +155,15 @@ export default function DicePlane() { ); const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0]; + const diceSpan = diceAreaSpan(planeSize.width, planeSize.height); return (
{isStart ? '시작' : plane.type} @@ -268,8 +220,8 @@ export default function DicePlane() {
void; +}) { + const geo = buildPerimeterGeometry(board.width, board.height); + const [selected, setSelected] = useState(1); + const cellImageInputRef = useRef(null); + + useEffect(() => { + if (selected >= board.cells.length) { + setSelected(Math.min(1, board.cells.length - 1)); + } + }, [board.cells.length, selected]); + + const selectedCell = board.cells[selected]; + const cellCount = perimeterLength(board.width, board.height); + + const applySize = (width: number, height: number) => { + onChange(resizeBoardConfig(width, height, board)); + }; + + const onCellImagePick = async (file: File) => { + const aspect = ASSET_ASPECT.cell; + const dataUrl = await fileToAssetDataUrl(file, { + aspectW: aspect.w, + aspectH: aspect.h, + maxW: 512, + maxH: 512, + quality: 0.88, + }); + onChange(updateBoardCell(selected, { imageSrc: dataUrl }, board)); + }; + + return ( +
+
+
+

주사위판 칸 (board)

+

+ 가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지를 설정합니다. 0번(시작) 이름은 고정입니다. +

+
+ +
+ +
+
+ + applySize(Number(e.target.value), board.height)} + className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white" + /> +
+
+ + applySize(board.width, Number(e.target.value))} + className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white" + /> +
+

+ {MIN_PLANE_SIZE}~{MAX_PLANE_SIZE} · 둘레 {cellCount}칸 +

+
+ +
+ {geo.map((g, index) => { + const cell = board.cells[index]; + const active = selected === index; + return ( + + ); + })} +
+ + {selectedCell && ( +
+
+ + onChange(updateBoardCell(selected, { type: e.target.value }, board))} + className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50" + /> + + {CELL_TYPE_PRESETS.map((t) => ( + +
+
+ + +
+
+
+ +
+ + +
+
+
+ { + const file = e.target.files?.[0]; + if (file) void onCellImagePick(file).catch(() => undefined); + e.target.value = ''; + }} + /> +
+
+ )} +
+ ); +} + export default function Home() { const [assets, setAssets] = useState(DEFAULT_ASSETS); + const [board, setBoard] = useState(null); const [ready, setReady] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -90,6 +325,7 @@ export default function Home() { const reload = () => { setAssets(getAssets()); + setBoard(getOrCreateBoardConfig()); }; useEffect(() => { @@ -97,21 +333,27 @@ export default function Home() { setReady(true); }, []); - const save = async (kind: 'background' | 'chessman' | DiceFace, file: File) => { + const save = async ( + kind: 'background' | 'chessman' | 'diceFloor' | { slot: DiceSlot; face: DiceFace }, + file: File, + ) => { try { - const aspectKind = kind === 'background' || kind === 'chessman' ? kind : 'dice'; + const aspectKind = + kind === 'background' || kind === 'chessman' || kind === 'diceFloor' ? kind : 'dice'; const aspect = ASSET_ASPECT[aspectKind]; const isBg = kind === 'background'; + const isFloor = kind === 'diceFloor'; const dataUrl = await fileToAssetDataUrl(file, { aspectW: aspect.w, aspectH: aspect.h, - maxW: isBg ? 1920 : 512, - maxH: isBg ? 1080 : 512, - quality: isBg ? 0.85 : 0.9, + maxW: isBg ? 1920 : isFloor ? 1024 : 512, + maxH: isBg ? 1080 : isFloor ? 1024 : 512, + quality: isBg || isFloor ? 0.85 : 0.9, }); if (kind === 'background') setBackgroundAsset(dataUrl); else if (kind === 'chessman') setChessmanAsset(dataUrl); - else setDiceFaceAsset(kind, dataUrl); + else if (kind === 'diceFloor') setDiceFloorImage(dataUrl); + else setDiceFaceAsset(kind.slot, kind.face, dataUrl); reload(); setError(null); } catch (e) { @@ -123,7 +365,7 @@ export default function Home() { try { setBusy(true); setError(null); - await exportAssetPack(assets); + await exportAssetPack(assets, board ?? getOrCreateBoardConfig()); } catch (e) { setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.'); } finally { @@ -153,9 +395,8 @@ export default function Home() {

온라인 주루마블

Drinkmarble

- 이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다. - 값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙 - 크롭됩니다. + 이미지와 판 칸 설정은 서버가 아니라 이 브라우저 localStorage에만 저장됩니다. + 처음이면 칸은 랜덤으로 채워집니다.

- {!ready ? ( + {!ready || !board ? (

불러오는 중…

) : (
-

커스텀 이미지

+

커스텀 설정

- 내보내기 ZIP 구조: manifest.json, background.*, chessman.*, dice/1.* … 6.* + ZIP: manifest.json, board.json, cells/N.*, background.*, chessman.*, + dice/1|2/면.*, dice-floor.*

{error && (

{error}

)} + +
-

- 주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label}) -

-
- {DICE_FACES.map((face) => ( +
+

주사위 설정

+
+ 개수 + {([1, 2] as DiceCount[]).map((count) => ( + + ))} +
+
+ +
+
+
+

주사위 판(바닥)

+

+ 색상 또는 이미지({ASSET_ASPECT.diceFloor.label}) · 이미지가 있으면 색 대신 사용 +

+
+
+ +
+
+
+ void save(face, file)} + label="바닥 이미지" + ratioLabel={ASSET_ASPECT.diceFloor.label} + previewClassName="aspect-square w-28" + src={ + assets.diceFloorImage ?? + `data:image/svg+xml,${encodeURIComponent( + ``, + )}` + } + onPick={(file) => void save('diceFloor', file)} onReset={() => { - clearDiceFaceAsset(face); + clearDiceFloorImage(); reload(); }} /> - ))} +
-
-

- 이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다. -

+ {([1, 2] as DiceSlot[]) + .filter((slot) => slot === 1 || assets.diceCount === 2) + .map((slot) => { + const faces = slot === 1 ? assets.dice : assets.dice2; + return ( +
+

+ {assets.diceCount === 2 ? `${slot}번 주사위 면` : '주사위 면'} (1~6 ·{' '} + {ASSET_ASPECT.dice.label}) +

+
+ {DICE_FACES.map((face) => ( + void save({ slot, face }, file)} + onReset={() => { + clearDiceFaceAsset(slot, face); + reload(); + }} + /> + ))} +
+
+ ); + })} +
)}