커스텀 필요한 항목들 모두 커스텀 가능하도록 수정.
This commit is contained in:
395
app/page.tsx
395
app/page.tsx
@@ -5,21 +5,43 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ASSET_ASPECT,
|
||||
DEFAULT_ASSETS,
|
||||
DEFAULT_DICE_FLOOR_COLOR,
|
||||
DICE_FACES,
|
||||
type DiceCount,
|
||||
type DiceFace,
|
||||
type DiceSlot,
|
||||
type DrinkmarbleAssets,
|
||||
clearAllAssets,
|
||||
clearBackgroundAsset,
|
||||
clearChessmanAsset,
|
||||
clearDiceFaceAsset,
|
||||
clearDiceFloorColor,
|
||||
clearDiceFloorImage,
|
||||
fileToAssetDataUrl,
|
||||
getAssets,
|
||||
setBackgroundAsset,
|
||||
setChessmanAsset,
|
||||
setDiceCount,
|
||||
setDiceFaceAsset,
|
||||
setDiceFloorColor,
|
||||
setDiceFloorImage,
|
||||
} from './game/asset-storage';
|
||||
import { exportAssetPack, importAssetPack } from './game/asset-pack';
|
||||
|
||||
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
|
||||
import {
|
||||
type BoardConfig,
|
||||
CELL_EVENT_OPTIONS,
|
||||
CELL_TYPE_PRESETS,
|
||||
MAX_PLANE_SIZE,
|
||||
MIN_PLANE_SIZE,
|
||||
buildPerimeterGeometry,
|
||||
cellColorFor,
|
||||
clearBoardConfig,
|
||||
getOrCreateBoardConfig,
|
||||
perimeterLength,
|
||||
randomizeAndSaveBoard,
|
||||
resizeBoardConfig,
|
||||
updateBoardCell,
|
||||
} from './game/board-config';
|
||||
|
||||
function AssetThumb({
|
||||
src,
|
||||
@@ -81,8 +103,221 @@ function AssetThumb({
|
||||
);
|
||||
}
|
||||
|
||||
function BoardEditor({
|
||||
board,
|
||||
onChange,
|
||||
}: {
|
||||
board: BoardConfig;
|
||||
onChange: (next: BoardConfig) => void;
|
||||
}) {
|
||||
const geo = buildPerimeterGeometry(board.width, board.height);
|
||||
const [selected, setSelected] = useState(1);
|
||||
const cellImageInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
|
||||
<p className="text-[11px] text-white/50">
|
||||
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지를 설정합니다. 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>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg bg-black/25 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">가로 (칸)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_PLANE_SIZE}
|
||||
max={MAX_PLANE_SIZE}
|
||||
value={board.width}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">세로 (칸)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_PLANE_SIZE}
|
||||
max={MAX_PLANE_SIZE}
|
||||
value={board.height}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<p className="pb-1.5 text-xs text-white/50">
|
||||
{MIN_PLANE_SIZE}~{MAX_PLANE_SIZE} · 둘레 {cellCount}칸
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid gap-1"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${board.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${board.height}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{geo.map((g, index) => {
|
||||
const cell = board.cells[index];
|
||||
const active = selected === index;
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
title={`#${index} ${cell?.type ?? ''}`}
|
||||
onClick={() => setSelected(index)}
|
||||
className="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,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
textShadow: cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
|
||||
outline: active ? '2px solid #fde68a' : undefined,
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
>
|
||||
{index === 0 ? '시작' : cell?.type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedCell && (
|
||||
<div className="grid gap-3 rounded-lg bg-black/25 p-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">칸 #{selected} 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
list="cell-type-presets"
|
||||
disabled={selected === 0}
|
||||
value={selected === 0 ? '시작' : selectedCell.type}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<datalist id="cell-type-presets">
|
||||
{CELL_TYPE_PRESETS.map((t) => (
|
||||
<option key={t} value={t} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-white/60">효과</label>
|
||||
<select
|
||||
disabled={selected === 0}
|
||||
value={selectedCell.eventId ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onChange(
|
||||
updateBoardCell(
|
||||
selected,
|
||||
{ eventId: value === '' ? null : value },
|
||||
board,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{CELL_EVENT_OPTIONS.map((opt) => (
|
||||
<option key={String(opt.id)} value={opt.id ?? ''}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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">
|
||||
칸 이미지 ({ASSET_ASPECT.cell.label} · 선택)
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-white/15 px-2 py-1 text-xs text-white hover:bg-white/25"
|
||||
onClick={() => cellImageInputRef.current?.click()}
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
|
||||
onClick={() =>
|
||||
onChange(updateBoardCell(selected, { imageSrc: null }, board))
|
||||
}
|
||||
>
|
||||
제거
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="h-20 w-20 overflow-hidden rounded-lg bg-black/40"
|
||||
style={{
|
||||
backgroundColor: cellColorFor(selectedCell.eventId, selected),
|
||||
backgroundImage: selectedCell.imageSrc
|
||||
? `url(${selectedCell.imageSrc})`
|
||||
: undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cellImageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void onCellImagePick(file).catch(() => undefined);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
|
||||
const [board, setBoard] = useState<BoardConfig | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
<p className="text-sm text-white/60">온라인 주루마블</p>
|
||||
<h1 className="mt-1 text-4xl font-bold tracking-tight">Drinkmarble</h1>
|
||||
<p className="mt-2 max-w-md text-sm text-white/65">
|
||||
이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다.
|
||||
값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙
|
||||
크롭됩니다.
|
||||
이미지와 판 칸 설정은 서버가 아니라 이 브라우저 localStorage에만 저장됩니다.
|
||||
처음이면 칸은 랜덤으로 채워집니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
@@ -166,12 +407,12 @@ export default function Home() {
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{!ready ? (
|
||||
{!ready || !board ? (
|
||||
<p className="text-white/50">불러오는 중…</p>
|
||||
) : (
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">커스텀 이미지</h2>
|
||||
<h2 className="text-lg font-semibold">커스텀 설정</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -195,11 +436,12 @@ export default function Home() {
|
||||
className="rounded bg-white/10 px-3 py-1.5 text-xs text-white/80 hover:bg-white/20 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
clearAllAssets();
|
||||
clearBoardConfig();
|
||||
reload();
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
전부 기본값으로
|
||||
전부 초기화
|
||||
</button>
|
||||
<input
|
||||
ref={packInputRef}
|
||||
@@ -215,13 +457,16 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-white/45">
|
||||
내보내기 ZIP 구조: manifest.json, background.*, chessman.*, dice/1.* … 6.*
|
||||
ZIP: manifest.json, board.json, cells/N.*, background.*, chessman.*,
|
||||
dice/1|2/면.*, dice-floor.*
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
|
||||
)}
|
||||
|
||||
<BoardEditor board={board} onChange={setBoard} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AssetThumb
|
||||
label="배경 (background)"
|
||||
@@ -248,30 +493,114 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white/80">
|
||||
주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label})
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{DICE_FACES.map((face) => (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-medium text-white/80">주사위 설정</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white/50">개수</span>
|
||||
{([1, 2] as DiceCount[]).map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
className={`rounded px-3 py-1 text-xs font-medium ${
|
||||
assets.diceCount === count
|
||||
? 'bg-amber-500 text-stone-950'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDiceCount(count);
|
||||
reload();
|
||||
}}
|
||||
>
|
||||
{count}개
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">주사위 판(바닥)</p>
|
||||
<p className="text-[11px] text-white/50">
|
||||
색상 또는 이미지({ASSET_ASPECT.diceFloor.label}) · 이미지가 있으면 색 대신 사용
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
|
||||
onClick={() => {
|
||||
clearDiceFloorColor();
|
||||
clearDiceFloorImage();
|
||||
reload();
|
||||
}}
|
||||
>
|
||||
기본
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex flex-col gap-1 text-xs text-white/60">
|
||||
바닥 색
|
||||
<input
|
||||
type="color"
|
||||
value={assets.diceFloorColor || DEFAULT_DICE_FLOOR_COLOR}
|
||||
onChange={(e) => {
|
||||
setDiceFloorColor(e.target.value);
|
||||
reload();
|
||||
}}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-white/15 bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
<AssetThumb
|
||||
key={face}
|
||||
label={`${face}면`}
|
||||
ratioLabel={ASSET_ASPECT.dice.label}
|
||||
previewClassName="aspect-square w-full"
|
||||
src={assets.dice[face]}
|
||||
onPick={(file) => void save(face, file)}
|
||||
label="바닥 이미지"
|
||||
ratioLabel={ASSET_ASPECT.diceFloor.label}
|
||||
previewClassName="aspect-square w-28"
|
||||
src={
|
||||
assets.diceFloorImage ??
|
||||
`data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="${assets.diceFloorColor}"/></svg>`,
|
||||
)}`
|
||||
}
|
||||
onPick={(file) => void save('diceFloor', file)}
|
||||
onReset={() => {
|
||||
clearDiceFaceAsset(face);
|
||||
clearDiceFloorImage();
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-white/45">
|
||||
이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다.
|
||||
</p>
|
||||
{([1, 2] as DiceSlot[])
|
||||
.filter((slot) => slot === 1 || assets.diceCount === 2)
|
||||
.map((slot) => {
|
||||
const faces = slot === 1 ? assets.dice : assets.dice2;
|
||||
return (
|
||||
<div key={slot} className="mb-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-white/70">
|
||||
{assets.diceCount === 2 ? `${slot}번 주사위 면` : '주사위 면'} (1~6 ·{' '}
|
||||
{ASSET_ASPECT.dice.label})
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{DICE_FACES.map((face) => (
|
||||
<AssetThumb
|
||||
key={`${slot}-${face}`}
|
||||
label={`${face}면`}
|
||||
ratioLabel={ASSET_ASPECT.dice.label}
|
||||
previewClassName="aspect-square w-full"
|
||||
src={faces[face]}
|
||||
onPick={(file) => void save({ slot, face }, file)}
|
||||
onReset={() => {
|
||||
clearDiceFaceAsset(slot, face);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user