- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.

- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
This commit is contained in:
kimyu
2026-09-08 01:03:21 +09:00
parent fc6eb5fce1
commit 1b99e3961d
9 changed files with 830 additions and 82 deletions

193
app/game/asset-pack.ts Normal file
View File

@@ -0,0 +1,193 @@
import JSZip from 'jszip';
import {
type DiceFace,
type DrinkmarbleAssets,
getAssets,
setBackgroundAsset,
setChessmanAsset,
setDiceFaceAsset,
} from './asset-storage';
const PACK_VERSION = 1;
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
type PackManifest = {
version: number;
app: 'drinkmarble';
files: {
background?: string;
chessman?: string;
dice?: Partial<Record<DiceFace, string>>;
};
};
async function srcToBlob(src: string): Promise<Blob> {
const res = await fetch(src);
if (!res.ok) {
throw new Error(`이미지를 읽지 못했습니다: ${src}`);
}
return res.blob();
}
function extFromBlob(blob: Blob, fallback = 'png') {
const type = blob.type;
if (type.includes('jpeg') || type.includes('jpg')) return 'jpg';
if (type.includes('webp')) return 'webp';
if (type.includes('png')) return 'png';
return fallback;
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('파일을 읽을 수 없습니다.'));
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(blob);
});
}
/**
* 현재 적용 중인 애셋(커스텀 또는 기본 public)을 ZIP으로 내보낸다.
* drinkmarble-pack.zip
* ├── manifest.json
* ├── background.*
* ├── chessman.*
* └── dice/1.* … 6.*
*/
export async function exportAssetPack(assets: DrinkmarbleAssets = getAssets()) {
const zip = new JSZip();
const files: PackManifest['files'] = {};
const bgBlob = await srcToBlob(assets.background);
const bgName = `background.${extFromBlob(bgBlob)}`;
zip.file(bgName, bgBlob);
files.background = bgName;
const manBlob = await srcToBlob(assets.chessman);
const manName = `chessman.${extFromBlob(manBlob)}`;
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;
}
const manifest: PackManifest = {
version: PACK_VERSION,
app: 'drinkmarble',
files,
};
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
const zipBlob = await zip.generateAsync({ type: 'blob' });
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
downloadBlob(zipBlob, `drinkmarble-pack-${stamp}.zip`);
}
/**
* ZIP 팩을 읽어 localStorage에 반영한다.
* manifest.json이 없으면 관례적 파일명(background.*, chessman.*, dice/1.*)을 시도한다.
*/
export async function importAssetPack(file: File): Promise<DrinkmarbleAssets> {
if (!file.name.toLowerCase().endsWith('.zip')) {
throw new Error('ZIP 파일만 가져올 수 있습니다.');
}
const zip = await JSZip.loadAsync(file);
const manifestFile = zip.file('manifest.json');
let manifest: PackManifest | null = null;
if (manifestFile) {
const text = await manifestFile.async('string');
const parsed = JSON.parse(text) as PackManifest;
if (parsed.app && parsed.app !== 'drinkmarble') {
throw new Error('Drinkmarble 팩이 아닙니다.');
}
manifest = parsed;
}
const findEntry = (path: string) => {
const exact = zip.file(path);
if (exact) return exact;
const lower = path.toLowerCase();
const match = Object.keys(zip.files).find(
(name) => !zip.files[name].dir && name.toLowerCase() === lower,
);
return match ? zip.file(match) : null;
};
const resolvePath = (preferred?: string, fallbacks: string[] = []) => {
const candidates = [preferred, ...fallbacks].filter(Boolean) as string[];
for (const path of candidates) {
const entry = findEntry(path);
if (entry) return entry;
}
// 확장자만 다른 경우
for (const path of candidates) {
const base = path.replace(/\.[^.]+$/, '');
const match = Object.keys(zip.files).find(
(name) =>
!zip.files[name].dir &&
name.replace(/\.[^.]+$/, '').toLowerCase() === base.toLowerCase(),
);
if (match) return zip.file(match);
}
return null;
};
const bgEntry = resolvePath(manifest?.files.background, [
'background.jpg',
'background.jpeg',
'background.png',
'background.webp',
]);
const manEntry = resolvePath(manifest?.files.chessman, [
'chessman.jpg',
'chessman.jpeg',
'chessman.png',
'chessman.webp',
]);
let imported = 0;
if (bgEntry) {
setBackgroundAsset(await blobToDataUrl(await bgEntry.async('blob')));
imported += 1;
}
if (manEntry) {
setChessmanAsset(await blobToDataUrl(await manEntry.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 (imported === 0) {
throw new Error('팩에서 사용할 이미지를 찾지 못했습니다.');
}
return getAssets();
}

217
app/game/asset-storage.ts Normal file
View File

@@ -0,0 +1,217 @@
export type DiceFace = 1 | 2 | 3 | 4 | 5 | 6;
export type DrinkmarbleAssets = {
background: string;
chessman: string;
dice: Record<DiceFace, string>;
};
const STORAGE_KEY = 'drinkmarble.assets';
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',
},
};
type StoredAssets = Partial<{
background: string;
chessman: string;
dice: Partial<Record<DiceFace, string>>;
}>;
function readStored(): StoredAssets {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as StoredAssets;
// 이전 단일 배경 키 마이그레이션
const legacyBg = window.localStorage.getItem('drinkmarble.backgroundSrc');
if (legacyBg) {
const migrated: StoredAssets = { background: legacyBg };
writeStored(migrated);
window.localStorage.removeItem('drinkmarble.backgroundSrc');
return migrated;
}
return {};
} catch {
return {};
}
}
function writeStored(next: StoredAssets) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
/** 저장된 값이 있으면 쓰고, 없으면 서버 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],
},
};
}
export function setBackgroundAsset(src: string) {
const stored = readStored();
writeStored({ ...stored, background: src });
}
export function setChessmanAsset(src: string) {
const stored = readStored();
writeStored({ ...stored, chessman: src });
}
export function setDiceFaceAsset(face: DiceFace, src: string) {
const stored = readStored();
writeStored({
...stored,
dice: { ...stored.dice, [face]: src },
});
}
export function clearBackgroundAsset() {
const stored = readStored();
const { background: _removed, ...rest } = stored;
writeStored(rest);
}
export function clearChessmanAsset() {
const stored = readStored();
const { chessman: _removed, ...rest } = stored;
writeStored(rest);
}
export function clearDiceFaceAsset(face: DiceFace) {
const stored = readStored();
if (!stored.dice) return;
const dice = { ...stored.dice };
delete dice[face];
writeStored({ ...stored, dice });
}
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 isCustomAsset(src: string, fallback: string) {
return src !== fallback && src.startsWith('data:');
}
/** 애셋별 고정 비율. 업로드 시 중앙 크롭 후 이 비율로 저장한다. */
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' },
} as const;
export type AssetKind = keyof typeof ASSET_ASPECT;
function centerCropRect(
srcW: number,
srcH: number,
aspectW: number,
aspectH: number,
) {
const target = aspectW / aspectH;
const source = srcW / srcH;
if (source > target) {
const sw = srcH * target;
return { sx: (srcW - sw) / 2, sy: 0, sw, sh: srcH };
}
const sh = srcW / target;
return { sx: 0, sy: (srcH - sh) / 2, sw: srcW, sh };
}
/** 업로드 이미지를 지정 비율로 중앙 크롭·리사이즈해 data URL로 만든다. */
export function fileToAssetDataUrl(
file: File,
options: {
aspectW: number;
aspectH: number;
maxW?: number;
maxH?: number;
quality?: number;
},
): Promise<string> {
const maxW = options.maxW ?? 1024;
const maxH = options.maxH ?? 1024;
const quality = options.quality ?? 0.85;
const aspect = options.aspectW / options.aspectH;
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('파일을 읽을 수 없습니다.'));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('이미지 형식이 올바르지 않습니다.'));
img.onload = () => {
const crop = centerCropRect(img.width, img.height, options.aspectW, options.aspectH);
// 출력 크기는 비율을 유지한 채 max 안에 맞춤
let outW = maxW;
let outH = Math.round(outW / aspect);
if (outH > maxH) {
outH = maxH;
outW = Math.round(outH * aspect);
}
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('canvas를 사용할 수 없습니다.'));
return;
}
ctx.drawImage(
img,
crop.sx,
crop.sy,
crop.sw,
crop.sh,
0,
0,
outW,
outH,
);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = String(reader.result);
};
reader.readAsDataURL(file);
});
}

