Levelyn์ RPG ์์๋ฅผ ๊ฒฐํฉํ ํด๋นํธ๋์ปค ์ ํ๋ฆฌ์ผ์ด์
์
๋๋ค. ์ฌ์ฉ์๋ ํ ์ผ์ ์๋ฃํ๋ฉฐ ์บ๋ฆญํฐ๋ฅผ ์ฑ์ฅ์ํค๊ณ , ํฅ์ฌ๊ณค ๋งต์ ํํํ๋ฉฐ, ๋ชฌ์คํฐ์ ์ ํฌ๋ฅผ ๋ฒ์ผ ์ ์์ต๋๋ค.
https://levelyn.p-e.kr/
ํ๋กํ | ์ญํ | ์ด๋ฆ | ๋ด๋น ์์ญ |
---|---|---|---|
![]() |
Frontend | ํฉ๋ค๊ฒฝ |
- Github actions + S3 ์๋ ๋ฐฐํฌ - ์นด์นด์ค OAuth 2.0 ์์ ๋ก๊ทธ์ธ ๊ตฌํ, ๋ผ์ฐํธ ๊ฐ๋ ๋ฐ ์ธ์ฆ ์ํ ๊ด๋ฆฌ - appwrite storage๋ฅผ ํ์ฉํ ๋์ ์ด๋ฏธ์ง ์๋น - ๋ฉ์ธ, ํ๋กํ ํ์ด์ง ๊ตฌํ - ์๋ ๊ธฐ๋ฐ ๋๋ค ํฅ์ฌ๊ณค ํ์ผ๋งต ์์คํ ๊ตฌํ - SSE ๊ธฐ๋ฐ ์ค์๊ฐ ์ ํฌ ์์คํ ๊ตฌํ - Recharts ๊ธฐ๋ฐ ์ฃผ๊ฐ ํต๊ณ ์ฐจํธ ๊ตฌํ - ๋ฐํ |
![]() |
Frontend | ์ ๊ธฐ์ |
- Husky ์ธํ
, ESLint+Prettier ์ฝ๋ ํ์ง ๊ด๋ฆฌ ์์คํ
๊ตฌ์ฃฝ - ๋์์ธ ํ ํฐ ๊ธฐ๋ฐ ํ ๋ง ์์คํ ๋ฐ ์ ์ญ ๋ ์ด์์ ๊ตฌ์ฑ - ๋๋๊ทธ ์ค ๋๋กญ ์์คํ ๋ฐ ๊ณต์ฉ ์์ดํ /์คํฌ ์ฌ๋กฏ ๊ตฌํ - ํ ์ผ ๋ฑ๋ก/์์ /์ญ์ ๊ตฌํ - ์ธ๋ฒคํ ๋ฆฌ(์คํฌ, ์์ดํ ) ํ๋ฉด ๋์์ธ ๋ฐ ๊ตฌํ - ์คํ๋์ ํ๋ฉด ๋ฐ PWA ๋ฉ๋ํ์คํธ ๊ตฌ์ฑ - React-Query ๊ธฐ๋ฐ ์๋ฒ ์ํ ๊ด๋ฆฌ - ๋ฐํ ์๋ฃ ์ ์ |
![]() |
Backend | ์ํธ์ | ๋ฐฑ์๋ ๊ฐ๋ฐ |

