- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.
- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
This commit is contained in:
193
app/game/asset-pack.ts
Normal file
193
app/game/asset-pack.ts
Normal 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
217
app/game/asset-storage.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber';
|
||||||
import {
|
import {
|
||||||
Physics,
|
Physics,
|
||||||
@@ -15,25 +15,26 @@ import {
|
|||||||
Vector3,
|
Vector3,
|
||||||
type OrthographicCamera,
|
type OrthographicCamera,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
|
import { getDiceFaceTextureList } from './asset-storage';
|
||||||
|
|
||||||
const FLOOR_SIZE = 10;
|
const FLOOR_SIZE = 6;
|
||||||
const FLOOR_HALF = FLOOR_SIZE / 2;
|
const FLOOR_HALF = FLOOR_SIZE / 2;
|
||||||
const WALL_THICKNESS = 0.2;
|
const WALL_THICKNESS = 0.25;
|
||||||
const WALL_HEIGHT = 10;
|
const WALL_HEIGHT = 8;
|
||||||
const WALL_HALF_T = WALL_THICKNESS / 2;
|
const WALL_HALF_T = WALL_THICKNESS / 2;
|
||||||
const WALL_HALF_H = WALL_HEIGHT / 2;
|
const WALL_HALF_H = WALL_HEIGHT / 2;
|
||||||
|
|
||||||
const STILL_SPEED_SQ = 0.05;
|
const STILL_SPEED_SQ = 0.05;
|
||||||
const STILL_FRAMES = 18;
|
const STILL_FRAMES = 18;
|
||||||
|
|
||||||
// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z
|
// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z (기본 경로, 런타임에 localStorage로 교체)
|
||||||
const DICE_FACE_TEXTURES = [
|
const DEFAULT_DICE_FACE_TEXTURES = [
|
||||||
'/dice/2.png', // +X
|
'/dice/2.png',
|
||||||
'/dice/5.png', // -X
|
'/dice/5.png',
|
||||||
'/dice/1.png', // +Y
|
'/dice/1.png',
|
||||||
'/dice/6.png', // -Y
|
'/dice/6.png',
|
||||||
'/dice/3.png', // +Z
|
'/dice/3.png',
|
||||||
'/dice/4.png', // -Z
|
'/dice/4.png',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
||||||
@@ -99,8 +100,8 @@ function InvisibleWalls() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiceMesh() {
|
function DiceMesh({ faceUrls }: { faceUrls: string[] }) {
|
||||||
const textures = useLoader(TextureLoader, [...DICE_FACE_TEXTURES]);
|
const textures = useLoader(TextureLoader, faceUrls);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
for (const texture of textures) {
|
for (const texture of textures) {
|
||||||
@@ -115,7 +116,7 @@ function DiceMesh() {
|
|||||||
<boxGeometry args={[1, 1, 1]} />
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
{textures.map((texture, index) => (
|
{textures.map((texture, index) => (
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
key={texture.uuid}
|
key={`${texture.uuid}-${index}`}
|
||||||
attach={`material-${index}`}
|
attach={`material-${index}`}
|
||||||
map={texture}
|
map={texture}
|
||||||
roughness={0.55}
|
roughness={0.55}
|
||||||
@@ -169,10 +170,12 @@ function RapierDiceExample({
|
|||||||
diceRef,
|
diceRef,
|
||||||
rolling,
|
rolling,
|
||||||
onSettled,
|
onSettled,
|
||||||
|
faceUrls,
|
||||||
}: {
|
}: {
|
||||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
diceRef: React.RefObject<RapierRigidBody | null>;
|
||||||
rolling: boolean;
|
rolling: boolean;
|
||||||
onSettled: (value: number) => void;
|
onSettled: (value: number) => void;
|
||||||
|
faceUrls: string[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
@@ -203,7 +206,7 @@ function RapierDiceExample({
|
|||||||
friction={0.6}
|
friction={0.6}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DiceMesh />
|
<DiceMesh key={faceUrls.join('|')} faceUrls={faceUrls} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</RigidBody>
|
</RigidBody>
|
||||||
|
|
||||||
@@ -237,6 +240,11 @@ export default function Dice({
|
|||||||
const diceRef = useRef<RapierRigidBody>(null);
|
const diceRef = useRef<RapierRigidBody>(null);
|
||||||
const [rolling, setRolling] = useState(false);
|
const [rolling, setRolling] = useState(false);
|
||||||
const [result, setResult] = useState<number | null>(null);
|
const [result, setResult] = useState<number | null>(null);
|
||||||
|
const [faceUrls, setFaceUrls] = useState<string[]>([...DEFAULT_DICE_FACE_TEXTURES]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFaceUrls(getDiceFaceTextureList());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onSettled = useCallback(
|
const onSettled = useCallback(
|
||||||
(value: number) => {
|
(value: number) => {
|
||||||
@@ -252,7 +260,12 @@ export default function Dice({
|
|||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
|
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
|
||||||
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
|
<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
|
<button
|
||||||
type="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"
|
className="absolute bottom-2 left-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
applyLandEvent,
|
applyLandEvent,
|
||||||
type PlaneCell,
|
type PlaneCell,
|
||||||
} from './cell-event';
|
} from './cell-event';
|
||||||
|
import { getAssets } from './asset-storage';
|
||||||
|
|
||||||
const PLANE_SIZE = { width: 9, height: 7 } as const;
|
const PLANE_SIZE = { width: 9, height: 7 } as const;
|
||||||
const STEP_MS = 280;
|
const STEP_MS = 280;
|
||||||
@@ -16,9 +17,9 @@ const CELL_TYPES = ['한잔', '안주', '모두한잔', '두잔', '벌칙1', '
|
|||||||
|
|
||||||
/** 특수 칸 배치 (28칸 기준 인덱스) */
|
/** 특수 칸 배치 (28칸 기준 인덱스) */
|
||||||
const SPECIAL_CELLS: Record<number, { type: string; eventId: string }> = {
|
const SPECIAL_CELLS: Record<number, { type: string; eventId: string }> = {
|
||||||
7: { type: '무인도', eventId: 'island' },
|
8: { type: '무인도', eventId: 'island' },
|
||||||
14: { type: '역방향', eventId: 'reverse' },
|
14: { type: '역방향', eventId: 'reverse' },
|
||||||
21: { type: '비행기', eventId: 'airplane' }, // → 시작(0)
|
22: { type: '비행기', eventId: 'airplane' }, // → 시작(0)
|
||||||
};
|
};
|
||||||
|
|
||||||
function randomType() {
|
function randomType() {
|
||||||
@@ -85,6 +86,7 @@ export default function DicePlane() {
|
|||||||
const chessmanRef = useRef(new Chessman(28));
|
const chessmanRef = useRef(new Chessman(28));
|
||||||
const boardRef = useRef(dicePlaneArray);
|
const boardRef = useRef(dicePlaneArray);
|
||||||
const [chessmanIndex, setChessmanIndex] = useState(0);
|
const [chessmanIndex, setChessmanIndex] = useState(0);
|
||||||
|
const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png');
|
||||||
const [hopKey, setHopKey] = useState(0);
|
const [hopKey, setHopKey] = useState(0);
|
||||||
const [moving, setMoving] = useState(false);
|
const [moving, setMoving] = useState(false);
|
||||||
const [landedLabel, setLandedLabel] = useState<string | null>(null);
|
const [landedLabel, setLandedLabel] = useState<string | null>(null);
|
||||||
@@ -98,6 +100,7 @@ export default function DicePlane() {
|
|||||||
chessmanRef.current = new Chessman(cells.length, 0);
|
chessmanRef.current = new Chessman(cells.length, 0);
|
||||||
boardRef.current = cells;
|
boardRef.current = cells;
|
||||||
setChessmanIndex(0);
|
setChessmanIndex(0);
|
||||||
|
setChessmanSrc(getAssets().chessman);
|
||||||
setHopKey(0);
|
setHopKey(0);
|
||||||
setLandedLabel(null);
|
setLandedLabel(null);
|
||||||
setStatusText('시계방향');
|
setStatusText('시계방향');
|
||||||
@@ -255,7 +258,12 @@ export default function DicePlane() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{chessmanCell && (
|
{chessmanCell && (
|
||||||
<ChessmanPiece x={chessmanCell.x} y={chessmanCell.y} hopKey={hopKey} />
|
<ChessmanPiece
|
||||||
|
x={chessmanCell.x}
|
||||||
|
y={chessmanCell.y}
|
||||||
|
hopKey={hopKey}
|
||||||
|
src={chessmanSrc}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import DicePlane from './diceplane';
|
import DicePlane from './diceplane';
|
||||||
import Dice from './dice';
|
import { DEFAULT_ASSETS, getAssets } from './asset-storage';
|
||||||
|
|
||||||
export default function GamePage() {
|
export default function GamePage() {
|
||||||
|
const [backgroundSrc, setBackgroundSrc] = useState(DEFAULT_ASSETS.background);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBackgroundSrc(getAssets().background);
|
||||||
|
setReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-screen overflow-hidden">
|
<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 />
|
<DicePlane />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
331
app/page.tsx
331
app/page.tsx
@@ -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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex flex-col gap-2 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
<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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Image
|
<div className="flex flex-col">
|
||||||
className="dark:invert h-5 w-[100px]"
|
<span className="text-sm font-medium text-white">{label}</span>
|
||||||
src="/next.svg"
|
<span className="text-[11px] text-white/50">비율 {ratioLabel} · 중앙 크롭</span>
|
||||||
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>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<div className="flex gap-1">
|
||||||
<a
|
<button
|
||||||
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]"
|
type="button"
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
className="rounded bg-white/15 px-2 py-1 text-xs text-white hover:bg-white/25"
|
||||||
target="_blank"
|
onClick={() => inputRef.current?.click()}
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
<Image
|
변경
|
||||||
className="dark:invert h-[14px] w-4"
|
</button>
|
||||||
src="/vercel.svg"
|
<button
|
||||||
alt="Vercel logomark"
|
type="button"
|
||||||
width={16}
|
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
|
||||||
height={14}
|
onClick={onReset}
|
||||||
/>
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
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>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@react-three/drei": "^10.7.8",
|
"@react-three/drei": "^10.7.8",
|
||||||
"@react-three/fiber": "^9.7.0",
|
"@react-three/fiber": "^9.7.0",
|
||||||
"@react-three/rapier": "^2.2.0",
|
"@react-three/rapier": "^2.2.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"next": "16.3.3",
|
"next": "16.3.3",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
@@ -2866,6 +2867,11 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/cross-env": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
@@ -4173,6 +4179,11 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -4728,6 +4739,17 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5480,6 +5502,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -5583,6 +5610,11 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/promise-worker-transferable": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -5814,6 +5865,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -5907,6 +5963,11 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/sharp": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.4",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||||
@@ -6109,6 +6170,14 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/utility-types": {
|
||||||
"version": "3.11.0",
|
"version": "3.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@react-three/drei": "^10.7.8",
|
"@react-three/drei": "^10.7.8",
|
||||||
"@react-three/fiber": "^9.7.0",
|
"@react-three/fiber": "^9.7.0",
|
||||||
"@react-three/rapier": "^2.2.0",
|
"@react-three/rapier": "^2.2.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"next": "16.3.3",
|
"next": "16.3.3",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
|
|||||||
BIN
public/background/default.png
Normal file
BIN
public/background/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Reference in New Issue
Block a user