diff --git a/.gitignore b/.gitignore index 6e3c953..8d86eef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ .vite/ - +config.json +package-lock.json diff --git a/CONTEXT.md b/CONTEXT.md index bd685be..edfbd06 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,25 +4,52 @@ ### [Core Engine] - **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다. + - 앱 로드 시 `trackVisitor()`를 호출해 방문자 체크 API와 연동합니다. - **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다. + - `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`: 일반 공격 1회 적중 시 적용되는 랜덤 피해량 범위. + - `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기. + - `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`: 처치 후 회복량과 크기/공격속도/이동속도 성장 배율. + - `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 선택 실루엣의 캐릭터 이격 거리, 두께, 투명도. - `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도. - `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기. - `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE). +### [Server/API - server/] +- **`server/index.js`**: + - Fastify 서버 진입점입니다. + - 개발 모드에서는 `@fastify/middie`로 Vite 미들웨어를 붙이고, `/api/*` 요청은 Vite SPA fallback이 가로채지 않도록 Fastify 라우트로 통과시킵니다. + - 운영 모드(`npm start`)에서는 `@fastify/static`으로 `dist/`를 서빙하고, HTML 요청은 `index.html`로 fallback합니다. +- **`server/config.js`**: + - `config.json`을 읽어 서버 포트, MongoDB host/port/db/user/pass, 쿠키 보안 옵션을 정규화합니다. + - `MONGODB_URI`가 직접 있으면 우선 사용하고, 없으면 `MONGODB_HOST`/`MONGODB_PORT` 기반으로 URI를 조립합니다. +- **`server/db.js`**: + - `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다. + - 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다. +- **`server/visitors.js`**: + - `POST /api/visitors/check`: `arena_visitor_id` `HttpOnly` 쿠키를 확인하고 없으면 UUID를 발급합니다. + - MongoDB `visitors` 컬렉션에 `_id = visitorId`로 upsert해 방문자 1명당 1개 문서를 유지합니다. + - `GET /api/visitors/stats`: 전체 유니크 방문자 수를 반환합니다. + ### [Game Logic - src/game/] - **`ArenaScene.js`**: - `update()`: 매 프레임 생존 팀을 체크하고 스코어보드를 갱신합니다. - `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다. + - `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 선택 상태를 설정하고 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다. - `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다. - **`matchSetup.js`**: - 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다. - **`combat.js`**: - `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다. + - `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다. + - `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. - `projectilePathHitsDefender()`: 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다. ### [Assets & UI] +- **`fighterAssets.js`**: 원본 캐릭터 스프라이트의 alpha 값을 읽어 선택용 노란 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠해 선택 윤곽이 캐릭터에 붙어 보이지 않도록 합니다. +- **`fighterFactory.js`**: 캐릭터 히트박스, 이름표, 체력바, 선택 실루엣 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다. - **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. `type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다. - **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달합니다. +- **`visitorCounter.js`**: `POST /api/visitors/check`를 호출하고, 응답의 `uniqueVisitors` 값을 `#visitor-count`에 표시합니다. ## 2. 주요 로직 구현 세부 사항 @@ -38,7 +65,34 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO 미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다. - `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다. +### 캐릭터 선택 실루엣 +선택 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 spritesheet입니다. + +1. `fighterAssets.js`가 로드된 원본 스프라이트시트의 alpha 데이터를 캔버스에서 읽습니다. +2. 원본 alpha 픽셀 주변 `SELECTED_FIGHTER_OUTLINE_GAP` 범위는 공백 마스크로 남깁니다. +3. 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH` 범위에만 노란색 outline을 칠합니다. +4. `fighterFactory.js`가 선택된 캐릭터 뒤에 outline sprite를 배치하고, 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다. + +이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 선택 윤곽을 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 윤곽이 같은 배율로 따라갑니다. + +### 유니크 방문자 체크 +브라우저가 직접 MongoDB에 연결하지 않고, Fastify API가 MongoDB 커넥션 풀을 유지합니다. + +1. 프론트엔드가 앱 로드 시 `POST /api/visitors/check`를 호출합니다. +2. 서버가 `arena_visitor_id` 쿠키를 검사합니다. +3. 쿠키가 없거나 유효하지 않으면 `crypto.randomUUID()`로 새 방문자 ID를 만들고 `HttpOnly` 쿠키로 내려줍니다. +4. MongoDB에는 `_id`, `firstSeenAt`, `lastSeenAt`, `visits`, `firstUserAgent`, `lastUserAgent`를 저장합니다. +5. `countDocuments()`로 전체 유니크 방문자 수를 계산해 반환합니다. + +방문자 체크는 인증 기능이 아니며, 브라우저/쿠키 단위의 단순 유니크 카운트입니다. + ## 3. 개발 및 유지보수 규칙 - **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다. - **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오. +- **공격력 조정**: 기본 피해량은 `src/constants.js`의 `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 우선 확인합니다. - **DOM 접근**: 성능을 위해 `ArenaScene`은 상단 스코어보드 등 필요한 시점에만 최소한으로 DOM에 접근합니다. +- **서버 설정**: `.env` 대신 `config.json`을 사용합니다. `config.json`은 로컬 전용 파일이며, 저장소에는 `config.json.sample`만 공유합니다. +- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다. +- **기본 포트**: `SERVER_PORT` 기본값은 `9736`입니다. +- **MongoDB 연결**: DB 접속 정보는 `config.json`의 `MONGODB_HOST`, `MONGODB_PORT`, `MONGODB_DB`, 선택적 `MONGODB_USER`, `MONGODB_PASS`로 관리합니다. +- **API 변경**: `/api/*` 경로는 Fastify 라우트가 담당합니다. 개발 모드에서 Vite 미들웨어가 API 요청을 SPA HTML로 처리하지 않도록 서버 라우팅 순서를 유지해야 합니다. diff --git a/agent.md b/agent.md index 85c0cf8..a4b9b25 100644 --- a/agent.md +++ b/agent.md @@ -4,14 +4,23 @@ **Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다. +서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수를 기록하는 간단한 방문자 통계 API를 제공합니다. + ## 2. 프로젝트 전체 구조 (Directory Tree) ```text ├── index.html # 메인 HTML 진입점 및 UI 레이아웃 -├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite) +├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite, Fastify, MongoDB) +├── config.json # 로컬 서버/MongoDB 설정 (git ignore) +├── config.json.sample # 공유용 서버/MongoDB 설정 예시 ├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서) ├── CONTEXT.md # 상세 개발 가이드 및 로직 설명 ├── todo.md # 작업 내역 및 잔여 이슈 관리 +├── server/ # Fastify API 서버 및 MongoDB 연결 관리 +│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙 +│ ├── config.js # config.json 로드 및 MongoDB URI 조립 +│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료 +│ └── visitors.js # 유니크 방문자 체크 및 통계 API ├── public/ # 정적 리소스 (게임 에셋) │ └── assets/ │ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋 @@ -19,20 +28,21 @@ │ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함 └── src/ # 소스 코드 root ├── main.js # Phaser 게임 인스턴스 생성 및 초기화 - ├── constants.js # 전역 물리/UI 상수 통합 관리 (줌, 카메라 속도 등) + ├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등) ├── styles.css # UI 스타일링 (스코어보드, 승리 배너 애니메이션) ├── game/ # 게임 로직 모듈 │ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, 스코어보드 제어) │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링 │ ├── combat.js # 전투 AI 및 피격 판정 로직 │ ├── combatSettings.js# 전투 속도 및 이동 배율 관리 - │ ├── fighterAssets.js# 스프라이트 시트 로드 및 애니메이션 생성 - │ ├── fighterFactory.js# 캐릭터 객체 및 HUD 생성 + │ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 선택 실루엣 생성 + │ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD 및 선택 윤곽 동기화 │ ├── fighterManifest.js# 캐릭터 스킨 데이터 정의 (20종 캐릭터 상세 설정) │ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직 │ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산 └── ui/ - └── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어 + ├── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어 + └── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신 ``` ## 3. 핵심 기능 @@ -43,16 +53,30 @@ - **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다. - **역동적인 전투 연출**: - 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션. - - 치명타(Critical) 발생 시 화면 흔들림 효과 및 대미지 가중치 적용. + - `ATTACK_DAMAGE_MIN/MAX`로 기본 공격력 범위를 관리하고, 치명타(Critical) 발생 시 즉시 처치 및 화면 흔들림 효과를 적용합니다. + - 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다. +- **캐릭터 선택 관전**: 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 원본 스프라이트 알파 마스크를 바탕으로 1px 공백을 둔 노란 실루엣 윤곽이 표시됩니다. - **실시간 경기 중계 UI**: 상단 좌/우 영역에 팀별 현재 생존 인원을 실시간으로 표시하며, 승리 시 대형 배너로 결과를 알립니다. +- **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다. ## 4. 기술 사양 - **Framework**: Phaser 3.90.0 (Arcade Physics 기반) - **Build Tool**: Vite 7.1.12 +- **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`) +- **Database**: MongoDB 7.x Node Driver - **UI Logic**: Vanilla JS & CSS (Flexbox/Grid 활용) -## 5. 관련 문서 +## 5. 서버/API 설정 + +- 개발/운영 서버는 `npm run dev` 또는 `npm start`로 실행하며 기본 포트는 `config.json`의 `SERVER_PORT` 값인 `9736`입니다. +- `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다. +- 기본 API: + - `GET /api/health`: 서버 및 MongoDB 설정 여부 확인. + - `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환. + - `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회. + +## 6. 관련 문서 - [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독) - [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리 diff --git a/config.json.sample b/config.json.sample new file mode 100644 index 0000000..63c51e3 --- /dev/null +++ b/config.json.sample @@ -0,0 +1,13 @@ +{ + "SERVER_HOST": "0.0.0.0", + "SERVER_PORT": 9736, + "MONGODB_HOST": "172.16.0.7", + "MONGODB_PORT": 27017, + "MONGODB_DB": "arena", + "MONGODB_USER": "", + "MONGODB_PASS": "", + "MONGODB_VISITOR_COLLECTION": "visitors", + "MONGODB_MAX_POOL_SIZE": 10, + "MONGODB_SERVER_SELECTION_TIMEOUT_MS": 5000, + "COOKIE_SECURE": false +} diff --git a/index.html b/index.html index 4106757..e8f4ff7 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@