View File

@@ -1,6 +1,6 @@
'use client';
import { Suspense, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber';
import {
Physics,
@@ -15,25 +15,26 @@ import {
Vector3,
type OrthographicCamera,
} from 'three';
import { getDiceFaceTextureList } from './asset-storage';
const FLOOR_SIZE = 10;
const FLOOR_SIZE = 6;
const FLOOR_HALF = FLOOR_SIZE / 2;
const WALL_THICKNESS = 0.2;
const WALL_HEIGHT = 10;
const WALL_THICKNESS = 0.25;
const WALL_HEIGHT = 8;
const WALL_HALF_T = WALL_THICKNESS / 2;
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
const DICE_FACE_TEXTURES = [
'/dice/2.png', // +X
'/dice/5.png', // -X
'/dice/1.png', // +Y
'/dice/6.png', // -Y
'/dice/3.png', // +Z
'/dice/4.png', // -Z
// 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;
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
@@ -99,8 +100,8 @@ function InvisibleWalls() {
);
}
function DiceMesh() {
const textures = useLoader(TextureLoader, [...DICE_FACE_TEXTURES]);
function DiceMesh({ faceUrls }: { faceUrls: string[] }) {
const textures = useLoader(TextureLoader, faceUrls);
useLayoutEffect(() => {
for (const texture of textures) {
@@ -115,7 +116,7 @@ function DiceMesh() {
<boxGeometry args={[1, 1, 1]} />
{textures.map((texture, index) => (
<meshStandardMaterial
key={texture.uuid}
key={`${texture.uuid}-${index}`}
attach={`material-${index}`}
map={texture}
roughness={0.55}
@@ -169,10 +170,12 @@ function RapierDiceExample({
diceRef,
rolling,
onSettled,
faceUrls,
}: {
diceRef: React.RefObject<RapierRigidBody | null>;
rolling: boolean;
onSettled: (value: number) => void;
faceUrls: string[];
}) {
return (
<Canvas
@@ -203,7 +206,7 @@ function RapierDiceExample({
friction={0.6}
>
<Suspense fallback={null}>
<DiceMesh />
<DiceMesh key={faceUrls.join('|')} faceUrls={faceUrls} />
</Suspense>
</RigidBody>
@@ -237,6 +240,11 @@ export default function Dice({
const diceRef = useRef<RapierRigidBody>(null);
const [rolling, setRolling] = useState(false);
const [result, setResult] = useState<number | null>(null);
const [faceUrls, setFaceUrls] = useState<string[]>([...DEFAULT_DICE_FACE_TEXTURES]);
useEffect(() => {
setFaceUrls(getDiceFaceTextureList());
}, []);
const onSettled = useCallback(
(value: number) => {
@@ -252,7 +260,12 @@ export default function Dice({
return (
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
<RapierDiceExample diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
<RapierDiceExample
diceRef={diceRef}
rolling={rolling}
onSettled={onSettled}
faceUrls={faceUrls}
/>
<button
type="button"
className="absolute bottom-2 left-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"

View File

@@ -8,6 +8,7 @@ import {
applyLandEvent,
type PlaneCell,
} from './cell-event';
import { getAssets } from './asset-storage';
const PLANE_SIZE = { width: 9, height: 7 } as const;
const STEP_MS = 280;
@@ -16,9 +17,9 @@ const CELL_TYPES = ['한잔', '안주', '모두한잔', '두잔', '벌칙1', '
/** 특수 칸 배치 (28칸 기준 인덱스) */
const SPECIAL_CELLS: Record<number, { type: string; eventId: string }> = {
7: { type: '무인도', eventId: 'island' },
8: { type: '무인도', eventId: 'island' },
14: { type: '역방향', eventId: 'reverse' },
21: { type: '비행기', eventId: 'airplane' }, // → 시작(0)
22: { type: '비행기', eventId: 'airplane' }, // → 시작(0)
};
function randomType() {
@@ -85,6 +86,7 @@ export default function DicePlane() {
const chessmanRef = useRef(new Chessman(28));
const boardRef = useRef(dicePlaneArray);
const [chessmanIndex, setChessmanIndex] = useState(0);
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
const [hopKey, setHopKey] = useState(0);
const [moving, setMoving] = useState(false);
const [landedLabel, setLandedLabel] = useState<string | null>(null);
@@ -98,6 +100,7 @@ export default function DicePlane() {
chessmanRef.current = new Chessman(cells.length, 0);
boardRef.current = cells;
setChessmanIndex(0);
setChessmanSrc(getAssets().chessman);
setHopKey(0);
setLandedLabel(null);
setStatusText('시계방향');
@@ -255,7 +258,12 @@ export default function DicePlane() {
})}
{chessmanCell && (
<ChessmanPiece x={chessmanCell.x} y={chessmanCell.y} hopKey={hopKey} />
<ChessmanPiece
x={chessmanCell.x}
y={chessmanCell.y}
hopKey={hopKey}
src={chessmanSrc}
/>
)}
<div

View File

@@ -1,12 +1,43 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import DicePlane from './diceplane';
import Dice from './dice';
import { DEFAULT_ASSETS, getAssets } from './asset-storage';
export default function GamePage() {
const [backgroundSrc, setBackgroundSrc] = useState(DEFAULT_ASSETS.background);
const [ready, setReady] = useState(false);
useEffect(() => {
setBackgroundSrc(getAssets().background);
setReady(true);
}, []);
return (
<div className="relative h-screen w-screen overflow-hidden">
{ready && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={backgroundSrc}
alt=""
aria-hidden
draggable={false}
className="pointer-events-none absolute inset-0 z-0 h-full w-full object-cover"
/>
)}
<div className="absolute inset-0 z-0 bg-black/25" aria-hidden />
<Link
href="/"
className="absolute top-3 left-3 z-40 rounded bg-black/70 px-3 py-1.5 text-sm text-white hover:bg-black/85"
>
설정으로
</Link>
<div className="relative z-10 h-full w-full">
<DicePlane />
</div>
</div>
);
}

View File

@@ -1,69 +1,280 @@
import Image from "next/image";
'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);
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<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 flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<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()}
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
변경
</button>
<button
type="button"
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
onClick={onReset}
>
Documentation
</a>
기본
</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>
</main>
</div>
);
}

74
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@react-three/rapier": "^2.2.0",
"jszip": "^3.10.1",
"next": "16.3.3",
"react": "19.2.8",
"react-dom": "19.2.8",
@@ -2866,6 +2867,11 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@@ -4173,6 +4179,11 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4728,6 +4739,17 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5480,6 +5502,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5583,6 +5610,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
@@ -5671,6 +5703,25 @@
}
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5814,6 +5865,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -5907,6 +5963,11 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
@@ -6109,6 +6170,14 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6740,6 +6809,11 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/utility-types": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",

View File

@@ -12,6 +12,7 @@
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@react-three/rapier": "^2.2.0",
"jszip": "^3.10.1",
"next": "16.3.3",
"react": "19.2.8",
"react-dom": "19.2.8",

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB