- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.
- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
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';
|
||||
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
<DicePlane />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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