Arena Picker

팀 전투 뽑기

+

방문자 확인 중

diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 7f633be..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1161 +0,0 @@ -{ - "name": "arena-picker", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "arena-picker", - "version": "0.1.0", - "dependencies": { - "phaser": "^3.90.0" - }, - "devDependencies": { - "vite": "^7.1.12" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/phaser": { - "version": "3.90.0", - "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz", - "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json index f6bd740..b8d4340 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,19 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "node server/index.js", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "start": "node server/index.js --production" }, "dependencies": { + "@fastify/middie": "^9.3.2", + "@fastify/static": "^9.1.3", + "fastify": "^5.8.5", + "mongodb": "^7.2.0", "phaser": "^3.90.0" }, "devDependencies": { "vite": "^7.1.12" } } - diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..b21b3a2 --- /dev/null +++ b/server/config.js @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_CONFIG = { + SERVER_HOST: "0.0.0.0", + SERVER_PORT: 9736, + MONGODB_HOST: "", + MONGODB_PORT: 27017, + MONGODB_DB: "arena", + MONGODB_USER: "", + MONGODB_PASS: "", + MONGODB_URI: "", + MONGODB_VISITOR_COLLECTION: "visitors", + MONGODB_MAX_POOL_SIZE: 10, + MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000, + COOKIE_SECURE: false, +}; + +let config; + +export function getConfig() { + if (!config) { + config = loadConfig(); + } + + return config; +} + +export function loadConfig(filePath = path.resolve(process.cwd(), "config.json")) { + const rawConfig = fs.existsSync(filePath) + ? JSON.parse(fs.readFileSync(filePath, "utf8")) + : {}; + + return normalizeConfig(rawConfig); +} + +export function hasMongoConfig() { + const appConfig = getConfig(); + return Boolean(appConfig.MONGODB_URI || appConfig.MONGODB_HOST); +} + +export function getMongoUri() { + const appConfig = getConfig(); + + if (appConfig.MONGODB_URI) { + return appConfig.MONGODB_URI; + } + + if (!appConfig.MONGODB_HOST) { + throw new Error("MongoDB configuration is required for visitor tracking."); + } + + const host = appConfig.MONGODB_HOST; + const port = Number(appConfig.MONGODB_PORT || DEFAULT_CONFIG.MONGODB_PORT); + const credentials = mongoCredentials(appConfig); + + return `mongodb://${credentials}${host}:${port}`; +} + +function normalizeConfig(rawConfig) { + const server = rawConfig.server || {}; + const mongodb = rawConfig.mongodb || {}; + + return { + SERVER_HOST: stringValue(rawConfig.SERVER_HOST, server.host, DEFAULT_CONFIG.SERVER_HOST), + SERVER_PORT: numberValue(rawConfig.SERVER_PORT, server.port, DEFAULT_CONFIG.SERVER_PORT), + MONGODB_HOST: stringValue(rawConfig.MONGODB_HOST, mongodb.host, DEFAULT_CONFIG.MONGODB_HOST), + MONGODB_PORT: numberValue(rawConfig.MONGODB_PORT, mongodb.port, DEFAULT_CONFIG.MONGODB_PORT), + MONGODB_DB: stringValue(rawConfig.MONGODB_DB, mongodb.db, DEFAULT_CONFIG.MONGODB_DB), + MONGODB_USER: stringValue(rawConfig.MONGODB_USER, mongodb.user, DEFAULT_CONFIG.MONGODB_USER), + MONGODB_PASS: stringValue(rawConfig.MONGODB_PASS, mongodb.pass, DEFAULT_CONFIG.MONGODB_PASS), + MONGODB_URI: stringValue(rawConfig.MONGODB_URI, mongodb.uri, DEFAULT_CONFIG.MONGODB_URI), + MONGODB_VISITOR_COLLECTION: stringValue( + rawConfig.MONGODB_VISITOR_COLLECTION, + mongodb.visitorCollection, + DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION, + ), + MONGODB_MAX_POOL_SIZE: numberValue( + rawConfig.MONGODB_MAX_POOL_SIZE, + mongodb.maxPoolSize, + DEFAULT_CONFIG.MONGODB_MAX_POOL_SIZE, + ), + MONGODB_SERVER_SELECTION_TIMEOUT_MS: numberValue( + rawConfig.MONGODB_SERVER_SELECTION_TIMEOUT_MS, + mongodb.serverSelectionTimeoutMs, + DEFAULT_CONFIG.MONGODB_SERVER_SELECTION_TIMEOUT_MS, + ), + COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE), + }; +} + +function mongoCredentials(appConfig) { + if (!appConfig.MONGODB_USER) { + return ""; + } + + const user = encodeURIComponent(appConfig.MONGODB_USER); + const pass = encodeURIComponent(appConfig.MONGODB_PASS || ""); + + return `${user}:${pass}@`; +} + +function stringValue(...values) { + const value = values.find((candidate) => typeof candidate === "string" && candidate.length > 0); + return value ?? ""; +} + +function numberValue(...values) { + const value = values.find((candidate) => { + const numericValue = Number(candidate); + return Number.isFinite(numericValue) && numericValue > 0; + }); + + return Number(value); +} + +function booleanValue(...values) { + const value = values.find((candidate) => typeof candidate === "boolean" || candidate === "true" || candidate === "false"); + + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + return Boolean(value); +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..c68f00c --- /dev/null +++ b/server/db.js @@ -0,0 +1,52 @@ +import { MongoClient } from "mongodb"; +import { getConfig, getMongoUri, hasMongoConfig } from "./config.js"; + +const DEFAULT_DB_NAME = "arena_picker"; + +let mongoClient; +let mongoClientPromise; + +export { hasMongoConfig }; + +export async function getMongoClient() { + if (mongoClient) { + return mongoClient; + } + + if (!mongoClientPromise) { + const appConfig = getConfig(); + const client = new MongoClient(getMongoUri(), { + maxPoolSize: appConfig.MONGODB_MAX_POOL_SIZE, + serverSelectionTimeoutMS: appConfig.MONGODB_SERVER_SELECTION_TIMEOUT_MS, + }); + + mongoClientPromise = client + .connect() + .then((connectedClient) => { + mongoClient = connectedClient; + return connectedClient; + }) + .catch((error) => { + mongoClientPromise = undefined; + throw error; + }); + } + + return mongoClientPromise; +} + +export async function getDb() { + const client = await getMongoClient(); + return client.db(getConfig().MONGODB_DB || DEFAULT_DB_NAME); +} + +export async function closeMongoConnection() { + if (!mongoClient && !mongoClientPromise) { + return; + } + + const client = mongoClient || (await mongoClientPromise); + mongoClient = undefined; + mongoClientPromise = undefined; + await client.close(); +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..bbd2e59 --- /dev/null +++ b/server/index.js @@ -0,0 +1,112 @@ +import fastifyMiddie from "@fastify/middie"; +import fastifyStatic from "@fastify/static"; +import Fastify from "fastify"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getConfig } from "./config.js"; +import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js"; +import { visitorRoutes } from "./visitors.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const distPath = path.join(root, "dist"); +const isProduction = process.env.NODE_ENV === "production" || process.argv.includes("--production"); +const appConfig = getConfig(); +const port = appConfig.SERVER_PORT; +const host = appConfig.SERVER_HOST; + +const app = Fastify({ + bodyLimit: 16 * 1024, +}); + +app.addContentTypeParser("*", { parseAs: "string" }, (request, body, done) => { + done(null, body); +}); + +app.get("/api/health", async () => { + return { + ok: true, + dbConfigured: hasMongoConfig(), + }; +}); + +await app.register(visitorRoutes, { prefix: "/api/visitors" }); + +if (isProduction) { + await app.register(fastifyStatic, { + root: distPath, + prefix: "/", + }); + + app.setNotFoundHandler((request, reply) => { + const acceptsHtml = String(request.headers.accept || "").includes("text/html"); + + if (request.method !== "GET" || request.url.startsWith("/api/") || !acceptsHtml) { + reply.code(404).send({ error: "not_found" }); + return; + } + + reply.sendFile("index.html"); + }); +} else { + await app.register(fastifyMiddie); + + const { createServer: createViteServer } = await import("vite"); + const vite = await createViteServer({ + root, + server: { + middlewareMode: true, + hmr: { + server: app.server, + }, + }, + appType: "spa", + }); + + app.use((request, response, next) => { + if (request.url?.startsWith("/api/")) { + next(); + return; + } + + vite.middlewares(request, response, next); + }); +} + +app.setErrorHandler((error, request, reply) => { + const isMissingMongoConfig = error.message.includes("MongoDB configuration"); + const status = isMissingMongoConfig ? 503 : 500; + + console.error(error); + reply.code(status).send({ + error: isMissingMongoConfig ? "mongodb_not_configured" : "internal_server_error", + message: isProduction ? "Visitor tracking is unavailable." : error.message, + }); +}); + +await app.listen({ port, host }); +console.log(`Arena Picker listening on http://localhost:${port}`); + +if (hasMongoConfig()) { + getMongoClient() + .then(() => { + console.log("MongoDB connection pool is ready."); + }) + .catch((error) => { + console.error("MongoDB connection failed. Visitor API will retry on request.", error); + }); +} + +["SIGINT", "SIGTERM"].forEach((signal) => { + process.on(signal, () => { + shutdown(signal); + }); +}); + +function shutdown(signal) { + console.log(`${signal} received. Closing server.`); + app.close() + .then(closeMongoConnection) + .finally(() => { + process.exit(0); + }); +} diff --git a/server/visitors.js b/server/visitors.js new file mode 100644 index 0000000..c44a0d1 --- /dev/null +++ b/server/visitors.js @@ -0,0 +1,117 @@ +import { randomUUID } from "node:crypto"; +import { getConfig } from "./config.js"; +import { getDb } from "./db.js"; + +const COOKIE_NAME = "arena_visitor_id"; +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2; +const DEFAULT_COLLECTION_NAME = "visitors"; +const USER_AGENT_LIMIT = 500; + +let indexesReady; + +export async function visitorRoutes(fastify) { + fastify.post("/check", async (request, reply) => { + return recordVisitor(request, reply); + }); + + fastify.get("/stats", async () => { + const collection = await getVisitorCollection(); + await ensureVisitorIndexes(collection); + + return { + uniqueVisitors: await collection.countDocuments(), + }; + }); +} + +async function recordVisitor(request, reply) { + const collection = await getVisitorCollection(); + await ensureVisitorIndexes(collection); + + let visitorId = readCookie(request, COOKIE_NAME); + const hadValidCookie = isValidVisitorId(visitorId); + + if (!hadValidCookie) { + visitorId = randomUUID(); + } + + const now = new Date(); + const userAgent = String(request.headers["user-agent"] || "").slice(0, USER_AGENT_LIMIT); + const result = await collection.updateOne( + { _id: visitorId }, + { + $setOnInsert: { + _id: visitorId, + firstSeenAt: now, + firstUserAgent: userAgent, + }, + $set: { + lastSeenAt: now, + lastUserAgent: userAgent, + }, + $inc: { + visits: 1, + }, + }, + { upsert: true }, + ); + + if (!hadValidCookie || result.upsertedCount > 0) { + writeVisitorCookie(reply, visitorId); + } + + return { + isNewVisitor: result.upsertedCount > 0, + uniqueVisitors: await collection.countDocuments(), + checkedAt: now.toISOString(), + }; +} + +async function getVisitorCollection() { + const db = await getDb(); + return db.collection(getConfig().MONGODB_VISITOR_COLLECTION || DEFAULT_COLLECTION_NAME); +} + +async function ensureVisitorIndexes(collection) { + if (!indexesReady) { + indexesReady = collection.createIndex({ lastSeenAt: -1 }); + } + + return indexesReady; +} + +function readCookie(request, name) { + const cookieHeader = request.headers.cookie; + + if (!cookieHeader) { + return ""; + } + + const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); + const matchedCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`)); + + if (!matchedCookie) { + return ""; + } + + try { + return decodeURIComponent(matchedCookie.slice(name.length + 1)); + } catch { + return ""; + } +} + +function writeVisitorCookie(reply, visitorId) { + const secureFlag = getConfig().COOKIE_SECURE ? "; Secure" : ""; + + reply.header( + "Set-Cookie", + `${COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`, + ); +} + +function isValidVisitorId(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ); +} diff --git a/src/constants.js b/src/constants.js index 5027382..ab9d85b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,57 +1,143 @@ +// 경기장을 구성하는 격자 칸 수입니다. 값이 커질수록 전장이 넓어집니다. export const GRID_SIZE = 50; +// 격자 한 칸의 픽셀 크기입니다. 경기장 크기와 좌표 간격에 영향을 줍니다. export const TILE_SIZE = 64; +// 실제 전장 전체 픽셀 크기입니다. GRID_SIZE와 TILE_SIZE를 기반으로 계산합니다. export const ARENA_SIZE = GRID_SIZE * TILE_SIZE; +// 근접 캐릭터가 공격을 시작할 수 있는 기본 거리입니다. export const ATTACK_RANGE = 84; +// 기본 공격 쿨다운(ms)입니다. 낮을수록 공격 빈도가 높아집니다. export const ATTACK_COOLDOWN = 840; +// 공격이 한 번 적중했을 때 적용되는 최소 피해량입니다. +export const ATTACK_DAMAGE_MIN = 14; +// 공격이 한 번 적중했을 때 적용되는 최대 피해량입니다. +export const ATTACK_DAMAGE_MAX = 24; +// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다. export const DEFAULT_TEAM_SIZE = 5; +// 캐릭터 스프라이트의 기본 화면 배율입니다. export const FIGHTER_SCALE = 3; +// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다. +export const FIGHTER_FRAME_WIDTH = 100; +// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다. +export const FIGHTER_FRAME_HEIGHT = 100; +// 캐릭터 히트박스의 원본 프레임 기준 너비입니다. +export const FIGHTER_HITBOX_WIDTH = 22; +// 캐릭터 히트박스의 원본 프레임 기준 높이입니다. +export const FIGHTER_HITBOX_HEIGHT = 20; +// 100x100 프레임 안에서 히트박스가 시작되는 X 좌표입니다. +export const FIGHTER_HITBOX_OFFSET_X = 39; +// 100x100 프레임 안에서 히트박스가 시작되는 Y 좌표입니다. 실제 캐릭터 픽셀 하단은 대체로 y=59입니다. +export const FIGHTER_HITBOX_OFFSET_Y = 40; +// 캐릭터의 기본 최대 체력입니다. +export const FIGHTER_MAX_HP = 100; +// 적 처치 시 현재 체력 기준으로 회복되는 비율입니다. +export const KILL_HEALTH_RECOVERY_RATIO = 0.3; +// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다. +export const KILL_GROWTH_MULTIPLIER = 1.25; +// 처치 성장 연출 tween 지속 시간(ms)입니다. +export const KILL_GROWTH_TWEEN_DURATION = 180; +// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다. export const MAX_TEAM_SIZE = 100; +// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다. export const MELEE_CRITICAL_CHANCE = 0.05; +// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다. export const MOVE_SPEED = 148; +// 투사체가 자동으로 사라지기까지의 시간(ms)입니다. export const PROJECTILE_LIFETIME = 1800; +// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다. export const PROJECTILE_SPEED = 420; +// 원거리 캐릭터의 기본 치명타 확률입니다. export const RANGED_CRITICAL_CHANCE = 0; +// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다. export const RANGED_ATTACK_RANGE = TILE_SIZE * 5; +// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다. export const MELEE_HIT_DELAY = 260; +// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다. export const PROJECTILE_FIRE_DELAY = 360; +// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다. export const PROJECTILE_BODY_OFFSET = 4; +// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다. export const PROJECTILE_HIT_PADDING = 20; +// 투사체 충돌 원형 바디의 반지름입니다. export const PROJECTILE_HIT_RADIUS = 12; +// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다. export const PROJECTILE_SPAWN_DISTANCE = 1; +// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다. export const SPELL_CAST_DELAY = 340; +// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다. export const SPELL_HIT_DELAY = 160; +// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다. export const CAMERA_MIN_ZOOM = 1; +// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다. export const CAMERA_MAX_ZOOM = 3; +// 마우스 휠 한 번당 카메라 줌 변화량입니다. export const CAMERA_ZOOM_STEP = 0.1; +// 미니맵 카메라가 보일 때의 투명도입니다. export const MINIMAP_ALPHA = 0.8; +// 미니맵이 화면 가장자리에서 떨어지는 거리입니다. export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016); +// 미니맵의 고정 픽셀 크기입니다. export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22); +// 미니맵 현재 뷰포트 표시용 바깥 윤곽선 두께입니다. export const MINIMAP_VIEW_FRAME_OUTLINE = 18; +// 미니맵 현재 뷰포트 표시용 안쪽 선 두께입니다. export const MINIMAP_VIEW_FRAME_STROKE = 10; +// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다. export const SPECTATOR_CAMERA_LERP = 0.1; +// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다. export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5; +// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다. export const SPECTATOR_FINAL_FIGHT_ZOOM = 3; -export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 10; +// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다. +export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30; +// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다. export const SPECTATOR_LATE_FIGHT_ZOOM = 2; +// 캐릭터를 선택했을 때 최소로 확보하는 카메라 줌입니다. +export const SELECTED_FIGHTER_CAMERA_ZOOM = 2; +// 선택 실루엣과 원본 캐릭터 사이에 비워두는 픽셀 간격입니다. +export const SELECTED_FIGHTER_OUTLINE_GAP = 1; +// 선택 실루엣 자체가 차지하는 픽셀 두께입니다. +export const SELECTED_FIGHTER_OUTLINE_WIDTH = 1; +// 선택 실루엣의 빨간색 채널 값입니다. +export const SELECTED_FIGHTER_OUTLINE_RED = 255; +// 선택 실루엣의 초록색 채널 값입니다. +export const SELECTED_FIGHTER_OUTLINE_GREEN = 228; +// 선택 실루엣의 파란색 채널 값입니다. +export const SELECTED_FIGHTER_OUTLINE_BLUE = 64; +// 선택 실루엣의 전체 투명도입니다. 0.65는 윤곽을 또렷하게 보이면서 원본 캐릭터를 덮지 않습니다. +export const SELECTED_FIGHTER_OUTLINE_ALPHA = 0.65; +// 참가자 닉네임을 잘라낼 최대 글자 수입니다. export const NICKNAME_LENGTH = 18; +// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다. export const FIGHTER_ANIMATION_OPTIONS = { + // 기본 공격 애니메이션 속도입니다. attack: { frameRate: 15, repeat: 0 }, + // 보조 공격 애니메이션 속도입니다. attack02: { frameRate: 15, repeat: 0 }, + // 강공격/치명타용 공격 애니메이션 속도입니다. attack03: { frameRate: 15, repeat: 0 }, + // 방어 애니메이션 속도입니다. block: { frameRate: 13, repeat: 0 }, + // 사망 애니메이션 속도입니다. death: { frameRate: 11, repeat: 0 }, + // 회복 애니메이션 속도입니다. heal: { frameRate: 13, repeat: 0 }, + // 피격 애니메이션 속도입니다. hurt: { frameRate: 13, repeat: 0 }, + // 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다. idle: { frameRate: 7, repeat: -1 }, + // 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다. walk: { frameRate: 10, repeat: -1 }, + // 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다. walk02: { frameRate: 10, repeat: -1 }, }; +// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다. export const TEAM_COLORS = [ "#da6a48", "#5fb4d9", diff --git a/src/game/ArenaScene.js b/src/game/ArenaScene.js index a94de24..bb9ef2d 100644 --- a/src/game/ArenaScene.js +++ b/src/game/ArenaScene.js @@ -9,6 +9,7 @@ import { MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEW_FRAME_OUTLINE, MINIMAP_VIEW_FRAME_STROKE, + SELECTED_FIGHTER_CAMERA_ZOOM, SPECTATOR_CAMERA_LERP, SPECTATOR_FINAL_FIGHTER_THRESHOLD, SPECTATOR_FINAL_FIGHT_ZOOM, @@ -45,6 +46,7 @@ export class ArenaScene extends Phaser.Scene { } }; this.observedCombat = []; + this.selectedFighter = null; this.teams = []; } @@ -70,7 +72,6 @@ export class ArenaScene extends Phaser.Scene { this.cameras.main.ignore(this.minimapViewportFrame); this.updateMinimapViewportFrame(); this.minimapCamera.setAlpha(0); // 기본적으로는 숨김 - // 마우스 휠로 줌 조절 this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => { const newZoom = Phaser.Math.Clamp( @@ -83,6 +84,19 @@ export class ArenaScene extends Phaser.Scene { // 확대 시 미니맵 표시 }); + this.input.on("gameobjectdown", (pointer, gameObject) => { + if (this.fighters.includes(gameObject)) { + this.selectFighter(gameObject); + } + }); + this.input.on("pointerdown", (pointer, gameObjects = []) => { + if (!gameObjects.some((gameObject) => this.fighters.includes(gameObject))) { + this.clearSelectedFighter(); + } + }); + this.input.keyboard?.on("keydown-ESC", () => { + this.clearSelectedFighter(); + }); this.ready = true; this.startMatch(this.getInitialMatchConfig()); @@ -104,6 +118,7 @@ export class ArenaScene extends Phaser.Scene { this.matchId += 1; this.matchOver = false; this.observedCombat = []; + this.clearSelectedFighter(); this.setMainCameraZoom(CAMERA_MIN_ZOOM); this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); clearCombatObjects(this); @@ -122,6 +137,20 @@ export class ArenaScene extends Phaser.Scene { update(time) { this.fighters.forEach(syncFighterHud); + if (!this.matchOver) { + this.fighters.forEach((fighter) => { + updateFighter(this, fighter, time, () => { + this.updateScoreboard(); + this.finishMatch(); + }); + }); + } + + if (this.focusSelectedFighter()) { + this.updateMinimapViewportFrame(); + return; + } + if (this.matchOver) { this.updateMinimapViewportFrame(); return; @@ -150,16 +179,56 @@ update(time) { this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); } - this.fighters.forEach((fighter) => { - updateFighter(this, fighter, time, () => { - this.updateScoreboard(); - this.finishMatch(); - }); - }); - this.updateMinimapViewportFrame(); } + selectFighter(fighter) { + if (!isLivingFighter(fighter)) { + return; + } + + if (this.selectedFighter === fighter) { + this.clearSelectedFighter(); + return; + } + + this.clearSelectedFighter(); + this.selectedFighter = fighter; + fighter.isSelected = true; + this.observedCombat = []; + this.setMainCameraZoom(Math.max(this.cameras.main.zoom, SELECTED_FIGHTER_CAMERA_ZOOM)); + this.centerCameraOnFighter(fighter); + syncFighterHud(fighter); + } + + clearSelectedFighter() { + if (this.selectedFighter) { + this.selectedFighter.isSelected = false; + syncFighterHud(this.selectedFighter); + } + + this.selectedFighter = null; + } + + focusSelectedFighter() { + if (!this.selectedFighter) { + return false; + } + + if (!isLivingFighter(this.selectedFighter)) { + this.clearSelectedFighter(); + return false; + } + + this.centerCameraOnFighter(this.selectedFighter); + return true; + } + + centerCameraOnFighter(fighter) { + const target = fighter.body?.center ?? fighter; + this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y)); + } + setMainCameraZoom(zoom) { const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM); diff --git a/src/game/combat.js b/src/game/combat.js index dc3ea9c..e88eda3 100644 --- a/src/game/combat.js +++ b/src/game/combat.js @@ -1,8 +1,14 @@ import Phaser from "phaser"; import { ATTACK_COOLDOWN, + ATTACK_DAMAGE_MAX, + ATTACK_DAMAGE_MIN, ATTACK_RANGE, + FIGHTER_MAX_HP, FIGHTER_SCALE, + KILL_HEALTH_RECOVERY_RATIO, + KILL_GROWTH_MULTIPLIER, + KILL_GROWTH_TWEEN_DURATION, MELEE_HIT_DELAY, MELEE_CRITICAL_CHANCE, MOVE_SPEED, @@ -41,7 +47,7 @@ export function updateFighter(scene, fighter, time, onWinner) { fighter.setFlipX(enemy.x < fighter.x); if (distance > getAttackRange(fighter)) { - scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * getMovementSpeedMultiplier()); + scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * fighterMovementSpeedMultiplier(fighter)); playIfNeeded(fighter, "walk"); return; } @@ -66,10 +72,11 @@ export function clearCombatObjects(scene) { function beginAttack(scene, attacker, defender, time, onWinner) { const attack = createAttackProfile(attacker); - attacker.nextAttackAt = time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN); + attacker.nextAttackAt = + time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker); attacker.isLocked = true; scene.observeCombat?.(attacker, defender); - playAnimation(attacker, attack.animation, getAttackSpeedMultiplier()); + playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker)); switch (getCombatType(attacker)) { case "projectile": @@ -86,7 +93,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) { function queueMeleeHit(scene, attacker, defender, onWinner, attack) { const matchId = scene.matchId; - scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY), () => { + scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => { applyHit(scene, attacker, defender, onWinner, matchId, { instantKill: attack.isCritical, }); @@ -96,7 +103,7 @@ function queueMeleeHit(scene, attacker, defender, onWinner, attack) { function queueProjectile(scene, attacker, defender, onWinner) { const matchId = scene.matchId; - scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY), () => { + scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => { if (!isAttackValid(scene, attacker, defender, matchId)) { return; } @@ -108,7 +115,7 @@ function queueProjectile(scene, attacker, defender, onWinner) { function queueInstantSpell(scene, attacker, defender, onWinner) { const matchId = scene.matchId; - scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY), () => { + scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => { if (!isAttackValid(scene, attacker, defender, matchId)) { return; } @@ -144,7 +151,8 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) { projectile, defenderHitPoint.x, defenderHitPoint.y, - (attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(), + (attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * + fighterAttackSpeedMultiplier(attacker), ); trackCombatObject(scene, projectile); @@ -209,9 +217,12 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) { disposeCombatObject(scene, effect); }); - scene.time.delayedCall(scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY), () => { + scene.time.delayedCall( + scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker), + () => { applyHit(scene, attacker, defender, onWinner, matchId); - }); + }, + ); } function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) { @@ -219,7 +230,9 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = return; } - defender.hp = instantKill ? 0 : Math.max(0, defender.hp - Phaser.Math.Between(14, 24)); + defender.hp = instantKill + ? 0 + : Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX)); defender.body.setVelocity(0, 0); if (defender.hp === 0) { @@ -336,9 +349,36 @@ function killFighter(defender, winner, onWinner) { winner.isLocked = false; winner.body.setVelocity(0, 0); playAnimation(winner, "idle"); + applyKillReward(winner); onWinner(winner); } +function applyKillReward(winner) { + winner.killCount = (winner.killCount ?? 0) + 1; + + const rewardMultiplier = KILL_GROWTH_MULTIPLIER ** winner.killCount; + winner.killRewardMultiplier = rewardMultiplier; + winner.hp = recoveredHealth(winner); + + const nextScaleX = (winner.baseScaleX ?? FIGHTER_SCALE) * rewardMultiplier; + const nextScaleY = (winner.baseScaleY ?? FIGHTER_SCALE) * rewardMultiplier; + + winner.scene.tweens.add({ + targets: winner, + scaleX: nextScaleX, + scaleY: nextScaleY, + duration: KILL_GROWTH_TWEEN_DURATION, + ease: "Back.Out", + }); +} + +function recoveredHealth(fighter) { + const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP; + const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO); + + return Math.min(maxHp, fighter.hp + recovery); +} + function findNearestEnemy(fighters, fighter) { let nearestEnemy; let nearestDistance = Number.POSITIVE_INFINITY; @@ -381,8 +421,16 @@ function playAnimation(fighter, action, timeScale = 1) { fighter.play(fighterAnimationKey(fighter.skin, action), true); } -function scaledAttackDelay(duration) { - return duration / getAttackSpeedMultiplier(); +function scaledAttackDelay(duration, fighter) { + return duration / fighterAttackSpeedMultiplier(fighter); +} + +function fighterAttackSpeedMultiplier(fighter) { + return getAttackSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1); +} + +function fighterMovementSpeedMultiplier(fighter) { + return getMovementSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1); } function trackCombatObject(scene, object) { diff --git a/src/game/fighterAssets.js b/src/game/fighterAssets.js index 5778e12..c62e5b1 100644 --- a/src/game/fighterAssets.js +++ b/src/game/fighterAssets.js @@ -1,4 +1,16 @@ -import { FIGHTER_ANIMATION_OPTIONS } from "../constants.js"; +import { + FIGHTER_ANIMATION_OPTIONS, + FIGHTER_FRAME_HEIGHT, + FIGHTER_FRAME_WIDTH, + SELECTED_FIGHTER_OUTLINE_ALPHA, + SELECTED_FIGHTER_OUTLINE_BLUE, + SELECTED_FIGHTER_OUTLINE_GAP, + SELECTED_FIGHTER_OUTLINE_GREEN, + SELECTED_FIGHTER_OUTLINE_RED, + SELECTED_FIGHTER_OUTLINE_WIDTH, +} from "../constants.js"; + +const SOURCE_ALPHA_THRESHOLD = 8; export function fighterSheetKey(skin, action) { return `${skin.key}-${action}`; @@ -8,6 +20,14 @@ export function fighterAnimationKey(skin, action) { return `${fighterSheetKey(skin, action)}-anim`; } +export function fighterOutlineSheetKey(skin, action) { + return `${fighterSheetKey(skin, action)}-outline`; +} + +export function fighterOutlineSheetKeyFromSheetKey(sheetKey) { + return `${sheetKey}-outline`; +} + export function fighterAttackEffectKey(skin) { return `${skin.key}-attack-effect`; } @@ -26,7 +46,7 @@ export function preloadFighterSheets(scene, skins) { scene.load.spritesheet( fighterSheetKey(skin, action), `${skin.assetRoot}/${animation.file}`, - { frameWidth: 100, frameHeight: 100 }, + { frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT }, ); }); @@ -39,21 +59,21 @@ export function createFighterAnimations(scene, skins) { Object.entries(skin.animations).forEach(([action, animation]) => { const key = fighterAnimationKey(skin, action); - if (scene.anims.exists(key)) { - return; + if (!scene.anims.exists(key)) { + const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action]; + + scene.anims.create({ + key, + frames: scene.anims.generateFrameNumbers(fighterSheetKey(skin, action), { + start: 0, + end: animation.frames - 1, + }), + frameRate, + repeat, + }); } - const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action]; - - scene.anims.create({ - key, - frames: scene.anims.generateFrameNumbers(fighterSheetKey(skin, action), { - start: 0, - end: animation.frames - 1, - }), - frameRate, - repeat, - }); + createFighterOutlineSheet(scene, skin, action, animation.frames); }); createAttackEffectAnimation(scene, skin); @@ -72,7 +92,7 @@ function preloadCombatAssets(scene, skin) { scene.load.spritesheet( fighterAttackEffectKey(skin), `${skin.assetRoot}/${attackEffect.file}`, - { frameWidth: 100, frameHeight: 100 }, + { frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT }, ); } } @@ -100,3 +120,114 @@ function createAttackEffectAnimation(scene, skin) { repeat: 0, }); } + +function createFighterOutlineSheet(scene, skin, action, frameCount) { + const key = fighterOutlineSheetKey(skin, action); + + if (scene.textures.exists(key)) { + return; + } + + const sourceTexture = scene.textures.get(fighterSheetKey(skin, action)); + const sourceImage = sourceTexture?.getSourceImage?.(); + + if (!sourceImage) { + return; + } + + const sheetWidth = FIGHTER_FRAME_WIDTH * frameCount; + const sheetHeight = FIGHTER_FRAME_HEIGHT; + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = sheetWidth; + sourceCanvas.height = sheetHeight; + + const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true }); + sourceContext.drawImage(sourceImage, 0, 0); + + const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data; + const outlineCanvas = document.createElement("canvas"); + outlineCanvas.width = sheetWidth; + outlineCanvas.height = sheetHeight; + + const outlineContext = outlineCanvas.getContext("2d"); + const outlineImage = outlineContext.createImageData(sheetWidth, sheetHeight); + const outlineData = outlineImage.data; + const gapMask = new Uint8Array(sheetWidth * sheetHeight); + const outerMask = new Uint8Array(sheetWidth * sheetHeight); + const outlineAlpha = Math.round(SELECTED_FIGHTER_OUTLINE_ALPHA * 255); + + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const frameLeft = frameIndex * FIGHTER_FRAME_WIDTH; + + for (let y = 0; y < FIGHTER_FRAME_HEIGHT; y += 1) { + for (let x = 0; x < FIGHTER_FRAME_WIDTH; x += 1) { + const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4; + + if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) { + continue; + } + + markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, x, y); + } + } + } + + paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha); + outlineContext.putImageData(outlineImage, 0, 0); + scene.textures.addSpriteSheet(key, outlineCanvas, { + frameWidth: FIGHTER_FRAME_WIDTH, + frameHeight: FIGHTER_FRAME_HEIGHT, + }); +} + +function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) { + const outerRadius = SELECTED_FIGHTER_OUTLINE_GAP + SELECTED_FIGHTER_OUTLINE_WIDTH; + + for ( + let offsetY = -outerRadius; + offsetY <= outerRadius; + offsetY += 1 + ) { + const targetY = sourceY + offsetY; + + if (targetY < 0 || targetY >= FIGHTER_FRAME_HEIGHT) { + continue; + } + + for ( + let offsetX = -outerRadius; + offsetX <= outerRadius; + offsetX += 1 + ) { + const targetX = sourceX + offsetX; + + if (targetX < 0 || targetX >= FIGHTER_FRAME_WIDTH) { + continue; + } + + const maskIndex = (targetY * sheetWidth) + frameLeft + targetX; + const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY)); + + outerMask[maskIndex] = 1; + + if (distance <= SELECTED_FIGHTER_OUTLINE_GAP) { + gapMask[maskIndex] = 1; + } + } + } +} + +function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) { + for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) { + if (!outerMask[maskIndex] || gapMask[maskIndex]) { + continue; + } + + const outlineIndex = maskIndex * 4; + + outlineData[outlineIndex] = SELECTED_FIGHTER_OUTLINE_RED; + outlineData[outlineIndex + 1] = SELECTED_FIGHTER_OUTLINE_GREEN; + outlineData[outlineIndex + 2] = SELECTED_FIGHTER_OUTLINE_BLUE; + outlineData[outlineIndex + 3] = outlineAlpha; + } +} diff --git a/src/game/fighterFactory.js b/src/game/fighterFactory.js index 03dbfa3..30574d5 100644 --- a/src/game/fighterFactory.js +++ b/src/game/fighterFactory.js @@ -1,6 +1,21 @@ import Phaser from "phaser"; -import { FIGHTER_SCALE } from "../constants.js"; -import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js"; +import { + FIGHTER_FRAME_HEIGHT, + FIGHTER_FRAME_WIDTH, + FIGHTER_HITBOX_HEIGHT, + FIGHTER_HITBOX_OFFSET_X, + FIGHTER_HITBOX_OFFSET_Y, + FIGHTER_HITBOX_WIDTH, + FIGHTER_MAX_HP, + FIGHTER_SCALE, +} from "../constants.js"; +import { + fighterAnimationKey, + fighterOutlineSheetKeyFromSheetKey, + fighterSheetKey, +} from "./fighterAssets.js"; + +const NAME_LABEL_BOTTOM_GAP = 14; export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) { const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0); @@ -8,11 +23,26 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, fighter.setDepth(2); fighter.setCollideWorldBounds(true); fighter.setFlipX(faceLeft); - fighter.body.setSize(22, 20); - fighter.body.setOffset(39, 60); + fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT); + fighter.body.setOffset(FIGHTER_HITBOX_OFFSET_X, FIGHTER_HITBOX_OFFSET_Y); + fighter.setInteractive( + new Phaser.Geom.Rectangle( + FIGHTER_HITBOX_OFFSET_X, + FIGHTER_HITBOX_OFFSET_Y, + FIGHTER_HITBOX_WIDTH, + FIGHTER_HITBOX_HEIGHT, + ), + Phaser.Geom.Rectangle.Contains, + ); + fighter.input.cursor = "pointer"; + fighter.selectionOutline = scene.add + .sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0) + .setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE) + .setDepth(1.9) + .setVisible(false); fighter.nameLabel = scene.add - .text(x, y - 68, name, { + .text(x, y, name, { color: "#fff2c2", fontFamily: "Inter, Pretendard, sans-serif", fontSize: "18px", @@ -20,7 +50,7 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, stroke: team.color, strokeThickness: 4, }) - .setOrigin(0.5) + .setOrigin(0.5, 0) .setDepth(4); fighter.healthBack = scene.add .rectangle(x, y - 44, 72, 8, 0x17180e, 0.92) @@ -33,7 +63,13 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, fighter.skin = skin; fighter.team = team; fighter.teamIndex = teamIndex; - fighter.hp = 100; + fighter.baseScaleX = FIGHTER_SCALE; + fighter.baseScaleY = FIGHTER_SCALE; + fighter.isSelected = false; + fighter.killCount = 0; + fighter.killRewardMultiplier = 1; + fighter.maxHp = FIGHTER_MAX_HP; + fighter.hp = fighter.maxHp; fighter.nextAttackAt = 0; fighter.isLocked = false; fighter.isDead = false; @@ -50,21 +86,56 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, }); attachHudCleanup(fighter); + syncFighterHud(fighter); return fighter; } export function syncFighterHud(fighter) { - fighter.nameLabel.setPosition(fighter.x, fighter.y - 68); - fighter.healthBack.setPosition(fighter.x, fighter.y - 44); - fighter.healthBar.setPosition(fighter.x - 34, fighter.y - 44); - fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / 100)); + const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE); + const healthOffset = 44 * scaleRatio; + const hitbox = fighter.body; + const nameX = hitbox.x + hitbox.width / 2; + const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP; + + fighter.nameLabel.setPosition(nameX, nameY); + fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset); + fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset); + fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP))); + syncSelectionOutline(fighter); +} + +function syncSelectionOutline(fighter) { + const outline = fighter.selectionOutline; + + if (!outline) { + return; + } + + const isVisible = Boolean(fighter.isSelected && !fighter.isDead); + outline.setVisible(isVisible); + + if (!isVisible) { + return; + } + + const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key); + + if (fighter.scene.textures.exists(outlineTextureKey)) { + outline.setTexture(outlineTextureKey, fighter.frame.name); + } + + outline.setPosition(fighter.x, fighter.y); + outline.setScale(fighter.scaleX, fighter.scaleY); + outline.setFlipX(fighter.flipX); + outline.setDepth(fighter.depth - 0.1); } function attachHudCleanup(fighter) { const originalDestroy = fighter.destroy.bind(fighter); fighter.destroy = (...args) => { + fighter.selectionOutline.destroy(); fighter.nameLabel.destroy(); fighter.healthBack.destroy(); fighter.healthBar.destroy(); diff --git a/src/main.js b/src/main.js index c0e6103..e33b072 100644 --- a/src/main.js +++ b/src/main.js @@ -2,6 +2,7 @@ import Phaser from "phaser"; import { ArenaScene } from "./game/ArenaScene.js"; import { ARENA_SIZE } from "./constants.js"; import { createMatchForm } from "./ui/matchForm.js"; +import { trackVisitor } from "./ui/visitorCounter.js"; import "./styles.css"; const matchForm = createMatchForm(); @@ -32,4 +33,22 @@ const game = new Phaser.Game({ matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig)); +const visitorCountNode = document.querySelector("#visitor-count"); + +trackVisitor({ + onUpdate({ uniqueVisitors }) { + if (visitorCountNode) { + visitorCountNode.textContent = `방문자 ${uniqueVisitors.toLocaleString("ko-KR")}`; + } + }, + onError(error) { + console.warn(error); + + if (visitorCountNode) { + visitorCountNode.textContent = ""; + visitorCountNode.hidden = true; + } + }, +}); + window.arenaGame = game; diff --git a/src/styles.css b/src/styles.css index a38b389..90ce6ea 100644 --- a/src/styles.css +++ b/src/styles.css @@ -65,6 +65,18 @@ h1 { letter-spacing: 0; } +.visitor-count { + width: fit-content; + margin: 0; + border: 1px solid rgb(230 207 134 / 0.18); + border-radius: 6px; + padding: 6px 10px; + background: rgb(29 32 23 / 0.74); + color: #f1d892; + font-size: 0.88rem; + font-weight: 800; +} + form { display: grid; gap: 16px; diff --git a/src/ui/visitorCounter.js b/src/ui/visitorCounter.js new file mode 100644 index 0000000..b47adf1 --- /dev/null +++ b/src/ui/visitorCounter.js @@ -0,0 +1,23 @@ +export async function trackVisitor({ onUpdate, onError } = {}) { + try { + const response = await fetch("/api/visitors/check", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: "{}", + }); + + if (!response.ok) { + throw new Error(`Visitor check failed: ${response.status}`); + } + + const visitorStats = await response.json(); + onUpdate?.(visitorStats); + return visitorStats; + } catch (error) { + onError?.(error); + return null; + } +} diff --git a/todo.md b/todo.md index 369a1c6..d9a1d5d 100644 --- a/todo.md +++ b/todo.md @@ -13,3 +13,27 @@ - **조치 사항**: - `src/game/ArenaScene.js`의 `finishMatch` 로직을 개선하여 생존 팀이 1개일 때 해당 닉네임 승리 표시. - 생존자가 없을 경우 "무승부!"가 표시되도록 예외 처리 추가. + +4. MongoDB 기반 유니크 방문자 체크 API 추가 (완료) +- **조치 사항**: + - Fastify 서버를 추가하고 `config.json` 기반 서버/MongoDB 설정을 도입. + - MongoDB 커넥션 풀을 재사용하는 `server/db.js` 구성. + - `POST /api/visitors/check`, `GET /api/visitors/stats`, `GET /api/health` API 추가. + - 프론트엔드 로드 시 방문자 체크 API를 호출하고 유니크 방문자 수를 표시. + +5. 처치 보상 및 공격력 튜닝 상수화 (완료) +- **조치 사항**: + - 적 처치 시 현재 체력 기준 30% 회복, 누적 성장 배율에 따른 크기/공격속도/이동속도 증가를 적용. + - 기존 `combat.js`에 하드코딩되어 있던 `14~24` 피해량을 `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX` 상수로 분리. + - 주요 전투/카메라/UI 상수마다 조정 대상이 무엇인지 주석 추가. + +6. 캐릭터 히트박스 기준 이름표 및 선택 관전 기능 (완료) +- **조치 사항**: + - 캐릭터 이름표를 스프라이트 중심이 아니라 실제 히트박스 하단에 고정. + - 캐릭터 클릭 시 선택 상태를 설정하고 카메라를 해당 캐릭터 히트박스 중심에 고정. + - 선택 표시는 사각형 대신 원본 alpha 마스크 기반 노란 실루엣으로 생성. + - 실루엣은 캐릭터 바로 옆 1px을 비우고 그 바깥 1px에만 표시해 자글자글한 느낌을 줄임. + +7. 패키지 락 파일 제외 (완료) +- **조치 사항**: + - `package-lock.json`을 git 추적에서 제외하고 `.gitignore`에 추가.