Add visitor tracking and combat selection features
This commit is contained in:
parent
104bf4fe48
commit
1509b0c5dd
|
|
@ -1,4 +1,5 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.vite/
|
.vite/
|
||||||
|
config.json
|
||||||
|
package-lock.json
|
||||||
|
|
|
||||||
54
CONTEXT.md
54
CONTEXT.md
|
|
@ -4,25 +4,52 @@
|
||||||
|
|
||||||
### [Core Engine]
|
### [Core Engine]
|
||||||
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
||||||
|
- 앱 로드 시 `trackVisitor()`를 호출해 방문자 체크 API와 연동합니다.
|
||||||
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
|
- **`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`: 카메라 추적의 부드러움 정도.
|
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
|
||||||
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
|
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
|
||||||
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
|
- `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/]
|
### [Game Logic - src/game/]
|
||||||
- **`ArenaScene.js`**:
|
- **`ArenaScene.js`**:
|
||||||
- `update()`: 매 프레임 생존 팀을 체크하고 스코어보드를 갱신합니다.
|
- `update()`: 매 프레임 생존 팀을 체크하고 스코어보드를 갱신합니다.
|
||||||
- `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.
|
- `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.
|
||||||
|
- `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 선택 상태를 설정하고 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다.
|
||||||
- `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
|
- `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
|
||||||
- **`matchSetup.js`**:
|
- **`matchSetup.js`**:
|
||||||
- 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다.
|
- 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다.
|
||||||
- **`combat.js`**:
|
- **`combat.js`**:
|
||||||
- `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
- `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
||||||
|
- `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다.
|
||||||
|
- `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다.
|
||||||
- `projectilePathHitsDefender()`: 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다.
|
- `projectilePathHitsDefender()`: 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다.
|
||||||
|
|
||||||
### [Assets & UI]
|
### [Assets & UI]
|
||||||
|
- **`fighterAssets.js`**: 원본 캐릭터 스프라이트의 alpha 값을 읽어 선택용 노란 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠해 선택 윤곽이 캐릭터에 붙어 보이지 않도록 합니다.
|
||||||
|
- **`fighterFactory.js`**: 캐릭터 히트박스, 이름표, 체력바, 선택 실루엣 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다.
|
||||||
- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. `type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다.
|
- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. `type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다.
|
||||||
- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달합니다.
|
- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달합니다.
|
||||||
|
- **`visitorCounter.js`**: `POST /api/visitors/check`를 호출하고, 응답의 `uniqueVisitors` 값을 `#visitor-count`에 표시합니다.
|
||||||
|
|
||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
|
|
||||||
|
|
@ -38,7 +65,34 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
|
||||||
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
|
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
|
||||||
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
|
- `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. 개발 및 유지보수 규칙
|
## 3. 개발 및 유지보수 규칙
|
||||||
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
|
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
|
||||||
- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
|
- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
|
||||||
|
- **공격력 조정**: 기본 피해량은 `src/constants.js`의 `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 우선 확인합니다.
|
||||||
- **DOM 접근**: 성능을 위해 `ArenaScene`은 상단 스코어보드 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
|
- **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로 처리하지 않도록 서버 라우팅 순서를 유지해야 합니다.
|
||||||
|
|
|
||||||
38
agent.md
38
agent.md
|
|
@ -4,14 +4,23 @@
|
||||||
|
|
||||||
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다.
|
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다.
|
||||||
|
|
||||||
|
서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수를 기록하는 간단한 방문자 통계 API를 제공합니다.
|
||||||
|
|
||||||
## 2. 프로젝트 전체 구조 (Directory Tree)
|
## 2. 프로젝트 전체 구조 (Directory Tree)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
├── index.html # 메인 HTML 진입점 및 UI 레이아웃
|
├── 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 # 프로젝트 개요 및 기능 정의 (본 문서)
|
├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서)
|
||||||
├── CONTEXT.md # 상세 개발 가이드 및 로직 설명
|
├── CONTEXT.md # 상세 개발 가이드 및 로직 설명
|
||||||
├── todo.md # 작업 내역 및 잔여 이슈 관리
|
├── todo.md # 작업 내역 및 잔여 이슈 관리
|
||||||
|
├── server/ # Fastify API 서버 및 MongoDB 연결 관리
|
||||||
|
│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙
|
||||||
|
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
|
||||||
|
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
||||||
|
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||||
├── public/ # 정적 리소스 (게임 에셋)
|
├── public/ # 정적 리소스 (게임 에셋)
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
||||||
|
|
@ -19,20 +28,21 @@
|
||||||
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
||||||
└── src/ # 소스 코드 root
|
└── src/ # 소스 코드 root
|
||||||
├── main.js # Phaser 게임 인스턴스 생성 및 초기화
|
├── main.js # Phaser 게임 인스턴스 생성 및 초기화
|
||||||
├── constants.js # 전역 물리/UI 상수 통합 관리 (줌, 카메라 속도 등)
|
├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등)
|
||||||
├── styles.css # UI 스타일링 (스코어보드, 승리 배너 애니메이션)
|
├── styles.css # UI 스타일링 (스코어보드, 승리 배너 애니메이션)
|
||||||
├── game/ # 게임 로직 모듈
|
├── game/ # 게임 로직 모듈
|
||||||
│ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, 스코어보드 제어)
|
│ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, 스코어보드 제어)
|
||||||
│ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
|
│ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
|
||||||
│ ├── combat.js # 전투 AI 및 피격 판정 로직
|
│ ├── combat.js # 전투 AI 및 피격 판정 로직
|
||||||
│ ├── combatSettings.js# 전투 속도 및 이동 배율 관리
|
│ ├── combatSettings.js# 전투 속도 및 이동 배율 관리
|
||||||
│ ├── fighterAssets.js# 스프라이트 시트 로드 및 애니메이션 생성
|
│ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 선택 실루엣 생성
|
||||||
│ ├── fighterFactory.js# 캐릭터 객체 및 HUD 생성
|
│ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD 및 선택 윤곽 동기화
|
||||||
│ ├── fighterManifest.js# 캐릭터 스킨 데이터 정의 (20종 캐릭터 상세 설정)
|
│ ├── fighterManifest.js# 캐릭터 스킨 데이터 정의 (20종 캐릭터 상세 설정)
|
||||||
│ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직
|
│ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직
|
||||||
│ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산
|
│ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산
|
||||||
└── ui/
|
└── ui/
|
||||||
└── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어
|
├── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어
|
||||||
|
└── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 핵심 기능
|
## 3. 핵심 기능
|
||||||
|
|
@ -43,16 +53,30 @@
|
||||||
- **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다.
|
- **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다.
|
||||||
- **역동적인 전투 연출**:
|
- **역동적인 전투 연출**:
|
||||||
- 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션.
|
- 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션.
|
||||||
- 치명타(Critical) 발생 시 화면 흔들림 효과 및 대미지 가중치 적용.
|
- `ATTACK_DAMAGE_MIN/MAX`로 기본 공격력 범위를 관리하고, 치명타(Critical) 발생 시 즉시 처치 및 화면 흔들림 효과를 적용합니다.
|
||||||
|
- 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다.
|
||||||
|
- **캐릭터 선택 관전**: 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 원본 스프라이트 알파 마스크를 바탕으로 1px 공백을 둔 노란 실루엣 윤곽이 표시됩니다.
|
||||||
- **실시간 경기 중계 UI**: 상단 좌/우 영역에 팀별 현재 생존 인원을 실시간으로 표시하며, 승리 시 대형 배너로 결과를 알립니다.
|
- **실시간 경기 중계 UI**: 상단 좌/우 영역에 팀별 현재 생존 인원을 실시간으로 표시하며, 승리 시 대형 배너로 결과를 알립니다.
|
||||||
|
- **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다.
|
||||||
|
|
||||||
## 4. 기술 사양
|
## 4. 기술 사양
|
||||||
|
|
||||||
- **Framework**: Phaser 3.90.0 (Arcade Physics 기반)
|
- **Framework**: Phaser 3.90.0 (Arcade Physics 기반)
|
||||||
- **Build Tool**: Vite 7.1.12
|
- **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 활용)
|
- **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): 상세 개발 가이드 및 핵심 로직 설명 (필독)
|
- [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독)
|
||||||
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리
|
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<div class="entry-copy">
|
<div class="entry-copy">
|
||||||
<p class="eyebrow">Arena Picker</p>
|
<p class="eyebrow">Arena Picker</p>
|
||||||
<h1>팀 전투 뽑기</h1>
|
<h1>팀 전투 뽑기</h1>
|
||||||
|
<p id="visitor-count" class="visitor-count" aria-live="polite">방문자 확인 중</p>
|
||||||
</div>
|
</div>
|
||||||
<form id="fighter-form" autocomplete="off">
|
<form id="fighter-form" autocomplete="off">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -4,15 +4,19 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node server/index.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"start": "node server/index.js --production"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/middie": "^9.3.2",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"mongodb": "^7.2.0",
|
||||||
"phaser": "^3.90.0"
|
"phaser": "^3.90.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^7.1.12"
|
"vite": "^7.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,57 +1,143 @@
|
||||||
|
// 경기장을 구성하는 격자 칸 수입니다. 값이 커질수록 전장이 넓어집니다.
|
||||||
export const GRID_SIZE = 50;
|
export const GRID_SIZE = 50;
|
||||||
|
// 격자 한 칸의 픽셀 크기입니다. 경기장 크기와 좌표 간격에 영향을 줍니다.
|
||||||
export const TILE_SIZE = 64;
|
export const TILE_SIZE = 64;
|
||||||
|
// 실제 전장 전체 픽셀 크기입니다. GRID_SIZE와 TILE_SIZE를 기반으로 계산합니다.
|
||||||
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
||||||
|
|
||||||
|
// 근접 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
||||||
export const ATTACK_RANGE = 84;
|
export const ATTACK_RANGE = 84;
|
||||||
|
// 기본 공격 쿨다운(ms)입니다. 낮을수록 공격 빈도가 높아집니다.
|
||||||
export const ATTACK_COOLDOWN = 840;
|
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 DEFAULT_TEAM_SIZE = 5;
|
||||||
|
// 캐릭터 스프라이트의 기본 화면 배율입니다.
|
||||||
export const FIGHTER_SCALE = 3;
|
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 MAX_TEAM_SIZE = 100;
|
||||||
|
// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다.
|
||||||
export const MELEE_CRITICAL_CHANCE = 0.05;
|
export const MELEE_CRITICAL_CHANCE = 0.05;
|
||||||
|
// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다.
|
||||||
export const MOVE_SPEED = 148;
|
export const MOVE_SPEED = 148;
|
||||||
|
// 투사체가 자동으로 사라지기까지의 시간(ms)입니다.
|
||||||
export const PROJECTILE_LIFETIME = 1800;
|
export const PROJECTILE_LIFETIME = 1800;
|
||||||
|
// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다.
|
||||||
export const PROJECTILE_SPEED = 420;
|
export const PROJECTILE_SPEED = 420;
|
||||||
|
// 원거리 캐릭터의 기본 치명타 확률입니다.
|
||||||
export const RANGED_CRITICAL_CHANCE = 0;
|
export const RANGED_CRITICAL_CHANCE = 0;
|
||||||
|
// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
||||||
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
|
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
|
||||||
|
|
||||||
|
// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||||
export const MELEE_HIT_DELAY = 260;
|
export const MELEE_HIT_DELAY = 260;
|
||||||
|
// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다.
|
||||||
export const PROJECTILE_FIRE_DELAY = 360;
|
export const PROJECTILE_FIRE_DELAY = 360;
|
||||||
|
// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다.
|
||||||
export const PROJECTILE_BODY_OFFSET = 4;
|
export const PROJECTILE_BODY_OFFSET = 4;
|
||||||
|
// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다.
|
||||||
export const PROJECTILE_HIT_PADDING = 20;
|
export const PROJECTILE_HIT_PADDING = 20;
|
||||||
|
// 투사체 충돌 원형 바디의 반지름입니다.
|
||||||
export const PROJECTILE_HIT_RADIUS = 12;
|
export const PROJECTILE_HIT_RADIUS = 12;
|
||||||
|
// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다.
|
||||||
export const PROJECTILE_SPAWN_DISTANCE = 1;
|
export const PROJECTILE_SPAWN_DISTANCE = 1;
|
||||||
|
// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다.
|
||||||
export const SPELL_CAST_DELAY = 340;
|
export const SPELL_CAST_DELAY = 340;
|
||||||
|
// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||||
export const SPELL_HIT_DELAY = 160;
|
export const SPELL_HIT_DELAY = 160;
|
||||||
|
|
||||||
|
// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다.
|
||||||
export const CAMERA_MIN_ZOOM = 1;
|
export const CAMERA_MIN_ZOOM = 1;
|
||||||
|
// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다.
|
||||||
export const CAMERA_MAX_ZOOM = 3;
|
export const CAMERA_MAX_ZOOM = 3;
|
||||||
|
// 마우스 휠 한 번당 카메라 줌 변화량입니다.
|
||||||
export const CAMERA_ZOOM_STEP = 0.1;
|
export const CAMERA_ZOOM_STEP = 0.1;
|
||||||
|
// 미니맵 카메라가 보일 때의 투명도입니다.
|
||||||
export const MINIMAP_ALPHA = 0.8;
|
export const MINIMAP_ALPHA = 0.8;
|
||||||
|
// 미니맵이 화면 가장자리에서 떨어지는 거리입니다.
|
||||||
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
|
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
|
||||||
|
// 미니맵의 고정 픽셀 크기입니다.
|
||||||
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
|
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
|
||||||
|
// 미니맵 현재 뷰포트 표시용 바깥 윤곽선 두께입니다.
|
||||||
export const MINIMAP_VIEW_FRAME_OUTLINE = 18;
|
export const MINIMAP_VIEW_FRAME_OUTLINE = 18;
|
||||||
|
// 미니맵 현재 뷰포트 표시용 안쪽 선 두께입니다.
|
||||||
export const MINIMAP_VIEW_FRAME_STROKE = 10;
|
export const MINIMAP_VIEW_FRAME_STROKE = 10;
|
||||||
|
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
|
||||||
export const SPECTATOR_CAMERA_LERP = 0.1;
|
export const SPECTATOR_CAMERA_LERP = 0.1;
|
||||||
|
// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다.
|
||||||
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
|
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
|
||||||
|
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||||
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
|
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 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 NICKNAME_LENGTH = 18;
|
||||||
|
|
||||||
|
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다.
|
||||||
export const FIGHTER_ANIMATION_OPTIONS = {
|
export const FIGHTER_ANIMATION_OPTIONS = {
|
||||||
|
// 기본 공격 애니메이션 속도입니다.
|
||||||
attack: { frameRate: 15, repeat: 0 },
|
attack: { frameRate: 15, repeat: 0 },
|
||||||
|
// 보조 공격 애니메이션 속도입니다.
|
||||||
attack02: { frameRate: 15, repeat: 0 },
|
attack02: { frameRate: 15, repeat: 0 },
|
||||||
|
// 강공격/치명타용 공격 애니메이션 속도입니다.
|
||||||
attack03: { frameRate: 15, repeat: 0 },
|
attack03: { frameRate: 15, repeat: 0 },
|
||||||
|
// 방어 애니메이션 속도입니다.
|
||||||
block: { frameRate: 13, repeat: 0 },
|
block: { frameRate: 13, repeat: 0 },
|
||||||
|
// 사망 애니메이션 속도입니다.
|
||||||
death: { frameRate: 11, repeat: 0 },
|
death: { frameRate: 11, repeat: 0 },
|
||||||
|
// 회복 애니메이션 속도입니다.
|
||||||
heal: { frameRate: 13, repeat: 0 },
|
heal: { frameRate: 13, repeat: 0 },
|
||||||
|
// 피격 애니메이션 속도입니다.
|
||||||
hurt: { frameRate: 13, repeat: 0 },
|
hurt: { frameRate: 13, repeat: 0 },
|
||||||
|
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||||
idle: { frameRate: 7, repeat: -1 },
|
idle: { frameRate: 7, repeat: -1 },
|
||||||
|
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||||
walk: { frameRate: 10, repeat: -1 },
|
walk: { frameRate: 10, repeat: -1 },
|
||||||
|
// 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||||
walk02: { frameRate: 10, repeat: -1 },
|
walk02: { frameRate: 10, repeat: -1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다.
|
||||||
export const TEAM_COLORS = [
|
export const TEAM_COLORS = [
|
||||||
"#da6a48",
|
"#da6a48",
|
||||||
"#5fb4d9",
|
"#5fb4d9",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
MINIMAP_VIEWPORT_SIZE,
|
MINIMAP_VIEWPORT_SIZE,
|
||||||
MINIMAP_VIEW_FRAME_OUTLINE,
|
MINIMAP_VIEW_FRAME_OUTLINE,
|
||||||
MINIMAP_VIEW_FRAME_STROKE,
|
MINIMAP_VIEW_FRAME_STROKE,
|
||||||
|
SELECTED_FIGHTER_CAMERA_ZOOM,
|
||||||
SPECTATOR_CAMERA_LERP,
|
SPECTATOR_CAMERA_LERP,
|
||||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
|
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
|
||||||
SPECTATOR_FINAL_FIGHT_ZOOM,
|
SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||||
|
|
@ -45,6 +46,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.observedCombat = [];
|
this.observedCombat = [];
|
||||||
|
this.selectedFighter = null;
|
||||||
this.teams = [];
|
this.teams = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +72,6 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.cameras.main.ignore(this.minimapViewportFrame);
|
this.cameras.main.ignore(this.minimapViewportFrame);
|
||||||
this.updateMinimapViewportFrame();
|
this.updateMinimapViewportFrame();
|
||||||
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
|
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
|
||||||
|
|
||||||
// 마우스 휠로 줌 조절
|
// 마우스 휠로 줌 조절
|
||||||
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
|
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
|
||||||
const newZoom = Phaser.Math.Clamp(
|
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.ready = true;
|
||||||
this.startMatch(this.getInitialMatchConfig());
|
this.startMatch(this.getInitialMatchConfig());
|
||||||
|
|
@ -104,6 +118,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.matchId += 1;
|
this.matchId += 1;
|
||||||
this.matchOver = false;
|
this.matchOver = false;
|
||||||
this.observedCombat = [];
|
this.observedCombat = [];
|
||||||
|
this.clearSelectedFighter();
|
||||||
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
|
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
|
||||||
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||||
clearCombatObjects(this);
|
clearCombatObjects(this);
|
||||||
|
|
@ -122,6 +137,20 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
update(time) {
|
update(time) {
|
||||||
this.fighters.forEach(syncFighterHud);
|
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) {
|
if (this.matchOver) {
|
||||||
this.updateMinimapViewportFrame();
|
this.updateMinimapViewportFrame();
|
||||||
return;
|
return;
|
||||||
|
|
@ -150,16 +179,56 @@ update(time) {
|
||||||
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.fighters.forEach((fighter) => {
|
|
||||||
updateFighter(this, fighter, time, () => {
|
|
||||||
this.updateScoreboard();
|
|
||||||
this.finishMatch();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateMinimapViewportFrame();
|
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) {
|
setMainCameraZoom(zoom) {
|
||||||
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
ATTACK_COOLDOWN,
|
ATTACK_COOLDOWN,
|
||||||
|
ATTACK_DAMAGE_MAX,
|
||||||
|
ATTACK_DAMAGE_MIN,
|
||||||
ATTACK_RANGE,
|
ATTACK_RANGE,
|
||||||
|
FIGHTER_MAX_HP,
|
||||||
FIGHTER_SCALE,
|
FIGHTER_SCALE,
|
||||||
|
KILL_HEALTH_RECOVERY_RATIO,
|
||||||
|
KILL_GROWTH_MULTIPLIER,
|
||||||
|
KILL_GROWTH_TWEEN_DURATION,
|
||||||
MELEE_HIT_DELAY,
|
MELEE_HIT_DELAY,
|
||||||
MELEE_CRITICAL_CHANCE,
|
MELEE_CRITICAL_CHANCE,
|
||||||
MOVE_SPEED,
|
MOVE_SPEED,
|
||||||
|
|
@ -41,7 +47,7 @@ export function updateFighter(scene, fighter, time, onWinner) {
|
||||||
fighter.setFlipX(enemy.x < fighter.x);
|
fighter.setFlipX(enemy.x < fighter.x);
|
||||||
|
|
||||||
if (distance > getAttackRange(fighter)) {
|
if (distance > getAttackRange(fighter)) {
|
||||||
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * getMovementSpeedMultiplier());
|
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * fighterMovementSpeedMultiplier(fighter));
|
||||||
playIfNeeded(fighter, "walk");
|
playIfNeeded(fighter, "walk");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -66,10 +72,11 @@ export function clearCombatObjects(scene) {
|
||||||
|
|
||||||
function beginAttack(scene, attacker, defender, time, onWinner) {
|
function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||||
const attack = createAttackProfile(attacker);
|
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;
|
attacker.isLocked = true;
|
||||||
scene.observeCombat?.(attacker, defender);
|
scene.observeCombat?.(attacker, defender);
|
||||||
playAnimation(attacker, attack.animation, getAttackSpeedMultiplier());
|
playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker));
|
||||||
|
|
||||||
switch (getCombatType(attacker)) {
|
switch (getCombatType(attacker)) {
|
||||||
case "projectile":
|
case "projectile":
|
||||||
|
|
@ -86,7 +93,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||||
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
||||||
const matchId = scene.matchId;
|
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, {
|
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||||
instantKill: attack.isCritical,
|
instantKill: attack.isCritical,
|
||||||
});
|
});
|
||||||
|
|
@ -96,7 +103,7 @@ function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
||||||
function queueProjectile(scene, attacker, defender, onWinner) {
|
function queueProjectile(scene, attacker, defender, onWinner) {
|
||||||
const matchId = scene.matchId;
|
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)) {
|
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +115,7 @@ function queueProjectile(scene, attacker, defender, onWinner) {
|
||||||
function queueInstantSpell(scene, attacker, defender, onWinner) {
|
function queueInstantSpell(scene, attacker, defender, onWinner) {
|
||||||
const matchId = scene.matchId;
|
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)) {
|
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +151,8 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||||
projectile,
|
projectile,
|
||||||
defenderHitPoint.x,
|
defenderHitPoint.x,
|
||||||
defenderHitPoint.y,
|
defenderHitPoint.y,
|
||||||
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(),
|
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) *
|
||||||
|
fighterAttackSpeedMultiplier(attacker),
|
||||||
);
|
);
|
||||||
trackCombatObject(scene, projectile);
|
trackCombatObject(scene, projectile);
|
||||||
|
|
||||||
|
|
@ -209,9 +217,12 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
|
||||||
disposeCombatObject(scene, effect);
|
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);
|
applyHit(scene, attacker, defender, onWinner, matchId);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
|
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
|
||||||
|
|
@ -219,7 +230,9 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill =
|
||||||
return;
|
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);
|
defender.body.setVelocity(0, 0);
|
||||||
|
|
||||||
if (defender.hp === 0) {
|
if (defender.hp === 0) {
|
||||||
|
|
@ -336,9 +349,36 @@ function killFighter(defender, winner, onWinner) {
|
||||||
winner.isLocked = false;
|
winner.isLocked = false;
|
||||||
winner.body.setVelocity(0, 0);
|
winner.body.setVelocity(0, 0);
|
||||||
playAnimation(winner, "idle");
|
playAnimation(winner, "idle");
|
||||||
|
applyKillReward(winner);
|
||||||
onWinner(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) {
|
function findNearestEnemy(fighters, fighter) {
|
||||||
let nearestEnemy;
|
let nearestEnemy;
|
||||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
@ -381,8 +421,16 @@ function playAnimation(fighter, action, timeScale = 1) {
|
||||||
fighter.play(fighterAnimationKey(fighter.skin, action), true);
|
fighter.play(fighterAnimationKey(fighter.skin, action), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scaledAttackDelay(duration) {
|
function scaledAttackDelay(duration, fighter) {
|
||||||
return duration / getAttackSpeedMultiplier();
|
return duration / fighterAttackSpeedMultiplier(fighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fighterAttackSpeedMultiplier(fighter) {
|
||||||
|
return getAttackSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fighterMovementSpeedMultiplier(fighter) {
|
||||||
|
return getMovementSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackCombatObject(scene, object) {
|
function trackCombatObject(scene, object) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
export function fighterSheetKey(skin, action) {
|
||||||
return `${skin.key}-${action}`;
|
return `${skin.key}-${action}`;
|
||||||
|
|
@ -8,6 +20,14 @@ export function fighterAnimationKey(skin, action) {
|
||||||
return `${fighterSheetKey(skin, action)}-anim`;
|
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) {
|
export function fighterAttackEffectKey(skin) {
|
||||||
return `${skin.key}-attack-effect`;
|
return `${skin.key}-attack-effect`;
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +46,7 @@ export function preloadFighterSheets(scene, skins) {
|
||||||
scene.load.spritesheet(
|
scene.load.spritesheet(
|
||||||
fighterSheetKey(skin, action),
|
fighterSheetKey(skin, action),
|
||||||
`${skin.assetRoot}/${animation.file}`,
|
`${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]) => {
|
Object.entries(skin.animations).forEach(([action, animation]) => {
|
||||||
const key = fighterAnimationKey(skin, action);
|
const key = fighterAnimationKey(skin, action);
|
||||||
|
|
||||||
if (scene.anims.exists(key)) {
|
if (!scene.anims.exists(key)) {
|
||||||
return;
|
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];
|
createFighterOutlineSheet(scene, skin, action, animation.frames);
|
||||||
|
|
||||||
scene.anims.create({
|
|
||||||
key,
|
|
||||||
frames: scene.anims.generateFrameNumbers(fighterSheetKey(skin, action), {
|
|
||||||
start: 0,
|
|
||||||
end: animation.frames - 1,
|
|
||||||
}),
|
|
||||||
frameRate,
|
|
||||||
repeat,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
createAttackEffectAnimation(scene, skin);
|
createAttackEffectAnimation(scene, skin);
|
||||||
|
|
@ -72,7 +92,7 @@ function preloadCombatAssets(scene, skin) {
|
||||||
scene.load.spritesheet(
|
scene.load.spritesheet(
|
||||||
fighterAttackEffectKey(skin),
|
fighterAttackEffectKey(skin),
|
||||||
`${skin.assetRoot}/${attackEffect.file}`,
|
`${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,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { FIGHTER_SCALE } from "../constants.js";
|
import {
|
||||||
import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js";
|
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 }) {
|
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
|
||||||
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
|
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.setDepth(2);
|
||||||
fighter.setCollideWorldBounds(true);
|
fighter.setCollideWorldBounds(true);
|
||||||
fighter.setFlipX(faceLeft);
|
fighter.setFlipX(faceLeft);
|
||||||
fighter.body.setSize(22, 20);
|
fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT);
|
||||||
fighter.body.setOffset(39, 60);
|
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
|
fighter.nameLabel = scene.add
|
||||||
.text(x, y - 68, name, {
|
.text(x, y, name, {
|
||||||
color: "#fff2c2",
|
color: "#fff2c2",
|
||||||
fontFamily: "Inter, Pretendard, sans-serif",
|
fontFamily: "Inter, Pretendard, sans-serif",
|
||||||
fontSize: "18px",
|
fontSize: "18px",
|
||||||
|
|
@ -20,7 +50,7 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||||
stroke: team.color,
|
stroke: team.color,
|
||||||
strokeThickness: 4,
|
strokeThickness: 4,
|
||||||
})
|
})
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5, 0)
|
||||||
.setDepth(4);
|
.setDepth(4);
|
||||||
fighter.healthBack = scene.add
|
fighter.healthBack = scene.add
|
||||||
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
|
.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.skin = skin;
|
||||||
fighter.team = team;
|
fighter.team = team;
|
||||||
fighter.teamIndex = teamIndex;
|
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.nextAttackAt = 0;
|
||||||
fighter.isLocked = false;
|
fighter.isLocked = false;
|
||||||
fighter.isDead = false;
|
fighter.isDead = false;
|
||||||
|
|
@ -50,21 +86,56 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||||
});
|
});
|
||||||
|
|
||||||
attachHudCleanup(fighter);
|
attachHudCleanup(fighter);
|
||||||
|
syncFighterHud(fighter);
|
||||||
|
|
||||||
return fighter;
|
return fighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncFighterHud(fighter) {
|
export function syncFighterHud(fighter) {
|
||||||
fighter.nameLabel.setPosition(fighter.x, fighter.y - 68);
|
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE);
|
||||||
fighter.healthBack.setPosition(fighter.x, fighter.y - 44);
|
const healthOffset = 44 * scaleRatio;
|
||||||
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - 44);
|
const hitbox = fighter.body;
|
||||||
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / 100));
|
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) {
|
function attachHudCleanup(fighter) {
|
||||||
const originalDestroy = fighter.destroy.bind(fighter);
|
const originalDestroy = fighter.destroy.bind(fighter);
|
||||||
|
|
||||||
fighter.destroy = (...args) => {
|
fighter.destroy = (...args) => {
|
||||||
|
fighter.selectionOutline.destroy();
|
||||||
fighter.nameLabel.destroy();
|
fighter.nameLabel.destroy();
|
||||||
fighter.healthBack.destroy();
|
fighter.healthBack.destroy();
|
||||||
fighter.healthBar.destroy();
|
fighter.healthBar.destroy();
|
||||||
|
|
|
||||||
19
src/main.js
19
src/main.js
|
|
@ -2,6 +2,7 @@ import Phaser from "phaser";
|
||||||
import { ArenaScene } from "./game/ArenaScene.js";
|
import { ArenaScene } from "./game/ArenaScene.js";
|
||||||
import { ARENA_SIZE } from "./constants.js";
|
import { ARENA_SIZE } from "./constants.js";
|
||||||
import { createMatchForm } from "./ui/matchForm.js";
|
import { createMatchForm } from "./ui/matchForm.js";
|
||||||
|
import { trackVisitor } from "./ui/visitorCounter.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const matchForm = createMatchForm();
|
const matchForm = createMatchForm();
|
||||||
|
|
@ -32,4 +33,22 @@ const game = new Phaser.Game({
|
||||||
|
|
||||||
matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig));
|
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;
|
window.arenaGame = game;
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,18 @@ h1 {
|
||||||
letter-spacing: 0;
|
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 {
|
form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
todo.md
24
todo.md
|
|
@ -13,3 +13,27 @@
|
||||||
- **조치 사항**:
|
- **조치 사항**:
|
||||||
- `src/game/ArenaScene.js`의 `finishMatch` 로직을 개선하여 생존 팀이 1개일 때 해당 닉네임 승리 표시.
|
- `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`에 추가.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue