Files
drinkmarble/app/page.tsx

610 lines
22 KiB
TypeScript

'use client';
import Link from 'next/link';
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';
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,
label,
ratioLabel,
previewClassName,
onPick,
onReset,
}: {
src: string;
label: string;
ratioLabel: string;
previewClassName: string;
onPick: (file: File) => void;
onReset: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex flex-col gap-2 rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col">
<span className="text-sm font-medium text-white">{label}</span>
<span className="text-[11px] text-white/50">비율 {ratioLabel} · 중앙 크롭</span>
</div>
<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={() => inputRef.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={onReset}
>
기본
</button>
</div>
</div>
<div className={`overflow-hidden rounded-lg bg-black/30 ${previewClassName}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={label} className="h-full w-full object-cover" />
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
</div>
);
}
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);
const packInputRef = useRef<HTMLInputElement>(null);
const reload = () => {
setAssets(getAssets());
setBoard(getOrCreateBoardConfig());
};
useEffect(() => {
reload();
setReady(true);
}, []);
const save = async (
kind: 'background' | 'chessman' | 'diceFloor' | { slot: DiceSlot; face: DiceFace },
file: File,
) => {
try {
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 : 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 if (kind === 'diceFloor') setDiceFloorImage(dataUrl);
else setDiceFaceAsset(kind.slot, kind.face, dataUrl);
reload();
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : '저장에 실패했습니다.');
}
};
const onExport = async () => {
try {
setBusy(true);
setError(null);
await exportAssetPack(assets, board ?? getOrCreateBoardConfig());
} catch (e) {
setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.');
} finally {
setBusy(false);
}
};
const onImport = async (file: File | null) => {
if (!file) return;
try {
setBusy(true);
setError(null);
await importAssetPack(file);
reload();
} catch (e) {
setError(e instanceof Error ? e.message : '가져오기에 실패했습니다.');
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen bg-[#1c1224] text-white">
<div className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-6 py-10">
<header className="flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<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에만 저장됩니다.
처음이면 칸은 랜덤으로 채워집니다.
</p>
</div>
<Link
href="/game"
className="inline-flex items-center justify-center rounded-full bg-amber-500 px-6 py-3 text-base font-semibold text-stone-950 transition hover:bg-amber-400"
>
게임 시작
</Link>
</header>
{!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>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy}
className="rounded bg-sky-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-500 disabled:opacity-50"
onClick={() => void onExport()}
>
ZIP 내보내기
</button>
<button
type="button"
disabled={busy}
className="rounded bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600 disabled:opacity-50"
onClick={() => packInputRef.current?.click()}
>
ZIP 가져오기
</button>
<button
type="button"
disabled={busy}
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}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={(e) => {
void onImport(e.target.files?.[0] ?? null);
e.target.value = '';
}}
/>
</div>
</div>
<p className="text-xs text-white/45">
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)"
ratioLabel={ASSET_ASPECT.background.label}
previewClassName="aspect-video w-full"
src={assets.background}
onPick={(file) => void save('background', file)}
onReset={() => {
clearBackgroundAsset();
reload();
}}
/>
<AssetThumb
label="말 (chessman)"
ratioLabel={ASSET_ASPECT.chessman.label}
previewClassName="aspect-square w-40 mx-auto"
src={assets.chessman}
onPick={(file) => void save('chessman', file)}
onReset={() => {
clearChessmanAsset();
reload();
}}
/>
</div>
<div>
<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
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={() => {
clearDiceFloorImage();
reload();
}}
/>
</div>
</div>
{([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>
</div>
);
}