- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.
- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user