diff --git a/app/game/asset-pack.ts b/app/game/asset-pack.ts new file mode 100644 index 0000000..72d3e7f --- /dev/null +++ b/app/game/asset-pack.ts @@ -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>; + }; +}; + +async function srcToBlob(src: string): Promise { + 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 { + 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 { + 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(); +} diff --git a/app/game/asset-storage.ts b/app/game/asset-storage.ts new file mode 100644 index 0000000..78a98ed --- /dev/null +++ b/app/game/asset-storage.ts @@ -0,0 +1,217 @@ +export type DiceFace = 1 | 2 | 3 | 4 | 5 | 6; + +export type DrinkmarbleAssets = { + background: string; + chessman: string; + dice: Record; +}; + +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>; +}>; + +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 { + 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); + }); +} diff --git a/app/game/dice.tsx b/app/game/dice.tsx index ab5ef89..0901776 100644 --- a/app/game/dice.tsx +++ b/app/game/dice.tsx @@ -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() { {textures.map((texture, index) => ( ; rolling: boolean; onSettled: (value: number) => void; + faceUrls: string[]; }) { return ( - + @@ -237,6 +240,11 @@ export default function Dice({ const diceRef = useRef(null); const [rolling, setRolling] = useState(false); const [result, setResult] = useState(null); + const [faceUrls, setFaceUrls] = useState([...DEFAULT_DICE_FACE_TEXTURES]); + + useEffect(() => { + setFaceUrls(getDiceFaceTextureList()); + }, []); const onSettled = useCallback( (value: number) => { @@ -252,7 +260,12 @@ export default function Dice({ return (
- +
- +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {label} +
+ { + const file = e.target.files?.[0]; + if (file) onPick(file); + e.target.value = ''; + }} + /> + + ); +} + +export default function Home() { + const [assets, setAssets] = useState(DEFAULT_ASSETS); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const packInputRef = useRef(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 ( +
+
+
+
+

온라인 주루마블

+

Drinkmarble

+

+ 이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다. + 값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙 + 크롭됩니다. +

+
+ + 게임 시작 + +
+ + {!ready ? ( +

불러오는 중…

+ ) : ( +
+
+

커스텀 이미지

+
+ + + + { + void onImport(e.target.files?.[0] ?? null); + e.target.value = ''; + }} + /> +
+
+ +

+ 내보내기 ZIP 구조: manifest.json, background.*, chessman.*, dice/1.* … 6.* +

+ + {error && ( +

{error}

+ )} + +
+ void save('background', file)} + onReset={() => { + clearBackgroundAsset(); + reload(); + }} + /> + void save('chessman', file)} + onReset={() => { + clearChessmanAsset(); + reload(); + }} + /> +
+ +
+

+ 주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label}) +

+
+ {DICE_FACES.map((face) => ( + void save(face, file)} + onReset={() => { + clearDiceFaceAsset(face); + reload(); + }} + /> + ))} +
+
+ +

+ 이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다. +

+
+ )} +
); } diff --git a/package-lock.json b/package-lock.json index eb4ed0e..bc83b62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7c5cd3d..f40bc40 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/background/default.png b/public/background/default.png new file mode 100644 index 0000000..1528669 Binary files /dev/null and b/public/background/default.png differ