Levelyn-FE/
โโโ public/ # ์ ์ ํ์ผ
โ โโโ fonts/ # ํฐํธ ํ์ผ (Pretendard)
โ โโโ icons/ # ์ฑ ์์ด์ฝ
โ โโโ manifest.json # PWA ๋งค๋ํ์คํธ
โ
โโโ src/
โ โโโ assets/ # ์ด๋ฏธ์ง ๋ฐ ์ ์ ์์
โ โ โโโ avatar.png # ์บ๋ฆญํฐ ์๋ฐํ
โ โ โโโ background.png # ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง
โ โ โโโ home.png # ํ ๋ฐฐ๊ฒฝ
โ โ โโโ logo.png # ๋ก๊ณ
โ โ โโโ ...
โ โ
โ โโโ components/ # ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์ปดํฌ๋ํธ
โ โ โโโ common/ # ๊ณตํต ์ปดํฌ๋ํธ
โ โ โ โโโ BarChart.tsx # ์ฐจํธ ์ปดํฌ๋ํธ
โ โ โ โโโ Button.tsx # ๋ฒํผ ์ปดํฌ๋ํธ
โ โ โ โโโ CheckBox.tsx # ์ฒดํฌ๋ฐ์ค
โ โ โ โโโ Drawer.tsx # ๋๋ก์ด UI
โ โ โ โโโ Dropdown.tsx # ๋๋กญ๋ค์ด
โ โ โ โโโ Header.tsx # ํค๋
โ โ โ โโโ ItemBox.tsx # ์์ดํ
๋ฐ์ค
โ โ โ โโโ Modal/ # ๋ชจ๋ฌ ์ปดํฌ๋ํธ๋ค
โ โ โ โ โโโ CombatModalContent.tsx # ์ ํฌ ๋ชจ๋ฌ
โ โ โ โ โโโ EventModalContent.tsx # ์ด๋ฒคํธ ๋ชจ๋ฌ
โ โ โ โ โโโ index.tsx # ๊ธฐ๋ณธ ๋ชจ๋ฌ
โ โ โ โโโ ProgressBar.tsx # ํ๋ก๊ทธ๋ ์ค ๋ฐ
โ โ โ โโโ TextField.tsx # ํ
์คํธ ํ๋
โ โ โ โโโ tilemap.tsx # ํฅ์ฌ๊ณค ํ์ผ๋งต
โ โ โ โโโ TodoItem.tsx # ํ ์ผ ์์ดํ
โ โ โ
โ โ โโโ layout/ # ๋ ์ด์์ ์ปดํฌ๋ํธ
โ โ โโโ BottomNavigation/ # ํ๋จ ๋ค๋น๊ฒ์ด์
โ โ
โ โโโ contexts/ # React Context
โ โ โโโ AuthContext.tsx # ์ธ์ฆ ์ํ ๊ด๋ฆฌ
โ โ โโโ NotificationContext.tsx # ์๋ฆผ ๋ฐ ์ ํฌ ์ํ
โ โ
โ โโโ hooks/ # ์ปค์คํ
ํ
โ โ โโโ useDragAndDrop.ts # ๋๋๊ทธ ์ค ๋๋กญ ๋ก์ง
โ โ โโโ useHome.ts # ํ ํ๋ฉด ๋ก์ง
โ โ โโโ useInventory.ts # ์ธ๋ฒคํ ๋ฆฌ ๋ก์ง
โ โ
โ โโโ pages/ # ํ์ด์ง ์ปดํฌ๋ํธ
โ โ โโโ home/ # ํ ํ์ด์ง
โ โ โโโ login/ # ๋ก๊ทธ์ธ ํ์ด์ง
โ โ โโโ Inventory/ # ์ธ๋ฒคํ ๋ฆฌ ํ์ด์ง
โ โ โโโ Profile/ # ํ๋กํ ํ์ด์ง
โ โ โโโ CreateTodo/ # ํ ์ผ ์์ฑ
โ โ โโโ EditTodo/ # ํ ์ผ ์์
โ โ โโโ KakaoCallback/ # ์นด์นด์ค ๋ก๊ทธ์ธ ์ฝ๋ฐฑ
โ โ โโโ ErrorPage/ # ์๋ฌ ํ์ด์ง
โ โ
โ โโโ services/ # API ์๋น์ค ๋ ์ด์ด
โ โ โโโ api.ts # ๊ธฐ๋ณธ API ์ค์
โ โ โโโ appwrite.ts # Appwrite ํด๋ผ์ด์ธํธ
โ โ โโโ auth.ts # ์ธ์ฆ ๊ด๋ จ API
โ โ โโโ goal.ts # ๋ชฉํ ๊ด๋ จ API
โ โ โโโ inventory.ts # ์ธ๋ฒคํ ๋ฆฌ API
โ โ โโโ myPage.ts # ๋ง์ดํ์ด์ง API
โ โ โโโ sse.ts # SSE ์ฐ๊ฒฐ ๊ด๋ฆฌ
โ โ โโโ todo.ts # ํ ์ผ ๊ด๋ จ API
โ โ
โ โโโ stories/ # Storybook ์คํ ๋ฆฌ
โ โ โโโ button.stories.tsx
โ โ โโโ Modal.stories.tsx
โ โ โโโ ...
โ โ
โ โโโ styles/ # ์คํ์ผ ๊ด๋ จ
โ โ โโโ GlobalStyles.tsx # ์ ์ญ ์คํ์ผ
โ โ โโโ theme.ts # ํ
๋ง ์ค์
โ โ โโโ emotion.d.ts # Emotion ํ์
์ ์
โ โ โโโ tokens/ # ๋์์ธ ํ ํฐ
โ โ โโโ colors.ts # ์์ ํ๋ ํธ
โ โ โโโ textStyles.ts # ํ
์คํธ ์คํ์ผ
โ โ
โ โโโ types/ # TypeScript ํ์
์ ์
โ โ โโโ battle.types.ts # ์ ํฌ ๊ด๋ จ ํ์
โ โ โโโ goal.types.ts # ๋ชฉํ ๊ด๋ จ ํ์
โ โ โโโ inventory.types.ts # ์ธ๋ฒคํ ๋ฆฌ ํ์
โ โ โโโ myPage.types.ts # ๋ง์ดํ์ด์ง ํ์
โ โ โโโ todo.types.ts # ํ ์ผ ๊ด๋ จ ํ์
โ โ
โ โโโ utils/ # ์ ํธ๋ฆฌํฐ ํจ์
โ โ โโโ hexagon.ts # ํฅ์ฌ๊ณค ๊ด๋ จ ๊ณ์ฐ
โ โ โโโ localStorage.ts # ๋ก์ปฌ ์คํ ๋ฆฌ์ง ๊ด๋ฆฌ
โ โ โโโ longPressHandler.ts # ๋กฑํ๋ ์ค ํธ๋ค๋ฌ
โ โ
โ โโโ App.tsx # ๋ฉ์ธ ์ฑ ์ปดํฌ๋ํธ
โ โโโ main.tsx # ์ํธ๋ฆฌ ํฌ์ธํธ
โ โโโ vite-env.d.ts # Vite ํ๊ฒฝ ํ์
โ
โโโ .github/
โ โโโ workflows/
โ โโโ deploy.yml # GitHub Actions ๋ฐฐํฌ ์ํฌํ๋ก์ฐ
โ
โโโ package.json # ํ๋ก์ ํธ ์์กด์ฑ
โโโ vite.config.ts # Vite ์ค์
โโโ tsconfig.json # TypeScript ์ค์
โโโ jest.config.js # Jest ํ
์คํธ ์ค์
โโโ eslint.config.js # ESLint ์ค์
โโโ README.md # ํ๋ก์ ํธ ๋ฌธ์
- Node.js 18.0.0 ์ด์
- npm
- ๋ฆฌํฌ์งํ ๋ฆฌ ํด๋ก
git clone https://github.com/prgrms-fullstack-devcourse/Levelyn-FE.git
cd levelyn-fe
- ์์กด์ฑ ์ค์น
npm install
- ํ๊ฒฝ ๋ณ์ ์ค์
.env
ํ์ผ์ ์์ฑํ๊ณ ๋ค์ ๋ณ์๋ค์ ์ค์ ํ์ธ์:
VITE_KAKAO_REST_API_KEY=your_kakao_api_key
VITE_KAKAO_REDIRECT_URI=your_redirect_uri
VITE_APPWRITE_PROJECT_ID=your_appwrite_project_id
VITE_APPWRITE_ENDPOINT=your_appwrite_endpoint
VITE_APPWRITE_IMAGES_BUCKET_ID=your_bucket_id
- ๊ฐ๋ฐ ์๋ฒ ์คํ
npm run dev
- ๋ธ๋ผ์ฐ์ ์์ ํ์ธ
http://localhost:5173
์์ ์ ํ๋ฆฌ์ผ์ด์ ์ ํ์ธํ ์ ์์ต๋๋ค.
๊ฒ์์ ๋๋ฉ์ธ ๊ฐํ๋ฅผ ์ํ UX ๊ณ ๋ ค ์ค๊ณ
์ฌ์ฉ์ ์ธ๊ฒ์ด์ง๋จผํธ ์ง์์ฑ๊ณผ ํํ ๋๊ธฐ ๋ถ์ฌ๋ฅผ ์ํด ์๋ ๊ธฐ๋ฐ ๋๋ค ํฅ์ฌ๊ณค ํด๋ฌ์คํฐ ์์คํ ์ ๊ณ ์ํ์ต๋๋ค.
// ์๋ ๊ธฐ๋ฐ ๋๋ค ํจ์๋ก ์ผ๊ด์ฑ ์๋ ํจํด ์์ฑ
export const seededRandom = (seed: number, min = 0, max = 1): number => {
const x = Math.sin(seed) * 10000;
const random = x - Math.floor(x);
return min + random * (max - min);
};
// Axial ์ขํ๊ณ ๊ธฐ๋ฐ ํฅ์ฌ๊ณค ๋ฐฐ์น
export const axialToPixel = (q: number, r: number, size: number): [number, number] => {
const x = size * (1.5 * q);
const y = size * ((Math.sqrt(3) / 2) * q + Math.sqrt(3) * r);
return [x, y];
};
ํต์ฌ ๊ตฌํ ํฌ์ธํธ:
- 8๊ฐ ํฅ์ฌ๊ณค ์๋ฃ ์ ์๋ก์ด ๋งต ์์ฑ:
Math.floor(totalCompleted / MAX_HEXAGONS) + 1
์๋ ๊ณ์ฐ - ์ธ์ ํฅ์ฌ๊ณค ํ์ ์๊ณ ๋ฆฌ์ฆ: 6๋ฐฉํฅ ์ด์ ์ขํ๋ฅผ ํตํ ์์ฐ์ค๋ฌ์ด ํด๋ฌ์คํฐ ํ์ฅ
- Axial ์ขํ๊ณ ํ์ฉ: ํฅ์ฌ๊ณค ๊ทธ๋ฆฌ๋์ ์ํ์ ์ ํ์ฑ๊ณผ ํจ์จ์ ์ธ ๊ณ์ฐ
- ๋ฉ๋ชจ์ด์ ์ด์
์ต์ ํ:
useMemo
๋ฅผ ํตํ ์๋ ๋ณ๊ฒฝ ์์๋ง ์ฌ๊ณ์ฐ
๊ฒ์์ ๋ชฐ์ ๊ฐ์ ์ํ ์ค์๊ฐ ํต์ ์ํคํ ์ฒ
๊ธฐ์กด HTTP ํด๋ง์ ํ๊ณ๋ฅผ ๊ทน๋ณตํ๊ณ ์ค์๊ฐ ์ ํฌ์ ๊ธด๋ฐ๊ฐ์ ๊ตฌํํ๊ธฐ ์ํด SSE(Server-Sent Events)๋ฅผ ๋์ ํ์ต๋๋ค.
// SSE ์ฐ๊ฒฐ ๊ด๋ฆฌ ๋ฐ ๋ฉํฐํ๋ ์ฑ
export const connectSSE = (endpoint: string, eventHandlers: { [event: string]: (data: any) => void }) => {
const url = `${API_BASE_URL}${endpoint}?token=${token}`;
const eventSource = new EventSource(url, { withCredentials: true });
// ๋์ ์ด๋ฒคํธ ํธ๋ค๋ฌ ๋ฑ๋ก
Object.entries(eventHandlers).forEach(([event, handler]) => {
eventSource.addEventListener(event, (e) => {
handler(JSON.parse(e.data));
});
});
sseConnections[endpoint] = eventSource; // ์ฐ๊ฒฐ ์ํ ๊ด๋ฆฌ
};
// ์ค์๊ฐ ์ ํฌ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
const handleBattleStream = (data: BattleStreamData) => {
if (data.damage > 0) {
// ์คํฌ ์ดํํธ ๋๊ธฐํ
const key = data.skillId === -1 ? 'basic-attack' : `skill-${data.skillId}`;
setSkillEffectUrl(preloadedUrls[key]);
setShowSkill(true);
setTimeout(() => setShowSkill(false), 500); // ์ ๋๋ฉ์ด์
ํ์ด๋ฐ
}
// HP ์ฆ์ ๋ฐ์์ผ๋ก ์ค์๊ฐ์ฑ ๋ณด์ฅ
setBattleState((prev) => ({
...prev,
monster: { ...prev.monster, hp: data.mobHp },
}));
};
ํต์ฌ ๊ตฌํ ํฌ์ธํธ:
- ๋จ๋ฐฉํฅ ์ค์๊ฐ ํต์ : ์๋ฒโํด๋ผ์ด์ธํธ ์คํธ๋ฆฌ๋ฐ์ผ๋ก ์ง์ฐ ์๊ฐ ์ต์ํ
- ์ฐ๊ฒฐ ํ ๊ด๋ฆฌ: ์๋ํฌ์ธํธ๋ณ SSE ์ฐ๊ฒฐ ์ํ ์ถ์ ๋ฐ ์ค๋ณต ๋ฐฉ์ง
- ์ด๋ฏธ์ง ํ๋ฆฌ๋ก๋ฉ: ์ ํฌ ์์ ์ ๋ชจ๋ ์คํฌ ์ดํํธ ๋ฆฌ์์ค ์ฌ์ ๋ก๋ฉ
- ๋น๋๊ธฐ ์ ๋๋ฉ์ด์ : ๋ฐ๋ฏธ์ง ๊ณ์ฐ๊ณผ ์๊ฐ์ ํผ๋๋ฐฑ์ ๋ถ๋ฆฌ๋ ํ์ด๋ฐ ์ ์ด
๋ชจ๋ฐ์ผ ํผ์คํธ ๋๋๊ทธ ์ค ๋๋กญ ์ธํฐํ์ด์ค
export function useDragAndDrop<T>(onDrop: (item: T) => void) {
const [draggedItem, setDraggedItem] = useState<T | null>(null);
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
const onTouchStart = (item: T) => (e: React.TouchEvent) => {
const touch = e.touches[0];
setDraggedItem(item);
setDragPosition({ x: touch.clientX, y: touch.clientY });
};
const onTouchEnd = (_e: React.TouchEvent) => {
if (draggedItem && dropZoneRef.current) {
const rect = dropZoneRef.current.getBoundingClientRect();
const touch = _e.changedTouches[0];
// ๋๋กญ ์กด ์ถฉ๋ ๊ฐ์ง
if (
touch.clientX >= rect.left &&
touch.clientX <= rect.right &&
touch.clientY >= rect.top &&
touch.clientY <= rect.bottom
) {
onDrop(draggedItem);
}
}
};
}
ํต์ฌ ๊ตฌํ ํฌ์ธํธ:
- ํฐ์น ์ด๋ฒคํธ ์ฐ์ ์ค๊ณ: ๋ชจ๋ฐ์ผ ํ๊ฒฝ์ ๋๋๊ทธ ์ค ๋๋กญ ๋ค์ดํฐ๋ธ ์ง์
- ์ค์๊ฐ ์์น ์ถ์ :
clientX/Y
์ขํ๋ฅผ ํตํ ์ ํํ ๋๋๊ทธ ์์น ๊ณ์ฐ - ์ถฉ๋ ๊ฐ์ง ์๊ณ ๋ฆฌ์ฆ:
getBoundingClientRect()
๋ฅผ ํ์ฉํ ๋๋กญ ์กด ์์ญ ํ์ - ์ฅ๋น ํ์ ์ ์ฝ: ID ๊ธฐ๋ฐ ์ฌ๋กฏ ๋งค์นญ์ผ๋ก ์๋ชป๋ ์ฅ์ฐฉ ๋ฐฉ์ง
ํ๋ก์ ํธ๋ GitHub Actions๋ฅผ ํตํด AWS S3์ ์๋ ๋ฐฐํฌ๋ฉ๋๋ค.
main
๋ธ๋์น์ ์ฝ๋ ํธ์- GitHub Actions ์ํฌํ๋ก์ฐ ์๋ ์คํ
- ๋น๋ ๋ฐ S3 ๋ฐฐํฌ ์๋ฃ
Levelyn์ผ๋ก ํ ์ผ ๊ด๋ฆฌ๋ฅผ ๊ฒ์์ฒ๋ผ ์ฆ๊ฒ๊ฒ! ๐ฎโจ