281 lines
9.2 KiB
TypeScript
281 lines
9.2 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
ASSET_ASPECT,
|
|
DEFAULT_ASSETS,
|
|
type DiceFace,
|
|
type DrinkmarbleAssets,
|
|
clearAllAssets,
|
|
clearBackgroundAsset,
|
|
clearChessmanAsset,
|
|
clearDiceFaceAsset,
|
|
fileToAssetDataUrl,
|
|
getAssets,
|
|
setBackgroundAsset,
|
|
setChessmanAsset,
|
|
setDiceFaceAsset,
|
|
} from './game/asset-storage';
|
|
import { exportAssetPack, importAssetPack } from './game/asset-pack';
|
|
|
|
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export default function Home() {
|
|
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
|
|
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());
|
|
};
|
|
|
|
useEffect(() => {
|
|
reload();
|
|
setReady(true);
|
|
}, []);
|
|
|
|
const save = async (kind: 'background' | 'chessman' | DiceFace, file: File) => {
|
|
try {
|
|
const aspectKind = kind === 'background' || kind === 'chessman' ? kind : 'dice';
|
|
const aspect = ASSET_ASPECT[aspectKind];
|
|
const isBg = kind === 'background';
|
|
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,
|
|
});
|
|
if (kind === 'background') setBackgroundAsset(dataUrl);
|
|
else if (kind === 'chessman') setChessmanAsset(dataUrl);
|
|
else setDiceFaceAsset(kind, dataUrl);
|
|
reload();
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : '저장에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
const onExport = async () => {
|
|
try {
|
|
setBusy(true);
|
|
setError(null);
|
|
await exportAssetPack(assets);
|
|
} 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에만 저장됩니다.
|
|
값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙
|
|
크롭됩니다.
|
|
</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 ? (
|
|
<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();
|
|
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, background.*, chessman.*, dice/1.* … 6.*
|
|
</p>
|
|
|
|
{error && (
|
|
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
|
|
)}
|
|
|
|
<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>
|
|
<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) => (
|
|
<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)}
|
|
onReset={() => {
|
|
clearDiceFaceAsset(face);
|
|
reload();
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-white/45">
|
|
이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다.
|
|
</p>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|